Skip to content

Sub-Agent Execution

This document explains how Helix Agents handles sub-agent (child agent) execution and the patterns involved.

Overview

Sub-agents enable:

  1. Task Delegation - Parent agents delegate specialized tasks
  2. Composition - Build complex agents from simpler ones
  3. Isolation - Sub-agents have their own state
  4. Reusability - Define once, use from multiple parents

Creating Sub-Agent Tools

Using createSubAgentTool

typescript
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:

typescript
// When LLM calls the tool, it uses 'summarize'
// Internally stored as 'subagent__summarizer'

const SUBAGENT_TOOL_PREFIX = 'subagent__';

Detecting Sub-Agent Tools

typescript
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

typescript
// 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 output

4. Result Returned to Parent

Sub-Agent Complete

    ├── Output returned to parent


Parent Agent (resumed)

    └── Receives tool result with sub-agent output

State Isolation

Parent State

typescript
interface ParentState {
  notes: Note[];
  searchCount: number;
}

Sub-Agent State

typescript
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:

typescript
{
  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:

typescript
{
  type: 'text_delta',
  delta: 'Summarizing...',
  agentId: 'run-child-123',  // Sub-agent's ID
  agentType: 'summarizer',
  timestamp: 1702329600100
}

subagent_end

Emitted when sub-agent completes:

typescript
{
  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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
{
  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:

typescript
{
  role: 'tool',
  toolCallId: 's1',
  toolName: 'subagent__summarizer',
  content: '{"summary":"The texts discuss..."}'
}

Parallel Sub-Agents

Multiple sub-agents can run in parallel:

typescript
// 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:

typescript
// 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:

typescript
{
  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:

typescript
{
  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:

typescript
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

typescript
// 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

typescript
// 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 this

4. Handle Failures Gracefully

typescript
// In parent's system prompt
'If a sub-agent fails, try to complete the task yourself or report the failure.';

Testing

typescript
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');
  });
});

See Also

Released under the MIT License.