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

mermaid
graph TB
    Parent["Parent Agent (paused)"]

    Parent --> SubCreate["Sub-Agent Created"]

    subgraph SubConfig [" "]
        direction LR
        C1["sessionId: unique ID"]
        C2["streamId: linked to parent"]
        C3["parentSessionId: parent's sessionId"]
        C4["input: from tool arguments"]
    end

    SubCreate --> SubConfig

    SubConfig --> ExecLoop["Sub-Agent Execution Loop"]

    subgraph ExecSteps [" "]
        direction TB
        E1["Initialize state"]
        E2["Call LLM"]
        E3["Execute tools"]
        E4["Complete with output"]
        E1 --> E2 --> E3 --> E4
    end

    ExecLoop --> ExecSteps

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: 'session-child-abc123',      // Sub-agent's sessionId
  agentType: 'summarizer',
  input: { texts: ['text1', 'text2'] },
  parentSessionId: 'session-parent-xyz789', // Parent's sessionId
  timestamp: 1702329600000
}

Sub-Agent Events

Sub-agent emits its own events (text_delta, tool_start, etc.) with its own agentId (which is its sessionId):

typescript
{
  type: 'text_delta',
  delta: 'Summarizing...',
  agentId: 'session-child-abc123',  // Sub-agent's sessionId
  agentType: 'summarizer',
  timestamp: 1702329600100
}

subagent_end

Emitted when sub-agent completes:

typescript
{
  type: 'subagent_end',
  subAgentId: 'session-child-abc123',       // Sub-agent's sessionId
  agentType: 'summarizer',
  result: { summary: 'The texts discuss...' },
  success: true,
  parentSessionId: 'session-parent-xyz789', // Parent's sessionId
  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,
    },
    {
      parentSessionId: state.sessionId, // Parent's session ID (primary key)
    }
  );

  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 subSessionId = generateSubSessionId();
  const childResult = await executeChild(agentWorkflow, {
    workflowId: subSessionId,
    args: [
      {
        agentType: subAgentCall.agentType,
        sessionId: subSessionId,
        streamId: parentStreamId, // Share stream
        message: JSON.stringify(subAgentCall.input),
        parentSessionId: input.sessionId,
      },
    ],
    parentClosePolicy: 'ABANDON',
  });

  // Record result
  await activities.recordSubAgentResult({
    parentSessionId: input.sessionId,
    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: generateSubSessionId(),
    params: {
      agentType: subAgentCall.agentType,
      message: JSON.stringify(subAgentCall.input),
      parentSessionId: input.sessionId, // Parent's session ID (primary key)
    },
  });

  // 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, { ... }))
);

Interrupt Propagation

When a parent agent is interrupted while sub-agents are running, the interrupt must propagate through the entire hierarchy to ensure responsive cancellation.

The Challenge

Without interrupt propagation, the parent would block on Promise.all() waiting for children to complete:

typescript
// Problematic pattern - parent blocked until ALL children finish
const results = await Promise.all(
  subAgentCalls.map(call => executeSubAgent(call))
);
// Interrupt signal cannot be processed here!

This causes unacceptable latency - users might wait 60+ seconds for an interrupt to take effect.

Solution: Racing Pattern

The runtimes use a racing pattern to enable sub-second interrupt response:

typescript
// Temporal: Race Promise.all against interrupt trigger
const raceResult = await Promise.race([
  Promise.all(childPromises).then(results => ({ type: 'completed', results })),
  interruptTrigger.then(reason => ({ type: 'interrupted', reason })),
]);

if (raceResult.type === 'interrupted') {
  // Signal all running children to stop
  for (const childId of runningChildren) {
    await getExternalWorkflowHandle(childId).signal(INTERRUPT_SIGNAL_NAME, reason);
  }
}

Per-Runtime Implementation

JS Runtime

Uses AbortSignal linked between parent and children:

typescript
// Parent creates controller linked to its own signal
const batchController = new AbortController();
parentAbortSignal.addEventListener('abort', () => batchController.abort());

// Children receive linked signal
const childHandle = await executor.execute(childAgent, input, {
  abortSignal: batchController.signal,
});

Temporal Runtime

Uses Trigger primitive + external workflow handles:

typescript
// Platform adapter sets up interrupt trigger
const interruptTrigger = new Trigger<string>();
setHandler(interruptSignal, (reason) => {
  interruptTrigger.resolve(reason); // Wake up immediately
});

// Workflow uses trigger in race
runAgentWorkflow(input, activities, {
  interruptTrigger,
  getExternalWorkflowHandle: (id) => getExternalWorkflowHandle(id),
});

Cloudflare Runtime

Uses an event-based approach for immediate interrupt response:

typescript
// Executor sends both flag and event for immediate wake-up
async interrupt(reason: string) {
  // Set flag for persistence
  await stateStore.setInterruptFlag(runId, reason);

  // Send event for immediate wake-up
  await instance.sendEvent({ type: `interrupt-${runId}`, payload: { reason } });
}

// Workflow races completion against interrupt event
const result = await Promise.race([
  step.waitForEvent(`sub-agent-complete-${subSessionId}`, { timeout: maxWait })
    .then(e => ({ type: 'complete', event: e })),
  step.waitForEvent(`interrupt-${runId}`, { timeout: maxWait })
    .then(e => ({ type: 'interrupt', reason: e.payload?.reason })),
]);

if (result.type === 'interrupt') {
  // Propagate to children
  for (const child of pendingChildren) {
    await stateStore.setInterruptFlag(child.runId, result.reason);
  }
  throw new InterruptDetectedError(result.reason);
}

The event-based approach provides:

  • Immediate response: Interrupt events win the race immediately (< 100ms)
  • Pre-spawn check: Interrupts are also checked before spawning sub-agents
  • No polling overhead: Pure event-driven detection

Propagation Sequence

mermaid
graph TB
    User["User clicks 'Stop'"]
    User --> Flag["Parent interrupt flag set"]
    Flag --> Detect["Parent detects interrupt<br/>(immediate via event)"]

    Detect --> Child1["Child 1: interrupt flag set +<br/>parent-interrupted event"]
    Detect --> Child2["Child 2: interrupt flag set +<br/>parent-interrupted event"]
    Detect --> Child3["Child 3: interrupt flag set +<br/>parent-interrupted event"]

    Child1 --> Stop["Each child stops at next safe point"]
    Child2 --> Stop
    Child3 --> Stop

    Stop --> Return["Parent returns { status: 'interrupted' }"]

Target Latency

RuntimeInterrupt DetectionChild SignalingTotal
JSImmediateImmediate< 100ms
TemporalImmediate (Trigger)< 100ms< 500ms
CloudflareImmediate (event)< 100ms< 200ms

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',
  parentSessionId: '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 {
  subSessionRefs: SubSessionRef[]; // Note: field is subSessionRefs, not subAgents
}

interface SubSessionRef {
  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.