DEV Community

Cover image for DeepSeek Harness Series (08): Multi-Agent Collaboration — Subagents and Agent Teams
WonderLab
WonderLab

Posted on

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

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

Direction Allowed? Notes
Parent → Child ✅ Yes Requires the child's parentSession to point to the parent
Child → Parent ✅ Yes Subagents can message their parent
Sibling → Sibling ❌ No Two children of the same parent cannot message each other
Grandparent → Grandchild ❌ No Cross-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)
Enter fullscreen mode Exit fullscreen mode

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']
}
Enter fullscreen mode Exit fullscreen mode

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.'
Enter fullscreen mode Exit fullscreen mode

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: '...' }, ...]
Enter fullscreen mode Exit fullscreen mode

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:

Provider Description Best For
spawn-in-process Creates a new Agent instance in the same process Local development, testing
fork-in-process Forks the current Session; subagent inherits the prefix history Subtasks that need to inherit context
dsh-sdk Launches an isolated runtime via the dsh SDK Subagents that need full isolation
acp Communicates with remote Agents via the ACP protocol Remote Agent clusters
codex Calls a Codex Agent Code-specific tasks
claude-code Calls Claude Code Code-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)
Enter fullscreen mode Exit fullscreen mode

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}`
  },
})
Enter fullscreen mode Exit fullscreen mode

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

Framework Multi-Agent Model Key Difference from dsh
Claude Code Agent tool Also spawns subagents Simpler interface, but no toolFilter / persona / outputSchema
LangGraph Graph-based workflow, fixed topology dsh Subagents are dynamic — the model decides when to delegate at runtime, not via a predefined graph
AutoGen Conversational multi-Agent, Agents message each other directly dsh 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

Top comments (0)