Files
hermes-agent/tools/delegate_tool.py

1639 lines
67 KiB
Python

#!/usr/bin/env python3
"""
Delegate Tool -- Subagent Architecture
Spawns child AIAgent instances with isolated context, inherited toolsets,
and their own terminal sessions. Supports single-task and batch (parallel)
modes. Top-level model calls run in the background; orchestrator children
wait for their own workers so they can synthesize the results.
Each child gets:
- A fresh conversation (no parent history)
- Its own task_id (own terminal session, file ops cache)
- The parent's toolsets, with child-only blocked tools stripped
- A focused system prompt built from the delegated goal + context
The parent's context only sees the delegation call and the summary result,
never the child's intermediate tool calls or reasoning.
"""
import contextvars
import json
import logging
import re
import threading
import time
import weakref
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from toolsets import TOOLSETS
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
# moved name is re-imported here so ``tools.delegate_tool.<name>`` keeps
# resolving for callers and for tests that patch it. Mutable flag globals
# (_spawn_paused, *_WARNED) live only in their owning module.
from tools.delegate_tool_child_run import ( # noqa: F401
_WorktreeReporter,
_append_sibling_write_reminder,
_build_result_entry,
_cleanup_child_run,
_dump_subagent_timeout_diagnostic,
_emit_child_complete,
_handle_child_wait_failure,
_register_child,
_seed_child_workspace,
_start_heartbeat,
_validate_child_output_schema,
)
from tools.delegate_tool_config import ( # noqa: F401
_DEFAULT_MAX_CONCURRENT_CHILDREN,
_get_child_timeout,
_get_inherit_mcp_toolsets,
_get_max_async_children,
_get_max_concurrent_children,
_get_max_spawn_depth,
_get_orchestrator_enabled,
_get_subagent_approval_callback,
_get_worktree_isolation,
_inherit_parent_base_url,
_inherit_parent_capabilities,
_load_config,
_merge_request_overrides,
_resolve_child_credential_pool,
_resolve_delegation_credentials,
_subagent_auto_approve,
_subagent_auto_deny,
)
from tools.delegate_tool_dispatch import ( # noqa: F401
_dispatch_background,
_run_children_parallel,
)
from tools.delegate_tool_progress import ( # noqa: F401
DelegateEvent,
SUBAGENT_FAILURE_STATUSES,
_batch_prefix,
_build_child_progress_callback,
_build_child_system_prompt,
_clean_error_text,
_emit_parent_console,
_resolve_workspace_hint,
format_batch_tag,
format_subagent_failure_line,
)
from tools.delegate_tool_registry import ( # noqa: F401
_CONTROL_ACTIONS,
_active_subagents,
_active_subagents_lock,
_capture_gateway_steer_authority,
_close_subagent_steering,
_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_results import ( # noqa: F401
_apply_summary_budget,
_build_child_preserving_parent_tools,
_finalize_child_results,
_run_child_lifecycle,
_summarize_tool_arguments,
)
# Tools that children must never have access to
DELEGATE_BLOCKED_TOOLS = frozenset(
[
"delegate_task", # no recursive delegation
"clarify", # no user interaction
"memory", # no writes to shared MEMORY.md
"send_message", # no cross-platform side effects
"cronjob_manage", # no scheduling more work in the parent's name
]
)
# 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)."""
if r is None or not r:
return "leaf"
r_norm = str(r).strip().lower()
if r_norm in {"leaf", "orchestrator"}:
return r_norm
logger.warning("Unknown delegate_task role=%r, coercing to 'leaf'", r)
return "leaf"
def _is_mcp_toolset_name(name: str) -> bool:
"""Return True for canonical MCP toolsets and their registered aliases."""
if not name:
return False
if str(name).startswith("mcp-"):
return True
try:
from tools.registry import registry
target = registry.get_toolset_alias_target(str(name))
except Exception:
target = None
return bool(target and str(target).startswith("mcp-"))
def _expand_parent_toolsets(parent_toolsets: set) -> set:
"""Add every toolset whose tools are a subset of the parent's tools.
A parent on a composite like ``hermes-cli`` must still let a child request
``web``/``terminal``; bare name intersection would reject them.
"""
parent_tool_names: set = set()
for ts_name in parent_toolsets:
ts_def = TOOLSETS.get(ts_name)
if ts_def:
parent_tool_names.update(ts_def.get("tools", []))
if not parent_tool_names:
return set(parent_toolsets)
expanded = set(parent_toolsets)
for ts_name, ts_def in TOOLSETS.items():
if ts_name in expanded:
continue
ts_tools = ts_def.get("tools", [])
if ts_tools and set(ts_tools).issubset(parent_tool_names):
expanded.add(ts_name)
return expanded
def _preserve_parent_mcp_toolsets(
child_toolsets: List[str], parent_toolsets: set[str]
) -> List[str]:
"""Append any parent MCP toolsets that are missing from a narrowed child."""
preserved = list(child_toolsets)
for toolset_name in sorted(parent_toolsets):
if _is_mcp_toolset_name(toolset_name) and toolset_name not in preserved:
preserved.append(toolset_name)
return preserved
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
DEFAULT_TOOLSETS = ["terminal", "file", "web"]
def check_delegate_requirements() -> bool:
"""Delegation has no external requirements -- always available."""
return True
def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
"""Remove toolsets whose tools are ALL blocked (derived from DELEGATE_BLOCKED_TOOLS
so the two can't drift) plus composite toolsets children must never get."""
_COMPOSITE_BLOCKED_TOOLSETS = frozenset({"delegation"})
blocked_toolset_names = {
name
for name, defn in TOOLSETS.items()
if name in _COMPOSITE_BLOCKED_TOOLSETS
or all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
}
blocked_toolset_names.add("kanban")
return [t for t in toolsets if t not in blocked_toolset_names]
def _blocked_toolsets_for_role(role: str) -> List[str]:
"""One-tool deny toolsets for the role; passed as ``disabled_toolsets`` so
blocked names inside mixed bundles are subtracted AFTER composite expansion."""
blocked_names = set(DELEGATE_BLOCKED_TOOLS)
if role == "orchestrator":
blocked_names.discard("delegate_task")
return sorted(
name
for name, defn in TOOLSETS.items()
if defn.get("tools")
and set(defn.get("tools", ())).issubset(blocked_names)
)
def _resolve_child_toolsets(
parent_agent, toolsets: Optional[List[str]], effective_role: str
) -> tuple[List[str], List[str]]:
"""Return ``(enabled_toolsets, disabled_toolsets)`` for a child.
Children never gain tools the parent lacks: explicit ``toolsets`` are
intersected with the parent's (composite-expanded) set, otherwise the
parent's enabled set is inherited. Blocked tools are stripped twice — whole
blocked toolsets here, and exact one-tool deny toolsets via
``disabled_toolsets`` so blocked names inside mixed bundles (hermes-cli)
are subtracted AFTER composite expansion and survive registry refreshes.
Orchestrators get ``delegation`` re-added unconditionally (role-granted,
not inherited).
"""
# enabled_toolsets=None means "all tools", so derive from loaded tool names.
parent_enabled = getattr(parent_agent, "enabled_toolsets", None)
if parent_enabled is not None:
parent_toolsets = set(parent_enabled)
elif parent_agent and hasattr(parent_agent, "valid_tool_names"):
# enabled_toolsets is None (all tools) — derive from loaded tool names
import model_tools
parent_toolsets = {
ts
for name in parent_agent.valid_tool_names
if (ts := model_tools.get_toolset_for_tool(name)) is not None
}
else:
parent_toolsets = set(DEFAULT_TOOLSETS)
if toolsets:
expanded_parent = _expand_parent_toolsets(parent_toolsets)
child_toolsets = [t for t in toolsets if t in expanded_parent]
if _get_inherit_mcp_toolsets():
child_toolsets = _preserve_parent_mcp_toolsets(
child_toolsets, parent_toolsets
)
child_toolsets = _strip_blocked_tools(child_toolsets)
elif parent_agent and parent_enabled is not None:
child_toolsets = _strip_blocked_tools(parent_enabled)
elif parent_toolsets:
child_toolsets = _strip_blocked_tools(sorted(parent_toolsets))
else:
child_toolsets = _strip_blocked_tools(DEFAULT_TOOLSETS)
raw_parent_disabled = getattr(parent_agent, "disabled_toolsets", None)
if isinstance(raw_parent_disabled, (list, tuple, set)):
inherited_disabled = [str(name) for name in raw_parent_disabled]
else:
inherited_disabled = []
if effective_role == "orchestrator":
inherited_disabled = [
name for name in inherited_disabled if name != "delegation"
]
child_disabled_toolsets = list(
dict.fromkeys(
inherited_disabled + _blocked_toolsets_for_role(effective_role) + ["kanban"]
)
)
if effective_role == "orchestrator" and "delegation" not in child_toolsets:
child_toolsets.append("delegation")
return child_toolsets, child_disabled_toolsets
@dataclass
class _ChildRuntime:
"""Provider/transport/routing settings resolved for one child AIAgent."""
model: Any
provider: Any
base_url: Any
api_key: Any
api_mode: Any
capabilities: Optional[dict]
acp_command: Any
acp_args: list
reasoning: Any
fallback: Any
providers_allowed: Any
providers_ignored: Any
providers_order: Any
provider_sort: Any
provider_require_parameters: Any
provider_data_collection: Any
openrouter_min_coding_score: Any
optional_kwargs: Dict[str, Any]
def _resolve_child_runtime(
parent_agent,
delegation_cfg: dict,
parent_api_key: Any,
*,
model: Optional[str],
override_provider: Optional[str],
override_base_url: Optional[str],
override_api_key: Optional[str],
override_api_mode: Optional[str],
override_max_tokens: Optional[int],
override_acp_command: Optional[str],
override_acp_args: Optional[List[str]],
) -> _ChildRuntime:
"""Resolve the child's credentials, transport and routing: config override > parent inherit.
Rules that are easy to break: api_mode is re-derived (not inherited) when
the child's provider differs from the parent's or is Nous Portal (dual-wire);
a pinned ``delegation.command`` must exist on PATH or the spawn fails loudly;
``override_provider`` clears the parent's ACP transport, fallback chain and
OpenRouter routing filters so the pinned provider is actually honoured.
"""
effective_model = model or parent_agent.model
effective_provider = override_provider or getattr(parent_agent, "provider", None)
effective_base_url = override_base_url or parent_agent.base_url
if not override_base_url:
effective_base_url = _inherit_parent_base_url(parent_agent, effective_base_url)
effective_api_key = override_api_key or parent_api_key
child_capabilities = _inherit_parent_capabilities(
parent_agent, override_provider, override_base_url
)
# api_mode: each provider has its own wire, so a different provider re-derives
# (None) instead of inheriting (404s otherwise). Nous Portal is dual-wire
# within one provider (anthropic/* → Messages, else chat_completions), so
# same-provider inheritance would pin the child on the wrong wire — re-derive.
_parent_provider = getattr(parent_agent, "provider", None) or ""
_effective_provider_norm = (effective_provider or "").strip().lower()
if override_api_mode is not None:
effective_api_mode = override_api_mode
elif _effective_provider_norm in {"nous", "nous-portal", "nousresearch"}:
from hermes_cli.providers import nous_api_mode
effective_api_mode = nous_api_mode(effective_model)
elif effective_provider != _parent_provider:
effective_api_mode = None # force re-derivation from provider's defaults
else:
effective_api_mode = getattr(parent_agent, "api_mode", None)
# A pinned transport that cannot run must fail the spawn loudly, never fall
# back silently (delegate_task pre-validates; this covers direct callers).
if override_acp_command:
import shutil as _shutil
if not _shutil.which(override_acp_command):
raise ValueError(
f"Pinned delegation command '{override_acp_command}' was not "
f"found on PATH. Install it or remove delegation.command from "
f"config.yaml."
)
effective_acp_command = override_acp_command or getattr(
parent_agent, "acp_command", None
)
effective_acp_args = list(
override_acp_args
if override_acp_args is not None
else (getattr(parent_agent, "acp_args", []) or [])
)
# A pinned provider must use direct API calls; inheriting the parent's ACP
# transport would bypass the override credentials entirely.
if override_provider and not override_acp_command:
effective_acp_command = None
effective_acp_args = []
if override_acp_command:
# Forced ACP transport requires provider copilot-acp for run_agent to init the client.
effective_provider = "copilot-acp"
effective_api_mode = "chat_completions"
# Reasoning: delegation.reasoning_effort > parent. Keep the raw value — a
# YAML ``false`` must disable thinking, not coerce to "" and inherit.
child_reasoning = getattr(parent_agent, "reasoning_config", None)
try:
delegation_effort = delegation_cfg.get("reasoning_effort")
if delegation_effort or delegation_effort is False:
from hermes_constants import parse_reasoning_effort
parsed = parse_reasoning_effort(delegation_effort)
if parsed is not None:
child_reasoning = parsed
else:
logger.warning(
"Unknown delegation.reasoning_effort '%s', inheriting parent level",
delegation_effort,
)
except Exception as exc:
logger.debug("Could not load delegation reasoning_effort: %s", exc)
# Inherit the parent's fallback chain EXCEPT under a pinned provider: a
# mid-run 429/auth failure must not silently reroute the quiet child onto
# the parent's fallbacks. Predictability > liveness for explicit pins.
parent_fallback = (
None
if override_provider
else (getattr(parent_agent, "_fallback_chain", None) or None)
)
# OpenRouter routing filters are inherited, but cleared under a pinned
# provider — parent filters (e.g. only=["Anthropic"]) would silently force
# the child back onto the parent's provider.
child_providers_allowed = getattr(parent_agent, "providers_allowed", None)
child_providers_ignored = getattr(parent_agent, "providers_ignored", None)
child_providers_order = getattr(parent_agent, "providers_order", None)
child_provider_sort = getattr(parent_agent, "provider_sort", None)
child_provider_require_parameters = getattr(
parent_agent, "provider_require_parameters", False
)
child_provider_data_collection = getattr(
parent_agent, "provider_data_collection", None
) or ""
child_openrouter_min_coding_score = getattr(parent_agent, "openrouter_min_coding_score", None)
if override_provider:
child_providers_allowed = None
child_providers_ignored = None
child_providers_order = None
child_provider_sort = None
child_provider_require_parameters = False
child_provider_data_collection = ""
# openrouter_min_coding_score stays inherited: model-gated, no-op elsewhere.
child_max_tokens = (
override_max_tokens
if override_max_tokens is not None
else getattr(parent_agent, "max_tokens", None)
)
child_optional_kwargs: Dict[str, Any] = {}
if isinstance(child_max_tokens, int):
child_optional_kwargs["max_tokens"] = child_max_tokens
return _ChildRuntime(
model=effective_model,
provider=effective_provider,
base_url=effective_base_url,
api_key=effective_api_key,
api_mode=effective_api_mode,
capabilities=child_capabilities,
acp_command=effective_acp_command,
acp_args=effective_acp_args,
reasoning=child_reasoning,
fallback=parent_fallback,
providers_allowed=child_providers_allowed,
providers_ignored=child_providers_ignored,
providers_order=child_providers_order,
provider_sort=child_provider_sort,
provider_require_parameters=child_provider_require_parameters,
provider_data_collection=child_provider_data_collection,
openrouter_min_coding_score=child_openrouter_min_coding_score,
optional_kwargs=child_optional_kwargs,
)
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,
override_max_tokens: Optional[int] = None,
# ACP transport overrides from trusted delegation config.
override_acp_command: Optional[str] = None,
override_acp_args: Optional[List[str]] = None,
# Legacy; accepted for wire compat but ignored (capability is depth-derived).
role: str = "leaf",
):
"""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.
"""
from run_agent import AIAgent
import uuid as _uuid
# 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()
orchestrator_ok = _get_orchestrator_enabled() and child_depth < max_spawn
effective_role = "orchestrator" if orchestrator_ok 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)
tui_depth = max(0, child_depth - 1) # 0 = first-level child for the UI
delegation_cfg = _load_config()
child_toolsets, child_disabled_toolsets = _resolve_child_toolsets(
parent_agent, toolsets, effective_role
)
workspace_hint = _resolve_workspace_hint(parent_agent)
child_prompt = _build_child_system_prompt(
goal,
context,
workspace_path=workspace_hint,
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")
effective_model_for_cb = model or getattr(parent_agent, "model", None)
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=tui_depth,
model=effective_model_for_cb,
toolsets=child_toolsets,
session_ref=child_session_ref,
)
child_thinking_cb = None
if child_progress_cb:
def _child_thinking(text: str) -> None:
if not text:
return
try:
child_progress_cb("_thinking", text)
except Exception as e:
logger.debug("Child thinking callback relay failed: %s", e)
child_thinking_cb = _child_thinking
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_max_tokens=override_max_tokens,
override_acp_command=override_acp_command,
override_acp_args=override_acp_args,
)
effective_provider, effective_base_url = rt.provider, rt.base_url
# DEDICATED SessionDB handle for the child: the parent's 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.
child_session_db = None
parent_session_db = getattr(parent_agent, "_session_db", None)
if parent_session_db is not None:
try:
from hermes_state import get_shared_session_db
_parent_db_path = getattr(parent_session_db, "db_path", None)
child_session_db = (
get_shared_session_db(_parent_db_path)
if _parent_db_path is not None
else get_shared_session_db()
)
except Exception:
logger.debug(
"subagent: failed to open dedicated SessionDB; child persistence disabled",
exc_info=True,
)
child_session_db = None
from agent.delegation_context import delegated_child_context
with delegated_child_context():
try:
child = AIAgent(
base_url=rt.base_url,
api_key=rt.api_key,
model=rt.model,
provider=rt.provider,
capabilities=rt.capabilities,
api_mode=rt.api_mode,
acp_command=rt.acp_command,
acp_args=rt.acp_args,
max_iterations=max_iterations,
reasoning_config=rt.reasoning,
prefill_messages=getattr(parent_agent, "prefill_messages", None),
fallback_model=rt.fallback,
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",
skip_context_files=True,
skip_memory=True,
clarify_callback=None,
thinking_callback=child_thinking_cb,
session_db=child_session_db,
parent_session_id=getattr(parent_agent, "session_id", None),
providers_allowed=rt.providers_allowed,
providers_ignored=rt.providers_ignored,
providers_order=rt.providers_order,
provider_sort=rt.provider_sort,
provider_require_parameters=rt.provider_require_parameters,
provider_data_collection=rt.provider_data_collection,
request_overrides=(
# honored whenever set, incl. the inherit branch where
# _resolve_delegation_credentials already merged OVER the parent's
dict(override_request_overrides)
if override_request_overrides is not None
else (
{}
if override_provider
else dict(getattr(parent_agent, "request_overrides", {}) or {})
)
),
openrouter_min_coding_score=rt.openrouter_min_coding_score,
tool_progress_callback=child_progress_cb,
iteration_budget=None, # fresh budget per subagent
**rt.optional_kwargs,
)
except BaseException:
# No child close() will ever run — release the dedicated handle here.
if child_session_db is not None:
try:
from hermes_state import release_or_close
release_or_close(child_session_db)
except Exception:
pass
raise
child._print_fn = getattr(parent_agent, "_print_fn", None)
if child_session_db is not None:
child._owns_session_db = True # released by the child's close(), never by the parent
# Shared ref: session_id now, delegation_id once delegate_task stamps it —
# both ride on every relayed event (first emit is spawn_requested below).
child_session_ref["session_id"] = getattr(child, "session_id", "") or ""
child._progress_identity_ref = child_session_ref
child._delegate_depth = child_depth
child._delegate_role = effective_role # post-degrade role
child._subagent_id = subagent_id
child._parent_subagent_id = parent_subagent_id
# 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``).
parent_sid = getattr(parent_agent, "session_id", None)
if parent_sid and getattr(child, "_session_init_model_config", None) is not None:
child._session_init_model_config["_delegate_from"] = parent_sid
# Shared pool lets children rotate credentials on rate limits.
child_pool = _resolve_child_credential_pool(
effective_provider, parent_agent, effective_base_url
)
if child_pool is not None:
child._credential_pool = child_pool
# Register child for interrupt propagation
if hasattr(parent_agent, "_active_children"):
lock = getattr(parent_agent, "_active_children_lock", None)
if lock:
with lock:
parent_agent._active_children.append(child)
else:
parent_agent._active_children.append(child)
# Announce now: the child may queue for seconds when the pool is saturated.
if child_progress_cb:
try:
child_progress_cb("subagent.spawn_requested", preview=goal)
except Exception as exc:
logger.debug("spawn_requested relay failed: %s", exc)
try:
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"subagent_start",
parent_session_id=getattr(parent_agent, "session_id", None),
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,
)
except Exception:
logger.debug("subagent_start hook invocation failed", exc_info=True)
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").
"""
child_start = time.monotonic()
# 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
child_progress_cb = getattr(child, "tool_progress_callback", None)
child_pool = getattr(child, "_credential_pool", None)
leased_cred_id = None
if child_pool is not None:
leased_cred_id = child_pool.acquire_lease()
if leased_cred_id is not None:
try:
leased_entry = child_pool.current()
if leased_entry is not None and hasattr(child, "_swap_credential"):
child._swap_credential(leased_entry)
except Exception as exc:
logger.debug("Failed to bind child to leased credential: %s", exc)
# Heartbeat keeps the parent's _last_activity_ts moving so the gateway
# inactivity timeout doesn't fire while the child works; it stops itself
# once the child looks stale (see _HEARTBEAT_STALE_CYCLES_*).
heartbeat = _start_heartbeat(child, parent_agent, task_index)
_heartbeat_thread = heartbeat[1]
# 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,
)
worktree = _WorktreeReporter()
try:
_heartbeat_thread.start()
if child_progress_cb:
try:
child_progress_cb("subagent.start", preview=goal)
except Exception as e:
logger.debug("Progress callback start failed: %s", e)
ws = _seed_child_workspace(child, parent_agent, goal, task_index, _subagent_id, worktree)
goal = ws.goal
# Optional hard timeout (off by default — result(timeout=None) blocks
# until the child finishes; stuck-child protection is the heartbeat).
child_timeout = _get_child_timeout()
# Daemon worker: a timed-out child is abandoned below and a stdlib
# non-daemon worker would block interpreter exit at atexit-join time.
from tools.daemon_pool import DaemonThreadPoolExecutor
_timeout_executor = DaemonThreadPoolExecutor(
max_workers=1,
# Non-interactive approval callback in the worker thread so dangerous
# command prompts never fall back to input() and deadlock the parent
# TUI; deny vs approve follows delegation.subagent_auto_approve.
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}
def _relay_child_text(delta: str) -> None:
# Forward the child's streamed 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 not delta or not child_progress_cb:
return
try:
child_progress_cb("subagent.text", preview=delta)
except Exception as e:
logger.debug("Child text relay failed: %s", e)
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=goal,
task_id=ws.child_task_id,
stream_callback=_relay_child_text,
)
_child_context = contextvars.copy_context()
_child_future = _timeout_executor.submit(
_child_context.run,
_run_with_thread_capture,
)
try:
result = _child_future.result(timeout=child_timeout)
except Exception as _timeout_exc:
failure = _handle_child_wait_failure(
_timeout_exc,
child=child,
task_index=task_index,
goal=goal,
subagent_id=_subagent_id,
child_future=_child_future,
child_timeout=child_timeout,
child_start=child_start,
child_progress_cb=child_progress_cb,
worker_thread_holder=_worker_thread_holder,
worktree=worktree,
)
_child_close_deferred = failure.close_deferred
return failure.entry
finally:
# Shut down without waiting — a child stuck on blocking I/O would
# hang wait=True forever.
_timeout_executor.shutdown(wait=False)
schema = _validate_child_output_schema(
child, result, task_index, ws.child_task_id, _relay_child_text
)
# 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_pending_steer = (
_close_subagent_steering(_subagent_id, child) if _subagent_id else None
)
if _late_pending_steer:
_existing_pending = result.get("pending_steer")
result["pending_steer"] = (
f"{_existing_pending}\n{_late_pending_steer}"
if isinstance(_existing_pending, str) and _existing_pending
else _late_pending_steer
)
# Flush any remaining batched progress to gateway
if child_progress_cb and hasattr(child_progress_cb, "_flush"):
try:
child_progress_cb._flush()
except Exception as e:
logger.debug("Progress callback flush failed: %s", e)
duration = round(time.monotonic() - child_start, 2)
entry = _build_result_entry(child, result, task_index, duration, schema)
_append_sibling_write_reminder(entry, ws)
_emit_child_complete(child, result, entry, ws, duration, child_progress_cb)
worktree.attach(entry)
return entry
except Exception as exc:
_late_pending_steer = (
_close_subagent_steering(_subagent_id, child) if _subagent_id else None
)
duration = round(time.monotonic() - child_start, 2)
logging.exception(f"[subagent-{task_index}] failed")
if child_progress_cb:
try:
child_progress_cb(
"subagent.complete",
preview=str(exc),
status="failed",
duration_seconds=duration,
summary=str(exc),
)
except Exception as e:
logger.debug("Progress callback failure relay failed: %s", e)
_error_entry = {
"task_index": task_index,
"status": "error",
"summary": None,
"error": str(exc),
"api_calls": 0,
"duration_seconds": duration,
"_child_role": getattr(child, "_delegate_role", None),
}
if _late_pending_steer:
_error_entry["missed_steer"] = _late_pending_steer
_error_entry["error"] += (
" [steer did not land before the subagent stopped: "
f"{_late_pending_steer}]"
)
worktree.attach(_error_entry) # no-op when isolation never engaged
return _error_entry
finally:
_cleanup_child_run(
child,
parent_agent,
subagent_id=_subagent_id,
heartbeat=heartbeat,
child_pool=child_pool,
leased_cred_id=leased_cred_id,
close_deferred=_child_close_deferred,
)
def _recover_tasks_from_json_string(
tasks: Any,
) -> tuple[Optional[List[Dict[str, Any]]], Optional[str]]:
if not isinstance(tasks, str):
return None, None
raw = tasks.strip()
if not raw:
return None, "Provide either 'goal' (single task) or 'tasks' (batch)."
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
return None, (
"tasks must be a JSON array of task objects; received a string "
f"that could not be parsed as JSON ({exc.msg})."
)
if not isinstance(parsed, list):
return None, (
f"tasks must be a JSON array of task objects; parsed "
f"{type(parsed).__name__} instead."
)
return parsed, None
# Placeholder shapes for batch goal validation: bare 'TODO', bare 'task N'
# labels, or goals still carrying unexpanded template markers.
#
# The marker regex is deliberately NARROW: it only fires on snake_case /
# space-separated placeholder identifiers (`<feature_name>`, `{file path}`,
# `<FEATURE-NAME>`) — the shape LLM templates actually leave behind. Bare
# single-word brackets are left alone because legitimate coding goals are
# full of them: generics (`Vec<T>`, `Result<String>`), HTML tags (`<div>`),
# JSON/dict snippets (`{"key": 1}`), glob braces (`{a,b}`), and f-string
# style (`{i}`) must never be rejected (post-merge audit of #81141).
_PLACEHOLDER_GOAL_RE = re.compile(r"^(todo|task\s*\d+)$", re.IGNORECASE)
_TEMPLATE_MARKER_RE = re.compile(
r"<[A-Za-z][A-Za-z0-9]*(?:[ _-][A-Za-z0-9]+)+>"
r"|\{[A-Za-z][A-Za-z0-9]*(?:[ _-][A-Za-z0-9]+)+\}"
)
_MIN_BATCH_GOAL_LEN = 10
def _validate_batch_tasks(task_list: List[Dict[str, Any]]) -> Optional[str]:
"""Validate a tasks=[...] batch beyond per-task goal presence.
Returns an actionable error string, or None when the batch is valid.
A one-entry array is the canonical single-task shape (the advertised
interface is tasks-only; legacy top-level `goal` is wrapped into a
one-entry batch), so no minimum count is enforced. The placeholder/
template checks below still run on every entry.
Duplicate goals are deliberately NOT rejected: identical-goal fan-outs
are a legitimate pattern (best-of-N / ensemble sampling), and blocking
them broke real workflows (post-merge audit of #81141).
"""
for i, task in enumerate(task_list):
goal = str(task.get("goal", "")).strip()
normalized = " ".join(goal.lower().split())
if _PLACEHOLDER_GOAL_RE.match(normalized):
return (
f"Task {i} has a placeholder goal ({goal!r}). Replace it "
"with a specific, self-contained description of what the "
"subagent should accomplish."
)
marker = _TEMPLATE_MARKER_RE.search(goal)
if marker:
return (
f"Task {i} goal contains an unexpanded template marker "
f"({marker.group(0)!r}). Substitute the real value before "
"calling delegate_task — subagents cannot resolve "
"placeholders."
)
if len(goal) < _MIN_BATCH_GOAL_LEN and len(task_list) >= 2:
# Multi-task fan-outs with terse goals are usually unexpanded
# templates; a SINGLE task legitimately uses short goals
# ("Fix the tests"), so one-entry arrays keep the historical
# single-`goal` exemption.
return (
f"Task {i} goal is too short ({goal!r}). Write a specific, "
"self-contained goal of at least "
f"{_MIN_BATCH_GOAL_LEN} characters so the subagent knows "
"exactly what to do."
)
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,
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}, "
f"max_spawn_depth={max_spawn}). Raise "
f"delegation.max_spawn_depth in config.yaml if deeper "
f"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,
)
effective_max_iter = default_max_iter
# credentials_cfg (internal callers only, e.g. /review → auxiliary.review) is
# a per-call override shaped like the delegation config section.
try:
creds = _resolve_delegation_credentials(
credentials_cfg if credentials_cfg else cfg, parent_agent
)
except ValueError as exc:
return tool_error(str(exc))
max_children = _get_max_concurrent_children()
recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks)
if tasks_error:
return tool_error(tasks_error)
if recovered_tasks is not None:
tasks = recovered_tasks
# Small models emit tasks=[] alongside a single goal: treat as "no batch".
if isinstance(tasks, list) and not tasks:
tasks = None
if tasks and isinstance(tasks, list):
if len(tasks) > max_children:
return tool_error(
f"Too many tasks: {len(tasks)} provided, but "
f"max_concurrent_children is {max_children}. "
f"Either reduce the task count, split into multiple "
f"delegate_task calls, or increase "
f"delegation.max_concurrent_children in config.yaml."
)
task_list = tasks
elif goal and isinstance(goal, str) and goal.strip():
single_task: Dict[str, Any] = {"goal": goal, "context": context, "role": top_role}
if output_schema is not None:
single_task["output_schema"] = output_schema
task_list = [single_task]
else:
return tool_error(
"No tasks provided. Pass tasks=[{goal: '...', context: '...'}, "
"...] — one entry per subagent (a single task is a one-entry "
"array)."
)
if not task_list:
return tool_error("No tasks provided.")
for i, task in enumerate(task_list):
if not isinstance(task, dict):
return tool_error(
f"Task {i} must be an object, got {type(task).__name__}."
)
if not task.get("goal", "").strip():
return tool_error(f"Task {i} is missing a 'goal'.")
# Batch-only quality gate (placeholders, template markers); the single-goal
# form is exempt because short goals are valid there.
if tasks is not None and isinstance(tasks, list):
batch_error = _validate_batch_tasks(task_list)
if batch_error:
return tool_error(batch_error)
# A malformed output_schema fails the whole call before any child spawns;
# schema-less tasks resolve to None and take no new code paths downstream.
from tools.delegation_output_schema import coerce_output_schema
task_schemas: List[Optional[Dict[str, Any]]] = []
for i, task in enumerate(task_list):
raw_schema = task.get("output_schema")
if raw_schema is None and len(task_list) == 1 and output_schema is not None:
raw_schema = output_schema
coerced_schema, schema_err = coerce_output_schema(raw_schema)
if schema_err:
return tool_error(f"Task {i} output_schema invalid: {schema_err}")
task_schemas.append(coerced_schema)
overall_start = time.monotonic()
results = []
n_tasks = len(task_list)
task_labels = [t["goal"][:40] for t in task_list]
# 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.
from tools.delegation_live_log import (
create_live_transcripts,
update_manifest_statuses,
wrap_progress_callback,
)
live_deleg_id, live_writers, live_paths = create_live_transcripts(
task_list, context, model=creds.get("model"), provider=creds.get("provider")
)
# Announce the batch tag once so interleaved ``[tag n/N]`` lines are attributable.
if n_tasks > 1 and live_deleg_id:
_hdr = f" 🔀 [{format_batch_tag(live_deleg_id)}] delegating {n_tasks} tasks"
_hdr_spinner = getattr(parent_agent, "_delegate_spinner", None)
if _hdr_spinner:
try:
_hdr_spinner.print_above(_hdr)
except Exception:
_emit_parent_console(parent_agent, _hdr)
else:
_emit_parent_console(parent_agent, _hdr)
# Capture the ORIGINATING session's wake target BEFORE building any child:
# AIAgent construction clobbers the HERMES_SESSION_ID ContextVar/os.environ
# with the subagent's internal id.
from tools.async_delegation import _current_origin_session_id
_origin_wake_sid = _current_origin_session_id()
try:
from gateway.session_context import get_session_env
_origin_ui_session_id = get_session_env("HERMES_UI_SESSION_ID", "")
except Exception:
_origin_ui_session_id = ""
_origin_owner_transport, _origin_owner_session_record = (
_capture_gateway_steer_authority(_origin_ui_session_id)
)
# Build every child on the main thread (construction is not thread-safe).
children = []
for i, t in enumerate(task_list):
effective_role = _normalize_role(t.get("role") or top_role)
_task_schema = task_schemas[i] if i < len(task_schemas) else None
_child_context = t.get("context")
if _task_schema is not None:
from tools.delegation_output_schema import append_output_contract
_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=creds["model"],
max_iterations=effective_max_iter,
task_count=n_tasks,
parent_agent=parent_agent,
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_max_tokens=creds.get("max_output_tokens"),
override_acp_command=creds.get("command"),
override_acp_args=creds.get("args"),
role=effective_role,
)
except ValueError as exc:
return tool_error(str(exc)) # explicit-pin preflight failure
if _task_schema is not None:
try:
child._delegate_output_schema = _task_schema
except Exception:
logger.debug("Could not attach output schema to child %d", i)
# 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 live_deleg_id:
setattr(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))
def _execute_and_aggregate(*, honor_parent_interrupt: bool = True) -> dict:
"""Run all built children, join, finalize (hooks + cost rollup), return the combined dict.
Shared by the sync path and the background runner: even in the
background the batch JOINS on itself here so ONE consolidated results
block re-enters the conversation.
"""
if n_tasks == 1:
_i, _t, child = children[0]
result = _run_single_child(
_i,
_t["goal"],
child,
parent_agent,
owner_session_id=_origin_ui_session_id or None,
owner_transport=_origin_owner_transport,
owner_session_record=_origin_owner_session_record,
)
results.append(result)
else:
_run_children_parallel(
children,
results,
parent_agent=parent_agent,
n_tasks=n_tasks,
max_children=max_children,
task_labels=task_labels,
live_deleg_id=live_deleg_id,
honor_parent_interrupt=honor_parent_interrupt,
origin_ui_session_id=_origin_ui_session_id,
origin_owner_transport=_origin_owner_transport,
origin_owner_session_record=_origin_owner_session_record,
)
_finalize_child_results(results, task_list, children, parent_agent)
total_duration = round(time.monotonic() - overall_start, 2)
# Close out live transcripts (files are retained as the full-fidelity
# record; retention pruning happens on future dispatches).
for entry in results:
_idx = entry.get("task_index", -1)
_w = (
live_writers[_idx]
if isinstance(_idx, int) and 0 <= _idx < len(live_writers)
else None
)
if _w is not None:
try:
_w.finalize(entry)
except Exception:
logger.debug("Live transcript finalize failed", exc_info=True)
if _idx < len(live_paths):
entry["live_transcript"] = live_paths[_idx]
update_manifest_statuses(live_deleg_id, results)
combined: Dict[str, Any] = {
"results": results,
"total_duration_seconds": total_duration,
}
if live_paths:
combined["live_transcripts"] = list(live_paths)
return combined
if background:
return _dispatch_background(
parent_agent=parent_agent,
context=context,
task_list=task_list,
children=children,
creds=creds,
top_role=top_role,
live_deleg_id=live_deleg_id,
live_paths=live_paths,
origin_wake_sid=_origin_wake_sid,
origin_ui_session_id=_origin_ui_session_id,
execute_and_aggregate=_execute_and_aggregate,
)
return json.dumps(_execute_and_aggregate(), ensure_ascii=False)
# ── OpenAI function-calling schema ──────────────────────────────────────────
def _build_top_level_description() -> str:
"""Compose the delegate_task tool description.
Carries ONLY guidance stated nowhere else in the schema (limits live in
the 'tasks' parameter description, rebuilt per get_definitions() call).
"""
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"
"- Children can themselves delegate while depth remains "
f"(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"
)
return (
"Spawn subagents in isolated contexts; 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"
"Runs in the background: dispatch returns immediately with live "
"transcript paths, and the completed result (one consolidated message, "
"results in task order) re-enters the conversation on its own. Do NOT "
"wait or poll; continue other work. While children run, `action` "
"(list/steer/stop) controls them live — steer when a transcript shows "
"a child drifting.\n\n"
"USE FOR: reasoning-heavy subtasks, work that would flood your context "
"with intermediate data, 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 discards running subagents.\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"
+ restrictions_rule +
"- 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:
"""Return per-call schema overrides reflecting current config.
Plugged into ToolEntry.dynamic_schema_overrides so every
get_definitions() pass rewrites the description fields to the user's
actual limits.
"""
overrides_params = {
**DELEGATE_TASK_SCHEMA["parameters"],
}
# Deep-copy properties so we don't mutate the static schema dict.
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()
return {
"description": _build_top_level_description(),
"parameters": overrides_params,
}
DELEGATE_TASK_SCHEMA = {
"name": "delegate_task",
# NOTE: description / tasks.description / role.description are placeholder
# values. The real text is generated per get_definitions() call by
# _build_dynamic_schema_overrides() (registered via
# dynamic_schema_overrides below) so the model sees the user's actual
# delegation.max_concurrent_children / max_spawn_depth, not the framework
# defaults. Building these lazily (instead of at module import) also
# avoids forcing cli.CLI_CONFIG to load before the test conftest can
# redirect 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": {
# NOTE: the handler also accepts the legacy single-goal shape —
# top-level `goal` (string), `context` (string), `output_schema`
# (object) — wrapped into a one-entry batch at dispatch. Legacy,
# unadvertised (old transcripts/callers only); tasks=[...] is the
# only advertised shape. Do not re-add these to the schema.
"tasks": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": (
"What this subagent should accomplish. Be "
"specific and self-contained — it knows "
"nothing about your conversation history."
),
},
"context": {
"type": "string",
"description": (
"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": {
"type": "object",
"description": (
"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). Keep it forgiving — require only "
"fields you will read."
),
},
},
"required": ["goal"],
},
# No maxItems — the runtime limit is configurable via
# delegation.max_concurrent_children (default 3) and
# enforced with a clear error in delegate_task().
# NOTE: the handler also accepts a per-task `role` — legacy,
# ignored: delegation capability is depth-derived, not
# caller-declared. Unadvertised on purpose; do not re-add.
"description": "(rebuilt at get_definitions() time)",
},
# NOTE: the handler also accepts `background` (bool) — DEPRECATED,
# ignored: top-level delegations always run in the background.
# Deliberately unadvertised (old transcripts/callers only); do not
# re-add to the schema.
"action": {
"type": "string",
"enum": ["spawn", "list", "steer", "stop"],
"description": (
"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."
),
},
"subagent_id": {
"type": "string",
"description": (
"Target for action='steer'/'stop' (ids from the spawn "
"response or action='list')."
),
},
"message": {
"type": "string",
"description": (
"For action='steer': the course correction, appended to "
"the child's next tool result mid-run. Be directive and "
"specific."
),
},
},
"required": [],
},
}
# --- 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).
Delegations from the top-level agent always run in the background — the
model does not choose. This applies to both a single task and a fan-out
batch (the whole batch is one async unit that joins on all children and
returns one consolidated result). The one
exception is a delegation from an orchestrator subagent (depth > 0), which
needs its workers' results within its own turn. The live path is
``run_agent._dispatch_delegate_task``; this lambda mirrors it for the rare
case the intercept is bypassed. Direct Python callers of ``delegate_task``
keep the historical synchronous default.
"""
is_subagent = getattr(parent_agent, "_delegate_depth", 0) > 0
return not is_subagent
_MODEL_HIDDEN_TASK_FIELDS = {"acp_command", "acp_args"}
def _strip_model_hidden_task_fields(tasks: Any) -> Any:
if not isinstance(tasks, list):
return tasks
stripped_tasks = []
changed = False
for task in tasks:
if not isinstance(task, dict):
stripped_tasks.append(task)
continue
stripped = {
key: value
for key, value in task.items()
if key not in _MODEL_HIDDEN_TASK_FIELDS
}
changed = changed or len(stripped) != len(task)
stripped_tasks.append(stripped)
return stripped_tasks if changed else 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"),
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,
)