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