perf(think,sessions): stop re-reading the transcript during a turn - #2219
perf(think,sessions): stop re-reading the transcript during a turn#2219mattzcarey wants to merge 1 commit into
Conversation
A long Think turn read the whole persisted history 2 + T times: once to
reconcile the client's transcript, once to refresh the cache after
persisting it, and once per tool update (client result, approval,
cross-message result, execution outcome) to find the one message that
owns the call. Each read pays the sized path query and hydrates every
row, so the billed reads grew with transcript length times tool count.
Think now resolves a tool update's owner from the in-flight accumulator
and the live cache and reads that row alone; a chat request reconciles
against the cache, skips echoed messages whose stored form is unchanged,
and does not re-read the path after its writes. Storage is walked only
when the cache does not cover the active path, and then newest first,
stopping at the owner. The cache re-windows itself once appends carry it
past the hydration budget and marks itself stale on Sessions `import`
and `compaction` events. Media eviction is gated from memory on each
linear append instead of a stored-path scan after every cache refresh;
unchanged think_config rewrites and the per-call agent-tool DDL are
skipped.
Sessions gains `history({ newestFirst: true })`, a leaf-to-root walk
over parent pointers that pays one row per message the consumer takes
and plans compaction overlays only once it reaches a compacted span, and
reports `import` and `compaction` writes on the change feed.
Pinned on real DO SQLite: the owner lookup on a 200-message path reads
under 12 rows against 1801 for a full read; a Think tool update and a
turn start bill the same rows on a 12-message and a 160-message
transcript.
🦋 Changeset detectedLatest commit: 160c147 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 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 import sizesMeasured 267 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 (4)
All 267 current runtime imports
Reported by agent-think[bot]. |
| this._cachedBytesSinceSync += JSON.stringify(message).length; | ||
| if ( | ||
| this._lastHydration.totalContentBytes + this._cachedBytesSinceSync > | ||
| budget | ||
| ) { | ||
| this._cacheCoversActivePath = false; |
There was a problem hiding this comment.
🔴 Hydration budget misses cache growth
Large updates never reach _cachedBytesSinceSync, while non-ASCII appends count UTF-16 units instead of stored bytes. _cacheCoversActivePath can stay true beyond hydrationByteBudget, so later turns skip re-windowing and retain excess history.
Prompt for agents
Fix Think's post-hydration cache growth accounting in packages/think/src/think.ts. The current _noteCachedGrowth path only runs for linear appends and adds JSON.stringify(message).length, which is UTF-16 code units rather than the UTF-8/storage-sized units used by hydrationByteBudget. Updates can also enlarge cached messages without affecting the accounting. Track byte deltas for every cache mutation that can grow the cached transcript, using the same byte semantics as Sessions where possible, and mark _cacheCoversActivePath false once the refreshed total plus subsequent growth exceeds the budget. Preserve the optimization that avoids unnecessary full transcript reads.
Was this helpful? React with 👍 or 👎 to provide feedback.
| if (this._agedRowsHiddenFromCache()) { | ||
| if ( | ||
| this._lastHydration !== null && | ||
| this._mediaEvictionFruitlessAtBytes === | ||
| this._lastHydration.totalContentBytes | ||
| ) { | ||
| return; |
There was a problem hiding this comment.
🟡 Media eviction stays suppressed after appends
After a fruitless compacted or windowed pass, appends leave _lastHydration.totalContentBytes unchanged. The stale equality suppresses later passes, so newly aged media remains inline until another refresh occurs.
Prompt for agents
Make the fruitless media-eviction gate reflect storage changes after the last hydration. In packages/think/src/think.ts, _mediaEvictionFruitlessAtBytes is compared against _lastHydration.totalContentBytes, but linear append events do not update that hydration snapshot. On compacted or windowed caches, a fruitless pass can therefore suppress all later append-triggered passes. Invalidate or advance the fruitless marker whenever a durable mutation changes candidate rows or total stored bytes, while retaining the no-repeat-scan optimization for genuinely unchanged storage.
Was this helpful? React with 👍 or 👎 to provide feedback.
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
What
Think stops reading the transcript during a turn.
A long Think turn used to read the whole persisted history 2 + T times, where T is the number of tool updates in the turn (client results, approvals, cross-message results from approved server tools, execution outcomes). Each read walks the sized path query (several billed rows per message for the byte subqueries) and then hydrates every row. With a 400-message transcript and 20 tool updates that is on the order of 10k billed row reads for a turn whose stream log writes about 30 rows.
Four changes, all on the read side. Nothing about what is durable, or when, moves.
A tool update resolves its one target row from memory.
_applyToolUpdateToMessagesno longer callssession.getHistory()._resolveToolCallOwnerasks the in-flight accumulator first (a row under its id exists only when stall recovery persisted a partial), then the live cache for the owner's id, and reads that row alone. The apply is still a first-write-wins read-modify-write of the stored form, so Tool part state regresses during client tool round-trip; continuation starts but emits no output #1404 replays still write nothing and _scheduleAutoContinuation 50ms timer fires before all parallel client-tool results arrive → MissingToolResultsError #1649 mid-stream results still reach both the accumulator and the row. Storage is scanned only when the cache does not cover the active path (a windowed hydration or a failed boot hydration), and then newest first, stopping at the owner.A chat request reconciles against the cache.
_reconcileAndPersistIncomingusesthis.messagesas the server transcript when the cache covers the path, skips echoed messages whose stored form is already what the server holds (the client posts its whole transcript every request; each echoed message used to cost Sessions an existence read plus a full-row compare), and no longer re-reads the path after the writes. The change feed already patched the cache for every write. A windowed cache keeps the old reads.Media eviction is gated from memory and armed by appends. Every cache refresh used to arm a pass whose first act is a content-free scan of the whole stored path. The gate now runs on each linear append (the event that ages older messages) and schedules a pass only when an aged cached message still carries an inline payload (
hasEvictableMedia), or, when aged rows are not all in memory, once per distinct stored size.Two per-call costs become per-isolate.
think_configwrites of an unchanged request body or client-tool schemas are skipped, and the agent-tool child-run DDL (two CREATE IF NOT EXISTS plus three ALTER attempts that throw and are swallowed) runs once per isolate instead of before every milestone, progress snapshot and child-run read.Sessions gains the read shape Think needs:
session.history({ newestFirst: true })streams the active path leaf → root by following parent pointers, paying one row per message the consumer takes. Compaction overlays are honored without planning them up front: an overlay that applies ends at a row the walk reaches before anything it covers, so the walk stays row-by-row until it lands on a compaction's end, then plans the remaining prefix by id and streams it overlay-collapsed in eight-row windows. A lookup that stops in the messages after the last compaction never pays for that.Sessions also reports two writes the change feed used to hide:
import(one per rowimportMessage()actually writes) andcompaction(an overlay stored throughaddCompaction()). Think marks its cache stale onimportand refreshes oncompaction, so a host cache can no longer claim to cover a path that changed underneath it.compact()still reportscompact.Two guards keep the cache honest over a long-lived isolate. Appends are charged against the hydration budget measured at the last refresh, and once they would carry the cache past it the cache stops claiming to cover the path, so the next boundary re-reads and re-windows (#1710). And the eviction gate treats compaction overlays like a windowed hydration: rows collapsed under an overlay are not in the cache, so a compacted session gets one content-free pass per distinct stored size rather than none.
Measured
Billed rows, real Durable Object SQLite, pinned in tests.
getHistory)history({ newestFirst }))The Think numbers come from a
rowsRead/rowsWrittencounter on the test agent'sctx.storage.sql.exec, the same accounting the streams and sessions benches use.Not in this PR
@cloudflare/ai-chatand belongs in its own change.compactAfterre-derives the token estimate over the whole content-free path on every inserted append (Sessions). A running total kept alongside the leaf cache would remove it.getPartialStreamText,persistOrphanedStreamand the give-up path._annotateActionApprovalChunkper-chunk SELECT only fires for paused durable actions and is left alone.Tests
agentssessions suites (69): newest-first ordering, overlays, early exit, branch leaf;importandcompactionchange events; the storage-ops bench pins the owner lookup under 12 rows against a full read over 1000, with and without a compacted prefix, and bounds a lookup that has to enter the overlay.@cloudflare/thinksuites: client-tools (new rows-read tests for tool update and turn start), hydration-budget (tool result for a row outside the window still lands via the storage fallback), media-eviction (hasEvictableMedia; a linear append schedules the pass with no cache refresh), plus the full Think workers project and the@cloudflare/ai-chatworkers project.npm run typecheck,oxfmt --check .,oxlintclean.