Skip to content

Bound agent session Flow state with chunked messages and retention - #1221

Merged
zzheng-aiplay merged 1 commit into
mainfrom
zzheng/agents-chunked-messages
Nov 3, 2026
Merged

zzheng-aiplay merged 1 commit into
mainfrom
zzheng/agents-chunked-messages

Conversation

@zzheng-aiplay

@zzheng-aiplay zzheng-aiplay commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Store agent session messages as a Sequentially Chunked AttributeMap with retention, in the Python, TypeScript, and Go agents cores, so a session's Flow state stays bounded however long it runs.

  • Window. AgentMessages now holds only the newest messages, one instance per message. Past message_window_size (64), the oldest move into immutable instances of the new AgentMessageChunks AttributeMap, keyed by their first sequence, in the same commit.
  • Chunk caps. A chunk holds at most message_chunk_size (32) messages and message_chunk_max_bytes (1 MiB, at most 8 MiB) of encoded JSON, so it stays well under values.maxValueBytes. One commit moves at most 4 chunks and 8 MiB.
  • Oversized messages skip the window: the Step that stores one writes it straight into its own chunk, so the first store writes the largest value it will ever need. A message too large for the Server fails that turn at once, and no later rollover can wedge the session on it.
  • Index. The new AgentMessageIndex Attribute lists every kept chunk's first and last sequence, the window start, and the oldest kept message. It is written only when a chunk is written or deleted.
  • Turn counts. The new TurnReplySequence Attribute, {turn_id, sequence}, records where the turn's replies start, so turn_completed, turn_steered, and turn_cancelled never read a turn's older messages. A value that names another turn falls back to a scan.
  • Loads. RunModel no longer loads the whole map. Movements into RunModel and ApplyTurnControl (from every path, including Make cancel_turn abort model calls and abandon tool tasks #1223's FinishTurnCancellation) list only the chunks after ContextSummary.last_sequence, and CompactContext only the chunks it summarizes. Without compaction the model input is every message, so those Steps load every chunk.
  • Retention. With compaction, the oldest chunk is deleted while its last sequence is at or before ContextSummary.last_sequence and at least retained_message_limit (1,000; None/null keeps all) messages are stored after it, so the newest 1,000 messages and everything after the summary always stay. Deleted messages leave list_agent_messages and get_agent_snapshot; the additive AgentMessagesPage.deleted_before_sequence marks the deleted range. A runner without compaction on a session with deleted messages logs a warning.
  • 0.1.0 sessions are read as one large window. Their first new message starts the migration through the same bounded rollover, a few chunks a Step.
  • Upgrade rule (owner decision): stop every 0.1.0 Worker of a Flow type before any 0.2.0 Worker starts. Skipping it makes a 0.1.0 Worker fail turns on an upgraded session, because it cannot read the moved messages. (An earlier revision also inflated turn_completed counts; tagging TurnReplySequence with the turn ID removed that.) The CHANGELOG, the guide in both locales, and every core README state it.
  • Go uses the same layout and options (WithMessageWindowSize, WithMessageChunkSize, WithMessageChunkMaxBytes). It has no compaction, so it never deletes.

The conformance suite gains the new persistence names, the AgentMessageChunk, AgentMessageIndex, and AgentMessagesPage shapes, the chunk key format, the new RPC loads, and the default sizes and per-commit caps. The contract doc has a Message storage section, and the plan's A7 has an "As built" note.

Why

Item 4 of the owner review of the M2 decisions: #1178 (comment). RunModel loaded the whole AgentMessages map, so both the per-call load and Flow state grew with the session, and compaction only shortened the model input. This is a persisted layout, so it has to be settled before 0.2.0 publishes.

Design decisions and deviations from the review

  • The window is a set of instances, not one current value. The pattern rewrites one current instance on every append. Superseded values count toward limits.maxFlowStateBytes until the history window prunes them, so a rewritten window would add about half a window of superseded bytes per stored message. Keeping one instance per message makes each store one blind write and the window still bounded. Every message-writing Step loads the whole window, which is at most message_window_size messages.
  • No hook before deletion. A hook would run inside RunModel or CompactContext, need the deleted chunks loaded there, and turn a projection failure into a model-call failure. The guide documents the guarantee (the newest retained_message_limit messages and every message after the summary always stay), how to copy messages out with list_agent_messages after each turn_completed, and deleted_before_sequence lets a reader detect a gap.
  • Variable-length chunks. The byte cap makes chunk lengths vary, so the index lists each chunk's range instead of deriving keys from a fixed size.
  • Turn position outside the index. Keeping the reply position in the index would rewrite every chunk range each turn, without bound in a session without compaction. Its own small Attribute keeps the per-turn write a few bytes.
  • RPCs load every kept chunk. An RPC's loads are static unless the caller passes invocation options, and get_agent_snapshot / list_agent_messages have many callers. Retention bounds that load. The chunk keys are computable from AgentMessageIndex, so invocation-time loads can be added later without a layout change.
  • RecordTurnFailure is a failure route without movement options, so for a runner that builds unfinished-turn messages it loads every kept chunk.
  • Bounded migration. A 0.1.0 session can hold tens of MB of messages, more than one Step result may carry, so it migrates at most 4 chunks and 8 MiB per commit; readers use the index, so a half-moved session reads correctly.
  • Measurement. The run budget is not a usable instrument for the bound: the engine checks it only on a command that grows active storage, and an 800-turn session holding 2.3 MB of live messages ran under a 1 MiB limits.maxFlowStateBytes. The guard therefore sums the encoded size of every live Attribute and map instance value directly.

Testing

Integration tests against the private dexcli dev engine (cli-v1.30.0), in each core:

  • Rollover with a SIGKILL (all three): the Worker is killed inside the RunModel whose commit rolls over; afterwards every message is stored once, in order, the chunks match the index exactly, every turn_completed counts 1, and the last model call saw every user message.
  • Byte cap (all three): with a small message_chunk_max_bytes, chunks close before message_chunk_size, every chunk is under the cap, an oversized message goes straight into its own chunk (history shows no AgentMessages write for it), and reads, paging, and the model input cross them.
  • Value-size edge (all three): with values.maxValueBytes 8192, a message sized a few bytes under it (which used to fit the window and then wedge its rollover) fails the turn that first stores it, and the next turn completes.
  • Index writes (all three): from dexcli flow history, every Step that writes AgentMessageIndex also writes or deletes an AgentMessageChunks instance, most turns write no index, and TurnReplySequence is written once per turn.
  • Retention (Python, TypeScript): nothing is deleted without a summary; with compaction only chunks at or before ContextSummary.last_sequence are deleted, at least the limit always stays, the next chunk is not deletable, and pages carry deleted_before_sequence. Go asserts it never deletes.
  • Paging (all three): pages of 1 to 200 cross window and chunk boundaries, anchors inside and at the edges of chunks work, and the snapshot returns the newest 200.
  • 0.1.0 upgrade over several commits: Python and TypeScript run the released 0.1.0 Worker from PyPI and npm to store about 50 messages and leave a turn mid-approval or mid-call; this release reads that layout as is, the first new commit moves exactly 4 chunks, the half-moved session reads, pages, and replays the whole history to the model, later commits finish the migration, and the 0.1.0-started turn's reply count is right. Go writes the 0.1.0 layout with SetAttributes (no released Go Worker exists).
  • Bounded Flow state, measured (A7-5): the 800-turn guard (retention limit 100) measured stored messages of 161,708 / 184,796 / 161,820 / 184,924 bytes and whole live state of 167,330 / 190,418 / 167,451 / 190,555 bytes at turns 200 / 400 / 600 / 800, identical in Python and TypeScript, while the session stored 2.3 MB of messages. The nightly 2,000-turn test checks the default limit the same way. Go: a long session never holds more than its window.

Ran locally after rebasing onto #1222 and #1223: every core ci.sh (Python 408 passed, TypeScript 243 passed, Go), all nine adapter ci.sh, agents/script/smoke-test-built-packages.sh all, generate.py --check, make copyright-check, and cd docs && npm run check. Agents CI runs the same; Agents Long Tests Nightly runs the 2,000-turn tests.

Implement item 4 of the owner review of the M2 decisions
(#1178 (comment))
in the Python, TypeScript, and Go agents cores, with identical persisted
names and values:

- AgentMessages is now the window: the newest messages, one instance
  per message, so storing a message never rewrites a larger value. Past
  message_window_size (64), the oldest messages move into immutable
  instances of the new AgentMessageChunks AttributeMap, keyed by their
  first sequence, in the same commit. A chunk holds at most
  message_chunk_size (32) messages and message_chunk_max_bytes (1 MiB)
  of encoded JSON, so it stays under values.maxValueBytes. A larger
  message skips the window and goes straight into its own chunk when it
  is stored, so a message too large for the Server fails its turn at
  once instead of wedging a later rollover. One commit moves at most 4
  chunks and 8 MiB. The new AgentMessageIndex Attribute lists each kept
  chunk's first and last sequence, the window start, and the oldest kept
  message, and changes only when chunks are written or deleted.
- RunModel no longer loads the whole map. Movements into RunModel and
  ApplyTurnControl list only the chunks after the summary, and
  CompactContext only the chunks it summarizes. Without compaction the
  model input is every message, so those Steps load every chunk.
- With compaction, retention deletes the oldest chunk while its last
  sequence is at or before ContextSummary.last_sequence and at least
  retained_message_limit (1,000) messages are stored after it, so the
  newest 1,000 messages always stay. Deleted messages leave
  list_agent_messages and get_agent_snapshot; AgentMessagesPage gains
  deleted_before_sequence. A runner without compaction on a session
  with deleted messages logs a warning. Go has no compaction, so it
  never deletes.
- Turn message counts come from the new TurnReplySequence Attribute,
  tagged with the turn ID, so a turn compacted or retired mid-turn still
  counts every reply and no Step rewrites the index each turn.
- A session that 0.1.0 stored is read as one large window and migrates
  into chunks through the same bounded rollover, a few chunks a Step.
  Tests run the released 0.1.0 Workers. Upgrades stop every 0.1.0
  Worker before any 0.2.0 Worker starts.

The conformance suite gains the AgentMessageChunk, AgentMessageIndex,
TurnReplySequence, and AgentMessagesPage shapes, the AgentMessageChunks key format, the new
RPC loads, and the default sizes and per-commit caps. The A7-5 guard and
the nightly 2,000-turn test measure the stored messages and the whole
live state, which stay flat as the session grows. The contract doc, the
plan's A7 "As built" note, the core READMEs, the deployment guide in
both locales, and the CHANGELOG describe the layout and the upgrade
rule.
@zzheng-aiplay
zzheng-aiplay force-pushed the zzheng/agents-chunked-messages branch from 95cd76f to 6891866 Compare November 3, 2026 06:30
@zzheng-aiplay
zzheng-aiplay merged commit f2584a8 into main Nov 3, 2026
21 checks passed
@zzheng-aiplay
zzheng-aiplay deleted the zzheng/agents-chunked-messages branch November 3, 2026 07:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant