DSH Subagents & Multi-Agent Orchestration: From Spawn to Workflow

How DeepSeek Harness orchestrates multiple agents: named providers, continuable children, workflow scripts, the Ralph loop, and background jobs explained.

TL;DR: DSH multi-agent isn’t one pattern — it’s a composable seam with six named providers (spawn-in-process, fork-in-process, acp, codex, claude-code, dsh-sdk), two model-facing tools (subagent for continuable background children, subagent_fork for one-shot foreground), a workflow engine running model-written JS in worker_threads, the Ralph loop for bounded iterative tasks, and a unified background-job system. Providers swap independently; the model sees the same interface regardless of what’s behind it.

The Subagent Seam: Why Multiple Providers Coexist

Unlike bash (one executor per context), the subagent seam supports multiple named providers simultaneously via ctx.subagents. This is the LLM adapter registry pattern applied to child agents: each provider is registered by name, and the model-facing tool selects which one to use.

ProviderWhat It DoesUse Case
spawn-in-processFresh child agent in the same Node processDefault local delegation
fork-in-processChild seeded with parent’s conversation prefixContext-carrying delegation
acpAgent Communication Protocol over JSON-RPCCross-process/cross-machine
codexDelegates to OpenAI Codex agentExternal product integration
claude-codeDelegates to Claude CodeExternal product integration
dsh-sdkAnother DSH instance via SDKDistributed DSH deployments

The service definition (dsh-subagent) declares the contract. Each provider package implements it independently. The model-facing consumer tools (dsh-tool-subagent, dsh-tool-subagent-control, dsh-tool-subagent-report) use the contract without knowing which provider backs them.

// Registration is effect-scoped and HMR-safe
const dispose = ctx.subagents.registerProvider(myProvider)
// Removing a provider blocks new starts but doesn't revoke running children

DeepSeek Harness GitHub Repository

Two Model Tools: subagent vs subagent_fork

The model interacts with the subagent system through exactly two tools, each backed by a different provider and operating mode:

ToolModeDefault ExecutionProvider
subagentContinuableBackgroundspawn-in-process
subagent_forkOne-shotForegroundfork-in-process

subagent creates a continuable child that lives beyond the parent’s current turn. It runs in the background by default, has an inbox for follow-up messages, and delivers results through the report tool.

subagent_fork is one-shot: it creates a child seeded with the parent’s conversation history (a balanced completed-turn prefix), runs to completion in the foreground, and returns its output directly. No inbox, no follow-up — fire and forget with context.

SubagentCapabilities: What Providers Advertise

Before a one-shot start, the service checks provider capabilities against the request. If the provider can’t deliver what’s asked, it fails loud — never accepts-then-ignores:

interface SubagentCapabilities {
  readonly outputSchema: boolean;   // Can enforce structured output
  readonly depthLimit: boolean;     // Can limit recursion depth
  readonly toolFilter: boolean;     // Can restrict child's available tools
  readonly persona: boolean;        // Can apply a different persona
}

These flags describe the one-shot start() path only. Continuable children are gated by SubagentProvider.prepareContinuable — its presence IS the capability, with TypeScript narrowing as the discovery mechanism.

Continuable Children: The Activation Lifecycle

Continuable subagents are the core of DSH’s long-running multi-agent capability. They don’t just fire-and-forget — they persist, accept follow-up messages, and can cold-resume from storage.

Lifecycle States

A continuable child lives through an Activation lifecycle:

Created → Running → Idle → (Cold Storage) → Resumed → Running → ...

The key properties:

  • FIFO inbox: Messages queue in order; each accepted message becomes one Turn
  • Cold resume: A child not currently loaded can be resumed from its persisted session
  • No lost messages: The Agent inbox is the only queue, so every message has one observable order
// Start a continuable child
const { childId, messageId } = await ctx.subagents.startContinuable({
  provider: 'spawn-in-process',
  request: { description: 'Research agent', prompt: 'Find papers on...' },
  parent: currentAgent,
  signal: abortController.signal,
})

// Send follow-up later (different turn, even different session resume)
await ctx.subagents.followup(
  currentAgent,
  childId,
  [{ type: 'text', text: 'Also check arxiv for...' }],
  { signal }
)

Interruption Without Destruction

You can interrupt a child’s current turn without destroying it:

// Fire-and-return: cancel signal issued immediately
// but target may run briefly until it observes the signal
await ctx.subagents.interrupt(targetSessionId, { signal })
// Unclaimed inbox work preserved; waking send resumes the FIFO queue

DSH Packages Directory

The Report Tool: Child-to-Parent Communication

How does a background child tell its parent it has results? Through the report tool — a purpose-built communication channel with special properties:

PropertyBehavior
Per-child scopedRegistered per continuable child, not globally
Survives toolFilterEven if the parent filters child tools, report stays
Delivery modeswakeup (creates parent turn) or quiet (adds context without waking)
// Config for report delivery behavior
interface Config {
  reportDelivery?: 'wakeup' | 'quiet'
}
  • wakeup (default): The report creates one ordinary parent turn — the parent is notified immediately.
  • quiet: Adds context to the parent without waking it. The parent only sees the report when something else triggers a turn.

This is how background agents report completion without constantly interrupting foreground work.

Control Tools: Global Agent Management

Three tools provide runtime control over the agent constellation:

send_message

Sends a follow-up message to a background child, continuing its conversation. The message waits if the child is working — it can’t redirect work already underway.

interrupt_agent

Requests cancellation of a child’s current turn. Fire-and-return: the stop signal is issued before this returns, but the target may keep running briefly. The agent itself stays available for follow-ups.

list_agents

Lists continuable background subagents with their status:

StatusMeaning
runningWorking right now
idleLoaded but between turns (may be waiting on its own children)
readyExists only in storage, resumable
{
  "scope": "children"  // or "descendants" for full tree walk
}

descendants walks the complete tree in stable pre-order, annotating each entry with its parent session ID and depth. You can send_message only to depth-1 entries; deeper ones are interrupt_agent candidates only.

The Workflow Engine: Model-Written Orchestration Scripts

For complex multi-agent coordination beyond simple delegation, DSH provides a workflow engine that runs model-written JavaScript scripts in a worker_threads VM:

interface WorkflowStartRequest {
  script: string;           // Plain JS body (top-level await allowed)
  meta: WorkflowMeta;      // Identity block (name, description, phases)
  args?: unknown;           // Input exposed as the `args` global
  subagentProvider?: string; // Override child provider for this run
  maxTotalAgents?: number;  // Per-run child ceiling
  parent: Agent;            // Every spawned child attributed to this agent
  signal?: AbortSignal;     // Cancels the run
}

Inside the script, agent() spawns children:

// A workflow script (model-generated, validated before execution)
const researcher = await agent({
  description: 'Research papers on topic X',
  prompt: 'Find the top 5 papers...',
})

const writer = await agent({
  description: 'Write summary from research',
  prompt: `Summarize these findings: ${researcher.result}`,
})

return { summary: writer.result }

Key constraints:

  • One worker per run: Isolation via worker_threads
  • meta validated before execution: The engine never evaluates script text to extract metadata
  • parent is required: Every child is attributed to a live Agent for lineage and depth tracking
  • Result is plain JSON: The script’s return value is materialized as host-realm data

WorkflowResult: How Scripts Settle

interface WorkflowResult {
  value: unknown;               // Script return value (null for undefined)
  stopReason: 'completed' | 'cancelled' | 'error';
  error?: string;               // Present iff not completed
  agentsSpawned: number;        // Total agent() calls accepted
}

Non-completed outcomes map to isError tool results — partial output is never reported as success.

Cordis Paper on GitHub

The Ralph Loop: Bounded Iterative Execution

The Ralph loop is a specific orchestration pattern built from workflow and subagent primitives. It’s not a generic agent-loop mode — it’s a fixed foreground workflow for iterative tasks with bounded handoff:

PropertyRalph Loop Behavior
StructureFixed foreground workflow
ChildrenFresh child per round (no conversation carry-over)
State passingBounded structured handoff between rounds
NotA same-session goal, scheduler, or generic workflow feature

Ralph Round and Handoff

Each Ralph round is one fresh child session. The child receives no parent or prior-child conversation seed. Cross-round state flows through two channels:

  1. Shared workspace: Filesystem state visible to all rounds
  2. Ralph handoff: A bounded structured report with status, summary, evidence, next steps, and blocker text
// The handoff supplements the workspace — it doesn't replace it as authority
interface RalphHandoff {
  status: string;
  summary: string;
  evidence: string;
  nextSteps: string;
  blockerText?: string;
}

The loop continues spawning fresh children until the objective is met or a configured limit is reached. Each child sees only its handoff and the workspace — no conversation history accumulates.

Background Jobs: Unified Async Work

ctx.jobs unifies all background work under one interface:

Job TypeSource
Background bash commandsShell execution
PTY terminal sendsTerminal sessions
Background subagentsSubagent delegation

Three model-facing tools manage them:

job_list   — enumerate running/completed background work
job_output — read output from a specific job
job_kill   — cancel a running job

The model doesn’t need to know whether a background job is a bash command, a terminal session, or a subagent — they all surface through the same job_* interface. This is the Capability Seams pattern at work: one consumer, many providers.

How Providers Are Composed

The shipped compositions in packages/bundle/base/cordis.patch.yml load dsh-tool-subagent twice — once per backend:

  • One instance with toolName: 'subagent', backgroundMode: 'continuable', bound to spawn-in-process
  • One instance with toolName: 'subagent_fork', backgroundMode: 'one-shot', bound to fork-in-process

Each instance gets its own description and run_in_background behavior. The control tools (send_message, interrupt_agent, list_agents) are registered once, globally. The report tool is registered per-child, scoped to that child’s context.

This composition is just plugin configuration — swap the provider names and you redirect delegation to external products without changing the model-facing tools.

Practical Example: Research + Synthesis Pipeline

Here’s a realistic multi-agent pattern using continuable children:

export function apply(ctx: Context) {
  ctx.on('agent/pre-step', async (payload, next) => {
    // Custom orchestration logic could go here
    return next()
  })
}

// The model drives this organically:
// 1. Model calls subagent with "Research topic A" → background child A
// 2. Model calls subagent with "Research topic B" → background child B
// 3. Both children work concurrently, report back via `report` tool
// 4. Parent wakes, receives both reports, synthesizes
// 5. Model calls subagent_fork to draft final output with full context

No orchestration framework needed — the model itself decides when to delegate, wait, or synthesize. The tools and lifecycle manage the mechanics.

Connecting to the Architecture

FAQ

Q: Can a child agent spawn its own children? A: Yes. Depth is tracked through lineage, and depthLimit (in SubagentCapabilities) can cap recursion. Each child runs a full agent loop — it has the same tool access (minus any toolFilter restrictions) and can call subagent itself. The list_agents tool with scope: 'descendants' shows the full tree.

Q: What happens if a continuable child’s storage is lost? A: The child becomes unresumable. list_agents won’t show it (it only surfaces session-backed entries). Starting a new child with the same task is the recovery path — there’s no automatic retry. Persistence is optional; without it, enumeration is live-only.

Q: How does the workflow engine prevent runaway scripts? A: Three mechanisms: maxTotalAgents caps the number of agent() calls; signal (AbortSignal) cancels the run; the engine validates meta before execution and never evaluates script text to extract metadata. The one-worker-per-run isolation means a crashed script can’t affect other runs.

Q: Can I mix providers in one workflow run? A: A workflow run can override subagentProvider globally for its children, but individual agent() calls within the script don’t select providers — they all use the same one. To mix providers, use separate subagent tool calls from the parent agent directly rather than a workflow script.

Q: What’s the difference between Ralph loop and a workflow script? A: A workflow script is general-purpose: the model writes arbitrary JS that calls agent(). The Ralph loop is a fixed policy — one foreground workflow that spawns fresh children per round with bounded handoff. It’s built FROM workflow and subagent primitives but has a specific structure: no conversation carry-over between rounds, cross-round state only through workspace + handoff. Use workflows for custom orchestration; use Ralph for iterative bounded tasks.