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.

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

On this page