Skip to content

Commit f38586d

Browse files
committed
Feed system-prompt/tool-schema overhead into the context estimate, fall back to it when a provider reports zero usage, and let compaction fire before the turn floor or between inference cycles when a tool result pushes context past the danger threshold
The status-bar meter previously read only provider-reported input+cache tokens with no fallback, so a provider that omits or zeroes usage pinned it at a stale or 0% reading while real occupancy climbed. The local estimator also never counted the system prompt or tool schemas sent on every request, understating even its own fallback. Compaction's minimum-turn floor and its inference.done-only recheck meant a single huge early turn or a large tool result mid-streak could blow past the model's context window before the governor was allowed to act. Also unified the three independent input+cacheRead+cacheWrite computations (compaction, the status-bar meter, and faremeter) behind one function so they can't silently diverge.
1 parent cf3bb84 commit f38586d

16 files changed

Lines changed: 263 additions & 30 deletions

src/agent/compaction.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
TokenUsage,
88
} from "@intx/types/runtime";
99
import { createCompactionGovernor } from "./compaction.js";
10-
import { compactionThresholdFor } from "../provider/context-window.js";
10+
import { compactionThresholdFor, urgentCompactionThresholdFor } from "../provider/context-window.js";
1111

1212
const capabilities = {
1313
infer: (options?: unknown) => ({ type: "infer", ...(options !== undefined ? { options } : {}) }),
@@ -255,4 +255,41 @@ describe("compaction governor", () => {
255255
expect(governor.interceptActions(inferenceDone(overThreshold), inferAction, capabilities)).toBeNull();
256256
expect(governor.interceptActions(toolDone(), [{ type: "reply", content: "x" }], capabilities)).toBeNull();
257257
});
258+
259+
test("bypasses the minimum-turn floor when a single turn is urgently over threshold", () => {
260+
// Two turns is well under MIN_TURNS_TO_COMPACT, but reported usage alone
261+
// is past the urgent threshold — a huge early file read or tool payload.
262+
// Waiting for turn 7 here would risk a provider-side overflow first.
263+
const governor = createCompactionGovernor(() => {});
264+
const urgentTokens = urgentCompactionThresholdFor("m") + 1;
265+
governor.noteInferenceDone(inferenceDone(urgentTokens), turnsOfLength(2, 1));
266+
267+
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
268+
expect(actions).not.toBeNull();
269+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
270+
});
271+
272+
test("still respects the minimum-turn floor when only mildly over threshold", () => {
273+
// Same low turn count, but under the urgent threshold — the ordinary
274+
// floor still applies.
275+
const governor = createCompactionGovernor(() => {});
276+
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(2, 1));
277+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
278+
});
279+
280+
test("arms on tool.done from a live estimate even when the last snapshot was under threshold", () => {
281+
// inference.done reported a small usage (pending stays false), but the
282+
// tool result that follows is itself large enough to blow past the
283+
// urgent threshold before the next inference.done ever runs.
284+
const governor = createCompactionGovernor(() => {});
285+
governor.noteInferenceDone(inferenceDone(10), tenTurns);
286+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
287+
288+
const urgentChars = (urgentCompactionThresholdFor("m") + 1) * 4;
289+
governor.syncFromTurns(turnsOfLength(1, urgentChars));
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+
});
258295
});

src/agent/compaction.ts

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ 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 {
9+
compactionThresholdFor,
10+
contextTokensFromUsage,
11+
urgentCompactionThresholdFor,
12+
} from "../provider/context-window.js";
13+
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
914

1015
const COMPACTOR_NAME = "pruning-compactor";
1116
const MIN_TURNS_TO_COMPACT = 6;
@@ -20,17 +25,30 @@ const MAX_OVERFLOW_RECOVERIES = 2;
2025
// would be worse than growing the context.
2126
export type CompactionGovernor = ReturnType<typeof createCompactionGovernor>;
2227

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

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();
46+
// Running local estimate of the turns we send, plus the fixed system-prompt
47+
// and tool-schema overhead every request carries. Providers that omit usage
48+
// or report zero leave the proactive path blind; the estimate fills that
49+
// gap. When the provider reports real usage we prefer it so a coarse local
50+
// count cannot thrash against a trustworthy signal.
51+
const estimate = createContextEstimate(estimateOverheadTokens(systemPrompt, toolDefinitions));
3452

3553
// Re-sync after turn appends, tool results, and compaction rewrites. Callers
3654
// pass the full turn list so the estimate stays accurate without incremental
@@ -46,25 +64,42 @@ export function createCompactionGovernor(requestContinuation?: () => void) {
4664
overflowRecoveries = 0;
4765
if (requestContinuation === undefined) return;
4866
syncFromTurns(turns);
49-
const reportedTokens = event.usage?.input ?? 0;
50-
const contextTokens = reportedTokens > 0 ? reportedTokens : estimate.tokens;
67+
lastModel = event.source?.model;
68+
const reportedTokens = contextTokensFromUsage(event.usage);
69+
usingEstimate = reportedTokens <= 0;
70+
const contextTokens = usingEstimate ? estimate.tokens : reportedTokens;
5171
// Assign, don't OR: an under-threshold follow-up must disarm a sticky pending
5272
// left from an earlier over-threshold turn (e.g. after the provider reports
5373
// real usage that lands below the threshold).
74+
//
75+
// The turn-count floor exists to skip compacting a history too short to
76+
// meaningfully shrink, but a single early turn (one huge file read or
77+
// tool payload) can blow past the urgent threshold well before the floor
78+
// is met — in that case waiting is worse than compacting a short history,
79+
// so the floor is bypassed.
5480
pending =
55-
contextTokens > compactionThresholdFor(event.source?.model) &&
56-
turns.length > MIN_TURNS_TO_COMPACT;
81+
contextTokens > compactionThresholdFor(lastModel) &&
82+
(turns.length > MIN_TURNS_TO_COMPACT || contextTokens > urgentCompactionThresholdFor(lastModel));
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 results produced by that turn. A large tool result can
91+
// push the live estimate past the urgent threshold before the next
92+
// inference.done ever runs, so this also re-checks the live estimate
93+
// (already re-synced this cycle by the director before calling here)
94+
// rather than trusting a potentially stale `pending`.
6295
function interceptActions(
6396
event: ReactorInboundEvent,
6497
actions: ReactorAction[],
6598
capabilities: ReactorCapabilities,
6699
): ReactorAction[] | null {
67-
if (!pending || event.type !== "tool.done") return null;
100+
if (event.type !== "tool.done") return null;
101+
const urgentNow = estimate.tokens > urgentCompactionThresholdFor(lastModel);
102+
if (!pending && !urgentNow) return null;
68103
if (!actions.some((a) => a.type === "infer")) return null;
69104
pending = false;
70105
postCompactInfer = true;
@@ -140,6 +175,12 @@ export function createCompactionGovernor(requestContinuation?: () => void) {
140175
get estimatedTokens(): number {
141176
return estimate.tokens;
142177
},
178+
// True once the provider has omitted or zeroed usage on the current
179+
// turn, so a status-bar meter reading this can mark itself approximate
180+
// rather than silently understating a real number.
181+
get usingEstimate(): boolean {
182+
return usingEstimate;
183+
},
143184
syncFromTurns,
144185
noteInferenceDone,
145186
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
}

src/cost/cost-summary.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ describe("formatStatusBarSegments", () => {
8181
expect(segments.contextLabel).toBe("Ctx --%");
8282
expect(segments.contextPercentUsed).toBeNull();
8383
});
84+
85+
it("flags an estimated context percentage with a tilde", () => {
86+
const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true });
87+
expect(formatStatusBarSegments(summary).contextLabel).toBe("Ctx ~50%");
88+
});
8489
});
8590

8691
describe("formatCostCommandOutput", () => {
@@ -116,4 +121,9 @@ describe("formatCostCommandOutput", () => {
116121
const summary = buildCostSummary(baseInput);
117122
expect(formatCostCommandOutput(summary)).toContain("Context: 64000/unknown (--%)");
118123
});
124+
125+
it("flags an estimated context percentage with a tilde", () => {
126+
const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true });
127+
expect(formatCostCommandOutput(summary)).toContain("(~50%)");
128+
});
119129
});

src/cost/cost-summary.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export type CostSummaryInput = {
1717
outputTokens: number;
1818
cacheReadTokens: number;
1919
contextTokens: number;
20+
// True when contextTokens came from the local character-count estimate
21+
// because the provider omitted or zeroed usage on the latest turn, rather
22+
// than from provider-reported usage. Lets the display flag the number as
23+
// approximate instead of implying provider-grade precision.
24+
contextIsEstimate?: boolean;
2025
};
2126

2227
export type CostSummary = CostSummaryInput & {
@@ -53,8 +58,11 @@ export type StatusBarCostSegments = {
5358
contextPercentUsed: number | null;
5459
};
5560

56-
function formatContextPercent(percent: number | null): string {
57-
return percent === null ? "--%" : `${String(percent)}%`;
61+
function formatContextPercent(percent: number | null, isEstimate: boolean): string {
62+
if (percent === null) return "--%";
63+
// "~" flags a locally estimated number so the operator doesn't read it as
64+
// provider-confirmed — see contextIsEstimate on CostSummaryInput.
65+
return `${isEstimate ? "~" : ""}${String(percent)}%`;
5866
}
5967

6068
// Status bar space is tight, so cost is omitted entirely (not shown as $0 or
@@ -64,7 +72,7 @@ function formatContextPercent(percent: number | null): string {
6472
export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegments {
6573
return {
6674
...(summary.costHiddenReason === null ? { costLabel: summary.formattedCost } : {}),
67-
contextLabel: `Ctx ${formatContextPercent(summary.contextPercentUsed)}`,
75+
contextLabel: `Ctx ${formatContextPercent(summary.contextPercentUsed, summary.contextIsEstimate ?? false)}`,
6876
contextPercentUsed: summary.contextPercentUsed,
6977
};
7078
}
@@ -84,7 +92,7 @@ export function formatCostCommandOutput(summary: CostSummary): string {
8492
? `Cost: ${summary.formattedCost}`
8593
: `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`,
8694
`Tokens: ${String(summary.inputTokens)} in / ${String(summary.outputTokens)} out / ${String(summary.cacheReadTokens)} cache-read`,
87-
`Context: ${String(summary.contextTokens)}/${window} (${formatContextPercent(summary.contextPercentUsed)})`,
95+
`Context: ${String(summary.contextTokens)}/${window} (${formatContextPercent(summary.contextPercentUsed, summary.contextIsEstimate ?? false)})`,
8896
];
8997
return lines.join("\n");
9098
}

src/cost/faremeter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TokenUsage } from "@intx/types/runtime";
22

33
import { lookupModelPricing, type ModelPricing, type PricingCache } from "./pricing-fetcher.js";
4+
import { contextTokensFromUsage } from "../provider/context-window.js";
45

56
export type FaremeterConfig = {
67
inputPricePerToken: number;
@@ -60,7 +61,7 @@ export function createFaremeter(config: CreateFaremeterConfig = {}): Faremeter {
6061
return {
6162
addUsage(usage: TokenUsage): void {
6263
const { inputPricePerToken, outputPricePerToken, cacheReadPricePerToken } = pricesFor();
63-
lastContextSize = usage.input + usage.cacheRead + usage.cacheWrite;
64+
lastContextSize = contextTokensFromUsage(usage);
6465
outputTokens += usage.output + usage.thinking;
6566
totalCost += usage.input * inputPricePerToken + usage.output * outputPricePerToken + usage.cacheRead * cacheReadPricePerToken;
6667
},

0 commit comments

Comments
 (0)