Files
hermes-agent/tools/delegate_tool_results.py
Teknium 3d5831fa59 Merge pull request #103486 from NousResearch/fix/nested-delegate-deadline-and-summary-budget
fix(delegate): nested orchestrators get their workers' results back — delegate_task exempt from the 420 s tool deadline; summary budget uses current prompt, not the session sum
2026-09-06 12:02:43 -07:00

411 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Subagent result post-processing: summary budget/spill, tool-trace summaries, lifecycle hooks and cost rollup."""
from __future__ import annotations
import logging
import json
import threading
from typing import Any, Dict, List, Optional
from urllib.parse import urlsplit, urlunsplit
logger = logging.getLogger("tools.delegate_tool") # log-record parity with the origin module
def _stringify_tool_content(content: Any) -> str:
"""Stable text for tool-result content. Some OpenAI-compatible paths return
content-block lists; observability must never crash on them."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
item["text"] if isinstance(item, dict) and isinstance(item.get("text"), str)
else json.dumps(item, ensure_ascii=False, default=str) if isinstance(item, dict)
else str(item)
for item in content
)
if isinstance(content, dict):
return json.dumps(content, ensure_ascii=False, default=str)
return str(content)
def _looks_like_error_output(content: Any) -> bool:
"""Conservative error detector for tool-result previews: structured JSON with
an ``error`` key or an error/failed ``status``, or a first line starting with a
classic error marker. (Substring "error" alone painted normal output red.)"""
content = _stringify_tool_content(content)
if not content:
return False
if content.lstrip().startswith(("{", "[")):
try:
parsed = json.loads(content)
except Exception:
parsed = None
if isinstance(parsed, dict) and (
parsed.get("error") or str(parsed.get("status") or "").strip().lower() in {"error", "failed", "failure", "timeout"}
):
return True
first = content.splitlines()[0].strip().lower() if content.splitlines() else ""
return first.startswith(("error:", "failed:", "traceback ", "exception:"))
def _extract_output_tail(result: Dict[str, Any], *, max_entries: int = 12, max_chars: int = 8000) -> List[Dict[str, Any]]:
"""Last N tool-call results ``{tool, preview, is_error}`` from a child's conversation (the overlay's "Output"
section), chronological order. Content blocks are flattened first so a block-wrapped "Error: ..." is still
flagged; line structure is preserved (capped at ``max_chars``) so the overlay shows real output rather than a
whitespace-collapsed blob."""
messages = result.get("messages") if isinstance(result, dict) else None
if not isinstance(messages, list):
return []
name_by_call_id = {
tc["id"]: str((tc.get("function") or {}).get("name") or "tool")
for msg in messages if isinstance(msg, dict) and msg.get("role") == "assistant"
for tc in msg.get("tool_calls") or [] if tc.get("id")
}
tail: List[Dict[str, Any]] = []
for msg in reversed(messages): # newest first, then restore order below
if len(tail) >= max_entries:
break
if not isinstance(msg, dict) or msg.get("role") != "tool":
continue
content = _stringify_tool_content(msg.get("content") or "")
tool_name = name_by_call_id.get(msg.get("tool_call_id") or "", "tool")
tail.append({"tool": tool_name, "preview": content[:max_chars], "is_error": _looks_like_error_output(content)})
tail.reverse()
return tail
_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:
hostname = parsed.hostname
if not hostname:
return None
# ``SplitResult.netloc`` includes ``user:password@``. Rebuild the authority from parsed host/port so
# hook-visible history cannot carry URL credentials. Bracket IPv6 literals before appending a
# validated port.
host = f"[{hostname}]" if ":" in hostname else hostname
netloc = f"{host}:{parsed.port}" if parsed.port is not None else host
return urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
except ValueError:
return None
return bounded
def _sanitize_targets(mapping: Dict[str, Any]) -> Dict[str, Any]:
"""Keep only known side-effect target keys, each sanitized (URL secrets dropped)."""
targets: Dict[str, Any] = {}
for raw_key, value in mapping.items():
key = str(raw_key).lower()
if key in _TOOL_INPUT_TARGET_KEYS:
cleaned = _sanitize_tool_target(key, value)
if cleaned is not None:
targets[key] = cleaned
return targets
def _input_summary(keys: Any, targets: Any) -> Dict[str, Any]:
"""``{argument_keys, targets}`` with bounded, sanitized contents (empty on bad shapes)."""
return {
"argument_keys": [str(key)[:128] for key in keys[:64]] if isinstance(keys, list) else [],
"targets": _sanitize_targets(targets) if isinstance(targets, dict) else {},
}
def _summarize_tool_arguments(arguments: Any) -> Dict[str, Any]:
"""Summarize argument names and side-effect targets without raw payloads."""
try:
parsed = json.loads(arguments) if isinstance(arguments, str) else None
except (TypeError, ValueError):
parsed = None
if not isinstance(parsed, dict):
return _input_summary([], {})
return _input_summary(sorted(str(key)[:128] for key in parsed), parsed)
def _subagent_stop_tool_call_history(tool_trace: Any) -> List[Dict[str, Any]]:
"""Detached, metadata-only tool history for lifecycle hooks (input summaries re-sanitized)."""
if not isinstance(tool_trace, list):
return []
def _byte_count(item, key: str) -> int:
value = item.get(key, 0)
return max(0, int(value)) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
history: List[Dict[str, Any]] = []
for item in tool_trace:
if not isinstance(item, dict):
continue
status = str(item.get("status") or "unknown").lower()
summary = item.get("input_summary")
summary = summary if isinstance(summary, dict) else {}
history.append({
"tool_name": str(item.get("tool") or "unknown")[:256],
"tool_input": _input_summary(summary.get("argument_keys"), summary.get("targets")),
"input_bytes": _byte_count(item, "args_bytes"), "output_bytes": _byte_count(item, "result_bytes"),
"status": status if status in {"ok", "error"} else "unknown",
})
return history
# Hard per-summary character ceiling layered on top of the dynamic headroom budget (see _apply_summary_budget):
# belt-and-suspenders for models that ignore "be concise". 0 disables the ceiling.
DEFAULT_MAX_SUMMARY_CHARS = 24000
# Fraction of the parent's *remaining* context headroom the whole batch of summaries may consume, split per summary,
# so N children can't collectively blow the parent's window (the compression/429 death spiral).
_SUMMARY_HEADROOM_FRACTION = 0.5
# Floor so a single summary always gets a usable slice even when the parent is
# already nearly full — below this we'd be truncating to noise.
_MIN_SUMMARY_CHARS = 2000
def _spill_summary_to_file(task_index: int, summary: str) -> Optional[str]:
"""Write the full summary under ``cache/delegation`` (mounted read-only into remote backends via
``credential_files._CACHE_DIRS``, so the parent's terminal/``read_file`` can page it on any backend). Absolute
path, or None on failure — the trimmed head+tail is still returned regardless."""
try:
from hermes_constants import get_hermes_dir
import datetime as _dt
cache_dir = get_hermes_dir("cache/delegation", "delegation_cache")
cache_dir.mkdir(parents=True, exist_ok=True)
path = cache_dir / f"subagent-summary-{task_index}-{_dt.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.txt"
from tools.spill_safety import write_text_exclusive
# Exclusive symlink-refusing create; not private because cache/delegation is bind-mounted read-only into
# remote backends whose container UID must be able to read it.
write_text_exclusive(path, summary, private=False)
return str(path)
except Exception as exc:
logger.debug("Failed to spill subagent summary to file: %s", exc)
return None
def _trim_summary_with_footer(summary: str, cap: int, task_index: int) -> tuple[str, Optional[str]]:
"""``(model_text, spill_path)`` for one over-budget summary: a ~75% head / ~25% tail window snapped to line
boundaries (so the opening AND the closing outcomes/files-changed/issues both survive), the full text spilled
to disk, and a footer giving the exact ``read_file offset=`` for the omitted middle."""
original_len = len(summary)
head_budget = int(cap * 0.75)
tail_budget = cap - head_budget
head, tail = summary[:head_budget], summary[-tail_budget:]
nl = head.rfind("\n")
if nl > head_budget * 0.5:
head = head[:nl]
nl = tail.find("\n")
if 0 <= nl < tail_budget * 0.5:
tail = tail[nl + 1:]
spill_path = _spill_summary_to_file(task_index, summary)
if spill_path:
# Agent-visible path: the parent's read_file runs inside the active backend (#81984, #77015).
from tools.credential_files import to_agent_visible_cache_path
spill_path = to_agent_visible_cache_path(spill_path)
footer_lines = [
"", "─" * 8 + " [SUMMARY TRUNCATED] " + "─" * 8,
f"Showing {len(head):,} chars (head) + {len(tail):,} chars (tail) "
f"of {original_len:,} total — trimmed to protect the parent's context window.",
]
if spill_path:
# read_file is 1-indexed; +2 moves past the last head line shown.
footer_lines.append(f"Full subagent output saved to: {spill_path}")
footer_lines.append(
f'To read the omitted middle: read_file path="{spill_path}" '
f"offset={head.count(chr(10)) + 2} limit=200 (the file is the complete "
f"summary; raise/lower offset to page through it)."
)
else:
footer_lines.append("Full output could not be stored to disk; the head+tail above is all that was preserved.")
footer_lines.append("─" * 37)
return head + "\n\n[... middle omitted — see footer ...]\n\n" + tail + "\n".join(footer_lines), spill_path
def _parent_prompt_size_tokens(parent_agent) -> Optional[int]:
"""The parent's current prompt size: the aggregator's own last ``prompt_tokens`` (pre-MoA-fold), else
the last provider usage. ``None`` when no request has completed yet: the caller then applies the
static ceiling only. Treating "no usage yet" as zero handed a 190K/200K parent a 384K-char budget."""
size = getattr(parent_agent, "_last_prompt_size_tokens", None)
if isinstance(size, (int, float)) and size > 0:
return int(size)
last_usage = getattr(parent_agent, "_last_turn_usage", None) or {}
used = last_usage.get("prompt_tokens") if isinstance(last_usage, dict) else None
if isinstance(used, (int, float)) and used > 0:
return int(used)
return None
def _parent_summary_char_budget(parent_agent, n_summaries: int) -> Optional[int]:
"""Per-summary char budget from the parent's *remaining* context headroom (context length − the parent's
current prompt size − the compressor's output reserve), a fraction of it split across the batch at ~4
chars/token. None when the parent's context state is unknown — caller then uses the static ceiling only.
"Current prompt size" is the last API call's ``prompt_tokens`` (``_last_turn_usage``), never
``session_prompt_tokens``: that field is the running SUM over every call in the session, so after a
few hundred calls it exceeds any context window and every summary collapsed to the 2,000-char floor
(measured: all 1,393 child summaries in one run, each spilled to disk, the orchestrator working from
the stub)."""
try:
compressor = getattr(parent_agent, "context_compressor", None)
context_length = getattr(compressor, "context_length", None)
if not isinstance(context_length, int) or context_length <= 0:
return None
used_tokens = _parent_prompt_size_tokens(parent_agent)
if used_tokens is None:
return None # no usage yet and nothing to estimate from: static ceiling only, never "zero context"
headroom_tokens = context_length - int(used_tokens) - int(getattr(compressor, "max_tokens", 0) or 0)
if headroom_tokens <= 0:
return _MIN_SUMMARY_CHARS # parent already over budget: floor only
per_summary_tokens = int(headroom_tokens * _SUMMARY_HEADROOM_FRACTION) // max(1, n_summaries)
return max(_MIN_SUMMARY_CHARS, per_summary_tokens * 4)
except Exception:
logger.debug("Summary budget computation failed", exc_info=True)
return None
def _apply_summary_budget(results: List[Dict[str, Any]], parent_agent) -> None:
"""Trim subagent summaries in-place so a batch can't overflow the parent's context window (full text spilled to
disk). Per-summary cap = MIN(dynamic headroom budget, static ``delegation.max_summary_chars`` ceiling; 0 =
disabled); over-cap summaries become head+tail plus a pointer to the spill file."""
from tools.delegate_tool import _load_config
summaries = [r for r in results if isinstance(r, dict) and isinstance(r.get("summary"), str) and r["summary"]]
if not summaries:
return
try:
static_ceiling = int(_load_config().get("max_summary_chars", DEFAULT_MAX_SUMMARY_CHARS))
except (TypeError, ValueError):
static_ceiling = DEFAULT_MAX_SUMMARY_CHARS
candidates = [c for c in (static_ceiling, _parent_summary_char_budget(parent_agent, len(summaries))) if c and c > 0]
if not candidates:
return # both disabled / unknown → leave summaries untouched
cap = min(candidates)
for entry in summaries:
summary = entry["summary"]
if len(summary) <= cap:
continue
model_text, spill_path = _trim_summary_with_footer(summary, cap, entry.get("task_index", -1))
entry["summary"] = model_text
entry["summary_truncated"] = True
if spill_path:
entry["summary_full_path"] = spill_path
logger.debug(
"[subagent-%s] summary trimmed %d → ~%d chars (spill=%s)", entry.get("task_index", "?"), len(summary), cap,
spill_path or "none",
)
_PARENT_FINALIZATION_LOCK_GUARD = threading.Lock()
_PARENT_FINALIZATION_FALLBACK_LOCK = threading.RLock()
_CHILD_CONSTRUCTION_LOCK = threading.RLock()
def _build_child_preserving_parent_tools(**kwargs):
"""Build a child without leaking its resolved toolset into the parent."""
from tools.delegate_tool import _build_child_agent
import model_tools
with _CHILD_CONSTRUCTION_LOCK:
parent_tool_names = list(model_tools._last_resolved_tool_names)
try:
child = _build_child_agent(**kwargs)
finally:
model_tools._last_resolved_tool_names = parent_tool_names
child._delegate_saved_tool_names = parent_tool_names
return child
def _parent_finalization_lock(parent_agent) -> threading.RLock:
"""Per-parent lock serializing lifecycle side effects (created once under the guard)."""
if parent_agent is None:
return _PARENT_FINALIZATION_FALLBACK_LOCK
lock = getattr(parent_agent, "_subagent_finalization_lock", None)
if lock is not None:
return lock
with _PARENT_FINALIZATION_LOCK_GUARD:
lock = getattr(parent_agent, "_subagent_finalization_lock", None)
if lock is None:
lock = threading.RLock()
try:
setattr(parent_agent, "_subagent_finalization_lock", lock)
except Exception:
return _PARENT_FINALIZATION_FALLBACK_LOCK
return lock
def _notify_memory_manager(results, task_list, child_by_index, parent_agent) -> None:
memory = getattr(parent_agent, "_memory_manager", None) if parent_agent else None
if not memory:
return
for entry in results:
try:
task_index = entry.get("task_index", -1)
in_range = isinstance(task_index, int) and 0 <= task_index < len(task_list)
memory.on_delegation(
task=task_list[task_index]["goal"] if in_range else "", result=entry.get("summary", "") or "",
child_session_id=getattr(child_by_index.get(task_index), "session_id", ""),
)
except Exception:
pass
def _fire_subagent_stop_hooks(results, child_by_index, parent_agent) -> float:
"""Pop the model-hidden ``_child_role`` / ``_child_cost_usd`` fields from every
entry, fire ``subagent_stop`` per child, and return the summed child cost."""
try:
from hermes_cli.plugins import invoke_hook as invoke_hook
except Exception:
invoke_hook = None
children_cost_total = 0.0
for entry in results:
child_role = entry.pop("_child_role", None)
child_cost = entry.pop("_child_cost_usd", 0.0)
try:
if child_cost:
children_cost_total += float(child_cost)
except (TypeError, ValueError):
pass
if invoke_hook is None:
continue
try:
child = child_by_index.get(entry.get("task_index", -1))
invoke_hook(
"subagent_stop", parent_session_id=getattr(parent_agent, "session_id", None),
parent_turn_id=getattr(parent_agent, "_current_turn_id", "") or "",
child_session_id=getattr(child, "session_id", None), 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:
logger.debug("subagent_stop hook invocation failed", exc_info=True)
return children_cost_total
def _rollup_children_cost(parent_agent, children_cost_total: float) -> None:
"""Fold the children's spend into the parent's session cost (source/status
only set when the parent had none of its own)."""
if children_cost_total <= 0.0:
return
try:
current = float(getattr(parent_agent, "session_estimated_cost_usd", 0.0) or 0.0)
parent_agent.session_estimated_cost_usd = current + children_cost_total
if getattr(parent_agent, "session_cost_source", "none") in {None, "", "none"}:
parent_agent.session_cost_source = "subagent"
if getattr(parent_agent, "session_cost_status", "unknown") in {None, "", "unknown"}:
parent_agent.session_cost_status = "estimated"
except Exception:
logger.debug("Subagent cost rollup failed", exc_info=True)
def _finalize_child_results(
results: List[Dict[str, Any]], task_list: List[Dict[str, Any]], children: List[tuple[int, Dict[str, Any], Any]],
parent_agent,
) -> None:
"""Apply host-owned summary, memory, hook, and cost contracts once."""
with _parent_finalization_lock(parent_agent):
_apply_summary_budget(results, parent_agent)
child_by_index = {index: child for index, _task, child in children}
_notify_memory_manager(results, task_list, child_by_index, parent_agent)
_rollup_children_cost(parent_agent, _fire_subagent_stop_hooks(results, child_by_index, parent_agent))
def _run_child_lifecycle(task_index: int, goal: str, child=None, parent_agent=None) -> Dict[str, Any]:
"""Run one child and apply the same host lifecycle used by delegate_task."""
from tools.delegate_tool import _run_single_child
result = _run_single_child(task_index, goal, child, parent_agent)
result.setdefault("task_index", task_index)
task = {"goal": goal}
_finalize_child_results([result], [{"goal": ""} for _ in range(task_index)] + [task], [(task_index, task, child)], parent_agent)
return result