Files
hermes-agent/tools/delegate_tool_child_run.py
moep90 e002cdb92d fix(delegation): run the schema-retry turn in the delegated-child context
_validate_child_output_schema issues a second run_conversation on the child when
the first answer fails the declared output_schema. The main child turn is wrapped
in delegated_child_context; this one was not. It runs on the parent worker's
thread, where HERMES_KANBAN_TASK is set and nothing marks the execution as a
child, so every identity gate keyed on is_delegated_child_context() fails open.

The visible effect is the kanban stop guard: it nudges the child to call
kanban_complete or kanban_block. A child owns no board task and carries no kanban
toolset, so it cannot, and the nudge text ("do not narrate intent", "finish any
remaining deliverable") displaces the structured answer the retry exists to
produce. The retry then fails the same schema and delegate_task reports an error
for a child whose work was already complete.

Observed with four children, each nudged during its retry:

  [subagent-0] Kanban worker tried to exit without kanban_complete/kanban_block
  [subagent-2] Kanban worker tried to exit without kanban_complete/kanban_block
  [subagent-3] Kanban worker tried to exit without kanban_complete/kanban_block
  [subagent-1] Kanban worker tried to exit without kanban_complete/kanban_block
  4/4 - Final answer does not satisfy the declared output_schema (after 1 retry)

Wrap the retry the same way the main turn is wrapped. The context is entered and
exited around the single call, so nothing outside the retry sees it.

Signed-off-by: moep90 <volleyballlive@googlemail.com>
2026-09-15 18:41:19 -07:00

925 lines
51 KiB
Python

"""Running ONE pre-built child agent: heartbeat, registry entry, workspace seeding,
timeout/failure handling, result-entry assembly and cleanup (``_ChildRun``)."""
from __future__ import annotations
import logging
import contextvars
import json
import os
import threading
import time
from concurrent.futures import TimeoutError as FuturesTimeoutError
from typing import Any, Dict, List, Optional
from agent.interrupt_compat import request_hard_interrupt
from dataclasses import dataclass, field
from tools import file_state
from tools.delegate_tool_progress import _quiet, _safe_progress
from tools.delegate_tool_registry import (
_capture_gateway_steer_authority, _close_subagent_steering, _register_subagent, _unregister_subagent,
)
from tools.delegate_tool_results import (
_extract_output_tail, _looks_like_error_output, _stringify_tool_content, _summarize_tool_arguments,
)
logger = logging.getLogger("tools.delegate_tool") # log-record parity with the origin module
def _num(value: Any, default: int = 0) -> int:
"""int() for counters that may be mocks/None on test doubles."""
return int(value) if isinstance(value, (int, float)) else default
def _str_or_none(value: Any) -> Optional[str]:
return value if isinstance(value, str) else None
def _fabricated_entry(idx: int, status: str, error: str, child: Any, duration: float = 0) -> Dict[str, Any]:
"""Result entry for a child that raised, never finished, or was abandoned."""
return {
"task_index": idx, "status": status, "summary": None, "error": error, "api_calls": 0,
"duration_seconds": duration, "_child_role": getattr(child, "_delegate_role", None),
}
def _append_missed_steer(entry: Dict[str, Any], late_steer: Optional[str]) -> None:
"""Record steer text that won the race with the child's failure/timeout."""
if late_steer:
entry["missed_steer"] = late_steer
entry["error"] += (" [steer did not land before the subagent stopped: " f"{late_steer}]")
def _close_child(child: Any, log_message: str) -> None:
"""Best-effort ``child.close()`` (tool sandboxes, browser daemons, httpx clients)."""
with _quiet(log_message, exc_info=True):
close = getattr(child, "close", None)
if callable(close):
close()
def _with_children_lock(parent_agent: Any, op: str, child: Any) -> None:
"""``parent_agent._active_children.<op>(child)`` under the parent's lock when it has one."""
lock = getattr(parent_agent, "_active_children_lock", None)
if lock:
with lock:
getattr(parent_agent._active_children, op)(child)
else:
getattr(parent_agent._active_children, op)(child)
def _attach_child(parent_agent: Any, child: Any) -> None:
"""Register the child for parent interrupt propagation.
``interrupt()`` fans out to a SNAPSHOT of ``_active_children``; a child attached after the stop
landed (a fan-out still building its siblings, a turn that has not reached its iteration check yet)
would otherwise start with no signal and run to completion as an orphan. Mirror a pending stop here
so the whole spawn tree dies with its parent."""
if hasattr(parent_agent, "_active_children"):
_with_children_lock(parent_agent, "append", child)
if getattr(parent_agent, "_interrupt_requested", False) is not True:
return
# Same soft/hard split as ``interrupt()``'s own fan-out: a hard stop cancels, a soft one redirects.
message = getattr(parent_agent, "_interrupt_message", None)
hard = getattr(parent_agent, "_hard_interrupt_requested", None)
if hard is None or hard.is_set():
_signal_child_stop(child, message or "parent agent interrupted")
else:
with _quiet("Failed to propagate interrupt to late child: %s"):
child.interrupt(message)
def _detach_child(parent_agent: Any, child: Any) -> None:
"""Remove the child from parent interrupt propagation (no-op if absent)."""
if not hasattr(parent_agent, "_active_children"):
return
try:
_with_children_lock(parent_agent, "remove", child)
except (ValueError, UnboundLocalError) as e:
logger.debug("Could not remove child from active_children: %s", e)
def _signal_child_stop(child: Any, *reason: str) -> None:
"""Cooperative interrupt so the child's worker thread can exit cleanly."""
with _quiet(None):
if child is not None and not request_hard_interrupt(child, *reason) and hasattr(child, "_interrupt_requested"):
child._interrupt_requested = True
# ── 0-API-call timeout diagnostic ────────────────────────────────────────────
def _format_thread_stack(frame: Any, indent: str) -> List[str]:
import traceback as _traceback
return [f"{indent}{sub}" for frame_line in _traceback.format_stack(frame) for sub in frame_line.rstrip().split("\n")]
_DIAG_CHILD_ATTRS = (
"model", "provider", "api_mode", "base_url", "max_iterations", "quiet_mode", "skip_memory", "skip_context_files",
"platform", "_delegate_role", "_delegate_depth",
)
def _diag_section(label: str, produce) -> List[str]:
"""Lines from ``produce()``, or one ``<label: ...exc>`` line so a broken attribute never aborts the dump."""
try:
return list(produce())
except Exception as exc:
return [f" {label}{exc}>"]
def _diag_sizes(child: Any) -> List[str]:
def _prompt():
sys_prompt = getattr(child, "ephemeral_system_prompt", None) or getattr(child, "system_prompt", None) or ""
is_str = isinstance(sys_prompt, str)
return [
f" system_prompt_bytes: {len(sys_prompt.encode('utf-8')) if is_str else 'n/a'}",
f" system_prompt_chars: {len(sys_prompt) if is_str else 'n/a'}",
]
def _tools():
tools_schema = getattr(child, "tools", None)
if tools_schema is None:
return []
return [
f" tool_schema_count: {len(tools_schema)}",
f" tool_schema_bytes: {len(json.dumps(tools_schema, default=str).encode('utf-8'))}",
]
return ["## Prompt / schema sizes"] + _diag_section("system_prompt: <error: ", _prompt) + _diag_section("tool_schema: <error: ", _tools)
def _diag_threads(worker_thread: Optional[threading.Thread]) -> List[str]:
"""Worker stack plus all other live threads (bounded to 40): the worker is often parked on a helper thread, so a
pre-HTTP wedge is indistinguishable from a slow provider without the full picture."""
import sys as _sys
lines = ["## Worker thread stack at timeout"]
frames = _sys._current_frames()
if worker_thread is not None and worker_thread.is_alive():
worker_frame = frames.get(worker_thread.ident)
lines.extend(_format_thread_stack(worker_frame, " ") if worker_frame is not None else [" <worker frame not available>"])
else:
lines.append(" <no worker thread handle>" if worker_thread is None else " <worker thread already exited>")
def _all_threads():
frames = _sys._current_frames()
by_ident = {th.ident: th for th in threading.enumerate() if th.ident}
worker_ident = worker_thread.ident if worker_thread else None
out: List[str] = []
for dumped, (ident, frame) in enumerate(f for f in frames.items() if f[0] != worker_ident): # worker dumped above
if dumped >= 40:
out.append(f" <{len(frames) - dumped - 1} more threads omitted>")
break
th = by_ident.get(ident)
out.append(f" --- {th.name if th else f'ident={ident}'}{' daemon' if (th and th.daemon) else ''} ---")
out.extend(_format_thread_stack(frame, " "))
return out
return lines + ["", "## All thread stacks at timeout"] + _diag_section("<all-thread dump failed: ", _all_threads)
def _dump_subagent_timeout_diagnostic(
*, child: Any, task_index: int, timeout_seconds: float, duration_seconds: float,
worker_thread: Optional[threading.Thread], goal: str,
) -> Optional[str]:
"""Structured diagnostic for a subagent that timed out before any API call (otherwise "timed out with no
response", 0 API calls, nothing to inspect): ``~/.hermes/logs/subagent-timeout-<sid>-<ts>.log`` with the
child's config, prompt/schema sizes, activity snapshot and worker stack. Path, or None on failure."""
try:
from hermes_constants import get_hermes_home
import datetime as _dt
logs_dir = get_hermes_home() / "logs"
try:
logs_dir.mkdir(parents=True, exist_ok=True)
except Exception:
return None
subagent_id = getattr(child, "_subagent_id", None) or f"idx{task_index}"
dump_path = logs_dir / f"subagent-timeout-{subagent_id}-{_dt.datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
_goal_preview = (goal or "").strip()
if len(_goal_preview) > 1000:
_goal_preview = _goal_preview[:1000] + " ...[truncated]"
def _attr_line(attr):
try:
return f" {attr}: {getattr(child, attr, None)!r}"
except Exception:
return f" {attr}: <unreadable>"
tool_names = getattr(child, "valid_tool_names", None)
lines: List[str] = [
"# Subagent timeout diagnostic — issue #14726", f"# Generated: {_dt.datetime.now().isoformat()}", "",
"## Timeout", f" task_index: {task_index}", f" subagent_id: {subagent_id}",
f" configured_timeout: {timeout_seconds}s", f" actual_duration: {duration_seconds:.2f}s", "", "## Goal",
_goal_preview or "(empty)", "", "## Child config", *map(_attr_line, _DIAG_CHILD_ATTRS),
"", "## Toolsets", f" enabled_toolsets: {getattr(child, 'enabled_toolsets', None)!r}",
]
if tool_names:
lines.append(f" loaded tool count: {len(tool_names)}")
with _quiet(None):
lines.append(f" loaded tools: {sorted(tool_names)}")
lines += ["", *_diag_sizes(child), "", "## Activity summary"]
lines += _diag_section(
"<get_activity_summary failed: ", lambda: [f" {k}: {v!r}" for k, v in child.get_activity_summary().items()],
)
lines += ["", *_diag_threads(worker_thread), "", "## Notes",
" This file is written ONLY when a subagent times out with 0 API calls.",
" 0-API-call timeouts mean the child never reached its first LLM request.",
" Common causes: oversized prompt rejected by provider, transport hang,",
" credential resolution stuck. See issue #14726 for context.",
]
dump_path.write_text("\n".join(lines), encoding="utf-8")
return str(dump_path)
except Exception as exc:
logger.warning("Subagent timeout diagnostic dump failed: %s", exc)
return None
# ── Per-run helpers ──────────────────────────────────────────────────────────
class _Heartbeat:
"""One child's parent-activity heartbeat via the shared periodic scheduler
(``agent.periodic_scheduler``) — not one daemon thread per child. NOT started at construction:
the caller calls ``start()`` inside its ``try`` so a failed schedule (OS thread exhaustion on
first use) leaves ``handle`` None and ``stop()`` is a no-op."""
def __init__(self, child: Any, parent_agent: Any, task_index: int):
self.child, self.parent_agent, self.task_index = child, parent_agent, task_index
# Stale detection: a cycle counts as stale when (tool, iteration,
# activity_ts) all froze; thresholds differ idle vs in-tool.
self.last_seen = {"iter": 0, "tool": None, "ts": None, "stale": 0}
self.handle = None
def start(self) -> None:
from agent.periodic_scheduler import schedule
from tools.delegate_tool import _HEARTBEAT_INTERVAL
self.handle = schedule(self.tick, _HEARTBEAT_INTERVAL)
def stop(self) -> None:
"""wait=5 mirrors the old thread join: an in-flight tick finishes."""
if self.handle is not None:
self.handle.cancel(wait=5)
def tick(self):
"""Returning False stops the periodic callback."""
from tools.delegate_tool import _HEARTBEAT_STALE_CYCLES_IDLE, _HEARTBEAT_STALE_CYCLES_IN_TOOL
child, parent_agent, task_index, last_seen = self.child, self.parent_agent, self.task_index, self.last_seen
touch = getattr(parent_agent, "_touch_activity", None) if parent_agent is not None else None
if not touch:
return None
desc = f"delegate_task: subagent {task_index} working"
try:
child_summary = child.get_activity_summary()
child_tool = child_summary.get("current_tool")
child_iter = child_summary.get("api_call_count", 0)
child_max = child_summary.get("max_iterations", 0)
child_activity_ts = child_summary.get("last_activity_ts")
# A slow model wait refreshes last_activity_ts (direct_api_call
# heartbeat), so it never looks stale at the idle threshold.
activity_advanced = child_activity_ts is not None and (
last_seen["ts"] is None or child_activity_ts > last_seen["ts"]
)
if child_iter > last_seen["iter"] or child_tool != last_seen["tool"] or activity_advanced:
last_seen.update(iter=child_iter, tool=child_tool, stale=0)
if child_activity_ts is not None:
last_seen["ts"] = child_activity_ts
else:
last_seen["stale"] += 1
if last_seen["stale"] >= (_HEARTBEAT_STALE_CYCLES_IN_TOOL if child_tool else _HEARTBEAT_STALE_CYCLES_IDLE):
logger.warning(
"Subagent %d appears stale (no progress for %d heartbeat cycles, tool=%s) — stopping heartbeat",
task_index, last_seen["stale"], child_tool or "<none>",
)
return False # stop touching parent, let gateway timeout fire
if child_tool:
desc = f"delegate_task: subagent running {child_tool} (iteration {child_iter}/{child_max})"
elif child_summary.get("last_activity_desc", ""):
desc = f"delegate_task: subagent {child_summary.get('last_activity_desc', '')} (iteration {child_iter}/{child_max})"
except Exception:
pass
with _quiet(None):
touch(desc)
return None
def _start_heartbeat(child: Any, parent_agent: Any, task_index: int) -> _Heartbeat:
"""Build (not start) one child's heartbeat; see ``_Heartbeat``."""
return _Heartbeat(child, parent_agent, task_index)
def _register_child(
child: Any, parent_agent: Any, goal: str, *, owner_session_id: Optional[str], owner_transport: Any,
owner_session_record: Any,
) -> Optional[str]:
"""Register the live child in the module registry; return its subagent_id. Test doubles without a stable string
``_subagent_id`` are not registered (None) and the caller skips every registry interaction for them."""
_subagent_id = getattr(child, "_subagent_id", None)
if not isinstance(_subagent_id, str) or not _subagent_id:
return None
if owner_session_id is None:
with _quiet(None):
from gateway.session_context import get_session_env
owner_session_id = get_session_env("HERMES_UI_SESSION_ID", "") or None
if owner_session_id and (owner_transport is None or owner_session_record is None):
owner_transport, owner_session_record = _capture_gateway_steer_authority(owner_session_id)
_raw_depth = getattr(child, "_delegate_depth", 1)
_register_subagent({
"subagent_id": _subagent_id,
"parent_id": _str_or_none(getattr(child, "_parent_subagent_id", None)),
"depth": max(0, _raw_depth - 1) if isinstance(_raw_depth, int) else 0,
"goal": goal,
"delegation_id": _str_or_none(getattr(child, "_delegation_id", None)),
"model": _str_or_none(getattr(child, "model", None)),
"started_at": time.time(), "status": "running", "tool_count": 0, "agent": child,
# Owning conversation's durable session id (same lineage completion delivery routes by), sourced from the
# child's stamp so it survives a parent_agent rebuild between dispatch and run; used for list/steer/stop
# ownership when the weakref chain breaks.
"owner_agent_session_id": (
str(getattr(child, "_parent_session_id", "") or "") or str(getattr(parent_agent, "session_id", "") or "") or None
),
# Immutable live gateway/TUI session that commissioned this child.
# Empty outside those hosts; RPC authority fails closed.
"owner_session_id": owner_session_id,
"owner_transport": owner_transport,
"owner_session_record": owner_session_record,
})
return _subagent_id
def _create_isolated_worktree(parent_agent: Any, parent_task_id: Any, subagent_id: Optional[str]):
"""Opt-in worktree isolation: own git worktree off the parent's HEAD (the
child's terminal starts there). Git-only, local-backend-only; failures
degrade silently to the shared workspace. Returns the worktree info or None."""
from tools.delegate_tool import _get_worktree_isolation, _resolve_workspace_hint
if not _get_worktree_isolation():
return None
with _quiet("worktree isolation setup failed: %s"):
from tools import subagent_worktree
if not subagent_worktree.local_backend_active():
logger.debug("worktree isolation skipped: non-local terminal backend")
return None
_parent_cwd = None
with _quiet(None):
from tools.terminal_tool import get_session_cwd as _gsc
_parent_cwd = _gsc(parent_task_id)
return subagent_worktree.create_subagent_worktree(
_parent_cwd or _resolve_workspace_hint(parent_agent), subagent_id=subagent_id,
)
return None
def _defer_close_after_timeout(child: Any, child_future: Any) -> None:
"""Hand ``child.close()`` to a Future done-callback and drain its transports.
The interrupt is cooperative: the worker still runs its finally path, so closing now could close SQLite under its
final write — the done-callback is the first safe boundary. The abandoned worker is usually parked in an OpenSSL
read; NEVER hard-close that transport from this thread (cross-thread FD release under a live SSL read corrupts
native state) — shutdown() the pooled sockets so the read settles with EOF and the worker unwinds. One immediate
sweep + one delayed re-sweep for a connection opened in between; a worker that still won't settle keeps its
resources until process exit.
"""
child_future.add_done_callback(lambda _done: _close_child(child, "Failed to close timed-out child after worker exit"))
# Bounded drain (#94248 native half): the deferred close above only fires once the abandoned worker
# unwinds, but that worker is typically parked inside an in-flight OpenSSL read (Codex / httpx). Never
# hard-close that transport from this thread — releasing FDs under a live SSL read is the #29507/#70773
# native-corruption family. Instead shutdown() the child's pooled sockets, which is FD-safe from any
# thread and settles the blocked read with EOF/EPIPE so the worker can unwind and trigger the deferred
# close.
_drain = getattr(child, "_drain_transports_after_abandonment", None)
if not callable(_drain):
return
def _drain_once(phase: str) -> None:
with _quiet("Timed-out child transport drain (%s) failed", phase, exc_info=True):
_drain(reason=f"delegate_timeout_{phase}")
_drain_once("immediate")
_resweep_timer = threading.Timer(5.0, lambda: None if child_future.done() else _drain_once("resweep"))
_resweep_timer.daemon = True
_resweep_timer.start()
def _lease_child_credential(child: Any) -> tuple[Any, Optional[str]]:
"""Lease a credential from the child's pool (if any) and bind it; ``(pool, lease_id)``."""
child_pool = getattr(child, "_credential_pool", None)
if child_pool is None:
return None, None
leased_cred_id = child_pool.acquire_lease()
if leased_cred_id is not None:
with _quiet("Failed to bind child to leased credential: %s"):
leased_entry = child_pool.current()
if leased_entry is not None and hasattr(child, "_swap_credential"):
child._swap_credential(leased_entry)
return child_pool, leased_cred_id
def _merge_late_steer(result: Dict[str, Any], subagent_id: Optional[str], child: Any) -> None:
"""Linearization boundary for registry steering: from here the child cannot consume another steer. Closing under
the registry lock either rejects a concurrent caller or drains every accepted exact text into the result before
callbacks/result assembly run."""
late = _close_subagent_steering(subagent_id, child) if subagent_id else None
if late:
existing = result.get("pending_steer")
result["pending_steer"] = f"{existing}\n{late}" if isinstance(existing, str) and existing else late
@dataclass
class _SchemaOutcome:
schema: Optional[Dict[str, Any]]
valid: Optional[bool]
errors: List[str]
retries: int
def _validate_child_output_schema(
child: Any, result: Dict[str, Any], task_index: int, child_task_id: str, relay_child_text: Any
) -> _SchemaOutcome:
"""Validate the final answer against the attached output_schema with ONE bounded retry. Schema-less children (no
dict on ``child._delegate_output_schema``) take no branch here so their result entry stays byte-identical."""
_output_schema = getattr(child, "_delegate_output_schema", None)
if not isinstance(_output_schema, dict):
return _SchemaOutcome(_output_schema, None, [], 0)
from tools.delegation_output_schema import build_retry_message, validate_output
_first_text = result.get("final_response") or ""
_schema_valid, _schema_errors = validate_output(_first_text, _output_schema)
if _schema_valid or not _first_text.strip() or result.get("interrupted", False):
return _SchemaOutcome(_output_schema, _schema_valid, _schema_errors, 0)
# Exactly one retry turn, carrying the validation errors verbatim (no
# schema re-paste — the child already holds the contract in its context).
_retry_result = None
try:
# Same identity as the main child turn (delegate_tool_child_run's worker): this runs
# on the parent's thread, where HERMES_KANBAN_TASK is set, so without the marker the
# kanban stop guard nudges the child to call kanban_complete. It owns no board task
# and cannot, and the nudge displaces the answer this retry exists to produce.
from agent.delegation_context import delegated_child_context
with delegated_child_context(str(getattr(child, "session_id", "") or "")):
_retry_result = child.run_conversation(
user_message=build_retry_message(_schema_errors), task_id=child_task_id,
stream_callback=relay_child_text,
)
except Exception as _retry_exc:
logger.warning("Subagent %d schema-retry turn failed: %s", task_index, _retry_exc)
if isinstance(_retry_result, dict):
_retry_text = _retry_result.get("final_response") or ""
if _retry_text.strip():
result["final_response"] = _retry_text
try:
result["api_calls"] = int(result.get("api_calls", 0) or 0) + int(_retry_result.get("api_calls", 0) or 0)
except (TypeError, ValueError):
pass
_retry_messages = _retry_result.get("messages")
if isinstance(_retry_messages, list) and isinstance(result.get("messages"), list):
result["messages"] = result["messages"] + _retry_messages
_schema_valid, _schema_errors = validate_output(_retry_text, _output_schema)
return _SchemaOutcome(_output_schema, _schema_valid, _schema_errors, 1)
def _build_tool_trace(messages: Any) -> list[Dict[str, Any]]:
"""Tool trace from the child's conversation messages, pairing parallel
tool calls with their results by tool_call_id."""
tool_trace: list[Dict[str, Any]] = []
trace_by_id: Dict[str, Dict[str, Any]] = {}
if not isinstance(messages, list):
return tool_trace
for msg in messages:
if not isinstance(msg, dict):
continue
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(arguments),
"input_summary": _summarize_tool_arguments(arguments),
}
tool_trace.append(entry_t)
if tc.get("id"):
trace_by_id[tc["id"]] = entry_t
elif msg.get("role") == "tool":
content = _stringify_tool_content(msg.get("content", ""))
result_meta = {"result_bytes": len(content), "status": "error" if _looks_like_error_output(content) else "ok"}
tc_id = msg.get("tool_call_id")
target = trace_by_id.get(tc_id) if tc_id else None
if target is not None:
target.update(result_meta)
elif tool_trace:
tool_trace[-1].update(result_meta) # no tool_call_id: pair with the latest call
return tool_trace
def _build_result_entry(
child: Any, result: Dict[str, Any], task_index: int, duration: float, schema: _SchemaOutcome,
) -> Dict[str, Any]:
"""Parent-visible result entry (status, exit_reason, tool trace, tokens, cost).
``status``/``exit_reason``/``truncated`` follow the ``_run_single_child`` contract; a structured failure always
wins over the summary-presence heuristic (a fallback for legacy/mock results only)."""
summary = result.get("final_response") or ""
# "(empty)" is run_agent's give-up sentinel after repeated empty LLM
# responses (usually a transport bug) — a failure, not a success.
usable_summary = bool(summary) and summary.strip() != "(empty)"
if result.get("interrupted", False):
status, exit_reason = "interrupted", "interrupted"
elif result.get("failed") or result.get("error"):
# The loop returns the error text as final_response, which would otherwise read as "completed". Never report a
# provider rejection as "max_iterations" — that is only truthful for real budget exhaustion.
status, exit_reason = "failed", "error"
else:
# exit_reason ("completed" vs "max_iterations") tells the parent HOW the task ended; completed=False with no
# failure = budget exhaustion. A declared schema still violated after the bounded retry does NOT fail the
# run: the child's raw final text is the deliverable (audits of up to 68 min were written off as "failed"
# over a stray code fence or one missing field); ``schema_valid: false`` + ``schema_errors`` carry the
# contract verdict, and the summary is prefixed with a notice so a status-only reader cannot mistake it
# for validated output.
exit_reason = "completed" if result.get("completed", False) else "max_iterations"
status = "completed" if usable_summary else "failed"
_cost = getattr(child, "session_estimated_cost_usd", 0.0)
_cost_status = getattr(child, "session_cost_status", None)
# Result entry contract: see the _run_single_child docstring.
entry: Dict[str, Any] = {
"task_index": task_index,
"status": status,
"summary": summary,
"api_calls": result.get("api_calls", 0),
"duration_seconds": duration,
"model": _str_or_none(getattr(child, "model", None)),
"exit_reason": exit_reason,
# A budget-exhausted child still returns a summary (status stays
# "completed"), so the parent needs this explicit flag.
"truncated": exit_reason == "max_iterations",
"tokens": {
"input": _num(getattr(child, "session_prompt_tokens", 0)),
"output": _num(getattr(child, "session_completion_tokens", 0)),
},
"tool_trace": _build_tool_trace(result.get("messages") or []),
# Captured before the finally block calls child.close() so the parent thread can fire subagent_stop with the
# correct role; stripped before the dict is serialised back to the model (as is _child_cost_usd, folded into
# the parent's session cost by the aggregator).
"_child_role": getattr(child, "_delegate_role", None),
"_child_cost_usd": float(_cost or 0.0) if isinstance(_cost, (int, float)) else 0.0,
}
# Model-visible per-delegation spend (unlike _child_cost_usd above).
entry["cost_usd"] = round(entry["_child_cost_usd"], 6)
entry["cost_status"] = _cost_status if isinstance(_cost_status, str) and _cost_status else "unknown"
if status == "failed":
entry["error"] = result.get("error", "Subagent did not produce a response.")
# Classified reason from the child loop (e.g. "rate_limit", "billing")
# lets the parent tell a quota wall from a task error without parsing prose.
_failure_reason = result.get("failure_reason")
if isinstance(_failure_reason, str) and _failure_reason:
entry["failure_reason"] = _failure_reason
# Schema-validation outcome — emitted ONLY when a schema was requested, so
# legacy (schema-less) payloads keep their exact shape.
if isinstance(schema.schema, dict):
entry["schema_valid"] = bool(schema.valid)
if schema.retries:
entry["schema_retries"] = schema.retries
if not schema.valid and schema.errors:
entry["schema_errors"] = schema.errors
if schema.valid is False and usable_summary:
entry["schema_note"] = (
"Final answer does not satisfy the declared output_schema"
+ (" (after 1 retry)" if schema.retries else "")
+ "; `summary` is the child's raw, UNVALIDATED final text — extract what you need from it "
"yourself (see schema_errors) rather than re-running the task."
)
# A steer queued after the final assistant turn had no tool batch to land
# in; name it so the parent sees it was MISSED rather than silently absorbed.
_missed_steer = result.get("pending_steer")
if isinstance(_missed_steer, str) and _missed_steer.strip():
entry["missed_steer"] = _missed_steer
_miss_note = ("[steer did not land — the subagent finished before it could " f"be delivered: {_missed_steer}]")
entry["summary"] = f"{summary}\n\n{_miss_note}" if summary else _miss_note
return entry
def _is_image_url(ref: str) -> bool:
return ref.startswith(("http://", "https://", "data:image/"))
def _build_child_goal_message(goal: str, images: List[str], child) -> Any:
"""The child's first user message when a task forwards ``images``.
Routing reuses the inbound-image policy (``agent.image_routing``, honouring ``agent.image_input_mode``): a
vision-capable child gets an OpenAI-style content list (text part + one ``image_url`` part per image; local files
as data URLs behind the read guard, http(s)/data URLs verbatim); otherwise the goal gains ``[Image attached …]``
hint lines for ``vision_analyze``. Any failure degrades to the text-only goal so image plumbing never breaks a
spawn — logged at warning since the caller asked for the images.
"""
try:
# data: URLs ride as image parts only — their base64 never goes into the text hint or a text-mode goal.
data_urls = [s for s in images if s.startswith("data:image/")]
urls = [s for s in images if _is_image_url(s) and s not in data_urls]
paths = [s for s in images if not _is_image_url(s)]
from agent.image_routing import build_native_content_parts, decide_image_input_mode
cfg = None
with _quiet(None):
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly()
mode = decide_image_input_mode(
str(getattr(child, "provider", "") or ""), str(getattr(child, "model", "") or ""), cfg,
requested_provider=str(getattr(child, "requested_provider", "") or ""),
)
if mode == "native":
parts, skipped = build_native_content_parts(goal, paths, urls)
if skipped:
logger.warning("delegate_task: skipped %d unreadable image(s) for subagent: %s", len(skipped), ", ".join(skipped[:3]))
if data_urls:
parts = (parts or [{"type": "text", "text": goal}]) + [{"type": "image_url", "image_url": {"url": u}} for u in data_urls]
return parts if any(p.get("type") == "image_url" for p in parts) else goal
if data_urls:
logger.warning("delegate_task: %d inline data-URL image(s) dropped for a non-vision subagent", len(data_urls))
hints: List[str] = []
for p in paths:
if os.path.isfile(p):
hints.append(f"[Image attached at: {p}]")
else:
logger.warning("delegate_task: image path not found, not forwarded: %s", p)
hints.extend(f"[Image attached: {u}]" for u in urls)
if not hints:
return goal
return goal + "\n\n" + "\n".join(hints) + "\nUse vision_analyze to inspect these images."
except Exception:
logger.warning("delegate_task: image forwarding failed; sending text-only goal", exc_info=True)
return goal
@dataclass
class _ChildRun:
"""State of one child run, shared by every phase of ``_run_single_child``.
``worktree_info`` stays None until isolation engages (``attach_worktree`` is
then a no-op on every early error path); ``seed_workspace`` sets the rest."""
child: Any
parent_agent: Any
task_index: int
goal: str
subagent_id: Optional[str]
child_progress_cb: Any
child_start: float = field(default_factory=time.monotonic)
worktree_info: Optional[Dict[str, str]] = None
child_task_id: str = ""
parent_task_id: Optional[str] = None
wall_start: float = 0.0
parent_reads_snapshot: list = field(default_factory=list)
def elapsed(self) -> float:
return round(time.monotonic() - self.child_start, 2)
def relay_text(self, delta: str) -> None:
"""Stream callback forwarding the child's reply text up the progress relay so gateway watch windows mirror it
live (subagent.text → message.delta). Inert under CLI/TUI: their progress handlers ignore non-tool events."""
if delta:
_safe_progress(self.child_progress_cb, "subagent.text", preview=delta)
def attach_worktree(self, entry_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Inspect + prune the child worktree, reporting into the entry (no-op without isolation)."""
info = self.worktree_info
if info is None:
return entry_dict
from tools import subagent_worktree
try:
entry_dict["worktree"] = subagent_worktree.finalize_subagent_worktree(info)
except Exception as e:
# State is unknown: emit the SAME flagged schema the parent expects,
# via the shared factory so the two producers never drift.
logger.warning("worktree finalize failed: %s", e)
entry_dict["worktree"] = subagent_worktree.unproven_worktree_payload(info, f"finalize raised: {e}")
return entry_dict
def seed_workspace(self) -> None:
"""Seed cwd/container aliases and optional worktree isolation for the child;
``goal`` is extended with the worktree contract note when isolation engaged."""
import uuid as _uuid
self.child_task_id = self.subagent_id or f"subagent-{self.task_index}-{_uuid.uuid4().hex[:8]}"
self.parent_task_id = getattr(self.parent_agent, "_current_task_id", None)
# Seed the child's cwd record from the parent's: same starting directory,
# but the child's later `cd`s stay in its own record. Per-session container
# isolation keys containers by task_id; the child must share the PARENT's.
with _quiet("Child cwd seed failed: %s"):
from tools.terminal_tool import get_session_cwd, record_session_cwd, register_container_alias
record_session_cwd(self.child_task_id, get_session_cwd(self.parent_task_id))
register_container_alias(self.child_task_id, self.parent_task_id)
self.worktree_info = _create_isolated_worktree(self.parent_agent, self.parent_task_id, self.subagent_id)
if self.worktree_info is not None:
with _quiet("worktree cwd seed failed: %s"):
from tools.terminal_tool import record_session_cwd as _rsc
_rsc(self.child_task_id, self.worktree_info["path"])
# The child's context is already built; carry the isolation contract on
# the goal message instead (same turn, no system-prompt mutation).
from tools.subagent_worktree import build_worktree_context_note
self.goal = self.goal + build_worktree_context_note(self.worktree_info)
self.wall_start = time.time()
self.parent_reads_snapshot = list(file_state.known_reads(self.parent_task_id)) if self.parent_task_id else []
def finish_failed(
self, entry: Dict[str, Any], late_steer: Optional[str], *, preview: str, summary: str = "", status: Optional[str] = None,
) -> Dict[str, Any]:
"""Shared tail of every failure path: emit ``subagent.complete`` (``status`` defaults to the entry's), note
the steer text that won the race with the failure, report the worktree."""
_safe_progress(
self.child_progress_cb, "subagent.complete", preview=preview, status=status or entry["status"],
duration_seconds=entry["duration_seconds"], summary=summary,
)
_append_missed_steer(entry, late_steer)
return self.attach_worktree(entry)
def close_steering(self) -> Optional[str]:
"""Close steer acceptance (see ``_merge_late_steer``); returns late steer text, if any."""
return _close_subagent_steering(self.subagent_id, self.child) if self.subagent_id else None
def await_child(self) -> tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], bool]:
"""Run the child's conversation on a daemon worker: ``(result, None, False)`` or ``(None, error_entry,
close_deferred)`` on timeout/exception.
Hard timeout is off by default (``result(timeout=None)``; stuck children are the heartbeat's job). Daemon
worker: an abandoned timed-out child on a non-daemon thread would block interpreter exit at atexit join. The
worker installs a non-interactive approval callback (deny/approve per delegation.subagent_auto_approve) so
dangerous-command prompts never fall back to ``input()`` and deadlock the parent TUI. On failure: steer
acceptance closes BEFORE the stop signal (a concurrent steer is drained into the entry or rejected, never
lost); a 0-API-call timeout gets a diagnostic dump; a worker that still owns the child gets ``child.close()``
via a Future done-callback (``close_deferred=True``) — closing here would race its still-unwinding finally
path.
"""
from tools.delegate_tool import (_get_child_timeout, _get_subagent_approval_callback, _set_subagent_approval_cb)
from tools.daemon_pool import DaemonThreadPoolExecutor
child, task_index = self.child, self.task_index
child_timeout = _get_child_timeout()
executor = DaemonThreadPoolExecutor(
max_workers=1, initializer=_set_subagent_approval_cb, initargs=(_get_subagent_approval_callback(),),
)
# Worker thread handle so the timeout diagnostic can dump its stack.
worker_thread_holder: Dict[str, Optional[threading.Thread]] = {"t": None}
# Resolved after seed_workspace so a multimodal goal's text part carries the worktree note too.
_images = list(getattr(child, "_delegate_images", None) or [])
user_message: Any = _build_child_goal_message(self.goal, _images, child) if _images else self.goal
def _run_with_thread_capture():
worker_thread_holder["t"] = threading.current_thread()
from agent.delegation_context import delegated_child_context
with delegated_child_context(str(getattr(child, "session_id", "") or "")):
return child.run_conversation(
user_message=user_message, task_id=self.child_task_id, stream_callback=self.relay_text,
)
future = executor.submit(contextvars.copy_context().run, _run_with_thread_capture)
try:
return future.result(timeout=child_timeout), None, False
except Exception as wait_exc:
exc: BaseException = wait_exc # ``as`` targets are unbound after the except block
finally:
# Shut down without waiting — a child stuck on blocking I/O would hang wait=True forever.
executor.shutdown(wait=False)
_late_pending_steer = self.close_steering()
_signal_child_stop(child)
is_timeout = isinstance(exc, (FuturesTimeoutError, TimeoutError))
duration = self.elapsed()
logger.warning("Subagent %d %s after %.1fs", task_index, "timed out" if is_timeout else f"raised {type(exc).__name__}", duration)
child_api_calls = 0
with _quiet(None):
child_api_calls = int(child.get_activity_summary().get("api_call_count", 0) or 0)
# A timeout BEFORE any API call is a black box without a diagnostic dump.
before_first_call = is_timeout and child_api_calls == 0
diagnostic_path: Optional[str] = None
if before_first_call:
diagnostic_path = _dump_subagent_timeout_diagnostic(
child=child, task_index=task_index,
# is_timeout implies a cap was configured (result(timeout=None)
# never raises FuturesTimeoutError); guard for the type checker.
timeout_seconds=float(child_timeout or 0.0), duration_seconds=float(duration),
worker_thread=worker_thread_holder.get("t"), goal=self.goal,
)
if diagnostic_path:
logger.warning("Subagent %d 0-API-call timeout — diagnostic written to %s", task_index, diagnostic_path)
if not is_timeout:
_err = str(exc)
elif before_first_call:
_err = (
f"Subagent timed out after {child_timeout}s without making any API call — the child never reached its "
f"first LLM request (prompt construction, credential resolution, or transport may be stuck)."
)
else:
_err = (
f"Subagent timed out after {child_timeout}s with {child_api_calls} API call(s) completed — likely "
f"stuck on a slow API call, tool call, or unresponsive network request."
)
if diagnostic_path:
_err += f" Diagnostic: {diagnostic_path}"
status = "timeout" if is_timeout else "error"
_error_entry = {
"task_index": task_index, "status": status, "summary": None, "error": _err, "exit_reason": status,
"api_calls": child_api_calls, "duration_seconds": duration,
"timeout_seconds": child_timeout if is_timeout else None,
"timed_out_after_seconds": duration if is_timeout else None,
"timeout_phase": "before_first_llm_call" if before_first_call else "after_llm_calls" if is_timeout else None,
"_child_role": getattr(child, "_delegate_role", None),
"diagnostic_path": diagnostic_path,
}
self.finish_failed(_error_entry, _late_pending_steer, preview=f"Timed out after {duration}s" if is_timeout else str(exc))
close_deferred = is_timeout and not future.done()
if close_deferred:
_defer_close_after_timeout(child, future)
return None, _error_entry, close_deferred
def append_sibling_write_reminder(self, entry: Dict[str, Any]) -> None:
"""Warn the parent when this child wrote files the parent had already read. Checks writes by ANY non-parent
task_id (not just this child's) so nested orchestrator→worker chains are covered too."""
if not (self.parent_task_id and self.parent_reads_snapshot):
return
with _quiet("file_state sibling-write check failed", exc_info=True):
sibling_writes = file_state.writes_since(self.parent_task_id, self.wall_start, self.parent_reads_snapshot)
mod_paths = sorted({p for paths in sibling_writes.values() for p in paths}) if sibling_writes else []
if not mod_paths:
return
reminder = (
"\n\n[NOTE: subagent modified files the parent previously read — re-read before editing: "
+ ", ".join(mod_paths[:8])
+ (f" (+{len(mod_paths) - 8} more)" if len(mod_paths) > 8 else "")
+ "]"
)
if entry.get("summary"):
entry["summary"] = entry["summary"] + reminder
else:
entry["stale_paths"] = mod_paths
def account_background_processes(self, entry: Dict[str, Any]) -> None:
"""Name the child's background processes on the result BEFORE ``cleanup`` kills them: handed-off ones now
belong to the parent (their completion lands in the parent's chat); anything else still running is about to be
terminated, and the parent must hear that from the runtime rather than trust a child's "watcher running"."""
handed = list(getattr(self.child, "_handed_off_processes", None) or [])
if handed:
entry["handed_off_processes"] = handed
with _quiet(None):
from tools.process_registry import process_registry, _output_tail
leftover = process_registry.running_owned_by(self.child_task_id)
if leftover:
entry["orphaned_processes"] = [
{"session_id": s.id, "command": s.command[:200], "runtime_seconds": round(time.time() - s.started_at)}
for s in leftover]
unread = process_registry.unread_completions_owned_by(self.child_task_id)
if unread:
entry["unread_completions"] = [
{"session_id": s.id, "command": s.command[:200], "exit_code": s.exit_code,
"output_tail": _output_tail(s, 600)} for s in unread]
def emit_complete(self, result: Dict[str, Any], entry: Dict[str, Any], duration: float) -> None:
"""Fire ``subagent.complete`` with the per-branch observability payload (tokens, cost, files touched,
tool-output tail); every field is optional and degrades gracefully on the client."""
if not self.child_progress_cb:
return
child = self.child
summary = entry["summary"]
_files_read: list = []
with _quiet(None):
_files_read = list(file_state.known_reads(self.child_task_id))[:40]
_files_written_map: dict = {}
with _quiet(None):
_files_written_map = file_state.writes_since("", self.wall_start, []) # all writes since wall_start
complete_kwargs: Dict[str, Any] = {
"preview": summary[:160] if summary else entry.get("error", ""),
"status": entry["status"],
"duration_seconds": duration,
"summary": summary[:500] if summary else entry.get("error", ""),
"input_tokens": _num(getattr(child, "session_prompt_tokens", 0)),
"output_tokens": _num(getattr(child, "session_completion_tokens", 0)),
"reasoning_tokens": _num(getattr(child, "session_reasoning_tokens", 0)),
"api_calls": _num(entry["api_calls"]),
"files_read": _files_read,
"files_written": sorted({p for tid, paths in _files_written_map.items() if tid == self.child_task_id for p in paths})[:40],
"output_tail": _extract_output_tail(result, max_entries=8, max_chars=600),
}
if entry.get("failure_reason"):
# Classified verdict rides the event so every surface glosses the failure the same way.
complete_kwargs["failure_reason"] = entry["failure_reason"]
_cost_usd = getattr(child, "session_estimated_cost_usd", None)
if _cost_usd is not None:
with _quiet(None):
complete_kwargs["cost_usd"] = float(_cost_usd)
_safe_progress(self.child_progress_cb, "subagent.complete", **complete_kwargs)
def cleanup(self, *, heartbeat: _Heartbeat, child_pool: Any, leased_cred_id: Any, close_deferred: bool) -> None:
"""Finally-path teardown (idempotent, never raises). Order matters: stop heartbeat → drop registry entry →
release credential lease → restore the parent's process-global tool names → detach from the parent's
interrupt list → close the child (unless a timed-out worker still owns it) → pop the child's Relay scope if
no turn is active."""
child = self.child
heartbeat.stop()
# Safe even if the child was never registered (ID missing on test doubles).
if self.subagent_id:
_unregister_subagent(self.subagent_id, agent=child)
if child_pool is not None and leased_cred_id is not None:
with _quiet("Failed to release credential lease: %s"):
child_pool.release_lease(leased_cred_id)
# Restore the parent's tool names so the process-global is correct for
# any subsequent execute_code calls or other consumers.
import model_tools
saved_tool_names = getattr(child, "_delegate_saved_tool_names", None)
if isinstance(saved_tool_names, list):
model_tools._last_resolved_tool_names = list(saved_tool_names)
_detach_child(self.parent_agent, child)
# Close tool resources (terminal sandboxes, browser daemons, background
# processes, httpx clients) so subagent subprocesses don't outlive the delegation.
if not close_deferred:
_close_child(child, "Failed to close child agent after delegation")
# The child's execute_code kernels live exactly as long as the child (pinned against the LRU
# cap while it runs); dispose them here so they never squat the cap after the child is gone.
with _quiet("Failed to dispose child execute_code kernels: %s"):
from tools.code_kernel import shutdown_kernels_for_delegated_child
shutdown_kernels_for_delegated_child(str(getattr(child, "session_id", "") or ""))
# The AIAgent turn boundary normally closes the child scope itself. This fallback covers failures before that
# boundary starts, but must not pop a scope while a timed-out child worker is still unwinding.
with _quiet("Failed to close child Relay session after delegation"):
from agent import relay_runtime
runtime = relay_runtime.get_runtime(create=False)
child_session_id = str(getattr(child, "session_id", "") or "")
child_turn_is_active = relay_runtime.SESSION_COORDINATOR.has_active_turn(
profile_key=relay_runtime.current_profile_key(), session_id=child_session_id,
)
if runtime is not None and child_session_id and not child_turn_is_active:
runtime.unregister_subagent({"child_session_id": child_session_id})