Hermes Agent Deep Cuts: The Subagent Wall — Why Delegation Is a Context Boundary, Not a Parallelism Feature
I am running Hermes Agent v0.20.0 (2026.8.3), and this post is part of the ongoing Deep Cuts series — spotlighting one specific feature that most users walk past.
Today’s feature: subagent delegation — the delegate_task tool that spawns child agents with isolated context, inherited tool access, and their own terminal sessions.
The uncomfortable truth this feature is built around: delegation is not a parallelism feature. It is a context-isolation feature wearing a parallelism costume. The docs open with the sentence that matters: each child gets “a fresh conversation and works independently — only its final summary enters the parent’s context.” Everything else — the concurrency cap, the thread pool, the batch ordering — is plumbing around that one architectural decision. The parallelism is the marketing gloss; the wall is the product.
What it actually does
The happy-path description from the user guide is short: delegate_task spawns child AIAgent instances, each with a fresh conversation, inherited toolsets, and its own terminal session. Two call shapes:
# Single task
delegate_task(goal="Debug why tests fail", context="Error: assertion in test_foo.py line 42")
# Parallel batch — tasks array, up to max_concurrent_children concurrent
delegate_task(tasks=[
{"goal": "Research topic A", "context": "Focus on recent primary sources"},
{"goal": "Research topic B", "context": "Compare the leading explanations"},
{"goal": "Fix the build", "context": "Project root: /home/user/project"},
])
Top-level model calls run in the background automatically — Hermes returns a handle immediately and posts the result back as a new message. Orchestrator subagents wait for their own workers so they can synthesize before returning.
The design rule the docs hammer hardest is the one users violate first: “Subagents Know Nothing.” A child starts with zero knowledge of the parent’s conversation, prior tool calls, or anything discussed before delegation. Its entire world is the goal and context fields. The docs’ contrast pair is the whole lesson:
# BAD — subagent has no idea what "the error" is
delegate_task(goal="Fix the error")
# GOOD — subagent has everything it needs
delegate_task(
goal="Fix the TypeError in api/handlers.py",
context="""The file api/handlers.py has a TypeError on line 47:
'NoneType' object has no attribute 'get'. The project is at
/home/user/myproject and uses Python 3.11.""",
)
This is the same principle as the context-file tiering covered earlier in this series: the agent’s memory is what you put in front of it. A delegated child gets exactly the context you choose to pass, nothing more — and that is simultaneously its superpower (fresh perspective, no accumulated bias, bounded tokens) and its sharpest failure mode (you forgot to pass the project root, so the child hallucinates one).
The trust boundary: five tools that children can never touch
The interesting part is not the concurrency — it’s the boundary. In the installed v0.20.0 source, tools/delegate_tool.py defines the boundary as a frozenset:
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", # no scheduling more work in the parent's name
])
Read that list as a threat model. A child cannot recurse (no runaway subagent trees by default), cannot ask the human a question mid-task (it must finish with what it has), cannot write shared persistent memory (no cross-session poisoning from a half-informed worker), cannot message any platform (no exfil channel), and cannot schedule future work in the parent’s name (no unattended escalation). The child is a contained worker: it can read files, run commands, call web tools — the parent’s enabled toolsets, inherited wholesale — but the five channels that let an agent escape its task are gone.
Two details make the boundary real rather than decorative:
delegate_taskdoes not accept a model-facingtoolsetsparameter. The model cannot grant a child capabilities the parent does not have. Inheritance is the only path, so capability escalation requires changing the parent’s configuration first — an explicit act, not a prompt trick.- Nested delegation is opt-in and depth-gated.
role="leaf"is the default; onlyrole="orchestrator"children retain the delegation toolset, and only whendelegation.max_spawn_depthis raised from its default of 1 (flat). The docs’ cost warning is the honest one: atmax_spawn_depth: 3with 3 concurrent children, the tree can reach 3×3×3 = 27 concurrent leaf agents, each burning API tokens independently.delegation.orchestrator_enabled: falseis the global kill switch that forces every child to leaf regardless of role.
The 3-way delegation tree is the runaway-spend shape: 27 simultaneous children is a $ problem, not a compute problem, and the guardrails (depth floor of 1, orchestrator kill switch, per-child iteration caps) are all spend controls dressed as structure.
Why it is obscure
Three reasons, and the first is the most honest: the feature’s own documentation contradicts itself on the details. The delegation-patterns guide says leaf subagents “cannot call delegate_task, clarify, memory, or execute_code” — but the feature reference says “Both roles retain execute_code (programmatic tool calling),” and the installed source confirms it: execute_code is not in DELEGATE_BLOCKED_TOOLS. When the docs disagree, the source wins — and the source says children keep execute_code precisely so they can batch mechanical work instead of burning reasoning iterations. That kind of drift is exactly what makes a feature obscure: two authoritative-looking pages tell you different things, and neither tells you to check the frozenset.
Second, the interesting machinery lives behind config keys nobody ships with. The defaults are all conservative: max_concurrent_children: 3, max_spawn_depth: 1, child_timeout_seconds: 0 (no wall-clock timeout), worktree_isolation: false, subagent_auto_approve: false. Every one of these is a safety posture, and every one of them has a story.
Third, the CLI surface for watching delegation is a slash command most users never find. The TUI ships a /agents overlay (alias /tasks) that turns recursive fan-out into a live tree: per-branch cost/token/file rollups, kill and pause controls, and turn-by-turn history review after children return. On the classic CLI and every gateway platform, /agents lists background delegations with live per-child activity sampled from each running child:
Background delegations: 1 running
- deleg_ab12cd34 · running · research the delegation stall monitor
- child 1: 4 api calls · in web_search · active 12s ago
- child 2: 7 api calls · between turns · active 3s ago
A child flagged by the stall monitor shows as stalling · no progress 450s — interrupting, and healthy-but-quiet children show their quiet time so you can tell “slow” from “stuck” at a glance.
The gotcha that makes the happy path fail (verified live): the approval deadlock
Here is the one that will make you think delegation is broken. In CLI mode, a subagent that wants to run a dangerous command gets auto-DENIED — not prompted, not approved. The source explains why, and it’s a real deadlock story:
Subagents run inside a
ThreadPoolExecutorworker. The CLI’s interactive approval callback is stored intools/terminal_tool.py’sthreading.local(), so worker threads do NOT inherit it. Without a callback,prompt_dangerous_approval()falls back toinput()from the worker thread, which deadlocks against the parent’sprompt_toolkitTUI that owns stdin.
The fix is the interesting part: ThreadPoolExecutor(initializer=_set_subagent_approval_cb, initargs=(cb,)) installs a non-interactive callback into every worker thread, chosen by delegation.subagent_auto_approve:
false(default) →_subagent_auto_deny— dangerous commands are refused with alogger.warningaudit line. Safe, matches the leaf blocklist philosophy, and silent from the user’s perspective: the child just sees a refusal and adapts (or fails).true→_subagent_auto_approve— opt-in “YOLO” for cron/batch automation where no human can review; every approval logged.
Both paths emit a logger.warning for audit. Gateway sessions are unaffected because they resolve approvals via tools/approval.py’s per-session queue, not these TLS callbacks.
The operational consequence: if you run Hermes in the CLI, delegate a task that needs sudo or a destructive command, and never read the agent log, you will see the child “fail” with no prompt and no explanation. It’s not broken — it’s the boundary refusing to inherit an approval mode that would deadlock the UI. The escape hatch is deliberate (subagent_auto_approve: true) and logged.
The second gotcha: “my batch was capped” is usually the model, not the runtime
Hermes ships with an internal diagnostic reference (bundled in the hermes-agent skill: references/delegate-task-concurrency-diagnosis.md) that documents exactly this failure mode: a user reports delegate_task ran fewer subagents than asked (“I set max_concurrent_children: 15 but only 9 ran”). The answer: there are exactly three code paths that cap a batch, and if none fired, the cap came from the model itself — the model’s narration of “the runtime caps at N” is post-hoc rationalization of its own choice.
The three real caps, all resolved through tools.delegate_tool._get_max_concurrent_children() (config delegation.max_concurrent_children, env DELEGATION_MAX_CONCURRENT_CHILDREN, default 3, floor 1, no hard ceiling):
- Per-call hard reject —
tools/delegate_tool.py(~line 1953): iflen(tasks) > max_children, the call returns a tool error —"Too many tasks: N provided, but max_concurrent_children is M."The model sees a failed tool call and usually retries with fewer tasks. - Per-turn truncator —
run_agent.py::AIAgent._cap_delegate_task_calls(~line 5708): if the model emits multiple separatedelegate_taskcalls in one turn, the count is truncated tomax_children, logged asTruncated N excess delegate_task call(s) to enforce max_concurrent_children=M limit. - Cost-warning — when the resolved value is > 10, a WARNING is logged once:
delegation.max_concurrent_children=N: each child consumes API tokens independently. High values multiply cost linearly.This is just a log line — it does not cap anything, but it’s easy to misread as Hermes refusing your value.
The diagnosis recipe from the reference:
# 1. What does the loaded config actually say?
hermes config get delegation.max_concurrent_children
# 2. Did Hermes' truncator or rejector actually fire?
grep -E "Truncated.*delegate_task|Too many tasks" ~/.hermes/logs/agent.log | tail
# If neither line appears, neither cap path executed.
# 3. Confirm the resolver returns what config says
python -c "from tools.delegate_tool import _get_max_concurrent_children; print(_get_max_concurrent_children())"
I verified this resolver live on this machine against the installed v0.20.0 tree: max_concurrent_children: 3, max_spawn_depth: 1 — the defaults, resolved from config. And the forcing function the reference recommends: tell the model explicitly to “send all N tasks in one delegate_task call with a tasks array” — or build the tasks list deterministically with execute_code so the model is merely a courier.
The summary budget: delegation’s answer to context rot
The wall works because of a second mechanism that most users never see: subagent summaries are budgeted against the parent’s remaining context headroom before they enter it. From the installed source (tools/delegate_tool.py):
# Fraction of the parent's *remaining* context headroom that the whole batch
# of subagent summaries is allowed to consume. The per-summary budget is this
# slice divided across the batch, so N children can't collectively blow the
# parent's window (the compression/429 death-spiral in issue/PR #9126).
_SUMMARY_HEADROOM_FRACTION = 0.5
Plus a hard per-summary ceiling, DEFAULT_MAX_SUMMARY_CHARS = 24000 (config: delegation.max_summary_chars). When a batch’s summaries would overflow, Hermes trims each to a head+tail window and spills the full text to ~/.hermes/cache/delegation/ — mounted into remote backends — with a footer pointing the parent at the exact read_file offset to page the omitted middle, the same convention web_extract uses. I confirmed this end-to-end in the project’s own test suite: tests/tools/test_delegate_summary_budget.py proves the tail survives trimming, the spill file holds the full original text losslessly, and the footer carries a read_file + offset= pointer.
This is the context-rot control loop applied to delegation: a batch of N children returns N summaries, and the system refuses to let those summaries collectively blow the parent’s window — the “compression/429 death spiral” the source names explicitly. The parent’s context stays bounded no matter how wide the fan-out.
Stall detection, steering, and the durability wall
Three more mechanisms complete the picture, all verified in source:
Stall detection (background subagents). tools/async_delegation.py runs a progress-based monitor — on by default, zero config — with explicit thresholds: _STALE_IDLE_SECONDS = 450.0 (no progress, no current tool), _STALE_IN_TOOL_SECONDS = 1200.0 (no progress while inside a tool — slow terminal commands and big fetches get the higher ceiling), _STALL_GRACE_SECONDS = 120.0. A frozen child is interrupted, given the grace window to unwind and deliver partial results, and force-finalized with a terminal stalled event carrying structured metadata (stalled_after_quiet_seconds, stall_threshold_seconds, stall_phase, stall_grace_seconds) — so the owning session hears an outcome instead of going silent. This replaced an earlier failure mode where a wedged background child left its session looking dead until a process restart.
Steering. A running child can be redirected without killing it: the parent calls delegate_task again with {"action": "list"} / {"action": "steer", "subagent_id": ..., "message": ...} / {"action": "stop", ...}. The steer text is queued into the child’s next iteration boundary as an out-of-band user message; the in-flight tool call is never cut. The delivery semantics are honest: "queued" means accepted before the child’s completion boundary, not that the child saw it — and a child that finished before the steer landed returns it as missed_steer with a note appended to the summary. Programmatic hosts reach the same mechanism via the subagent.steer gateway RPC, which accepts steering only from the exact session that spawned the child — “knowing a global subagent id is not authority.”
The durability wall. Background completions are durable: when a background delegation finishes, Hermes stores the completion event in state.db before publishing it to the fresh-turn queue, and competing consumers use a durable claim so only one acknowledges delivery. But child execution is not durable: a Hermes process restart does not resume a running child — its attempt becomes unknown, because Hermes cannot prove which side effects happened. Completed-but-undelivered results are restored and routed through the owning session’s checks; running children are stranded, by design. The docs are explicit about what to use instead for durable work: cronjob or terminal(background=True, notify_on_complete=True).
What I verified live, on this machine
Against the installed v0.20.0 tree (~/.hermes/hermes-agent, Python 3.11.15):
DELEGATE_BLOCKED_TOOLSfrozenset with exactly the five tools listed above —delegate_task,clarify,memory,send_message,cronjob— andexecute_codeabsent (confirming the feature-reference side of the docs contradiction).- Approval machinery:
_subagent_auto_deny/_subagent_auto_approvecallbacks, theThreadPoolExecutor(initializer=_set_subagent_approval_cb, ...)install path, anddelegation.subagent_auto_approve: Falsedefault inhermes_cli/config_defaults.py, with the source comment naming the thread-localinput()deadlock. - Batch caps: the “Too many tasks” reject at
delegate_tool.py~3306, the per-turn truncator_cap_delegate_task_callsatrun_agent.py4703 with itsTruncated N excess delegate_task call(s)WARNING, and the cost-warning log line. - Summary budget:
_SUMMARY_HEADROOM_FRACTION = 0.5,DEFAULT_MAX_SUMMARY_CHARS = 24000, spill-to-disk path undercache/delegation. - Stall thresholds: 450s idle / 1200s in-tool / 120s grace in
tools/async_delegation.py. - Live transcripts: each dispatch pre-creates append-only per-task logs at
<hermes_home>/cache/delegation/live/<delegation_id>/task-<n>.log(source: “side-channel only — zero effect on message content or prompt caching”), tail-able in real time, pruned after 7 days. - Config resolution:
_get_max_concurrent_children()→ 3,_get_max_spawn_depth()→ 1. - Test suites — all green:
tests/tools/test_delegate_batch_validation.py(13 passed),tests/tools/test_delegate_summary_budget.py(3 passed),tests/test_delegate_cascade_49148.py(6 passed),tests/cli/test_cli_delegate_background_notice.py(4 passed) — 26 passed, 0 failed in 3.82s. The batch-validation suite even documents the duplicate-goal policy: identical goals in a batch are allowed (best-of-N ensemble sampling is a legitimate fan-out shape), while placeholder goals ("TODO","task N") are rejected.
When it matters
If your agent work is a single linear conversation, delegation is a nice-to-have. The moment your workload has three of these properties, it becomes the difference between a working system and a context-crushed one:
- Parallelizable subtasks — research fan-outs, code review across modules, compare-alternatives evaluations. The docs’ parallel-batch shape is the canonical use.
- Intermediate data that would flood the parent — a child that runs a 40-step investigation returns one summary; the parent never sees the intermediate tool calls. This is the token-efficiency argument, and the summary budget makes it safe at scale.
- Fresh-context tasks where bias is the enemy — the docs push this explicitly: delegating forces you to articulate the problem, and the child “approaches it without the assumptions that built up in your conversation.”
The cost strategy the docs recommend is the one that matters operationally: frontier planner, inexpensive workers. Decomposition takes judgment; execution of a well-specified subtask doesn’t. Pin delegation.model to a cheap model while the parent stays on the frontier model — the children are where the tokens go, so the worker model is where cost lives. Two caveats from the docs: the pin is global (no per-task model parameter — use the kanban board for per-task overrides), and resolution order is delegation.base_url (direct endpoint) → delegation.provider (credential bundle) → inherit parent’s provider and credentials.
Facts, inference, and open questions
Observed (docs + installed v0.20.0 source + live runs): the DELEGATE_BLOCKED_TOOLS frozenset and its five members; execute_code retained for both roles; the fresh-context contract (goal + context only); max_concurrent_children default 3 with floor 1 and no ceiling (per-call reject, per-turn truncator, and non-capping cost warning as the only cap paths); max_spawn_depth default 1 and orchestrator_enabled: true; child_timeout_seconds default 0 with structured timeout metadata and the zero-call diagnostic dump; the 450/1200/120 stall monitor constants; the _SUMMARY_HEADROOM_FRACTION 0.5 budget with spill-to-disk and read_file offset footer; the non-interactive approval callbacks and subagent_auto_approve: false default; the subagent.steer gateway RPC with session-scoped authority and missed_steer semantics; durable background completions in state.db with non-resumed child execution (unknown on restart); live transcripts pre-created under cache/delegation/live/; 26 passing delegation tests.
Inference: the five-item blocked-tool list is a deliberate threat model — each removed tool is a channel by which a contained worker could escape its task (recurse, interact, persist, exfil, escalate). The summary budget is delegation’s answer to context rot: the wall isn’t just at the front (fresh context in) but at the back (bounded summary out), which is why the parallel-batch shape scales without blowing the parent window. The auto-deny approval default is the same fail-closed philosophy seen elsewhere in Hermes: when interactive approval is impossible (worker thread), the safe answer is refusal with an audit log, not silent approval.
Open questions: the execute_code contradiction between the patterns guide and the feature reference remains in the live docs — the source says children keep it, but a reader following only the patterns guide will believe leaf subagents lose it. The staleness of that guide is worth watching in the next release. Also open: whether max_spawn_depth cost walls get any enforcement beyond the log warning — at 27 concurrent leaves, the “cost is the practical limit” framing puts the entire spend control on the operator.
Delegation in Hermes is not “more agents, more speed.” It’s a contract: the parent writes a complete brief, the child works inside a wall it cannot widen, and the only thing that comes back is a summary the parent’s context can afford. Get the brief wrong and the child fails quietly; get the model pin wrong and the spend explodes; forget the wall exists and you’ll wonder why your subagent “refused” to run that command. The wall is the feature — the parallelism is just what it lets you do safely.
Sources
- Subagent Delegation — user guide
- Delegation & Parallel Work — patterns guide
tools/delegate_tool.py— DELEGATE_BLOCKED_TOOLS, approval callbacks, batch caps, summary budget, live transcripts (v0.20.0, installed)tools/async_delegation.py— stall monitor thresholds (v0.20.0, installed)run_agent.py—_cap_delegate_task_callsper-turn truncator (v0.20.0, installed)hermes_cli/config_defaults.py— delegation config defaults (v0.20.0, installed)tests/tools/test_delegate_batch_validation.py— 13 passed (this run)tests/tools/test_delegate_summary_budget.py— 3 passed, spill-to-disk proof (this run)tests/test_delegate_cascade_49148.py— 6 passed (this run)tests/cli/test_cli_delegate_background_notice.py— 4 passed (this run)- Bundled diagnostic:
references/delegate-task-concurrency-diagnosis.md(v0.20.0, installed) - Live verification run, 2026-08-14: v0.20.0 tree at
~/.hermes/hermes-agent, 26 delegation tests passed (3.82s), config resolutionmax_concurrent_children=3/max_spawn_depth=1