refactor(delegate): docstring compaction by hand (every rule/why kept)

This commit is contained in:
Teknium
2026-09-02 19:16:07 -07:00
parent aa31f83995
commit 1283ccd855
8 changed files with 195 additions and 279 deletions
+18 -26
View File
@@ -82,13 +82,11 @@ def check_delegate_requirements() -> bool:
def _open_child_session_db(parent_agent) -> Any: def _open_child_session_db(parent_agent) -> Any:
"""DEDICATED SessionDB handle for the child, or None. """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
The parent's handle can be closed by its own lifecycle while a background silently dropped). It MUST open the same db FILE as the parent's handle
child still flushes (transcript silently dropped). It MUST open the same db (non-launch profiles), else lineage / session_search break; released by the
FILE as the parent's handle (non-launch profiles), else lineage / child's close() via _owns_session_db."""
session_search break; released by the child's close() via _owns_session_db.
"""
parent_session_db = getattr(parent_agent, "_session_db", None) parent_session_db = getattr(parent_agent, "_session_db", None)
if parent_session_db is None: if parent_session_db is None:
return None return None
@@ -120,11 +118,9 @@ def _build_child_agent(
# Legacy; accepted for wire compat but ignored (capability is depth-derived). # Legacy; accepted for wire compat but ignored (capability is depth-derived).
role: str = "leaf", role: str = "leaf",
): ):
"""Build (don't run) a child AIAgent on the main thread. """Build (don't run) a child AIAgent on the main thread. override_* (from
delegation config) replace parent inheritance so children can run on a
override_* (from delegation config) replace parent inheritance so children different provider:model pair."""
can run on a different provider:model pair.
"""
import uuid as _uuid import uuid as _uuid
from run_agent import AIAgent from run_agent import AIAgent
from agent.delegation_context import delegated_child_context from agent.delegation_context import delegated_child_context
@@ -347,13 +343,11 @@ def delegate_task(
output_schema: Optional[Dict[str, Any]] = None, action: Optional[str] = None, subagent_id: Optional[str] = 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, message: Optional[str] = None, parent_agent=None, credentials_cfg: Optional[Dict[str, Any]] = None,
) -> str: ) -> str:
"""Spawn child agents (single ``goal`` or ``tasks=[...]`` batch) or control running ones. """Spawn child agents (single ``goal`` or ``tasks=[...]`` batch) or control
running ones. ``action`` list/steer/stop run synchronously and bypass the pause
``action`` list/steer/stop run synchronously and bypass the pause gate, gate, depth limit and async dispatch. ``role`` is legacy (per-task beats
depth limit and async dispatch. ``role`` is legacy (per-task beats top-level; capability is depth-derived). Returns JSON with one results entry
top-level; capability is depth-derived). Returns JSON with one results per task, or a dispatch handle when running in the background."""
entry per task, or a dispatch handle when running in the background.
"""
if parent_agent is None: if parent_agent is None:
return tool_error("delegate_task requires a parent agent context.") return tool_error("delegate_task requires a parent agent context.")
@@ -597,14 +591,12 @@ from tools.registry import registry, tool_error
def _model_background_value(args: dict, parent_agent=None) -> bool: def _model_background_value(args: dict, parent_agent=None) -> bool:
"""Background flag for the MODEL-facing dispatch path (registry fallback). """Background flag for the MODEL-facing dispatch path (registry fallback).
Top-level delegations always run in the background — the model does not choose
Top-level delegations always run in the background — the model does not — for single tasks and fan-out batches alike (one async unit, one consolidated
choose — for single tasks and fan-out batches alike (one async unit, one result); an orchestrator subagent (depth > 0) is the exception since it needs
consolidated result). The exception is an orchestrator subagent (depth > 0), its workers' results within its own turn. The live path is
which 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 ``run_agent._dispatch_delegate_task``; this mirrors it for the rare case the
intercept is bypassed. Direct Python callers keep the synchronous default. intercept is bypassed. Direct Python callers keep the synchronous default."""
"""
return not getattr(parent_agent, "_delegate_depth", 0) > 0 return not getattr(parent_agent, "_delegate_depth", 0) > 0
_MODEL_HIDDEN_TASK_FIELDS = {"acp_command", "acp_args"} _MODEL_HIDDEN_TASK_FIELDS = {"acp_command", "acp_args"}
+42 -60
View File
@@ -155,12 +155,10 @@ def _dump_subagent_timeout_diagnostic(
*, child: Any, task_index: int, timeout_seconds: float, duration_seconds: float, *, child: Any, task_index: int, timeout_seconds: float, duration_seconds: float,
worker_thread: Optional[threading.Thread], goal: str, worker_thread: Optional[threading.Thread], goal: str,
) -> Optional[str]: ) -> Optional[str]:
"""Write a structured diagnostic for a subagent that timed out before any """Structured diagnostic for a subagent that timed out before any API call
API call (users hit "timed out with no response" and 0 API calls with no way (otherwise "timed out with no response", 0 API calls, nothing to inspect):
to inspect it). Lands under ``~/.hermes/logs/subagent-timeout-<sid>-<ts>.log`` ``~/.hermes/logs/subagent-timeout-<sid>-<ts>.log`` with the child's config,
with the child's config, prompt/schema sizes, activity snapshot and the prompt/schema sizes, activity snapshot and worker stack. Path, or None on failure."""
worker thread's stack. Returns the path, or None on failure.
"""
try: try:
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
import datetime as _dt import datetime as _dt
@@ -213,12 +211,9 @@ def _dump_subagent_timeout_diagnostic(
# ── Per-run helpers ────────────────────────────────────────────────────────── # ── Per-run helpers ──────────────────────────────────────────────────────────
def _start_heartbeat(child: Any, parent_agent: Any, task_index: int) -> tuple: def _start_heartbeat(child: Any, parent_agent: Any, task_index: int) -> tuple:
"""Build the parent-activity heartbeat thread for one child (not started). """``(stop_event, thread)`` for one child's parent-activity heartbeat, NOT
started: the caller starts it inside its ``try`` so a failed ``start()`` (OS
Returns ``(stop_event, thread)``. The caller starts the thread inside its thread exhaustion) leaves ``ident`` None and the finally-path join is skipped."""
``try`` so a failed ``start()`` (OS thread exhaustion) leaves ``ident`` None
and the finally-path join can be skipped safely.
"""
from tools.delegate_tool import (_HEARTBEAT_INTERVAL, _HEARTBEAT_STALE_CYCLES_IDLE, _HEARTBEAT_STALE_CYCLES_IN_TOOL) from tools.delegate_tool import (_HEARTBEAT_INTERVAL, _HEARTBEAT_STALE_CYCLES_IDLE, _HEARTBEAT_STALE_CYCLES_IN_TOOL)
_heartbeat_stop = threading.Event() _heartbeat_stop = threading.Event()
# Stale detection: a cycle counts as stale when (tool, iteration, # Stale detection: a cycle counts as stale when (tool, iteration,
@@ -269,11 +264,9 @@ def _register_child(
child: Any, parent_agent: Any, goal: str, *, owner_session_id: Optional[str], owner_transport: Any, child: Any, parent_agent: Any, goal: str, *, owner_session_id: Optional[str], owner_transport: Any,
owner_session_record: Any, owner_session_record: Any,
) -> Optional[str]: ) -> Optional[str]:
"""Register the live child in the module registry; return its subagent_id. """Register the live child in the module registry; return its subagent_id. Test
doubles without a stable string ``_subagent_id`` are not registered (None) and
Test doubles without a stable string ``_subagent_id`` are not registered the caller skips every registry interaction for them."""
(returns None) and the caller skips every registry interaction for them.
"""
_subagent_id = getattr(child, "_subagent_id", None) _subagent_id = getattr(child, "_subagent_id", None)
if not isinstance(_subagent_id, str) or not _subagent_id: if not isinstance(_subagent_id, str) or not _subagent_id:
return None return None
@@ -340,14 +333,14 @@ def _create_isolated_worktree(parent_agent: Any, parent_task_id: Any, subagent_i
def _defer_close_after_timeout(child: Any, child_future: Any) -> None: def _defer_close_after_timeout(child: Any, child_future: Any) -> None:
"""Hand ``child.close()`` to a Future done-callback and drain its transports. """Hand ``child.close()`` to a Future done-callback and drain its transports.
The interrupt is cooperative: the worker still runs its finally path, so The interrupt is cooperative: the worker still runs its finally path, so closing
closing now could close SQLite under its final write — the done-callback is now could close SQLite under its final write — the done-callback is the first
the first safe boundary. The abandoned worker is usually parked in an safe boundary. The abandoned worker is usually parked in an OpenSSL read; NEVER
OpenSSL read; NEVER hard-close that transport from this thread (cross-thread hard-close that transport from this thread (cross-thread FD release under a
FD release under a live SSL read corrupts native state) — shutdown() the live SSL read corrupts native state) — shutdown() the pooled sockets so the read
pooled sockets so the read settles with EOF and the worker unwinds. One settles with EOF and the worker unwinds. One immediate sweep + one delayed
immediate sweep + one delayed re-sweep for a connection opened in between; a re-sweep for a connection opened in between; a worker that still won't settle
worker that still won't settle keeps its resources until process exit. keeps its resources until process exit.
""" """
child_future.add_done_callback(lambda _done: _close_child(child, "Failed to close timed-out child after worker exit")) child_future.add_done_callback(lambda _done: _close_child(child, "Failed to close timed-out child after worker exit"))
_drain = getattr(child, "_drain_transports_after_abandonment", None) _drain = getattr(child, "_drain_transports_after_abandonment", None)
@@ -397,11 +390,9 @@ class _SchemaOutcome:
def _validate_child_output_schema( def _validate_child_output_schema(
child: Any, result: Dict[str, Any], task_index: int, child_task_id: str, relay_child_text: Any child: Any, result: Dict[str, Any], task_index: int, child_task_id: str, relay_child_text: Any
) -> _SchemaOutcome: ) -> _SchemaOutcome:
"""Validate the final answer against the attached output_schema with ONE bounded retry. """Validate the final answer against the attached output_schema with ONE bounded
retry. Schema-less children (no dict on ``child._delegate_output_schema``) take
Schema-less children (no dict on ``child._delegate_output_schema``) take no no branch here so their result entry stays byte-identical."""
branch here so their result entry stays byte-identical.
"""
_output_schema = getattr(child, "_delegate_output_schema", None) _output_schema = getattr(child, "_delegate_output_schema", None)
if not isinstance(_output_schema, dict): if not isinstance(_output_schema, dict):
return _SchemaOutcome(_output_schema, None, [], 0) return _SchemaOutcome(_output_schema, None, [], 0)
@@ -469,12 +460,10 @@ def _build_tool_trace(messages: Any) -> list[Dict[str, Any]]:
def _build_result_entry( def _build_result_entry(
child: Any, result: Dict[str, Any], task_index: int, duration: float, schema: _SchemaOutcome, child: Any, result: Dict[str, Any], task_index: int, duration: float, schema: _SchemaOutcome,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Derive the parent-visible result entry (status, exit_reason, tool trace, tokens, cost). """Parent-visible result entry (status, exit_reason, tool trace, tokens, cost).
``status``/``exit_reason``/``truncated`` follow the ``_run_single_child`` contract;
``status`` / ``exit_reason`` / ``truncated`` follow the contract in the a structured failure always wins over the summary-presence heuristic (a fallback
``_run_single_child`` docstring; a structured failure always wins over the for legacy/mock results only)."""
summary-presence heuristic (which is only a fallback for legacy/mock results).
"""
summary = result.get("final_response") or "" summary = result.get("final_response") or ""
# "(empty)" is run_agent's give-up sentinel after repeated empty LLM # "(empty)" is run_agent's give-up sentinel after repeated empty LLM
# responses (usually a transport bug) — a failure, not a success. # responses (usually a transport bug) — a failure, not a success.
@@ -561,11 +550,8 @@ def _build_result_entry(
@dataclass @dataclass
class _ChildRun: class _ChildRun:
"""State of one child run, shared by every phase of ``_run_single_child``. """State of one child run, shared by every phase of ``_run_single_child``.
``worktree_info`` stays None until isolation engages (``attach_worktree`` is
``worktree_info`` stays None until isolation engages, so ``attach_worktree`` then a no-op on every early error path); ``seed_workspace`` sets the rest."""
is a no-op on every early error path; ``child_task_id`` /
``parent_reads_snapshot`` are set by ``seed_workspace``.
"""
child: Any child: Any
parent_agent: Any parent_agent: Any
@@ -652,18 +638,16 @@ class _ChildRun:
"""Run the child's conversation on a daemon worker: ``(result, None, False)`` """Run the child's conversation on a daemon worker: ``(result, None, False)``
or ``(None, error_entry, close_deferred)`` on timeout/exception. or ``(None, error_entry, close_deferred)`` on timeout/exception.
The hard timeout is off by default (``result(timeout=None)`` blocks; stuck Hard timeout is off by default (``result(timeout=None)``; stuck children are
children are the heartbeat's job). Daemon worker: a timed-out child is the heartbeat's job). Daemon worker: an abandoned timed-out child on a
abandoned and a non-daemon thread would block interpreter exit at atexit non-daemon thread would block interpreter exit at atexit join. The worker
join. The worker installs a non-interactive approval callback so dangerous installs a non-interactive approval callback (deny/approve per
command prompts never fall back to ``input()`` and deadlock the parent TUI delegation.subagent_auto_approve) so dangerous-command prompts never fall
(deny vs approve follows delegation.subagent_auto_approve). back to ``input()`` and deadlock the parent TUI. On failure: steer acceptance
closes BEFORE the stop signal (a concurrent steer is drained into the entry
On failure: steer acceptance closes BEFORE the stop signal (a concurrent or rejected, never lost); a 0-API-call timeout gets a diagnostic dump; a
steer is drained into the entry or rejected, never silently lost); a worker that still owns the child gets ``child.close()`` via a Future
0-API-call timeout gets a diagnostic dump; a timed-out worker that still done-callback (``close_deferred=True``) — closing here would race its
owns the child gets ``child.close()`` via a Future done-callback
(``close_deferred=True``) because closing from this thread races its
still-unwinding finally path. still-unwinding finally path.
""" """
from tools.delegate_tool import (_get_child_timeout, _get_subagent_approval_callback, _set_subagent_approval_cb) from tools.delegate_tool import (_get_child_timeout, _get_subagent_approval_callback, _set_subagent_approval_cb)
@@ -811,13 +795,11 @@ class _ChildRun:
_safe_progress(self.child_progress_cb, "subagent.complete", **complete_kwargs) _safe_progress(self.child_progress_cb, "subagent.complete", **complete_kwargs)
def cleanup(self, *, heartbeat: tuple, child_pool: Any, leased_cred_id: Any, close_deferred: bool) -> None: def cleanup(self, *, heartbeat: tuple, child_pool: Any, leased_cred_id: Any, close_deferred: bool) -> None:
"""Finally-path teardown (idempotent, never raises). """Finally-path teardown (idempotent, never raises). Order matters: stop
heartbeat → drop registry entry → release credential lease → restore the
Order matters: stop heartbeat → drop registry entry → release credential parent's process-global tool names → detach from the parent's interrupt list
lease → restore the parent's process-global tool names → detach from the → close the child (unless a timed-out worker still owns it) → pop the child's
parent's interrupt list → close the child (unless a timed-out worker still Relay scope if no turn is active."""
owns it) → pop the child's Relay scope if no turn is active.
"""
child = self.child child = self.child
_heartbeat_stop, _heartbeat_thread = heartbeat _heartbeat_stop, _heartbeat_thread = heartbeat
_heartbeat_stop.set() _heartbeat_stop.set()
+48 -75
View File
@@ -105,22 +105,16 @@ def _get_max_concurrent_children() -> int:
return result return result
def _get_worktree_isolation() -> bool: def _get_worktree_isolation() -> bool:
"""delegation.worktree_isolation (bool, default False). """delegation.worktree_isolation (bool, default False): each child gets its own
git worktree off the parent's HEAD so parallel children never contend for one
When enabled each child gets its own git worktree off the parent's HEAD so working copy. Git-only and local-backend-only; otherwise silently ignored."""
parallel children never contend for one working copy. Git-only and
local-backend-only; otherwise silently ignored (shared workspace as before).
"""
return bool(_cfg().get("worktree_isolation", False)) return bool(_cfg().get("worktree_isolation", False))
def _get_max_async_children() -> int: def _get_max_async_children() -> int:
"""Concurrency cap for background delegations == delegation.max_concurrent_children. """Concurrency cap for background delegations == delegation.max_concurrent_children.
At capacity a new async dispatch is REJECTED (not queued) so a runaway model At capacity a new async dispatch is REJECTED (not queued) so a runaway model
can't pile up unbounded background work; the caller then runs synchronously. can't pile up unbounded background work; the caller then runs synchronously. A
A leftover ``delegation.max_async_children`` key is ignored with a one-time leftover ``delegation.max_async_children`` key is ignored with a one-time warning."""
deprecation warning.
"""
from tools.delegate_tool import _get_max_concurrent_children from tools.delegate_tool import _get_max_concurrent_children
if _cfg().get("max_async_children") is not None: if _cfg().get("max_async_children") is not None:
_warn_once( _warn_once(
@@ -136,24 +130,20 @@ def _parse_timeout(raw: Any) -> Optional[float]:
return None if parsed <= 0 else max(30.0, parsed) return None if parsed <= 0 else max(30.0, parsed)
def _get_child_timeout() -> Optional[float]: def _get_child_timeout() -> Optional[float]:
"""Hard wall-clock cap for one child, or None (default: no timeout). """Hard wall-clock cap for one child, or None (default: no timeout). Failures
should come from what the child does (API/tool errors, iteration budget), not a
Failures should come from what the child does (API/tool errors, iteration stopwatch; stuck children are caught by the heartbeat staleness monitor.
budget), not a stopwatch; stuck children are caught by the heartbeat delegation.child_timeout_seconds > 0 opts in (floor 30 s); 0 or negative
staleness monitor. delegation.child_timeout_seconds > 0 opts in (floor 30 s); disables. Env fallback: DELEGATION_CHILD_TIMEOUT_SECONDS."""
0 or negative disables. Env fallback: DELEGATION_CHILD_TIMEOUT_SECONDS.
"""
return _knob( return _knob(
"child_timeout_seconds", "DELEGATION_CHILD_TIMEOUT_SECONDS", _parse_timeout, DEFAULT_CHILD_TIMEOUT, "child_timeout_seconds", "DELEGATION_CHILD_TIMEOUT_SECONDS", _parse_timeout, DEFAULT_CHILD_TIMEOUT,
"delegation.child_timeout_seconds=%r is not a valid number; using default (no timeout)", "delegation.child_timeout_seconds=%r is not a valid number; using default (no timeout)",
) )
def _get_max_spawn_depth() -> int: def _get_max_spawn_depth() -> int:
"""delegation.max_spawn_depth floored at 1 (no ceiling). """delegation.max_spawn_depth floored at 1 (no ceiling). Depth 0 is the parent;
agents at depths 0..N-1 may spawn, depth N is the leaf floor. Default 1 is
Depth 0 is the parent; agents at depths 0..N-1 may spawn, depth N is the flat. Each extra level multiplies API cost."""
leaf floor. Default 1 is flat. Each extra level multiplies API cost.
"""
def _floored(v): def _floored(v):
ival = int(v) ival = int(v)
if ival < _MIN_SPAWN_DEPTH: if ival < _MIN_SPAWN_DEPTH:
@@ -183,12 +173,10 @@ def _normalized_runtime_url(value: Any) -> str:
return str(value or "").strip().rstrip("/") return str(value or "").strip().rstrip("/")
def _inherit_parent_capabilities(parent_agent, override_provider, override_base_url) -> Optional[dict]: def _inherit_parent_capabilities(parent_agent, override_provider, override_base_url) -> Optional[dict]:
"""Parent's endpoint-trust capability map for a child, or None. """Parent's endpoint-trust capability map for a child, or None. ``agent.capabilities``
is a trust decision scoped to one provider+endpoint: inherited ONLY when the
``agent.capabilities`` is a trust decision scoped to one provider+endpoint: child runs the parent's exact route; any provider or base_url override stays
inherited ONLY when the child runs the parent's exact route; any provider or DEFAULT-DENY (matches the /model switch posture)."""
base_url override stays DEFAULT-DENY (matches the /model switch posture).
"""
if override_provider or override_base_url: if override_provider or override_base_url:
return None return None
parent_caps = getattr(parent_agent, "capabilities", None) parent_caps = getattr(parent_agent, "capabilities", None)
@@ -197,11 +185,9 @@ def _inherit_parent_capabilities(parent_agent, override_provider, override_base_
return {key: value for key, value in parent_caps.items() if isinstance(key, str) and isinstance(value, bool)} return {key: value for key, value in parent_caps.items() if isinstance(key, str) and isinstance(value, bool)}
def _inherit_parent_base_url(parent_agent, fallback_base_url: Optional[str]) -> Optional[str]: def _inherit_parent_base_url(parent_agent, fallback_base_url: Optional[str]) -> Optional[str]:
"""Base URL the parent is actually calling (live client), not a stale attribute. """Base URL the parent is actually calling (live client), not a stale attribute:
``parent_agent.base_url`` can lag the live client (old OpenRouter URL vs local
``parent_agent.base_url`` can lag the live client (old OpenRouter URL vs Ollama) and inheriting the stale one 401s with a dummy/local key."""
local Ollama); inheriting the stale one 401s with a dummy/local key.
"""
surface_url = _normalized_runtime_url(fallback_base_url) surface_url = _normalized_runtime_url(fallback_base_url)
client_kwargs = getattr(parent_agent, "_client_kwargs", None) client_kwargs = getattr(parent_agent, "_client_kwargs", None)
client = getattr(parent_agent, "client", None) client = getattr(parent_agent, "client", None)
@@ -225,15 +211,13 @@ def _loaded_pool(key: Any):
def _resolve_child_credential_pool( def _resolve_child_credential_pool(
effective_provider: Optional[str], parent_agent, effective_base_url: Optional[str] = None, effective_provider: Optional[str], parent_agent, effective_base_url: Optional[str] = None,
): ):
"""Credential pool for the child: parent's pool (same provider), that """Credential pool for the child: parent's pool (same provider), that provider's
provider's own pool, or None (child keeps its fixed credential). own pool, or None (child keeps its fixed credential). Custom endpoints all
collapse to ``provider="custom"``, so they are matched by endpoint identity (the
Custom endpoints all collapse to ``provider="custom"``, so they are matched ``custom:<name>`` pool key) — sharing the parent's pool across different custom
by endpoint identity (the ``custom:<name>`` pool key) — sharing the parent's endpoints would overwrite the child's delegated base_url on lease; an
pool across different custom endpoints would overwrite the child's delegated unregistered custom endpoint (no custom_providers entry) keeps the child's fixed
base_url on lease. An unregistered custom endpoint (no custom_providers credential rather than inherit the parent's."""
entry) keeps the child's fixed credential rather than inherit the parent's.
"""
parent_pool = getattr(parent_agent, "_credential_pool", None) parent_pool = getattr(parent_agent, "_credential_pool", None)
if not effective_provider: if not effective_provider:
return parent_pool return parent_pool
@@ -260,13 +244,10 @@ def _resolve_child_credential_pool(
def _merge_request_overrides(runtime_overrides, explicit_overrides): def _merge_request_overrides(runtime_overrides, explicit_overrides):
"""Merge explicit ``delegation.request_overrides`` OVER runtime-derived ones. """Merge explicit ``delegation.request_overrides`` OVER runtime-derived ones.
Explicit top-level keys win; ``extra_body`` is deep-merged ONE level so provider
Explicit top-level keys win; ``extra_body`` is deep-merged ONE level so personality (e.g. ``thinking: {type: disabled}``) survives unless the explicit
provider personality (e.g. ``thinking: {type: disabled}``) survives unless dict redefines that exact key. Both sides are deep-copied so transport-side
the explicit dict redefines that exact key. Both sides are deep-copied so mutation can't leak into the config/runtime cache. None when both are empty."""
transport-side mutation can't leak into the config/runtime cache. Returns
None when both sides are empty.
"""
import copy as _copy import copy as _copy
runtime_overrides = runtime_overrides if isinstance(runtime_overrides, dict) else None runtime_overrides = runtime_overrides if isinstance(runtime_overrides, dict) else None
explicit_overrides = explicit_overrides if isinstance(explicit_overrides, dict) else None explicit_overrides = explicit_overrides if isinstance(explicit_overrides, dict) else None
@@ -380,15 +361,12 @@ def _runtime_provider_credentials(v: dict, explicit_request_overrides) -> dict:
) )
def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
"""Resolve the child credential bundle from the ``delegation`` config section. """Child credential bundle from the ``delegation`` config section. Three
branches: ``base_url`` set → direct endpoint (``api_key`` None means inherit the
Three branches: ``base_url`` set → direct endpoint (``api_key`` None means parent's key, so providers keyed outside OPENAI_API_KEY work); ``provider`` set
inherit the parent's key, so providers keyed outside OPENAI_API_KEY work); → full bundle via the runtime provider system (same path as CLI/gateway
``provider`` set → full bundle via the runtime provider system (same path as startup); neither → None values, child inherits everything. ``request_overrides``
CLI/gateway startup); neither → None values, child inherits everything. is honored on every branch. Raises ValueError with a user-facing message."""
``request_overrides`` is honored on every branch. Raises ValueError with a
user-facing message on credential failure.
"""
values = {k: str(cfg.get(k) or "").strip() or None for k in ("model", "provider", "base_url", "api_key")} values = {k: str(cfg.get(k) or "").strip() or None for k in ("model", "provider", "base_url", "api_key")}
values["api_mode"] = str(cfg.get("api_mode") or "").strip().lower() or None values["api_mode"] = str(cfg.get("api_mode") or "").strip().lower() or None
explicit_request_overrides = cfg.get("request_overrides") if isinstance(cfg.get("request_overrides"), dict) else None explicit_request_overrides = cfg.get("request_overrides") if isinstance(cfg.get("request_overrides"), dict) else None
@@ -405,14 +383,12 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
return _runtime_provider_credentials(values, explicit_request_overrides) return _runtime_provider_credentials(values, explicit_request_overrides)
def _load_config() -> dict: def _load_config() -> dict:
"""Return the ``delegation`` config section (read-only — do NOT mutate). """The ``delegation`` config section (read-only — do NOT mutate). Prefers the
shared ``load_config_readonly()`` (follows HERMES_HOME/profile; no deepcopy,
Prefers the shared ``load_config_readonly()`` (follows HERMES_HOME/profile; since this runs on every get_definitions() rebuild) over the legacy
no deepcopy, since this runs on every get_definitions() rebuild) over the ``cli.CLI_CONFIG``, which can hide user-set keys — except that
legacy ``cli.CLI_CONFIG``, which can hide user-set keys. Exception:
``HERMES_IGNORE_USER_CONFIG=1`` is only honored by the legacy loader, so it ``HERMES_IGNORE_USER_CONFIG=1`` is only honored by the legacy loader, so it
stays authoritative when that flag is set. stays authoritative when that flag is set."""
"""
if os.environ.get("HERMES_IGNORE_USER_CONFIG") != "1": if os.environ.get("HERMES_IGNORE_USER_CONFIG") != "1":
try: try:
from hermes_cli.config import load_config_readonly from hermes_cli.config import load_config_readonly
@@ -444,15 +420,12 @@ def _resolve_child_runtime(
override_base_url: Optional[str], override_api_key: Optional[str], override_api_mode: 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]], override_max_tokens: Optional[int], override_acp_command: Optional[str], override_acp_args: Optional[List[str]],
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Resolve the child's credentials, transport and routing (config override > """Child credentials, transport and routing (config override > parent inherit)
parent inherit) as ``AIAgent`` keyword arguments. as ``AIAgent`` kwargs. 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
Rules that are easy to break: api_mode is re-derived (not inherited) when (dual-wire); a pinned ``delegation.command`` must exist on PATH or the spawn
the child's provider differs from the parent's or is Nous Portal (dual-wire); fails loudly; ``override_provider`` clears the parent's ACP transport, fallback
a pinned ``delegation.command`` must exist on PATH or the spawn fails loudly; chain and OpenRouter routing filters so the pinned provider is actually honoured."""
``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_model = model or parent_agent.model
effective_provider = override_provider or getattr(parent_agent, "provider", None) effective_provider = override_provider or getattr(parent_agent, "provider", None)
effective_base_url = override_base_url or _inherit_parent_base_url(parent_agent, parent_agent.base_url) effective_base_url = override_base_url or _inherit_parent_base_url(parent_agent, parent_agent.base_url)
+24 -32
View File
@@ -94,14 +94,12 @@ def _report_child_done(parent_agent, spinner_ref, entry, tag, task_labels, n_tas
spinner_ref.update_text(f"🔀 {'[' + tag + '] ' if tag else ''}{remaining} task{'s' if remaining != 1 else ''} remaining") spinner_ref.update_text(f"🔀 {'[' + tag + '] ' if tag else ''}{remaining} task{'s' if remaining != 1 else ''} remaining")
def _run_children_parallel(batch: _Batch, results: list, *, honor_parent_interrupt: bool) -> None: def _run_children_parallel(batch: _Batch, results: list, *, honor_parent_interrupt: bool) -> None:
"""Run the batch's children in parallel, appending entries to ``results``. """Run the batch's children in parallel, appending entries to ``results``
(sorted by task_index on return, one completion line printed per child).
Polls futures with a short ``wait()`` timeout instead of ``as_completed()`` Polls futures with a short ``wait()`` timeout instead of ``as_completed()`` so
so a wedged child cannot block the parent forever after an interrupt; a wedged child cannot block the parent forever after an interrupt; on parent
on parent interrupt the still-pending children are reported as interrupt the still-pending children are reported ``interrupted`` and
``interrupted`` and abandoned (they already got the interrupt signal). abandoned (they already got the interrupt signal)."""
Prints one completion line per child. ``results`` ends sorted by task_index.
"""
# Daemon workers (tools.daemon_pool): the `with` block still joins normally, # Daemon workers (tools.daemon_pool): the `with` block still joins normally,
# but if the parent is interrupted while a child is wedged, the abandoned # but if the parent is interrupted while a child is wedged, the abandoned
# worker must not block interpreter exit. # worker must not block interpreter exit.
@@ -138,13 +136,11 @@ def _run_children_parallel(batch: _Batch, results: list, *, honor_parent_interru
results.sort(key=lambda r: r["task_index"]) # match input order results.sort(key=lambda r: r["task_index"]) # match input order
def _execute_and_aggregate(batch: _Batch, *, honor_parent_interrupt: bool = True) -> dict: def _execute_and_aggregate(batch: _Batch, *, honor_parent_interrupt: bool = True) -> dict:
"""Run all built children, join, finalize (hooks + cost rollup), return the combined 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
Shared by the sync path and the background runner: even in the background background the batch JOINS on itself here so ONE consolidated results block
the batch JOINS on itself here so ONE consolidated results block re-enters re-enters the conversation. Live transcripts are finalized but retained as the
the conversation. Live transcripts are finalized but retained as the full-fidelity record (retention pruning happens on future dispatches)."""
full-fidelity record (retention pruning happens on future dispatches).
"""
from tools.delegation_live_log import update_manifest_statuses from tools.delegation_live_log import update_manifest_statuses
results: list = [] results: list = []
if len(batch.task_list) == 1: if len(batch.task_list) == 1:
@@ -192,13 +188,12 @@ def _run_sync_with_note(batch: _Batch, reason: str) -> str:
def _resolve_async_wake_sid(origin_wake_sid: str) -> Optional[str]: def _resolve_async_wake_sid(origin_wake_sid: str) -> Optional[str]:
"""Wake target for a detached batch, or None to force synchronous execution. """Wake target for a detached batch, or None to force synchronous execution.
Finite sessions (stateless HTTP requests, one-shot Kanban workers) cannot Finite sessions (stateless HTTP requests, one-shot Kanban workers) cannot route
route a detached result back after their turn/process ends. But if a raw a detached result back after their turn/process ends — but if a raw session id
session id is bound (the API server always binds one), gateway.wake can is bound (the API server always binds one), gateway.wake can still reach it by
still reach the session by self-POSTing /v1/chat/completions with that id, self-POSTing /v1/chat/completions, so only fall back to sync when there is truly
so only fall back to sync when there is truly no session id to wake. Uses no session id to wake. Uses the origin captured BEFORE child construction —
the origin captured BEFORE child construction — HERMES_SESSION_ID here HERMES_SESSION_ID here would be the subagent's internal id.
would be the subagent's internal id.
""" """
try: try:
from gateway.session_context import async_delivery_supported from gateway.session_context import async_delivery_supported
@@ -222,9 +217,9 @@ def _resolve_async_session_key(parent_agent: Any, origin_ui_session_id: str) ->
can rotate it mid-turn before the TUI-side dict is re-anchored, and a stale can rotate it mid-turn before the TUI-side dict is re-anchored, and a stale
approval-context key would orphan the completion. Gateway chats keep the approval-context key would orphan the completion. Gateway chats keep the
platform conversation key (agent:main:...). The CLI has no bound approval platform conversation key (agent:main:...). The CLI has no bound approval
contextvar and no HERMES_SESSION_KEY, so the key resolves empty; its drain contextvar and no HERMES_SESSION_KEY, so the key resolves empty; its drain is a
is a positive-ownership filter keyed on the durable session_id, so an empty positive-ownership filter on the durable session_id (empty would fail closed),
key would fail closed — stamp the parent's durable id. so stamp the parent's durable id.
""" """
from tools.approval import get_current_session_key from tools.approval import get_current_session_key
session_key = get_current_session_key(default="") session_key = get_current_session_key(default="")
@@ -298,13 +293,10 @@ def _dispatched_payload(dispatch: dict, goals: List[str], child_agents: List[Any
def _dispatch_background(batch: _Batch) -> str: def _dispatch_background(batch: _Batch) -> str:
"""Dispatch the WHOLE batch as one async unit and return the tool result JSON. """Dispatch the WHOLE batch as one async unit and return the tool result JSON.
The runner joins on every child and yields ONE consolidated results block that
The runner joins on every child and yields ONE consolidated results block re-enters the conversation as a single message when ALL children finish. Falls
that re-enters the conversation as a single message when ALL children back to running synchronously (with an explanatory ``note``) when the session
finish. Falls back to running it synchronously (with an explanatory cannot receive detached completions or the async pool is at capacity."""
``note``) when the session cannot receive detached completions or the async
pool is at capacity.
"""
from tools.delegate_tool import _get_max_async_children from tools.delegate_tool import _get_max_async_children
from tools.async_delegation import dispatch_async_delegation_batch from tools.async_delegation import dispatch_async_delegation_batch
wake_sid = _resolve_async_wake_sid(batch.origin_wake_sid) wake_sid = _resolve_async_wake_sid(batch.origin_wake_sid)
+21 -31
View File
@@ -72,14 +72,11 @@ def format_subagent_failure_line(
class DelegateEvent(str, enum.Enum): class DelegateEvent(str, enum.Enum):
"""Formal event types emitted during delegation progress. """Formal delegation progress event types. The relay normalises incoming legacy
strings (``tool.started``, ``_thinking``, …) to these via ``_LEGACY_EVENT_MAP``;
The relay normalises incoming legacy strings (``tool.started``, external consumers (gateway SSE, ACP adapter, CLI) still receive the legacy
``_thinking``, …) to these values via ``_LEGACY_EVENT_MAP``; external strings during the deprecation window. TASK_SPAWNED / TASK_COMPLETED /
consumers (gateway SSE, ACP adapter, CLI) still receive the legacy strings TASK_FAILED are reserved for future orchestrator lifecycle events, not emitted yet."""
during the deprecation window. TASK_SPAWNED / TASK_COMPLETED / TASK_FAILED
are reserved for future orchestrator lifecycle events, not emitted yet.
"""
TASK_SPAWNED = "delegate.task_spawned" TASK_SPAWNED = "delegate.task_spawned"
TASK_PROGRESS = "delegate.task_progress" TASK_PROGRESS = "delegate.task_progress"
@@ -126,12 +123,10 @@ def _build_child_system_prompt(
goal: str, context: Optional[str] = None, *, workspace_path: Optional[str] = None, role: str = "leaf", goal: str, context: Optional[str] = None, *, workspace_path: Optional[str] = None, role: str = "leaf",
max_spawn_depth: int = 2, child_depth: int = 1, max_spawn_depth: int = 2, child_depth: int = 1,
) -> str: ) -> str:
"""Build a focused system prompt for a child agent. """Focused system prompt for a child agent. role='orchestrator' appends a
delegation-capability block (modeled on OpenClaw's buildSubagentSystemPrompt);
role='orchestrator' appends a delegation-capability block (modeled on its depth note is literal truth grounded in the passed config so the LLM can't
OpenClaw's buildSubagentSystemPrompt); its depth note is literal truth confabulate nesting."""
grounded in the passed config so the LLM can't confabulate nesting.
"""
parts = ["You are a focused subagent working on a specific delegated task.", "", f"YOUR TASK:\n{goal}"] parts = ["You are a focused subagent working on a specific delegated task.", "", f"YOUR TASK:\n{goal}"]
if context and context.strip(): if context and context.strip():
parts.append(f"\nCONTEXT:\n{context}") parts.append(f"\nCONTEXT:\n{context}")
@@ -219,15 +214,12 @@ _BATCH_ORDINALS: Dict[str, int] = {}
_BATCH_ORDINALS_LOCK = threading.Lock() _BATCH_ORDINALS_LOCK = threading.Lock()
def format_batch_tag(delegation_id: Optional[str]) -> str: def format_batch_tag(delegation_id: Optional[str]) -> str:
"""Short human tag for a delegation batch: ``deleg_6a664903`` → ``set 1`` """Short human tag for a delegation batch: ``deleg_6a664903`` → ``set 1`` (first
(first batch seen in this process), the next distinct id → ``set 2``. batch seen in this process), the next distinct id → ``set 2``. Several batches
(a parent's fan-out plus a child's nested fan-out, or two concurrent tools)
Several batches (a parent's fan-out plus a child's nested fan-out, or two print interleaved ``[n/N]`` lines to one console; without a tag ``✓ [3/3]`` and
concurrent tools) print interleaved ``[n/N]`` lines to one console; without ``✓ [3/9]`` are indistinguishable, and a raw hex slice is unreadable. Empty
a tag ``✓ [3/3]`` and ``✓ [3/9]`` are indistinguishable, and a raw hex string when no id is known so callers can concatenate unconditionally."""
slice is unreadable. Empty string when no id is known so callers can
concatenate unconditionally.
"""
if not isinstance(delegation_id, str) or not delegation_id: if not isinstance(delegation_id, str) or not delegation_id:
return "" return ""
with _BATCH_ORDINALS_LOCK: with _BATCH_ORDINALS_LOCK:
@@ -268,14 +260,12 @@ def _short(text: str, n: int) -> str:
class _ChildProgressRelay: class _ChildProgressRelay:
"""Callable relaying one child's events to the parent display. """Callable relaying one child's events to the parent display. CLI: prints
tree-view lines above the parent's delegation spinner. Gateway: batches tool
CLI: prints tree-view lines above the parent's delegation spinner. names (``_BATCH_SIZE``) and relays to the parent's progress callback, threading
Gateway: batches tool names (``_BATCH_SIZE``) and relays to the parent's the identity kwargs (subagent_id, parent_id, depth, model, toolsets) into every
progress callback, threading the identity kwargs (subagent_id, parent_id, event so the TUI can rebuild the live spawn tree and route per-branch controls
depth, model, toolsets) into every event so the TUI can rebuild the live back by ``subagent_id``."""
spawn tree and route per-branch controls back by ``subagent_id``.
"""
_BATCH_SIZE = 5 _BATCH_SIZE = 5
+24 -30
View File
@@ -78,12 +78,10 @@ def _unregister_subagent(subagent_id: str, *, agent: Any = None) -> None:
def _close_subagent_steering(subagent_id: str, agent: Any) -> Optional[str]: def _close_subagent_steering(subagent_id: str, agent: Any) -> Optional[str]:
"""Atomically close steer acceptance and drain its final durable artifact. """Atomically close steer acceptance and drain its final durable artifact.
``steer_subagent`` holds the same registry lock through ``agent.steer``, so
``steer_subagent`` holds the same registry lock through ``agent.steer``. either acceptance wins and this drain sees its exact text, or closure wins and
Therefore either acceptance wins and this drain sees its exact text, or the caller is rejected. Exact agent identity prevents a finishing child with a
closure wins and the caller is rejected. Exact agent identity prevents a recycled public id from closing its replacement."""
finishing child with a recycled public id from closing its replacement.
"""
with _active_subagents_lock: with _active_subagents_lock:
record = _active_subagents.get(subagent_id) record = _active_subagents.get(subagent_id)
if record is None or record.get("agent") is not agent: if record is None or record.get("agent") is not agent:
@@ -120,14 +118,14 @@ def steer_subagent(
) -> bool: ) -> bool:
"""Queue steering text into a running subagent without stopping it. """Queue steering text into a running subagent without stopping it.
Calls AIAgent.steer(), which appends the text to the child's last tool result AIAgent.steer() appends the text to the child's last tool result at its next
at its next iteration boundary — the current tool call is never cut. True iff iteration boundary — the current tool call is never cut. True iff the text was
the text was QUEUED while the child still accepted work; False for QUEUED while the child still accepted work; False for unknown/closed id,
unknown/closed id, ownership mismatch, no live agent, or empty text. ownership mismatch, no live agent, or empty text. ``owner_session_id=None``
``owner_session_id=None`` keeps the in-process helper contract; gateway keeps the in-process helper contract; gateway callers must pass exact
callers must pass exact authority. Acceptance and completion are linearized authority. Acceptance and completion are linearized by the registry lock: if
by the registry lock: if acceptance wins but no delivery boundary remains, acceptance wins but no delivery boundary remains, the text lands in the entry
the text lands in the entry as ``missed_steer``. as ``missed_steer``.
""" """
if not text or not text.strip(): if not text or not text.strip():
return False return False
@@ -211,16 +209,15 @@ def _resolve_session_lineage(session_id: Optional[str], parent_agent: Any) -> st
def _owns_subagent_record(record: Dict[str, Any], parent_agent: Any) -> bool: def _owns_subagent_record(record: Dict[str, Any], parent_agent: Any) -> bool:
"""True when *parent_agent*'s conversation owns this live-child record. """True when *parent_agent*'s conversation owns this live-child record.
Tier 1: object identity — the ``_delegate_parent_ref`` weakref chain reaches Tier 1: identity — the ``_delegate_parent_ref`` weakref chain reaches
*parent_agent* (fast path while the parent AIAgent survives the run). *parent_agent* (fast path while the parent AIAgent survives the run). Tier 2:
Tier 2: durable lineage — the record's ``owner_agent_session_id`` matches the durable lineage — the record's ``owner_agent_session_id`` matches the caller's
caller's ``session_id``, resolving compression-rotation lineage on both ``session_id`` after resolving compression-rotation lineage on both sides.
sides. Tier 2 exists because the identity chain is BRITTLE across parent Tier 2 exists because the identity chain is BRITTLE across parent rebuilds:
rebuilds: the CLI sets ``self.agent = None`` mid-session (route change, the CLI sets ``self.agent = None`` mid-session (route change, credential
credential refresh, /model, MoA one-shots) and builds a NEW AIAgent while refresh, /model, MoA one-shots) and builds a NEW AIAgent while the child keeps
the child keeps a weakref to the old one. Delivery always routed by durable a weakref to the old one. Delivery routes by durable session id; control must
session id; control must use the same spine or running children go use the same spine or running children go invisible/unsteerable.
invisible/unsteerable.
""" """
if _is_descendant_of(record.get("agent"), parent_agent): if _is_descendant_of(record.get("agent"), parent_agent):
return True return True
@@ -261,12 +258,9 @@ def _list_payload(parent_agent: Any) -> Dict[str, Any]:
return payload return payload
def _handle_control_action(action: str, subagent_id: Optional[str], message: Optional[str], parent_agent: Any) -> str: def _handle_control_action(action: str, subagent_id: Optional[str], message: Optional[str], parent_agent: Any) -> str:
"""Synchronous control plane for delegate_task: list/steer/stop. """Synchronous control plane for delegate_task: list/steer/stop. Runs in-turn
(never backgrounded) over the same registry the TUI overlay drives, scoped so a
Runs in-turn (never backgrounded) and only over subagents descended from conversation can only control its own spawn tree."""
*parent_agent* — the same registry the TUI overlay drives, but scoped so
a conversation can only control its own spawn tree.
"""
if action == "list": if action == "list":
return json.dumps(_list_payload(parent_agent), ensure_ascii=False) return json.dumps(_list_payload(parent_agent), ensure_ascii=False)
+7 -10
View File
@@ -38,16 +38,13 @@ def _recover_tasks_from_json_string(tasks: Any) -> tuple[Optional[List[Dict[str,
return parsed, None return parsed, None
def _validate_batch_tasks(task_list: List[Dict[str, Any]]) -> Optional[str]: def _validate_batch_tasks(task_list: List[Dict[str, Any]]) -> Optional[str]:
"""Batch-only quality gate beyond per-task goal presence; actionable error or None. """Batch-only quality gate beyond per-task goal presence; actionable error or
None. No minimum count: a one-entry array is the canonical single-task shape
No minimum count: a one-entry array is the canonical single-task shape (legacy top-level `goal` is wrapped into one). Duplicate goals are deliberately
(legacy top-level `goal` is wrapped into one). Duplicate goals are NOT rejected — identical-goal fan-outs (best-of-N / ensemble sampling) are
deliberately NOT rejected — identical-goal fan-outs (best-of-N / ensemble legitimate and blocking them broke real workflows. The too-short check applies
sampling) are legitimate and blocking them broke real workflows. The only to multi-task fan-outs (terse goals there are usually unexpanded
too-short check applies only to multi-task fan-outs (terse goals there are templates); a SINGLE task legitimately uses short goals ("Fix the tests")."""
usually unexpanded templates); a SINGLE task legitimately uses short goals
("Fix the tests").
"""
for i, task in enumerate(task_list): for i, task in enumerate(task_list):
goal = str(task.get("goal", "")).strip() goal = str(task.get("goal", "")).strip()
if _PLACEHOLDER_GOAL_RE.match(" ".join(goal.lower().split())): if _PLACEHOLDER_GOAL_RE.match(" ".join(goal.lower().split())):
+11 -15
View File
@@ -40,11 +40,9 @@ def _is_mcp_toolset_name(name: str) -> bool:
return bool(target and str(target).startswith("mcp-")) return bool(target and str(target).startswith("mcp-"))
def _expand_parent_toolsets(parent_toolsets: set) -> set: def _expand_parent_toolsets(parent_toolsets: set) -> set:
"""Add every toolset whose tools are a subset of the parent's tools. """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``;
A parent on a composite like ``hermes-cli`` must still let a child request bare name intersection would reject them."""
``web``/``terminal``; bare name intersection would reject them.
"""
parent_tool_names = {t for ts_name in parent_toolsets for t in (TOOLSETS.get(ts_name) or {}).get("tools", [])} parent_tool_names = {t for ts_name in parent_toolsets for t in (TOOLSETS.get(ts_name) or {}).get("tools", [])}
expanded = set(parent_toolsets) expanded = set(parent_toolsets)
if parent_tool_names: if parent_tool_names:
@@ -76,16 +74,14 @@ def _blocked_toolsets_for_role(role: str) -> List[str]:
def _resolve_child_toolsets( def _resolve_child_toolsets(
parent_agent, toolsets: Optional[List[str]], effective_role: str parent_agent, toolsets: Optional[List[str]], effective_role: str
) -> tuple[List[str], List[str]]: ) -> tuple[List[str], List[str]]:
"""Return ``(enabled_toolsets, disabled_toolsets)`` for a child. """``(enabled_toolsets, disabled_toolsets)`` for a child. Children never gain
tools the parent lacks: explicit ``toolsets`` are intersected with the parent's
Children never gain tools the parent lacks: explicit ``toolsets`` are (composite-expanded) set, else the parent's enabled set is inherited. Blocked
intersected with the parent's (composite-expanded) set, else the parent's tools are stripped twice — whole blocked toolsets here, and exact one-tool deny
enabled set is inherited. Blocked tools are stripped twice — whole blocked toolsets via ``disabled_toolsets`` so blocked names inside mixed bundles
toolsets here, and exact one-tool deny toolsets via ``disabled_toolsets`` so (hermes-cli) are subtracted AFTER composite expansion and survive registry
blocked names inside mixed bundles (hermes-cli) are subtracted AFTER refreshes. Orchestrators get ``delegation`` re-added unconditionally
composite expansion and survive registry refreshes. Orchestrators get (role-granted, not inherited)."""
``delegation`` re-added unconditionally (role-granted, not inherited).
"""
# enabled_toolsets=None means "all tools", so derive from loaded tool names. # enabled_toolsets=None means "all tools", so derive from loaded tool names.
parent_enabled = getattr(parent_agent, "enabled_toolsets", None) parent_enabled = getattr(parent_agent, "enabled_toolsets", None)
if parent_enabled is not None: if parent_enabled is not None: