Files
openswarm/backend/apps/agents/manager/streaming/post_tool_hook.py
T

209 lines
9.9 KiB
Python

"""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.
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.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,
strip_forged_sentinels,
)
from backend.apps.agents.manager.streaming.HookContext import HookContext
from backend.apps.agents.manager.view_builder_state import view_builder_dirty_sessions
logger = logging.getLogger(__name__)
@typechecked
async def post_tool_hook(ctx: HookContext, input_data: dict, tool_use_id, context) -> Dict[str, object]:
session = ctx.session
session_id = ctx.session_id
elapsed_ms = None
if tool_use_id and tool_use_id in ctx.tool_start_times:
elapsed_ms = int((time.time() - ctx.tool_start_times.pop(tool_use_id)) * 1000)
raw_response = input_data.get("tool_response", "")
# Accumulate per-tool latency on the session. Lets the cloud aggregate a tool-latency distribution into the existing daily.summary without firing per-tool events.
hook_tool_name_early = input_data.get("tool_name", "")
if hook_tool_name_early and elapsed_ms is not None and elapsed_ms >= 0:
latencies = getattr(session, "tool_latencies", None)
if latencies is None:
latencies = {}
try:
session.tool_latencies = latencies
except Exception:
latencies = None
if latencies is not None:
slot = latencies.get(hook_tool_name_early)
if slot is None:
slot = {"count": 0, "total_ms": 0, "max_ms": 0}
latencies[hook_tool_name_early] = slot
slot["count"] = slot.get("count", 0) + 1
slot["total_ms"] = slot.get("total_ms", 0) + elapsed_ms
slot["max_ms"] = max(slot.get("max_ms", 0), elapsed_ms)
if isinstance(raw_response, list) and raw_response:
text_parts = [
block.get("text", "")
for block in raw_response
if isinstance(block, dict) and block.get("type") == "text"
]
if text_parts:
raw_response = "\n".join(text_parts) if len(text_parts) > 1 else text_parts[0]
if isinstance(raw_response, str):
content = raw_response
else:
try:
import json as json_lib
content = json_lib.dumps(raw_response, indent=2, default=str)
except Exception:
content = str(raw_response)
# Untrusted tool output could forge our trusted-note tag; neuter it before we append real ones below.
content = strip_forged_sentinels(content)
hook_tool_name_for_errors = input_data.get("tool_name", "")
wrote_files = hook_tool_name_for_errors in ("Write", "Edit", "MultiEdit")
tool_in = input_data.get("tool_input") or {}
file_path = tool_in.get("file_path") or tool_in.get("path") or ""
wrote_frontend_file = wrote_files and "/frontend/" in file_path
installed_pkg = False
if hook_tool_name_for_errors == "Bash":
bash_in = input_data.get("tool_input") or {}
cmd = (bash_in.get("command") or "").lower()
installed_pkg = any(s in cmd for s in (
"npm install", "npm i ", "npm uninstall", "npm ci",
"pnpm add", "pnpm install", "pnpm remove",
"yarn add", "yarn install", "yarn remove",
))
if session.mode == "view-builder" and (wrote_frontend_file or installed_pkg):
view_builder_dirty_sessions.add(session.id)
try:
from backend.apps.outputs.runtime import (
manager as outputs_runtime_manager,
)
outputs_runtime_manager.reset_render_state_for_workspace(session.id)
except Exception:
pass
elif wrote_files:
if file_path:
try:
await asyncio.sleep(0.4)
from backend.apps.outputs.runtime import (
manager as outputs_runtime_manager,
)
errs = outputs_runtime_manager.drain_errors_for_path(file_path)
except Exception:
errs = []
if errs:
joined = "\n".join(errs[-20:])
content = (
f"{content}\n\n"
+ wrap_platform_note(f"Build server reported (after this write):\n{joined}")
)
result_payload = {"text": content}
hook_tool_name = input_data.get("tool_name", "")
if hook_tool_name:
result_payload["tool_name"] = hook_tool_name
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
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:
truncated_content, blob_path = truncate_large_tool_result(
result_msg.content, session.id, result_msg.id
)
if blob_path:
result_msg.content = truncated_content
logger.info(f"Spilled tool result {result_msg.id} ({len(blob_path)} chars) to {blob_path}")
except Exception:
logger.exception("Tool result truncation failed; keeping inline body")
session.messages.append(result_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": result_msg.model_dump(mode="json"),
})
return {"continue_": True}