Skip to content

Commit 7abba13

Browse files
committed
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.
1 parent a63b181 commit 7abba13

3 files changed

Lines changed: 98 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ 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.
22+
1823
- **Compaction keeps scored work, not retry loops.** Errored tool results are no
1924
longer auto-pinned; identical errors collapse to one representative. Anchors
2025
are scored (writes, successful task completions, plan updates) and pair

‎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)