DeepSeek Harness Series (08): Multi-Agent Collaboration — Subagents and Agent Teams

One Agent calls another Agent — the result can be plain text or structured JSON. This article covers dsh's multi-Agent mechanisms: launching subagents, continuable subagents, tool filtering, personas, and the experimental Agent Teams.

·10 min read·AI Engineering

A Problem One Tool Can't Solve

Imagine this task: a comprehensive refactor of a large codebase — check naming conventions across every file, find all circular dependencies, clean up interface definitions. Over a thousand files.

You could hand this to a single Agent and let it process files one by one. But the problems appear quickly:

  • Limited context window: by file 300, everything from the beginning has been pushed out
  • Tools have no reasoning: a tool executes fixed logic — it can't adapt its strategy based on 'which subsystem does this file belong to?'
  • Tools have no persistent conversation history: a tool finishes and that's it; no state, no memory of what it saw last time

The essence of multi-Agent systems is delegating subtasks to another Agent that has its own independent conversation history. A subagent has its own Session, its own reasoning process, its own tools, and its own context window. The parent Agent only needs to say: 'process this batch of files and tell me what you find.'

The difference from calling a tool is the difference between 'handing something off to another person' and 'starting a machine.'


dsh's Subagent Mechanism

dsh implements Agent delegation via Subagents. There are two ways to launch one:

  1. Model-initiated: when the model decides to delegate a subtask, it calls the built-in tool subagent_spawn
  2. Code-initiated: inside a tool's execute function, call ctx.subagents.start() directly

Launching a subagent requires a SubagentStartRequest. The key fields:

// Simplified from SubagentStartRequest in packages/subagent/subagent/src/types.ts
// The actual type has more fields; these are the most important
 
interface SubagentStartRequest {
  // The subagent's initial prompt (ContentBlock array — supports text, images, etc.)
  prompt: ContentBlock[]
 
  // The parent Agent object (provides working directory and lineage info)
  parent: Agent
 
  // Cancellation signal (lets the parent abort the subagent)
  signal: AbortSignal
 
  // Optional: restrict which tools the subagent can use
  toolFilter?: ToolRestriction
 
  // Optional: give the subagent a custom persona (overrides the global deployment persona)
  persona?: string
 
  // Optional: structured output schema (makes the subagent return a JSON object instead of text)
  outputSchema?: ObjectJsonSchema
 
  // Optional: max delegation depth (prevents infinite recursion via nested subagents)
  maxDepth?: number
}

A few fields deserve attention:

toolFilter: by default a subagent inherits all of its parent's tools, but in most cases you want to give it a restricted set. A code-analysis subagent only needs to read files — not write them.

persona: a subagent can have its own system prompt prefix. You can give it a specialized role definition so it works within that role rather than inheriting the global persona.

outputSchema: instead of returning plain text, the subagent returns a structured JSON object. The parent Agent receives a type-safe result.structured.


One-Shot vs Continuable

Subagents have two lifecycle modes, distinguished by whether the parent needs to talk to the subagent more than once.

One-shot Subagent:

Launch → subagent completes all its work → returns result → done. One round trip, suitable for independent subtasks.

Continuable Subagent:

After launch, the subagent creates a persistent Session. The parent can keep sending it messages, and the subagent continues executing each time. Suitable for multi-turn collaborative work.

The two modes compared:

One-shot:
  Parent ──start()──► Child executes full task ──result──► Parent
  (subagent's lifecycle ends after completion)
 
Continuable:
  Parent ──startContinuable()──────► Child Session created, waits for input
  Parent ──sendMessage('batch 1')──► Child processes batch 1 ──result──► Parent
  Parent ──sendMessage('batch 2')──► Child processes batch 2 ──result──► Parent
  Parent ──sendMessage('summarize')► Child summarizes ──final result──► Parent
  (Child Session stays alive throughout, with a continuous conversation history)

The advantage of a continuable subagent: its conversation history is unbroken. It remembers what it processed in earlier turns, and can draw on that memory when summarizing at the end.


Communication Paths: Who Can Message Whom

In a multi-Agent system, a natural question is: can Agents message each other freely?

The answer is no. dsh enforces explicit constraints on communication paths:

DirectionAllowed?Notes
Parent → Child✅ YesRequires the child's parentSession to point to the parent
Child → Parent✅ YesSubagents can message their parent
Sibling → Sibling❌ NoTwo children of the same parent cannot message each other
Grandparent → Grandchild❌ NoCross-generation messaging is rejected
       Parent
      /      \
  Child A   Child B
     |
   Grandchild
 
✅ Parent ──► Child A
✅ Child A ──► Parent
❌ Child A ──► Child B  (sibling messaging rejected)
❌ Parent ──► Grandchild  (cross-generation rejected)

This is a deliberate constraint. Allowing arbitrary messaging turns the Agent network into an untraceable message graph — when debugging, you'd have no idea where a message came from. A clear hierarchy means clear responsibility boundaries.


toolFilter and persona

Tool Filtering

Giving a subagent only the tools it actually needs is the most important access-control mechanism in a multi-Agent system:

// Subagent can only use these three tools.
// Everything else the parent has (write_file, execute_shell, etc.) is invisible to it.
toolFilter: {
  allow: ['read_file', 'search_files', 'list_files'],
  // You can also use deny to exclude specific tools:
  // deny: ['write_file', 'execute_shell']
}

Why bother? It's not only about security — it's about task focus. A code-analysis subagent with write_file in its tool list might 'helpfully' modify code it thinks is wrong. That's not the behavior you want. Give it read-only tools and it can only read.

Persona

// Subagent uses a custom system prompt prefix.
// This overrides the global deployment persona,
// so the subagent operates within a specialized role.
persona: 'You are a specialized test writer. Focus only on writing unit tests for the given code. Do not modify existing source files, do not suggest refactoring.'

The point of persona: the parent Agent can be a general-purpose assistant, while the subagents it spawns are laser-focused experts. Each subagent gets a freshly defined role, unaffected by global configuration.


Structured Output (outputSchema)

Subagents return text by default. But in automated pipelines, the parent Agent usually needs data it can process directly — not a paragraph of natural language.

outputSchema solves this:

// Make the subagent return structured JSON — no text parsing needed (pseudocode)
const result = await ctx.subagents.start({
  prompt: [{ type: 'text', text: 'Analyze this code and find all bugs.' }],
  parent: currentAgent,
  signal,
  outputSchema: {
    type: 'object',
    properties: {
      bugs: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            file:        { type: 'string' },
            line:        { type: 'number' },
            severity:    { type: 'string', enum: ['critical', 'major', 'minor'] },
            description: { type: 'string' },
          },
        },
      },
    },
  },
})
 
// result.text is the subagent's text output (if any)
// result.structured is a type-safe JSON object — use it directly, no parsing
console.log(result.structured.bugs)
// → [{ file: 'auth.ts', line: 42, severity: 'critical', description: '...' }, ...]

This keeps data flow clean across the multi-Agent system: the subagent's analysis becomes the parent Agent's decision input directly, with no extra parsing layer in between.


Six Subagent Providers

dsh supports multiple underlying implementations for different deployment scenarios:

ProviderDescriptionBest For
spawn-in-processCreates a new Agent instance in the same processLocal development, testing
fork-in-processForks the current Session; subagent inherits the prefix historySubtasks that need to inherit context
dsh-sdkLaunches an isolated runtime via the dsh SDKSubagents that need full isolation
acpCommunicates with remote Agents via the ACP protocolRemote Agent clusters
codexCalls a Codex AgentCode-specific tasks
claude-codeCalls Claude CodeCode-specific tasks

For most local development, spawn-in-process is sufficient. When you need isolation — say, different subagents with different filesystem permissions — consider dsh-sdk.


Experimental: Agent Teams

Subagents follow a hierarchical model — parent-child relationships are explicit, communication paths are strict. But some collaboration patterns are inherently 'flat': multiple specialist Agents working on the same task in parallel, each reporting results as they finish.

Agent Teams is dsh's work-in-progress multi-Agent collaboration framework, accessible via ctx.agentTeams (experimental service):

  • roster: register team members, each with a name and role
  • mailbox: members send and receive messages via their mailbox
  • task board: a shared task list the team can claim and submit work against
  • coordinator mode: a coordinator Agent can distribute tasks and collect results

Compared to the Subagent hierarchy, Teams is closer to actual teamwork: no strict parent-child structure, members can communicate as peers, and tasks can be assigned dynamically.

Subagent model (hierarchical):       Agent Teams model (peer-to-peer):
 
     Orchestrator                        ┌──────────────────┐
    /     |      \                       │    task board     │
  Sub A  Sub B  Sub C                   └──────────────────┘
(strict parent-child,               Agent A ◄──► Agent B
 one-way delegation)                    │              │
                                        └────► Agent C ◄┘
                                        (peer messaging, dynamic collaboration)

Note: Agent Teams is currently experimental. The API may change in future releases. Confirm version stability before using in production.


Hands-On: Parent Agent Calling a Subagent

Here's a complete pattern: a parent Agent defines a tool that internally launches a subagent to perform a specialized analysis task.

// Pseudocode: parent Agent tool that delegates to a subagent for code analysis
const analyzeCodebaseTool = defineTool({
  name: 'analyze_codebase',
  description: 'Spawn a specialized subagent to analyze a given directory.',
  parameters: {
    directory:   { type: 'string', required: true, description: 'Directory to analyze' },
    focus:       { type: 'string', required: true, description: 'Analysis focus (e.g., naming conventions, circular dependencies)' },
  },
 
  async execute(args, exec) {
    // Launch the subagent (pseudocode — actual call is ctx.subagents.start())
    const result = await ctx.subagents.start({
      prompt: [{
        type: 'text',
        text: `Analyze the directory ${args.directory}. Focus on: ${args.focus}. Check each file and produce a structured issue list.`,
      }],
      parent: exec.agent,   // pass the parent Agent to establish lineage
      signal: exec.signal,  // pass cancellation signal — if parent is cancelled, subagent stops too
 
      // Subagent can only read files, not modify code
      toolFilter: {
        allow: ['read_file', 'list_files', 'search_files'],
      },
 
      // Specialized persona: code review expert
      persona: 'You are a code review expert. Read code carefully, identify issues, and provide a structured summary. Do not modify any files.',
 
      // Require structured JSON output
      outputSchema: {
        type: 'object',
        properties: {
          issues: {
            type: 'array',
            items: {
              type: 'object',
              properties: {
                file:     { type: 'string' },
                issue:    { type: 'string' },
                severity: { type: 'string', enum: ['high', 'medium', 'low'] },
              },
            },
          },
          summary: { type: 'string' },
        },
      },
    })
 
    // result.structured is type-safe JSON — use it directly
    const { issues, summary } = result.structured
    return `Analysis complete. Found ${issues.length} issue(s).\n${summary}`
  },
})

The core value of this pattern: the parent Agent doesn't need to know how the subagent analyzes code — it just receives structured results and makes its next decision.


Comparison with Other Frameworks

FrameworkMulti-Agent ModelKey Difference from dsh
Claude Code Agent toolAlso spawns subagentsSimpler interface, but no toolFilter / persona / outputSchema
LangGraphGraph-based workflow, fixed topologydsh Subagents are dynamic — the model decides when to delegate at runtime, not via a predefined graph
AutoGenConversational multi-Agent, Agents message each other directlydsh is hierarchical with explicit parent-child relationships; sibling Agents cannot communicate directly

In short: LangGraph suits scenarios with fixed, predictable flows; dsh Subagents suit scenarios where the flow is determined dynamically by the model.


What's Next in the Series

The next article covers observability: how do you know what an Agent did while it was running? What mechanisms does dsh provide for tracing tool calls, Token usage, and subagent lineage trees — and how do you hook into a logging system in production?


Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.

Find more on my Homepage