Subagents
Spawn child runtimes from a tool call. Every child call lands on the ledger with parent_turn_id and subagent_depth — fan-out rolls back to the parent turn.
A subagent is a child runtime spawned from a tool call. The parent
turn pauses on the subagent's tool, the subagent runs its own
anchor-plan → tool-loop → synthesize → post-turn cycle, and the
result bubbles back to the parent as the tool result. Every call
the child makes lands on the same audit ledger with
parent_turn_id and subagent_depth columns set, so a GROUP BY parent_turn_id rolls nested fan-out back to the user message that
caused it. See Event log for the broader
projection surface.
Subagent is one of three concepts in the composing-agents cluster, alongside the skill that defines the spec and the agent profile that records every invocation. The subagent is the runtime spawn; the skill is the durable definition; the profile is the rolling signal that decides whether the spec gets routed to again.
Subagents are off by default — the substrate runs strictly serial. Enable them when the parent agent decomposes work into independent subtasks (research fan-out, multi-document analysis, parallel benchmark runs).
import {
SubagentManager,
createSubagentManager,
SUBAGENT_LIMITS,
RESULT_PREVIEW_MAX_CHARS,
buildDependencyGraph,
topologicalSort,
} from "@pleach/core";
import type {
Subagent,
SubagentStatus,
SubagentTask,
SubagentSpawnConfig,
SubAgentConfig,
SubAgentHandle,
SubAgentResult,
SubAgentToolCall,
SubagentSessionFlags,
} from "@pleach/core";
// LightweightSessionRuntime, the workspace-context helpers, and the
// WorkspaceContext type ship only from the `@pleach/core/subagents`
// subpath — they are not re-exported from the root barrel.
import {
LightweightSessionRuntime,
fetchWorkspaceContext,
formatWorkspaceContextPrompt,
} from "@pleach/core/subagents";
import type { WorkspaceContext } from "@pleach/core/subagents";Enabling subagents
import { SessionRuntime } from "@pleach/core";
const runtime = new SessionRuntime({
storage: supabaseAdapter,
enableSubagentConcurrency: true,
maxConcurrentSubagents: 4,
userId: "user_123",
});| Config field | Default | Effect |
|---|---|---|
enableSubagentConcurrency | false | Master switch |
maxConcurrentSubagents | 4 | Cap on the SubagentManager (the host-driven queue path) |
Bare core needs a host-supplied executor
The flags above turn the surface on; they don't make a subagent
run on bare @pleach/core. The runtime's default executor is a
no-op that returns an "executor not configured" failure, so the
spawn emits subagent.spawned then subagent.failed with no work
done. To actually run subagents the host supplies a
subagentExecutor on SessionRuntimeConfig (the LLM call is
bring-your-own) and/or drives the SubagentManager itself. Without
that wiring the model is offered no delegation tool and spawns
don't execute. @pleach/core provides scheduling and lifecycle,
not the model call.
Two concurrency paths exist, and they cap differently.
The imperative spawn path (runtime.async.spawn / spawnAgent)
caps on the hard SUBAGENT_LIMITS.maxConcurrent constant (3),
not on maxConcurrentSubagents. When the active subagent count is
already at that limit, the spawn does not queue or wait — it
returns a failed SubAgentResult (status: "failed", error
Sub-agent concurrency limit reached) and emits subagent.spawned
immediately followed by subagent.failed. Depth behaves the same
way: exceeding SUBAGENT_LIMITS.maxDepth (3) rejects the spawn
rather than queuing it.
maxConcurrentSubagents (default 4) caps the SubagentManager —
the queue/DAG fan-out component. The runtime constructs a
SubagentManager when enableSubagentConcurrency is set, but does
not drive it on the imperative spawn path. The queue,
buildDependencyGraph, and topologicalSort are a host-driven
surface: a host that wants queue-on-cap (rather than fail-on-cap)
fan-out orchestrates the SubagentManager directly.
How a subagent gets spawned
Spawns are imperative in bare @pleach/core — there is no
metadata-flag auto-escalation. The entry points are:
runtime.async.spawn(config)(or the@deprecatedruntime.spawnAgent(config)) — the host or a tool calls it directly with aSubAgentConfig.- The
SubagentManagerAPI —createSubagentManager(...)then its spawn/queue methods, for host-driven fan-out. - A host
OrchestratorAdapterthat surfaces delegation tools (the Ivy host uses thedelegate_to_*prefix; the prefix is host-configurable viadelegationToolPrefix). The model calling one of those tools routes through the host'ssubagentExecutor.
There is no spawnsSubagent: true tool-metadata flag in core
(the field does not exist), and bare core wires neither a
planner-emitted subagent task nor an intent-classifier route to a
spec — those are host responsibilities.
When a spawn runs, the runtime allocates a
LightweightSessionRuntime and dispatches through the configured
subagentExecutor.
Namespacing in the event stream
Every event a subagent emits carries namespace: string[].
Empty array for the root orchestrator; populated for events
emitted from inside a subagent.
for await (const event of runtime.executeMessage(sessionId, prompt)) {
if (event.namespace && event.namespace.length > 0) {
// Subagent event — route to a sub-panel
renderInSubagentPanel(event.namespace, event);
} else {
renderInRootPanel(event);
}
}The namespace path is the subagent tree — a subagent that itself
spawns a subagent emits events with namespace: ["parent", "child"]. UIs render this as a tree of nested transcripts.
Subagent lifecycle events
Four stream events bracket a subagent's life:
| Event | Payload | When |
|---|---|---|
subagent.spawned | { subagentId, task, specName?, context? } | Allocated; runtime is starting it |
subagent.progress | { subagentId, progress, message?, activeToolName? } | Periodic progress update |
subagent.completed | { subagentId, content, toolsUsed, toolCallDetails? } | Finished cleanly |
subagent.failed | { subagentId, error, terminalStatus? } | Errored; terminalStatus discriminates cancelled / failed / timeout |
progress is 0–1. activeToolName lets a UI show what the
subagent is currently doing without subscribing to its full
event stream.
Durable subagent audit rows
The four events above are the live stream. The audit ledger also
records subagent activity durably, one row per tool the subagent uses:
subagent.tool_used carries { subagentId, payload: { tool_name } },
while subagent.completed and subagent.failed land as terminal rows
(the failed row carries terminal_status). These are what a
replay or an audit query reconstructs a subagent's tool
history from after the turn — independent of whether any UI was
subscribed to the stream.
WorkspaceContext
Live workspace state a subagent can inherit when its spec is
workspaceAware. Queried from a sandbox-style execution
environment at spawn time so the subagent doesn't burn a turn
asking what's installed.
interface WorkspaceContext {
pythonVersion: string;
packages: Array<{ name: string; version: string }>;
cliTools: string[];
disk?: { total: string; used: string; available: string; usePct: string };
fileCount: number;
files?: string[];
}fetchWorkspaceContext(sandbox) queries a sandbox connection and
returns the snapshot (or null if the sandbox isn't active —
callers proceed without context rather than failing the spawn).
formatWorkspaceContextPrompt(ctx) renders the snapshot as a
prompt section. setDomainClassifierPatterns(patterns)
configures the file-classification heuristic that picks which
files surface in files.
A workspace-aware subagent needs the host to supply the sandbox
connection. Hosts wire one in via the contributeSandboxBridge
plugin hook — see Plugin contract for
the bridge surface.
SubAgentConfig and the spawn surface
interface SubAgentConfig {
task: string;
name?: string; // spec name; default "general-purpose"
tools?: string[] | "inherit" | "none";
context?: { conversationSummary?: string; variables?: Record<string, unknown> };
maxSteps?: number; // default 5
maxDepth?: number; // checked at spawn time
timeout?: number; // default 120_000ms
streamProgress?: boolean;
systemPromptSuffix?: string;
resumeId?: string; // resume a prior subagent's transcript
}
interface SubAgentHandle {
id: string;
status: "cancelled" | "completed" | "failed" | "pending" | "running";
progress?: number; // 0–100
}
interface SubAgentResult {
id: string;
status: "cancelled" | "completed" | "failed" | "timeout";
content: string;
toolCalls: SubAgentToolCall[];
error?: string;
metrics: {
totalSteps: number;
durationMs: number;
inputTokens?: number;
outputTokens?: number;
};
transcript?: Array<{ role: string; content: unknown }>;
agentName?: string;
checkpointId?: string;
turnMetrics?: {
successfulToolNames: string[];
failedToolNames: string[];
turnHadFailedTools: boolean;
retryCount: number;
totalToolCalls: number;
};
}`context.variables` is currently inert
context.variables is accepted on SubAgentConfig but the
runtime does not consume it — when a spawn supplies it, the
runtime logs a warning (context.variables provided but executor does not consume them; embed into context.conversationSummary or task) and proceeds without it. Put anything the child needs into
context.conversationSummary or task instead.
SubagentSessionFlags propagates parent-session state into the
child so the subagent doesn't re-offer tools the parent already
knows are unavailable:
interface SubagentSessionFlags {
sandboxUnavailableInSession?: boolean;
filteredToolNames?: string[];
}LightweightSessionRuntime
Subagents run on LightweightSessionRuntime — a slimmer
SessionRuntime variant that shares the parent's storage,
checkpointer, and plugin set but isolates its channels and
event stream. It is exported from the @pleach/core/subagents
subpath, not the root barrel.
import { LightweightSessionRuntime } from "@pleach/core/subagents";
// Usually you don't construct this directly — the SubagentManager does.
// Direct construction is for testing or custom orchestration:
const sub = new LightweightSessionRuntime({
parentSessionId: runtime.sessionId,
agentName: "research-fanout",
parentPluginManager: pluginManager, // optional
store: baseStore, // optional (BaseStore)
sessionFlags: { // optional
sandboxUnavailableInSession: false,
filteredToolNames: [],
},
});The lightweight runtime inherits:
| From parent | What |
|---|---|
| Storage adapter | Reads + writes the same DB; isolated subagent_id column scope |
| Checkpointer | Subagent checkpoints land in the same store with parent-id provenance |
| Plugin set | Plugins are shared; per-subagent contributions are filtered by scope |
| Audit ledger | Subagent calls write to the same ledger with the subagent's id |
What's isolated:
- Channels — the subagent has its own per-channel state
- Event log namespace — events tagged with the subagent's path
- Tool registry filter — subagents typically run a narrower tool set than their parent
SUBAGENT_LIMITS and depth tracking
Hard limits the substrate enforces independently of consumer config:
const SUBAGENT_LIMITS = {
maxDepth: 3, // parent → child → grandchild
maxConcurrent: 3, // concurrent subagents per session
maxPerTurn: 5, // subagents spawned in one turn
timeoutMs: 120_000, // default timeout
} as const;
const RESULT_PREVIEW_MAX_CHARS = 400;Depth is checked at spawn time — exceeding maxDepth rejects the
spawn rather than proceeding into a fourth nested runtime. Breaching
the per-turn fan-out cap throws SubagentFanOutExceeded (exported from
@pleach/core), so a runaway spawn loop surfaces as a typed error you
can catch rather than an unbounded tree.
SubAgentToolCall.resultPreview is truncated at
RESULT_PREVIEW_MAX_CHARS so the per-step records on the parent's
SubAgentResult.toolCalls stay bounded. turnDepth on
RuntimeStateSnapshot (passed to runtime-aware prompt
contributions) reports the same value the subagent sees.
The depth value is also what lands on every AuditableCall row
emitted from inside a subagent — payload.subagentDepth carries
1 for a direct child, 2 for a grandchild, and the audit ledger
keeps the attribution distinct so a GROUP BY subagentDepth query
answers "how much did the fan-out tier cost relative to the root
turn." The same field threads through llm.turn.depth on the
stream, so a UI panel can colour-code per-tier spend without
joining back to the ledger.
Authoring subagent specs
Subagent specs are authored as skills — markdown files with YAML
frontmatter, loaded by SkillLoader from @pleach/core/skills. A
skill's execution block (maxSteps, timeout, canDelegate)
marks it as subagent-capable. The orchestrator's intent classifier
routes to it via the intents field.
A minimal skill file at skills/builtin/research-fanout/SKILL.md:
---
name: research-fanout
description: Fan out searches across multiple sources in parallel.
allowed-tools:
- search_corpus
- search_news
intents:
- research
- literature_review
activation: intent-matched
execution:
maxSteps: 6
timeout: 120000
---
You are a research subagent. Run each search in parallel and
synthesise the results into a single structured report.Legacy in-code subagent specs — the subagentSpecs array passed
to the SkillLoader constructor — are migrated to builtin skills
automatically at load time. A spec whose name matches an existing
skill file is skipped. Migration is the backward-compatibility path,
not the authoring target.
See Skills for the full Skill shape, the
three-source merge order, and the getByIntent / getByName
loader API.
Distinct delegate_to_* tools from a spec registry
The generic spawn_agent tool takes a spec name as an argument. An
alternative that measurably improves the model's routing accuracy is to
give it a DISTINCT tool per specialist — delegate_to_research,
delegate_to_compound, … — each carrying that spec's own description.
createDelegateTools generates them from your registry:
import { createDelegateTools, type SubAgentSpec } from "@pleach/core/subagents";
const SPECS: Record<string, SubAgentSpec> = {
research: { name: "research", description: "Multi-source literature fan-out.", tools: ["search_corpus"], maxSteps: 6 },
// …your specialists
};
const delegateTools = createDelegateTools(SPECS);
// → [{ name: "delegate_to_research", description: "Delegate a task to the research specialist agent. …", … }]It's domain-free by dependency injection: you own the registry (the
domain lives in its values), and the generator maps the shape. A
general-purpose spec is excluded by default (it already maps to
spawn_agent); pass { exclude: [...] } to change that. The companion
isDelegateTool(name) and specNameFromDelegateTool(name, specs?)
identify a delegate call and map it back to its spec.
Aborting a subagent
The parent's AbortSignal propagates to every active subagent —
aborting the parent turn cancels every running child. That's the
primary cancel path today: cancel the parent and every active
child unwinds.
Per-subagent cancellation is partially wired. SubagentManager
marks the subagent "cancelled" and emits subagent.cancelled,
but the async-task path
(Async tasks)
currently logs a warning rather than tearing down the running
worker — cancellation is cooperative, not preemptive. A subagent
mid-tool-call won't stop until that call returns.
The aborted subagent emits subagent.failed with
terminalStatus: "cancelled". The discriminator matters for
dashboards: "cancelled" is user-driven, "failed" is an error
inside the subagent's own runtime (a tool threw, a provider call
exhausted its cascade), and "timeout" is the
SubAgentConfig.timeout ceiling firing — typically 120_000ms.
Alert on "failed"; don't alert on "cancelled".
Where to go next
Stream events
`subagent.spawned` / `progress` / `completed` / `failed` payloads.
Skills
`SkillLoader` — define subagent specs as markdown skills; the `execution` block makes a skill delegatable.
SessionRuntime
`enableSubagentConcurrency` and `maxConcurrentSubagents` config fields.
Auditable call row
`parent_turn_id` and `subagent_depth` columns roll fan-out back to the user turn.