Skip to content

Commit b2b6848

Browse files
Merge context meter accuracy and compaction arming
2 parents cd21f54 + fc57acc commit b2b6848

23 files changed

Lines changed: 370 additions & 49 deletions

src/agent/compaction.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,25 @@ import type {
88
} from "@intx/types/runtime";
99
import { createCompactionGovernor } from "./compaction.js";
1010
import { compactionThresholdFor } from "../provider/context-window.js";
11+
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
1112

1213
const capabilities = {
1314
infer: (options?: unknown) => ({ type: "infer", ...(options !== undefined ? { options } : {}) }),
1415
compact: (compactor: string, reason: string) => ({ type: "compact", compactor, reason }),
1516
} as unknown as ReactorCapabilities;
1617

18+
// Distinct, non-zero cacheRead/cacheWrite so a test asserting on the total
19+
// would fail if compaction.ts ever stopped routing through the shared
20+
// contextTokensFromUsage and summed only `input` again.
1721
function usage(input: number): TokenUsage {
18-
return { input, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
22+
return { input, output: 0, cacheRead: 3, cacheWrite: 5, thinking: 0 };
23+
}
24+
25+
// A provider that truly omits usage reports every field as zero, not just
26+
// `input` — distinct from usage(0), which still carries the fixture's
27+
// non-zero cache values above.
28+
function zeroUsage(): TokenUsage {
29+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
1930
}
2031

2132
function turnsOfLength(count: number, textLength: number): ConversationTurn[] {
@@ -39,7 +50,7 @@ function inferenceDoneWithoutUsage(): Extract<ReactorInboundEvent, { type: "infe
3950
return {
4051
type: "inference.done",
4152
turn: { role: "assistant", content: [{ type: "text", text: "ok" }] },
42-
usage: usage(0),
53+
usage: zeroUsage(),
4354
source: { sourceId: "s", provider: "p", model: "m" },
4455
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
4556
}
@@ -255,4 +266,69 @@ describe("compaction governor", () => {
255266
expect(governor.interceptActions(inferenceDone(overThreshold), inferAction, capabilities)).toBeNull();
256267
expect(governor.interceptActions(toolDone(), [{ type: "reply", content: "x" }], capabilities)).toBeNull();
257268
});
269+
270+
test("stays inert below the minimum-turn floor no matter how far over threshold", () => {
271+
// Two turns is well under MIN_TURNS_TO_COMPACT. createPruningCompactor
272+
// no-ops at the same floor (see session/compactor.ts), so arming here
273+
// would spend a reactor cycle that cannot shrink anything.
274+
const governor = createCompactionGovernor(() => {});
275+
governor.noteInferenceDone(inferenceDone(overThreshold * 10), turnsOfLength(2, 1));
276+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
277+
});
278+
279+
test("arms on tool.done from a live estimate even when the last snapshot was under threshold", () => {
280+
// Usage is omitted (pending is derived from the local estimate, which
281+
// starts small and stays false), but the tool result that follows is
282+
// itself large enough to cross the ordinary threshold before the next
283+
// inference.done ever runs.
284+
const governor = createCompactionGovernor(() => {});
285+
governor.noteInferenceDone(inferenceDoneWithoutUsage(), tenTurns);
286+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
287+
288+
const overThresholdChars = (compactionThresholdFor("m") + 1) * 4;
289+
governor.syncFromTurns(turnsOfLength(10, Math.ceil(overThresholdChars / 10)));
290+
291+
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
292+
expect(actions).not.toBeNull();
293+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
294+
});
295+
296+
test("never arms at the exact turn count createPruningCompactor no-ops on", () => {
297+
// createPruningCompactor's own no-op floor (session/compactor.ts) is
298+
// compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS). Arming at or below it
299+
// would spend a reactor cycle that is guaranteed to shrink nothing.
300+
const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
301+
const governor = createCompactionGovernor(() => {});
302+
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(floor, 1));
303+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
304+
});
305+
306+
test("arms one turn past the floor createPruningCompactor no-ops on", () => {
307+
const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
308+
const governor = createCompactionGovernor(() => {});
309+
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(floor + 1, 1));
310+
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
311+
expect(actions).not.toBeNull();
312+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
313+
});
314+
315+
test("does not catch a huge tool result mid-cycle when the provider reported real usage", () => {
316+
// Disclosed, accepted gap: the live tool.done re-check only re-derives
317+
// arming from the local estimate when the last inference.done snapshot
318+
// came from that same estimate (usingEstimate). When the provider
319+
// reported real usage under threshold, that snapshot is trusted as
320+
// authoritative until the next inference.done — a huge tool result
321+
// arriving in between is not caught until then, unlike the
322+
// usage-omitted case covered above.
323+
const governor = createCompactionGovernor(() => {});
324+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
325+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
326+
327+
const overThresholdChars = (compactionThresholdFor("m") + 1) * 4;
328+
governor.syncFromTurns(turnsOfLength(10, Math.ceil(overThresholdChars / 10)));
329+
330+
// Still null: the live estimate is now over threshold, but the last
331+
// arming decision trusted reported usage, so it is not re-checked here.
332+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
333+
});
258334
});

src/agent/compaction.ts

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ import type {
33
ReactorAction,
44
ReactorCapabilities,
55
ReactorInboundEvent,
6+
ToolDefinition,
67
} from "@intx/types/runtime";
7-
import { compactionThresholdFor } from "../provider/context-window.js";
8-
import { createContextEstimate } from "./context-estimate.js";
8+
import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js";
9+
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
10+
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
911

1012
const COMPACTOR_NAME = "pruning-compactor";
11-
const MIN_TURNS_TO_COMPACT = 6;
13+
// The exact turn count `createPruningCompactor` (session/compactor.ts) is
14+
// guaranteed to no-op on. Derived from the same keepRecentTurns both real
15+
// registrations (session, sub-agent) use, so this floor cannot silently
16+
// drift from what the compactor will actually do — arming at or below it
17+
// would spend a reactor cycle that shrinks nothing.
18+
const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
1219
const MAX_OVERFLOW_RECOVERIES = 2;
1320

1421
// A compact action runs in its own reactor cycle, after which the reactor
@@ -20,51 +27,82 @@ const MAX_OVERFLOW_RECOVERIES = 2;
2027
// would be worse than growing the context.
2128
export type CompactionGovernor = ReturnType<typeof createCompactionGovernor>;
2229

23-
export function createCompactionGovernor(requestContinuation?: () => void) {
30+
export function createCompactionGovernor(
31+
requestContinuation?: () => void,
32+
systemPrompt = "",
33+
toolDefinitions: readonly ToolDefinition[] = [],
34+
) {
2435
let pending = false;
2536
let idlePending = false;
2637
let postCompactInfer = false;
2738
let overflowRecoveries = 0;
39+
// Set whenever the arming decision fell back to the local estimate because
40+
// the provider omitted usage or reported zero, so callers rendering a meter
41+
// can flag the number as approximate instead of implying provider-grade
42+
// precision.
43+
let usingEstimate = false;
44+
// Model of the last inference.done turn, kept for live re-checks between
45+
// inference cycles (see interceptActions) where the event carries no model.
46+
let lastModel: string | undefined;
47+
let turnCount = 0;
2848

29-
// Running local estimate of the turns we send. Providers that omit usage or
30-
// report zero leave the proactive path blind; the estimate fills that gap.
31-
// When the provider reports real usage we prefer it so a coarse local count
32-
// cannot thrash against a trustworthy signal.
33-
const estimate = createContextEstimate();
49+
// Running local estimate of the turns we send, plus the fixed system-prompt
50+
// and tool-schema overhead every request carries. Providers that omit usage
51+
// or report zero leave the proactive path blind; the estimate fills that
52+
// gap. When the provider reports real usage we prefer it so a coarse local
53+
// count cannot thrash against a trustworthy signal.
54+
const estimate = createContextEstimate(estimateOverheadTokens(systemPrompt, toolDefinitions));
3455

3556
// Re-sync after turn appends, tool results, and compaction rewrites. Callers
3657
// pass the full turn list so the estimate stays accurate without incremental
3758
// add/subtract bookkeeping.
3859
function syncFromTurns(turns: readonly ConversationTurn[]): number {
60+
turnCount = turns.length;
3961
return estimate.syncFromTurns(turns);
4062
}
4163

64+
function isOverThreshold(contextTokens: number): boolean {
65+
return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT;
66+
}
67+
4268
function noteInferenceDone(
4369
event: Extract<ReactorInboundEvent, { type: "inference.done" }>,
4470
turns: readonly ConversationTurn[],
4571
): void {
4672
overflowRecoveries = 0;
4773
if (requestContinuation === undefined) return;
4874
syncFromTurns(turns);
49-
const reportedTokens = event.usage?.input ?? 0;
50-
const contextTokens = reportedTokens > 0 ? reportedTokens : estimate.tokens;
51-
// Assign, don't OR: an under-threshold follow-up must disarm a sticky pending
52-
// left from an earlier over-threshold turn (e.g. after the provider reports
53-
// real usage that lands below the threshold).
54-
pending =
55-
contextTokens > compactionThresholdFor(event.source?.model) &&
56-
turns.length > MIN_TURNS_TO_COMPACT;
75+
lastModel = event.source?.model;
76+
const reportedTokens = contextTokensFromUsage(event.usage);
77+
usingEstimate = reportedTokens <= 0;
78+
const contextTokens = usingEstimate ? estimate.tokens : reportedTokens;
79+
// Assign, don't OR: an under-threshold follow-up must disarm a sticky
80+
// pending left from an earlier over-threshold turn (e.g. after the
81+
// provider reports real usage that lands below the threshold).
82+
pending = isOverThreshold(contextTokens);
5783
}
5884

5985
// Compaction waits for the natural pause between a tool batch finishing and
6086
// the follow-up infer: the infer is dropped from the action set, the compact
6187
// cycle runs, and the continuation message re-enters inference.
88+
//
89+
// `pending` reflects the snapshot as of the last inference.done, which
90+
// predates any tool result produced by that turn's own tool batch. When the
91+
// provider is reporting real usage, that snapshot is authoritative and
92+
// `pending` alone is trusted (there is no fresher provider number to check
93+
// against until the next inference.done). But when usage was omitted or
94+
// zero, `pending` was itself derived from the local estimate — in that case
95+
// a large tool result can push the estimate over threshold before the next
96+
// inference.done ever runs, so this re-derives the same arming rule against
97+
// the live estimate (already re-synced this cycle by the director) instead
98+
// of trusting a `pending` that can be stale by exactly one tool batch.
6299
function interceptActions(
63100
event: ReactorInboundEvent,
64101
actions: ReactorAction[],
65102
capabilities: ReactorCapabilities,
66103
): ReactorAction[] | null {
67-
if (!pending || event.type !== "tool.done") return null;
104+
if (event.type !== "tool.done") return null;
105+
if (!pending && !(usingEstimate && isOverThreshold(estimate.tokens))) return null;
68106
if (!actions.some((a) => a.type === "infer")) return null;
69107
pending = false;
70108
postCompactInfer = true;
@@ -140,6 +178,12 @@ export function createCompactionGovernor(requestContinuation?: () => void) {
140178
get estimatedTokens(): number {
141179
return estimate.tokens;
142180
},
181+
// True once the provider has omitted or zeroed usage on the current
182+
// turn, so a status-bar meter reading this can mark itself approximate
183+
// rather than silently understating a real number.
184+
get usingEstimate(): boolean {
185+
return usingEstimate;
186+
},
143187
syncFromTurns,
144188
noteInferenceDone,
145189
noteIdleTurn,

src/agent/context-estimate.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { describe, expect, test } from "bun:test";
2-
import type { ContentBlock, ConversationTurn, MediaSource } from "@intx/types/runtime";
2+
import type { ContentBlock, ConversationTurn, MediaSource, ToolDefinition } from "@intx/types/runtime";
33
import {
44
createContextEstimate,
55
estimateContentBlockTokens,
66
estimateContextTokens,
77
estimateMediaSourceTokens,
8+
estimateOverheadTokens,
89
estimateTokensFromChars,
910
} from "./context-estimate.js";
1011

@@ -85,7 +86,30 @@ describe("estimateContextTokens", () => {
8586
});
8687
});
8788

89+
describe("estimateOverheadTokens", () => {
90+
test("counts the system prompt and every tool's name, description, and schema", () => {
91+
const systemPrompt = "x".repeat(40);
92+
const tools: ToolDefinition[] = [
93+
{ name: "run_shell", description: "y".repeat(20), inputSchema: { command: "string" } },
94+
];
95+
const expectedChars =
96+
40 + "run_shell".length + 20 + JSON.stringify({ command: "string" }).length;
97+
expect(estimateOverheadTokens(systemPrompt, tools)).toBe(estimateTokensFromChars(expectedChars));
98+
});
99+
100+
test("is zero for an empty prompt and no tools", () => {
101+
expect(estimateOverheadTokens("", [])).toBe(0);
102+
});
103+
});
104+
88105
describe("createContextEstimate", () => {
106+
test("folds a fixed overhead into every sync", () => {
107+
const estimate = createContextEstimate(100);
108+
expect(estimate.tokens).toBe(100);
109+
expect(estimate.syncFromTurns([textTurn("xxxx")])).toBe(101);
110+
expect(estimate.tokens).toBe(101);
111+
});
112+
89113
test("re-syncs from the full turn list after each append", () => {
90114
const estimate = createContextEstimate();
91115
expect(estimate.tokens).toBe(0);

src/agent/context-estimate.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
// tool payloads, images) so proactive compaction still has a signal. This is
66
// a lower bound: system prompt, tool schemas, and framing are not counted.
77

8-
import type { ContentBlock, ConversationTurn, MediaSource } from "@intx/types/runtime";
8+
import type {
9+
ContentBlock,
10+
ConversationTurn,
11+
MediaSource,
12+
ToolDefinition,
13+
} from "@intx/types/runtime";
914

1015
const CHARS_PER_TOKEN = 4;
1116

@@ -77,17 +82,35 @@ export function estimateContextTokens(turns: readonly ConversationTurn[]): numbe
7782
return total;
7883
}
7984

85+
// The system prompt and tool schemas ride on every request the same way turns
86+
// do, but they never appear in `turns` — they're framing the harness supplies
87+
// out of band. Without this, the estimate undercounts by whatever AGENTS.md
88+
// and the active tool roster cost, which is often tens of thousands of tokens
89+
// before a single turn is sent.
90+
export function estimateOverheadTokens(
91+
systemPrompt: string,
92+
toolDefinitions: readonly ToolDefinition[],
93+
): number {
94+
let chars = systemPrompt.length;
95+
for (const tool of toolDefinitions) {
96+
chars += tool.name.length + tool.description.length + JSON.stringify(tool.inputSchema).length;
97+
}
98+
return estimateTokensFromChars(chars);
99+
}
100+
80101
// Mutable running estimate. Callers re-sync from the full turn list after each
81102
// append so compaction rewrites and tool results stay accurate without
82-
// incremental add/subtract bookkeeping.
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.
83106
export type ContextEstimate = ReturnType<typeof createContextEstimate>;
84107

85-
export function createContextEstimate() {
86-
let tokens = 0;
108+
export function createContextEstimate(overheadTokens = 0) {
109+
let tokens = overheadTokens;
87110
let turnCount = 0;
88111

89112
function syncFromTurns(turns: readonly ConversationTurn[]): number {
90-
tokens = estimateContextTokens(turns);
113+
tokens = overheadTokens + estimateContextTokens(turns);
91114
turnCount = turns.length;
92115
return tokens;
93116
}

src/agent/director.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ class ChatDirectorImpl extends DefaultDirector {
361361
this.onActivateTools = onActivateTools;
362362
this.workflowCoordinator = workflowCoordinator;
363363
this.onTasksChange = onTasksChange;
364-
this.compaction = createCompactionGovernor(requestContinuation);
364+
this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions);
365365
this.modelFamilyPolicy = modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" });
366366
}
367367

@@ -385,6 +385,13 @@ class ChatDirectorImpl extends DefaultDirector {
385385
return [...this.tasks];
386386
}
387387

388+
// The status bar's context meter falls back to this when a provider omits
389+
// or zeroes usage on the latest turn — a local lower-then-corrected bound
390+
// beats displaying a number the provider never actually reported.
391+
getContextEstimate(): { tokens: number; isEstimate: boolean } {
392+
return { tokens: this.compaction.estimatedTokens, isEstimate: this.compaction.usingEstimate };
393+
}
394+
388395
private openTaskIds(): string[] {
389396
return this.tasks
390397
.filter((t) => t.status === "todo" || t.status === "doing")
@@ -796,4 +803,5 @@ export interface ChatDirector extends ReactorDirector {
796803
setGoalGovernor(goal: GoalGovernor | undefined): void;
797804
getGoalGovernor(): GoalGovernor | undefined;
798805
getTasks(): Task[];
806+
getContextEstimate(): { tokens: number; isEstimate: boolean };
799807
}

0 commit comments

Comments
 (0)