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.
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.
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.
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.