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 pendingClientToolCalls | Before (failed session) | Now (failed session) |
|---|---|---|
This run's call with no submittedAt (or no runId) | kept | dropped |
This run's answered call (submittedAt set) | kept | kept |
| This run's denied or approved approval | kept | kept |
| Another run's call | kept | kept |
An unanswered call carrying a stashed message (entryInput) | kept | kept |
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
- Don't read an unanswered call from a failed session. Code that inspected a failed session's
pendingClientToolCallsto find the call the run was waiting on will no longer find it. Read the transcript instead: the call'stool_useis in the last assistant message, and the next entry pairs it. - 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 answersunknown_tool_call. Treat both as "this call can no longer be answered". failCommittedRundrops by default; itsdropUnansweredPendingoption is removed. Remove the option from your calls. There is no way to keep the old behaviour.failSessionWithoutRundrops the unanswered calls of the endedcurrentRunIdit is given, and unstamped ones.- Custom agent loops: apply
withoutUnansweredPendingOf(session.pendingClientToolCalls, runId)in your own failed terminal write, as every built-in runtime now does.