From 739e7e252778a216b0c652b83c43c0834895ec0a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:41:01 -0700 Subject: [PATCH 1/4] Fold system-prompt and tool-schema overhead into the context estimate The system prompt and active tool schemas ride on every request the same way turns do, but the local context estimate only ever summed turn content, so it undercounted by whatever the harness's own framing cost. --- src/agent/compaction.ts | 20 +++++++++++------- src/agent/context-estimate.test.ts | 26 ++++++++++++++++++++++- src/agent/context-estimate.ts | 33 +++++++++++++++++++++++++----- src/agent/director.ts | 2 +- src/subagent/nudge-director.ts | 2 +- 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index ee5291dd3..893445741 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -3,9 +3,10 @@ import type { ReactorAction, ReactorCapabilities, ReactorInboundEvent, + ToolDefinition, } from "@intx/types/runtime"; import { compactionThresholdFor } from "../provider/context-window.js"; -import { createContextEstimate } from "./context-estimate.js"; +import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js"; const COMPACTOR_NAME = "pruning-compactor"; const MIN_TURNS_TO_COMPACT = 6; @@ -20,17 +21,22 @@ const MAX_OVERFLOW_RECOVERIES = 2; // would be worse than growing the context. export type CompactionGovernor = ReturnType; -export function createCompactionGovernor(requestContinuation?: () => void) { +export function createCompactionGovernor( + requestContinuation?: () => void, + systemPrompt = "", + toolDefinitions: readonly ToolDefinition[] = [], +) { let pending = false; let idlePending = false; let postCompactInfer = false; let overflowRecoveries = 0; - // Running local estimate of the turns we send. Providers that omit usage or - // report zero leave the proactive path blind; the estimate fills that gap. - // When the provider reports real usage we prefer it so a coarse local count - // cannot thrash against a trustworthy signal. - const estimate = createContextEstimate(); + // Running local estimate of the turns we send, plus the fixed system-prompt + // and tool-schema overhead every request carries. Providers that omit usage + // or report zero leave the proactive path blind; the estimate fills that + // gap. When the provider reports real usage we prefer it so a coarse local + // count cannot thrash against a trustworthy signal. + const estimate = createContextEstimate(estimateOverheadTokens(systemPrompt, toolDefinitions)); // Re-sync after turn appends, tool results, and compaction rewrites. Callers // pass the full turn list so the estimate stays accurate without incremental diff --git a/src/agent/context-estimate.test.ts b/src/agent/context-estimate.test.ts index 3337c6f93..1c5b2ddef 100644 --- a/src/agent/context-estimate.test.ts +++ b/src/agent/context-estimate.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test"; -import type { ContentBlock, ConversationTurn, MediaSource } from "@intx/types/runtime"; +import type { ContentBlock, ConversationTurn, MediaSource, ToolDefinition } from "@intx/types/runtime"; import { createContextEstimate, estimateContentBlockTokens, estimateContextTokens, estimateMediaSourceTokens, + estimateOverheadTokens, estimateTokensFromChars, } from "./context-estimate.js"; @@ -85,7 +86,30 @@ describe("estimateContextTokens", () => { }); }); +describe("estimateOverheadTokens", () => { + test("counts the system prompt and every tool's name, description, and schema", () => { + const systemPrompt = "x".repeat(40); + const tools: ToolDefinition[] = [ + { name: "run_shell", description: "y".repeat(20), inputSchema: { command: "string" } }, + ]; + const expectedChars = + 40 + "run_shell".length + 20 + JSON.stringify({ command: "string" }).length; + expect(estimateOverheadTokens(systemPrompt, tools)).toBe(estimateTokensFromChars(expectedChars)); + }); + + test("is zero for an empty prompt and no tools", () => { + expect(estimateOverheadTokens("", [])).toBe(0); + }); +}); + describe("createContextEstimate", () => { + test("folds a fixed overhead into every sync", () => { + const estimate = createContextEstimate(100); + expect(estimate.tokens).toBe(100); + expect(estimate.syncFromTurns([textTurn("xxxx")])).toBe(101); + expect(estimate.tokens).toBe(101); + }); + test("re-syncs from the full turn list after each append", () => { const estimate = createContextEstimate(); expect(estimate.tokens).toBe(0); diff --git a/src/agent/context-estimate.ts b/src/agent/context-estimate.ts index a6d1dc2f2..919e323f7 100644 --- a/src/agent/context-estimate.ts +++ b/src/agent/context-estimate.ts @@ -5,7 +5,12 @@ // tool payloads, images) so proactive compaction still has a signal. This is // a lower bound: system prompt, tool schemas, and framing are not counted. -import type { ContentBlock, ConversationTurn, MediaSource } from "@intx/types/runtime"; +import type { + ContentBlock, + ConversationTurn, + MediaSource, + ToolDefinition, +} from "@intx/types/runtime"; const CHARS_PER_TOKEN = 4; @@ -77,17 +82,35 @@ export function estimateContextTokens(turns: readonly ConversationTurn[]): numbe return total; } +// The system prompt and tool schemas ride on every request the same way turns +// do, but they never appear in `turns` — they're framing the harness supplies +// out of band. Without this, the estimate undercounts by whatever AGENTS.md +// and the active tool roster cost, which is often tens of thousands of tokens +// before a single turn is sent. +export function estimateOverheadTokens( + systemPrompt: string, + toolDefinitions: readonly ToolDefinition[], +): number { + let chars = systemPrompt.length; + for (const tool of toolDefinitions) { + chars += tool.name.length + tool.description.length + JSON.stringify(tool.inputSchema).length; + } + 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. +// 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. export type ContextEstimate = ReturnType; -export function createContextEstimate() { - let tokens = 0; +export function createContextEstimate(overheadTokens = 0) { + let tokens = overheadTokens; let turnCount = 0; function syncFromTurns(turns: readonly ConversationTurn[]): number { - tokens = estimateContextTokens(turns); + tokens = overheadTokens + estimateContextTokens(turns); turnCount = turns.length; return tokens; } diff --git a/src/agent/director.ts b/src/agent/director.ts index 80edc7469..3c01ec379 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -361,7 +361,7 @@ class ChatDirectorImpl extends DefaultDirector { this.onActivateTools = onActivateTools; this.workflowCoordinator = workflowCoordinator; this.onTasksChange = onTasksChange; - this.compaction = createCompactionGovernor(requestContinuation); + this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions); this.modelFamilyPolicy = modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" }); } diff --git a/src/subagent/nudge-director.ts b/src/subagent/nudge-director.ts index 02d5025df..dc233fe93 100644 --- a/src/subagent/nudge-director.ts +++ b/src/subagent/nudge-director.ts @@ -109,7 +109,7 @@ export class SubAgentDirector extends DefaultDirector { now: () => number = Date.now, ) { super(systemPrompt, toolDefinitions, {}); - this.compaction = createCompactionGovernor(requestContinuation); + this.compaction = createCompactionGovernor(requestContinuation, systemPrompt, toolDefinitions); this.maxTurns = maxTurns; this.repeatLimit = repeatLimit; this.stallTimeoutMs = stallTimeoutMs; From a816d7a2ee37c8c789ca75a5a33662a1db869323 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:43:05 -0700 Subject: [PATCH 2/4] Fall back to the local estimate on zero-usage provider turns Compaction arming read only reported input tokens, so a provider that omits or zeroes usage pinned the estimate at 0 and never armed, even as real occupancy grew. Cache reads and writes ride on the context window the same way input does, so both now route through one shared token-counting function instead of being hand-picked per call site. A tool result produced by a turn's own tool batch also arrives after that turn's arming decision was made; when the decision came from the local estimate (usage was missing), the governor now re-derives it against the live estimate on the next tool.done instead of waiting for the following inference.done. The arming rule itself is unchanged: it still requires more turns than createPruningCompactor's own no-op floor, since arming below that floor cannot compact anything. --- src/agent/compaction.test.ts | 26 ++++++++++++++ src/agent/compaction.ts | 56 +++++++++++++++++++++++++------ src/agent/director.ts | 8 +++++ src/provider/context-window.ts | 14 ++++++++ tests/unit/context-window.test.ts | 19 +++++++++++ 5 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 0290dd8a6..1cb3815b6 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -255,4 +255,30 @@ describe("compaction governor", () => { expect(governor.interceptActions(inferenceDone(overThreshold), inferAction, capabilities)).toBeNull(); expect(governor.interceptActions(toolDone(), [{ type: "reply", content: "x" }], capabilities)).toBeNull(); }); + + test("stays inert below the minimum-turn floor no matter how far over threshold", () => { + // Two turns is well under MIN_TURNS_TO_COMPACT. createPruningCompactor + // no-ops at the same floor (see session/compactor.ts), so arming here + // would spend a reactor cycle that cannot shrink anything. + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(overThreshold * 10), turnsOfLength(2, 1)); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + }); + + test("arms on tool.done from a live estimate even when the last snapshot was under threshold", () => { + // Usage is omitted (pending is derived from the local estimate, which + // starts small and stays false), but the tool result that follows is + // itself large enough to cross the ordinary threshold before the next + // inference.done ever runs. + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDoneWithoutUsage(), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + + const overThresholdChars = (compactionThresholdFor("m") + 1) * 4; + governor.syncFromTurns(turnsOfLength(10, Math.ceil(overThresholdChars / 10))); + + const actions = governor.interceptActions(toolDone(), inferAction, capabilities); + expect(actions).not.toBeNull(); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + }); }); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 893445741..4a5d299c3 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -5,7 +5,7 @@ import type { ReactorInboundEvent, ToolDefinition, } from "@intx/types/runtime"; -import { compactionThresholdFor } from "../provider/context-window.js"; +import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js"; import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js"; const COMPACTOR_NAME = "pruning-compactor"; @@ -30,6 +30,15 @@ export function createCompactionGovernor( let idlePending = false; let postCompactInfer = false; let overflowRecoveries = 0; + // Set whenever the arming decision fell back to the local estimate because + // the provider omitted usage or reported zero, so callers rendering a meter + // can flag the number as approximate instead of implying provider-grade + // precision. + let usingEstimate = false; + // Model of the last inference.done turn, kept for live re-checks between + // inference cycles (see interceptActions) where the event carries no model. + let lastModel: string | undefined; + let turnCount = 0; // Running local estimate of the turns we send, plus the fixed system-prompt // and tool-schema overhead every request carries. Providers that omit usage @@ -42,9 +51,18 @@ export function createCompactionGovernor( // pass the full turn list so the estimate stays accurate without incremental // add/subtract bookkeeping. function syncFromTurns(turns: readonly ConversationTurn[]): number { + turnCount = turns.length; return estimate.syncFromTurns(turns); } + // `createPruningCompactor` (session/compactor.ts) is the only layer that + // knows whether a history is actually shrinkable — it no-ops below its own + // keepRecentTurns floor. MIN_TURNS_TO_COMPACT mirrors that floor so the + // governor never arms a compaction the compactor is guaranteed to no-op. + function isOverThreshold(contextTokens: number): boolean { + return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT; + } + function noteInferenceDone( event: Extract, turns: readonly ConversationTurn[], @@ -52,25 +70,37 @@ export function createCompactionGovernor( overflowRecoveries = 0; if (requestContinuation === undefined) return; syncFromTurns(turns); - const reportedTokens = event.usage?.input ?? 0; - const contextTokens = reportedTokens > 0 ? reportedTokens : estimate.tokens; - // Assign, don't OR: an under-threshold follow-up must disarm a sticky pending - // left from an earlier over-threshold turn (e.g. after the provider reports - // real usage that lands below the threshold). - pending = - contextTokens > compactionThresholdFor(event.source?.model) && - turns.length > MIN_TURNS_TO_COMPACT; + lastModel = event.source?.model; + const reportedTokens = contextTokensFromUsage(event.usage); + usingEstimate = reportedTokens <= 0; + const contextTokens = usingEstimate ? estimate.tokens : reportedTokens; + // Assign, don't OR: an under-threshold follow-up must disarm a sticky + // pending left from an earlier over-threshold turn (e.g. after the + // provider reports real usage that lands below the threshold). + pending = isOverThreshold(contextTokens); } // Compaction waits for the natural pause between a tool batch finishing and // the follow-up infer: the infer is dropped from the action set, the compact // cycle runs, and the continuation message re-enters inference. + // + // `pending` reflects the snapshot as of the last inference.done, which + // predates any tool result produced by that turn's own tool batch. When the + // provider is reporting real usage, that snapshot is authoritative and + // `pending` alone is trusted (there is no fresher provider number to check + // against until the next inference.done). But when usage was omitted or + // zero, `pending` was itself derived from the local estimate — in that case + // a large tool result can push the estimate over threshold before the next + // inference.done ever runs, so this re-derives the same arming rule against + // the live estimate (already re-synced this cycle by the director) instead + // of trusting a `pending` that can be stale by exactly one tool batch. function interceptActions( event: ReactorInboundEvent, actions: ReactorAction[], capabilities: ReactorCapabilities, ): ReactorAction[] | null { - if (!pending || event.type !== "tool.done") return null; + if (event.type !== "tool.done") return null; + if (!pending && !(usingEstimate && isOverThreshold(estimate.tokens))) return null; if (!actions.some((a) => a.type === "infer")) return null; pending = false; postCompactInfer = true; @@ -146,6 +176,12 @@ export function createCompactionGovernor( get estimatedTokens(): number { return estimate.tokens; }, + // True once the provider has omitted or zeroed usage on the current + // turn, so a status-bar meter reading this can mark itself approximate + // rather than silently understating a real number. + get usingEstimate(): boolean { + return usingEstimate; + }, syncFromTurns, noteInferenceDone, noteIdleTurn, diff --git a/src/agent/director.ts b/src/agent/director.ts index 3c01ec379..d4cf90ad1 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -385,6 +385,13 @@ class ChatDirectorImpl extends DefaultDirector { return [...this.tasks]; } + // The status bar's context meter falls back to this when a provider omits + // or zeroes usage on the latest turn — a local lower-then-corrected bound + // beats displaying a number the provider never actually reported. + getContextEstimate(): { tokens: number; isEstimate: boolean } { + return { tokens: this.compaction.estimatedTokens, isEstimate: this.compaction.usingEstimate }; + } + private openTaskIds(): string[] { return this.tasks .filter((t) => t.status === "todo" || t.status === "doing") @@ -796,4 +803,5 @@ export interface ChatDirector extends ReactorDirector { setGoalGovernor(goal: GoalGovernor | undefined): void; getGoalGovernor(): GoalGovernor | undefined; getTasks(): Task[]; + getContextEstimate(): { tokens: number; isEstimate: boolean }; } diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index f5af660ee..935c4776c 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -3,8 +3,22 @@ // models.dev metadata is loaded at startup it takes priority; otherwise we fall // back to conservative per-family floors, and finally a common 128k window. +import type { TokenUsage } from "@intx/types/runtime"; + const DEFAULT_CONTEXT_WINDOW = 128_000; +// The one place "how much context is this turn occupying" gets computed from +// a provider's reported usage. Cache reads and writes still ride on the +// context window (a provider like Anthropic bills and counts them against +// it) even though they are not `input` — omitting them understates occupancy +// for any session using prompt caching. The status-bar meter and the +// compaction governor must both call this rather than hand-picking fields, +// or they silently diverge on what "context size" means. +export function contextTokensFromUsage(usage: TokenUsage | undefined): number { + if (usage === undefined) return 0; + return usage.input + usage.cacheRead + usage.cacheWrite; +} + // Populated at startup from the models.dev pricing cache (limit.context). // Exact model-id match wins over the family heuristics below. let contextWindowRegistry: Record = {}; diff --git a/tests/unit/context-window.test.ts b/tests/unit/context-window.test.ts index 1810b26a0..faf0423f7 100644 --- a/tests/unit/context-window.test.ts +++ b/tests/unit/context-window.test.ts @@ -1,12 +1,18 @@ import { test, expect, describe, afterEach } from "bun:test"; +import type { TokenUsage } from "@intx/types/runtime"; import { contextWindowFor, compactionThresholdFor, + contextTokensFromUsage, COMPACTION_WINDOW_FRACTION, CONTEXT_METER_DANGER_FRACTION, setModelContextWindows, } from "../../src/provider/context-window.js"; +function usage(overrides: Partial): TokenUsage { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0, ...overrides }; +} + afterEach(() => setModelContextWindows(undefined)); describe("contextWindowFor", () => { @@ -39,6 +45,19 @@ describe("compactionThresholdFor", () => { }); }); +describe("contextTokensFromUsage", () => { + test("sums input plus both cache fields, not just input", () => { + // Prompt caching (e.g. Anthropic) bills and counts cache reads/writes + // against the window; a formula that only looks at `input` understates + // occupancy on any session using it. + expect(contextTokensFromUsage(usage({ input: 100, cacheRead: 50, cacheWrite: 25 }))).toBe(175); + }); + + test("is zero for empty usage", () => { + expect(contextTokensFromUsage(usage({}))).toBe(0); + }); +}); + describe("context meter fractions", () => { test("warning aligns with the compaction window fraction", () => { expect(COMPACTION_WINDOW_FRACTION).toBe(0.6); From 8545ccd52bf094b03cc798cc52d974ebbb2ab453 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 21:43:53 -0700 Subject: [PATCH 3/4] Show the context meter as an estimate when it is one The status bar previously read only the last turn's reported input and cache tokens, with no fallback, so a provider that omits usage pinned the meter at a stale or 0% reading. It now trusts the same estimate the compaction governor already computed, including the governor's own decision on whether that number is estimated, rather than re-deriving that decision from a second usage read. The tilde prefix that marks an estimated percentage is written once and reused by both the status bar and the prompt border. Cost accounting's own input-plus-cache sum is replaced with the same shared function so all three consumers agree on what "context size" means. --- src/cost/cost-summary.test.ts | 11 ++++++++++ src/cost/cost-summary.ts | 19 ++++++++++++++---- src/cost/faremeter.ts | 3 ++- src/tui-opentui/prompt-border.test.ts | 29 ++++++++++++++++++++++----- src/tui-opentui/prompt-border.ts | 6 +++++- src/tui-opentui/runner-host.test.ts | 1 + src/tui-opentui/runner-host.ts | 1 + src/tui-opentui/shell.ts | 6 +++++- src/tui/commands/built-in.test.ts | 1 + src/tui/runner.ts | 11 +++++++++- 10 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/cost/cost-summary.test.ts b/src/cost/cost-summary.test.ts index fad205148..879f4fc5c 100644 --- a/src/cost/cost-summary.test.ts +++ b/src/cost/cost-summary.test.ts @@ -15,6 +15,7 @@ const baseInput: CostSummaryInput = { outputTokens: 500, cacheReadTokens: 200, contextTokens: 64_000, + contextIsEstimate: false, }; describe("buildCostSummary", () => { @@ -81,6 +82,11 @@ describe("formatStatusBarSegments", () => { expect(segments.contextLabel).toBe("Ctx --%"); expect(segments.contextPercentUsed).toBeNull(); }); + + it("flags an estimated context percentage with a tilde", () => { + const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true }); + expect(formatStatusBarSegments(summary).contextLabel).toBe("Ctx ~50%"); + }); }); describe("formatCostCommandOutput", () => { @@ -116,4 +122,9 @@ describe("formatCostCommandOutput", () => { const summary = buildCostSummary(baseInput); expect(formatCostCommandOutput(summary)).toContain("Context: 64000/unknown (--%)"); }); + + it("flags an estimated context percentage with a tilde", () => { + const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true }); + expect(formatCostCommandOutput(summary)).toContain("(~50%)"); + }); }); diff --git a/src/cost/cost-summary.ts b/src/cost/cost-summary.ts index 20da355ae..c5d4f413d 100644 --- a/src/cost/cost-summary.ts +++ b/src/cost/cost-summary.ts @@ -17,6 +17,12 @@ export type CostSummaryInput = { outputTokens: number; cacheReadTokens: number; contextTokens: number; + // True when contextTokens came from the local character-count estimate + // because the provider omitted or zeroed usage on the latest turn, rather + // than from provider-reported usage. Lets the display flag the number as + // approximate instead of implying provider-grade precision. The caller + // building this input owns the decision; nothing downstream re-derives it. + contextIsEstimate: boolean; }; export type CostSummary = CostSummaryInput & { @@ -53,8 +59,13 @@ export type StatusBarCostSegments = { contextPercentUsed: number | null; }; -function formatContextPercent(percent: number | null): string { - return percent === null ? "--%" : `${String(percent)}%`; +// "~" flags a locally estimated number so the operator doesn't read it as +// provider-confirmed. The one place this rule is encoded; every renderer of +// a context percentage (status bar, prompt border, /cost output) calls this +// rather than re-deciding the prefix itself. +export function formatContextPercentLabel(percent: number | null, isEstimate: boolean): string { + if (percent === null) return "--%"; + return `${isEstimate ? "~" : ""}${String(percent)}%`; } // Status bar space is tight, so cost is omitted entirely (not shown as $0 or @@ -64,7 +75,7 @@ function formatContextPercent(percent: number | null): string { export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegments { return { ...(summary.costHiddenReason === null ? { costLabel: summary.formattedCost } : {}), - contextLabel: `Ctx ${formatContextPercent(summary.contextPercentUsed)}`, + contextLabel: `Ctx ${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)}`, contextPercentUsed: summary.contextPercentUsed, }; } @@ -84,7 +95,7 @@ export function formatCostCommandOutput(summary: CostSummary): string { ? `Cost: ${summary.formattedCost}` : `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`, `Tokens: ${String(summary.inputTokens)} in / ${String(summary.outputTokens)} out / ${String(summary.cacheReadTokens)} cache-read`, - `Context: ${String(summary.contextTokens)}/${window} (${formatContextPercent(summary.contextPercentUsed)})`, + `Context: ${String(summary.contextTokens)}/${window} (${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)})`, ]; return lines.join("\n"); } diff --git a/src/cost/faremeter.ts b/src/cost/faremeter.ts index 6b3a5a178..671ac13d9 100644 --- a/src/cost/faremeter.ts +++ b/src/cost/faremeter.ts @@ -1,6 +1,7 @@ import type { TokenUsage } from "@intx/types/runtime"; import { lookupModelPricing, type ModelPricing, type PricingCache } from "./pricing-fetcher.js"; +import { contextTokensFromUsage } from "../provider/context-window.js"; export type FaremeterConfig = { inputPricePerToken: number; @@ -60,7 +61,7 @@ export function createFaremeter(config: CreateFaremeterConfig = {}): Faremeter { return { addUsage(usage: TokenUsage): void { const { inputPricePerToken, outputPricePerToken, cacheReadPricePerToken } = pricesFor(); - lastContextSize = usage.input + usage.cacheRead + usage.cacheWrite; + lastContextSize = contextTokensFromUsage(usage); outputTokens += usage.output + usage.thinking; totalCost += usage.input * inputPricePerToken + usage.output * outputPricePerToken + usage.cacheRead * cacheReadPricePerToken; }, diff --git a/src/tui-opentui/prompt-border.test.ts b/src/tui-opentui/prompt-border.test.ts index c56dcb08b..8bb2e8934 100644 --- a/src/tui-opentui/prompt-border.test.ts +++ b/src/tui-opentui/prompt-border.test.ts @@ -155,18 +155,26 @@ describe("composeRule", () => { describe("composeCostContextMeter", () => { test("null when the context window is unknown", () => { - expect(composeCostContextMeter({ contextPercentUsed: null })).toBeNull() + expect(composeCostContextMeter({ contextPercentUsed: null, contextIsEstimate: false })).toBeNull() }) test("carries the percent and cost", () => { - const meter = composeCostContextMeter({ contextPercentUsed: 68, costLabel: "$0.42" }) + const meter = composeCostContextMeter({ + contextPercentUsed: 68, + costLabel: "$0.42", + contextIsEstimate: false, + }) expect(meter).not.toBeNull() expect(meter!.percentLabel).toBe("68%") expect(meter!.costLabel).toBe("$0.42") }) test("drops the cost suffix when told to, keeping the percent", () => { - const meter = composeCostContextMeter({ contextPercentUsed: 68, costLabel: "$0.42" })! + const meter = composeCostContextMeter({ + contextPercentUsed: 68, + costLabel: "$0.42", + contextIsEstimate: false, + })! expect(costContextText(meter, true)).toContain("$0.42") expect(costContextText(meter, false)).not.toContain("$0.42") expect(costContextText(meter, false)).toContain("68%") @@ -174,11 +182,22 @@ describe("composeCostContextMeter", () => { test("turns pressured past the threshold, not before it", () => { const thresholdPercent = CONTEXT_PRESSURE_THRESHOLD * 100 - const below = composeCostContextMeter({ contextPercentUsed: thresholdPercent - 1 })! - const atOrAbove = composeCostContextMeter({ contextPercentUsed: thresholdPercent })! + const below = composeCostContextMeter({ + contextPercentUsed: thresholdPercent - 1, + contextIsEstimate: false, + })! + const atOrAbove = composeCostContextMeter({ + contextPercentUsed: thresholdPercent, + contextIsEstimate: false, + })! expect(below.pressured).toBe(false) expect(atOrAbove.pressured).toBe(true) }) + + test("flags an estimated percent with a tilde", () => { + const meter = composeCostContextMeter({ contextPercentUsed: 68, contextIsEstimate: true })! + expect(meter.percentLabel).toBe("~68%") + }) }) describe("abbreviateHome", () => { diff --git a/src/tui-opentui/prompt-border.ts b/src/tui-opentui/prompt-border.ts index 69c75d033..8407e4ad8 100644 --- a/src/tui-opentui/prompt-border.ts +++ b/src/tui-opentui/prompt-border.ts @@ -14,6 +14,7 @@ import { stringWidth } from "../tui/view/height.js" import { renderRamp } from "./ramp.js" +import { formatContextPercentLabel } from "../cost/cost-summary.js" /** Rounded box drawing, all single-cell. */ export const BORDER = { @@ -229,6 +230,9 @@ export type CostContextInput = { readonly contextPercentUsed: number | null /** Already formatted (e.g. `$0.42`); omitted or empty hides the cost suffix. */ readonly costLabel?: string | null + /** True when `contextPercentUsed` came from the local estimate because the + * provider omitted or zeroed usage, rather than from reported usage. */ + readonly contextIsEstimate: boolean } export type CostContextMeter = { @@ -249,7 +253,7 @@ export function composeCostContextMeter(input: CostContextInput): CostContextMet const percent = Math.max(0, Math.min(100, Math.round(input.contextPercentUsed))) const cost = input.costLabel?.trim() ?? "" return { - percentLabel: `${String(percent)}%`, + percentLabel: formatContextPercentLabel(percent, input.contextIsEstimate), costLabel: cost.length > 0 ? cost : null, pressured: percent / 100 >= CONTEXT_PRESSURE_THRESHOLD, } diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index cd9a4d822..1af430edf 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -31,6 +31,7 @@ function fakeCostSummary(): CostSummary { outputTokens: 50, cacheReadTokens: 0, contextTokens: 1000, + contextIsEstimate: false, costHiddenReason: null, contextWindow: 10000, contextPercentUsed: 10, diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index a05232ef0..e9a99a5c6 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -257,6 +257,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise setPromptCostContext(host.shell, { contextPercentUsed: summary.contextPercentUsed, costLabel: showCost && summary.costHiddenReason === null ? summary.formattedCost : null, + contextIsEstimate: summary.contextIsEstimate, }) } pushCostContext() diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index e603443f6..75b570e5d 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -1519,7 +1519,11 @@ export function setPromptWorkspace( */ export function setPromptCostContext( shell: AppShell, - input: { readonly contextPercentUsed: number | null; readonly costLabel?: string | null }, + input: { + readonly contextPercentUsed: number | null + readonly costLabel?: string | null + readonly contextIsEstimate: boolean + }, ): void { const meter = composeCostContextMeter(input) if (meterEquals(meter, shell.costContext)) return diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index e77d9275d..5c9623320 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -148,6 +148,7 @@ describe("/cost command", () => { outputTokens: 50, cacheReadTokens: 10, contextTokens: 160, + contextIsEstimate: false, }); const result = getCommand("cost")!.handler("", ctx); expect(result.type).toBe("message"); diff --git a/src/tui/runner.ts b/src/tui/runner.ts index ab56aa11e..b2dfe3180 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -94,6 +94,7 @@ import { defaultPricingCachePath } from "../cost/pricing-fetcher.js"; import { getActivePricingCache } from "../cost/cost-visibility.js"; import { createFaremeter, formatCost } from "../cost/faremeter.js"; import { buildCostSummary, type CostSummary } from "../cost/cost-summary.js"; +import { contextTokensFromUsage } from "../provider/context-window.js"; import { advertisedToolNamesForSessionMode, advertisedTools, @@ -1724,6 +1725,13 @@ export async function runTUI(initialConfig: Config): Promise { const faremeter = createFaremeter({ modelId: config.model, pricingCache }); faremeter.addUsage(usage); const totalCost = faremeter.getTotalCost(); + // A provider that omits or zeroes usage would otherwise pin the meter at + // 0% forever; fall back to the director's local estimate (turns plus + // system-prompt/tool-schema overhead). The governor already decided + // whether it's estimating when it computed this turn's arming — trust + // that decision rather than re-deriving it from a second usage read. + const contextEstimate = directorHolder.instance?.getContextEstimate(); + const isEstimate = contextEstimate !== undefined && contextEstimate.isEstimate; return buildCostSummary({ modelId: config.model, baseURL: config.baseURL, @@ -1733,7 +1741,8 @@ export async function runTUI(initialConfig: Config): Promise { inputTokens: usage.input, outputTokens: usage.output, cacheReadTokens: usage.cacheRead, - contextTokens: lastTurnUsage.input + lastTurnUsage.cacheRead + lastTurnUsage.cacheWrite, + contextTokens: isEstimate ? contextEstimate.tokens : contextTokensFromUsage(lastTurnUsage), + contextIsEstimate: isEstimate, }); }, startWorkflow: (name) => workflowController.start(name), From fc57acc66e0e09cf3033ae2a354093b928ff66d3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 6 Aug 2026 22:16:40 -0700 Subject: [PATCH 4/4] Derive the compaction floor from the compactor's own config MIN_TURNS_TO_COMPACT was an independent literal that happened to match createPruningCompactor's keepRecentTurns, itself duplicated as a third literal in the session and sub-agent compactor registrations. The independent copy was also off by one: the compactor's own no-op condition is keepRecentTurns + 1, not keepRecentTurns, so the governor could arm a compaction at the exact turn count the compactor was guaranteed to no-op on. All three call sites now share one exported constant, and the governor computes its floor with the same function the compactor uses internally. --- src/agent/compaction.test.ts | 54 +++++++++++++++++++++++++++++++-- src/agent/compaction.ts | 12 +++++--- src/context-compactor.test.ts | 25 +++++++++++++++ src/director.test.ts | 18 +++++++++-- src/session/compactor.ts | 15 ++++++++- src/session/runtime-assembly.ts | 5 ++- src/subagent/run.ts | 4 +-- 7 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 1cb3815b6..ce7427c70 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -8,14 +8,25 @@ import type { } from "@intx/types/runtime"; import { createCompactionGovernor } from "./compaction.js"; import { compactionThresholdFor } from "../provider/context-window.js"; +import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js"; const capabilities = { infer: (options?: unknown) => ({ type: "infer", ...(options !== undefined ? { options } : {}) }), compact: (compactor: string, reason: string) => ({ type: "compact", compactor, reason }), } as unknown as ReactorCapabilities; +// Distinct, non-zero cacheRead/cacheWrite so a test asserting on the total +// would fail if compaction.ts ever stopped routing through the shared +// contextTokensFromUsage and summed only `input` again. function usage(input: number): TokenUsage { - return { input, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }; + return { input, output: 0, cacheRead: 3, cacheWrite: 5, thinking: 0 }; +} + +// A provider that truly omits usage reports every field as zero, not just +// `input` — distinct from usage(0), which still carries the fixture's +// non-zero cache values above. +function zeroUsage(): TokenUsage { + return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 }; } function turnsOfLength(count: number, textLength: number): ConversationTurn[] { @@ -39,7 +50,7 @@ function inferenceDoneWithoutUsage(): Extract; } @@ -281,4 +292,43 @@ describe("compaction governor", () => { expect(actions).not.toBeNull(); expect(actions?.some((a) => a.type === "compact")).toBe(true); }); + + test("never arms at the exact turn count createPruningCompactor no-ops on", () => { + // createPruningCompactor's own no-op floor (session/compactor.ts) is + // compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS). Arming at or below it + // would spend a reactor cycle that is guaranteed to shrink nothing. + const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS); + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(floor, 1)); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + }); + + test("arms one turn past the floor createPruningCompactor no-ops on", () => { + const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS); + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(floor + 1, 1)); + const actions = governor.interceptActions(toolDone(), inferAction, capabilities); + expect(actions).not.toBeNull(); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + }); + + test("does not catch a huge tool result mid-cycle when the provider reported real usage", () => { + // Disclosed, accepted gap: the live tool.done re-check only re-derives + // arming from the local estimate when the last inference.done snapshot + // came from that same estimate (usingEstimate). When the provider + // reported real usage under threshold, that snapshot is trusted as + // authoritative until the next inference.done — a huge tool result + // arriving in between is not caught until then, unlike the + // usage-omitted case covered above. + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + + const overThresholdChars = (compactionThresholdFor("m") + 1) * 4; + governor.syncFromTurns(turnsOfLength(10, Math.ceil(overThresholdChars / 10))); + + // Still null: the live estimate is now over threshold, but the last + // arming decision trusted reported usage, so it is not re-checked here. + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + }); }); diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 4a5d299c3..1a3677426 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -6,10 +6,16 @@ import type { ToolDefinition, } from "@intx/types/runtime"; import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js"; +import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js"; import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js"; const COMPACTOR_NAME = "pruning-compactor"; -const MIN_TURNS_TO_COMPACT = 6; +// The exact turn count `createPruningCompactor` (session/compactor.ts) is +// guaranteed to no-op on. Derived from the same keepRecentTurns both real +// registrations (session, sub-agent) use, so this floor cannot silently +// drift from what the compactor will actually do — arming at or below it +// would spend a reactor cycle that shrinks nothing. +const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS); const MAX_OVERFLOW_RECOVERIES = 2; // A compact action runs in its own reactor cycle, after which the reactor @@ -55,10 +61,6 @@ export function createCompactionGovernor( return estimate.syncFromTurns(turns); } - // `createPruningCompactor` (session/compactor.ts) is the only layer that - // knows whether a history is actually shrinkable — it no-ops below its own - // keepRecentTurns floor. MIN_TURNS_TO_COMPACT mirrors that floor so the - // governor never arms a compaction the compactor is guaranteed to no-op. function isOverThreshold(contextTokens: number): boolean { return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT; } diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 4fa213aa0..84149b698 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "bun:test"; import { createPruningCompactor, + compactorNoOpFloor, buildContextEnvelope, formatPlan, classifyTaskBoundary, @@ -46,6 +47,30 @@ describe("createPruningCompactor", () => { expect(result.output).toBe(turns); // Same reference when no compaction needed }); + test("compactorNoOpFloor names the exact turn count apply() no-ops on", async () => { + // The compaction governor (agent/compaction.ts) derives its arming floor + // from this function so it never arms a compaction guaranteed to no-op. + // Anyone changing apply()'s no-op condition without updating + // compactorNoOpFloor accordingly breaks that guarantee silently. + const keepRecentTurns = 3; + const compactor = createPruningCompactor({ keepRecentTurns, summaryMaxChars: 500 }); + const floor = compactorNoOpFloor(keepRecentTurns); + + const atFloor = Array.from({ length: floor }, (_, i) => + makeTurn({ role: i % 2 === 0 ? "user" : "assistant" }), + ); + const pastFloor = Array.from({ length: floor + 1 }, (_, i) => + makeTurn({ role: i % 2 === 0 ? "user" : "assistant" }), + ); + + expect((await compactor.apply(atFloor, mockStrategyCtx)).record.reason).toBe( + "no compaction needed", + ); + expect((await compactor.apply(pastFloor, mockStrategyCtx)).record.reason).not.toBe( + "no compaction needed", + ); + }); + test("compacts old turns and preserves recent ones", async () => { const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 }); const turns: ConversationTurn[] = [ diff --git a/src/director.test.ts b/src/director.test.ts index 335344fb2..fbc85a3c1 100644 --- a/src/director.test.ts +++ b/src/director.test.ts @@ -3,6 +3,7 @@ import { createChatDirector } from "./agent/director.js"; import { createAgentToolset } from "./agent/tools.js"; import { advertisedTools, createActivatedToolTracker } from "./agent/tool-search.js"; import { createPermissionGate } from "./permission/gate.js"; +import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "./session/compactor.js"; import type { SessionMetadata, TaskBoundary } from "./session/compactor.js"; import type { ExtendedInferenceOptions } from "@intx/inference"; import type { ReactorState, ReactorCapabilities, ReactorAction, ReactorInboundEvent } from "@intx/types/runtime"; @@ -275,7 +276,14 @@ describe("chatDirector compaction", () => { const director = createChatDirector("", [], undefined, undefined, undefined, undefined, undefined, undefined, () => { continuations++; }); - const longState = { turns: Array.from({ length: 7 }, () => ({ role: "user", content: [], timestamp: 0 })) } as unknown as ReactorState; + // One turn past createPruningCompactor's own no-op floor (session/compactor.ts), + // so the arming check finds a history actually worth compacting. + const longState = { + turns: Array.from( + { length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 }, + () => ({ role: "user", content: [], timestamp: 0 }), + ), + } as unknown as ReactorState; const replyActions = actionsArray(await director.decide(textInferenceDone(999_999), longState, mockCapabilities)); expect(replyActions.some((a) => a.type === "reply")).toBe(true); @@ -288,7 +296,13 @@ describe("chatDirector compaction", () => { ]); }); - const longState = { turns: Array.from({ length: 7 }, () => ({ role: "user", content: [], timestamp: 0 })) } as unknown as ReactorState; + // One turn past createPruningCompactor's own no-op floor (session/compactor.ts). + const longState = { + turns: Array.from( + { length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 }, + () => ({ role: "user", content: [], timestamp: 0 }), + ), + } as unknown as ReactorState; function overThresholdToolTurn(): ReactorInboundEvent { return { diff --git a/src/session/compactor.ts b/src/session/compactor.ts index e85ddb4bc..33885b9cd 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -217,6 +217,19 @@ const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { stripResultContent: false, }; +// Recent turns kept verbatim by both real pruning-compactor registrations +// (the main session and sub-agents). Exported so callers that need to know +// in advance whether a compaction would do anything — the compaction +// governor's arming floor — derive it from this value instead of carrying +// an independent literal that can silently drift out of sync. +export const COMPACTOR_KEEP_RECENT_TURNS = 6; + +// `apply` below no-ops at or below this turn count: keeping `keepRecentTurns` +// turns plus at least one more is what makes pruning worth doing at all. +export function compactorNoOpFloor(keepRecentTurns: number): number { + return keepRecentTurns + 1; +} + // Minimum anchor score for a turn to be pulled forward past the summary boundary. const ANCHOR_SCORE_THRESHOLD = 5; @@ -425,7 +438,7 @@ export function createPruningCompactor( // leave the inference-facing context as soon as they exit the recent window. const aged = await ageImagesOutsideRecentWindow(turns, cfg.keepRecentTurns); - if (aged.turns.length <= cfg.keepRecentTurns + 1) { + if (aged.turns.length <= compactorNoOpFloor(cfg.keepRecentTurns)) { return { output: aged.turns, record: { diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 08b5f9579..91eaf99f3 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -38,7 +38,7 @@ import { import type { Approval, GrantScope } from "../permission/types.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import type { SubAgentProvider } from "../subagent/index.js"; -import { createPruningCompactor } from "./compactor.js"; +import { COMPACTOR_KEEP_RECENT_TURNS, createPruningCompactor } from "./compactor.js"; // --------------------------------------------------------------------------- // 1. Sub-agent provider literal @@ -238,7 +238,6 @@ export function buildSessionSourcesFromConfig( // 6. Pruning-compactor config // --------------------------------------------------------------------------- -const SESSION_COMPACTOR_KEEP_RECENT = 6; const SESSION_COMPACTOR_SUMMARY_MAX_CHARS = 2500; export type SessionPruningCompactorArgs = { @@ -251,7 +250,7 @@ export function createSessionPruningCompactor( args: SessionPruningCompactorArgs, ): Compactor { return createPruningCompactor({ - keepRecentTurns: SESSION_COMPACTOR_KEEP_RECENT, + keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS, ...(args.compactionMode !== "pruning" ? { summarize: args.summarize } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index de8d69b8e..ca8994871 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -40,7 +40,7 @@ import { shouldApplyGrokAntiThrash } from "./provider-family.js"; import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js"; import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; -import { createPruningCompactor } from "../session/compactor.js"; +import { COMPACTOR_KEEP_RECENT_TURNS, createPruningCompactor } from "../session/compactor.js"; import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; import { createModelSummarizer } from "../session/summarizer.js"; import { gatherEnvironment } from "../agent/environment.js"; @@ -496,7 +496,7 @@ async function runSubAgentInner(params: RunSubAgentParams): Promise { }), compactors: { "pruning-compactor": createPruningCompactor({ - keepRecentTurns: 6, + keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: 2500, stripResultContent: true, // A structured model summary keeps sub-agent context useful across a