Skip to content

Sub-Agent Orchestration

Sub-agents enable hierarchical agent systems where a parent agent can delegate specialized tasks to child agents. This is powerful for complex workflows that benefit from separation of concerns.

What Are Sub-Agents?

A sub-agent is an agent invoked by another agent as if it were a tool. The parent agent decides when to delegate, the sub-agent executes to completion, and the result flows back to the parent.

Parent Agent

    ├── Uses regular tools

    └── Delegates to Sub-Agent

            ├── Sub-agent runs to completion
            └── Result returned to parent

Creating Sub-Agent Tools

Use createSubAgentTool() to turn an agent into a tool:

typescript
import { defineAgent, createSubAgentTool } from '@helix-agents/sdk';
import { z } from 'zod';

// Define a specialized agent
const AnalyzerAgent = defineAgent({
  name: 'text-analyzer',
  description: 'Analyzes text for sentiment and topics',
  systemPrompt: 'You analyze text. Determine sentiment and extract key topics.',
  outputSchema: z.object({
    sentiment: z.enum(['positive', 'negative', 'neutral']),
    confidence: z.number(),
    topics: z.array(z.string()),
  }),
  llmConfig: { model: openai('gpt-4o-mini') },
});

// Create a tool that invokes this agent
const analyzeTool = createSubAgentTool(
  AnalyzerAgent,
  z.object({
    text: z.string().describe('Text to analyze'),
  }),
  { description: 'Analyze text for sentiment and key topics' }
);

// Use in parent agent
const OrchestratorAgent = defineAgent({
  name: 'orchestrator',
  systemPrompt: 'You coordinate research. Use the analyzer for sentiment analysis.',
  tools: [searchTool, analyzeTool], // Mix regular tools and sub-agents
  llmConfig: { model: openai('gpt-4o') },
});

Requirements

Sub-agents must have outputSchema:

typescript
// ✓ Valid sub-agent - has outputSchema
const ValidSubAgent = defineAgent({
  name: 'valid',
  outputSchema: z.object({ result: z.string() }), // Required!
  // ...
});

// ✗ Invalid sub-agent - no outputSchema
const InvalidSubAgent = defineAgent({
  name: 'invalid',
  // No outputSchema - will throw error when used as sub-agent
  // ...
});

The outputSchema defines the contract between parent and child - what the parent receives as the tool result.

How Sub-Agent Execution Works

When the parent LLM calls a sub-agent tool:

  1. Tool Call Detection - Framework identifies the tool as a sub-agent (prefixed with subagent__)
  2. Sub-Agent Initialization - New agent run created with:
    • Fresh runId (derived from parent's ID)
    • Same streamId as parent (for unified streaming)
    • Input converted to user message
  3. Execution - Sub-agent runs its full loop until completion
  4. Result Return - Sub-agent's output becomes the tool result
typescript
// Parent LLM calls:
{
  name: 'subagent__text-analyzer',
  arguments: { text: 'This product is amazing!' }
}

// Framework:
// 1. Creates sub-agent run
// 2. Converts input to message: "This product is amazing!"
// 3. Runs AnalyzerAgent loop
// 4. Returns output: { sentiment: 'positive', confidence: 0.95, topics: ['product'] }

Input Mapping

The input schema defines what arguments the parent provides. These are converted to a user message for the sub-agent:

typescript
const subAgentTool = createSubAgentTool(
  SubAgent,
  z.object({
    query: z.string(), // Common field names are recognized
    context: z.string().optional(),
  })
);

// When called with { query: "analyze this", context: "..." }
// Sub-agent receives user message: "analyze this"

Recognized input fields (in priority order):

  1. message
  2. query
  3. text
  4. content
  5. Falls back to JSON.stringify(input)

For complex inputs, the sub-agent's system prompt should explain how to interpret them.

Streaming Integration

Sub-agent events stream alongside parent events on the same stream:

typescript
for await (const chunk of stream) {
  switch (chunk.type) {
    case 'text_delta':
      // Could be from parent or sub-agent
      console.log(`[${chunk.agentType}]`, chunk.delta);
      break;

    case 'subagent_start':
      console.log(`Starting sub-agent: ${chunk.subAgentType}`);
      break;

    case 'subagent_end':
      console.log(`Sub-agent ${chunk.subAgentType} result:`, chunk.result);
      break;

    case 'tool_start':
      // Includes sub-agent tool calls from within sub-agents
      console.log(`[${chunk.agentType}] Tool: ${chunk.toolName}`);
      break;
  }
}

Sub-agent streaming events:

  • subagent_start - When sub-agent begins
  • All sub-agent's chunks (text_delta, tool_start, tool_end, etc.)
  • subagent_end - When sub-agent completes

This enables real-time visibility into nested execution.

State Isolation

Sub-agents have completely isolated state:

typescript
const ParentAgent = defineAgent({
  name: 'parent',
  stateSchema: z.object({
    parentCounter: z.number().default(0),
  }),
  // ...
});

const SubAgent = defineAgent({
  name: 'child',
  stateSchema: z.object({
    childCounter: z.number().default(0), // Separate from parent
  }),
  // ...
});

Key points:

  • Sub-agent cannot read parent's custom state
  • Parent cannot read sub-agent's custom state
  • Each has its own messages, stepCount, etc.
  • Sub-agent output is the only communication channel

To share data, pass it through the input and receive it in the output.

Error Handling

Sub-Agent Failures

If a sub-agent fails, the error becomes the tool result:

typescript
// Sub-agent throws error
throw new Error('Analysis failed: text too short');

// Parent receives tool result:
{
  success: false,
  error: 'Analysis failed: text too short'
}

The parent LLM sees the error and can decide how to proceed (retry, try different approach, etc.).

Handling Errors

Check for failures in parent's tools or logic:

typescript
const processResultTool = defineTool({
  name: 'process_analysis',
  execute: async (input, context) => {
    // The sub-agent result may have succeeded or failed
    if (!input.analysisResult.success) {
      // Handle sub-agent failure
      return {
        processed: false,
        reason: input.analysisResult.error,
      };
    }

    // Process successful result
    const analysis = input.analysisResult.result;
    // ...
  },
});

Nested Sub-Agents

Sub-agents can themselves have sub-agents:

typescript
// Level 3: Leaf agent
const SentimentAnalyzer = defineAgent({
  name: 'sentiment',
  outputSchema: z.object({ sentiment: z.string() }),
  // ...
});

// Level 2: Uses sentiment analyzer
const TextProcessor = defineAgent({
  name: 'processor',
  tools: [createSubAgentTool(SentimentAnalyzer /* ... */)],
  outputSchema: z.object({ processed: z.string() }),
  // ...
});

// Level 1: Uses text processor
const Orchestrator = defineAgent({
  name: 'orchestrator',
  tools: [createSubAgentTool(TextProcessor /* ... */)],
  // ...
});

Stream events include all levels:

[orchestrator] text_delta: "Let me analyze..."
[orchestrator] subagent_start: processor
  [processor] text_delta: "Processing..."
  [processor] subagent_start: sentiment
    [sentiment] text_delta: "Analyzing..."
    [sentiment] output: { sentiment: "positive" }
  [processor] subagent_end: sentiment
  [processor] output: { processed: "..." }
[orchestrator] subagent_end: processor
[orchestrator] text_delta: "Based on the analysis..."

Patterns

Specialist Pattern

Delegate specific tasks to specialists:

typescript
const ResearchAgent = defineAgent({
  name: 'researcher',
  tools: [
    searchTool,
    createSubAgentTool(FactCheckerAgent /* ... */),
    createSubAgentTool(SummarizerAgent /* ... */),
  ],
  systemPrompt: `You are a research coordinator.
1. Search for information
2. Send claims to the fact-checker
3. Send findings to the summarizer
4. Compile final report`,
});

Pipeline Pattern

Chain agents in a processing pipeline:

typescript
// Each agent processes and passes to next
const ExtractorAgent = defineAgent({
  name: 'extractor',
  outputSchema: z.object({ entities: z.array(z.string()) }),
});

const EnricherAgent = defineAgent({
  name: 'enricher',
  outputSchema: z.object({
    enrichedEntities: z.array(
      z.object({
        /* ... */
      })
    ),
  }),
});

const FormatterAgent = defineAgent({
  name: 'formatter',
  outputSchema: z.object({ formatted: z.string() }),
});

// Coordinator runs the pipeline
const PipelineAgent = defineAgent({
  name: 'pipeline',
  tools: [
    createSubAgentTool(ExtractorAgent, z.object({ text: z.string() })),
    createSubAgentTool(EnricherAgent, z.object({ entities: z.array(z.string()) })),
    createSubAgentTool(FormatterAgent, z.object({ data: z.unknown() })),
  ],
  systemPrompt: `Process text through the pipeline:
1. Extract entities
2. Enrich each entity
3. Format the output`,
});

Parallel Delegation Pattern

Delegate multiple tasks simultaneously:

typescript
const MultiAnalyzerAgent = defineAgent({
  name: 'multi-analyzer',
  tools: [
    createSubAgentTool(SentimentAgent, z.object({ text: z.string() })),
    createSubAgentTool(TopicAgent, z.object({ text: z.string() })),
    createSubAgentTool(EntityAgent, z.object({ text: z.string() })),
  ],
  systemPrompt: `Analyze text from multiple angles.
You can run multiple analyses in parallel.
Combine results into a comprehensive report.`,
});

The framework executes parallel tool calls concurrently when the LLM requests them.

Conditional Delegation Pattern

Delegate based on input characteristics:

typescript
const RouterAgent = defineAgent({
  name: 'router',
  tools: [
    createSubAgentTool(SimpleQAAgent, z.object({ question: z.string() }), {
      description: 'For simple factual questions',
    }),
    createSubAgentTool(ResearchAgent, z.object({ topic: z.string() }), {
      description: 'For topics requiring deep research',
    }),
    createSubAgentTool(MathAgent, z.object({ problem: z.string() }), {
      description: 'For mathematical calculations',
    }),
  ],
  systemPrompt: `Route questions to the appropriate specialist:
- Simple facts → SimpleQA
- Complex topics → Research
- Math problems → Math

Choose the best agent for each request.`,
});

Best Practices

1. Clear Output Schemas

Define precise output schemas for clear contracts:

typescript
// Good: Specific schema
const agent = defineAgent({
  outputSchema: z.object({
    sentiment: z.enum(['positive', 'negative', 'neutral']),
    confidence: z.number().min(0).max(1),
    reasoning: z.string(),
  }),
});

// Avoid: Vague schema
const agent = defineAgent({
  outputSchema: z.object({
    result: z.unknown(), // What is this?
  }),
});

2. Descriptive Sub-Agent Tools

Help the parent LLM choose correctly:

typescript
const tool = createSubAgentTool(AnalyzerAgent, z.object({ text: z.string() }), {
  description: `Analyze text for sentiment and extract key topics.
Use when you need:
- Sentiment classification (positive/negative/neutral)
- Topic extraction from text
- Confidence scores for analysis

Returns: { sentiment, confidence, topics }`,
});

3. Appropriate Granularity

Balance specialization vs. overhead:

typescript
// Good: Meaningful specialization
const FactCheckerAgent = defineAgent({
  /* verifies claims */
});
const SummarizerAgent = defineAgent({
  /* creates summaries */
});

// Avoid: Over-granular
const CapitalizerAgent = defineAgent({
  /* just capitalizes text */
});
// ^ This is better as a regular tool or string method

4. Handle Sub-Agent Limits

Set appropriate maxSteps for sub-agents:

typescript
const SubAgent = defineAgent({
  name: 'focused-task',
  maxSteps: 5, // Sub-agents should complete quickly
  // ...
});

5. Test Sub-Agents Independently

Sub-agents are full agents - test them alone first:

typescript
// Test sub-agent directly
const subHandle = await executor.execute(AnalyzerAgent, 'Test text');
const subResult = await subHandle.result();
expect(subResult.status).toBe('completed');

// Then test in orchestration
const parentHandle = await executor.execute(OrchestratorAgent, 'Analyze this');
const parentResult = await parentHandle.result();

Limitations

No Shared State

Sub-agents cannot access parent state. Design inputs/outputs to carry needed context:

typescript
// Pass context through input
const tool = createSubAgentTool(
  SubAgent,
  z.object({
    query: z.string(),
    context: z.object({
      previousFindings: z.array(z.string()),
      constraints: z.array(z.string()),
    }),
  })
);

Sequential by Default

Multiple sub-agent calls in one LLM response may execute in parallel, but the parent waits for all before continuing.

Overhead

Each sub-agent invocation includes:

  • State initialization
  • Full agent loop (potentially multiple LLM calls)
  • State persistence

For simple transformations, prefer regular tools.

Next Steps

  • Streaming - Handle sub-agent stream events
  • Runtimes - How different runtimes handle sub-agents
  • Examples - Real-world orchestration examples

Released under the MIT License.