Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions src/agent/context-estimate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
46 changes: 37 additions & 9 deletions src/agent/context-estimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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<typeof createContextEstimate>;

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;
}

Expand Down
Loading