Skip to content

Upgrade: late client-tool answers on runtime-js, the CF DO, CF Workflows, Temporal and DBOS (late-answers MR 1b and 1c) ​

A real client-tool answer that arrives after its call's deadline lapsed is no longer lost. This release wires the store API of MR 1a (late-client-tool-answers-store-api.md) into runtime-js, the Cloudflare Durable Object, Cloudflare Workflows, handleChatStream and agent-server. MR 1c brings the same answers to Temporal and DBOS (their sections below).

It is a major release of @helix-agents/core, @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-temporal, @helix-agents/runtime-dbos and @helix-agents/agent-server, and a minor of @helix-agents/ai-sdk.

Design: 2026-10-26-client-tool-late-answers-design.md (§2.1 to §2.4).

What a late answer gets now ​

When the real answer arrivesBeforeNow
Before the deadlineacceptedaccepted (unchanged)
After the timeout was stored, before the run consumed it, while its run is livealready_completed; the model saw the timeoutaccepted: the real answer replaces the timeout, and the model sees the real result
After the run consumed the timeout, or over a timeout a run that ended keptalready_completed; the answer was droppedlate_answer_after_timeout: recorded once, and the model gets one hidden notice at its next call
A second late answer for the same callalready_completedlate_answer_after_timeout again; nothing new is recorded and no second notice is sent

A submit whose own answer is the timeout (the chat handler's sweep, or a client sending client_tool_timeout) never replaces anything and is never late.

The new submit answer ​

SubmitToolResultResponse (and submitToolResultResponseSchema) gains:

ts
{
  status: 'late_answer_after_timeout';
  consumedAt: number;
} // no `continuation`: a late answer starts nothing
  • Any code that switches on status must handle it.
  • HTTP: a 200 with this body, on the CF DO's /submit-tool-result and on agent-server's submit routes. The DO never wakes the session for it.
  • handleChatStream: the response that ends the client's step carries one transient data-helix-late-answer part per late call, { toolCallId }. useHelixChat({ onLateAnswer }) already reads it, and a stock useChat ignores it (it parses on every pinned ai 6.x).

The model notice ​

A late answer reaches the model as ONE hidden user row before its next call: metadata.helixSynthetic: { kind: 'late_client_tool_result', toolCallId }, metadata.hidden: true, with content of the form The 'editBlock' call `tc-1` did complete after it timed out. Its result: …. Core isLateClientToolAnswerNotice detects it. An app that renders the raw message log should skip hidden rows. When the call was already consumed, the notice takes the tool name from the history; if the history no longer names the call, the notice says 'unknown'.

An entry that makes no model call leaves its notices for the next entry that does.

Behaviour changes in the runtimes ​

  • Drains consume in one gated commit. runtime-js's resume drain and CF Workflows' applyResultsAndReload remove a drained call's pending entry in the same commit that appends its result row. They write the call's completed marker on the tree root first (CF Workflows writes it for the first time: CP-257). A drained call's tool_end chunk now follows that commit, where it preceded it. If a newer run refuses that commit, the call's route is restored and it stays pending. A child session's run-start that consumes calls writes their tree root's markers before its commit.
  • The resume drain's bulk wipe is gone. runtime-js no longer clears the whole pending map after a drain; it removes only entries whose own clear failed.
  • The chat handler's sweep skips calls that already hold an answer.
  • agent-server: a RETRYABLE typed error thrown by a submit (for example state_concurrency_conflict) answers with its own code and a 503 (core typedErrorResponse), where it used to answer the 500 INTERNAL_ERROR envelope. Every other error keeps that envelope, with its errorCode.
  • A submit for a consumed call never waits. With no entry and an active completed marker on the call's tree root, the router skips the bounded wait for the call's commit (CP-253) and the marker decides at once.

DBOS (MR 1c) ​

  • Drain in-flight workflows before upgrading. The workflow's step sequence changes: a notice step runs before every model call, and a thrown model call is retried by the workflow (a durable wait, a stop read and a notice step between attempts; the same 3 attempts, 1 s then 2 s) instead of by the callLLM step. A workflow recorded by an older version does not replay under this one.
  • A lapsed wait is settled, never blindly cleared. An answer that lands on a call's entry as its recv deadline passes is the call's answer; with none, the entry is cleared and its tree root's marker is timedOut. An abort keeps a landed answer on the entry.
  • A real answer after the timeout was consumed is late_answer_after_timeout, recorded once, and the model gets one hidden notice before its next call (a checkpointed step before every model call).
  • A live child that can still register a call id is never answered from its root's marker: it takes the pre-submit stub path.
  • An approval-response is stored as its decision ({ approved, reason }) on the entry, as on the other runtimes.
  • DbosClientToolResolver subclasses: the approval gate's bookkeeping calls the new recordApprovalOutcome, so an override of recordOutcome no longer sees an approval's answer; override both. A lapsed wait is settled through the new ClientToolStep.settleLapsedClientToolCallStep (it called clearPendingClientToolCallStep).
  • An LLM error keeps its code: a failed run's persisted errorDetail, its K5 commit and its failed stream carry the provider error's code and retryability (DI-24), where they carried only { message }.

Temporal (MR 1c) ​

@helix-agents/runtime-temporal is a major release with the same answers as the runtimes above:

  • A real answer before the next resume's drain is simply stored (accepted). Temporal stores a lapse only in that drain, so no timeout is written and the model sees the real result.
  • A real answer over an unconsumed timeout of a live run replaces it (accepted). On Temporal that window is the drain itself, between its lapse write and its tree-root complete. It used to answer already_completed and drop the answer.
  • Over a timeout a run that is no longer live kept, or a consumed one, it is late: late_answer_after_timeout, recorded once. A repeat records nothing.
  • A submit whose owner holds no entry (the call was just consumed) is decided by the tree root's completed marker: late over a timeout, else already_completed. Only with no marker is it unknown_tool_call; it used to be unknown_tool_call whatever the marker.
  • The model notice: runLLMStep commits one hidden notice row per recorded late answer before every model call. The row is part of the step's baseMessageCount, never its newMessages. The workflow code is unchanged (no wf.patched), so running workflows replay as before.

Who has to change code ​

  • Code that switches on a submit answer's status: handle late_answer_after_timeout.
  • A client reading agent-server submit errors: a 503 carries the typed body (code); re-send the answer.
  • An app rendering the raw message log: skip hidden rows (it should already).
  • Custom runtimes: decide answered entries through core answerOverAnsweredEntry, and entry-less submits through answerWithoutEntry; deliver notices through deliverLateAnswerNotices before each model call.

Released under the MIT License.