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 parentCreating Sub-Agent Tools
Use createSubAgentTool() to turn an agent into a tool:
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:
// ✓ 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:
- Tool Call Detection - Framework identifies the tool as a sub-agent (prefixed with
subagent__) - Sub-Agent Initialization - New agent run created with:
- Fresh
runId(derived from parent's ID) - Same
streamIdas parent (for unified streaming) - Input converted to user message
- Fresh
- Execution - Sub-agent runs its full loop until completion
- Result Return - Sub-agent's
outputbecomes the tool result
// 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:
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):
messagequerytextcontent- 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:
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:
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:
// 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:
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:
// 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:
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:
// 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:
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:
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:
// 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:
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:
// 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 method4. Handle Sub-Agent Limits
Set appropriate maxSteps for sub-agents:
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:
// 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:
// 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.