Skip to content

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 SessionStateStore implementations: implement the two queue methods, honour deliverContextItems and expectMessageCount, and store context rows like any other row.
  • Code with an exhaustive switch (message.role): add a 'context' case.

The context role ​

ts
type ContextMessage =
  | (ContextRowBase & { kind: 'instruction' | 'notice' })
  | (ContextRowBase & { kind: 'memory'; memoryIds: string[] })
  | (ContextRowBase & {
      kind: 'tools';
      enable?: string[];
      disable?: string[];
      phase?: 'forced_completion';
    });
  • ContextRowBase carries role: 'context' and content: string. Its optional fields are source, pass, cursor, metadata and providerOptions.
  • A context row is part of history: getMessages returns it, and checkpoints and getMessageCount count it.
  • It is never part of the chat: UI rows, the AI SDK converter and getUIMessageCount skip it by role. So no UI message, snapshot or useChat client 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 ​

ts
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 throws state_session_not_found.
  • fenceRunId: the record lands only while that run is the session's current, live run. Otherwise it throws RunSupersededError and writes nothing.
  • Listing: undelivered items only, ordered by at, then key.
  • 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 (retryable state_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 ​

StoreMigrationTable
PostgresV29__agents_context_items
D1V33__agents_context_items
DO-SQLite28context_items
Redisnone (a new hash)per session
Memorynone

Contract: core/src/testing/context-item-operations.ts (CI-1..CI-10, EMC-1..EMC-4), run by runStateStoreContract for every store.

Released under the MIT License.