From 24d197eefdb3e6656f2e45885b5a3425a515675a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 16 Jul 2026 17:09:00 -0700 Subject: [PATCH] [eric] cleanup: delete the dead CLI-Agent hook branch (SpawnAgent route replaced it), the force-denied Cron*/no-op InvokeAgent FULL_TOOLS entries, and the unused stale-model resolve_model/MODEL_MAP --- .../agents/manager/prompt/tool_catalog.py | 3 +- .../manager/streaming/post_tool_hook.py | 72 ++----------------- backend/apps/outputs/html_inject.py | 11 --- backend/apps/outputs/outputs.py | 2 - backend/tests/test_tool_result_hook.py | 30 +------- 5 files changed, 7 insertions(+), 111 deletions(-) diff --git a/backend/apps/agents/manager/prompt/tool_catalog.py b/backend/apps/agents/manager/prompt/tool_catalog.py index dbedf807..d6640c2f 100644 --- a/backend/apps/agents/manager/prompt/tool_catalog.py +++ b/backend/apps/agents/manager/prompt/tool_catalog.py @@ -12,13 +12,12 @@ from backend.apps.tools_lib.tools_lib import ( logger = logging.getLogger(__name__) +# Cron* live only in the force-deny list (path_gate): our Schedule MCP replaces the CLI scheduler, so allowing them here just churned the allow/deny lists. InvokeAgent's real tool is the mcp__openswarm-invoke-agent__ ref; the bare name was a no-op. FULL_TOOLS = [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite", "EnterPlanMode", "ExitPlanMode", "EnterWorktree", "TaskOutput", "TaskStop", - "CronCreate", "CronList", "CronDelete", - "InvokeAgent", # ToolSearch is the loader the CLI uses to expose deferred tool schemas on demand. Must be in the allowedTools whitelist or the model can't call it, which means none of the deferred extended tools become reachable even when the CLI advertises them in the system prompt. "ToolSearch", ] diff --git a/backend/apps/agents/manager/streaming/post_tool_hook.py b/backend/apps/agents/manager/streaming/post_tool_hook.py index 68902019..a1ffaacf 100644 --- a/backend/apps/agents/manager/streaming/post_tool_hook.py +++ b/backend/apps/agents/manager/streaming/post_tool_hook.py @@ -1,22 +1,19 @@ """The SDK PostToolUse hook, lifted out of the agent loop. Runs after every tool call: records per-tool latency, normalizes the raw tool response into displayable text, re-renders -view-builder writes (and drains build errors), materializes a spawned Agent sub-session into -the manager registry, spills oversized results to disk, and broadcasts the tool_result message. +view-builder writes (and drains build errors), spills oversized results to disk, and +broadcasts the tool_result message. Operates on the HookContext (its `sessions` is the manager's live registry). The dict returns and payloads are the SDK hook protocol / existing message shapes, not internal models.""" import asyncio import logging import time -from datetime import datetime from typing import Dict -from uuid import uuid4 from typeguard import typechecked -from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.core.models import Message from backend.apps.agents.core.ws_manager import ws_manager -from backend.apps.agents.manager.session.apply_context_window import apply_context_window from backend.apps.agents.manager.session.history_compaction import ( truncate_large_tool_result, wrap_platform_note, @@ -140,68 +137,7 @@ async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, contex if elapsed_ms is not None: result_payload["elapsed_ms"] = elapsed_ms - if hook_tool_name == "Agent": - tool_input = input_data.get("tool_input", {}) - agent_prompt = tool_input.get("prompt", tool_input.get("task", "")) - - sub_text = content - sub_cost = 0.0 - sub_tokens = {"input": 0, "output": 0} - sub_model = session.model - if isinstance(raw_response, dict): - blocks = raw_response.get("content") - if isinstance(blocks, list): - parts = [ - b.get("text", "") - for b in blocks - if isinstance(b, dict) and b.get("type") == "text" - ] - if parts: - sub_text = "\n".join(parts) if len(parts) > 1 else parts[0] - elif isinstance(raw_response.get("text"), str): - sub_text = raw_response["text"] - usage = raw_response.get("usage", {}) - if isinstance(usage, dict): - sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0) - # Pill-only lane: NEW (uncached) input, excludes the cached static prefix so the bubble shows what this turn added. - sub_tokens["input_fresh"] = usage.get("input_tokens", 0) - sub_tokens["output"] = usage.get("output_tokens", 0) - if raw_response.get("total_cost_usd"): - sub_cost = raw_response["total_cost_usd"] - if raw_response.get("model"): - sub_model = raw_response["model"] - - sub_session_id = uuid4().hex - sub_name = agent_prompt[:50] if agent_prompt else "Sub-agent" - # Subagent context isolation invariant (Phase 3, Layer P): children DO NOT inherit the parent's active_mcps or compaction state. They start with the AgentSession defaults (empty lists). Reasoning: - Security: a parent that activated Gmail shouldn't leak Gmail tools to a subagent doing an unrelated task. The user only approved Gmail for the parent. - Token cost: subagents typically have a narrow task, they don't need the parent's full activated set. - Failure isolation: if the parent compacted history, the subagent shouldn't inherit a summary it can't re-expand. If a subagent ever needs a parent activation, the user must approve it explicitly via MCPActivate inside the subagent session, same gate as a fresh top-level chat. - sub_session = AgentSession( - id=sub_session_id, - name=sub_name, - status="completed", - model=sub_model, - mode="sub-agent", - cwd=session.cwd, - created_at=datetime.now(), - cost_usd=sub_cost, - tokens=sub_tokens, - messages=[ - Message(role="user", content=agent_prompt, branch_id="main"), - Message(role="assistant", content=sub_text, branch_id="main"), - ], - dashboard_id=session.dashboard_id, - parent_session_id=session_id, - # Explicit empty list (matches the model default) so the invariant is visible at the spawn site rather than relying on the field's default_factory. - active_mcps=[], - ) - apply_context_window(sub_session) - ctx.sessions[sub_session_id] = sub_session - await ws_manager.broadcast_global("agent:status", { - "session_id": sub_session_id, - "status": sub_session.status, - "session": sub_session.model_dump(mode="json"), - }) - result_payload["sub_session_id"] = sub_session_id - + # The CLI's built-in Agent/Task sub-agent tool is hard-blocked (disallowed_tools) and replaced by the SpawnAgent MCP route, which materializes real child sessions itself; no per-tool branch needed here anymore. result_msg = Message(role="tool_result", content=result_payload, branch_id=session.active_branch_id) # Spill oversized tool results to per-session disk storage. The replacement keeps the first 4KB inline so the model retains some signal; the rest lives on disk for the UI to surface in the compaction drawer. Crucially this happens at *write* time (before the next turn ships history to the SDK) so the bloat never re-enters context. try: diff --git a/backend/apps/outputs/html_inject.py b/backend/apps/outputs/html_inject.py index 4ac6ff06..3eae929b 100644 --- a/backend/apps/outputs/html_inject.py +++ b/backend/apps/outputs/html_inject.py @@ -14,17 +14,6 @@ from jsonschema import validate as schema_validate, ValidationError as SchemaVal logger = logging.getLogger(__name__) -MODEL_MAP = { - "sonnet": "claude-sonnet-4-20250514", - "opus": "claude-opus-4-20250514", - "haiku": "claude-haiku-4-5-20251001", -} - - -def resolve_model(short_name: str) -> str: - return MODEL_MAP.get(short_name, short_name) - - def get_anthropic_client(api_model: str | None = None): """Create an AsyncAnthropic client using the API key from app settings. diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 62dfd17c..51c480a4 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -28,8 +28,6 @@ from backend.apps.outputs.view_builder_templates import ( from backend.apps.settings.settings import load_settings from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR from backend.apps.outputs.html_inject import ( - MODEL_MAP, - resolve_model, get_anthropic_client, validate_against_schema, build_data_injection, diff --git a/backend/tests/test_tool_result_hook.py b/backend/tests/test_tool_result_hook.py index 6060e7f2..e5d52398 100644 --- a/backend/tests/test_tool_result_hook.py +++ b/backend/tests/test_tool_result_hook.py @@ -1,7 +1,7 @@ """Unit coverage for the extracted PostToolUse hook (tool_result_hook). The streaming harness mocks claude_agent_sdk.query, so it never fires the SDK's PostToolUse hooks; this pins the -behavior directly: a tool result becomes a tool_result message, and an Agent tool spawns a -sub-session into the manager's LIVE registry (the InstanceOf[dict] sharing, the subtle bit).""" +behavior directly: a tool result becomes a tool_result message, and view-builder writes surface +build/console errors into the result. SpawnAgent (the sub-agent path) is pinned in test_spawn_agent.py.""" import pytest from unittest.mock import patch, AsyncMock, MagicMock @@ -42,32 +42,6 @@ async def test_normal_tool_result_appends_message_and_continues(): send.assert_awaited() # the tool_result is broadcast to the UI -@pytest.mark.asyncio -async def test_agent_tool_spawns_subsession_into_live_registry(): - registry: dict = {} - ctx = p_ctx(registry) - parent_id = ctx.session_id - raw = { - "content": [{"type": "text", "text": "sub-agent did the work"}], - "usage": {"input_tokens": 7, "output_tokens": 3}, - "total_cost_usd": 0.01, - "model": "sonnet", - } - with patch.object(tool_result_hook.ws_manager, "send_to_session", new=AsyncMock()), \ - patch.object(tool_result_hook.ws_manager, "broadcast_global", new=AsyncMock()): - out = await tool_result_hook.post_tool_hook( - ctx, {"tool_name": "Agent", "tool_response": raw, "tool_input": {"prompt": "do x"}}, "tu1", None - ) - assert out == {"continue_": True} - # exactly one NEW session registered (besides the parent), parented correctly - children = [s for sid, s in registry.items() if sid != parent_id] - assert len(children) == 1 - child = children[0] - assert child.parent_session_id == parent_id - assert child.active_mcps == [] # context-isolation invariant: no inherited activations - assert "sub-agent did the work" in str(child.messages[-1].content) - - @pytest.mark.asyncio async def test_view_builder_dep_install_broadcasts_app_deps_changed(): """An npm install in a view-builder session must tell the app card this turn