Sub-Agent Execution
This document explains how Helix Agents handles sub-agent (child agent) execution and the patterns involved.
Overview
Sub-agents enable:
- Task Delegation - Parent agents delegate specialized tasks
- Composition - Build complex agents from simpler ones
- Isolation - Sub-agents have their own state
- Reusability - Define once, use from multiple parents
Creating Sub-Agent Tools
Using createSubAgentTool
import { createSubAgentTool, defineAgent } from '@helix-agents/core';
import { z } from 'zod';
// First define the sub-agent with an outputSchema
const SummarizerAgent = defineAgent({
name: 'summarizer',
outputSchema: z.object({
summary: z.string(),
keyPoints: z.array(z.string()),
}),
// ... other config
});
// Then create a tool that invokes it
const summarizeTool = createSubAgentTool(
SummarizerAgent, // Full agent config (must have outputSchema)
z.object({
texts: z.array(z.string()),
maxLength: z.number().optional(),
}),
{ description: 'Summarize a list of texts' } // Optional
);Tool Name Convention
Sub-agent tools use the subagent__ prefix internally:
// When LLM calls the tool, it uses 'summarize'
// Internally stored as 'subagent__summarizer'
const SUBAGENT_TOOL_PREFIX = 'subagent__';Detecting Sub-Agent Tools
import { isSubAgentTool, SUBAGENT_TOOL_PREFIX } from '@helix-agents/core';
// Check if a tool call is for a sub-agent
if (isSubAgentTool(toolName)) {
// Extract agent type
const agentType = toolName.slice(SUBAGENT_TOOL_PREFIX.length);
}Execution Flow
1. Parent Agent Makes Tool Call
Parent Agent
│
├── LLM decides to use 'summarize' tool
│
▼
{ type: 'tool_calls', toolCalls: [
{ id: 's1', name: 'summarize', arguments: { texts: [...] } }
]}2. Tool Call is Recognized as Sub-Agent
// In planStepProcessing
const subAgentCalls = toolCalls
.filter((tc) => isSubAgentTool(tc.name))
.map((tc) => ({
id: tc.id,
agentType: tc.name.slice(SUBAGENT_TOOL_PREFIX.length),
input: tc.arguments,
}));3. Sub-Agent is Executed
Parent Agent (paused)
│
▼
Sub-Agent Created
│
├── runId: unique ID
├── streamId: linked to parent stream
├── parentAgentId: parent's runId
└── input: from tool arguments
│
▼
Sub-Agent Execution Loop
│
├── Initialize state
├── Call LLM
├── Execute tools
└── Complete with output4. Result Returned to Parent
Sub-Agent Complete
│
├── Output returned to parent
│
▼
Parent Agent (resumed)
│
└── Receives tool result with sub-agent outputState Isolation
Parent State
interface ParentState {
notes: Note[];
searchCount: number;
}Sub-Agent State
interface SummarizerState {
texts: string[];
processedCount: number;
}Sub-agents cannot directly modify parent state. Communication happens through:
- Input - Data passed when invoking sub-agent
- Output - Structured result returned on completion
Stream Event Flow
subagent_start
Emitted when sub-agent begins:
{
type: 'subagent_start',
subAgentId: 'run-child-123',
agentType: 'summarizer',
input: { texts: ['text1', 'text2'] },
parentAgentId: 'run-parent-456',
timestamp: 1702329600000
}Sub-Agent Events
Sub-agent emits its own events (text_delta, tool_start, etc.) with its own agentId:
{
type: 'text_delta',
delta: 'Summarizing...',
agentId: 'run-child-123', // Sub-agent's ID
agentType: 'summarizer',
timestamp: 1702329600100
}subagent_end
Emitted when sub-agent completes:
{
type: 'subagent_end',
subAgentId: 'run-child-123',
agentType: 'summarizer',
result: { summary: 'The texts discuss...' },
success: true,
parentAgentId: 'run-parent-456',
timestamp: 1702329601000
}Runtime Implementations
JS Runtime
Sub-agents execute recursively in the same process:
// In JSAgentExecutor
for (const subAgentCall of plan.pendingSubAgentCalls) {
const subAgent = registry.get(subAgentCall.agentType);
// Execute sub-agent (recursive call)
const handle = await this.execute(
subAgent,
{
message: JSON.stringify(subAgentCall.input),
state: subAgentCall.input,
},
{
parentAgentId: runId,
}
);
const result = await handle.result();
// Add result to parent's messages
state.messages.push(
createSubAgentResultMessage({
toolCallId: subAgentCall.id,
agentType: subAgentCall.agentType,
result: result.output,
success: result.status === 'completed',
})
);
}Temporal Runtime
Sub-agents run as child workflows:
// In workflow
for (const subAgentCall of plan.pendingSubAgentCalls) {
const childResult = await executeChild(agentWorkflow, {
workflowId: generateSubAgentRunId(),
args: [
{
agentType: subAgentCall.agentType,
runId: generateSubAgentRunId(),
streamId: parentStreamId, // Share stream
message: JSON.stringify(subAgentCall.input),
parentAgentId: input.runId,
},
],
parentClosePolicy: 'ABANDON',
});
// Record result
await activities.recordSubAgentResult({
parentRunId: input.runId,
subAgentCall,
result: childResult,
});
}Cloudflare Runtime
Sub-agents spawn as separate workflow instances:
// In workflow step
for (const subAgentCall of plan.pendingSubAgentCalls) {
const instance = await workflowBinding.create({
id: generateSubAgentRunId(),
params: {
agentType: subAgentCall.agentType,
message: JSON.stringify(subAgentCall.input),
parentAgentId: input.runId,
},
});
// Wait for completion
const result = await instance.status();
}Message Recording
Assistant Message
Tool calls including sub-agent calls are recorded:
{
role: 'assistant',
content: 'I will summarize these texts.',
toolCalls: [
{ id: 's1', name: 'subagent__summarizer', arguments: { texts: [...] } }
]
}Tool Result Message
Sub-agent result is recorded as a tool result:
{
role: 'tool',
toolCallId: 's1',
toolName: 'subagent__summarizer',
content: '{"summary":"The texts discuss..."}'
}Parallel Sub-Agents
Multiple sub-agents can run in parallel:
// LLM requests multiple sub-agents
{
type: 'tool_calls',
toolCalls: [],
subAgentCalls: [
{ id: 's1', agentType: 'summarizer', input: { texts: batch1 } },
{ id: 's2', agentType: 'summarizer', input: { texts: batch2 } },
{ id: 's3', agentType: 'analyzer', input: { data: {...} } },
]
}The runtime executes them in parallel:
// JS Runtime
const results = await Promise.all(
subAgentCalls.map(call => executeSubAgent(call))
);
// Temporal Runtime
await Promise.all(
subAgentCalls.map(call => executeChild(agentWorkflow, { ... }))
);Error Handling
Sub-Agent Failure
If a sub-agent fails, the result indicates failure:
{
type: 'subagent_end',
subAgentId: 'run-child-123',
agentType: 'summarizer',
success: false,
error: 'Max steps exceeded',
parentAgentId: 'run-parent-456',
timestamp: 1702329601000
}Parent Handling
Parent receives error as tool result:
{
role: 'tool',
toolCallId: 's1',
toolName: 'subagent__summarizer',
content: '{"error":"Max steps exceeded"}'
}The LLM can then decide how to handle the failure.
State Reference Tracking
Parents track sub-agent references:
interface AgentState {
subAgentRefs: SubAgentRef[]; // Note: field is subAgentRefs, not subAgents
}
interface SubAgentRef {
id: string; // Sub-agent's runId
toolCallId: string; // Original tool call ID
agentType: string;
status: 'running' | 'completed' | 'failed';
output?: unknown;
error?: string;
}This allows:
- Querying sub-agent status
- Retrieving sub-agent results
- Cleanup on parent completion
Best Practices
1. Clear Input/Output Contracts
// Define clear schemas for sub-agent communication
const SummarizerInputSchema = z.object({
texts: z.array(z.string()),
maxLength: z.number().optional().default(500),
});
const SummarizerOutputSchema = z.object({
summary: z.string(),
keyPoints: z.array(z.string()),
});2. Meaningful Agent Types
// Good: descriptive type names
agentType: 'code-reviewer';
agentType: 'data-analyzer';
agentType: 'email-composer';
// Bad: generic names
agentType: 'helper';
agentType: 'agent1';3. Limit Nesting Depth
Avoid deep nesting of sub-agents:
Parent
└── Sub-Agent
└── Sub-Sub-Agent // Avoid this level
└── ... // Definitely avoid this4. Handle Failures Gracefully
// In parent's system prompt
'If a sub-agent fails, try to complete the task yourself or report the failure.';Testing
import { MockLLMAdapter } from '@helix-agents/core';
describe('SubAgent', () => {
it('executes sub-agent and returns result', async () => {
const mock = new MockLLMAdapter([
// Parent calls sub-agent
{
type: 'tool_calls',
toolCalls: [],
subAgentCalls: [
{
id: 's1',
agentType: 'summarizer',
input: { texts: ['text1'] },
},
],
},
// Parent finishes with sub-agent result
{
type: 'structured_output',
output: { result: 'Used summary: ...' },
},
]);
// Register both agents
registry.register(ParentAgent);
registry.register(SummarizerAgent);
const result = await executor.execute(ParentAgent, 'Summarize texts');
expect(result.status).toBe('completed');
});
});