Skip to content

Commit 2349ece

Browse files
committed
Merge remote-tracking branch 'origin/main' into cl-6914-every-compaction-rewrites-the-prompt-head-100-kv-cache-loss
2 parents 522faba + ff5b21d commit 2349ece

3 files changed

Lines changed: 97 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1515

1616
### Agent
1717

18+
- **Context estimate syncs incrementally on append.** `syncFromTurns` keys
19+
prefix turns by object identity and estimates only the new suffix. A rewrite,
20+
shrink, or middle-turn identity break still fully recomputes so image-aging
21+
cannot leave a stale total.
1822
- **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.
1923

2024
- **Compaction keeps scored work, not retry loops.** Errored tool results are no

src/agent/context-estimate.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,60 @@ describe("createContextEstimate", () => {
155155
expect(estimate.tokens).toBe(1);
156156
expect(estimate.turnCount).toBe(1);
157157
});
158+
159+
test("append reuses prefix identities and adds only the new turn", () => {
160+
const estimate = createContextEstimate();
161+
const first = textTurn("xxxx");
162+
const second = textTurn("yyyyyyyy", "assistant");
163+
const turns = [first];
164+
expect(estimate.syncFromTurns(turns)).toBe(1);
165+
166+
turns.push(second);
167+
expect(estimate.syncFromTurns(turns)).toBe(1 + 2);
168+
expect(estimate.tokens).toBe(3);
169+
expect(estimate.turnCount).toBe(2);
170+
});
171+
172+
test("second syncFromTurns with the same identities is a no-op", () => {
173+
const estimate = createContextEstimate();
174+
const first = textTurn("xxxx");
175+
const second = textTurn("yyyyyyyy", "assistant");
176+
const turns = [first, second];
177+
expect(estimate.syncFromTurns(turns)).toBe(3);
178+
expect(estimate.syncFromTurns(turns)).toBe(3);
179+
expect(estimate.syncFromTurns([first, second])).toBe(3);
180+
expect(estimate.tokens).toBe(3);
181+
expect(estimate.turnCount).toBe(2);
182+
});
183+
184+
test("rewrite or shrink fully recomputes", () => {
185+
const estimate = createContextEstimate();
186+
const first = textTurn("xxxx");
187+
const second = textTurn("yyyyyyyy", "assistant");
188+
expect(estimate.syncFromTurns([first, second])).toBe(3);
189+
190+
const rewritten = [textTurn("xxxx"), textTurn("yyyyyyyy", "assistant")];
191+
expect(estimate.syncFromTurns(rewritten)).toBe(estimateContextTokens(rewritten));
192+
expect(estimate.tokens).toBe(3);
193+
expect(estimate.turnCount).toBe(2);
194+
195+
const shrunk = rewritten.slice(0, 1);
196+
expect(estimate.syncFromTurns(shrunk)).toBe(estimateContextTokens(shrunk));
197+
expect(estimate.tokens).toBe(1);
198+
expect(estimate.turnCount).toBe(1);
199+
});
200+
201+
test("same-length middle identity break recomputes even when the last ref matches", () => {
202+
const estimate = createContextEstimate();
203+
const first = textTurn("aaaa");
204+
const middle = textTurn("bbbb");
205+
const last = textTurn("cccc");
206+
expect(estimate.syncFromTurns([first, middle, last])).toBe(3);
207+
208+
const replacedMiddle = textTurn("bbbbbbbb");
209+
const after = [first, replacedMiddle, last];
210+
expect(estimate.syncFromTurns(after)).toBe(estimateContextTokens(after));
211+
expect(estimate.tokens).toBe(4);
212+
expect(estimate.turnCount).toBe(3);
213+
});
158214
});

src/agent/context-estimate.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,18 @@ export function estimateContentBlockTokens(block: ContentBlock): number {
7272
}
7373
}
7474

75+
function estimateTurnTokens(turn: ConversationTurn): number {
76+
let total = 0;
77+
for (const block of turn.content) {
78+
total += estimateContentBlockTokens(block);
79+
}
80+
return total;
81+
}
82+
7583
export function estimateContextTokens(turns: readonly ConversationTurn[]): number {
7684
let total = 0;
7785
for (const turn of turns ?? []) {
78-
for (const block of turn.content) {
79-
total += estimateContentBlockTokens(block);
80-
}
86+
total += estimateTurnTokens(turn);
8187
}
8288
return total;
8389
}
@@ -98,20 +104,42 @@ export function estimateOverheadTokens(
98104
return estimateTokensFromChars(chars);
99105
}
100106

101-
// Mutable running estimate. Callers re-sync from the full turn list after each
102-
// append so compaction rewrites and tool results stay accurate without
103-
// incremental add/subtract bookkeeping. `overheadTokens` is fixed per session
104-
// (system prompt + tool schemas do not change turn to turn) and is folded into
105-
// every sync so the total tracks what actually goes out on the wire.
107+
// Mutable running estimate. Mid-cycle callers keep calling `syncFromTurns` so
108+
// tool results and image-aging stay visible before the next inference.done.
109+
// Prefix turns are keyed by object identity (===), not content: an append that
110+
// keeps every prior ref adds only the suffix; a shrink or any prefix identity
111+
// break fully recomputes. Length + last-turn alone is not enough — aging can
112+
// replace a middle turn and leave the last ref in place. Callers may push onto
113+
// the same array, so the cache snapshots refs rather than holding the array.
106114
export type ContextEstimate = ReturnType<typeof createContextEstimate>;
107115

108116
export function createContextEstimate(overheadTokens = 0) {
109117
let tokens = overheadTokens;
110118
let turnCount = 0;
119+
let cachedTurns: ConversationTurn[] = [];
120+
121+
function prefixRefsMatch(turns: readonly ConversationTurn[]): boolean {
122+
for (let i = 0; i < cachedTurns.length; i++) {
123+
if (turns[i] !== cachedTurns[i]) return false;
124+
}
125+
return true;
126+
}
111127

112128
function syncFromTurns(turns: readonly ConversationTurn[]): number {
113-
tokens = overheadTokens + estimateContextTokens(turns);
129+
if (turns.length === cachedTurns.length && prefixRefsMatch(turns)) {
130+
return tokens;
131+
}
132+
133+
if (turns.length > cachedTurns.length && prefixRefsMatch(turns)) {
134+
for (const turn of turns.slice(cachedTurns.length)) {
135+
tokens += estimateTurnTokens(turn);
136+
}
137+
} else {
138+
tokens = overheadTokens + estimateContextTokens(turns);
139+
}
140+
114141
turnCount = turns.length;
142+
cachedTurns = turns.slice();
115143
return tokens;
116144
}
117145

0 commit comments

Comments
 (0)