feat(agents): move sessions into a Lifecycle capability - #2196
Merged
Conversation
…to-a-capability # Conflicts: # examples/next/README.md # pnpm-lock.yaml
🦋 Changeset detectedLatest commit: d212bf1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
… context Sessions stored large content by truncating it and budgeted hydration by stored bytes, so a pointer row costing 8 MiB of memory was charged the ~100 bytes it occupies on disk. It also owned prompt assembly, which is not conversation storage. Storage. Every table is now WITHOUT ROWID with a composite key and no secondary index, ordered by a per-session `seq`, so a text append bills one row instead of two. The attachment reference table is (session_id, message_id, hash) and nothing else. An unchanged update writes nothing at all: no row, no FTS churn, no reference rewrite, no event. Offload. Media leaves the row at a size threshold wherever it appears, including `data:` URLs nested in tool output. Everything else, prose included, is offloaded largest-first only when the row cannot hold it, and a row that still does not fit raises SessionMessageTooLargeError. Nothing is truncated, and offloaded content reconstructs byte for byte. The aged-row maintenance pass applies that same policy, so a drained legacy row ends up exactly as if it had been written today. Memory. getRecentHistory charges each row its stored bytes plus, when reconstructing inline, the attachment bytes it re-inflates. That is the difference between a budget that bounds disk and one that bounds the isolate. Think and AIChatAgent both default to 32 MiB. Context. Blocks, frozen prompts, and the skill and search providers move to `agents/context`. Think declares them through a new configureContext() hook and reaches them through `this.context`; configureSession() keeps compaction and search. The Session handle stores messages and knows nothing about prompts. Hosts. AIChatAgent no longer loads the transcript in its constructor: the legacy lift and one bounded hydration run at start, the live array mirrors the change feed, and get-messages streams. Think reads pointers on its per-tool-result scan and no longer reads Sessions tables with raw SQL. Deleted: the synchronous storage aperture, the lossy eviction mode, sanitizeToolPairs, the token-counter plumbing, the per-field option thunks, and a duplicate copy of the sanitize helpers. Measured on a deployed worker with a real R2 bucket (examples/next/sessions-slam, 33 scenarios): one billed row per append, 1.6 MB and 5 MB text parts and a 3 MB tool output offloaded rather than cut, inline hydration stopping at 31.11 MiB against a 32 MiB budget, and 61.79 MiB of history streamed out of a 128 MiB isolate. Numbers are recorded in design/sessions.md. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
agent-think lives outside packages/ and examples/, so it was missed when the context system moved out of the Session handle. Its identity block is now declared through configureContext() and refreshed through this.context. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Renaming each lifted table to `*__lifted_v1` left every upgraded object storing its conversation history twice. A Durable Object tops out at 10 GB and gets uncomfortable well before that, so a 5 GB history plus its copy has nowhere to go. The copy already needs that space transiently, which is exactly why it must not be kept. Each source is now verified against its destination row by row, comparing the payload rather than just the key, and dropped only when every row arrived intact. A table that fails verification is left in place with a `session:migration:incomplete` event, so a partial lift keeps the only copy of its rows instead of destroying it. `assistant_sessions` and `assistant_fts` carry nothing the new schema needs, the registry being derived and the index rebuilt, so they are dropped outright. Think lifts `assistant_config` into its own table and now drops it too. AIChatAgent drops `cf_ai_chat_agent_messages` once every readable row has a copy, and its lift no longer reads the whole table into the isolate: order is read as ids alone, then bodies are fetched in windows bounded by rows and bytes, so a large transcript never lands in memory at once. Verified against real deployed storage by seeding a Think agent and an AIChatAgent on the pre-Sessions SDK and redeploying the identical worker built against this branch over the same objects. Think carried 5 rows and 67,184 bytes across with its branch topology and ids intact, AIChatAgent 4 rows and 53,765 bytes including a 53 KB inline image, both byte-identical, with no legacy or tombstone table left behind. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
…oviders Storage classified payloads by content type: media left the row at a 32 KiB threshold, everything else only to keep the row under its budget. Reading a PDF through pi settles that this is backwards. A document arrives as plain tool-output text with no media type, so the rule optimised the small case and ignored the large one. Deduplication, the other argument for it, does not depend on type either. Extraction into the attachment tables does not make the database smaller: chunk rows live in the same Durable Object, inside the same 10 GB. Only R2 reclaims space. And billing counts rows written, not bytes, so rewriting a 500 KB row costs the same single row as a tiny one while extracting it costs four. So there is now one rule and no content types in it. A payload is extracted when a bucket is configured and it reaches `r2ThresholdBytes`, which is the only extraction that reclaims anything, or when the row cannot otherwise hold it, largest first, into chunks. `inlineThresholdBytes` is gone and the R2 threshold is the single number. The maintenance pass returns immediately without a bucket, because inline is then the correct resting place. Separately, the skill-provider path is deleted: `R2SkillProvider`, `isSkillProvider`, the load and unload skill state on `ContextBlocks`, the `load_context` and `unload_context` tools, and the helpers that replayed that state from the transcript. Nothing ever registered a provider with a `load()` method. Think shipped its own Agent Skills through `agents/skills` before this path had a user and registers a plain readonly catalog block, so the tools never appeared and the history scan always returned immediately. A dead prompt line telling the model to use context-loading tools went with it. `agents/context` drops from 1,387 lines to about 880. Think's media eviction is untouched. It decides what the model sees, which is a different question from where bytes live. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Sessions is a message store, not a file store. A message can reference a file without being one, and hosts that handle files already have somewhere to put them: Think's Workspace spills to R2 at 1,500,000 bytes, the same threshold the session tier used, so the two were doing one job twice. With R2 gone there is no reason to extract a payload eagerly at all, because chunk rows live in the same Durable Object and never reclaim a byte. The rule is now one sentence with no configuration: a payload stays in its message row until the row cannot hold it, and then the largest payloads are chunked out until it fits. That takes the aged-row maintenance pass with it. Its only remaining job was draining rows into R2, so the pass, its scheduler, its backlog chaining, the `offload_candidate_bytes` column and the four core methods that stamped and read it are all gone. Think never used it: it disables the pass whenever its own media eviction is on and drives that from a truncated hydration read. Also removed: the R2 bucket port, key construction and cleanup, the declared-versus-unknown-length streaming split and `FixedLengthStream`, the `backend` and `r2_key` blob columns, and the in-memory bucket fakes three packages carried to observe a tier that no longer exists. `SessionsAttachmentOptions` goes from seven fields to three. Think's media eviction is untouched in behavior. It decides what the model sees, which is a different question from where bytes live, and it is what moves bytes to the Workspace. The sessions-slam example is deleted along with the measured table it fed, so the docs no longer quote numbers nothing can reproduce. Found in passing: ai-chat's test worker was inserting into a column renamed some time ago, which surfaced as seven swallowed unhandled rejections in a passing run. Adds design/context.md, recording that prompt assembly and history shaping belong in agents/context while retention stays with the host that owns the file store, and that shaping tool output at the boundary is the missing third piece (#2201). Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Media declared with a non-text media type and carried inline is stored
separately, addressed by SHA-256, and inlined again on read. The message
keeps a pointer and its mediaType, so a round trip is exact and a row
stays small however large its payloads are. Read with
{ attachments: "pointer" } to see references instead.
The rule is typed rather than sized. An image is extracted at any size
and text is never extracted at any size, so a message's stored shape
never depends on how large an image happened to be. Row chunking stays
as the independent size backstop for prose: the two never interact,
because media leaves before the row is measured.
This is not the layer that was removed. That one extracted only when a
row was over budget, which made it a rescue mechanism competing with
chunking and one that could fail with nothing to extract. 775 lines
replace 1,477.
Payload lifetime is derived from reference rows, taken from the stored
message rather than from what a given write extracted, so a pointer-mode
read written back keeps its payload alive. SessionRowStat.bytes charges
each message for what it points at, at inlined size, so a byte budget
still bounds real hydrated memory.
Cost, measured: a 200 KB image bills four rows and a 2 MiB image five,
against one when inlined. Text messages are unchanged at one row.
Also adds agents/context intake shaping. shapeMessage/shapeHistory cap
oversized tool results with a continuation hint and drop host-named
duplicate fields, on the read path so storage stays lossless. The limits
are a function of one message, so a shaped prefix stays byte-identical
across turns and prompt caching holds — which is why this landed here
and sliding history truncation stayed with the hosts.
Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Pi resizes images on the way into context. We deliberately do not, and this records why rather than leaving it as an open question: downscaling re-encodes a user's own bytes, which is a lossy transform of content nobody asked us to change, and it is the one kind of shaping a host cannot undo afterwards. Text caps and duplicate-field dropping stay. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Intake shaping is a separate concern from keeping attachments out of the message, and reviewing them together obscures both. It lands on a branch stacked on this one; nothing here depends on it. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
This was referenced Sep 2, 2026
…to-a-capability # Conflicts: # design/rfc-think-multi-session.md
…ions losing rows Three review findings, all real. The hydration budget was not a bound. `getRecentHistory` admitted rows whenever fewer than `minRecentMessages` had been taken, whatever they weighed, so a window of media-heavy messages hydrated far past the limit that was supposed to cap it. A floor that ignores size is not a floor under a budget, it is a hole in one. The parameter is gone from core, handle, Think and AIChat; the budget is a hard ceiling that always returns at least the newest message. Think's window can therefore be shorter than MODEL_RECENT_WINDOW when messages are unusually large. That is the intended trade: with a 32 MB budget it only ever binds on media, and 32 MB of text is far past what any model could read anyway. AIChat's legacy lift could delete history. It dropped the source table when `imported + skipped === order.length` — but a skipped row is one that could NOT be parsed or imported, so accounting for it and migrating it are not the same thing, and any malformed row was destroyed. It now drops only when every row actually landed, and says what it kept. Sessions stamped its schema version even when `migrateLegacy` reported an incomplete copy, so the lift never retried and the rows it left behind stayed unreachable. `migrateLegacy` now reports completeness and the version is stamped only on success. Both lifts are idempotent, so retrying costs reads and nothing else. Also resolves the conflict with main in rfc-think-multi-session.md, taking main's text, which records the same supersession plus the replacement RFC. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Contributor
🟡 agents import sizesMeasured 294 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared Changed imports (135)
All 267 current runtime imports
Reported by agent-think[bot]. |
`pathRowStats` charges a message for the payloads it points at, because that is what a read materializes. The incremental stats cache did not: it added the serialized POINTER json, so `stats().totalContentBytes` depended on whether the cache happened to be warm. Invalidate instead of tracking. Replicating the base64 charge in append and update would put the formula in three places and let them drift, which is how the two disagreed in the first place. A media write now drops the cache and the next `stats()` derives it — one recursive CTE read against writes that cost ~1000x more. Text writes, the hot path, keep the incremental path untouched. Narrow in practice: Think and pi read `totalContentBytes` from `getRecentHistory`, which derives from `pathRowStats` on every call and never from this cache, so no shipped behavior was wrong. But an API that returns a different number depending on cache warmth is a trap, and the regression test added here is the first thing that would have hit it. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Two findings from auditing Think against the Sessions API. `SessionStats` loses `totalContentBytes` and `pathLength`. Nothing read either — Think and pi take their byte total from `getRecentHistory`, which derives it from `pathRowStats` on every call and never from this cache. Both fields also measured the ACTIVE BRANCH only, excluding other branches, other sessions in the object, and the attachment tables, so as a "how big is this session" signal they answered a different question than the one anyone would ask them. A real size signal against the 10 GB ceiling has to sum the tables and deserves its own function. What remains is the token estimate that gates auto-compaction, which is the only field with a reader and the reason the cache exists at all. That also removes the cache invalidation added a commit ago: it existed solely to keep `totalContentBytes` honest once row stats began charging attachment bytes. Notably the field that diverged was the unused one — `tokenEstimate` is stamped from the message BEFORE extraction, so it always counted the payload and the compaction trigger was never wrong. `appendMessage` now returns the same inlined message whether it inserted or found a duplicate. It previously returned `getMessageRaw` on the duplicate and not-inserted paths, so `AppendResult.message` and the change feed carried `attachment:sha256:` pointers on some appends and inline content on others — one call, two shapes, depending on whether the row already existed. Think compensated for that with `_messageForCache`, which serialized every incoming message and substring-searched it for the pointer prefix before deciding whether to re-read, on the streaming hot path. Fixing the contract deletes the helper and its three call sites. AIChat's similarly named helper stays: it does v4 to v5 transformation, which is genuinely its own concern. The remaining duplication — both hosts reimplementing the change-feed cache mirror — is filed as #2205 rather than attempted here. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Finishing a fix I got half right. The previous commit made `appendMessage`'s duplicate paths inline but left the inserted path returning `prepared.message` — the caller's own object. So a caller that reads with `attachments: "pointer"` and writes the result back got pointer form on the insert and inline form on the retry: the same inconsistency as before, mirrored. It also invalidated the assumption the previous commit relied on to delete Think's `_messageForCache`. That helper existed because the feed could carry pointers; removing it was only safe if Sessions guarantees it never does. For pointer-form writes, it did not, and the pointers would have reached Think's live cache and then a model request. `appendMessage` and `updateMessage` now pass what they return and emit through `core.inlineMessage()`, so the guarantee holds for every write regardless of what the caller supplied. It is a no-op by reference when there are no pointers, so the ordinary write pays a walk and nothing else. The invariant now has one choke point instead of being maintained per branch, which is what went wrong twice here: each fix made another call site consistent rather than stating the contract in one place. Both tests fail without the change: insert-then-duplicate returning identical shapes with nothing pointer-shaped reaching a subscriber, and the same for updates. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
…to-a-capability # Conflicts: # packages/ai-chat/src/index.ts # packages/ai-chat/src/tests/worker.ts
`updateMessage` resolved attachments before checking the outcome, so a write against a row that no longer exists materialized every referenced payload and then returned null — megabytes loaded to be discarded, on a write guaranteed to fail. That path is also the one place a pointer legitimately cannot resolve: if the row is gone its payloads may have been collected with it. Bailing on `missing` before inlining settles both. Also drops `MAX_BOUND_PARAMS` and `buildInClauseStrings`, which arrived with main for the row-size-limit path this branch deletes. Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
Add ThinkSession, a forwarding wrapper around the agents/sessions handle, so a subclass written against Think 0.17 keeps compiling and running: configureSession() still accepts the withContext()/withCachedPrompt() chain, the context accessors on this.session forward to this.context, and appendMessage/getHistory/getRecentHistory accept their positional arguments. Context blocks load during onStart again so the synchronous accessors answer after start. MediaEvictionConfig.externalizeToWorkspace is accepted and ignored, and WorkspaceLike.writeFileBytes is optional; a workspace without it disables media eviction and skills projection with a one-time warning. Rewrite the think, agents and ai-chat changesets as upgrade guides that state the one-way storage migration and its rollback loss, drop the shell changeset (no shell change in this PR), and add an "Upgrading from 0.17" section to the Think docs. Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu
Build the FTS index on the first search() instead of by option. Think enabled searchIndexing but never searched, so every append billed a second row for an index nothing read; now an object pays for the index only once something calls search(), with the existing SQL backfill. Removes the searchIndexing option and SessionSearchDisabledError. Remove what no host uses: fork(), listSessions(), appendMany(), stats() and its maintained cache, the pointer-mode read option, the dead rawMessagesByStats/describe helpers, SessionSerializationError, and the constants and estimators the index re-exported. Merge the leaf and seq caches into one per-session tail read once per object lifetime; the token estimate that gates compactAfter is derived from content-free rows on each call. Collapse appendMessage's three duplicate paths into one: core.append returns the stored row whether or not it inserted. Dispatch the append event before auto-compaction runs, and report a throwing change-feed listener through session:error instead of rejecting a write that already committed. AIChat retention counts stored rows rather than the hydrated window, and persistMessages skips a message whose JSON matches the mirrored array before Sessions would decode and hash its media to find out. ContextBlocks shares one block loader, keeps toSystemPrompt and friends private, and exposes freezeSystemPrompt/refreshSystemPrompt as the prompt surface. Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu
…ncation
skillWorkspace defaults to false so an upgrade writes nothing into a
user's Workspace: 0.17 loaded skills from their sources only, and this
release keeps doing that unless a subclass opts in with {}.
getRecentHistory reports truncated: true when the branch is deeper than
the 10,000-row path cap, which hides older rows exactly as the byte
budget does. Found while lifting a 1.14 GB, 494k-message legacy
transcript: the read returned the newest 10,001 rows and claimed the
whole path fit.
Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu
A branch of exactly the cap's length returned every row and was still reported truncated. Truncation now means the oldest returned row has a parent the read could not follow.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds the experimental
agents/sessionsLifecycle capability and theagents/contextmodule, and moves Think and AIChatAgent conversation persistence onto them.Sessions owns durable conversation storage and nothing else: a tree of messages with branches and compaction overlays, streamed and byte-budgeted reads, opt-in full-text search, and a content-addressed attachment store.
The old
agents/experimental/memoryprovider stack, its Postgres providers, and SessionManager are removed. A user-facing conversation directory remains the responsibility of a parent or router Durable Object.Nothing is truncated, and nothing is too large to store
Two independent mechanisms, split by type rather than by size.
Attachments leave the message. A part that declares a non-text media type and carries its bytes inline — an image, an audio clip, a PDF — is stored outside the row under its SHA-256. The part keeps its shape and
mediaType; only the payload becomes anattachment:sha256:<hex>pointer. Reads put it back verbatim.The rule is uniform: an image is extracted at 8 KB and at 8 MB, and prose is never extracted at any size. A message's stored shape therefore never depends on how large an image happened to be.
Row chunking is the size backstop. A message whose serialized JSON still exceeds
MAX_INLINE_ROW_BYTES(1.5 MiB) is split across numbered continuation rows and reassembled on read, cut on UTF-8 byte boundaries and never through a surrogate pair. There is no ceiling and no size error.The two never interact: media leaves before the row is measured, so a message carrying a large image usually has no continuation rows at all.
Payload lifetime is derived from reference rows; bytes go when the last reference does. Content addressing buys idempotency on a retried write, not a space saving — nothing in the design assumes payloads repeat.
Why this is not the layer an earlier revision removed
That one extracted only when a row was over budget, which made it a rescue mechanism competing with chunking — and it could not do the job it existed for, because extraction needed an extractable leaf. A row over budget from bloated metadata or thousands of small parts had nothing to extract and failed. Chunking won that argument; this store is an ingest rule, not a rescue.
What it costs
A 200 KB image bills four row writes — the message, one payload chunk, its metadata, one reference — where inlining billed one. A 2 MiB image bills five. Text is unaffected at one row. The bench asserts these exactly.
What that buys is a message row that stays a few hundred bytes however large the payload is, which is what makes pointer-mode reads cheap and the byte budget meaningful.
Hydration budgets bound memory
getRecentHistory(maxContentBytes)charges each row its stored bytes, its continuation rows, and the payloads it points at, at inlined size. Extraction makes the row of a message carrying a 2 MB image tiny, so counting rows alone would let a budget admit a window that hydrates far larger than it measured.The budget is a hard ceiling with no message-count floor beneath it. A floor that admitted rows regardless of size is not a floor under a budget, it is a hole in one — twenty media-heavy rows would be admitted whatever the limit said. The newest message is always returned.
minRecentMessagesis gone.This does change Think: its window can be shorter than
MODEL_RECENT_WINDOWwhen messages are unusually large. With the 32 MiB default it only ever binds on media, since 32 MiB of text is far past what any model could read.Storage economics
Every table is
WITHOUT ROWIDwith a composite primary key and no secondary index, ordered by a per-sessionseq. A text append with search off bills one row write, down from two. An unchanged update writes nothing: no row, no continuation, no FTS churn, no event. State is derived rather than maintained.The attachment reference table carries no index on
hash: it takes a write on every media append, and the reachability check that reads it is a scan of a small table, which is far cheaper than maintaining an index on the write path.Context is not conversation storage
Context blocks, frozen prompts, and the search provider move to a new
agents/contextmodule. Think declares blocks through a newconfigureContext()hook and reaches them throughthis.context;configureSession()keeps compaction and search. TheSessionhandle stores messages and knows nothing about prompts.History shaping deliberately stays with the hosts for now. Truncating old tool output slides its boundary a little further every turn, and a sliding boundary rewrites the prompt prefix, which is what invalidates a cache. Moving it here before that is understood would relocate the problem into the module meant to solve it. See #2200.
Intake shaping — the read-path half of #2201 — is stacked on this branch as #2203.
Host mappings
Think
Keeps its message cache, reconciliation, broadcasts, model assembly, branching, and public arrays.
hydrationByteBudgetdefaults to 32 MiB and now bounds memory. Media eviction is unchanged in purpose and simpler in code: it reads inlined history, decodes adata:URL directly, and its Workspace write,[evicted …]marker and/attachments/evicted/paths are untouched. Rewriting a message to a marker now also drops its attachment reference, so eviction genuinely reclaims session storage rather than only shortening context. Think no longer reads Sessions tables with raw SQL.AIChatAgent
Keeps its mutable
messagesarray, destructive regeneration, retention, broadcasts, and v4 conversion. Boot no longer loads the transcript synchronously in the constructor: the legacy lift and one bounded hydration run inonStart, the live array mirrors the Sessions change feed, andget-messagesstreams its response.Removed
The synchronous storage aperture (
session.importMessage()replaces its import use), the R2 tier, the aged-row maintenance pass, the lossy eviction mode,SessionMessageTooLargeErrorand the size ceiling it guarded,sessionAttachments,sanitizeToolPairs, the token-counter andCompactContextplumbing,onCompactionError, themissingUpdateoption, the per-field option thunks, and a duplicate copy of the sanitize helpers.SessionStatslosestotalContentBytesandpathLength. Nothing read either, and both measured only the active branch — excluding other branches, other sessions in the object, and the attachment tables — so as a size signal they answered a different question than the one anyone would ask them. A real signal against the 10 GB ceiling has to sum the tables and deserves its own function.Migration
Legacy
assistant_*message and compaction rows are lifted in SQL. Each source is dropped only after every one of its rows is verified to have a copy holding the same payload; there are no tombstones, because 5 GB of duplicated history in a 10 GB object is its own outage. A lift that cannot verify itself leaves the source in place and does not stamp the schema version, so a later start retries rather than stranding the rows.AIChatAgent lifts
cf_ai_chat_agent_messagesinto a linear chain and drops it only when every row imported — a row that failed to parse is skipped, not migrated, and dropping the table on a skip would delete it permanently.The Sessions schema itself has never been deployed, so it needs no migration of its own.
Measured
Against 251,895 real messages from local Claude Code and pi transcripts: three exceed the 1.5 MiB row budget, all three in pi, and all three also exceed SQLite's 2 MB ceiling — so without chunking they would have been outright insert failures rather than degraded writes. The largest purely model-generated message anywhere is 215 KB, about 13.6% of the budget. Inbound tool content, not model output, is what gets large.
Prior deployed runs also confirmed the 32 MiB budget genuinely bounding hydration (19 of 536 messages, 31.11 MiB) and Think/AIChat migrations landing byte-identical. Full detail in
design/sessions.md.Validation
agents: 118 files, 1887 tests passing@cloudflare/think: 44 files, 882 tests passing@cloudflare/ai-chat: 50 files, 664 tests passingoxfmt,oxlint,sherif, and the package exports check all cleanFollow-ups filed: #2204 (optional R2 tier for the attachment store), #2205 (the change-feed cache mirror is duplicated in every host).