Skip to content

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").

typescript
// 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 ​

BeforeAfter
a call beside __finish__ was answered not_executed and never ranit 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 kindsit 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 any finishWith call beside a __finish__, is answered not_executed and 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.

Released under the MIT License.