Skip to content

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 SessionStateStore implementations: implement the three methods, honour deliverLateAnswers, 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: read marker.at instead of a bare number.

Three new required store methods ​

ts
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[]>;
  • replacePendingAnswer is a field-scoped compare-and-set of one pending entry's answer. While the entry holds the timeout stamped at expectSubmittedAt (core isTimeoutAnswer), its submittedResult / submittedError are replaced, submittedAt is set to the store's clock and replacedTimeoutAt (new on PendingClientToolCall) to expectSubmittedAt. Nothing else on the session changes: no version move, no checkpoint. Any other entry state is { replaced: false } with nothing written. opts.fenceRunId (the run must be current and live, else RunSupersededError) and opts.refuseIfStopped (CommitStoppedError) are checked in the same atomic step. Read call ids by own key.
  • recordLateAnswer is insert-once per session and call id: the first record wins ({ recorded: true }), a repeat writes nothing ({ recorded: false }).
  • listUndeliveredLateAnswers returns the session's undelivered records, ordered by at then callId; an empty list for none or for a missing session.
  • Records are not copied by cloneSession and 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): a validation_error, not retryable (a caller bug);
  • a record that is missing, already delivered or named twice: core LateAnswerDeliveryConflictError (code state_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>:

ts
interface CompletedClientToolCall {
  at: number; // when the call ended, on the store's clock
  timedOut?: true; // the answer its drain consumed was the timeout
}
  • ClientToolCallOwnershipChange.complete takes an optional timedOut list (a subset of toolCallIds); those ids' markers are written with timedOut: true. Retention reaps by marker.at.
  • A run-start commit's consumed markers: core applyConsumedCompletedMarkers(completed, restamp, now, pendingBefore) takes the pending map from before the commit and marks timedOut where 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 ​

StoreMigrationAdds
PostgresV27__agents_late_client_tool_answers
D1V31__agents_late_client_tool_answers (TABLE_NAMES.lateAnswers)
CF Durable Object (DO-SQLite)26late_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.

Released under the MIT License.