pleach
Build

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 fieldDefaultEffect
enableSubagentConcurrencyfalseMaster switch
maxConcurrentSubagents4Cap 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 @deprecated runtime.spawnAgent(config)) — the host or a tool calls it directly with a SubAgentConfig.
  • The SubagentManager API — createSubagentManager(...) then its spawn/queue methods, for host-driven fan-out.
  • A host OrchestratorAdapter that surfaces delegation tools (the Ivy host uses the delegate_to_* prefix; the prefix is host-configurable via delegationToolPrefix). The model calling one of those tools routes through the host's subagentExecutor.

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:

EventPayloadWhen
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 parentWhat
Storage adapterReads + writes the same DB; isolated subagent_id column scope
CheckpointerSubagent checkpoints land in the same store with parent-id provenance
Plugin setPlugins are shared; per-subagent contributions are filtered by scope
Audit ledgerSubagent 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

On this page