Add tool label registry and SSE v2 tool event shaping

Pure functions build tool_start/tool_end payloads with run_id, friendly
label, truncated input (config dropped), status, duration and artifact ref.
Labels cover every agent tool, both old and #55 YNAB names, and the
homebox_/bookstack_ MCP prefixes, falling back to "Using <tool>…".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:52:23 -04:00
parent 1a73e26f23
commit d2f21a6d23
3 changed files with 251 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
"""Pure shaping of LangChain tool events into SSE `tool_start` / `tool_end` payloads."""
import json
import re
from .tool_labels import tool_label
INPUT_VALUE_MAX_CHARS = 200
# Injected by LangChain rather than chosen by the model; never shown to the client.
_HIDDEN_INPUT_KEYS = frozenset({"config", "callbacks", "run_manager"})
_TOOL_HEADER_RE = re.compile(r"^\[(\S+) result\]")
def _truncate(text: str) -> str:
if len(text) > INPUT_VALUE_MAX_CHARS:
return text[: INPUT_VALUE_MAX_CHARS - 1] + "…"
return text
def summarize_tool_input(raw) -> dict[str, str | int | float | bool]:
"""Reduce a tool's input to flat, display-safe scalars."""
if raw is None:
return {}
if not isinstance(raw, dict):
return {"input": _truncate(str(raw))}
summary: dict[str, str | int | float | bool] = {}
for key, value in raw.items():
if key in _HIDDEN_INPUT_KEYS or value is None:
continue
if isinstance(value, (bool, int, float)):
summary[key] = value
elif isinstance(value, str):
summary[key] = _truncate(value)
else:
try:
summary[key] = _truncate(json.dumps(value, default=str))
except (TypeError, ValueError):
summary[key] = _truncate(str(value))
return summary
def tool_start_payload(event: dict) -> dict:
name = event["name"]
return {
"type": "tool_start",
"run_id": event["run_id"],
"tool": name,
"label": tool_label(name),
"input": summarize_tool_input(event.get("data", {}).get("input")),
}
def tool_end_payload(
event: dict,
started_at: float | None,
artifact_ref: dict | None,
*,
now: float,
status: str = "ok",
) -> dict:
name = event["name"]
return {
"type": "tool_end",
"run_id": event["run_id"],
"tool": name,
"label": tool_label(name),
"status": status,
"ms": round((now - started_at) * 1000) if started_at is not None else 0,
"artifact": artifact_ref,
}
def tool_name_from_message(text: str) -> str:
"""Recover the tool name from a persisted `[tool_name result]` header."""
match = _TOOL_HEADER_RE.match(text)
return match.group(1) if match else "tool"
+64
View File
@@ -0,0 +1,64 @@
"""Friendly, user-facing labels for agent tool calls (shown in the activity timeline)."""
TOOL_LABELS: dict[str, str] = {
"get_current_date": "Checking the date…",
"web_search": "Searching the web…",
"simba_search": "Searching household documents…",
"save_user_memory": "Saving to memory…",
# YNAB (current names)
"ynab_budget_summary": "Checking budget…",
"ynab_search_transactions": "Searching transactions…",
"ynab_category_spending": "Reviewing category spending…",
"ynab_category_allocations": "Reviewing category budgets…",
"ynab_insights": "Analyzing spending trends…",
# YNAB (names after the #55 mirror)
"ynab_account_balances": "Checking account balances…",
"ynab_spending_report": "Building spending report…",
"ynab_category_budget": "Reviewing category budgets…",
"ynab_recent_changes": "Checking recent budget changes…",
"ynab_sql": "Querying budget data…",
# Mealie
"mealie_todays_meals": "Checking today's meals…",
"mealie_meal_plans": "Checking the meal plan…",
"mealie_get_recipe": "Fetching recipe…",
"mealie_shopping_lists": "Checking shopping lists…",
"mealie_create_meal_plan": "Adding to the meal plan…",
# Obsidian / journal
"obsidian_search_notes": "Searching notes…",
"obsidian_read_note": "Reading note…",
"obsidian_create_note": "Creating note…",
"obsidian_create_task": "Creating task…",
"journal_get_today": "Reading today's journal…",
"journal_get_tasks": "Checking tasks…",
"journal_add_task": "Adding task…",
"journal_complete_task": "Completing task…",
# Google
"get_calendar_events": "Checking calendar…",
"create_calendar_event": "Creating calendar event…",
"gmail_search_emails": "Searching email…",
"gmail_get_email": "Reading email…",
# SimbaKit
"simbakit_device_status": "Checking Simba's devices…",
"simbakit_weight_history": "Checking Simba's weight…",
"simbakit_litter_activity": "Checking litter box activity…",
"simbakit_drinking_activity": "Checking water fountain activity…",
# Beli
"beli_search_restaurants": "Searching restaurants…",
}
# MCP tools are discovered at runtime, so they match by name prefix.
TOOL_PREFIX_LABELS: tuple[tuple[str, str], ...] = (
("homebox_", "Checking home inventory…"),
("bookstack_", "Checking the wiki…"),
)
def tool_label(tool_name: str) -> str:
"""Return the friendly label for a tool, falling back to "Using <tool>…"."""
label = TOOL_LABELS.get(tool_name)
if label:
return label
for prefix, prefix_label in TOOL_PREFIX_LABELS:
if tool_name.startswith(prefix):
return prefix_label
return f"Using {tool_name}…"