A __finish__ step runs its sibling calls first
This release changes what happens when a model emits __finish__ together with other calls in one step. It is a breaking release of @helix-agents/core, @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-temporal and @helix-agents/runtime-dbos, each a major bump; @helix-agents/sdk re-exports core, so it takes a minor bump. Deploy the runtimes together with core.
Design: 2026-11-03-finish-step-sibling-calls-design.md.
What was wrong
A structured-output agent completes by calling __finish__. When the model emitted __finish__ beside other calls in the same step (for example [recordCredits, __finish__]), the run completed with the __finish__ output and every other call was answered not_executed (superseded_by_terminal_call) without running. A model that records its work and finishes in one step lost that work: the credit was never recorded, though the run reported success.
Breaking for core: a custom loop must handle plan.finish
A __finish__ step with sibling calls is no longer terminal at plan time. If you build your own loop on planStepProcessing (the "bring your own loop" path), such a step now plans isTerminal: false, with the siblings in pendingToolCalls / pendingSubAgentCalls and the finish in plan.finish ({ callId, output, supersededCalls }). A loop that only checks plan.isTerminal runs the siblings and then calls the model again, or ends with no output. A lone __finish__ still plans terminal, as before.
Handle plan.finish after the step's calls ran: append the losers' rows and the acknowledgement (buildFinishPhaseRows), then complete with plan.finish.output. Pass isFinishWithToolName so the planner knows your finishWith tools: a finishWith call beside a __finish__ then loses (C2) instead of running as a sibling (the option defaults to "no tool is a finishWith tool").
// Before: a __finish__ step was always terminal
const plan = planStepProcessing(stepResult, { outputSchema: agent.outputSchema });
// ... append the assistant message, run plan.pendingToolCalls ...
if (plan.statusUpdate) {
state.status = plan.statusUpdate.status;
state.output = plan.statusUpdate.output;
}
if (plan.isTerminal) break;
// After: a __finish__ step with siblings carries plan.finish
import { buildFinishPhaseRows, isFinishWithTool } from '@helix-agents/core';
const toolsByName = new Map(tools.map((t) => [t.name, t])); // tools = buildEffectiveTools(agent)
const plan = planStepProcessing(stepResult, {
outputSchema: agent.outputSchema,
// Without it a finishWith call beside __finish__ runs as a sibling instead of losing (C2).
isFinishWithToolName: (name) => {
const t = toolsByName.get(name);
return t !== undefined && isFinishWithTool(t);
},
});
// ... append the assistant message, run plan.pendingToolCalls / plan.pendingSubAgentCalls ...
if (plan.finish) {
// The losers' rows, then the acknowledgement, last.
state.messages.push(...buildFinishPhaseRows(plan.finish));
state.status = 'completed';
state.output = plan.finish.output;
}
if (plan.statusUpdate) {
state.status = plan.statusUpdate.status;
state.output = plan.statusUpdate.output;
}
if (plan.isTerminal || plan.finish) break;If a sibling suspends (a client-executed tool, an approval, a sub-agent), run the finish on the resume, once the answers are recorded: findDeferredCalls(messages, kindOf).finish names the deferred __finish__, and deriveFinishOutput(call, outputSchema) re-derives its output from the committed arguments.
What changed
| Before | After |
|---|---|
a call beside __finish__ was answered not_executed and never ran | it runs first, as any step's call (hooks, frames, state writes, usage); then the run completes with the __finish__ output |
planStepProcessing returned a terminal plan for any step with a __finish__ | a __finish__ step with siblings is an ordinary plan (isTerminal: false, the siblings pending) carrying finish; a lone __finish__ is unchanged |
| — | StepProcessingPlan.finish?: { callId, output, supersededCalls } (StepFinishPlan) |
findDeferredCalls returned { companion, finishWith }; DeferredCallKind had two kinds | it returns { companion, finishWith, finish }; DeferredCallKind adds 'finish' (a __finish__, classified by name) |
| — | deriveFinishOutput(call, schema), buildFinishPhaseRows(finish), findCommittedFinish(messages) |
What did not change:
- Terminal calls still lose (RM-29 C2). Among several
__finish__calls the first wins; a later__finish__, and anyfinishWithcall beside a__finish__, is answerednot_executedand never runs. - The output is validated at plan time, before any sibling runs. An invalid
__finish__output fails the step as before, and nothing runs. - A forced completion call that carries another call is still a recoverable protocol miss.
- A sibling that fails is recorded as that call's result, and the finish is still honoured.
- The transcript stays valid: the siblings' results come first, then the losers' rows, then the
__finish__acknowledgement, last, all in the step commit.
A sibling that suspends
A sibling that waits for the client (a client-executed tool or an approval) or for a sub-agent defers the finish, as it defers a finishWith call today. The run suspends (suspended_client_tool, or suspended_step_partial when another sibling already ran); once the answer arrives, the resume honours the finish with no model call. A with_message resume over a deferred finish records it not_executed (unpaired_on_continuation) before the message, so the message redirects the turn (runtime-js, the CF Durable Object, CF Workflows). DBOS waits for the client inline, so its finish runs after the wait in the same workflow.
Temporal
A new workflow execution takes the new path behind wf.patched('helix-finish-siblings-v1'). A workflow whose history was recorded before the upgrade replays the old terminal plan, so it stays deterministic. No action is needed.
DBOS
DBOS derives the plan in workflow code from the recorded LLM step output, so a workflow recorded under the old code and recovered under the new code could take the new path. Drain in-flight DBOS workflows before upgrading.
Reading the history
A sibling's row is now its real result. Code that read a not_executed row beside __finish__ as "the model's extra call never ran" should read it from the row itself (isNotExecutedToolResult(message)); losing terminal calls still carry superseded_by_terminal_call.