feat(delegate): expose redacted child tool history

This commit is contained in:
embwl0x 2026-07-10 06:36:03 -04:00 committed by Teknium
parent b9ba7c78e4
commit e369d6ea3f
7 changed files with 233 additions and 4 deletions

View file

@ -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
"""

View file

@ -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.

View file

@ -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,
},
}

View file

@ -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()

View file

@ -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):

View file

@ -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:

View file

@ -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.