Files
ciregenzandClaude Opus 5 0f2543db65 [eric] agents: a finished turn always leaves the user something to read
Every silent-stop fix so far was a detector for a shape somebody had already
found in the field, which is why the class kept coming back wearing a new hat.
turn_spoke.py moves the question down a tier: at the one exit every terminal
path passes through, ask whether anything readable appeared since the user last
spoke, and if not, say the honest line. Cause no longer has to be enumerated for
the user to be answered.

proactive_prune.py is the hermes trigger we were missing. Their own tests say
our bug out loud: on a large window, a percentage-of-window compaction check
almost never fires, so aged tool output rides in history and is re-sent verbatim
every turn. Measured here, our shaping cut 0.0% at every session size; with a
fixed 60K-token cost trigger it cuts 88% at 12 turns, 93% at 30, 94% at 60. The
prompt-cache contract is load-bearing rather than optional, because our prune is
a rebuild: it commits only when it reclaims enough to pay for the busted prefix,
then disarms until history has regrown a full runway.

Also: lane preflight now treats only 401/403 as a dead credential, since
testStatus=="unavailable" conflated a throttled lane with a revoked one and told
users to reconnect a merely rate-limited Gemini; and awaiting_reconnect is
cleared when the retry budget is spent, so a stale flag can no longer muzzle the
floor and end an ask in total silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wtspwSFzZmjCx9UNPAorQ
2026-08-20 11:36:34 -07:00

252 lines
13 KiB
Python

"""Self-contained sub-steps of the per-turn options build, pulled out of run_agent_loop's options
assembly so each file stays under the ceiling. Free functions taking the manager (for maybe_compact /
emit_context_update); pure relocation."""
import logging
from typing import Dict, List, Optional
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession, Message
from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input, wrap_platform_note
logger = logging.getLogger(__name__)
# Always SDK-blocked regardless of permissions: claude.ai partner MCPs bypass our MCPActivate gate, and the CLI's built-in sub-agent tool (Task on 2.1.122, Agent on older builds) is replaced by our SpawnAgent MCP.
HARD_BLOCKED_TOOLS: List[str] = ["mcp__claude_ai_*", "Agent", "Task"]
@typechecked
def merge_hard_blocked_tools(effective_disallowed: List[str]) -> List[str]:
"""The SDK deny list = the computed per-turn denies PLUS the unconditional hard blocks. A plain assignment here once silently discarded effective_disallowed (Cron*/Skill/web-swap/per-tool MCP denies), leaving the runtime gate as the only wall; merge, never overwrite."""
return effective_disallowed + [t for t in HARD_BLOCKED_TOOLS if t not in effective_disallowed]
# `manager` is the AgentManager; it isn't annotated because typing it would import agent_manager back into a module agent_manager already imports (a cycle). Same reason self is never annotated.
@typechecked
async def pre_send_context_guard(manager, session: AgentSession, session_id: str) -> None:
# The second trigger (hermes lift): the threshold below fires at a percentage of the window,
# which on a 1M lane almost never arrives, so history rides untouched to the cliff. This one
# fires on COST and pays for its own cache miss.
try:
from backend.apps.agents.manager.session.proactive_prune import (
arm_proactive_prune,
estimate_aged_rebuild_tokens,
should_proactively_prune,
)
if should_proactively_prune(session):
arm_proactive_prune(session)
await ws_manager.send_to_session(session_id, "agent:context_status", {
"session_id": session_id,
"reason": "compacted",
"compacted_through_msg_id": session.compacted_through_msg_id,
})
await manager.emit_context_update(
session_id, session,
input_tokens=estimate_aged_rebuild_tokens(session),
output_tokens=session.tokens.get("output", 0),
)
except Exception:
logger.exception("proactive prune failed; proceeding without it")
try:
if manager.maybe_compact(session):
# A mark alone never applies on the resume path (the CLI replays its own untrimmed transcript), so pay for the rebuild too: next turn drops the SDK convo and rebuilds with the cutoff + distilled summary. One respawn per compaction epoch is the price of never reaching the wall.
session.needs_fresh_session = True
new_input = estimate_post_compact_input(session)
await ws_manager.send_to_session(session_id, "agent:context_status", {
"session_id": session_id,
"reason": "compacted",
"compacted_through_msg_id": session.compacted_through_msg_id,
})
await manager.emit_context_update(
session_id,
session,
input_tokens=new_input,
output_tokens=session.tokens.get("output", 0),
)
except Exception:
logger.exception("compaction failed; proceeding without it")
# Pre-send hard guard (Phase 2). After compaction, if the session is still over context_soft_cap_pct of the window, LRU-trim oldest active_mcps. Stops the 429 from ever firing on predictable overflow paths.
try:
# Use the most recent measurement (the prior turn's input_tokens) as the estimate. Conservative because the current turn's user prompt + any new history adds on top, but the first turn of a fresh session has tokens=0 so we only act once we've seen real numbers.
p_est_tokens = session.tokens.get("input", 0)
p_hard_cap = int(session.context_window * session.context_soft_cap_pct)
if p_est_tokens >= p_hard_cap:
trimmed: List[str] = []
while p_est_tokens >= p_hard_cap and len(session.active_mcps) > 1:
# Keep at least one MCP active so the model can finish whatever it was doing; trim from oldest which is FIFO order in the list.
trimmed.append(f"mcp:{session.active_mcps.pop(0)}")
p_est_tokens -= 8_000 # rough per-MCP schema cost
if trimmed:
await ws_manager.send_to_session(session_id, "agent:context_status", {
"session_id": session_id,
"reason": "trimmed",
"trimmed": trimmed,
"estimate_after": p_est_tokens,
})
# Surface a visible system breadcrumb in the chat so the user (and the model on the next turn) know which MCPs got dropped. Without this, the model may keep trying to call a now-missing tool and the user has no idea why.
try:
p_names = ", ".join(t.replace("mcp:", "") for t in trimmed)
p_trim_msg = Message(
role="system",
content=wrap_platform_note(
f"Trimmed {len(trimmed)} app{'s' if len(trimmed) != 1 else ''} from this session to fit "
f"the model's context: {p_names}. Re-activate via MCPSearch + MCPActivate "
"if you still need them."
),
branch_id=session.active_branch_id,
)
session.messages.append(p_trim_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": p_trim_msg.model_dump(mode="json"),
})
except Exception:
logger.exception("failed to emit MCP-trimmed breadcrumb")
# Trimming changes mcp_servers / outputs context → rebuild options. The cheapest correct path is to flag for fork on next turn via needs_fork and let the existing fork path handle it.
session.needs_fork = True
except Exception:
logger.exception("pre-send token guard failed; proceeding")
@typechecked
def set_framework_overhead(session: AgentSession, composed_prompt: Optional[str]) -> None:
"""Per-turn estimate of framework overhead (subtracted from displayed input). Conservative on
purpose so honest over-shows beat lies: 16K Claude Code preset, 12K base+deferred tools, ~3K/MCP
(real defs span 1-10K; 3K median keeps the meter honest), char/4 of the composed prompt."""
p_PRESET_OVERHEAD = 16_000
p_TOOL_DEFS_OVERHEAD = 12_000
p_PER_MCP_OVERHEAD = 3_000
p_composed_tokens = len(composed_prompt or "") // 4
p_mcp_tokens = len(session.active_mcps) * p_PER_MCP_OVERHEAD
session.framework_overhead_tokens = (
p_PRESET_OVERHEAD + p_TOOL_DEFS_OVERHEAD + p_composed_tokens + p_mcp_tokens
)
@typechecked
def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False, rich_ui_ok: bool = False) -> None:
"""Add the DDG-backed web tools when the primary has no reliable native web path. They ride the
combined openswarm-core process now (ENG-208): flip the module flag and env on the entry that
register_builtin_mcp_servers already made, instead of spawning an eleventh interpreter."""
# Tell the MCP which primary the session is using so it can route to that provider's native search tool.
if p_m.startswith(("gc/", "gemini/", "ag/")):
p_primary_hint = "gemini"
elif p_m.startswith("cx/"):
p_primary_hint = "openai"
else:
p_primary_hint = ""
core = mcp_servers.get("openswarm-core")
if core is not None:
env = core["env"]
modules = [m for m in env.get("OSW_MCP_MODULES", "").split(",") if m]
if "web" not in modules:
modules.append("web")
env["OSW_MCP_MODULES"] = ",".join(modules)
env["OPENSWARM_PRIMARY_API"] = p_primary_hint
env["OPENSWARM_BROWSER_OK"] = "1" if browser_ok else "0"
env["OPENSWARM_RICH_UI_OK"] = "1" if rich_ui_ok else "0"
return
# Belt for a caller that skipped register_builtin (none today): web tools still arrive, alone.
import os
import sys
import backend.apps.agents as p_agents_pkg
combined_path = os.path.join(os.path.dirname(p_agents_pkg.__file__), "combined_meta_mcp_server.py")
from backend.auth import get_auth_token as p_get_auth_token3
mcp_servers["openswarm-core"] = {
"command": sys.executable,
"args": [combined_path],
"env": {
"OSW_MCP_MODULES": "web",
"OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"),
"OPENSWARM_AUTH_TOKEN": p_get_auth_token3(),
"OPENSWARM_PRIMARY_API": p_primary_hint,
"OPENSWARM_BROWSER_OK": "1" if browser_ok else "0",
"OPENSWARM_RICH_UI_OK": "1" if rich_ui_ok else "0",
},
"type": "stdio",
}
logger.info(
f"[MCP-DEBUG] Primary {p_m} has no reliable native web search, "
f"registering openswarm-web (DDG search + trafilatura fetch, free)"
)
@typechecked
def append_web_tools_hint(composed_prompt: Optional[str], need_web_mcp: bool, effective_allowed: List[str]) -> str:
"""Append a <web_tools> block naming the MCP-backed WebSearch/WebFetch when the deferred bare
WebSearch tool isn't usable on this session, so smaller models don't thrash on ToolSearch."""
p_web_tools_available = need_web_mcp and (
"mcp__openswarm-core__WebSearch" in effective_allowed
or "mcp__openswarm-core__WebFetch" in effective_allowed
)
if not p_web_tools_available:
return composed_prompt
p_hint_lines = ["<web_tools>"]
p_hint_lines.append(
"This session does NOT have the built-in `WebSearch` / "
"`WebFetch` tools (they delegate to Anthropic Haiku, which "
"isn't reachable on this primary). Use the MCP-backed "
"equivalents instead, call them DIRECTLY, no ToolSearch "
"step needed:"
)
if "mcp__openswarm-core__WebSearch" in effective_allowed:
p_hint_lines.append(
"- `mcp__openswarm-core__WebSearch(query: str, "
"num_results?: int)`, DuckDuckGo search."
)
if "mcp__openswarm-core__WebFetch" in effective_allowed:
p_hint_lines.append(
"- `mcp__openswarm-core__WebFetch(url: str, prompt?: "
"str)`, fetch a URL and return readable text."
)
p_hint_lines.append(
"Do not call `ToolSearch(select:WebSearch)`, bare "
"`WebSearch` is unavailable on this session and that path "
"will return empty matches."
)
p_hint_lines.append("</web_tools>")
p_web_hint = "\n".join(p_hint_lines)
return f"{composed_prompt}\n\n{p_web_hint}" if composed_prompt else p_web_hint
@typechecked
def inject_thinking_options(options_kwargs: Dict, session: AgentSession, prompt: str, resolved_model: str, api_type: str) -> None:
"""Map the session's thinking_level onto the SDK options (anthropic thinking/effort, openai/codex
reasoning_effort), with the short-prompt + gc/gemini-3 force-off overrides. Best-effort."""
try:
level = getattr(session, "thinking_level", "auto") or "auto"
# Trivially short prompts ("hi", "thanks") don't benefit from 5-30s of hidden reasoning, but
# this flip rides the BOOT fingerprint: a short first message drifted it and threw away the
# pre-warmed CLI, measured as a second spawn on 5 of 5 short-prompt sessions. A wasted 0.9s
# respawn costs more than the reasoning it saves, so the flip only applies once a session is
# already running on a live client (later turns), never on the first message.
p_prompt_len = len((prompt or "").strip())
p_first_turn = not getattr(session, "sdk_session_id", None)
if 0 < p_prompt_len < 50 and level != "off" and not p_first_turn:
level = "off"
# gc/gemini-3* without Antigravity 400s every multi-step turn on thoughtSignature continuity.
if (
isinstance(resolved_model, str)
and resolved_model.startswith("gc/gemini-3")
and level != "off"
):
logger.info(
"Forcing thinking_level=off for %s (gc/ thoughtSignature isn't roundtrippable; connect Antigravity for reasoning).",
resolved_model,
)
level = "off"
if api_type == "anthropic":
if level == "off":
options_kwargs["thinking"] = {"type": "disabled"}
elif level in ("low", "medium", "high"):
options_kwargs["effort"] = level
elif api_type in ("openai", "codex"):
# GPT-5 + Codex take reasoning_effort; 9Router carries the Anthropic-shaped `effort`.
if level in ("low", "medium", "high"):
options_kwargs["effort"] = level
except Exception as e:
logger.debug(f"thinking_level param injection skipped: {e}")