diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8532ab..1d372be0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ 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. - **Thinking-only replay no longer collapses into an identical request.** Assistant turns with no text or tool_call (empty content, leftover thinking/citation) are replaced with a stable `[thinking-only turn omitted]` marker so the turn is kept, roles still alternate, and the next `buildRequest` body differs from the previous one. - **Compaction keeps scored work, not retry loops.** Errored tool results are no diff --git a/src/agent/context-estimate.test.ts b/src/agent/context-estimate.test.ts index e196571a..d69b2093 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 97150c37..ce496f34 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; }