10 · When Is a Subagent Worth It
Decide when delegation pays back its handoff, shared-state, duplicate-work, and merge costs
Chapter brief
Question to answer
When does a subagent's parallelism repay handoff, duplicate-work, and merge costs?
By the end, you can
- Decide to split by parallelism, context contamination, and permission layering
- Define goal, context, permissions, budget, and output format on every delegation edge
- Set depth, concurrency, recursion, and failure-recovery guardrails
- Read this now if
- Engineers implementing delegation, multi-agent collaboration, or dynamic task decomposition
- Prerequisites
- Understand a single-agent loop, context isolation, and tool permissions
- Deliverable
- A subagent decision sheet and delegation-edge schema
- Evidence boundary
- Gains depend on task parallelism and extra tokens; node count is not maturity
When a subagent is worth the handoff
Section titled “When a subagent is worth the handoff”Scenario: to compare eight platforms, an orchestrator launches eight subagents. Four reopen the same pricing page, two use different version cutoffs, and two return claims without sources. Parallel work ends quickly, but the parent spends longer deduplicating, repairing citations, and resolving conflicts.
Passing conditions: establish a single-agent baseline before splitting; every delegation edge carries goal, scope, source requirement, permissions, budget, and output schema; the orchestrator detects overlapping assignments; parents accept only evidence-bearing results with an explicit completion state.
Price the handoff first
Section titled “Price the handoff first”How the four systems cover the five concurrency-critical concerns:
| Dimension | Codex | Claude Code | OpenClaw | Hermes |
|---|---|---|---|---|
| Subagent abstraction | `SubAgentSource` enum, 5 variants (Review / Compact / ThreadSpawn{depth, agent_role} / MemoryConsolidation / Other) | `TaskType` 7 variants (local_bash / local_agent / remote_agent / in_process_teammate / local_workflow / monitor_mcp / dream) | `SpawnSubagentParams` 13 fields (task / label / agentId / model / thinking / mode / sandbox / cleanup / attachments...) | `delegate_task(goal, context, toolset, max_iterations)` function call |
| Depth control | `ThreadSpawn.depth` recorded; rejects past configured limit | No explicit depth limit (user manages it) | `DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH` configurable; session store persists spawnDepth | `MAX_DEPTH = 2` (parent 0 -> child 1 -> grandchild rejected) |
| Tool propagation | Child inherits parent `exec_policy` / `plugins` / `mcp_manager`; review explicitly disables some features | Each task type carries its own spec | inherit / require sandbox toggle; workspace inheritable or overridden | Five tools hard-blocked: delegate / clarify / memory / send_message / execute_code |
| Concurrency model | Interactive uses receiver / sender; one-shot awaits; callers use `tokio::join!` for parallel | `isConcurrencySafe()` per task type tells dispatcher when to parallelize | Per-session active-run limit; push-based completion + auto-announce so callers do not poll | ThreadPoolExecutor, default 3 workers, configurable; parent blocks until all done |
| Completion signal | Receiver pushes `EventMsg::TurnComplete` events back to parent | Task FSM: pending -> running -> completed / failed / killed | subagent-announce pushes events to parent; falls back to polling on timeout | Synchronous `as_completed` over futures |
Source evidence: permissions, depth, and return paths
Section titled “Source evidence: permissions, depth, and return paths”Codex · Split “spawn subagent” into 5 typed sources, each routing to a different permission and prompt path
Section titled “Codex · Split “spawn subagent” into 5 typed sources, each routing to a different permission and prompt path”Codex first asks why a child agent exists, then records that purpose in SubAgentSource. Each source carries its own prompt, permissions, and telemetry path; the child inherits parent services instead of opening a privilege escape.
Even though “user manually triggers /review for code review”, “context exceeded so trigger summarisation”, “user invokes their own ThreadSpawn template”, and “system periodically consolidates memory” are all ‘spawn a subagent’, they use different prompts, allow different tools, expect different output shapes, may or may not show in the UI, and emit different telemetry.
If they all funnel through one generic spawn function, every caller has to configure these differences. As call sites multiply, missing permissions and diverging configuration become more likely.
So Codex forces every spawn to carry a SubAgentSource enum value that explicitly tells the system what kind of subagent this is:
Codex codex/codex-rs/protocol/src/protocol.rs:2558-2576 SubAgentSource 5 variants; each routes to a different prompt and permission profile
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]#[serde(rename_all = "snake_case")]#[ts(rename_all = "snake_case")]pub enum SubAgentSource { Review, Compact, ThreadSpawn { parent_thread_id: ThreadId, depth: i32, #[serde(default)] agent_path: Option<AgentPath>, #[serde(default)] agent_nickname: Option<String>, #[serde(default, alias = "agent_type")] agent_role: Option<String>, }, MemoryConsolidation, Other(String),}These 5 sources carry very different semantics and constraints. Review corresponds to the user typing /review for code review: the subagent runs the review-specific system prompt and disables a set of features (see chapter 09), output is a structured code review report. Compact corresponds to system-triggered context compression when token limit is hit.
The subagent receives the entire parent conversation history and outputs a “semantically preserved but shorter summary” (see chapter 11 on session lifecycle). ThreadSpawn is the user-configured subagent template, freely composable via agent_role and agent_nickname (e.g.
“define a testwriter agent specifically for adding tests to my code”); critically, this variant carries a depth field so the framework can track “how deep is the current call chain” and reject further spawns past a configured limit. MemoryConsolidation is periodic memory consolidation: the subagent runs in background to distill long-term-worthy insights from conversation history into memory entries (see chapter 16). Other(String) is an escape hatch for plugins and experimental scenarios, but the string must be explicit.
“Anonymous spawn” is forbidden.
The benefits of this typing are immediate. First, dispatch can branch on source: Review goes through review prompt, Compact goes through summarise prompt, MemoryConsolidation runs silently in background, ThreadSpawn shows up in the user’s UI, Other gets the strictest default constraints.
Without this typing, dispatch would have to heuristically infer from prompt content, fragile and unmaintainable.
Second, telemetry is consistent: every spawn event automatically carries a source label, and analysis can ask “how many Reviews last week, average duration, failure rate?” rather than manually mining a pile of spawn logs.
Third, the permission path is explicit: each source maps to its own feature constraints (Review automatically disables web_search because reviewing code doesn’t need network, while ThreadSpawn allows it by default), and dispatch just looks up the table by source.
Spawning itself goes through one entry point Codex::spawn(CodexSpawnArgs { ... }), but the args carry many services inherited from the parent session:
Codex codex/codex-rs/core/src/codex_delegate.rs:74-105 Child agent inherits 7 service categories from parent session, avoiding cold-start overhead
pub(crate) async fn run_codex_thread_interactive( config: Config, auth_manager: Arc<AuthManager>, models_manager: SharedModelsManager, parent_session: Arc<Session>, parent_ctx: Arc<TurnContext>, cancel_token: CancellationToken, subagent_source: SubAgentSource, initial_history: Option<InitialHistory>,) -> Result<Codex, CodexErr> { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, installation_id: parent_session.installation_id.clone(), auth_manager, models_manager, environment_manager: Arc::clone(&parent_session.services.environment_manager), skills_manager: Arc::clone(&parent_session.services.skills_manager), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source.clone()), thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), persist_extended_history: false, metrics_service_name: None, // ... inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), // ...Look at inherited_exec_policy: this is the most critical safety boundary in Codex’s subagent design.
A child cannot be more permissive than its parent: a binary the parent forbids is also forbidden for the child, no “spawn a subagent to bypass execpolicy” escape route. This single constraint shuts down a particularly nasty attack pattern.
If parent and child could have separate permissions, a malicious prompt could induce the agent to spawn a subagent “to help me run this tool” and bypass the parent’s restrictions, effectively adding a privilege escalation attack surface.
Codex closes that path directly. The same logic applies to plugins / mcp_manager / skills_manager / environment_manager / agent_control / models_manager / auth_manager: they are all Arc references inherited from the parent, the child sees exactly the parent’s toolset (minus any explicitly disabled features), with no path to gaining capabilities the parent lacks.
Approval handling follows the same logic: when a subagent wants to run a risky command, it does not pop its own UI dialog for user approval (if it did, the user would see a flurry of popups from different subagents with no way to make sense of them); instead, the approval event is pushed back to the parent session, which decides whether to surface a prompt.
So from the user’s perspective, every approval comes from the parent agent; subagents are “an extension of the parent’s hand”, UI consistency stays intact.
This is the same idea as the review sub-agent’s AskForApproval::Never in chapter 09 applied generally: subagents never own the UI, all user interaction goes through the parent.
Claude Code · Unify subagents with every other async execution into one Task abstraction
Section titled “Claude Code · Unify subagents with every other async execution into one Task abstraction”Async work in an IDE is broader than child agents. Claude Code uses Task for local commands, remote agents, workflows, and teammates, then combines a state machine with isConcurrencySafe to decide whether work can queue or run together.
Running a local bash command is async, calling a remote agent service is async, collaborating with another teammate agent in the same process is async, running a workflow is async.
Writing separate lifecycle managers, state machines, concurrency controls, and UI displays for each async execution type incurs high long-term maintenance cost, and behaviour drifts apart (a bash-task cancel mechanism diverging from a subagent cancel mechanism, etc.).
So Claude Code abstracts all async executions into one unified Task concept, with TaskType being an enum of 7 variants:
Claude Code claude-code/src/Task.ts:6-30 TaskType 7 variants; subagent is just one of them
export type TaskType = | 'local_bash' | 'local_agent' | 'remote_agent' | 'in_process_teammate' | 'local_workflow' | 'monitor_mcp' | 'dream'
export type TaskStatus = | 'pending' | 'running' | 'completed' | 'failed' | 'killed'
/** * True when a task is in a terminal state and will not transition further. * Used to guard against injecting messages into dead teammates, evicting * finished tasks from AppState, and orphan-cleanup paths. */export function isTerminalTaskStatus(status: TaskStatus): boolean { return status === 'completed' || status === 'failed' || status === 'killed'}The 7 TaskTypes each cover a specific async-execution scenario. local_bash is “run a shell command locally” (cd to a directory, run tests, run lint); it shares the same lifecycle management as spawning a subagent but does not involve LLM calls. local_agent is “run a subagent locally” (Codex-style subagent), primarily for letting the model split a big task off to a focused subagent. remote_agent is “call a remote agent service” (e.g. an Anthropic-deployed endpoint specialised for one task). in_process_teammate is “collaborate with another teammate agent in the same process”.
Typical use is an IDE with multiple agent profiles each handling a slice (one writes code, one writes docs, one does review), spawning each other when needed. local_workflow is “run a predefined workflow” (e.g. a “release process” or “review process” composed of multiple standardised steps). monitor_mcp is “run a long-running MCP monitor task” (e.g. monitoring GitHub PR status changes). dream is the most distinctive new one introduced in Claude Code 2.1, a “background prefetch subagent”.
While the user is having their main conversation, a dream task in the background prepares resources that might be needed (e.g. pre-greps relevant project files, pre-loads relevant docs); this is the only TaskType not directly user-triggered, the reason it exists is “make the agent feel faster” (resources are already prepared by the time the user asks).
TaskStatus is a clean state machine: pending (waiting to schedule), running (executing), completed (succeeded), failed (errored), killed (cancelled). isTerminalTaskStatus(status) is a simple helper for “has this task reached a terminal state”, used to prevent injecting messages into dead teammates, evicting tasks from AppState when terminal, and orphan resource cleanup.
The “state machine terminal state” abstraction is foundational but critical in async execution systems. Without it, you get a flood of race conditions.
Every task also self-reports whether it is “concurrency-safe”: every tool implements an isConcurrencySafe() interface returning true or false, and the dispatcher (chapter 04 on tool system) reads this value to decide whether to run this tool in parallel with the rest of the turn’s tool calls.
For instance, reading a file is concurrency-safe (multiple parallel reads have no side effect), writing to a file is not (two parallel writes to the same file produce chaos), spawning a subagent is usually safe (each subagent carries independent state), running a bash command is usually not safe (commands may have side-effect dependencies on each other).
This “tool self-declared capability” frees the dispatcher from hard-coding concurrency rules per tool.
Claude Code also provides a set of tools the model uses to manage the task list itself: TaskCreateTool creates a new task, TaskListTool lists currently active tasks, TaskUpdateTool injects new info into a task, TaskGetTool retrieves task results, TaskStopTool terminates a task.
These 5 tools form a user-level API, and the model can decide in its prompt “I will spawn 3 subagents to do these three things in parallel, then wait for all to complete before consolidating”.
Every task writes its output to disk via outputFile, keeping the main process from running out of memory from many subagent outputs. This is a very pragmatic engineering detail.
OpenClaw · Build subagents as a complete subsystem: 6 modules + 13 fields + anti-polling warning
Section titled “OpenClaw · Build subagents as a complete subsystem: 6 modules + 13 fields + anti-polling warning”OpenClaw treats delegation as a persistent run object. Spawn, registry, depth, announce, attachments, and lifecycle events each own a boundary, and completion returns to the parent through a push event.
Open the source and you see a coordinated set of files: subagent-spawn.ts handles creation, subagent-registry.ts handles tracking active subagents, subagent-depth.ts handles cross-process depth persistence, subagent-announce.ts handles completion push, subagent-attachments.ts handles file attachments to subagents, subagent-lifecycle-events.ts handles lifecycle events (start / complete / fail / cancel).
This “6-file coordination” decomposition shows OpenClaw does not treat subagents as a lightweight tool but as a first-class runtime entity peer to the main agent.
The most direct illustration: SpawnSubagentParams has 13 configuration fields:
OpenClaw openclaw/src/agents/subagent-spawn.ts:46-80 SpawnSubagentParams 13 fields; finer-grained config than either Codex or Claude Code
export type SpawnSubagentParams = { task: string; label?: string; agentId?: string; model?: string; thinking?: string; runTimeoutSeconds?: number; thread?: boolean; mode?: SpawnSubagentMode; // "run" | "session" cleanup?: "delete" | "keep"; sandbox?: SpawnSubagentSandboxMode; // "inherit" | "require" expectsCompletionMessage?: boolean; attachments?: Array<{ name: string; content: string; encoding?: "utf8" | "base64"; mimeType?: string; }>; attachMountPath?: string;};Each of these 13 fields solves a specific scenario. task is the description of what the subagent should do. label is the human-readable name (so the parent can say in status updates “review-agent still running, test-writer completed”). agentId chooses which agent profile to use (OpenClaw supports multiple agent templates). model lets the subagent use a different model from the parent (e.g. parent uses sonnet but the child uses opus for harder reasoning). thinking specifies reasoning depth (low / medium / high). runTimeoutSeconds is the subagent’s maximum execution time (it gets killed past this). thread decides whether to start a new thread or extend the parent’s. mode is “run” or “session”: run ends after completion, session stays alive for follow-up. cleanup decides whether to delete or keep the subagent’s session data after completion (keep for debugging, delete to save disk). sandbox decides whether to inherit the parent’s sandbox or require an independent one. expectsCompletionMessage decides whether the parent waits synchronously or fire-and-forget. attachments is a list of file attachments to pass to the subagent (each with name / content / encoding / mimeType). attachMountPath is where the attachments mount inside the subagent’s sandbox.
This “13 dimensions configurable” makes OpenClaw able to serve very diverse agent orchestration scenarios, but it also means new users will trip over mode / cleanup / sandbox enum fields. Sensible defaults and clear docs are critical.
OpenClaw’s most distinctive design is push-based completion + writing the anti-polling warning literally into the spawn tool’s return value text:
OpenClaw openclaw/src/agents/subagent-spawn.ts:81-84 Hard warning to the model: do not poll, wait for the push event
export const SUBAGENT_SPAWN_ACCEPTED_NOTE = "Auto-announce is push-based. After spawning children, do NOT call sessions_list, sessions_history, exec sleep, or any polling tool. Wait for completion events to arrive as user messages, track expected child session keys, and only send your final answer after ALL expected completions arrive. If a child completion event arrives AFTER your final answer, reply ONLY with NO_REPLY.";That string is literally the spawn tool’s return value. After spawning a subagent, the model reads this and is explicitly told four things: “do not poll” (do not call sessions_list, sessions_history, exec sleep, or any other tool that tries to actively query subagent state), “wait for push events” (the subagent’s completion arrives as a user message automatically delivered into the parent’s conversation), “track the expected child session keys” (remember the session keys you got at spawn time; only give a final answer once all expected keys have completed), “if a completion arrives after your final answer, reply NO_REPLY” (do not respond again, avoid duplicating content).
This “write engineering discipline directly into tool output text” looks simple-minded but is extremely effective: the model reads the spawn return value while deciding what to do next, and telling it “do not poll” in the spawn output is far more visible and actionable than burying it in the system prompt.
This is an engineering fix for the “subagent mode burns tokens polling” anti-pattern, reflecting the OpenClaw team having observed the same issue recurring in real use and deciding to use the tool return text as an in-context warning.
Depth information is persisted to the session store rather than kept in memory:
OpenClaw openclaw/src/agents/subagent-depth.ts:1-48 spawnDepth persisted to session store, survives process restart
import fs from "node:fs";import JSON5 from "json5";import type { OpenClawConfig } from "../config/config.js";import { resolveStorePath } from "../config/sessions/paths.js";import { getSubagentDepth, parseAgentSessionKey } from "../sessions/session-key-utils.js";import { resolveDefaultAgentId } from "./agent-scope.js";
type SessionDepthEntry = { sessionId?: unknown; spawnDepth?: unknown; spawnedBy?: unknown;};
function normalizeSpawnDepth(value: unknown): number | undefined { if (typeof value === "number") { return Number.isInteger(value) && value >= 0 ? value : undefined; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return undefined; } const numeric = Number(trimmed); return Number.isInteger(numeric) && numeric >= 0 ? numeric : undefined; } return undefined;}This matches OpenClaw’s overall positioning, a long-lived agent platform where sessions resume across processes, so subagent depth has to be persistent state, not an in-memory variable scoped to the process.
Imagine the scenario: parent agent spawns a subagent, then the process is restarted by the IDE; after restart, the parent session resumes and the subagent resumes too.
If depth lived only in memory, both would restart from depth=0 after restart, breaking the “max N levels of recursion” guarantee (because in principle after restart you could spawn another N levels).
Persisting to the session store prevents that corner case.
Hermes · A narrow subagent model: one function, 5 hard-blocked tools, and a fixed MAX_DEPTH
Section titled “Hermes · A narrow subagent model: one function, 5 hard-blocked tools, and a fixed MAX_DEPTH”Hermes exposes one delegate_task function. MAX_DEPTH=2, a tool allowlist, and heartbeats cover the small case; the parent waits synchronously, so long work needs a separate UI measurement.
This design covers bounded delegation without adding a general orchestration lifecycle. Whether that scope is sufficient depends on the product’s need for cancellation, recovery, and asynchronous progress.
The reason: Hermes itself is a chat agent (multi-platform chat), not an agent orchestration framework, so its subagent needs come down to one thing, “give a clearly defined sub-task to a temporary agent, get the result”, and it does not need OpenClaw’s level of complex lifecycle management.
But “simple” is not “undesigned”. Hermes’s critical safety constraints are all baked into constants so no caller can bypass them:
Hermes hermes-agent/tools/delegate_tool.py:31-54 Hard-block 5 tools to prevent recursion / cross-platform side effects / shared-state pollution; MAX_DEPTH baked in
# Tools that children must never have access toDELEGATE_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 "execute_code", # children should reason step-by-step, not write scripts])
# Build a description fragment listing toolsets available for subagents.# Excludes toolsets where ALL tools are blocked, composite/platform toolsets# (hermes-* prefixed), and scenario toolsets._EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"})_SUBAGENT_TOOLSETS = sorted( name for name, defn in TOOLSETS.items() if name not in _EXCLUDED_TOOLSET_NAMES and not name.startswith("hermes-") and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", [])))_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS)
_DEFAULT_MAX_CONCURRENT_CHILDREN = 3MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2)Each of these 5 hard-blocked tools corresponds to a real disaster Hermes actually hit in production. If delegate_task self-recursion were not blocked, the subagent could spawn the same tool calling itself.
It instantly turns into infinite nesting: every level doubles the tokens, in a few levels the context window blows up and the bill goes wild. clarify is Hermes’s tool for the agent to ask the user for clarification.
Subagents should not have it because the user is currently in conversation with the parent agent, and a subagent unilaterally questioning the user creates UI chaos where nobody can tell who is asking what. memory is Hermes’s tool for writing to long-term MEMORY.md; letting a subagent write would pollute the parent’s long-term preferences.
Subagents are temporary, what they learn shouldn’t affect the parent’s permanent state. send_message is the tool for sending to Telegram / Slack / Discord; letting a subagent use it would cause cross-platform duplicate messages (one Telegram message gets sent by parent and child both). execute_code is the tool for running a Python sandbox.
Subagents shouldn’t use it because they are scoped to “use existing tools to solve one focused problem”; spawning another Python sandbox makes the reasoning chain hard to trace.
These 5 restrictions share one property: they are not “subagents might abuse them so block”, they are “this role conceptually should not have this capability”.
That is a design principle worth learning: restrictions are not about distrusting the model but about cleanly defining “this role does not have this capability”.
The meaning of MAX_DEPTH = 2 is: the root agent (the one the user is talking to) is depth 0, it can spawn subagents at depth 1, depth 1 subagents trying to spawn further are rejected (because they don’t have the delegate_task tool).
Why 2 and not 3 or 5? The Hermes team’s experience: practically useful subagent tasks almost always fit in one layer (parent focuses on one thing and delegates a helper task out); going to the second layer typically reflects the model falling into a useless decomposition loop (every sub-task gets further decomposed to grandchildren that don’t actually need it).
Hermes chooses a depth limit of two because deeper delegation is treated as a decomposition-loop risk in this design. There is no cross-product sample supporting the 99% figure; if you relax the limit, measure completion rate, duplicate work, and tokens by depth.
Concurrency goes through ThreadPoolExecutor:
Hermes hermes-agent/tools/delegate_tool.py:56-84 ThreadPoolExecutor with configurable concurrency; parent blocks until all children complete
def _get_max_concurrent_children() -> int: """Read delegation.max_concurrent_children from config, falling back to DELEGATION_MAX_CONCURRENT_CHILDREN env var, then the default (3).
Uses the same ``_load_config()`` path that the rest of ``delegate_task`` uses, keeping config priority consistent (config.yaml > env > default). """ cfg = _load_config() val = cfg.get("max_concurrent_children") if val is not None: try: return max(1, int(val)) except (TypeError, ValueError): logger.warning( "delegation.max_concurrent_children=%r is not a valid integer; " "using default %d", val, _DEFAULT_MAX_CONCURRENT_CHILDREN, ) env_val = os.getenv("DELEGATION_MAX_CONCURRENT_CHILDREN") if env_val: try: return max(1, int(env_val)) except (TypeError, ValueError): pass return _DEFAULT_MAX_CONCURRENT_CHILDRENDEFAULT_MAX_ITERATIONS = 50_HEARTBEAT_INTERVAL = 30 # seconds between parent activity heartbeats during delegationThe parent synchronously blocks on as_completed() waiting for all children to complete; during execution a heartbeat fires every 30 seconds so the upper UI knows the parent is still waiting (not deadlocked).
This is the classic synchronous concurrency model, the opposite of OpenClaw’s push-based async.
The upside of synchronous-blocking is the parent’s logic is dead simple (spawn all children, wait for all, consolidate); the downside is the parent cannot do anything else while waiting, which hurts UI responsiveness for long tasks.
The Hermes snapshot chooses synchronous waiting and emits a heartbeat. Whether that fits a workload depends on child-duration distribution, UI responsiveness, and slow-child timeouts; this article has no one-to-two-minute benchmark.
Invariants shared by child agents
Section titled “Invariants shared by child agents”Across the four snapshots, four control questions recur: recursion depth, delegated permissions, completion return paths, and waiting behavior. Treat them as an audit checklist, not universal consensus.
The first is how depth is controlled. Codex records ThreadSpawn.depth, OpenClaw persists spawnDepth, and Hermes hard-codes MAX_DEPTH = 2; the cited Claude Code Task path does not show the same explicit cap. Unbounded recursion expands calls, tokens, and debugging paths, so an implementation should enforce depth, total-call budget, or a non-recursive toolset rather than rely on prompt discipline.
The second is whether a child can delegate again. Hermes puts delegate_task in a blocked-tool list; other implementations constrain different spawn paths through source, feature flags, toolsets, or depth. Recursive delegation can be a product feature, but it needs a depth limit, total budget, and permission inheritance rather than prompt-only restraint.
The third is that delegation should not grant broader capability by itself. This protects against prompt injection using spawn to bypass a parent’s limits. The cited implementations tighten this through inherited execution policy, restricted toolsets, or recursion blocks. Because the mechanisms differ, verify every spawn path instead of inferring the boundary from one default.
The fourth is how completion reaches the parent. Codex sends EventMsg::TurnComplete through receiver/sender, OpenClaw uses subagent-announce with an explicit “do not poll” warning, Hermes awaits futures with as_completed(), and Claude Code exposes TaskStatus transitions. Repeated model-driven status queries add tokens and latency; quantify the difference by comparing push, await, and polling traces on the same tasks.
Choose by task shape
Section titled “Choose by task shape”These checks do not imply one abstraction. The actual choice is how much state, permission, and scheduling the task needs, and how much maintenance surface the product can own.
If an orchestration framework genuinely needs cross-process state, attachments, and completion notifications, start by studying OpenClaw’s subagent platform. The cited snapshot’s six modules, 13 fields, push notification, and depth persistence cover those capabilities. The cost is long-term configuration, storage, and lifecycle maintenance; task samples and resource budgets should justify any claim about dozens of concurrent agents.
If subagent uses have converged to a few scenarios such as review, compaction, and memory consolidation, start by studying Codex’s SubAgentSource typing. Typing gives dispatch, telemetry, and permission paths an explicit basis. The cost is that a new use changes the protocol and is harder to add externally; whether that constraint pays off depends on change frequency and plugin needs.
If an IDE must manage subagents together with bash, remote calls, and workflows, start by studying Claude Code’s Task abstraction. A shared lifecycle, state machine, UI, and cancellation path reduces entry-point differences. The cost is maintaining seven TaskType boundaries, while the cited path does not show a common explicit depth cap.
If a conversational agent only needs one bounded delegation, start by studying Hermes’s delegate_task function and five blocked tools. Its API surface is small and each restriction maps to a concrete risk; comprehension and delivery time still depend on surrounding tools, tests, and failure handling. It lacks attachments, mode, and cleanup controls, and synchronous waiting affects long-task responsiveness.
Choice: parallel gain must beat handoff cost
Section titled “Choice: parallel gain must beat handoff cost”There is no star rating here. Parallelism should follow task separability and handoff cost; this site has not measured which fan-out wins.
| Task shape | Start with | Measure first |
|---|---|---|
| Fixed child-agent roles that inherit parent policy | Codex SubAgentSource and service inheritance | Handoff fields, narrowed permissions, and failure return |
| Shell, remote calls, and agents share one scheduler | Claude Code TaskType, status machine, and concurrency flags | Queueing, cancellation, and shared-resource conflicts |
| Multi-model orchestration needs attachments and cross-process notices | OpenClaw spawn/registry/depth/announce | Push completion, duplicate work, storage, and merge time |
| One constrained delegation is enough | Hermes delegate_task, depth, and blocked tools | Wall time, parent blocking, and budget exhaustion |
Run the same task with 1, 2, 4, and 8 agents. Record wall time, input/output tokens, duplicate reads, conflicts, and human merge time before making a speed claim.
Start with one constrained delegation
Section titled “Start with one constrained delegation”Below is a starting checklist from the cited implementations. Validate the task shape with a functional delegate, then add permissions, notification, and persistence from traces.
Build recipe
最小可行
- Start functional (borrow from Hermes' delegate_task): args task (task description) / context (necessary context, not parent's full history) / toolset (restricted tool set) / max_iterations (independent budget); simple and clear, args explicit
- Hard-block 5 tools to prevent recursion / cross-platform side effects: blocked_tools = [delegate, clarify, memory, send_message, execute_code]; delegate prevents recursion, clarify prevents child asking parent back, memory prevents shared memory pollution, send_message prevents out-of-bounds messaging, execute_code restricts available tools
- Set MAX_DEPTH early (borrow from Hermes' two levels): parent(0) → child(1) → grandchild reject. Two levels is a source-specific product choice, not evidence of 95% coverage; require task samples and audit records before going deeper
- Make concurrency configurable (Hermes defaults to 3 in this snapshot); the parent waits for children through ThreadPoolExecutor. Three is an implementation parameter, not a universal sweet spot; tune from provider quota, task latency, memory, and rate-limit traces
进阶
- Type the source enum (borrow from Codex' SubAgentSource): Review / Compact / ThreadSpawn / MemoryConsolidation / Other; different source sub-agents need different trace / monitoring strategies; typing enables event stream classification queries
- Child inherits parent core services (exec_policy / plugins / mcp / skills); never more permissive than parent: prevents child "bypassing parent's safety constraints to do things" (many attack patterns rely on this)
- Approvals route back to parent (child has AskForApproval::Never); child never owns UI: child runs silently, approval events bubble to parent agent; avoiding "multiple sub-agents simultaneously prompting" UX disaster
- Push-based completion (borrow from OpenClaw' subagent-announce): spawn return value tells the model "don't poll, just wait for notification"; model polling wastes tokens calling wait_for_subagent tool
- Persist depth to session store (borrow from OpenClaw' subagent-depth.ts): survives process restart with depth info preserved (recovery knows which layer this is); pure in-memory depth can't resume
- Unify subagents with other async execution (borrow from Claude Code' Task + TaskStatus): 7 TaskTypes managed under one abstraction (local_bash / local_agent / remote_agent / in_process_teammate / local_workflow / monitor_mcp / dream), UI / monitoring sees Task without distinguishing types
- Spill outputs to disk (borrow from Claude Code' outputFile): a fleet of sub-agent outputs OOMs main process; once on disk main process holds only path; this is long-running scenario memory optimisation
一开始别做
- Don't share parent conversation history wholesale into child: context inheritance biases child toward "direction already taken" (cognitive anchoring bias), losing independent judgement; only pass task description + necessary context
- Don't allow unbounded same-kind spawning: recursion increases call count, tokens, and debug depth. Set MAX_DEPTH and a total budget, then choose the limit from task traces
- Don't poll for child completion: parent agent constantly calling wait_for_subagent burns tokens for nothing; use push-based completion + hard warning (borrow from OpenClaw)
- Don't let children run send_message / write shared memory: cross-platform side effects (child suddenly messages user, user confused), shared state pollution (multiple children writing memory simultaneously, data conflicts)
- Don't ignore attachment size: base64 expands transfer bytes, while model-token cost depends on the provider's multimodal encoding. Enforce both byte and provider-token budgets, compressing or rejecting over-limit inputs
Put concurrency differences back in context
Section titled “Put concurrency differences back in context”Lined up, the abstraction-depth spectrum is one glance: function (Hermes) -> typed enum (Codex) -> task abstraction (Claude Code) -> full platform (OpenClaw).
Evidence for delegation permissions and completion
Section titled “Evidence for delegation permissions and completion”What to carry forward and the next experiment
Section titled “What to carry forward and the next experiment”A subagent is not a free thread. Each node adds a context retelling, permission boundary, failure-recovery path, and merge cost. Default to one loop; split only when parallelism, context isolation, permission layering, or a human gate gives a specific benefit.
Next experiment: run the same task with 1, 2, 4, and 8 workers under the same model and total budget. Record wall time, total tokens, duplicate URLs, conflicting claims, missing sources, human merge time, and final quality. Do not compare “first response” alone; total cost and verifiable output belong in the same result.
Appendix: exercises and review
Section titled “Appendix: exercises and review”Open the exercises and ten review questions
Exercises
Section titled “Exercises”- Easy: write a
delegate_taskfunction with argsgoal / context / toolset / max_iterations. The child toolset hard-blocks recursive delegate. - Medium: add a depth limit. Record
spawn_depthin the session and reject spawns beyond 2. Verify: a three-level spawn fails at level 3 with an error containing the depth. - Medium: push-based completion. The parent must not poll for child status. The child’s completion arrives as a user message in the parent’s next turn. Verify: after spawning, the parent can only wait for the message, not call
sessions_list. - Hard: type the source. Define a
SubAgentSourceenum with at least Review / Compact / ThreadSpawn. Each source uses a different system prompt and a different feature constraint. Verify: Review disables web_search automatically; ThreadSpawn allows web_search.
Review questions
Section titled “Review questions”Q1 · Concept: Why does Codex type the subagent source as a SubAgentSource enum? Wouldn’t a single spawn(prompt) function do?
Typing solves three concrete problems:
1. Dispatch paths diverge.
The five sources take genuinely different code paths. Review loads the review prompt and disables a batch of features; Compact switches to the summarization prompt while allowing some tools;
MemoryConsolidation goes through the memory subsystem and cannot use file edit; ThreadSpawn is a user template whose feature set is config-driven.
With a generic spawn(prompt), all that logic moves to the caller; every new type forces every caller to be updated. The enum keeps dispatch centralized.
2. Telemetry can aggregate.
Codex cares about “average review subagent duration”, “compact failure rate”, and “memory consolidation trigger frequency”. These metrics only make sense grouped by source.
If source were a free-form string, each caller would invent its own (review / Review / “code review” / “/review”) and the metrics would be unusable. The enum forces alignment.
3. Permission / sandbox configuration lives in one place.
Each source corresponds to a reasonable “tightening”: Review wants AskForApproval::Never; Compact wants web_search=Disabled; MemoryConsolidation does not need BashTool.
The enum puts these definitions in one file (subagent_source.rs) instead of scattering them across five callers.
Why not a trait + dynamic dispatch?
Rust traits would also work. But the enum serializes more directly (it already implements Serialize + Deserialize + JsonSchema + TS), which matters because the protocol must travel to the TUI, the log, and the subagent itself.
Enum + serde gives one consistent shape.
Counter-example: Claude Code’s TaskType is a string union, no strong type. Adding a new variant relies on TypeScript exhaustiveness checks; you might miss a switch case. Codex’s Rust enum forces every match to be updated by the compiler.
Source: codex/codex-rs/protocol/src/protocol.rs:2558-2576 plus the source-specific branches in codex_delegate.rs.
Follow-up: “Why does Other(String) exist?” To cover plugin and experimental flows. The five variants are the categories in this source snapshot, not evidence of 95% production coverage; new types can live in Other("plugin-name") until their usage and constraints justify a real variant.
Q2 · Architecture: Codex makes the child inherit the parent’s exec_policy / plugins / mcp_manager. When does this “parent-child shared service” cause problems?
Inheritance is correct by default for three reasons:
- Child is never broader than parent: there is no “spawn a child to bypass limits” escape hatch. Safe.
- Avoid duplicate initialization: plugins and mcp_manager may spawn processes and establish connections. Sharing can skip that work, but this article has no 100-500ms benchmark; measure cold and reused paths separately.
- State stays in sync: if the parent just disabled a plugin, the child should also see it disabled. Sharing prevents drift.
But three concrete scenarios bite back:
Scenario 1 · Concurrent mutation of mcp_manager state.
mcp_manager keeps an “active MCP servers” list. Parent enables git server at turn 1; child also enables it mid-turn (refcount = 2). Parent disables at turn 2 (refcount = 1, server stays up).
Child finishes without an explicit disable (it inherited from parent). Now the git server lives forever: process leak.
Codex’s fix: mcp_manager is shared via Arc, but enable/disable is parent-exclusive; the child can only read. The dispatch layer in codex_delegate enforces this.
Scenario 2 · exec_policy updated by parent while child is running.
Rust Arc::clone(&parent.exec_policy) gives the child an immutable Arc. But if exec_policy internally holds a Mutex<HashMap<...>> and the parent edits it mid-flight, should the child see the new policy?
Codex picks “snapshot at spawn”: the child uses the policy as it was at spawn time; later changes do not affect it. Rationale: the child’s prompt was decided at spawn time; runtime policy changes make child behavior unpredictable, and “user edits config and expects it to apply mid-task” is unintuitive.
Trade-off: in-flight subagent runs cannot pick up policy changes. Codex prefers “predictable behavior” over “live config”.
Scenario 3 · skills_manager load order.
skills_manager keeps a “loaded skills” list. Parent loads skill A at turn 0; child loads skill B at turn 0.5; should the parent see skill B at turn 1?
Codex’s choice: shared manager, both parent and child see B. Rationale: skills are “loadable capabilities”, not “behavioral state”. Loading more does no harm; whoever needs it next gets it ready.
Engineering lesson: the shared-service boundary follows semantics. Read-only loadable capabilities are sharable; capabilities with state machines need cloning; cross-child mutation of parent state needs strict enforcement.
Source: codex/codex-rs/core/src/codex_delegate.rs:74-105 (the seven Arc::clone lines).
Follow-up: “What about cross-process agents?” One service tree per process; cross-process subagents get a serialized snapshot, not an Arc. Cross-process subagents therefore lose live state (e.g., plugin updates) but gain isolation. Trade-off you make at the protocol layer.
Q3 · Concept: Hermes blocks five tools for children (delegate_task / clarify / memory / send_message / execute_code). What real failure does each correspond to?
Each item maps to a failure mode described around the source. This site does not have Hermes incident records to verify the anecdotes independently:
delegate_task · Self-recursive nesting
Children are also LLM-driven. If a child has delegate_task, it will think “this task is too big, let me spawn my own child”. Parent → child → grandchild → great-grandchild; every level burns tokens.
A four-level recursive spawn consuming 60K of context is presented here as a source-side anecdote, not a site-verified incident. The verifiable design is MAX_DEPTH = 2 plus the tool-level block.
clarify · Child cannot talk to the user
The user is talking to the parent. A child popping a clarify("which algorithm do you want?") confuses the routing: either the UI shows multiple stacked clarify dialogs, or the message vanishes silently.
The deadlock story is also an unverified source-side anecdote. The testable failure mode is routing ambiguity when a child invokes clarify; the design response is to return uncertainty to the parent instead of opening child-owned UI.
memory · Shared MEMORY.md pollution
MEMORY.md is Hermes’s long-term memory file, user-scoped. If a child can write:
- Child A: “user prefers concise answers”
- Child B: “user prefers detailed explanations” (because A and B saw different tasks)
After both write, MEMORY.md contradicts itself, and the parent’s next answer splits personality. From then on only the parent writes; children emit short-term facts in the result and the parent decides whether to promote them.
send_message · Cross-platform duplicate notifications
send_message is Hermes’s Telegram / Slack / Email tool. If children can also send, a “summarize and notify” task ends up:
- Parent asks a child to summarize.
- Child finishes and decides “let me notify them too” (autonomous decision).
- Parent gets the result and also notifies.
User pinged twice. Hermes had this happen: a user got two identical “Build broken” messages on Slack. From then on, external side effects are parent-exclusive.
execute_code · Child should reason with tools, not spawn another Python
execute_code is Hermes’s Python sandbox. Children given access tend to “write a Python script”.
But child tasks are usually “analyze this log” or “summarize this file”; they should reason and use read_file / grep, not spawn another Python process. Once children also use execute_code:
- Extra process per child, resource use 2×.
- Python errors don’t propagate cleanly back to the parent.
- Child stdout goes to the child LLM but the parent wants it too; needs explicit return.
Engineering lesson: a child is a “resource-constrained reasoning LLM”, not “a mini agent that picks its own stack”. Sharing the parent’s tools is fine; “spawn a new sandbox” is not.
Source: hermes-agent/tools/delegate_tool.py:31-54 (DELEGATE_BLOCKED_TOOLS).
Follow-up: “What if the business genuinely needs the child to write memory?” Make the child output proposed_memory_update: ["fact 1", "fact 2"]. The parent then applies rule / human review before touching MEMORY.md. Keep the decision with the parent.
Q4 · Concept: OpenClaw uses push-based completion and stuffs an anti-polling warning into the tool return value. What problem does that solve?
It solves a classic LLM-as-orchestrator failure: the model will not wait; it polls.
The failure
Parent spawns a child and the model thinks “I should check on this task”. It starts calling sessions_list, sessions_history, exec sleep, etc.
Every poll is another LLM call plus tool call, burning tokens, while the UI looks like the parent is “thinking”.
GPT-4 / Claude 3 / Sonnet have all been observed doing this. Even if the system prompt says “do not poll”, the model forgets after a few turns.
OpenClaw’s fix
SUBAGENT_SPAWN_ACCEPTED_NOTE is literally inside the tool result field:
Auto-announce is push-based. After spawning children, do NOT callsessions_list, sessions_history, exec sleep, or any polling tool.Wait for completion events to arrive as user messages, track expectedchild session keys, and only send your final answer after ALL expectedcompletions arrive. If a child completion event arrives AFTER yourfinal answer, reply ONLY with NO_REPLY.Putting it in the tool result instead of the system prompt is intentional, for four reasons:
- Adjacent to behavior: the model has just spawned and reads “do not poll” immediately. More salient than a system prompt at the top.
- Repeats per call: every spawn re-injects the warning. The model gets a reminder each time, even if it forgot the system prompt.
- Points at specific anti-patterns: “do not call sessions_list / sessions_history / exec sleep” is actionable, unlike a vague “do not poll”.
- NO_REPLY protocol: teaches the model how to gracefully handle the “child completed after final answer” case.
Required supporting machinery
The warning alone is not enough:
- subagent-announce push event: the child sends a user message into the parent’s conversation when it finishes; the parent wakes up without polling.
- Timeout fallback: if push exceeds N seconds, fall back to a single poll as a safety net.
- Session-key tracking: parent records the child session keys at spawn time; final answer waits until every key has announced.
This is “prompt engineering as architecture”
OpenClaw did not change the model, did not fine-tune, did not retrieve. It put the behavioral rule at the point where the behavior happens. A textbook case of prompt engineering becoming system design.
Contrast with Codex
Codex children also push (EventMsg::TurnComplete), but the parent is Rust code awaiting recv(), not an LLM deciding when to query. Codex does not depend on the model “not polling”.
Which is better?
Codex is more reliable (code enforces it) but limits flexibility (the parent must be code). OpenClaw is more flexible (LLM can do complex decisions) but needs prompt enforcement.
Both are valid; pick based on whether your orchestrator is code or model.
Source: openclaw/src/agents/subagent-spawn.ts:81-84 + subagent-announce.ts.
Follow-up: “Why not just block sessions_list?” Because sessions_list is legitimate in other flows (user asks for the session list). A tool cannot be globally banned just because subagent mode misuses it. So the enforcement moves to the prompt.
Q5 · Concept: Claude Code’s Task abstraction unifies seven async execution kinds. What are the benefits and costs of putting subagent and bash under one abstraction?
Benefit 1 · UI consistency
The task list UI needs one renderer, not four (bash / subagent / remote / teammate). A unified TaskListUI displays status / progress / result. Low cognitive cost for users.
Benefit 2 · Concurrency dispatch lives in one place
isConcurrencySafe() is an interface method, each task self-reports. The dispatcher does not branch if task_type === 'bash' then ... else if task_type === 'subagent' then ....
Adding a task type just means implementing the interface; the dispatcher stays untouched. Open-closed.
Benefit 3 · State machine consistency
pending → running → completed/failed/killed applies to all task types. Logging, monitoring, error handling stay uniform. isTerminalTaskStatus() is a single function used everywhere.
Benefit 4 · Persistence consistency
Every task writes outputFile to disk. Bash output, subagent reply, remote result: same persistence strategy. Crash recovery and cross-session queries use one mechanism.
Cost 1 · Abstraction loses detail
The seven types genuinely differ:
- bash: blocking / streaming output / signal handling
- subagent: async / token cost / approval routing
- remote_agent: network latency / auth / quota
- teammate: long-lived / message injection
- workflow: multi-step / state persistence
Each type-specific concern either bloats the Task interface or leaks into type-specific config. Claude Code picks the latter: TaskCreateTool has 13+ params, many only relevant to one type.
Cost 2 · New task types are not free
Even with no dispatcher change, you must implement isConcurrencySafe() / outputFile / status transitions / signal handling, etc.
When dream was added, a pile of corner cases appeared (what if the user switches conversations while dream runs? Who sees the dream result?).
Cost 3 · Debugging is harder
Bash failure and subagent failure look very different, but the log says Task xxx failed. You have to drill into type-specific errors. Per-type loggers make logs more direct but lose uniformity.
Conclusion
The Task abstraction fits “async execution is varied but scheduling is similar” products:
- Claude Code is an agent IDE; from the user’s view, running bash / calling a subagent / dispatching a teammate is “wait for a result”. Abstracting helps users.
- Hermes is a chat agent; subagents are a minority operation (the dominant tools are read / write / bash). Unifying the abstraction costs more than it saves. Hermes uses a standalone
delegate_taskfunction.
Engineering lesson: abstraction is not unconditionally good. It depends on whether these things are actually homogeneous in the user’s view. If yes, abstract. If not, keep them separate.
Source: claude-code/src/Task.ts:6-30 + src/tools/TaskCreateTool/.
Follow-up: “How did dream fit into the 7 types?” dream is the “background prefetch” added in 2.1; the user did not trigger it. To fit Task, the dispatcher gained an auto_triggered flag, and the UI hides dream by default (debug view shows it). Concrete example of abstraction leakage: accommodating dream added a property to the abstraction.
Q6 · Practical: You are adding subagent capability to your own agent from scratch. What does the path from MVP to production look like?
Advance through five scopes: functional MVP → safety boundary → parallelism → typed → platformized. Each scope must pass resource, permission, and result-contract fixtures.
Stage 1 · functional MVP
Borrow from Hermes:
def delegate_task(goal: str, context: str, toolset: list[str], max_iter: int = 50): return run_subagent_sync(goal, context, toolset, max_iter)Use it first to validate the parent/child result contract. Parent uses it like:
parent: I want to analyze these 3 log filesparent: delegate_task("analyze log 1", ..., ["read_file", "grep"])... wait for resultparent: delegate_task("analyze log 2", ..., ["read_file", "grep"])... wait for resultStage 2 · safety boundary (do not skip)
Add four things that cannot be skipped:
- MAX_DEPTH = 2: parent → child → grandchild rejected. Simple counter passed in spawn args.
- blocked_tools: hard-block 5 (recursive delegate / clarify / memory / send_message / execute_code). Filter even if the toolset listed them.
- Concurrent limit: default 3, configurable. ThreadPoolExecutor / asyncio.Semaphore.
- Timeout: per-child max_iterations + total timeout (e.g. 5 min). On timeout, kill the child; parent gets
TimeoutError.
This stage is the boundary between “minimum viable” and “blows up in production”.
Acceptance gate: recursion, blocked tools, concurrency limits, and timeout fixtures reject or terminate child tasks and clean up resources.
Stage 3 · parallelism
Prerequisite: Stage 2 depth, tool, and timeout boundaries are stable.
Let the parent run multiple children at once:
import asyncio
async def parent_turn(): tasks = [ delegate_task_async("analyze log 1", ...), delegate_task_async("analyze log 2", ...), delegate_task_async("analyze log 3", ...), ] results = await asyncio.gather(*tasks)When the three calls have no data dependency they can run concurrently. Wall time is then bounded by the slowest child plus scheduling overhead; measure speedup against the serial baseline.
UI consideration: three children running simultaneously means three progress indicators. Give each child a child_id and render per-child progress.
Acceptance gate: compare a fixed no-dependency task set with the serial baseline, recording duplicate calls, conflicts, and child cleanup.
Stage 4 · typed sources
Prerequisite: child sources show repeatable behavior differences and string configuration is becoming hard to maintain.
When subagent kinds exceed 3 (e.g., review / summarize / search / analyze), switch to enum:
class SubAgentSource(Enum): Review = "review" Summarize = "summarize" Search = "search" Analyze = "analyze"
def delegate_task(source: SubAgentSource, ...): prompt = PROMPTS[source] feature_constraints = FEATURE_CONSTRAINTS[source]Benefits:
- Centralized dispatch
- Metrics aggregated by source
- Feature constraints determined by source (Review auto-disables web_search)
Acceptance gate: each source’s prompt, tool constraints, and metric labels agree in schema fixtures; unknown sources are rejected explicitly.
Stage 5 · platformization (when needed)
Prerequisite: the product needs cross-process state, notifications, attachments, or multi-user framework capabilities.
If you are building an agent framework (other developers write agents on top), borrow OpenClaw:
- subagent-registry (visibility into all active runs)
- subagent-depth (persisted across processes)
- subagent-announce (push completion)
- subagent-attachments (file passing)
- subagent-lifecycle-events (spawn / progress / complete event stream)
These are “framework features” you do not need in a single-user agent.
Acceptance gate: kill/resume, push notifications, attachment transfer, and lifecycle events replay correctly, and the platform layer never widens inherited permissions.
Key takeaways:
- Fix MAX_DEPTH first (recursive nesting expands calls, tokens, and debugging paths)
- Fix blocked_tools first (shared-state pollution)
- Promote concurrency after timeout + heartbeat fixtures pass (UI freezing)
- Add typing / platformization from demand (use traces and real calls)
Source route: Hermes tools/delegate_tool.py shows minimal delegation, Codex protocol.rs SubAgentSource shows typed purposes, and OpenClaw agents/subagent-*.ts shows state and notification. Their contracts differ, so select layers from task traces instead of stitching them together verbatim.
Follow-up: “How does the parent handle a child failure?” Three strategies: (1) retry once (transient error); (2) mark failed and let the parent decide (business logic error); (3) abort the parent task (fatal, e.g. quota exhausted). Most cases pick (2), keep the decision with the parent.
Q7 · Architecture: Codex’s inherited_exec_policy ensures the child is never more permissive than the parent. Is that always right? What if a business genuinely needs “child can do more than parent”?
Default-tight is the safe default
The child’s prompt is decided by the parent. The parent may be prompt-injected, deceived, or maliciously controlled.
If the child could exceed the parent’s limits, an attacker could “have the parent spawn a more powerful child” to bypass the sandbox.
Classic attack pattern: user input “write a script that deletes /etc/passwd” → parent’s exec_policy blocks rm -rf → but the child does not inherit → child runs → boom.
Codex’s default-tight policy is a useful safety starting point: by default, the child is a subset of the parent; when a business case genuinely needs more, the parent can grant it explicitly with additional constraints.
But “child does more than parent” is sometimes a real need
Three typical scenarios:
Scenario 1 · Parent is the restricted main conversation, child is a background analysis task.
User opens Claude Code in --restricted mode (read-only analysis), no file writes. But user triggers /review and the review subagent needs to run gh pr diff and call the GitHub API. The parent disabled network; the child needs it.
How to handle it?
Do not let the child override the parent. Instead, the user explicitly grants the child extra capability at the parent layer: “review subagent is allowed to use network”.
The parent grants the privilege at spawn time, knowing it is for review, and may attach further constraints (only gh commands, only GitHub domains).
Codex actually does this: when subagent_source = Review, the spawn explicitly sets web_access = AllowedFor(["github.com"]), bypassing the parent’s web_access = Disabled. But only for Review; other sources do not benefit.
Scenario 2 · Parent is IDE mode (no subprocess spawning); child is a build task.
A build must spawn cargo / npm. The parent disables subprocesses to keep the IDE process clean. The child’s build is an explicit user intent.
How to handle it?
Mark the spawn “this is a build task; subprocess is allowed”. The child’s args carry purpose: BuildTask, and the spawn dispatcher removes that single restriction inherited from the parent.
Scenario 3 · Parent is read-only review; the child needs to fix.
/review is read-only. The user sees the review and says “fix it”, which would spawn a fix subagent that writes files.
How to handle it?
Treat the fix subagent as a fresh user request whose permissions are re-evaluated, not inherited from the parent. The “child” is nominally a child but the permission slate is reset.
Or, route the fix subagent through the parent’s entry (user re-prompts).
General pattern
inherited_exec_policy strict-by-default + explicit grants for exceptions. Grants must:
- Happen at spawn time (not during child runtime)
- Scope to a specific source (only certain
SubAgentSourcebenefit) - Carry additional constraints (whitelist of domains / commands / resource caps)
- Audit logs (every grant logged for post-incident review)
Anti-pattern
Letting the child request more permissions from the parent during runtime. The child says “I need rm permission” in its prompt; the parent LLM decides whether to grant.
That outsources the permission decision to the LLM, too large a security surface. Attackers can write a prompt that convinces the parent “this is fine” and break the model.
Source: codex/codex-rs/core/src/codex_delegate.rs:117 (inherited_exec_policy = Some(Arc::clone(...))) + Review-specific overrides in tasks/review.rs.
Follow-up: “Will Anthropic / OpenAI fine-grained permissions solve this?” Similar in spirit. Anthropic’s Computer Use introduces per-action permissions; each action is decided separately, not “child inherits parent”. But the engineering still defaults the child to be strict and only grants on demand; the granularity is finer.
Q8 · Practical: A user reports “after my agent spawns 10 subagents, the parent UI freezes”. Systematic triage.
Four layers: concurrency model → resources → communication → model.
Layer 1 · Concurrency model (check first)
Check whether subagents are sync or async:
- Sync blocking (Hermes-style
as_completed): the parent is stuck inexecutor.shutdown(wait=True). Waits for all 10 to finish before unblocking. Check if children all completed. - Async push (OpenClaw-style): the parent should not block. If it does, push events did not arrive.
Investigation commands:
py-spy dump --pid <parent_pid> # Pythonrust-gdb -p <parent_pid> # RustLook for the parent stuck in wait() / recv() / gather(). If yes, go to the next layer.
Layer 2 · Resource exhaustion (check next)
Ten children can blow up four resources:
- CPU: each child is an LLM call, cheap locally. But child internal work (bash / build) eats 1 core; 10 children consume 8 cores entirely.
- Memory: each child has its own conversation history + tool output buffer. 10 × 50K context × 4 bytes/token ≈ hundreds of MB. Node.js / Python easily OOM.
- File descriptors: each child opens N (log / temp / socket). 10 × 50 ≈ 500, hitting
ulimit. - API rate limit: 10 children concurrent LLM calls exceed RPM. Subsequent calls retry, cascading slowdowns.
Investigation:
top -p <parent_pid> -H # per-thread CPU / memorylsof -p <parent_pid> | wc -l # FD countcat /proc/<parent_pid>/limits # ulimitgrep "429" logs/ # rate-limit errorsLayer 3 · Communication bottleneck
For push-based:
- Event queue backlog: 10 children push completion at once; the main loop cannot keep up. Check queue size / drain rate.
- Token buffer blown: each child result is a few K characters; 10 returned together creates a 50K input to the parent LLM, exceeding context window.
- UI render lag: each push triggers a re-render. 10 events in 1 second; 60fps cannot keep up.
Investigation:
curl localhost:9090/metrics | grep subagent_queuegrep "input_tokens" logs/parent.logchrome devtools performance tabLayer 4 · Model behavior (check last)
If everything else looks fine:
- Parent is polling: even with OpenClaw’s warning, the parent starts
sessions_list. Each poll is an LLM call (10s each); UI says “thinking” but the parent is doing meaningless lookups. - Parent waits for all completions: parent was told “wait until all children done”; one child stuck, parent stuck. Need a timeout safety net.
- Children spawn more children: even with
MAX_DEPTH = 2, a child can suggest “I need to spawn another”; the parent recursively spawns more.
Investigation: look at the parent LLM trace (the messages of each call); find polling or spawn loops.
Common root causes + fixes
The following is a diagnostic order, not an observed frequency distribution. The original 30/25/20/15/10 split had no incident sample; reorder it from trace and incident data after deployment.
- Rate limit hit: limit concurrency, add exponential backoff.
- Sync blocking dragged down by a slow child: switch to async / push-based + per-child timeout.
- Resource exhaustion: monitoring + concurrency limit + cleanup.
- Parent model polling: borrow OpenClaw’s anti-poll warning.
- UI render lag: debounce + batch events.
Prevention
Use these as explicit starting parameters, then tune them from quota and trace data:
- max concurrent = 3
- single-child timeout = 5 min
- queue size limit + drop policy
- API rate-limit-aware retry
Source reference: Hermes’s _DEFAULT_MAX_CONCURRENT_CHILDREN = 3, _HEARTBEAT_INTERVAL = 30, and DEFAULT_MAX_ITERATIONS = 50. They are current source defaults; this site has no incident data explaining how they were selected.
Follow-up: “What if the business genuinely needs 10 concurrent without freezing?” Do specialized parallelism (worker pool / connection pool / API tier upgrade), not use a generic subagent framework to brute-force it. Subagent is “one agent delegates one task to a throwaway agent”, not “batch process 10000 tasks”. The latter needs a data pipeline, not an agent.
Q9 · Engineering: Claude Code writes task outputFile to disk; OpenClaw persists spawnDepth to session store. Which kind of persistence matters more?
Both matter, for different reasons
outputFile (Claude Code)
Purpose: avoid in-memory blow-up + cross-session recovery.
Scenario: user spawns a bash task running find / for minutes producing hundreds of thousands of lines. An in-memory buffer cannot hold it; outputFile to disk lets the parent hold only a path reference and read chunks on demand.
Bonus: session recovery. User kills Claude Code and reopens; outputFile is still on disk and task history is intact.
spawnDepth (OpenClaw)
Purpose: safety constraints survive across processes.
Scenario: parent spawns child, sets child.spawnDepth = 1. Parent process crashes and restarts. If spawnDepth was in-memory only, the reloaded child thinks it is root again and can spawn more children. MAX_DEPTH is defeated.
Persisting spawnDepth keeps the safety constraint intact across crashes, restarts, and cross-process moves.
Which matters more?
If forced to pick:
- Single-process, short session (CLI tool): outputFile matters more (memory blow-up is a daily issue).
- Multi-process, long session (agent platform): spawnDepth matters more (safety must survive restarts).
Claude Code is the former (CLI / IDE); OpenClaw is the latter (long-lived agent platform). Their priorities follow from that.
For long-lived systems, persist both
For subagent systems that span processes, run for a long time, or must recover after restarts, persist:
- Persist large outputs (outputFile / stdout redirect / chunked storage)
- Persist safety constraints (depth / quota / rate-limit state)
- Persist state machines (task FSM across restarts)
Hermes does neither because:
- Sync blocking, short sessions, small outputs: outputFile not urgent.
- Single process: spawnDepth in memory is fine.
But Hermes will need both when it becomes a long-lived agent.
Implementation details
outputFile:
- Path:
~/.claude/tasks/<task_id>/output.log - Write: append-only, child stdout redirected directly
- Read: parent reads on demand (no full load)
- Cleanup: N days after completion, or user delete
spawnDepth:
- Path:
~/.openclaw/sessions/<session_id>/depth.json - Write: on every spawn
- Read: at parent startup
- Cleanup: archive at session end
Anti-patterns
- In-memory only: crash loses everything; safety constraints defeated.
- SQL DB: subagent spawn/complete is high frequency; per-event DB write is too slow. File-based is enough.
- Shared file with concurrent writes: race conditions. One directory per session.
Follow-up: “How to share outputFile across users?” Tasks are user-scoped; outputFile lives in the user’s home dir, not shared. For cross-user analytics, build a separate telemetry pipeline; do not mix it with outputFile.
Q10 · Open-ended: Design a composable subagent framework from task constraints, with a minimum API and verification path.
Layered design; enable layers on demand:
Layer 1 · Functional interface (required)
interface SpawnArgs { goal: string; context?: string; toolset?: string[]; max_iterations?: number; source?: SubAgentSource;}
async function spawnSubagent(args: SpawnArgs): Promise<SubAgentResult> { // borrow Hermes delegate_task}This is a minimum interface sketch. A production implementation still needs timeout, cancellation, budgets, tracing, and error propagation; the snippet length is not a delivery estimate.
Layer 2 · Typing (recommended)
enum SubAgentSource { Review = 'review', Summarize = 'summarize', Search = 'search', Analyze = 'analyze', Custom = 'custom',}
const SOURCE_CONFIG: Record<SubAgentSource, SourceConfig> = { [SubAgentSource.Review]: { system_prompt: REVIEW_PROMPT, feature_constraints: { web_search: 'disabled', spawn_subagent: 'disabled' }, approval_policy: 'never', },};Borrow Codex SubAgentSource. Each source routes to its own config.
Layer 3 · Safety constraints (retain where needed)
const HARD_LIMITS = { MAX_DEPTH: 2, MAX_CONCURRENT: 3, SINGLE_TIMEOUT_MS: 5 * 60 * 1000, BLOCKED_TOOLS: new Set([ 'spawn_subagent', 'clarify', 'memory_write', 'send_message', 'execute_code', ]),};
function validateSpawn(parent: Session, args: SpawnArgs) { if (parent.spawnDepth >= HARD_LIMITS.MAX_DEPTH) { throw new DepthExceeded(); } args.toolset = args.toolset?.filter(t => !HARD_LIMITS.BLOCKED_TOOLS.has(t));}Borrow Hermes’s five blocked tools + MAX_DEPTH. Keep these constraints for cross-process or long-lived runs; for short, single-process tools, weigh them against the risk.
Layer 4 · Service inheritance (recommended)
interface ParentServices { execPolicy: ExecPolicy; plugins: PluginManager; mcpManager: McpManager; skills: SkillsManager;}
function inheritServices(parent: ParentServices): ParentServices { return { execPolicy: cloneStrict(parent.execPolicy), // child ≤ parent always plugins: shareRef(parent.plugins), mcpManager: shareRef(parent.mcpManager), skills: shareRef(parent.skills), };}Borrow Codex inherited_exec_policy.
Layer 5 · Concurrency (optional, pick one)
// Sync blocking (Hermes style)async function runSync(args: SpawnArgs[]): Promise<SubAgentResult[]> { const limit = pLimit(HARD_LIMITS.MAX_CONCURRENT); return Promise.all(args.map(a => limit(() => spawnSubagent(a))));}
// Async push (OpenClaw style)async function spawnAsync(args: SpawnArgs, callback: (r: SubAgentResult) => void) { spawnSubagent(args).then(callback);}Pick one based on business; expose both for complex products.
Layer 6 · Persistence (production required)
interface SubAgentStore { saveDepth(sessionId: string, depth: number): Promise<void>; loadDepth(sessionId: string): Promise<number>; saveOutput(taskId: string, output: string): Promise<void>; loadOutput(taskId: string): Promise<string>;}
class FileSystemStore implements SubAgentStore { // outputFile → ~/.youragent/tasks/<task_id>/output.log // spawnDepth → ~/.youragent/sessions/<session_id>/depth.json}Borrow Claude Code outputFile + OpenClaw spawnDepth.
Layer 7 · Completion notification (optional)
type CompletionEvent = | { kind: 'started'; taskId: string } | { kind: 'progress'; taskId: string; output: string } | { kind: 'completed'; taskId: string; result: SubAgentResult } | { kind: 'failed'; taskId: string; error: Error };
interface SubAgentBroadcaster { subscribe(callback: (e: CompletionEvent) => void): void;}Borrow OpenClaw subagent-announce. Push to UI / logger / parent agent.
Layer 8 · Anti-polling warning (strongly recommended)
const ANTI_POLL_WARNING = `Auto-announce is push-based. After spawning children, do NOT callsessions_list, sessions_history, exec sleep, or any polling tool.Wait for completion events to arrive as user messages.`;
function spawnTool(args: SpawnArgs): ToolResult { return { success: true, message: `Spawned ${args.taskId}. ${ANTI_POLL_WARNING}`, };}Borrow OpenClaw SUBAGENT_SPAWN_ACCEPTED_NOTE. Embed in tool result.
Layer 9 · Task abstraction (optional, only for agent platforms)
type TaskType = 'subagent' | 'bash' | 'remote_agent' | 'workflow' | ...;type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'killed';
interface Task { id: string; type: TaskType; status: TaskStatus; isConcurrencySafe(): boolean;}Borrow Claude Code Task abstraction. Only relevant when the product supports multiple async execution kinds.
Final API
import { spawnSubagent } from '@your-org/subagent';
const result = await spawnSubagent({ goal: 'analyze these 3 logs', toolset: ['read_file', 'grep'], source: SubAgentSource.Analyze, max_iterations: 30,});
console.log(result.summary);Simple one-liner. Enable layers as scenarios get richer.
vs four systems:
- Codex contributes
SubAgentSource+ service inheritance. - Claude Code contributes outputFile + Task abstraction (optional layer).
- OpenClaw contributes anti-polling warning + spawnDepth persistence.
- Hermes contributes the five tool blocks + ThreadPoolExecutor.
Evaluate by scope:
- Layers 1-3 · core delegation: spawn contract, depth/tool boundaries, and timeout semantics. Gate: unit tests cover rejection paths, and an integration fixture receives either a child result or an explicit failure.
- Layers 4-6 · concurrency and typing: add only when child tasks truly have no data dependency or source kinds have become hard to maintain. Prerequisite: a serial baseline and
child_id; gate: a fixed task set measures speedup, duplicate calls, cleanup, and source constraints. - Layers 7-9 · platform capabilities: add only when cross-process state, durable notifications, attachments, or UI observability are required. Prerequisite: lifecycle and permission boundaries are defined; gate: kill/resume, push notification, attachment, and state-query fixtures are replayable.
Critical decisions:
- Core API is JSON-friendly (cross-language schema)
- Configuration is file-based (YAML / JSON describing source behavior)
- Reject polling: bake the anti-polling warning into the core contract
Follow-up: “How do you test such a framework?” Three layers: (1) unit-test spawn / depth / blocked_tools; (2) integration-test push event flow; (3) chaos-test 100 child spawns + kill half + verify resource cleanup. The first two run in CI; the third runs weekly.