From 553b307a577cb3087fd96b670daf8847fc8c7656 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 24 Jun 2026 00:32:44 -0700 Subject: [PATCH] [eric] agents: run/ decomposition follows conventions (naming, @typechecked+types, p-private) --- backend/apps/agents/agent_manager.py | 8 ++--- .../{run_options.py => RunOptionsMixin.py} | 36 +++++++++---------- .../{turn_runner.py => TurnRunnerMixin.py} | 16 +++++++-- .../apps/agents/manager/run/error_cards.py | 10 ++++-- .../agents/manager/run/run_options_helpers.py | 35 ++++++++++++++---- backend/tests/test_streaming_harness.py | 6 ++-- 6 files changed, 72 insertions(+), 39 deletions(-) rename backend/apps/agents/manager/run/{run_options.py => RunOptionsMixin.py} (91%) rename backend/apps/agents/manager/run/{turn_runner.py => TurnRunnerMixin.py} (91%) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 706b1d86..8095c647 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -27,8 +27,8 @@ from backend.apps.agents.manager.AgentLaunchMixin import AgentLaunchMixin from backend.apps.agents.manager.MockAgentMixin import MockAgentMixin from backend.apps.agents.manager.RunSupportMixin import RunSupportMixin from backend.apps.agents.manager.run.error_cards import handle_run_error -from backend.apps.agents.manager.run.turn_runner import TurnRunnerMixin -from backend.apps.agents.manager.run.run_options import RunOptionsMixin +from backend.apps.agents.manager.run.TurnRunnerMixin import TurnRunnerMixin +from backend.apps.agents.manager.run.RunOptionsMixin import RunOptionsMixin logger = logging.getLogger(__name__) @@ -122,7 +122,7 @@ class AgentManager(SessionLifecycleMixin, SessionPersistenceMixin, MessagingMixi # who want a prompt on every command can flip Bash to "ask" in the UI. try: (options, options_kwargs, prompt_content, p_stderr_buffer, - global_settings) = await self.p_build_agent_options( + global_settings) = await self.build_agent_options( session, session_id, prompt, prompt_content, builtin_perms, selected_browser_ids, selected_app_output_ids, selected_setting_ids, fork_session, p_router_model_id, p_api_type_for_session) @@ -131,7 +131,7 @@ class AgentManager(SessionLifecycleMixin, SessionPersistenceMixin, MessagingMixi turn = TurnState() thinking = ThinkingState() - await self.p_run_turn_with_retry( + await self.run_turn_with_retry( session, session_id, prompt_content, options, options_kwargs, turn, thinking, p_stderr_buffer, resolved_model, api_type, global_settings, ) diff --git a/backend/apps/agents/manager/run/run_options.py b/backend/apps/agents/manager/run/RunOptionsMixin.py similarity index 91% rename from backend/apps/agents/manager/run/run_options.py rename to backend/apps/agents/manager/run/RunOptionsMixin.py index 4dcf0d6e..4900a765 100644 --- a/backend/apps/agents/manager/run/run_options.py +++ b/backend/apps/agents/manager/run/RunOptionsMixin.py @@ -7,7 +7,10 @@ the gate hooks resolve across the MRO unchanged.""" import os 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 @@ -25,17 +28,23 @@ from backend.apps.agents.manager.prompt.system_prompt import compose_turn_system 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, register_web_mcp_server, append_web_tools_hint, inject_thinking_options, + pre_send_context_guard, set_framework_overhead, register_web_mcp_server, + append_web_tools_hint, inject_thinking_options, ) logger = logging.getLogger(__name__) class RunOptionsMixin: - async def p_build_agent_options(self, session, session_id, prompt, prompt_content, - builtin_perms, selected_browser_ids, selected_app_output_ids, - selected_setting_ids, fork_session, p_router_model_id, - p_api_type_for_session): + # 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 @@ -92,20 +101,7 @@ class RunOptionsMixin: selected_setting_ids, ) - # 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 - # MCP tool definitions range 1-10K depending on server; 3K is a - # rough median that keeps the meter honest without over-trimming), - # char/4 of 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 - ) + 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 @@ -163,7 +159,7 @@ class RunOptionsMixin: # 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] = [] + p_stderr_buffer: List[str] = [] def p_stderr_cb(line: str) -> None: p_stderr_buffer.append(line) diff --git a/backend/apps/agents/manager/run/turn_runner.py b/backend/apps/agents/manager/run/TurnRunnerMixin.py similarity index 91% rename from backend/apps/agents/manager/run/turn_runner.py rename to backend/apps/agents/manager/run/TurnRunnerMixin.py index 8c752406..250641d8 100644 --- a/backend/apps/agents/manager/run/turn_runner.py +++ b/backend/apps/agents/manager/run/TurnRunnerMixin.py @@ -6,23 +6,33 @@ except-handlers can still read them after a mid-stream failure.""" import asyncio import logging import time +from typing import Dict, List, 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.agents.core.error_classify import CAPACITY_BACKOFFS, capacity_retry_wait +from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming import ( stream_event, assistant_message, result_message, thinking as thinking_mod, ) +from backend.apps.settings.models import AppSettings logger = logging.getLogger(__name__) class TurnRunnerMixin: - async def p_run_turn_with_retry(self, session, session_id, prompt_content, options, - options_kwargs, turn, thinking, p_stderr_buffer, - resolved_model, api_type, global_settings): + # `options` is the SDK ClaudeAgentOptions, lazy-imported below (so mock-mode can import the + # manager without the SDK present), so it's left unannotated; everything else is typed. + @typechecked + async def run_turn_with_retry(self, session: AgentSession, session_id: str, + prompt_content: Union[str, List], options, + options_kwargs: Dict, turn: TurnState, thinking: ThinkingState, + p_stderr_buffer: List[str], resolved_model: str, api_type: str, + global_settings: AppSettings) -> None: from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ResultMessage from claude_agent_sdk.types import StreamEvent, SystemMessage diff --git a/backend/apps/agents/manager/run/error_cards.py b/backend/apps/agents/manager/run/error_cards.py index f7e23ad2..327302b2 100644 --- a/backend/apps/agents/manager/run/error_cards.py +++ b/backend/apps/agents/manager/run/error_cards.py @@ -4,10 +4,13 @@ emits the matching system message + WS event. Pulled out of agent_manager so the the file ceiling; pure relocation, no self (operates on the passed run state).""" import logging +from typing import List +from typeguard import typechecked -from backend.apps.agents.core.models import Message +from backend.apps.agents.core.models import AgentSession, Message from backend.apps.agents.core.ws_manager import ws_manager from backend.apps.settings.settings import load_settings +from backend.apps.agents.manager.streaming.state import TurnState from backend.apps.agents.core.error_classify import ( is_long_context_error, is_transient_capacity_error, @@ -21,7 +24,8 @@ from backend.apps.agents.core.error_classify import ( logger = logging.getLogger(__name__) -async def handle_run_error(e, session, session_id, turn, p_stderr_buffer) -> None: +@typechecked +async def handle_run_error(e: Exception, session: AgentSession, session_id: str, turn: TurnState, p_stderr_buffer: List[str]) -> None: logger.exception(f"Agent {session_id} error: {e}") session.status = "error" @@ -85,7 +89,7 @@ async def handle_run_error(e, session, session_id, turn, p_stderr_buffer) -> Non from backend.apps.service.client import submit_diagnostic submit_diagnostic({ "kind": "context_overflow", - "where": "agent_manager.p_run_streaming_turn", + "where": "manager.run.error_cards.handle_run_error", "session_id": session_id, "model": session.model, "provider": session.provider, diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index 1ba6e33c..c72deed5 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -3,15 +3,20 @@ assembly so each file stays under the ceiling. Free functions taking the manager emit_context_update); pure relocation.""" import logging +from typing import Dict, List, Optional +from typeguard import typechecked -from backend.apps.agents.core.models import Message +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 logger = logging.getLogger(__name__) -async def pre_send_context_guard(manager, session, session_id) -> None: +# `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: try: if manager.maybe_compact(session): new_input = estimate_post_compact_input(session) @@ -42,7 +47,7 @@ async def pre_send_context_guard(manager, session, session_id) -> None: 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] = [] + 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 @@ -88,7 +93,23 @@ async def pre_send_context_guard(manager, session, session_id) -> None: logger.exception("pre-send token guard failed; proceeding") -def register_web_mcp_server(mcp_servers, p_m) -> None: +@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) -> None: """Register the DDG-backed openswarm-web stdio MCP into the server set when the primary has no reliable native web path. The server script lives in the agents package (not here), so resolve it off that package dir, not __file__.""" @@ -121,7 +142,8 @@ def register_web_mcp_server(mcp_servers, p_m) -> None: ) -def append_web_tools_hint(composed_prompt, need_web_mcp, effective_allowed) -> str: +@typechecked +def append_web_tools_hint(composed_prompt: Optional[str], need_web_mcp: bool, effective_allowed: List[str]) -> str: """Append a 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 ( @@ -158,7 +180,8 @@ def append_web_tools_hint(composed_prompt, need_web_mcp, effective_allowed) -> s return f"{composed_prompt}\n\n{p_web_hint}" if composed_prompt else p_web_hint -def inject_thinking_options(options_kwargs, session, prompt, resolved_model, api_type) -> None: +@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: diff --git a/backend/tests/test_streaming_harness.py b/backend/tests/test_streaming_harness.py index bbd114a1..9974b37b 100644 --- a/backend/tests/test_streaming_harness.py +++ b/backend/tests/test_streaming_harness.py @@ -62,7 +62,7 @@ def p_capture_env(monkeypatch, settings, api_type, resolved_model, model_entry): into ClaudeAgentOptions (the provider-route auth config the SDK runs under).""" import backend.apps.agents.providers.registry as reg import backend.apps.agents.agent_manager as am - import backend.apps.agents.manager.run.run_options as run_opts + import backend.apps.agents.manager.run.RunOptionsMixin as run_opts monkeypatch.setattr(am, "load_settings", lambda: settings, raising=True) monkeypatch.setattr(run_opts, "load_settings", lambda: settings, raising=True) monkeypatch.setattr(reg, "get_api_type", lambda model: api_type, raising=True) @@ -169,7 +169,7 @@ def test_loop_builds_direct_anthropic_key_env(monkeypatch): from backend.apps.settings.models import AppSettings import backend.apps.agents.providers.registry as reg import backend.apps.agents.agent_manager as am - import backend.apps.agents.manager.run.run_options as run_opts + import backend.apps.agents.manager.run.RunOptionsMixin as run_opts settings = AppSettings(anthropic_api_key="sk-ant-test123", connection_mode="own_key") monkeypatch.setattr(am, "load_settings", lambda: settings, raising=True) @@ -206,7 +206,7 @@ def test_loop_with_session_cwd_runs_workspace_git_init(monkeypatch): # sessions normally have no cwd, which masked a NameError (the call said ensure_cwd_git_repo # while only _ensure_cwd_git_repo was imported). raising=True here would fail if the name were # missing again; the assertions confirm the cwd path actually runs and the turn completes. - import backend.apps.agents.manager.run.run_options as run_opts + import backend.apps.agents.manager.run.RunOptionsMixin as run_opts called = {} def fake_ensure(cwd, home=None):