Upgrade: the context row and the context-item queue (Additive Sessions MR 2)
This release adds the store half of Additive Sessions: a new message role, context, and a durable queue that apps, tools and hooks will write context items to. 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 writes a context row or reads the queue yet. The runtimes start in the next Additive Sessions MRs. Upgrade every store package together with core.
Design: 2026-11-05-additive-sessions-context-pipeline-design.md (§4, §6.2, §9.2, §14).
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 two queue methods, honourdeliverContextItemsandexpectMessageCount, and storecontextrows like any other row. - Code with an exhaustive
switch (message.role): add a'context'case.
The context role
type ContextMessage =
| (ContextRowBase & { kind: 'instruction' | 'notice' })
| (ContextRowBase & { kind: 'memory'; memoryIds: string[] })
| (ContextRowBase & {
kind: 'tools';
enable?: string[];
disable?: string[];
phase?: 'forced_completion';
});ContextRowBasecarriesrole: 'context'andcontent: string. Its optional fields aresource,pass,cursor,metadataandproviderOptions.- A context row is part of history:
getMessagesreturns it, and checkpoints andgetMessageCountcount it. - It is never part of the chat: UI rows, the AI SDK converter and
getUIMessageCountskip it by role. So no UI message, snapshot oruseChatclient sees it, and the UI message ids around it are unchanged. messageSchema()parses each kind, so a store that reads rows through it needs no change for the role.- Until the Vercel adapter renders the role per provider, it sends each row as a hidden user turn with core's text (
contextRowText,lowerContextRows).
Two new required store methods
recordContextItem(
sessionId: string,
record: StoredContextItem, // { key, item: ContextItem, at }
opts?: { fenceRunId?: string }
): Promise<{ recorded: boolean }>;
listUndeliveredContextItems(sessionId: string): Promise<StoredContextItem[]>;They copy the late-answer contract (recordLateAnswer, listUndeliveredLateAnswers) with key in place of the call id:
- Insert-once per session and key. The first record wins (
{ recorded: true }), and a repeat writes nothing ({ recorded: false }). A missing session throwsstate_session_not_found. fenceRunId: the record lands only while that run is the session's current, live run. Otherwise it throwsRunSupersededErrorand writes nothing.- Listing: undelivered items only, ordered by
at, thenkey. - Items are not copied by
cloneSession, and are deleted with the session.
MessageCommit.deliverContextItems
appendMessages takes an optional deliverContextItems: { key, rowOffset }[], with and without startRun, by the deliverLateAnswers rules:
- The store marks each item delivered, with its row's message index, in the same atomic step as the rows. The rows land after a run-start's rewind.
- A truncate that removes an item's row re-opens the item.
- An offset outside the commit's rows is a non-retryable
validation_error. - An item that is missing, already delivered or named twice refuses the whole commit with
ContextItemDeliveryConflictError(retryablestate_concurrency_conflict). - Either refusal writes nothing.
Core's shared rules are in store/context-items.ts: assertContextItemOffsets, assertContextItemsDeliverable, reopenContextItem and undeliveredContextItems.
MessageCommit.expectMessageCount
On appendMessages without startRun, the store refuses the append unless the session holds exactly that many rows. The check runs in the same atomic step as the write. The refusal is MessageCountConflictError (retryable state_concurrency_conflict; recognise it with isMessageCountConflict), and nothing is written. Of two appends made from one read, exactly one lands. The field is ignored with startRun.
Core's late-answer delivery (deliverLateAnswerNotices) now passes the length of the log it read. When that log moved, it delivers nothing and returns no rows, and the answers wait for the next model call.
Migrations
| Store | Migration | Table |
|---|---|---|
| Postgres | V29 | __agents_context_items |
| D1 | V33 | __agents_context_items |
| DO-SQLite | 28 | context_items |
| Redis | none (a new hash) | per session |
| Memory | none |
Contract: core/src/testing/context-item-operations.ts (CI-1..CI-10, EMC-1..EMC-4), run by runStateStoreContract for every store.