From ba2fb448f6d1b6c5977444bc2b43c721c6b2d03a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 10:00:51 -0700 Subject: [PATCH] Sync context estimates incrementally from turn identities Walking the full history on every reactor event is wasteful. Cache prefix turn refs and add only the suffix when identities hold. Rewrite, shrink, or a middle identity break still fully recomputes so image-aging cannot leave a stale total. --- CHANGELOG.md | 5 +++ src/agent/context-estimate.test.ts | 56 ++++++++++++++++++++++++++++++ src/agent/context-estimate.ts | 46 +++++++++++++++++++----- 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f676aed65..8cfd75ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ### Agent +- **Context estimate syncs incrementally on append.** `syncFromTurns` keys + prefix turns by object identity and estimates only the new suffix. A rewrite, + shrink, or middle-turn identity break still fully recomputes so image-aging + cannot leave a stale total. + - **Compaction keeps scored work, not retry loops.** Errored tool results are no longer auto-pinned; identical errors collapse to one representative. Anchors are scored (writes, successful task completions, plan updates) and pair diff --git a/src/agent/context-estimate.test.ts b/src/agent/context-estimate.test.ts index e196571a1..d69b20937 100644 --- a/src/agent/context-estimate.test.ts +++ b/src/agent/context-estimate.test.ts @@ -155,4 +155,60 @@ describe("createContextEstimate", () => { expect(estimate.tokens).toBe(1); expect(estimate.turnCount).toBe(1); }); + + test("append reuses prefix identities and adds only the new turn", () => { + const estimate = createContextEstimate(); + const first = textTurn("xxxx"); + const second = textTurn("yyyyyyyy", "assistant"); + const turns = [first]; + expect(estimate.syncFromTurns(turns)).toBe(1); + + turns.push(second); + expect(estimate.syncFromTurns(turns)).toBe(1 + 2); + expect(estimate.tokens).toBe(3); + expect(estimate.turnCount).toBe(2); + }); + + test("second syncFromTurns with the same identities is a no-op", () => { + const estimate = createContextEstimate(); + const first = textTurn("xxxx"); + const second = textTurn("yyyyyyyy", "assistant"); + const turns = [first, second]; + expect(estimate.syncFromTurns(turns)).toBe(3); + expect(estimate.syncFromTurns(turns)).toBe(3); + expect(estimate.syncFromTurns([first, second])).toBe(3); + expect(estimate.tokens).toBe(3); + expect(estimate.turnCount).toBe(2); + }); + + test("rewrite or shrink fully recomputes", () => { + const estimate = createContextEstimate(); + const first = textTurn("xxxx"); + const second = textTurn("yyyyyyyy", "assistant"); + expect(estimate.syncFromTurns([first, second])).toBe(3); + + const rewritten = [textTurn("xxxx"), textTurn("yyyyyyyy", "assistant")]; + expect(estimate.syncFromTurns(rewritten)).toBe(estimateContextTokens(rewritten)); + expect(estimate.tokens).toBe(3); + expect(estimate.turnCount).toBe(2); + + const shrunk = rewritten.slice(0, 1); + expect(estimate.syncFromTurns(shrunk)).toBe(estimateContextTokens(shrunk)); + expect(estimate.tokens).toBe(1); + expect(estimate.turnCount).toBe(1); + }); + + test("same-length middle identity break recomputes even when the last ref matches", () => { + const estimate = createContextEstimate(); + const first = textTurn("aaaa"); + const middle = textTurn("bbbb"); + const last = textTurn("cccc"); + expect(estimate.syncFromTurns([first, middle, last])).toBe(3); + + const replacedMiddle = textTurn("bbbbbbbb"); + const after = [first, replacedMiddle, last]; + expect(estimate.syncFromTurns(after)).toBe(estimateContextTokens(after)); + expect(estimate.tokens).toBe(4); + expect(estimate.turnCount).toBe(3); + }); }); diff --git a/src/agent/context-estimate.ts b/src/agent/context-estimate.ts index 97150c37f..ce496f345 100644 --- a/src/agent/context-estimate.ts +++ b/src/agent/context-estimate.ts @@ -72,12 +72,18 @@ export function estimateContentBlockTokens(block: ContentBlock): number { } } +function estimateTurnTokens(turn: ConversationTurn): number { + let total = 0; + for (const block of turn.content) { + total += estimateContentBlockTokens(block); + } + return total; +} + export function estimateContextTokens(turns: readonly ConversationTurn[]): number { let total = 0; for (const turn of turns ?? []) { - for (const block of turn.content) { - total += estimateContentBlockTokens(block); - } + total += estimateTurnTokens(turn); } return total; } @@ -98,20 +104,42 @@ export function estimateOverheadTokens( return estimateTokensFromChars(chars); } -// Mutable running estimate. Callers re-sync from the full turn list after each -// append so compaction rewrites and tool results stay accurate without -// incremental add/subtract bookkeeping. `overheadTokens` is fixed per session -// (system prompt + tool schemas do not change turn to turn) and is folded into -// every sync so the total tracks what actually goes out on the wire. +// Mutable running estimate. Mid-cycle callers keep calling `syncFromTurns` so +// tool results and image-aging stay visible before the next inference.done. +// Prefix turns are keyed by object identity (===), not content: an append that +// keeps every prior ref adds only the suffix; a shrink or any prefix identity +// break fully recomputes. Length + last-turn alone is not enough — aging can +// replace a middle turn and leave the last ref in place. Callers may push onto +// the same array, so the cache snapshots refs rather than holding the array. export type ContextEstimate = ReturnType; export function createContextEstimate(overheadTokens = 0) { let tokens = overheadTokens; let turnCount = 0; + let cachedTurns: ConversationTurn[] = []; + + function prefixRefsMatch(turns: readonly ConversationTurn[]): boolean { + for (let i = 0; i < cachedTurns.length; i++) { + if (turns[i] !== cachedTurns[i]) return false; + } + return true; + } function syncFromTurns(turns: readonly ConversationTurn[]): number { - tokens = overheadTokens + estimateContextTokens(turns); + if (turns.length === cachedTurns.length && prefixRefsMatch(turns)) { + return tokens; + } + + if (turns.length > cachedTurns.length && prefixRefsMatch(turns)) { + for (const turn of turns.slice(cachedTurns.length)) { + tokens += estimateTurnTokens(turn); + } + } else { + tokens = overheadTokens + estimateContextTokens(turns); + } + turnCount = turns.length; + cachedTurns = turns.slice(); return tokens; }