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
parts?: AssistantMessagePart[]; // Ordered reasoning / text / tool-call parts (also on tool_calls)
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
}Structured output is a __finish__ tool call
There is no structured-output step result. A structured-output completion is a ToolCallsStepResult carrying the __finish__ call, whose arguments are the output. An adapter returns it like any other call, with every call co-emitted with it and the step's text, so core decides the step and the turn keeps its trace (RM-35).
ErrorStepResult
LLM error:
interface ErrorStepResult {
type: 'error';
error: Error; // Always the original LLM error
shouldStop: boolean; // Whether to terminate
stopReason?: StopReason;
onErrorFailure?: Error; // The adapter's awaited callbacks.onError rejected (DI-23)
}planStepProcessing() ignores onErrorFailure: the plan is built from error alone. Each runtime's LLM-step seam calls logOnErrorFailure() right after generateStep returns, which logs a surfaced onErrorFailure at warn (llm.on_error_callback_failed, with sessionId, runId and the original error code). The seams are core's step-iterator (runtime-js and the CF DO runtime), Temporal's runLLMStep activity, DBOS's runCallLLM step, and CF Workflows' AgentSteps.executeAgentStep. A bring-your-own loop that passes callbacks should do the same.
E5: the core step-iterator, Temporal's runLLMStep, DBOS's runCallLLM and CF Workflows' AgentSteps.executeAgentStep wrap their callbacks in holdLLMErrorReport(). The adapter's onError only records the error. After generateStep returns, settle(result, terminal) writes the runtime's error chunk only for a terminal failure, and only then can the write's rejection land on onErrorFailure. Temporal settles as the last step of runLLMStep, after the attempt's usage recording, afterLLMCall / onMessage hooks and writes: an attempt the activity retry repeats because one of them threw writes no chunk (so Temporal's afterLLMCall result never carries onErrorFailure). For an ordinary call terminal is isTerminalLLMStepError(result) (an error step with shouldStop). A forced call's error step is terminal on runtime-js and the CF DO (the step iterator) once it is not retried, and on Temporal unless it is retryable or unclassified, which runLLMStep rethrows for the activity retry without writing a chunk; DBOS keeps a forced call's error a recoverable miss with no chunk (until MR 3). The step iterator first retries a retryable: true error step under deps.llmStepRetry (MA-26) and drops its held report (settle(result, false)); only the error that is not retried is settled with terminal. CF Workflows hands a retryable: true error step back to step.do for a retry, so there terminal is "an error step the step does not hand back for a retry". A thrown generateStep never settles. An error chunk from an attempt that is called again would end the client's read of the response (see Error Handling).
planStepProcessing
The main function for analyzing step results:
function planStepProcessing<TOutput>(
stepResult: StepResult,
options?: PlanStepProcessingOptions<TOutput>
): StepProcessingPlan<TOutput>;Return Value
interface StepProcessingPlan<TOutput> {
// Data for creating assistant message (null for a rejected forced response)
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;
// Forced calls only: the response broke the completion protocol, or a
// native_output answer did not parse
forcedCompletionResult?:
| { kind: 'protocol_violation'; returnedToolNames: string[]; returnedSubAgentTypes: string[] }
| { kind: 'invalid_output'; error: string };
}The assistant message plan carries the step result's parts through to createAssistantMessage, so the ordered response parts reach the persisted message on every runtime.
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
A __finish__ call completes the turn. The assistant message keeps every call of the step; nothing is dispatched:
const stepResult = {
type: 'tool_calls',
toolCalls: [
{ id: 'look', name: 'lookup', arguments: { q: 'x' } },
{ id: 'fin', name: '__finish__', arguments: { result: 'done' } },
],
subAgentCalls: [],
shouldStop: false,
stopReason: 'tool_use',
};
const plan = planStepProcessing(stepResult);
// Result:
// {
// assistantMessagePlan: { toolCalls: [look, fin], ... }, // both calls kept
// pendingToolCalls: [],
// pendingSubAgentCalls: [],
// statusUpdate: { status: 'completed', output: { result: 'done' } },
// isTerminal: true,
// output: { result: 'done' },
// }The runtime then pairs every call (C2, below): fin gets {"acknowledged":true} and look a typed not_executed result. lookup never runs.
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.
A Temporal checkpoint can cut a finishWith pair (RM-52)
The invariant holds for the committed log, not for every checkpoint prefix of it on Temporal. Temporal writes a finishWith step's checkpoint before it appends that call's result, so the checkpoint's messageCount ends on the tool_use, and on a turn that completes via finishWith that checkpoint stays the latest. A retry({ checkpointId }) to it truncates the log there: in a live repro the retry reported completed, but the call's real result was deleted and its tool_use was left unpaired mid-history, where the continuation heal cannot reach it. runtime-js checkpoints the step with the result included. DBOS's tool-step checkpoint has the same short count (RM-40), but DBOS retry() does not truncate to a checkpoint. See RM-52 in the conformance findings.
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 terminal-output mechanisms, two can leave a call without a result if nothing intervenes:
__finish__tool call — the assistant message carries thetool_usebut no result unless it is paired.finishWithtool — conforms when it is the ONLY terminal call in the step (the iterator pushes the tool's own result message), but a step that emits several terminal calls (multiplefinishWithcalls, multiple__finish__calls, or a regular/sub-agent call co-emitted with__finish__) leaves every call but the winner without a result unless the terminal-step policy below runs.
Two mechanisms keep the invariant true: a terminal-step policy that pairs every call in the step the moment it completes (C2/C3), and a trailing continuation heal that repairs calls left unpaired by a run that stopped or was killed mid-tool (C5). See the design spec docs/superpowers/specs/2026-09-24-transcript-validity-design.md for the full RM-29 contract (C1–C5) and proof matrix; this section summarizes the mechanics.
Terminal-step policy (C2/C3)
In one LLM step, one terminal call wins: among finishWith calls, the first one in emission order that succeeds; among __finish__ calls, the first one. A step never offers both kinds (__finish__ is injected only when the agent has no finishWith tool); a hallucinated __finish__ co-emitted with a finishWith call is treated as terminal by planStepProcessing (pre-existing behaviour), so it wins. The winner keeps its real result: {"acknowledged":true} for __finish__ (a fixed sentinel, 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), or the tool's own output for finishWith. Every other call in the step — a 2nd+ finishWith/__finish__ call, or any regular or sub-agent call co-emitted with __finish__ — is not executed and gets a typed synthetic not-executed result instead (see below), so nothing is ever left dangling. A failing finishWith keeps its real error result, and the next finishWith in the step is still tried. On Temporal, runPhase2FinishWith additionally persists the real tool_result for every finishWith call it executes, success or failure — this runtime previously appended none.
Every adapter surfaces the co-emitted calls (RM-35): @helix-agents/llm-vercel returns a step containing __finish__ as tool_calls with every call and the text, and there is no step result that ends a run without an assistant message, so the co-emitted calls always reach this policy.
The single source of truth for the winning __finish__'s payload is synthesizeFinishToolResult(toolCallId) (packages/core/src/orchestration/message-builder.ts:233); for a non-winning call it is createNotExecutedToolResult(call, reason) (packages/core/src/orchestration/transcript.ts). This policy is implemented once in core (step-iterator.ts's terminal branch and phase-2 loop) and reimplemented at each runtime that reimplements the step loop — Temporal (activities.ts), DBOS (workflows/shared.ts), and Cloudflare (steps.ts, covering both the Durable Object and Workflows execution paths). A future runtime that reimplements the step loop must add this policy to its own step path; it will not inherit it for free.
The synthetic not-executed result (C4)
createNotExecutedToolResult(call, reason) builds a normal failed tool result via createToolResultMessage({ success: false }):
{
"content": "{\"error\":\"Tool call was not executed: <reason text>\"}",
"metadata": {
"helixToolFailed": true,
"helixSynthetic": { "kind": "not_executed", "reason": "<reason>" }
}
}reason is 'superseded_by_terminal_call' (a terminal-step loser, C2/C3), 'unpaired_on_continuation' (a continuation heal, C5 below) or 'run_stopped' (a stop's terminal pairs the stopped run's calls that have no result, CP-118; see §Stop terminal pairing below). isNotExecutedToolResult(message) checks the shape with Zod (notExecutedToolResultMetaSchema()), never a string match. The builder is pure and deterministic (no timestamps or random ids), so it is replay-safe on DBOS, Temporal and CFW.
Trailing continuation heal (C5)
Because the message log is append-only, a synthetic result can only be appended at the tail — a saved heal can therefore only repair unpaired calls that end the history. This is the only remaining source of unpaired calls after C2/C3 in the committed log: a run killed while tools were executing, on runtimes that append the assistant message before tools run (DBOS, Temporal; CF Workflows commits a step once, after its last result, RM-69), a stopped run's pending client-tool calls, and, on Temporal, any call a stop left unfinished (a stop's terminal pairs the others, §Stop terminal pairing below). (But see RM-52: on Temporal, a retry({ checkpointId }) to a finishWith step's checkpoint truncates away the call's result and leaves a mid-history unpaired call today.)
findTrailingUnpairedToolCalls(messages, { excludeIds }) / buildTrailingHealMessages(messages, { excludeIds }) (packages/core/src/orchestration/transcript.ts) replace the removed findUnpairedFinishCallId (no back-compat): they find the unpaired calls of the last assistant message, when only role: 'tool' messages follow it — not only a dangling __finish__.
When a new turn starts on a completed, failed or interrupted session — including a persistent-companion re-consult — the runtime pairs every trailing unpaired call before appending the new turn's input:
- an unpaired
__finish__gets the{ acknowledged: true }ack (samesynthesizeFinishToolResulthelper); - every other unpaired call gets a not-executed result with reason
unpaired_on_continuation.
On a retry() or a resume, the heal never touches ids still legitimately pending: a client tool or approval registered in pendingClientToolCalls is passed in via excludeIds and left untouched, because the continued run still awaits it.
A new turn settles them instead (RM-81). A pending entry of a completed, failed or interrupted session belongs to a run that ended, so no submit can answer it any more (every submit is fenced on the entry's run). The new turn's run-start commit settles it, before the input, through core planNewTurnPendingSettlement, which subsumes that turn's heal:
- an answered client call drains its stored result (or its submitted error);
- a denied approval drains its denial text (success-shaped on runtime-js and the CF DO, failure-shaped on CF Workflows: CP-66);
- an unanswered call, and an approved approval whose tool never ran, get a not-executed result (
unpaired_on_continuation); - each settled entry is consumed in the same commit (
PendingRestamp.consume, with itsalready_completedmarker on the root), so a late submit for it answersalready_completed; - an entry whose call is not trailing is dropped (nothing can be paired in place).
Each settled call's tool_end follows the new run's run_started; the chat transformer turns it into a data-tool-output-elsewhere trigger, since the call sits in an earlier message, and a client re-anchors on the snapshot. A bare stock useChat (no resync hook) ignores that data part, so its stale tool part stays input-available until a reload: ai refuses an output for a call outside the response message ("No tool invocation found"), as for an abandoned call. runtime-js, the CF DO and CF Workflows do this; Temporal and DBOS still spare pending ids on a new turn (FU-TEMPORAL-RM81-NEW-TURN-PENDING, FU-DBOS-RM81-NEW-TURN-PENDING).
Each settled call then fires the hooks a resumed run's drained answer fires, once: onMessage for its result row, and afterTool for a client result (never for a denial or a not-executed call, whose tool never ran). On runtime-js and the CF DO that is finishConsumedEntryCalls, as for a with_message entry's consumed calls. On CF Workflows it is the drain's own fireSettledAnswerHooks, in the turn's own durable step (<agentType>-settled-answer-hooks, after the run-start step, which carries the settled calls in its result), and a hook's updateState is persisted fenced by the new run. Like the drain's hooks, they fire again until that step completes and never after it.
Residual (CF Workflows). The settled calls reach that step only through the run-start step's result: a crash INSIDE the run-start step, after its commit and before the step completes, re-executes it without its plan (the consumed pending entries are gone), so those calls' hooks never fire. The window is one step body between a commit and its result's persist; FU-CFW-RM81-SETTLED-HOOKS-RUNSTART-CRASH tracks rebuilding the calls from durable data.
Mid-history gaps (an unpaired call followed by a later user/assistant message) are out of scope and are not repaired — the append-only log cannot rewrite the past, and there is no back-compat requirement for sessions poisoned before this fix. (Mid-history gaps are not only a pre-fix legacy: RM-52's Temporal retry-to-checkpoint creates one today.)
This heal runs at every runtime step site — runtime-js (executor and run-loop continuation), runtime-temporal (companion re-consult and the root new-turn heal in initializeAgentState), runtime-dbos (including root continuation, not only companion re-consult, and retry() / resume()-from- interrupted, which start a fresh workflow from the persisted history), and runtime-cloudflare. It is not inherited from core: a new runtime must add it at its own continuation sites. A finishWith call deferred by a same-step client-tool or suspending sub-agent call is executed exactly once after resume (findDeferredFinishWithCalls/runDeferredFinishWith), on all five runtimes. On CF Workflows a persistent companion call deferred by a same-step client tool (the step's companion calls run after its phase 1, so its suspension returns before them) also runs exactly once after resume, before the deferred finishWith (findDeferredCalls, the step's own companion runner); on runtime-js and the CF DO the step iterator runs a companion call with the step's other server calls, before the suspension commits, so it is never deferred. An interrupt pending at that point takes precedence: the deferred call is not executed (a stop gates new work, Ruling O-73) and the run ends interrupted. On runtime-js, the CF DO and CF Workflows the stop's terminal pairs the call run_stopped (below); on Temporal it stays trailing until a new turn's heal (FU-TEMPORAL-STOP-PAIRS-UNFINISHED-CALLS). A deferred finishWith that already RAN when a stop lands keeps its real result.
Stop terminal pairing (CP-118) and completed work (Ruling O-73)
A stop refuses NEW work, never the record of completed work (Ruling O-73). The result row of a call whose work finished (a tool that executed, a child outcome that arrived, a finishWith that ran, an approved tool that ran, a drained client answer) is appended run-fenced but NOT stop-guarded: no refuseIfStopped, and the superseded-run fence stays on, so a superseded run still writes nothing (RunSupersededError). The tool's STAGED state writes are part of that record too and are committed the same way (CF Workflows: a step's own results and staged writes ride its one step commit, under the stop's { allowKind } when a stop ends the step, RM-69; mergeStagedIntoState for a resumed run's deferred finishWith, the approve drain's final write; runtime-js: the deferred finishWith step commit, the approved tool's fenced save). On a CF Workflows resumed run the row and the state are two run-fenced commits (each idempotent under step.do retry), not one. Starting anything new (a tool, a child, the next step) is gated on the run's stop record: with a stop, it does not start. The run still ends by its stop (interrupted, or failed / framework_cancelled for an abort).
So the stop's terminal has only the calls with NO result left to pair: work that never started, a wait the stop cut short, or a crash between the work and its record. It pairs them, BEFORE its K5, with a run_stopped not-executed result (core pairUnfinishedCalls): the aborted tool_end frames first, then ONE append commit by the run under the stop's own guard ({ allowKind }, the guard its K5 commits under). The rows are recomputed from the history on every K5 attempt (a retried attempt appends nothing twice), the pending client-tool ids are read before any K5 attempt cleared them, and nothing is paired once the run's K5 landed. Each attempt reads the whole history once. A pairing failure other than a stop or the fence is retried; on the last attempt the K5 commits unpaired (logged) and the next turn's C5 heal pairs the calls.
A call is paired at most once. CF Workflows' local sub-agent wave closes a record gate when a branch sees the stop: a sibling whose child outcome arrives afterwards does not record it, and the stop's terminal awaits every record that started before the gate closed, then pairs the rest in the step's own commit under the stop (RM-69: the step's answered rows and the run_stopped rows of its other calls commit together, so the K5's pairing finds nothing left). runtime-js cannot race this way: its seams (the drain, the remote reconcile, the child injection, the deferred finishWith) are sequential and awaited before its stop terminal, and its in-step sub-agent results ride the step commit.
Exception: pending client-tool calls (the pendingClientToolCalls ids, approvals included) are excluded. They stay unpaired until a resume drains them or a new turn settles them (RM-81, above), so a late submit for one is still answered { status: 'unknown_tool_call', reason: 'run_stopped' } (WA MR2 Ruling 56). An approved tool that a stop kept from running stays pending this way.
| Runtime | Completed-work records (O-73) | Stop pairing (run_stopped) |
|---|---|---|
| runtime-js | the deferred finishWith commit, the drain's result rows, the remote reconcile, the child injection (before the boundary's stop poll); an approved tool and the deferred finishWith gated on the stop | the run loop (before its step_discarded / run_interrupted frames, and again in its stop K5); endEntryRunByStop |
| CF DO | the same (it runs the runtime-js executor) | the same |
| CF Workflows | every result appendCommitted(…, { recordsCompletedWork: true }): spawns, waitForResult, continuations, companion resume, phase-2 / deferred finishWith, the remote last resort, the drain, approvals | commitTerminalState with a stop (the workflow's handleStop, or a stop that refused the planned K5) |
| Temporal | not yet (FU-TEMPORAL-STOP-PAIRS-UNFINISHED-CALLS) | not yet: the call stays trailing until a new turn's heal |
| DBOS | no window, except its approval continuation (FU-DBOS-APPROVED-RESULT-SURVIVES-STOP): a step's rows land in one commit, and a partly committed step's remaining results are not stop-guarded | no window |
On CF Workflows a phase-2 finishWith is gated by executeToolCall's preflight (its state load reads the run's stop: a stopped run's call is refused with no row), and the deferred one by the workflow's deferred-finishwith stop read. Synthetic rows (superseded_by_terminal_call, unpaired_on_continuation), a with_message entry's input and stash rows, and injected user rows stay stop-guarded: they are not records of completed work.
A stop that core commitStopTerminal commits for a run nothing executes (the CF DO's drive/stop.ts and settle.ts, the DO stop deadline, CF Workflows readers' settle_stopped, a parent paused on its children) does not pair yet: a call such a run leaves unpaired (a suspended sub-agent's call, a deferred finishWith whose tool ignored the abort past the deadline) is paired by the next turn's C5 heal (FU-STOP-SETTLE-PAIRS-UNFINISHED-CALLS).
Stop Condition Checking
resolveStopDecision
The one stop decision every runtime makes after a step (C1). shouldStopExecution is its boolean form (resolveStopDecision(...).stop), kept for custom loops:
type StopCause = 'terminal' | 'stop_when' | 'max_steps';
type StopDecision = { stop: false } | { stop: true; cause: StopCause };
function resolveStopDecision<TOutput>(
stepResult: StepResult,
stepCount: number, // after this step, 1-indexed
config: StopConfig<TOutput>
): StopDecision;
function shouldStopExecution<TOutput>(
stepResult: StepResult,
stepCount: number,
config: StopConfig<TOutput>
): boolean;
interface StopConfig<TOutput> {
maxSteps?: number; // unset → DEFAULT_MAX_STEPS (50)
stopWhen?: (result: StepResult) => boolean;
}Stop Conditions (Priority Order)
- Terminal (
terminal): structured output — terminal per-turn (continuable), see the note below — or an error or text step withshouldStop. A final answer on the last allowed step is a normal completion. - Custom stopWhen (
stop_when): application-specific. It beatsmaxSteps, so a predicate that fires on the cap step is astop_whenstop. - Max steps (
max_steps):stepCount >= maxSteps, the model had not finished.
The step iterator acts on the cause, after the step's tools ran and its rows committed:
stop_whenwithoutoutputSchema→completed, outputundefined,completionReason: 'stop_when'on the run;max_stepswithoutoutputSchema→failed,createMaxStepsExhaustedError(maxSteps)(framework_max_steps_exhausted, non-retryable),completionReason: 'max_steps';- either cause for an
outputSchemaagent with no output → forced completion (triggermax_steps), nocompletionReason; - an error stop reason on a step with a stop cause →
failedwith that reason's message first (DI-26 owns those); - a forced call is never preempted (the decision is skipped for it).
A throwing stopWhen is not caught by core: runtime-js / the DO fail the run through the run-loop catch (CP-71). CF Workflows evaluates the decision inside the LLM step's step.do (a replay reuses it), marks a throw as a hook failure (failed once, never retried, before the step's tools run) and returns the cause as stopCause. Temporal and DBOS still call shouldStopExecution with maxSteps alone (DI-29, DI-25; CF-D1 MR 2 / MR 3).
Structured output is terminal per turn, not for the lifetime of the session. Emitting structured output (a
__finish__call, or a native answer normalised to one) 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 decision = resolveStopDecision(stepResult, stepCount, {
maxSteps: 10,
stopWhen: (result) =>
result.type === 'tool_calls' && result.toolCalls.some((tc) => tc.name === 'final_answer'),
});
// { stop: true, cause: 'stop_when' } once `final_answer` was calleddetermineFinalStatus
Maps step result to final status:
function determineFinalStatus<TOutput>(stepResult: StepResult): '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
Temporal and DBOS never call determineFinalStatus (DI-26)
planStepProcessing gives a text step no status update, so a runtime must call determineFinalStatus itself. JS (and the Cloudflare DO, which embeds it) does, and Cloudflare Workflows applies the same isErrorStopReason check. Temporal and DBOS do not: for an agent without outputSchema, a final text step with an error stop reason ends the run completed with no error (observed live for content_filter, and for max_tokens on Temporal). See DI-26 in the conformance findings.
Forced completion, not a retry nudge: For an agent with an outputSchema, determineFinalStatus is never the last word when a step would otherwise finalize without output. Before a max_tokens text result, an ordinary terminal-text result (end_turn/stop_sequence), or a maxSteps exhaustion is allowed to finalize the run, classifyForcedCompletionEntry() inspects it: if the agent declares an outputSchema and no output was produced yet, it classifies the trigger (terminal_text, max_tokens, or max_steps) and the runtime enters a durable forced-completion phase instead of finalizing. Only non-recoverable stop reasons (content_filter, refusal, error, unknown — still distinguished via isRecoverableErrorStopReason()) bypass forced completion and fail immediately with their original classification.
Once in the forced-completion phase, every subsequent call is a forced call, bounded by its own maxCompletionRetries budget — independent of maxSteps. At each forced call the runtime resolves the adapter's capabilities for the model that call uses (resolveCompletionCapabilities, after llmConfigOverride on JS / CF DO) and prepareForcedCompletionCall({ state, effectiveTools, resolution, modelLabel }) builds the directive for the selected strategy: forced_tool (a named toolChoice; on a model whose thinking is not history-bound also one tool, a trimmed system prompt and reasoning disabled — byte for byte the legacy request) or native_output (the full tools and prompt, outputFormat with the completion tool's schema, minOutputTokens). The strategy is never persisted. planStepProcessing receives forcedCompletion: { expectedToolName } (or { expectedToolName, strategy: 'native_output', callIdSalt }): it strips any response other than the single expected completion call (protocol violation) and, under native_output, normalises a terminal JSON text answer into a synthetic call to the completion tool (nativeCompletionCallId, metadata.helixNativeCompletion) or flags unparseable text as invalid_output. resolveForcedCallTransition() then classifies the result (classifyForcedCompletionResult()) and returns the transition (recordForcedCompletionMiss() / recordForcedCompletionSuccess() / failForcedCompletion()); a provider error on a forced call fails the phase with the provider's own detail on JS / CF DO / CF Workflows / Temporal (CF Workflows first retries a retryable or unclassified one through step.do, Temporal through its activity retry). The phase either ends the run completed with schema-valid output or failed with a classified error (framework_forced_completion_exhausted, framework_completion_protocol_violation, framework_completion_tool_missing, framework_completion_strategy_unavailable, framework_prompt_prefix_changed, or the provider's code; framework_completion_invalid_output is the code of an individual miss) — it never reaches completed with missing output. All of this lives in packages/core/src/orchestration/forced-completion.ts, completion-strategy.ts and step-processor.ts; runtime-js, the CF DO, CF Workflows and Temporal use all of it (Temporal inside its runLLMStep activity, which returns the transition for the workflow to apply), while DBOS still passes LEGACY_RESOLUTION and keeps a forced call's error a recoverable miss until MR 3. See Finishing Agents → Forced completion for the user-facing guide and @helix-agents/core reference for the full API.
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;
}A successful string result is stored verbatim and marked metadata.helixContentEncoding: 'text' (COMMON_METADATA_KEYS.CONTENT_ENCODING); anything else (objects, numbers, booleans, null, every error result) is JSON-stringified with no marker (RM-32). Decode with decodeToolResultContent:
createToolResultMessage({
toolCallId: 'tc1',
toolName: 'search',
result: { items: ['a', 'b'] },
success: true,
});
// Output
{
role: 'tool',
toolCallId: 'tc1',
toolName: 'search',
content: '{"items":["a","b"]}',
metadata: { helixToolFailed: false },
}
createToolResultMessage({ toolCallId: 'tc2', toolName: 'say', result: 'hello', success: true });
// content: 'hello', metadata: { helixToolFailed: false, helixContentEncoding: 'text' }
// decodeToolResultContent(message) → 'hello' (a JSON-looking string such as '42' stays a string)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;
}
const decision = resolveStopDecision(stepResult, stepCount, config);
if (decision.stop) {
// terminal → plan.statusUpdate; stop_when → completed;
// max_steps → failed, createMaxStepsExhaustedError(maxSteps)
break;
}
}Temporal Runtime
Same functions used in the runLLMStep activity (simplified):
// In the activity
async function runLLMStep(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);
});
});