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 arrives | Before | Now |
|---|---|---|
| Before the deadline | accepted | accepted (unchanged) |
| After the timeout was stored, before the run consumed it, while its run is live | already_completed; the model saw the timeout | accepted: 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 kept | already_completed; the answer was dropped | late_answer_after_timeout: recorded once, and the model gets one hidden notice at its next call |
| A second late answer for the same call | already_completed | late_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:
{
status: 'late_answer_after_timeout';
consumedAt: number;
} // no `continuation`: a late answer starts nothing- Any code that switches on
statusmust handle it. - HTTP: a 200 with this body, on the CF DO's
/submit-tool-resultand 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 transientdata-helix-late-answerpart per late call,{ toolCallId }.useHelixChat({ onLateAnswer })already reads it, and a stockuseChatignores it (it parses on every pinnedai6.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'
applyResultsAndReloadremove 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'stool_endchunk 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 (coretypedErrorResponse), where it used to answer the 500INTERNAL_ERRORenvelope. Every other error keeps that envelope, with itserrorCode. - 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. DbosClientToolResolversubclasses: the approval gate's bookkeeping calls the newrecordApprovalOutcome, so an override ofrecordOutcomeno longer sees an approval's answer; override both. A lapsed wait is settled through the newClientToolStep.settleLapsedClientToolCallStep(it calledclearPendingClientToolCallStep).- 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-rootcomplete. It used to answeralready_completedand 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 itunknown_tool_call; it used to beunknown_tool_callwhatever the marker. - The model notice:
runLLMStepcommits one hidden notice row per recorded late answer before every model call. The row is part of the step'sbaseMessageCount, never itsnewMessages. The workflow code is unchanged (nowf.patched), so running workflows replay as before.
Who has to change code
- Code that switches on a submit answer's
status: handlelate_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 throughanswerWithoutEntry; deliver notices throughdeliverLateAnswerNoticesbefore each model call.