diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 0290dd8a6..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; } @@ -255,4 +266,69 @@ 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); + }); + + 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 ee5291dd3..1a3677426 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -3,12 +3,19 @@ import type { ReactorAction, ReactorCapabilities, ReactorInboundEvent, + ToolDefinition, } from "@intx/types/runtime"; -import { compactionThresholdFor } from "../provider/context-window.js"; -import { createContextEstimate } from "./context-estimate.js"; +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 @@ -20,25 +27,44 @@ 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; + // 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. 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 // add/subtract bookkeeping. function syncFromTurns(turns: readonly ConversationTurn[]): number { + turnCount = turns.length; return estimate.syncFromTurns(turns); } + function isOverThreshold(contextTokens: number): boolean { + return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT; + } + function noteInferenceDone( event: Extract, turns: readonly ConversationTurn[], @@ -46,25 +72,37 @@ export function createCompactionGovernor(requestContinuation?: () => void) { 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; @@ -140,6 +178,12 @@ export function createCompactionGovernor(requestContinuation?: () => void) { 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/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..d4cf90ad1 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: "" }); } @@ -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/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/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/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/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/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/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; 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 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), 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);