feat(agents): move sessions into a Lifecycle capability (#2196)
* feat(agents): add sessions lifecycle capability
* feat(agents): add sessions context and compaction
* feat(agents): add bounded session media eviction
* feat(agents): support host session media policies
* feat(think): replatform sessions onto lifecycle capability
* feat(ai-chat): persist messages with sessions capability
* refactor(agents): replace experimental memory sessions
* feat(sessions): own streamed attachment storage
* feat(sessions): offload losslessly, bound hydration memory, split out 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
* fix(agent-think): move its context block onto configureContext
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
* fix(sessions): drop lifted legacy tables instead of keeping tombstones
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
* refactor(sessions): one extraction rule, and drop the unused skill providers
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
* refactor(sessions): drop the R2 tier and the aged-row maintenance pass
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 (cloudflare/agents#2201).
Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
* feat(sessions): keep attachments out of the message, add intake shaping
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
* docs(context): rule out image downscaling in intake shaping
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
* refactor(context): move intake shaping to its own PR
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
* fix(sessions,ai-chat): make the read budget a real bound, stop migrations 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
* chore: format changeset
Claude-Session: https://claude.ai/code/session_01MXpoKqJrUFTqgmDrehDti4
* fix(sessions): derive byte totals when a write touches attachments
`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
* refactor(sessions): align the surface with what hosts actually use
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
* fix(sessions): guarantee every emitted message is inlined
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
* fix(sessions): skip inlining when an update has no target
`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
* feat(think): keep the 0.17 session API working on top of Sessions
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
* refactor(sessions): ship the surface the hosts use, and derive the rest
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
* docs(design): bring the sessions design record up to the shipped surface
Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu
* fix(think): keep skill projection off by default; report path-cap truncation
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
* style(think): format README table
Claude-Session: https://claude.ai/code/session_01PbR74FnDvmhMyzGxKUGXEu
* fix(sessions): report path-cap truncation only when older rows exist
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. M
Matt committed
ec93caf6ec1efebb521aa9ab30c0a8cb2b4d50d5
Parent: 6da4c44
Committed by GitHub <noreply@github.com>
on 9/3/2026, 2:31:17 PM