Upgrade: late client-tool answers, the store API (late-answers MR 1a)
This release adds the store half of the late client-tool answers rule (SDK-2): three required SessionStateStore methods, a delivery field on MessageCommit, and a new completed-marker shape. It is a major release of @helix-agents/core, @helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-postgres, @helix-agents/store-cloudflare and @helix-agents/runtime-cloudflare.
No runtime answers a submit differently yet: the runtimes start calling these methods in the next late-answers MR. Upgrade every store package together with core.
Design: 2026-10-26-client-tool-late-answers-design.md (§2.2, §2.7 "As built: MR 1a").
Who has to change code
- Apps on a shipped store (memory, Redis, Postgres, D1, the CF Durable Object): run the migrations below; nothing else.
- Custom
SessionStateStoreimplementations: implement the three methods, honourdeliverLateAnswers, and move to the new marker shape. The core store guard (SESSION_STATE_STORE_REQUIRED_METHODS) now lists the three methods, so a store without them is refused. - Code that reads
SessionState.completedClientToolCalls: readmarker.atinstead of a bare number.
Three new required store methods
replacePendingAnswer(
sessionId: string,
input: ReplacePendingAnswerInput,
opts?: ReplacePendingAnswerOptions
): Promise<{ replaced: boolean }>;
recordLateAnswer(
sessionId: string,
late: Omit<LateClientToolAnswer, 'delivered'>
): Promise<{ recorded: boolean }>;
listUndeliveredLateAnswers(sessionId: string): Promise<LateClientToolAnswer[]>;replacePendingAnsweris a field-scoped compare-and-set of one pending entry's answer. While the entry holds the timeout stamped atexpectSubmittedAt(coreisTimeoutAnswer), itssubmittedResult/submittedErrorare replaced,submittedAtis set to the store's clock andreplacedTimeoutAt(new onPendingClientToolCall) toexpectSubmittedAt. Nothing else on the session changes: noversionmove, no checkpoint. Any other entry state is{ replaced: false }with nothing written.opts.fenceRunId(the run must be current and live, elseRunSupersededError) andopts.refuseIfStopped(CommitStoppedError) are checked in the same atomic step. Read call ids by own key.recordLateAnsweris insert-once per session and call id: the first record wins ({ recorded: true }), a repeat writes nothing ({ recorded: false }).listUndeliveredLateAnswersreturns the session's undelivered records, ordered byatthencallId; an empty list for none or for a missing session.- Records are not copied by
cloneSessionand are deleted with the session.
MessageCommit.deliverLateAnswers
appendMessages (with or without startRun) takes an optional deliverLateAnswers: LateAnswerDelivery[]: each record's call id and rowOffset, its notice row's position among the rows THIS commit appends (from 0). The store marks them delivered in the same atomic step as the rows, after a run-start's rewind, and records each notice row's message index itself: core lateAnswerRowIndex(countBefore, rowOffset), countBefore being the message count just before the commit's rows (after a rewind), read in that same atomic step. So an append by another writer between the caller's read and its commit can never make the recorded index stale. Refusals, each with nothing written:
- an offset outside the commit's rows (core
assertLateAnswerOffsets): avalidation_error, not retryable (a caller bug); - a record that is missing, already delivered or named twice: core
LateAnswerDeliveryConflictError(codestate_concurrency_conflict, retryable).
It is not part of the run-start digest.
A truncate (truncateMessages, or a run-start's truncateTo) that removes a delivered record's notice row (its index is at or past the kept count) re-opens the record, so it is delivered again. Core reopenLateAnswer, assertLateAnswersDeliverable and undeliveredLateAnswers implement these rules for a store that keeps the records in memory.
The completed marker is { at, timedOut? }
SessionState.completedClientToolCalls is now Record<string, CompletedClientToolCall>:
interface CompletedClientToolCall {
at: number; // when the call ended, on the store's clock
timedOut?: true; // the answer its drain consumed was the timeout
}ClientToolCallOwnershipChange.completetakes an optionaltimedOutlist (a subset oftoolCallIds); those ids' markers are written withtimedOut: true. Retention reaps bymarker.at.- A run-start commit's consumed markers: core
applyConsumedCompletedMarkers(completed, restamp, now, pendingBefore)takes the pending map from before the commit and markstimedOutwhere the consumed answer was the timeout. - There is no read of the old numeric shape (fresh start). A stored bare number is an
InvalidStoredStateError.
Whole-state writes keep newer answers
A whole-state write (saveState, commitState, the step commit, promoteStaging) must not undo a concurrent replacePendingAnswer. Merge the pending map you are given with the stored one through core mergePendingAnswers, in the same atomic step as the write. A store whose write is not one atomic read-modify-write pins the stored pending field it merged from and re-plans or retries when it moved (D1 and Redis do this; Postgres reads it under a row lock).
Migrations
| Store | Migration | Adds |
|---|---|---|
| Postgres | V27 | __agents_late_client_tool_answers |
| D1 | V31 | __agents_late_client_tool_answers (TABLE_NAMES.lateAnswers) |
| CF Durable Object (DO-SQLite) | 26 | late_client_tool_answers |
Redis and memory need no migration.
The contract suite
@helix-agents/core/testing exports lateAnswerOperationTests (LA-1..LA-13), and stateStoreContractTests runs it. LA-12 replaces an answer between a whole-state write's merge read and its write, through the runner's interleavePendingMerge hook (ArmPendingMergeInterleave). It applies to a store that pins and re-plans (D1, Redis); a runner without the hook skips the LA-12 cases with a written reason.