Skip to content

Step Processing

This document explains how Helix Agents processes LLM step results and plans subsequent actions.

Overview

After each LLM call, the framework must:

  1. Parse the response (text, tool calls, structured output)
  2. Determine what actions to take
  3. Decide whether to continue or stop

This is handled by pure functions in the orchestration module.

StepResult Types

The LLM adapter returns a StepResult discriminated union:

TextStepResult

Plain text response:

typescript
interface TextStepResult {
  type: 'text';
  content: string; // The text content
  thinking?: ThinkingContent; // Reasoning trace
  shouldStop: boolean; // LLM-indicated stop
  stopReason?: StopReason; // Why it stopped
}

ToolCallsStepResult

One or more tool invocations:

typescript
interface ToolCallsStepResult {
  type: 'tool_calls';
  content?: string; // Optional text with tools
  toolCalls: ParsedToolCall[];
  subAgentCalls: ParsedSubAgentCall[];
  thinking?: ThinkingContent;
  stopReason?: StopReason;
}

interface ParsedToolCall {
  id: string; // Tool call ID
  name: string; // Tool name
  arguments: unknown; // Parsed arguments
}

interface ParsedSubAgentCall {
  id: string; // Call ID
  agentType: string; // Sub-agent type
  input: unknown; // Input for sub-agent
}

StructuredOutputStepResult

Direct structured output (no tool call):

typescript
interface StructuredOutputStepResult<TOutput> {
  type: 'structured_output';
  output: TOutput; // Validated output
  stopReason?: StopReason;
}

ErrorStepResult

LLM error:

typescript
interface ErrorStepResult {
  type: 'error';
  error: Error;
  shouldStop: boolean; // Whether to terminate
  stopReason?: StopReason;
}

planStepProcessing

The main function for analyzing step results:

typescript
function planStepProcessing<TOutput>(
  stepResult: StepResult<TOutput>,
  options?: PlanStepProcessingOptions<TOutput>
): StepProcessingPlan<TOutput>;

Return Value

typescript
interface StepProcessingPlan<TOutput> {
  // Data for creating assistant message (null for structured_output)
  assistantMessagePlan: AssistantMessagePlan | null;

  // Tools to execute (excludes __finish__)
  pendingToolCalls: ParsedToolCall[];

  // Sub-agents to invoke
  pendingSubAgentCalls: ParsedSubAgentCall[];

  // Status update to apply (null if no change)
  statusUpdate: StatusUpdatePlan | null;

  // Whether execution should stop
  isTerminal: boolean;

  // Parsed output if __finish__ was called
  output?: TOutput;

  // Stop reason for logging/debugging
  stopReason?: StopReason;
}

Processing Flow

Text Response

typescript
const stepResult = { type: 'text', content: 'Hello!', shouldStop: false };
const plan = planStepProcessing(stepResult);

// Result:
// {
//   assistantMessagePlan: { content: 'Hello!', toolCalls: [], ... },
//   pendingToolCalls: [],
//   pendingSubAgentCalls: [],
//   statusUpdate: null,
//   isTerminal: false,
// }

Tool Calls

typescript
const stepResult = {
  type: 'tool_calls',
  toolCalls: [
    { id: 'tc1', name: 'search', arguments: { query: 'test' } },
    { id: 'tc2', name: 'fetch', arguments: { url: 'https://...' } },
  ],
  subAgentCalls: [],
};
const plan = planStepProcessing(stepResult);

// Result:
// {
//   assistantMessagePlan: { toolCalls: [...], ... },
//   pendingToolCalls: [
//     { id: 'tc1', name: 'search', ... },
//     { id: 'tc2', name: 'fetch', ... },
//   ],
//   pendingSubAgentCalls: [],
//   statusUpdate: null,
//   isTerminal: false,
// }

finish Tool Call

The __finish__ tool is special—it signals completion:

typescript
const stepResult = {
  type: 'tool_calls',
  toolCalls: [{ id: 'tc1', name: '__finish__', arguments: { result: 'done' } }],
  subAgentCalls: [],
};
const plan = planStepProcessing(stepResult, {
  outputSchema: z.object({ result: z.string() }),
});

// Result:
// {
//   assistantMessagePlan: { toolCalls: [...], ... },  // Includes __finish__ in history
//   pendingToolCalls: [],  // Empty! __finish__ is not executed
//   pendingSubAgentCalls: [],
//   statusUpdate: { status: 'completed', output: { result: 'done' } },
//   isTerminal: true,
//   output: { result: 'done' },
// }

The runtime's step loop does not stop there: immediately after appending the assistant message it appends a synthetic tool_result for the __finish__ call ({"acknowledged":true}), so the persisted transcript pairs the tool_use. The committed history is:

jsonc
[
  // ...prior turns...
  {
    "role": "assistant",
    "toolCalls": [{ "id": "tc1", "name": "__finish__", "arguments": { "result": "done" } }],
  },
  {
    "role": "tool",
    "toolCallId": "tc1",
    "toolName": "__finish__",
    "content": "{\"acknowledged\":true}",
  },
]

See §The __finish__ history invariant for why this matters and where it is applied.

Structured Output

Direct structured output (no __finish__ tool):

typescript
const stepResult = {
  type: 'structured_output',
  output: { result: 'done' },
};
const plan = planStepProcessing(stepResult);

// Result:
// {
//   assistantMessagePlan: null,  // No assistant message
//   pendingToolCalls: [],
//   pendingSubAgentCalls: [],
//   statusUpdate: { status: 'completed', output: { result: 'done' } },
//   isTerminal: true,
//   output: { result: 'done' },
// }

Error

typescript
const stepResult = {
  type: 'error',
  error: new Error('Rate limited'),
  shouldStop: true,
};
const plan = planStepProcessing(stepResult);

// Result:
// {
//   assistantMessagePlan: null,
//   pendingToolCalls: [],
//   pendingSubAgentCalls: [],
//   statusUpdate: { status: 'failed', error: 'Rate limited' },
//   isTerminal: true,
// }

The __finish__ history invariant

The framework upholds a single invariant about persisted conversation history:

Persisted history never contains a tool_use without a matching tool_result.

This is what makes a completed structured-output session safely continuable (see Session Model → continuation contract). Every reader — the next LLM call, transcript exports, replay, the UI, usage rollups, all runtimes — can trust the transcript with no special-casing.

Of the three terminal-output mechanisms only one ever dangled:

  • __finish__ tool call — the assistant message carries the tool_use but no result. This is the site that is healed.
  • finishWith tool — already conforms (the iterator pushes the tool's own result message).
  • native structured_output step — no assistant message at all (assistantMessagePlan: null), so there is nothing to pair.

Eager heal

When an agent completes via __finish__, the step loop appends a synthetic tool_result for the __finish__ call immediately after the assistant message. The payload is a fixed sentinel — {"acknowledged":true} — not an echo of the output (the output already lives on the assistant message's tool-call arguments). The __finish__ tool is framework-internal and filtered from user-facing streams, so the sentinel never surfaces to consumers.

The single source of truth for the synthetic message is synthesizeFinishToolResult(toolCallId) in packages/core/src/orchestration/message-builder.ts:233. Despite living in core, the heal is NOT inherited automatically by every runtime. Only runtime-js funnels its step loop through core runStepIteration. Temporal, DBOS, and the Cloudflare Workflows runtime each reimplement the step loop, so the heal is replicated at four runtime step sites, all calling the shared synthesizeFinishToolResult helper so the payload cannot drift:

  • runtime-js — core step-iterator.ts terminal-without-tools append.
  • runtime-temporalactivities.ts step site.
  • runtime-dbosworkflows/shared.ts step site.
  • runtime-cloudflaresteps.ts step site (covers both the Durable Object and Workflows execution paths).

A future runtime that reimplements the step loop must add the heal to its own step path (call synthesizeFinishToolResult); it will not inherit it for free.

Legacy heal on continuation reopen

Sessions that completed before the per-step heal shipped carry a real dangling __finish__. The continuation path therefore also runs a defensive legacy heal on reopen: before weaving in the new user message it scans the preserved history via findUnpairedFinishCallId(messages) (message-builder.ts:251) and, if an unpaired __finish__ is present, synthesizes the missing tool_result (same synthesizeFinishToolResult helper) so the reopened transcript is valid. This is a no-op for sessions completed under current code (their __finish__ is already paired). Net: eager-at-completion going forward plus a heal-on-continue safety net for pre-existing data, so the invariant holds even for legacy histories. See Sub-Agents guide → Re-consulting a persistent companion (Known limitations) for the one Cloudflare pre-heal-upgrade gap.

Stop Condition Checking

shouldStopExecution

Determines if the agent should stop:

typescript
function shouldStopExecution<TOutput>(
  stepResult: StepResult<TOutput>,
  stepCount: number,
  config: StopConfig<TOutput>
): boolean;

interface StopConfig<TOutput> {
  maxSteps?: number;
  stopWhen?: (result: StepResult<TOutput>) => boolean;
}

Stop Conditions (Priority Order)

  1. Structured output - Terminal per-turn (continuable) — see note below
  2. Error with shouldStop - Terminal error
  3. Text with shouldStop - LLM indicated stop
  4. Max steps exceeded - Safety limit
  5. Custom stopWhen - Application-specific

Structured output is terminal per turn, not for the lifetime of the session. Emitting structured output (via __finish__ or a native structured_output step) ends the current turn terminally — the session rests in completed with the validated output. But that session can still be continued: a follow-up turn reopens it via the completed → active CAS (root continuation) or the dispatcher continuation path (persistent companion), preserving conversation memory and returning a fresh validated output. Structured-output agents are therefore first-class participants in the multi-turn continuation contract — not single-shot. See §The __finish__ history invariant below and the Sub-Agents guide → Re-consulting a persistent companion.

typescript
const shouldStop = shouldStopExecution(stepResult, stepCount, {
  maxSteps: 10,
  stopWhen: (result) => result.type === 'text' && result.content.includes('DONE'),
});

determineFinalStatus

Maps step result to final status:

typescript
function determineFinalStatus<TOutput>(stepResult: StepResult<TOutput>): 'completed' | 'failed';

Error stop reasons cause failure:

  • max_tokens → failed (but see note below)
  • content_filter → failed
  • refusal → failed
  • error → failed

Normal completions succeed:

  • end_turn → completed
  • stop_sequence → completed
  • tool_use → completed

Recoverable errors: When an agent has an outputSchema, max_tokens is treated as a recoverable error. Instead of immediately failing, the framework retries with a correction message that nudges the model to call the completion tool (__finish__) directly rather than writing a long text response. The retry message includes a hint about the truncation. Non-recoverable error stop reasons (content_filter, refusal, error, unknown) are not retryable and fail immediately. Use isRecoverableErrorStopReason() to check.

Message Building

createAssistantMessage

Creates the assistant message for history:

typescript
function createAssistantMessage(input: AssistantMessagePlan): AssistantMessage;

interface AssistantMessagePlan {
  content?: string;
  toolCalls: ParsedToolCall[];
  subAgentCalls: ParsedSubAgentCall[];
  thinking?: ThinkingContent;
}

Sub-agent calls are stored with the subagent__ prefix:

typescript
// Input
{
  toolCalls: [{ id: 't1', name: 'search', arguments: {} }],
  subAgentCalls: [{ id: 's1', agentType: 'summarizer', input: {} }],
}

// Output message.toolCalls
[
  { id: 't1', name: 'search', arguments: {} },
  { id: 's1', name: 'subagent__summarizer', arguments: {} },
]

createToolResultMessage

Creates tool result messages:

typescript
function createToolResultMessage(input: ToolResultInput): ToolResultMessage;

interface ToolResultInput {
  toolCallId: string;
  toolName: string;
  result?: unknown;
  success: boolean;
  error?: string;
}

Result is JSON-stringified:

typescript
createToolResultMessage({
  toolCallId: 'tc1',
  toolName: 'search',
  result: { items: ['a', 'b'] },
  success: true,
});

// Output
{
  role: 'tool',
  toolCallId: 'tc1',
  toolName: 'search',
  content: '{"items":["a","b"]}',
}

createSubAgentResultMessage

Same as tool result but with prefix:

typescript
createSubAgentResultMessage({
  toolCallId: 's1',
  agentType: 'summarizer',
  result: { summary: '...' },
  success: true,
});

// Output
{
  role: 'tool',
  toolCallId: 's1',
  toolName: 'subagent__summarizer',
  content: '{"summary":"..."}',
}

buildMessagesForLLM

Prepares messages for LLM calls:

typescript
function buildMessagesForLLM<TState>(
  messages: Message[],
  systemPrompt: string | ((state: TState) => string),
  customState: TState
): Message[];

Resolves dynamic prompts and prepends system message:

typescript
const messages = buildMessagesForLLM(
  state.messages,
  (state) => `You have ${state.notes.length} notes.`,
  state.customState
);

// Prepends:
// { role: 'system', content: 'You have 5 notes.' }

Runtime Integration

JS Runtime

typescript
// In JSAgentExecutor
while (state.status === 'running') {
  const messages = buildMessagesForLLM(...);
  const stepResult = await llmAdapter.generateStep(...);
  const plan = planStepProcessing(stepResult, { outputSchema });

  if (plan.assistantMessagePlan) {
    state.messages.push(createAssistantMessage(plan.assistantMessagePlan));
  }

  for (const toolCall of plan.pendingToolCalls) {
    // Execute tool, create result message
  }

  if (plan.statusUpdate) {
    state.status = plan.statusUpdate.status;
    state.output = plan.statusUpdate.output;
  }

  if (plan.isTerminal || shouldStopExecution(stepResult, stepCount, config)) {
    break;
  }
}

Temporal Runtime

Same functions used in activities:

typescript
// In activity
export async function executeAgentStep(input) {
  const stepResult = await llmAdapter.generateStep(...);
  const plan = planStepProcessing(stepResult, { outputSchema });

  // Return plan for workflow to process
  return {
    assistantMessage: plan.assistantMessagePlan
      ? createAssistantMessage(plan.assistantMessagePlan)
      : null,
    pendingToolCalls: plan.pendingToolCalls,
    statusUpdate: plan.statusUpdate,
    isTerminal: plan.isTerminal,
  };
}

Testing

typescript
import { planStepProcessing, shouldStopExecution } from '@helix-agents/core';

describe('planStepProcessing', () => {
  it('detects __finish__ tool', () => {
    const plan = planStepProcessing({
      type: 'tool_calls',
      toolCalls: [{ id: 't1', name: '__finish__', arguments: { done: true } }],
      subAgentCalls: [],
    });

    expect(plan.isTerminal).toBe(true);
    expect(plan.pendingToolCalls).toHaveLength(0);
    expect(plan.output).toEqual({ done: true });
  });

  it('excludes __finish__ from pending tools', () => {
    const plan = planStepProcessing({
      type: 'tool_calls',
      toolCalls: [
        { id: 't1', name: 'search', arguments: {} },
        { id: 't2', name: '__finish__', arguments: {} },
      ],
      subAgentCalls: [],
    });

    expect(plan.pendingToolCalls).toHaveLength(0);
  });
});

See Also

Released under the MIT License.