diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 80453bc37139..5699e4d9eb65 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -100,6 +100,7 @@ emitted by each built-in hook site. child_role – role string of the child agent child_summary – summary of the child's work child_status – exit status string (e.g. "success", "error") + tool_call_history – redacted tool name/input summary/byte counts/status list duration_ms – wall-clock time of the child run in milliseconds """ diff --git a/docs/observability/README.md b/docs/observability/README.md index 9040929ca489..800c7d73e958 100644 --- a/docs/observability/README.md +++ b/docs/observability/README.md @@ -217,7 +217,11 @@ Subagent hooks describe delegated child-agent work: and `child_goal`. `subagent_stop` fields include parent/child session IDs, role/status fields, -`child_summary`, and `duration_ms`. +`child_summary`, `duration_ms`, and a metadata-only `tool_call_history`. Each +history entry contains the tool name, argument names, bounded side-effect +targets, input/output byte counts, and outcome. URL query strings and fragments +are removed; raw arguments, prompts, commands, contents, headers, and results +are intentionally excluded. Observers can use these hooks to model nested trajectories while keeping child agent execution linked to the parent turn that spawned it. diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index d3f86bd00e80..58faf7b41df5 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -189,6 +189,18 @@ _DEFAULT_PAYLOADS = { "child_role": None, "child_summary": "Synthetic summary for hooks test", "child_status": "completed", + "tool_call_history": [ + { + "tool_name": "write_file", + "tool_input": { + "argument_keys": ["content", "path"], + "targets": {"path": "/tmp/report.txt"}, + }, + "input_bytes": 128, + "output_bytes": 32, + "status": "ok", + } + ], "duration_ms": 1234, }, } diff --git a/tests/agent/test_subagent_stop_hook.py b/tests/agent/test_subagent_stop_hook.py index a2b417a0721f..84c0bbe4127b 100644 --- a/tests/agent/test_subagent_stop_hook.py +++ b/tests/agent/test_subagent_stop_hook.py @@ -5,6 +5,7 @@ Covers wire-up from tools.delegate_tool.delegate_task: * runs on the parent thread (no re-entrancy for hook authors) * carries child_role when the agent exposes _delegate_role * carries child_role=None when _delegate_role is not set (pre-M3) + * exposes a detached, metadata-only tool_call_history """ from __future__ import annotations @@ -15,7 +16,7 @@ from unittest.mock import MagicMock, patch import pytest -from tools.delegate_tool import delegate_task +from tools.delegate_tool import _summarize_tool_arguments, delegate_task from hermes_cli import plugins @@ -193,6 +194,91 @@ class TestBatchMode: class TestPayloadShape: + def test_includes_redacted_tool_call_history(self): + captured = _register_capturing_hook() + + with patch("tools.delegate_tool._run_single_child") as mock_run: + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "wrote the report", + "api_calls": 1, + "duration_seconds": 0.1, + "tool_trace": [{ + "tool": "write_file", + "args_bytes": 128, + "result_bytes": 32, + "status": "ok", + "input_summary": { + "argument_keys": ["content", "path", "token"], + "targets": { + "path": "/private/report.json", + "token": "must-not-leak", + }, + }, + "args": {"path": "/private/report.json"}, + "result": "secret output", + }], + } + delegate_task(goal="do X", parent_agent=_make_parent()) + + assert captured[0]["tool_call_history"] == [{ + "tool_name": "write_file", + "tool_input": { + "argument_keys": ["content", "path", "token"], + "targets": {"path": "/private/report.json"}, + }, + "input_bytes": 128, + "output_bytes": 32, + "status": "ok", + }] + + def test_tool_input_summary_keeps_targets_not_payloads(self): + summary = _summarize_tool_arguments(json.dumps({ + "path": "/workspace/report.json", + "content": "private report contents", + "url": "https://example.test/upload?token=secret#fragment", + "command": "curl -H 'Authorization: secret' example.test", + })) + + assert summary == { + "argument_keys": ["command", "content", "path", "url"], + "targets": { + "path": "/workspace/report.json", + "url": "https://example.test/upload", + }, + } + + def test_tool_call_history_is_detached_from_delegate_result(self): + def _mutating_hook(**kwargs): + kwargs["tool_call_history"][0]["status"] = "hook-mutated" + + plugins.get_plugin_manager()._hooks.setdefault( + "subagent_stop", [] + ).append(_mutating_hook) + + with patch("tools.delegate_tool._run_single_child") as mock_run: + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "done", + "api_calls": 1, + "duration_seconds": 0.1, + "tool_trace": [{ + "tool": "terminal", + "args_bytes": 10, + "result_bytes": 20, + "status": "ok", + "input_summary": { + "argument_keys": ["command"], + "targets": {}, + }, + }], + } + raw = delegate_task(goal="do X", parent_agent=_make_parent()) + + assert json.loads(raw)["results"][0]["tool_trace"][0]["status"] == "ok" + def test_role_absent_becomes_none(self): captured = _register_capturing_hook() diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index e17a0c77e24f..48d5a629d871 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -784,6 +784,10 @@ class TestDelegateObservability(unittest.TestCase): self.assertEqual(entry["tool_trace"][0]["tool"], "web_search") self.assertIn("args_bytes", entry["tool_trace"][0]) self.assertIn("result_bytes", entry["tool_trace"][0]) + self.assertEqual( + entry["tool_trace"][0]["input_summary"], + {"argument_keys": ["query"], "targets": {}}, + ) self.assertEqual(entry["tool_trace"][0]["status"], "ok") def test_tool_trace_handles_list_content_blocks(self): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 50f58c1a0d2c..c7dba38ea828 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -30,6 +30,7 @@ from concurrent.futures import ( TimeoutError as FuturesTimeoutError, ) from typing import Any, Dict, List, Optional +from urllib.parse import urlsplit, urlunsplit from toolsets import TOOLSETS @@ -298,6 +299,121 @@ def _stringify_tool_content(content: Any) -> str: return str(content) +_TOOL_INPUT_TARGET_KEYS = frozenset({ + "cwd", + "destination_path", + "directory", + "dst", + "endpoint", + "file_path", + "new_path", + "old_path", + "path", + "source_path", + "src", + "target_path", + "url", + "urls", +}) +_TOOL_INPUT_URL_KEYS = frozenset({"endpoint", "url", "urls"}) + + +def _sanitize_tool_target(key: str, value: Any) -> Any: + """Keep bounded side-effect targets while dropping URL secrets.""" + if isinstance(value, list): + cleaned = [ + item for item in (_sanitize_tool_target(key, item) for item in value[:16]) + if item is not None + ] + return cleaned or None + if not isinstance(value, str) or not value: + return None + bounded = value[:1024] + if key in _TOOL_INPUT_URL_KEYS: + try: + parsed = urlsplit(bounded) + if parsed.scheme and parsed.netloc: + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + except ValueError: + return None + return bounded + + +def _summarize_tool_arguments(arguments: Any) -> Dict[str, Any]: + """Summarize argument names and side-effect targets without raw payloads.""" + if not isinstance(arguments, str): + return {"argument_keys": [], "targets": {}} + try: + parsed = json.loads(arguments) + except (TypeError, ValueError): + return {"argument_keys": [], "targets": {}} + if not isinstance(parsed, dict): + return {"argument_keys": [], "targets": {}} + + keys = sorted(str(key)[:128] for key in parsed)[:64] + targets: Dict[str, Any] = {} + for raw_key, value in parsed.items(): + key = str(raw_key).lower() + if key not in _TOOL_INPUT_TARGET_KEYS: + continue + cleaned = _sanitize_tool_target(key, value) + if cleaned is not None: + targets[key] = cleaned + return {"argument_keys": keys, "targets": targets} + + +def _sanitize_tool_input_summary(summary: Any) -> Dict[str, Any]: + if not isinstance(summary, dict): + return {"argument_keys": [], "targets": {}} + keys = summary.get("argument_keys") + safe_keys = ( + [str(key)[:128] for key in keys[:64]] + if isinstance(keys, list) + else [] + ) + targets = summary.get("targets") + safe_targets: Dict[str, Any] = {} + if isinstance(targets, dict): + for raw_key, value in targets.items(): + key = str(raw_key).lower() + if key not in _TOOL_INPUT_TARGET_KEYS: + continue + cleaned = _sanitize_tool_target(key, value) + if cleaned is not None: + safe_targets[key] = cleaned + return {"argument_keys": safe_keys, "targets": safe_targets} + + +def _subagent_stop_tool_call_history(tool_trace: Any) -> List[Dict[str, Any]]: + """Build a detached, metadata-only tool history for lifecycle hooks.""" + if not isinstance(tool_trace, list): + return [] + + history: List[Dict[str, Any]] = [] + for item in tool_trace: + if not isinstance(item, dict): + continue + tool_name = str(item.get("tool") or "unknown")[:256] + status = str(item.get("status") or "unknown").lower() + if status not in {"ok", "error"}: + status = "unknown" + + def _byte_count(key: str) -> int: + value = item.get(key, 0) + if not isinstance(value, (int, float)) or isinstance(value, bool): + return 0 + return max(0, int(value)) + + history.append({ + "tool_name": tool_name, + "tool_input": _sanitize_tool_input_summary(item.get("input_summary")), + "input_bytes": _byte_count("args_bytes"), + "output_bytes": _byte_count("result_bytes"), + "status": status, + }) + return history + + def _looks_like_error_output(content: Any) -> bool: """Conservative stderr/error detector for tool-result previews. @@ -2168,9 +2284,11 @@ def _run_single_child( if msg.get("role") == "assistant": for tc in msg.get("tool_calls") or []: fn = tc.get("function", {}) + arguments = fn.get("arguments", "") entry_t = { "tool": fn.get("name", "unknown"), - "args_bytes": len(fn.get("arguments", "")), + "args_bytes": len(arguments), + "input_summary": _summarize_tool_arguments(arguments), } tool_trace.append(entry_t) tc_id = tc.get("id") @@ -2864,6 +2982,9 @@ def delegate_task( child_role=child_role, child_summary=entry.get("summary"), child_status=entry.get("status"), + tool_call_history=_subagent_stop_tool_call_history( + entry.get("tool_trace") + ), duration_ms=int((entry.get("duration_seconds") or 0) * 1000), ) except Exception: diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index cdb957e3748b..52417bf5741b 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -959,7 +959,7 @@ Fires **once per child agent** after `delegate_task` finishes. Whether you deleg ```python def my_callback(parent_session_id: str, child_role: str | None, child_summary: str | None, child_status: str, - duration_ms: int, **kwargs): + tool_call_history: list[dict], duration_ms: int, **kwargs): ``` | Parameter | Type | Description | @@ -968,6 +968,7 @@ def my_callback(parent_session_id: str, child_role: str | None, | `child_role` | `str \| None` | Orchestrator role tag set on the child (`None` if the feature isn't enabled) | | `child_summary` | `str \| None` | The final response the child returned to the parent | | `child_status` | `str` | `"completed"`, `"failed"`, `"interrupted"`, or `"error"` | +| `tool_call_history` | `list[dict]` | Ordered metadata-only tool calls: `tool_name`, bounded `tool_input`, `input_bytes`, `output_bytes`, and `status`; raw inputs and outputs are excluded | | `duration_ms` | `int` | Wall-clock time spent running the child, in milliseconds | **Fires:** In `tools/delegate_tool.py`, after `ThreadPoolExecutor.as_completed()` drains all child futures. Firing is marshalled to the parent thread so hook authors don't have to reason about concurrent callback execution.