"""Per-turn SDK options assembly, lifted out of run_agent_loop so agent_manager stays under the file ceiling. Builds hook_ctx + the gate hooks, the MCP server set + effective tool lists, the options_kwargs (provider env, preset, thinking, resume/history), runs the pre-send context guard, and returns the ClaudeAgentOptions plus the bits the streaming turn needs. Mixin method: self.* and the gate hooks resolve across the MRO unchanged.""" import json import logging from typing import Dict, List, Optional, Union from typeguard import typechecked from backend.apps.agents.core.models import AgentSession from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.settings.settings import load_settings from backend.apps.tools_lib.tools_lib import load_all_tools, sanitize_server_name from backend.apps.agents.tools.web import should_register_web_mcp from backend.apps.agents.manager.permissions import gate_hooks from backend.apps.agents.manager.streaming import post_tool_hook as post_tool_hook_mod from backend.apps.agents.manager.streaming import stop_hook as stop_hook_mod from backend.apps.agents.manager.streaming.HookContext import HookContext from backend.apps.agents.manager.permissions.build_effective_tool_lists import build_effective_tool_lists from backend.apps.agents.manager.register_builtin_mcp_servers import register_builtin_mcp_servers from backend.apps.agents.manager.configure_provider_env import configure_provider_env from backend.apps.agents.manager.session.workspace_git import ensure_cwd_git_repo from backend.apps.agents.manager.session.history_compaction import build_history_prefix, get_branch_messages from backend.apps.agents.manager.prompt.compose_turn_system_prompt import compose_turn_system_prompt from backend.apps.agents.manager.prompt.tool_catalog import get_all_tool_names from backend.apps.agents.manager.prompt.prompt_context import resolve_mode from backend.apps.agents.manager.run.run_options_helpers import ( pre_send_context_guard, set_framework_overhead, register_web_mcp_server, append_web_tools_hint, inject_thinking_options, ) logger = logging.getLogger(__name__) from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol class RunOptions(AgentManagerProtocol): # No return annotation: the returned tuple carries an SDK ClaudeAgentOptions, which can't be module-imported here (mock-mode would fail to import the manager); it's lazy-imported below. @typechecked async def build_agent_options(self, session: AgentSession, session_id: str, prompt: str, prompt_content: Union[str, List], builtin_perms: Dict[str, str], selected_browser_ids: Optional[List[str]], selected_app_output_ids: Optional[List[str]], selected_setting_ids: Optional[List[str]], fork_session: bool, p_router_model_id: str, p_api_type_for_session: str): from claude_agent_sdk import ClaudeAgentOptions from claude_agent_sdk.types import HookMatcher hook_ctx = HookContext( session=session, session_id=session_id, prompt=prompt, builtin_perms=builtin_perms, policy_defaults={}, sessions=self.sessions, ) async def can_use_tool(tool_name, input_data, context): return await gate_hooks.can_use_tool(hook_ctx, tool_name, input_data, context) async def pre_tool_hook(input_data, tool_use_id, context): return await gate_hooks.pre_tool_hook(hook_ctx, input_data, tool_use_id, context) async def post_tool_hook(input_data, tool_use_id, context): return await post_tool_hook_mod.post_tool_hook(hook_ctx, input_data, tool_use_id, context) _, mode_sys_prompt, _ = resolve_mode(session.mode, get_all_tool_names) # Reconcile active_mcps against currently-enabled tools (Phase 3). If the user toggled a server off in the Tools page mid-session, drop it from active_mcps automatically so the model isn't told "X is active" while build_mcp_servers silently filters it out. Emit a context_status event so the model and UI both know. try: p_enabled = { sanitize_server_name(t.name) for t in load_all_tools() if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected") } p_stale = [s for s in session.active_mcps if s not in p_enabled] if p_stale: session.active_mcps = [s for s in session.active_mcps if s in p_enabled] session.needs_fork = True await ws_manager.send_to_session(session_id, "agent:context_status", { "session_id": session_id, "reason": "mcp_disabled_externally", "deactivated": p_stale, }) logger.info(f"Reconciled stale active_mcps for session {session_id}: dropped {p_stale}") except Exception: logger.exception("active_mcps reconciliation failed; proceeding") global_settings = load_settings() composed_prompt = compose_turn_system_prompt( session, mode_sys_prompt, global_settings.default_system_prompt, selected_browser_ids, selected_app_output_ids, selected_setting_ids, ) set_framework_overhead(session, composed_prompt) # Pass session.active_mcps as the activation filter. Empty list ⇒ no MCP tools shipped to the SDK; the model must MCPSearch and MCPActivate first. The product invariant lives here at the dispatch layer (see build_mcp_servers docstring). mcp_servers = await self.build_mcp_servers(session.allowed_tools, session.active_mcps) browser_delegation_tools, invoke_agent_tools = register_builtin_mcp_servers( mcp_servers, session, builtin_perms, selected_browser_ids ) # Register the DDG-backed openswarm-web MCP only when the primary has no reliable native Anthropic web path (decided in tools/web.py); p_m feeds the registration log + provider branch just below, so it stays a loop local. p_m = p_router_model_id if isinstance(p_router_model_id, str) else "" need_web_mcp = should_register_web_mcp( model=session.model, router_model_id=p_router_model_id, api_type=p_api_type_for_session, anthropic_api_key=getattr(global_settings, "anthropic_api_key", None), connection_mode=getattr(global_settings, "connection_mode", "own_key"), ) if need_web_mcp: register_web_mcp_server(mcp_servers, p_m) effective_allowed, effective_disallowed = build_effective_tool_lists( session, mcp_servers, builtin_perms, need_web_mcp, browser_delegation_tools, invoke_agent_tools, ) composed_prompt = append_web_tools_hint(composed_prompt, need_web_mcp, effective_allowed) # Log effective tool lists google_allowed = [t for t in effective_allowed if "google-workspace" in t] reddit_allowed = [t for t in effective_allowed if "reddit" in t] builtin_allowed = [t for t in effective_allowed if not t.startswith("mcp__")] logger.info(f"[MCP-DEBUG] effective_allowed: {len(effective_allowed)} total " f"(builtins={len(builtin_allowed)}, google={len(google_allowed)}, reddit={len(reddit_allowed)})") if effective_disallowed: logger.info(f"[MCP-DEBUG] effective_disallowed: {effective_disallowed}") # `p_router_model_id` and `p_api_type_for_session` were resolved at the top of run_agent_loop (before any closures were defined) so analytics closures could tag events with them. Reuse those values here and keep session.provider in sync. resolved_model = p_router_model_id api_type = p_api_type_for_session session.provider = api_type # Capture the Claude CLI's stderr into a buffer so the retry classifier can see the real cause of a process crash (e.g. "No pool capacity available" from the OpenSwarm proxy, or the Anthropic SDK's 429/overloaded error body). Without this the SDK's ProcessError only stringifies to "Command failed with exit code 1 / Check stderr output for details", which masks transient capacity issues. p_stderr_buffer: List[str] = [] def p_stderr_cb(line: str) -> None: p_stderr_buffer.append(line) # Cap the buffer so a runaway subprocess can't balloon RAM. if len(p_stderr_buffer) > 500: del p_stderr_buffer[:250] async def stop_hook(input_data, tool_use_id, context): return await stop_hook_mod.stop_hook(hook_ctx, input_data, tool_use_id, context) options_kwargs = { "model": resolved_model, # 64 MB ceiling on the SDK <-> CLI JSON-RPC channel. The default 5 MB blocked any base64'd PDF over ~3.5 MB; we now route PDFs/images as native content blocks, which base64-expand by ~33%. 64 MB clears the largest single Anthropic PDF (32 MB raw) with headroom for prompt + tool results sharing the same frame. "max_buffer_size": 64 * 1024 * 1024, "permission_mode": "default", "can_use_tool": can_use_tool, "stderr": p_stderr_cb, "hooks": { "PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])], "PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])], "Stop": [HookMatcher(matcher=None, hooks=[stop_hook])], }, "allowed_tools": effective_allowed, "disallowed_tools": effective_disallowed, "include_partial_messages": True, } # cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key. await configure_provider_env( options_kwargs, session, resolved_model, api_type, global_settings, [] ) if mcp_servers: options_kwargs["mcp_servers"] = mcp_servers mcp_json_len = len(json.dumps({"mcpServers": mcp_servers})) logger.info(f"[MCP-DEBUG] mcp_servers passed to SDK: {list(mcp_servers.keys())}, JSON length={mcp_json_len}") # claude_code preset for BOTH system_prompt and tools so the CLI's deferred-tools scaffolding survives. Raw string would replace it. options_kwargs["tools"] = { "type": "preset", "preset": "claude_code", } # exclude_dynamic_sections=True moves cwd/git/OS grounding out of the cached prefix and into the first user message, unlocks Anthropic prompt cache (~80% input-token cut, 13-31% faster TTFT). Trade-off: grounding freezes at turn 1. if composed_prompt: options_kwargs["system_prompt"] = { "type": "preset", "preset": "claude_code", "append": composed_prompt, "exclude_dynamic_sections": True, } else: options_kwargs["system_prompt"] = { "type": "preset", "preset": "claude_code", "exclude_dynamic_sections": True, } if session.max_turns: options_kwargs["max_turns"] = session.max_turns # The claude_code preset auto-attaches the user's claude.ai- connected partner MCPs (`mcp__claude_ai_*`). Those bypass our MCPActivate gate, don't share OAuth state with the OpenSwarm Gmail/Calendar/Drive connectors the user actually configured here, and confuse the model into picking the partner shim instead of our vetted server. Hard-block them at the SDK layer so the model can't even attempt the call. options_kwargs["disallowed_tools"] = [ "mcp__claude_ai_*", ] if session.cwd: # Pre-existing sessions may have workspaces that predate the git-init block in launch_agent, leaving them without a valid HEAD. Ensure it here so subagent worktree-add always works. ensure_cwd_git_repo(session.cwd) options_kwargs["cwd"] = session.cwd inject_thinking_options(options_kwargs, session, prompt, resolved_model, api_type) # Fresh-restart path: some session changes must not reuse the CLI's resume transcript. MCPActivate needs a new transport so tool schemas are reread; branch edits/switches need the model to see only get_branch_messages(session), not facts from the old branch's SDK transcript. Soft restart: drop resume + sdk_session_id, replay local history via the prompt, let the SDK build a clean session from the current app state. if session.needs_fresh_session: if session.sdk_session_id: logger.info( f"Fresh-session restart for {session_id}: dropping " f"sdk_session_id={session.sdk_session_id}; active_mcps={session.active_mcps}" ) session.sdk_session_id = None session.needs_fresh_session = False session.needs_fork = False # superseded by the fresh restart if session.sdk_session_id: options_kwargs["resume"] = session.sdk_session_id if fork_session or session.needs_fork: options_kwargs["fork_session"] = True if session.needs_fork: session.needs_fork = False elif len(session.messages) > 1: history = build_history_prefix( get_branch_messages(session), cutoff_msg_id=session.compacted_through_msg_id, ) if history: if isinstance(prompt_content, str): prompt_content = history + "\n\n" + prompt_content elif isinstance(prompt_content, list): prompt_content.insert(0, {"type": "text", "text": history}) # Compaction trigger (Phase 2). Driven by live ctx_used ratio rather than turn count, fires when input_tokens/context_window crosses session.compact_threshold_pct (default 0.65). Cheap, programmatic summarization (no aux LLM call) so this adds zero latency on the user's turn. await pre_send_context_guard(self, session, session_id) logger.info(f"[MCP-DEBUG] Creating ClaudeAgentOptions short={session.model} resolved={resolved_model} api_type={api_type}") options = ClaudeAgentOptions(**options_kwargs) logger.info("[MCP-DEBUG] ClaudeAgentOptions created. Starting query...") return options, options_kwargs, prompt_content, p_stderr_buffer, global_settings