Files
hermes-agent/tools/delegate_tool.py
T
Victor Kyriazakos cf6623e433 fix(gateway): resolve delegation mode from configured interim and keep the pinned mode across refreshes
A scheduled heartbeat muted interim messages by rewriting the turn's display
settings, and the agent build read that muted value to freeze the session's
delegation mode. Heartbeats run under the user's session key, so on a cold
agent cache a heartbeat built (and cached, and pinned) an inline agent for an
interim-on chat, and the user's later turns ran inline. The heartbeat rewrite
no longer touches interim: TurnRunner already drops interim messages for a
heartbeat turn, and the TurnContext keeps the chat's configured value, which
is what a fresh agent's delegation.gateway_mode reads.

A fresh agent restored its session's tools[] pin (schema bytes) but kept the
build-time inline flag, so the next rebuild from fresh definitions
(compaction boundary, /reload-mcp, Bot Chat refresh) re-derived the other
variant and flipped the chat mid-conversation. restore_agent_tool_prefix now
re-freezes the flag from the restored delegate_task, making the pin
authoritative.

Docs: inline turns past the notify interval still get the long-running
notice (display.long_running_notifications); the stability sentence now
names compaction, /reload-mcp and heartbeats, and the post-update exception;
a deferred delegate_task (tool_search.defer) stays background.

Tests: heartbeat-first turn with an agent cache (interim on stays
background, control, interim off stays inline), a composed cell with agent
cache + real SessionDB pin + heartbeat + restart, and pin restore followed by
a compaction or /reload-mcp refresh in both directions.
2026-10-28 19:35:16 -07:00

934 lines
53 KiB
Python

#!/usr/bin/env python3
"""
Delegate Tool -- Subagent Architecture
Spawns child AIAgent instances with a fresh conversation, their own task_id
(terminal session, file-ops cache), the parent's toolsets minus child-blocked
tools, and a focused system prompt built from goal + context. Single-task and
batch (parallel) modes; top-level model calls run in the background while
orchestrator children wait for their own workers. The parent only ever sees
the delegation call and the summary result, never the child's intermediate
tool calls or reasoning.
"""
import logging
import time
import weakref
from pathlib import Path
from typing import Any, Dict, List, Optional
from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb
from utils import is_truthy_value
logger = logging.getLogger(__name__)
# The delegate_tool_* siblings hold the pieces split out of this module; every name callers or patching tests reach as
# ``tools.delegate_tool.<name>`` is re-imported here. Mutable flag globals live only in their owning module.
from tools.delegate_tool_child_run import (
_ChildRun, _attach_child, _build_child_goal_message, _close_child, _detach_child, _build_result_entry, _dump_subagent_timeout_diagnostic, _fabricated_entry,
_lease_child_credential, _merge_late_steer, _register_child, _start_heartbeat, _validate_child_output_schema,
)
from tools.delegate_tool_config import (
_DEFAULT_MAX_CONCURRENT_CHILDREN, _get_child_timeout, _get_max_async_children, _get_max_concurrent_children,
_get_max_spawn_depth, _get_oneshot_max_children, _get_orchestrator_enabled, _get_subagent_approval_callback, _get_worktree_isolation,
_inherit_parent_capabilities, _load_config, _merge_request_overrides, _resolve_child_credential_pool,
_resolve_child_runtime, _resolve_delegation_credentials,
_subagent_auto_approve, _subagent_auto_deny,
)
from tools.delegate_tool_dispatch import _Batch, _announce_batch, _capture_origin, _run_batch
from tools.delegate_tool_progress import (
DelegateEvent, SUBAGENT_FAILURE_STATUSES, _batch_prefix, _build_child_progress_callback,
_build_child_system_prompt, _clean_error_text, _emit_parent_console, _quiet, _resolve_workspace_hint,
_safe_progress, format_batch_tag, format_subagent_failure_line,
)
from tools.delegate_tool_registry import (
_CONTROL_ACTIONS, _active_subagents, _active_subagents_lock, _capture_gateway_steer_authority,
_handle_control_action, _is_descendant_of, _owns_subagent_record, _register_subagent, _unregister_subagent,
get_subagent_attribution, interrupt_subagent, is_spawn_paused, list_active_subagents, set_spawn_paused,
steer_subagent,
)
from tools.delegate_tool_tasks import (
_MAX_TASK_IMAGES, _coerce_task_images, _coerce_task_schemas, _normalize_task_images, _normalize_task_list,
)
from tools.delegate_tool_tuning import pin_child_service_tier
from tools.delegate_tool_toolsets import (
DELEGATE_BLOCKED_TOOLS, _expand_parent_toolsets, _resolve_child_toolsets, _strip_blocked_tools,
)
from tools.delegate_tool_results import (
_apply_summary_budget, _build_child_preserving_parent_tools, _run_child_lifecycle, _summarize_tool_arguments,
)
_ROLES = frozenset({"leaf", "orchestrator"})
def _parent_live_home(parent_agent: Any) -> Optional[Path]:
"""Resolve the live transcripts' profile home from parent-owned state.
The parent's per-profile SessionDB sits directly under its profile home
(``<home>/state.db``), so the db path's parent IS the home. Returns None
when the parent exposes no usable SessionDB — the caller then falls back
to the ambient resolve, which is exactly the #91996 failure mode, so the
skip is logged rather than silent.
"""
parent_db = getattr(getattr(parent_agent, "_session_db", None), "db_path", None)
# Concrete str/Path only — NOT the os.PathLike protocol: MagicMock (and any
# duck-typed test double) registers __fspath__ and so IS PathLike, which is
# how a Mock "home" slipped through and transcripts landed at
# str(<MagicMock>) paths (PR #131931 side-effect screen).
if isinstance(parent_db, (str, Path)):
return Path(parent_db).parent
if parent_db is not None:
logger.debug(
"delegate_task: parent _session_db.db_path is %r (not a str/Path); "
"live-transcript home pinning skipped, falling back to ambient "
"HERMES_HOME resolve (transcripts may land in a different profile, #91996)",
parent_db,
)
return None
logger.warning(
"delegate_task: parent agent exposes no _session_db; live-transcript "
"home pinning skipped, falling back to ambient HERMES_HOME resolve "
"(transcripts may land in a different profile, #91996)"
)
return None
# Nested delegation is granted by depth/role in _build_child_agent, never by the
# model naming toolsets (there is no model-facing toolsets argument).
def _normalize_role(r: Optional[str]) -> str:
"""'leaf' | 'orchestrator'; None/empty/unknown -> 'leaf' (unknown warns)."""
r_norm = str(r).strip().lower() if r else "leaf"
if r_norm not in _ROLES:
logger.warning("Unknown delegate_task role=%r, coercing to 'leaf'", r)
return "leaf"
return r_norm
DEFAULT_MAX_ITERATIONS = 250
_HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegation
# Stale-heartbeat thresholds (cycles of _HEARTBEAT_INTERVAL with no progress). Progress = iteration, current_tool OR
# last_activity_ts advancing; an in-flight model wait refreshes last_activity_ts, so slow models are not "idle". Idle
# stays tight so a truly wedged child doesn't mask the gateway timeout; in-tool is much higher so legitimately long
# tools can finish.
_HEARTBEAT_STALE_CYCLES_IDLE = 15 # 450s idle between turns → stale
_HEARTBEAT_STALE_CYCLES_IN_TOOL = 40 # 1200s stuck on same tool → stale
# After the stale verdict ends the wait, keep polling the worker this long for its real
# result (#113222): a child that already wrote its final answer often finishes unwinding a
# moment later, and that recorded result must be collected instead of a synthesized timeout.
_STALE_RESULT_GRACE_SECONDS = 2.0
def check_delegate_requirements() -> bool:
"""Delegation has no external requirements -- always available."""
return True
def _open_child_session_db(parent_agent) -> Any:
"""DEDICATED SessionDB handle for the child, or None: the parent's handle can be closed by its own lifecycle while
a background child still flushes (transcript silently dropped). It MUST open the same db FILE as the parent's
handle (non-launch profiles), else lineage / session_search break; released by the child's close() via
_owns_session_db."""
# Each child gets a DEDICATED SessionDB connection instead of the parent's live object. The parent's
# handle is owned by the parent's lifecycle (cron run_job's finally block, gateway session end, /new)
# and can be closed while a fire-and-forget background child is still flushing on a daemon thread —
# every subsequent flush then hits the closed handle and the child's transcript is silently dropped
# (#81267). It MUST point at the same database FILE as the parent's handle: parents can hold non-default
# per-profile handles (tui_gateway opens SessionDB(db_path=<profile>/ state.db) for non-launch
# profiles), and a bare SessionDB() would write the child's transcript into the launch profile's db,
# breaking parent_session_id lineage and session_search. AsyncSessionDB wrappers (gateway) forward
# .db_path via __getattr__, so this works through them.
from agent.runtime_session_store import RuntimeSessionStore, WorkerPersistenceError, is_worker_process
parent_session_db = getattr(parent_agent, "_session_db", None)
if is_worker_process() or isinstance(parent_session_db, RuntimeSessionStore):
# Missing db_path on a scoped store is NOT permission to open the launch profile: the
# owner reserves the child's row and execution under the parent's own (#141793).
if not isinstance(parent_session_db, RuntimeSessionStore):
raise WorkerPersistenceError('worker_child_registration_required')
from agent.runtime_session_child import reserve_child_store
return reserve_child_store(parent_session_db)
if parent_session_db is None:
return None
with _quiet("subagent: failed to open dedicated SessionDB; child persistence disabled", exc_info=True):
from hermes_state_registry import acquire
_parent_db_path = getattr(parent_session_db, "db_path", None)
return acquire(_parent_db_path) if _parent_db_path is not None else acquire()
return None
def _apply_child_cache_ttl(child) -> None:
"""A delegated child never uses the 1h cache tier. The tier is priced for a person who steps
away between turns (2x write vs 1.25x for 5m, #14971); a subagent calls every few seconds for
minutes and is gone, so it pays the 2x on every tool result and never collects the retention.
Caching itself stays exactly as configured (disabled stays disabled)."""
if getattr(child, "_cache_ttl", None) == "1h":
child._cache_ttl = "5m"
_CHILD_CAP_MIN = 16_000 # below this a child compresses on every call; treat as a config error
def _child_compression_cap_tokens(raw) -> int | None:
"""Validated ``delegation.compression_threshold_tokens``: an int >= 16000, or None for "no cap".
Unset / ``0`` / ``false`` / ``null`` mean no subagent-specific cap: the child compacts at the
same ratio trigger as everyone else (0.50 x window). A bool ``true`` (YAML) would coerce to 1
and make every call compress; a string like ``"200k"`` would silently read as no cap. Both are
config errors: warn and treat as unset so a typo never changes compaction behaviour."""
if raw is None or raw is False or raw == 0:
return None
if isinstance(raw, bool) or not isinstance(raw, (int, float)) or int(raw) < _CHILD_CAP_MIN:
logger.warning(
"delegation.compression_threshold_tokens=%r is not a token count >= %d; ignoring it "
"(children keep the ratio trigger).", raw, _CHILD_CAP_MIN,
)
return None
return int(raw)
def _apply_child_compression_cap(child, delegation_cfg: dict) -> None:
"""Optional absolute cap on the child's compaction trigger, ``delegation.compression_threshold_tokens``
(lower of it and any global ``compression.threshold_tokens``). Off by default: a 1M-window child
compacts where its parent does. The compressor applies the cap on first window resolution, which
happens after construction, so setting it here is exactly equivalent to config."""
from agent.context_compressor import ContextCompressor
cc = getattr(child, "context_compressor", None)
if not isinstance(cc, ContextCompressor):
return
cap = _child_compression_cap_tokens((delegation_cfg or {}).get("compression_threshold_tokens"))
if cap is None:
return
existing = cc.threshold_tokens_cap
cc.threshold_tokens_cap = min(cap, existing) if isinstance(existing, int) and existing > 0 else cap
if cc._threshold_tokens is not None: # already resolved: re-clamp now
cc._apply_threshold_tokens_cap()
def _build_child_agent(
task_index: int,
goal: str,
context: Optional[str],
toolsets: Optional[list[str]],
model: Optional[str],
max_iterations: int,
task_count: int,
parent_agent,
# Credential overrides from delegation config
override_provider: Optional[str] = None,
override_base_url: Optional[str] = None,
override_api_key: Optional[str] = None,
override_api_mode: Optional[str] = None,
override_request_overrides: Optional[dict[str, Any]] = None,
# ACP transport overrides from trusted delegation config.
override_acp_command: Optional[str] = None,
override_acp_args: Optional[list[str]] = None,
# Configuration block that owns the selected provider/model route. Internal
# callers such as /review pass auxiliary.review here so fallback policy is
# not accidentally read from the general delegation block.
routing_cfg: Optional[dict[str, Any]] = None,
# Legacy; accepted for wire compat but ignored (capability is depth-derived).
role: str = "leaf",
# Per-task route from the delegation provider (tools/delegate_tool_routes.py): a reasoning
# effort that beats delegation.reasoning_effort, and the profile home the child runs as.
override_reasoning_effort: Any = None,
profile_home: Optional[Path] = None,
override_service_tier: Optional[str] = None, # a route's checked speed tier (tools/delegate_tool_tuning.py)
):
"""Build (don't run) a child AIAgent on the main thread. override_* (from delegation config) replace parent
inheritance so children can run on a different provider:model pair. ``profile_home`` builds (and later runs,
see ``_ChildRun.await_child``) the child inside that profile's scope; it never shares the parent's pool."""
import uuid as _uuid
from run_agent import AIAgent
from agent.delegation_context import delegated_child_context
from tools.delegate_tool_routes import delegated_profile_scope
# Role is depth-derived: a child may delegate iff the kill switch is on and
# depth budget remains below max_spawn_depth. The `role` arg is ignored.
child_depth = getattr(parent_agent, "_delegate_depth", 0) + 1
max_spawn = _get_max_spawn_depth()
effective_role = "orchestrator" if _get_orchestrator_enabled() and child_depth < max_spawn else "leaf"
# One subagent_id shared by the progress callback, spawn_requested event and
# the live registry; parent_id is set when THIS parent is itself a subagent.
subagent_id = f"sa-{task_index}-{_uuid.uuid4().hex[:8]}"
parent_subagent_id = getattr(parent_agent, "_subagent_id", None)
# General delegation behavior (reasoning, compression, capabilities) stays
# global. Only fallback policy follows the owner of a per-call route such
# as auxiliary.review.
delegation_cfg = _load_config()
child_toolsets, child_disabled_toolsets = _resolve_child_toolsets(parent_agent, toolsets, effective_role)
child_prompt = _build_child_system_prompt(
goal, context, workspace_path=_resolve_workspace_hint(parent_agent), role=effective_role,
max_spawn_depth=max_spawn, child_depth=child_depth,
)
parent_api_key = getattr(parent_agent, "api_key", None)
if (not parent_api_key) and hasattr(parent_agent, "_client_kwargs"):
parent_api_key = parent_agent._client_kwargs.get("api_key")
# Shared ref: session_id once the child exists, delegation_id once
# delegate_task stamps it — both ride on every relayed event.
child_session_ref: dict[str, Any] = {}
child_progress_cb = _build_child_progress_callback(
task_index, goal, parent_agent, task_count, subagent_id=subagent_id, parent_id=parent_subagent_id,
depth=max(0, child_depth - 1), # 0 = first-level child for the UI
model=model or getattr(parent_agent, "model", None), toolsets=child_toolsets, session_ref=child_session_ref,
)
with delegated_profile_scope(profile_home):
rt = _resolve_child_runtime(
parent_agent, delegation_cfg, parent_api_key, model=model, override_provider=override_provider,
override_base_url=override_base_url, override_api_key=override_api_key, override_api_mode=override_api_mode,
override_acp_command=override_acp_command,
override_acp_args=override_acp_args,
routing_cfg=routing_cfg, override_reasoning_effort=override_reasoning_effort,
)
if override_service_tier:
rt["service_tier"] = override_service_tier
if override_request_overrides is not None:
# honored whenever set, incl. the inherit branch where
# _resolve_delegation_credentials already merged OVER the parent's
request_overrides = dict(override_request_overrides)
else:
request_overrides = {} if override_provider else dict(getattr(parent_agent, "request_overrides", {}) or {})
parent_sid = getattr(parent_agent, "session_id", None)
child_session_db = _open_child_session_db(parent_agent)
with delegated_child_context():
try:
child = AIAgent(
**rt, max_iterations=max_iterations, prefill_messages=getattr(parent_agent, "prefill_messages", None),
enabled_toolsets=child_toolsets, disabled_toolsets=child_disabled_toolsets, quiet_mode=True,
ephemeral_system_prompt=child_prompt, log_prefix=f"[subagent-{task_index}]", platform="subagent",
side_agent=True,
skip_context_files=True, skip_memory=True, clarify_callback=None,
thinking_callback=(
(lambda text: _safe_progress(child_progress_cb, "_thinking", text) if text else None)
if child_progress_cb else None
),
session_db=child_session_db, parent_session_id=parent_sid, request_overrides=request_overrides,
session_id=getattr(child_session_db, "reserved_session_id", None),
tool_progress_callback=child_progress_cb,
iteration_budget=None, # fresh budget per subagent
# A profile route carries that profile's identity (its SOUL.md under the bound home).
**({"load_soul_identity": True} if profile_home is not None else {}),
)
except BaseException:
# No child close() will ever run: release the dedicated handle here.
if child_session_db is not None:
with _quiet(None):
from hermes_state_registry import release_or_close
release_or_close(child_session_db)
raise
# Shared pool lets children rotate credentials on rate limits. A profile child loads ITS profile's pool
# (inside the scope) and never shares the launching parent's.
child_pool = _resolve_child_credential_pool(
rt["provider"], None if profile_home is not None else parent_agent, rt["base_url"],
effective_requested_provider=rt.get("requested_provider"),
)
child._print_fn = getattr(parent_agent, "_print_fn", None)
_apply_child_cache_ttl(child)
if child_session_db is not None:
child._owns_session_db = True # released by the child's close(), never by the parent
# Ownership transfer for the dedicated handle: the child's close() must release it (nothing else holds a
# reference), and no parent teardown can close it out from under a background child (#81267).
child_session_ref["session_id"] = getattr(child, "session_id", "") or ""
child._progress_identity_ref = child_session_ref
child._delegate_depth, child._delegate_role = child_depth, effective_role # post-degrade role
child._subagent_id, child._parent_subagent_id = subagent_id, parent_subagent_id
_apply_child_compression_cap(child, delegation_cfg)
# Ownership chain for action=list/steer/stop; weakref so a finished parent
# can be collected while a detached child record lingers in the registry.
try:
child._delegate_parent_ref = weakref.ref(parent_agent)
except TypeError:
child._delegate_parent_ref = None # non-weakref-able test doubles
# Sidebar marker: subagent sessions stay out of session pickers even when a
# parent delete orphans them (mirrors /branch's ``_branched_from``).
if parent_sid and getattr(child, "_session_init_model_config", None) is not None:
child._session_init_model_config["_delegate_from"] = parent_sid
child._delegate_profile_home = profile_home # _ChildRun.await_child runs the conversation in this scope
if child_pool is not None:
child._credential_pool = child_pool
_attach_child(parent_agent, child) # interrupt propagation
# spawn_requested now — the child may queue for seconds when the pool is
# saturated — then the subagent_start lifecycle hook.
_safe_progress(child_progress_cb, "subagent.spawn_requested", preview=goal)
with _quiet("subagent_start hook invocation failed", exc_info=True):
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"subagent_start", parent_session_id=parent_sid,
parent_turn_id=getattr(parent_agent, "_current_turn_id", "") or "", parent_subagent_id=parent_subagent_id,
child_session_id=getattr(child, "session_id", None), child_subagent_id=subagent_id,
child_role=effective_role, child_goal=goal,
)
return child
def _run_single_child(
task_index: int, goal: str, child=None, parent_agent=None, *, owner_session_id: Optional[str] = None,
owner_transport: Any = None, owner_session_record: Any = None, **_kwargs,
) -> dict[str, Any]:
"""Run a pre-built child agent (called from a worker thread) and return its result entry.
Contract, derived from the child's structured completion fields:
status ∈ {completed, interrupted, failed} — a structured failure
(failed=True / non-empty error) or an invalid terminal state
is "failed" even when a summary exists.
exit_reason ∈ {completed, max_iterations, interrupted, error} —
"max_iterations" only for genuine budget exhaustion
(completed=False with no failure fields), never for errors.
truncated == (exit_reason == "max_iterations").
* ``"completed"`` — normal finish. See #97655.
"""
child_progress_cb = getattr(child, "tool_progress_callback", None)
child_pool, leased_cred_id = _lease_child_credential(child)
# Heartbeat keeps the parent's _last_activity_ts moving so the gateway inactivity timeout doesn't fire while the
# child works; once the child looks stale (see _HEARTBEAT_STALE_CYCLES_*) it also ends await_child's wait.
heartbeat = _start_heartbeat(child, parent_agent, task_index)
# TUI/RPC registry entry (kill/pause/status by subagent_id); None for test
# doubles without a stable id. Unregistered in the finally block.
_subagent_id = _register_child(
child, parent_agent, goal, owner_session_id=owner_session_id, owner_transport=owner_transport,
owner_session_record=owner_session_record,
)
run = _ChildRun(child, parent_agent, task_index, goal, _subagent_id, child_progress_cb, heartbeat=heartbeat)
# Set when a timed-out Future still owns the child: closing it from this
# thread before the worker settles races the conversation's finally path.
_child_close_deferred = False
try:
heartbeat.start()
_safe_progress(child_progress_cb, "subagent.start", preview=goal)
run.seed_workspace()
result, failure_entry, _child_close_deferred = run.await_child()
if failure_entry is not None:
return failure_entry
schema = _validate_child_output_schema(child, result, task_index, run.child_task_id, run.relay_text)
_merge_late_steer(result, _subagent_id, child)
# Flush any remaining batched progress to gateway
if child_progress_cb and hasattr(child_progress_cb, "_flush"):
with _quiet("Progress callback flush failed: %s"):
child_progress_cb._flush()
duration = run.elapsed()
entry = _build_result_entry(child, result, task_index, duration, schema)
run.append_sibling_write_reminder(entry)
run.account_background_processes(entry)
run.emit_complete(result, entry, duration)
return run.attach_worktree(entry)
except Exception as exc:
# Close steer acceptance before any completion callback (see _merge_late_steer).
_late_pending_steer = run.close_steering()
logging.exception(f"[subagent-{task_index}] failed")
# Entry status "error" (contract), progress event status "failed" (UI vocabulary).
return run.finish_failed(
_fabricated_entry(task_index, "error", str(exc), child, run.elapsed()), _late_pending_steer,
preview=str(exc), summary=str(exc), status="failed",
)
finally:
run.cleanup(heartbeat=heartbeat, child_pool=child_pool, leased_cred_id=leased_cred_id, close_deferred=_child_close_deferred)
def _credential_overrides(creds: dict[str, Any], routing_cfg: dict[str, Any]) -> dict[str, Any]:
"""``_build_child_agent`` override kwargs for a resolved credential bundle (delegate_task and the lifecycle API)."""
return {
"override_provider": creds["provider"], "override_base_url": creds["base_url"],
"override_api_key": creds["api_key"], "override_api_mode": creds["api_mode"],
"override_request_overrides": creds.get("request_overrides"),
"override_acp_command": creds.get("command"),
"override_acp_args": creds.get("args"),
"override_service_tier": creds.get("service_tier"),
"routing_cfg": routing_cfg,
}
def _route_overrides(route: Any) -> dict[str, Any]:
"""``_build_child_agent`` kwargs for one task route (``tools.delegate_tool_routes.TaskRoute``)."""
return {
**_credential_overrides(route.creds, route.routing_cfg),
"override_reasoning_effort": route.reasoning_effort, "profile_home": route.profile_home,
}
def _build_children(
task_list: list[dict[str, Any]], task_schemas: list[Optional[dict[str, Any]]], routes: list[Any], *,
top_role: str, max_iterations: int, parent_agent,
live_deleg_id: Optional[str], live_writers: list, task_images: Optional[list[Optional[list[str]]]] = None,
) -> tuple[list[tuple], Optional[str]]:
"""Build every child on the main thread (construction is not thread-safe), each on its task's route;
``(children, None)`` or ``([], error)`` on an explicit-pin preflight failure."""
from tools.delegation_live_log import wrap_progress_callback
from tools.delegation_output_schema import append_output_contract
children = []
for i, t in enumerate(task_list):
route = routes[i]
if route.error:
continue # refused by its backend: no child; its result entry is the refusal
_task_schema = task_schemas[i] if i < len(task_schemas) else None
_child_context = t.get("context")
if _task_schema is not None:
_child_context = append_output_contract(_child_context, _task_schema)
try:
child = _build_child_preserving_parent_tools(
task_index=i, goal=t["goal"], context=_child_context,
toolsets=None, # always inherit the parent's toolsets
model=route.creds["model"], max_iterations=max_iterations, task_count=len(task_list),
parent_agent=parent_agent, role=_normalize_role(t.get("role") or top_role), **_route_overrides(route),
)
except ValueError as exc:
# An owner refusal can land mid-batch: the siblings built so far hold reservations.
for _, _, built in children:
_detach_child(parent_agent, built)
_close_child(built, "Failed to close child after a refused sibling")
return [], str(exc)
pin_child_service_tier(child, route.creds)
if _task_schema is not None:
with _quiet("Could not attach output schema to child %d", i):
child._delegate_output_schema = _task_schema
# Validated per-task images; absent on image-less tasks, which keep the text-only goal turn.
_t_images = task_images[i] if task_images and i < len(task_images) else None
if _t_images:
with _quiet("Could not attach images to child %d", i):
child._delegate_images = _t_images
# Tee progress events into the live transcript (wrapper keeps the
# _flush contract and swallows writer failures).
_writer = live_writers[i] if i < len(live_writers) else None
if _writer is not None:
child.tool_progress_callback = wrap_progress_callback(getattr(child, "tool_progress_callback", None), _writer)
child._live_transcript_path = str(_writer.path)
if route.report() is not None:
child._delegate_route = route.report()
if live_deleg_id:
child._delegation_id = live_deleg_id
_ident_ref = getattr(child, "_progress_identity_ref", None)
if isinstance(_ident_ref, dict):
_ident_ref["delegation_id"] = live_deleg_id
children.append((i, t, child))
return children, None
def _routes_for(task_list: list[dict[str, Any]], cfg: dict[str, Any], credentials_cfg: Optional[dict[str, Any]],
parent_agent: Any) -> tuple[list[Any], Optional[str]]:
"""Per-task routes: an internal caller's explicit ``credentials_cfg`` (e.g. /review → auxiliary.review) owns
every task's route; otherwise the active delegation provider decides each one."""
from tools.delegate_tool_routes import resolve_task_routes, route_for_config
if credentials_cfg is None:
return resolve_task_routes(task_list, cfg, parent_agent)
try:
route = route_for_config(credentials_cfg, parent_agent)
except ValueError as exc:
return [], str(exc)
return [route] * len(task_list), None
def _settle_refused_transcripts(refused: list[dict[str, Any]], live_deleg_id: Optional[str], live_writers: list,
live_home: Any) -> None:
from tools.delegation_live_log import update_manifest_statuses
for entry in refused:
_writer = live_writers[entry["task_index"]] if entry["task_index"] < len(live_writers) else None
if _writer is not None:
with _quiet("Live transcript finalize failed", exc_info=True):
_writer.finalize(entry)
update_manifest_statuses(live_deleg_id, refused, home=live_home, completed=False)
def _oneshot_spawn_budget(parent_agent: Any, requested: int) -> Optional[str]:
"""Charge *requested* children against the finite one-shot session's total (delegation.oneshot_max_children);
the error text tells the model to do the work inline. Interactive and gateway sessions are never charged."""
from agent.oneshot_footprint import is_single_query_session
if not is_single_query_session():
return None
cap = _get_oneshot_max_children()
if cap <= 0:
return None
spent = getattr(parent_agent, "_oneshot_children_spawned", 0)
if spent + requested > cap:
return (
f"Delegation budget for this one-shot run is exhausted ({spent}/{cap} subagents used; "
f"delegation.oneshot_max_children). Do the remaining work yourself in this session — reviewing "
f"your own diff and running the tests inline is expected here, not a delegated review."
)
parent_agent._oneshot_children_spawned = spent + requested
return None
def delegate_task(
goal: Optional[str] = None, context: Optional[str] = None, tasks: Optional[list[dict[str, Any]]] = None,
max_iterations: Optional[int] = None, role: Optional[str] = None, background: Optional[bool] = None,
output_schema: Optional[dict[str, Any]] = None, images: Optional[list[str]] = None, action: Optional[str] = None,
subagent_id: Optional[str] = None, message: Optional[str] = None, parent_agent=None,
credentials_cfg: Optional[dict[str, Any]] = None,
) -> str:
"""Spawn child agents (single ``goal`` or ``tasks=[...]`` batch) or control running ones. ``action``
list/steer/stop run synchronously and bypass the pause gate, depth limit and async dispatch. ``role`` is legacy
(per-task beats top-level; capability is depth-derived). Returns JSON with one results entry per task, or a
dispatch handle when running in the background."""
if parent_agent is None:
return tool_error("delegate_task requires a parent agent context.")
normalized_action = (action or "").strip().lower()
if normalized_action in _CONTROL_ACTIONS:
return _handle_control_action(normalized_action, subagent_id, message, parent_agent)
if normalized_action and normalized_action != "spawn":
return tool_error(f"Unknown action '{action}'. Use spawn (default), list, steer, or stop.")
# Operator kill switch (TUI / delegation.pause RPC): blocks NEW spawns only.
if is_spawn_paused():
return tool_error(
"Delegation spawning is paused. Clear the pause via the TUI "
"(`p` in /agents) or the `delegation.pause` RPC before retrying."
)
top_role = _normalize_role(role)
# background applies to single tasks AND batches: a batch is ONE async unit
# that joins on every child and re-enters as a single consolidated message.
background = is_truthy_value(background, default=False) if background is not None else False
depth = getattr(parent_agent, "_delegate_depth", 0)
max_spawn = _get_max_spawn_depth()
if depth >= max_spawn:
return tool_error(
f"Delegation depth limit reached (depth={depth}, max_spawn_depth={max_spawn}). Raise "
f"delegation.max_spawn_depth in config.yaml if deeper nesting is required (no hard ceiling, but each level "
f"multiplies API cost)."
)
cfg = _load_config()
default_max_iter = cfg.get("max_iterations", DEFAULT_MAX_ITERATIONS)
# Caller-supplied max_iterations is ignored: the config value is authoritative
# so budgets stay predictable (kwarg kept for internal callers/tests).
if max_iterations is not None and max_iterations != default_max_iter:
logger.debug(
"delegate_task: ignoring caller-supplied max_iterations=%s; using delegation.max_iterations=%s from config",
max_iterations, default_max_iter,
)
max_children = _get_max_concurrent_children()
task_list, err = _normalize_task_list(goal, context, tasks, output_schema, top_role, max_children)
if not err:
task_schemas, err = _coerce_task_schemas(task_list, output_schema)
if not err:
task_images, err = _coerce_task_images(task_list, images)
if not err:
# One route per task from the active delegation provider (delegation.backend), or from credentials_cfg
# (internal callers only, e.g. /review → auxiliary.review: a per-call routing owner shaped like the
# delegation section, kept together with its fallback policy through child construction). Explicit-pin
# preflight failures (e.g. pinned delegation.command missing from PATH) refuse the spawn loudly (#80450).
routes, err = _routes_for(task_list, cfg, credentials_cfg, parent_agent)
if err:
return tool_error(err)
# Fail closed per task: a task its backend cannot route is refused alone (its entry carries the error); the call
# is a tool error only when no task can run.
runnable = [r for r in routes if not r.error]
if not runnable:
return tool_error(" ".join(dict.fromkeys(r.error for r in routes)))
refused = [r.refused_entry(i) for i, r in enumerate(routes) if r.error]
# Batch-level model/provider labels (transcript headers, completion block): blank when tasks took different
# routes, so each child reports what it actually runs on.
creds = runnable[0].creds if all(r.creds == runnable[0].creds for r in runnable) else dict.fromkeys(runnable[0].creds)
err = _oneshot_spawn_budget(parent_agent, len(runnable))
if err:
return tool_error(err)
overall_start = time.monotonic()
# Live transcripts: cache/delegation/live/<id>/task-<n>.log per task, a side channel with zero effect on message
# content or prompt caching. Best-effort: on failure live_paths is empty and delegation proceeds.
#
# The transcripts' profile home is resolved from stable parent-owned
# state (the parent's per-profile SessionDB path), NOT ambient
# get_hermes_dir(): this thread may have crossed a raw threading.Thread
# boundary that dropped the session's _HERMES_HOME_OVERRIDE ContextVar,
# and process-wide HERMES_HOME is unstable under concurrent
# multi-profile workers — either way transcripts could land in the
# wrong profile (#91996). state.db sits directly under the home, so
# its parent IS the home; None falls back to today's ambient resolve
# (with a warning — that fallback is exactly the #91996 failure mode).
_live_home = _parent_live_home(parent_agent)
from tools.delegation_live_log import create_live_transcripts
live_deleg_id, live_writers, live_paths = create_live_transcripts(
task_list, context, model=creds.get("model"), provider=creds.get("provider"),
home=_live_home,
)
if refused: # settled before anything runs: transcripts and manifest show the refusal (async units skip them)
_settle_refused_transcripts(refused, live_deleg_id, live_writers, _live_home)
_announce_batch(parent_agent, len(task_list), live_deleg_id)
origin = _capture_origin()
children, err = _build_children(
task_list, task_schemas, routes, top_role=top_role, max_iterations=default_max_iter, parent_agent=parent_agent,
live_deleg_id=live_deleg_id, live_writers=live_writers, task_images=task_images,
)
if err:
return tool_error(err)
batch = _Batch(
task_list, children, parent_agent, creds, context, top_role, max_children,
live_deleg_id, live_writers, live_paths, *origin, overall_start,
live_home=_live_home, refused=refused,
)
return _run_batch(batch, background)
# ── OpenAI function-calling schema ──────────────────────────────────────────
def _build_top_level_description(*, independent_completions=None) -> str:
"""delegate_task description: ONLY guidance stated nowhere else in the schema
(limits live in the 'tasks' parameter description, rebuilt per get_definitions())."""
try:
orchestration_available = _get_max_spawn_depth() >= 2 and _get_orchestrator_enabled()
except Exception:
orchestration_available = False
# Mention recursion only where it's actually available. send_message is deliberately not named (gateway-internal
# vocabulary); model_tools session-filters the list to tools the session has.
if orchestration_available:
restrictions_rule = (
"- Children cannot call clarify, memory, or cronjob.\n"
f"- Children can themselves delegate while depth remains (max_spawn_depth={_get_max_spawn_depth()}); the "
"runtime derives this from depth automatically.\n"
)
else:
restrictions_rule = "- Children cannot call delegate_task, clarify, memory, or cronjob.\n"
from tools.delegate_tool_config import _get_independent_completions
if independent_completions is None:
independent_completions = _get_independent_completions()
delivery = _DELIVERY_INDEPENDENT if independent_completions else _DELIVERY_JOINED
return _DESCRIPTION_HEAD.format(delivery=delivery) + restrictions_rule + _DESCRIPTION_TAIL
_DELIVERY_INDEPENDENT, _DELIVERY_JOINED = "each ungrouped task / `group` returns on its own", "one message per call"
_DESCRIPTION_DELIVERY = (
"Sessions without a later-result consumer (including one-shot CLI and cron) join parallel children "
"and return results in this tool call. "
"Otherwise runs in the background: dispatch returns live transcript paths and results re-enter "
"as a new message when subagents finish ({delivery}). Background results are delivered only "
"BETWEEN your turns: finish whatever does not depend on them, then give a one-line status and END YOUR TURN. Never "
"wait or poll on transcripts, artifact files, or CI for a child. "
"While children run, `action` (list/steer/stop) controls them live.\n\n"
)
_ONESHOT_DESCRIPTION_DELIVERY = "Parallel children are joined and their results return in this tool call.\n\n"
_DESCRIPTION_HEAD = (
"Spawn isolated subagents for parallel or context-heavy work. Each gets its own conversation, terminal "
"session, and toolset, and only its final summary returns to you. Pass every task in `tasks` — one entry spawns one subagent, several run in parallel "
"(limit in the tasks description).\n\n"
+ _DESCRIPTION_DELIVERY +
"USE FOR: reasoning-heavy subtasks, work that would flood your context, or independent parallel workstreams.\n"
"DO NOT USE FOR (use these instead):\n"
"- Mechanical multi-step work with no reasoning needed -> execute_code\n"
"- A single tool call -> call the tool directly\n"
"- Tasks needing user interaction -> subagents cannot ask questions\n"
"- Durable work that must survive this session -> cronjob or terminal(background=True, notify=True); /stop, /new, "
"or process exit halts running subagents (whole tree); each returns an 'interrupted' completion with partial output.\n\n"
"RULES:\n"
"- Children know nothing of this conversation: pass everything needed via 'context', including any required "
"output language, tone, or style (e.g. \"respond in Chinese\").\n"
"- Child summaries are SELF-REPORTS, not verified facts: a child claiming \"uploaded successfully\" or "
"\"file written\" may be wrong. For external side effects (uploads, remote writes, publishing), require a "
"verifiable handle (URL, ID, absolute path) and verify it yourself before telling the user the operation "
"succeeded.\n"
"- Children cannot close tracked work: a child asked to close it returns findings instead; "
"the parent applies the transition.\n"
)
_DESCRIPTION_TAIL = (
"- Children inherit the parent model unless pinned via delegation.provider / delegation.model in config.yaml."
)
def _build_tasks_param_description() -> str:
"""Compose the 'tasks' parameter description with current concurrency limit."""
try:
max_children = _get_max_concurrent_children()
except Exception:
max_children = _DEFAULT_MAX_CONCURRENT_CHILDREN
return (
f"The task(s), up to {max_children} in parallel for this user (set "
"via delegation.max_concurrent_children). Each entry spawns one "
"subagent with isolated context and terminal session; a single task "
"is a one-entry array. Required when spawning."
)
def _build_dynamic_schema_overrides() -> dict:
"""Per-call schema overrides (ToolEntry.dynamic_schema_overrides): every
get_definitions() pass rewrites the descriptions to the user's actual limits."""
from tools.delegate_tool_config import _get_independent_completions
independent_completions = _get_independent_completions()
overrides_params = {**DELEGATE_TASK_SCHEMA["parameters"]}
# Copy properties so the static schema dict is never mutated.
overrides_params["properties"] = {k: dict(v) for k, v in DELEGATE_TASK_SCHEMA["parameters"]["properties"].items()}
overrides_params["properties"]["tasks"]["description"] = _build_tasks_param_description()
if not independent_completions:
tasks = overrides_params["properties"]["tasks"]
tasks["items"] = {**tasks["items"], "properties": {
k: v for k, v in tasks["items"]["properties"].items() if k != "group"
}}
# The active delegation provider's per-task parameters and guidance (delegation.backend; none for the
# built-in default). Built from config with the rest of the definition, never per turn.
from tools.delegate_tool_routes import schema_extension
extra_params, addendum = schema_extension(_load_config())
if extra_params:
tasks = overrides_params["properties"]["tasks"]
tasks["items"] = {**tasks["items"], "properties": {**tasks["items"]["properties"], **extra_params}}
description = _build_top_level_description(independent_completions=independent_completions)
return {
"description": f"{description}\n{addendum}" if addendum else description,
"parameters": overrides_params,
}
def _p(type_: str, description: str, **extra) -> dict:
return {"type": type_, **extra, "description": description}
DELEGATE_TASK_SCHEMA = {
"name": "delegate_task",
# description / tasks.description are placeholders: the real text is built per get_definitions() call by
# _build_dynamic_schema_overrides() so the model sees the user's actual max_concurrent_children / max_spawn_depth.
# Lazy (not at import) so cli.CLI_CONFIG isn't forced to load before the test conftest redirects HERMES_HOME.
"description": (
"Spawn one or more subagents in isolated contexts. "
"Description is rebuilt at every get_definitions() call to reflect the user's current delegation limits."
),
"parameters": {
"type": "object",
"properties": {
# The handler also accepts the legacy single-goal shape (top-level `goal`/`context`/`output_schema`),
# wrapped into a one-entry batch at dispatch, and a per-task `role` (legacy, ignored: capability is
# depth-derived). Both unadvertised on purpose (old transcripts only); do not re-add. No maxItems — the
# runtime limit (delegation.max_concurrent_children) is enforced with a clear error in delegate_task().
"tasks": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"goal": _p(
"string",
"What this subagent should accomplish. Be specific and self-contained — it knows "
"nothing about your conversation history.",
),
"context": _p(
"string",
"Background THIS child needs: file paths, error messages, constraints. Each child "
"sees only its own context — repeat shared background in every task that needs it.",
),
"output_schema": _p(
"object",
"Optional JSON Schema this child's final answer must validate against (told to the "
"child up front; parent validates with one bounded correction retry; result gains "
"schema_valid, plus schema_errors on failure — the child's raw text is still returned "
"as summary, never discarded). Keep it forgiving — require only fields you will read.",
),
"images": _p(
"array",
"Optional images this child must SEE (max 8): local file paths or http(s) URLs — e.g. a "
"screenshot the user sent, a design mock, a chart. Vision-capable children receive the "
"pixels on their first turn; non-vision children get path hints for vision_analyze. Text "
"files do NOT belong here — put paths in 'context' instead.",
items={"type": "string"},
),
"group": _p(
"string",
"Optional result-delivery bucket within this call (only when delegation.independent_completions "
"is enabled; otherwise the whole call returns as one message). Tasks sharing a group return "
"together in ONE message; ungrouped tasks return individually as each finishes. This does not "
"order execution; if B needs A's output, dispatch B after A returns.",
),
},
"required": ["goal"],
},
"description": "(rebuilt at get_definitions() time)",
},
# `background` (bool) is also accepted — DEPRECATED, ignored: top-level
# delegations always run in the background. Unadvertised; do not re-add.
"action": _p(
"string",
"Default 'spawn'. Live control of running children: "
"'list' = ids/goals/status/transcripts; 'steer' = queue "
"course-correction text into one child (subagent_id + "
"message) without stopping it; 'stop' = end one child "
"early (subagent_id; partial result still returns). "
"Control actions return immediately; goal/tasks are ignored unless spawning.",
enum=["spawn", "list", "steer", "stop"],
),
"subagent_id": _p("string", "Target for action='steer'/'stop' (ids from the spawn response or action='list')."),
"message": _p(
"string",
"For action='steer': the course correction, appended to "
"the child's next tool result mid-run. Be directive and specific.",
),
},
"required": [],
},
}
_LIVE_CONTROL_PARAMS = frozenset({"action", "subagent_id", "message"})
def oneshot_delegate_function(function: dict) -> dict:
"""A built delegate_task definition minus live control, for a one-shot session (agent/oneshot_footprint.py):
its children are joined inside the call, so ``action`` list/steer/stop never has a running child to reach and
the background-delivery paragraph never applies. The handler still accepts those arguments."""
description = function.get("description") or ""
for delivery in (_DELIVERY_INDEPENDENT, _DELIVERY_JOINED):
description = description.replace(_DESCRIPTION_DELIVERY.format(delivery=delivery), _ONESHOT_DESCRIPTION_DELIVERY)
params = function["parameters"]
properties = {k: v for k, v in params["properties"].items() if k not in _LIVE_CONTROL_PARAMS}
return {**function, "description": description, "parameters": {**params, "properties": properties}}
def session_joins_delegation(agent: Any) -> bool:
"""Whether *agent*'s top-level delegations join in the call because the ``delegate_task`` the model sees is the
joined variant (a gateway session with ``delegation.gateway_mode`` inline). Dispatch follows the schema bytes,
which ride the session's tools[] pin: a config change can never leave the model reading "results return in
this tool call" while children detach, or the reverse. One-shot sessions keep their own fallback (and note)."""
if getattr(agent, "_oneshot_session", None) is True:
return False
for tool in getattr(agent, "tools", None) or ():
function = tool.get("function") if isinstance(tool, dict) else None
if isinstance(function, dict) and function.get("name") == "delegate_task":
return _ONESHOT_DESCRIPTION_DELIVERY in (function.get("description") or "")
return False
def adopt_session_delegation_mode(agent: Any) -> None:
"""Re-freeze *agent*'s inline decision (``agent_joins_delegation``) from the ``delegate_task`` it carries once a
fresh agent has restored its session's tools[] pin, so the pin is authoritative: a later rebuild from fresh
definitions (compaction boundary, ``/reload-mcp``, Bot Chat refresh) derives the session's variant, not the one
the current config would build."""
tools = getattr(agent, "tools", None) or ()
if any(isinstance(tool, dict) and (tool.get("function") or {}).get("name") == "delegate_task" for tool in tools):
agent._delegation_inline = session_joins_delegation(agent)
# --- Registry ---
from tools.registry import registry, tool_error
def _model_background_value(args: dict, parent_agent=None) -> bool:
"""Background flag for the MODEL-facing dispatch path (registry fallback). Top-level delegations always run in the
background — the model does not choose — for single tasks and fan-out batches alike (one async unit, one
consolidated result); an orchestrator subagent (depth > 0) is the exception since it needs its workers' results
within its own turn. The live path is ``run_agent._dispatch_delegate_task``; this mirrors it for the rare case
the intercept is bypassed. Direct Python callers keep the synchronous default."""
return not getattr(parent_agent, "_delegate_depth", 0) > 0
_MODEL_HIDDEN_TASK_FIELDS = {"acp_command", "acp_args"}
def _strip_model_hidden_task_fields(tasks: Any) -> Any:
"""Drop trusted-config-only task fields from model-supplied tasks (same list object back when nothing changed)."""
if not isinstance(tasks, list) or not any(isinstance(t, dict) and _MODEL_HIDDEN_TASK_FIELDS & t.keys() for t in tasks):
return tasks
return [{k: v for k, v in t.items() if k not in _MODEL_HIDDEN_TASK_FIELDS} if isinstance(t, dict) else t for t in tasks]
registry.register(
name="delegate_task",
toolset="delegation",
schema=DELEGATE_TASK_SCHEMA,
handler=lambda args, **kw: delegate_task(
goal=args.get("goal"), context=args.get("context"), tasks=_strip_model_hidden_task_fields(args.get("tasks")),
max_iterations=args.get("max_iterations"), role=args.get("role"),
background=_model_background_value(args, kw.get("parent_agent")), output_schema=args.get("output_schema"),
images=args.get("images"), action=args.get("action"), subagent_id=args.get("subagent_id"), message=args.get("message"),
parent_agent=kw.get("parent_agent"),
),
check_fn=check_delegate_requirements,
emoji="🔀",
dynamic_schema_overrides=_build_dynamic_schema_overrides,
)