[eric] agents: run/ decomposition follows conventions (naming, @typechecked+types, p-private)

This commit is contained in:
ciregenz
2026-06-24 00:33:21 -07:00
parent 7330c1293b
commit 553b307a57
6 changed files with 72 additions and 39 deletions
+4 -4
View File
@@ -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,
)
@@ -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)
@@ -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
@@ -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,
@@ -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 <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 (
@@ -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:
+3 -3
View File
@@ -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):