mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
feat(delegate): expose redacted child tool history
This commit is contained in:
parent
b9ba7c78e4
commit
e369d6ea3f
7 changed files with 233 additions and 4 deletions
|
|
@ -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
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue