mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 19:52:23 +02:00
166 lines
8.1 KiB
Python
166 lines
8.1 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), 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 typing import Dict
|
|
|
|
from typeguard import typechecked
|
|
|
|
from backend.apps.agents.core.models import Message
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
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 wrote_frontend_file or installed_pkg:
|
|
try:
|
|
from backend.apps.outputs.runtime import (
|
|
manager as outputs_runtime_manager,
|
|
)
|
|
# ANY session building an app has a preview runtime attached: the dedicated view-builder AND
|
|
# a plain agent using CreateApp (how onboarding builds its dashboard). Gate on the runtime,
|
|
# not the mode, so the Stop render-gate covers agent-mode app builds too (a plain /frontend/
|
|
# write with no runtime, e.g. editing OpenSwarm's own source, has none and is skipped).
|
|
if outputs_runtime_manager.get(session.id) is not None:
|
|
view_builder_dirty_sessions.add(session.id)
|
|
outputs_runtime_manager.reset_render_state_for_workspace(session.id)
|
|
if installed_pkg:
|
|
# Tell the app card this turn changed deps so its turn-finish reload restarts Vite; a soft webview reload can't pick up newly installed packages.
|
|
try:
|
|
await ws_manager.send_to_session(session.id, "agent:app_deps_changed", {
|
|
"session_id": session.id,
|
|
})
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
# Every write drains, App Builder included. This was an `elif` on the branch above, so a view-builder frontend write took that branch and skipped the drain: the one agent whose whole job is the app never saw its own vite/babel/tsc errors.
|
|
if wrote_files and file_path:
|
|
errs: list[str] = []
|
|
console_errs: list[str] = []
|
|
try:
|
|
# Give vite/uvicorn a beat to actually emit whatever this write broke.
|
|
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)
|
|
console_errs = outputs_runtime_manager.drain_frontend_errors_for_path(file_path)
|
|
except Exception:
|
|
pass
|
|
notes = []
|
|
if errs:
|
|
notes.append("Build server reported (after this write):\n" + "\n".join(errs[-20:]))
|
|
if console_errs:
|
|
notes.append("The app's console logged (after this write):\n" + "\n".join(console_errs[-10:]))
|
|
if notes:
|
|
content = f"{content}\n\n" + wrap_platform_note("\n\n".join(notes))
|
|
|
|
result_payload = {"text": content}
|
|
# Pairing by array index mispairs any parallel batch (first-completing result lands on the first call); the provider id lets the frontend pair by identity (ENG-232).
|
|
if tool_use_id:
|
|
result_payload["tool_use_id"] = str(tool_use_id)
|
|
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
|
|
|
|
# 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:
|
|
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}
|