Step Processing
This document explains how Helix Agents processes LLM step results and plans subsequent actions.
Overview
After each LLM call, the framework must:
- Parse the response (text, tool calls, structured output)
- Determine what actions to take
- 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:
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:
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):
interface StructuredOutputStepResult<TOutput> {
type: 'structured_output';
output: TOutput; // Validated output
stopReason?: StopReason;
}ErrorStepResult
LLM error:
interface ErrorStepResult {
type: 'error';
error: Error;
shouldStop: boolean; // Whether to terminate
stopReason?: StopReason;
}planStepProcessing
The main function for analyzing step results:
function planStepProcessing<TOutput>(
stepResult: StepResult<TOutput>,
options?: PlanStepProcessingOptions<TOutput>
): StepProcessingPlan<TOutput>;Return Value
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
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
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:
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:
[
// ...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):
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
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_usewithout a matchingtool_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 thetool_usebut no result. This is the site that is healed.finishWithtool — already conforms (the iterator pushes the tool's own result message).- native
structured_outputstep — 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— corestep-iterator.tsterminal-without-tools append.runtime-temporal—activities.tsstep site.runtime-dbos—workflows/shared.tsstep site.runtime-cloudflare—steps.tsstep 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:
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)
- Structured output - Terminal per-turn (continuable) — see note below
- Error with shouldStop - Terminal error
- Text with shouldStop - LLM indicated stop
- Max steps exceeded - Safety limit
- Custom stopWhen - Application-specific
Structured output is terminal per turn, not for the lifetime of the session. Emitting structured output (via
__finish__or a nativestructured_outputstep) ends the current turn terminally — the session rests incompletedwith the validated output. But that session can still be continued: a follow-up turn reopens it via thecompleted → activeCAS (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.
const shouldStop = shouldStopExecution(stepResult, stepCount, {
maxSteps: 10,
stopWhen: (result) => result.type === 'text' && result.content.includes('DONE'),
});determineFinalStatus
Maps step result to final status:
function determineFinalStatus<TOutput>(stepResult: StepResult<TOutput>): 'completed' | 'failed';Error stop reasons cause failure:
max_tokens→ failed (but see note below)content_filter→ failedrefusal→ failederror→ failed
Normal completions succeed:
end_turn→ completedstop_sequence→ completedtool_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:
function createAssistantMessage(input: AssistantMessagePlan): AssistantMessage;
interface AssistantMessagePlan {
content?: string;
toolCalls: ParsedToolCall[];
subAgentCalls: ParsedSubAgentCall[];
thinking?: ThinkingContent;
}Sub-agent calls are stored with the subagent__ prefix:
// 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:
function createToolResultMessage(input: ToolResultInput): ToolResultMessage;
interface ToolResultInput {
toolCallId: string;
toolName: string;
result?: unknown;
success: boolean;
error?: string;
}Result is JSON-stringified:
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:
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:
function buildMessagesForLLM<TState>(
messages: Message[],
systemPrompt: string | ((state: TState) => string),
customState: TState
): Message[];Resolves dynamic prompts and prepends system message:
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
// 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:
// 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
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);
});
});