Skip to content

A failed run drops its unanswered client calls (CP-256) ​

This guide covers the release in which a run that ends failed no longer leaves its unanswered client-tool calls pending in the session. It is for people who read a failed session's pendingClientToolCalls, call core failCommittedRun, or write their own agent loop.

Versions: @helix-agents/core, @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-dbos and @helix-agents/runtime-temporal take a major. Upgrade them together.

What changed ​

A failed run's unanswered call can never be answered: every submit is fenced on the run that suspended on it. Before this release, a run that failed while it held such a call (on Temporal, DBOS, CF Workflows and runtime-js, and on the CF Durable Object outside its deadline alarm) kept the call in the failed session. The next turn's repair of unpaired calls skips pending ids, so the model could be sent a tool_use with no tool_result.

Now every failed terminal applies one rule, core withoutUnansweredPendingOf(pending, runId):

Entry in pendingClientToolCallsBefore (failed session)Now (failed session)
This run's call with no submittedAt (or no runId)keptdropped
This run's answered call (submittedAt set)keptkept
This run's denied or approved approvalkeptkept
Another run's callkeptkept
An unanswered call carrying a stashed message (entryInput)keptkept

The same failed write also clears interruptContext and remotePendingCalls on every runtime. A completed or interrupted terminal is unchanged, and so is a stop's terminal, which already cleared every pending call.

The next entry (a retry() or a new turn) pairs each dropped call's tool_use with a not_executed result whose reason is unpaired_on_continuation, before its own input. An answered entry is still drained by a retry(), as before. A new turn settles an answered entry on runtime-js, the CF Durable Object and CF Workflows. Temporal and DBOS still leave it out of the new turn's repair, so its call can stay unpaired in the model's history there (FU-TEMPORAL-RM81-NEW-TURN-PENDING, FU-DBOS-RM81-NEW-TURN-PENDING). An unanswered call that carries a stashed message (a with_message entry's input that has not landed yet) is kept too, so a retry() or a new turn still delivers the message.

What to change ​

  1. Don't read an unanswered call from a failed session. Code that inspected a failed session's pendingClientToolCalls to find the call the run was waiting on will no longer find it. Read the transcript instead: the call's tool_use is in the last assistant message, and the next entry pairs it.
  2. A late submit for a dropped call is still refused, but the answer can change. Before, it met the failed run's fence (state_run_superseded). With no pending entry left, a runtime that looks the call up first now answers unknown_tool_call. Treat both as "this call can no longer be answered".
  3. failCommittedRun drops by default; its dropUnansweredPending option is removed. Remove the option from your calls. There is no way to keep the old behaviour. failSessionWithoutRun drops the unanswered calls of the ended currentRunId it is given, and unstamped ones.
  4. Custom agent loops: apply withoutUnansweredPendingOf(session.pendingClientToolCalls, runId) in your own failed terminal write, as every built-in runtime now does.

Released under the MIT License.