From ab31daefdb5fc0c8472019a00bd44d4f2ccbb489 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 08:49:11 -0700 Subject: [PATCH 1/3] test(compaction): red latch tests for post-compact above-threshold re-arm --- src/agent/compaction.test.ts | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 90d585e31..ffcb83a7d 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -936,6 +936,166 @@ describe("compaction governor", () => { }); }); +describe("post-compact above-threshold latch (CL-9006)", () => { + // Wide resume gap: twice the old resume delta — a full warning band of + // growth past the post-compact measurement before a still-over session may + // fold again (danger-anchored; see COMPACTION_WIDE_RESUME_FRACTION). + const wideDelta = resumeDelta * 2; + + test("resume-delta-scale growth while still over threshold does not re-arm", () => { + const governor = createCompactionGovernor(() => undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + + // Post-compact measurement stays over the high watermark: the latch sets. + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + // Growth by the old resume delta must NOT re-arm on its own. + governor.noteInferenceDone( + inferenceDone(overThreshold + resumeDelta), + tenTurns, + ); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).toBeNull(); + // The idle path shares the same latch. + governor.noteIdleTurn(inferenceDone(overThreshold + resumeDelta), [ + { type: "reply", content: "done" }, + ]); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); + + test("a wide resume gap while still over threshold re-arms", () => { + const governor = createCompactionGovernor(() => undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + governor.noteInferenceDone( + inferenceDone(overThreshold + wideDelta), + tenTurns, + ); + const actions = governor.interceptActions( + toolDone(), + inferAction, + capabilities, + ); + expect(actions).not.toBeNull(); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + }); + + test("the consecutive-compact cap holds across tool-call occupancy", () => { + const governor = createCompactionGovernor(() => undefined); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + governor.noteInferenceDone( + inferenceDone(overThreshold + wideDelta), + tenTurns, + ); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + + // Post-compact measurement still over: tool-call occupancy must not reset + // the cap, even past a wide gap. + governor.noteInferenceDone( + inferenceDone(overThreshold + wideDelta), + tenTurns, + ); + governor.noteInferenceDone( + inferenceDoneWithTools(overThreshold + 2 * wideDelta), + tenTurns, + ); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).toBeNull(); + // The idle path shares the same cap. + governor.noteIdleTurn( + inferenceDoneWithTools(overThreshold + 2 * wideDelta), + [{ type: "reply", content: "done" }], + ); + expect( + governor.interceptIdleContinuation(emptyMessage(), capabilities), + ).toBeNull(); + }); + + test("overflow recoveries are bounded across still-over measurements", () => { + const governor = createCompactionGovernor(() => undefined); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer"); + + // Still-over post-compact measurement: no relief, budget stays spent. + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer"); + + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.interceptOverflow(overflowError(), capabilities)).toBeNull(); + }); + + test("an under-threshold fold restores the overflow budget", () => { + const governor = createCompactionGovernor(() => undefined); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + // Fold evidence: usage back under the watermark restores the budget. + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).not.toBeNull(); + }); + + test("auto re-arm after an operator compact uses the identical latch", () => { + const governor = createCompactionGovernor(() => undefined); + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + expect(governor.requestManual("", { inFlight: true })).toBe("armed"); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).not.toBeNull(); + + // Post-operator-compact measurement stays over: small growth must not + // re-arm the automatic path. + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + governor.noteInferenceDone( + inferenceDone(overThreshold + resumeDelta), + tenTurns, + ); + expect( + governor.interceptActions(toolDone(), inferAction, capabilities), + ).toBeNull(); + + // Wide gap re-arms identically to the automatic path. + governor.noteInferenceDone( + inferenceDone(overThreshold + wideDelta), + tenTurns, + ); + const actions = governor.interceptActions( + toolDone(), + inferAction, + capabilities, + ); + expect(actions).not.toBeNull(); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + }); +}); + describe("cache expiry never folds (CL-8914)", () => { const MINUTE_MS = 60_000; From 8011e728e8123364957bba9190dac6bfcd18bf0f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 08:57:23 -0700 Subject: [PATCH 2/3] fix(compaction): latch post-compact re-arm on a wide resume gap --- docs/ARCHITECTURE.md | 4 +- src/agent/compaction.test.ts | 66 +++++++++++++++++-------------- src/agent/compaction.ts | 38 +++++++++--------- src/provider/context-window.ts | 48 ++++++++++++++++++---- tests/unit/context-window.test.ts | 53 +++++++++++++++++++++---- 5 files changed, 143 insertions(+), 66 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0d264445c..078ae8b1b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -189,12 +189,12 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h When a cycle's input tokens cross a threshold, the director compacts the inference-facing history (the full run is always retained in the context store). The threshold is **model-aware** — roughly 60% of the active model's real context window — so small-window models compact early enough to avoid provider context-overflow while large-window models do not compact prematurely. The compacted prefix is **append-only across passes**: the existing compacted user turn stays byte-identical; new folds become later summary turns with a harness-inserted assistant spacer (identified by reserved `model: "harness"`, plus a visible sentinel; persisted `[compaction]` tokens without a producer still freeze) between them so the prompt head can remain in the provider KV cache. Model-emitted copies of the spacer are not frozen. Spacer-only model replies are incomplete: ChatDirector nudges, then falls through loop-protection, workflow-idle, and open-task rails rather than empty-settling with work still open. The governor covers five cases: -- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After a compact that remains over the high watermark, the governor uses **growth hysteresis** (wait for usage to grow by ~10% of the window) instead of re-arming on every cycle; dropping under 60% is not required. +- **Threshold at a tool pause** — Once over threshold, the follow-up `infer` after a tool batch is swapped for a `compact` cycle, and inference resumes via a host continuation message. After a compact that remains over the high watermark, the governor latches: it does not re-arm on growth alone until usage climbs a wide resume gap (~20% of the window, a full warning band) past the post-compact measurement, or until a summarizing fold gets usage back at or under the threshold. A consecutive-compact cap holds across tool-call occupancy as a second rail. - **Idle (end-of-turn)** — An interactive turn can end with a reply and then sit idle with no tool batch to intercept; the governor requests a continuation at that pause and compacts when it arrives. An operator message that races the continuation still compacts first, then re-enters inference to answer it. - **Idle recompress past the provider TTL** — Cache expiry does not compact. Stored turns stay. When settings set `anthropicCachePrompt`, and the saved Anthropic-protocol cache write is at least 5 minutes old, the pre-inference context transform stubs old tool-result bodies on the outgoing prompt only. `turns.jsonl` is not rewritten. The setting defaults off. - **Operator `/compact`** — A first-class slash command folds now without waiting for the occupancy governor. Optional trailing instructions go to the summarizer (empty uses the default structured fold) and are stored on the compact record so later auto-folds still see them. Director rebuild (`/model`, interrupt, resume) restores those instructions from the latest compact record's parameters. An in-flight turn uses the same pair-safe compact-then-continue hop as threshold compact; an idle compact shows the fold and does not start a new turn. -- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. Overflow ignores hysteresis for the compact itself. +- **Overflow recovery** — A `context_overflow` inference error would otherwise become a terminal error reply; the governor compacts and retries instead, bounded so a history the compactor cannot shrink does not loop forever. The overflow compact itself always fires, but the recovery counts against the same latch: only an under-threshold fold restores the budget, and the proactive path stays subject to the latched re-arm rule. The compaction control flow is shaped by a reactor invariant: a `compact` action runs in its own cycle (it cannot be paired with `infer`), and **the reactor delivers no event after a compact cycle**. A director that simply emitted `compact` in place of the follow-up `infer` would leave the loop idle forever — the cause of an earlier stall. Instead the governor pairs `compact` with an emit action (`custom.compaction.continue`), and the host answers that emission by delivering a content-less inbound message (`buildCompactionContinuationMessage()`) through the serial operation queue, generation-guarded like every other deliver. A hop superseded because interrupt already bumped generation and enqueued a rebuild is re-queued onto the replacement agent rather than consumed on the outgoing liveAgent. That message adds no turn (`createInboundTurn` returns `null` for empty content) but re-enters the loop, where the director issues the follow-up `infer` against the freshly truncated history. Each emission is answered at most once (a replayed duplicate of an already-answered emission is ignored), and an unsolicited continuation with neither resume flag set answers `wait` rather than burning a billable inference. The legacy `requestContinuation` closure survives only on the sub-agent path, which delivers directly with no host emit hop; idle arming keeps the two channels exclusive (closure fires and the arming flag stays false, or no closure and the flag reports the arming) so a caller honoring both cannot double-deliver. diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index ffcb83a7d..d444aea5f 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -14,7 +14,7 @@ import { stickyExtraInstructionsFromRecords, } from "./compaction.js"; import { - compactionResumeDeltaFor, + compactionWideResumeDeltaFor, compactionThresholdFor, } from "../provider/context-window.js"; import { @@ -147,7 +147,7 @@ function overflowError(): ReactorInboundEvent { } const overThreshold = compactionThresholdFor("m") + 1; -const resumeDelta = compactionResumeDeltaFor("m"); +const wideDelta = compactionWideResumeDeltaFor("m"); const inferAction: ReactorAction[] = [{ type: "infer" }]; const tenTurns = turnsOfLength(10, 1); const threeTurns = turnsOfLength(3, 1); @@ -634,15 +634,15 @@ describe("compaction governor", () => { governor.interceptActions(toolDone(), inferAction, capabilities), ).not.toBeNull(); - // Post-compact snapshot is still over high; growth hysteresis must hold - // the next arm until usage grows by resumeDelta. + // Post-compact snapshot is still over high; the latch must hold the next + // arm until usage climbs a wide resume gap past the snapshot. governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); }); - test("re-arms after usage grows by the resume delta past the last compact", () => { + test("re-arms after usage grows by the wide resume gap past the last compact", () => { const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); expect( @@ -655,7 +655,7 @@ describe("compaction governor", () => { ).toBeNull(); governor.noteInferenceDone( - inferenceDone(overThreshold + resumeDelta), + inferenceDone(overThreshold + wideDelta), tenTurns, ); const actions = governor.interceptActions( @@ -667,7 +667,7 @@ describe("compaction governor", () => { expect(actions?.some((a) => a.type === "compact")).toBe(true); }); - test("clears hysteresis once usage drops under the high watermark", () => { + test("clears the latch once usage drops under the high watermark", () => { const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); expect( @@ -695,7 +695,7 @@ describe("compaction governor", () => { expect(actions?.some((a) => a.type === "compact")).toBe(true); }); - test("overflow still compact while hysteresis blocks the proactive path", () => { + test("overflow still compacts while the latch blocks the proactive path", () => { const governor = createCompactionGovernor(() => undefined); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); expect( @@ -712,7 +712,7 @@ describe("compaction governor", () => { expect(actions?.some((a) => a.type === "compact")).toBe(true); }); - test("consecutive threshold and idle compacts are bounded until occupancy", () => { + test("consecutive threshold and idle compacts stay bounded across tool-call occupancy", () => { const governor = createCompactionGovernor(() => undefined); const echo = LEGACY_COMPACT_SPACER_TEXT; governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns); @@ -722,42 +722,47 @@ describe("compaction governor", () => { governor.noteInferenceDone(inferenceDone(overThreshold, echo), tenTurns); governor.noteInferenceDone( - inferenceDone(overThreshold + resumeDelta, echo), + inferenceDone(overThreshold + wideDelta, echo), tenTurns, ); expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).not.toBeNull(); + // Still over after two folds: the cap holds on both rails even as usage + // keeps climbing past wide gaps ... governor.noteInferenceDone( - inferenceDone(overThreshold + resumeDelta, echo), + inferenceDone(overThreshold + wideDelta, echo), tenTurns, ); governor.noteInferenceDone( - inferenceDone(overThreshold + 2 * resumeDelta, echo), + inferenceDone(overThreshold + 2 * wideDelta, echo), tenTurns, ); expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); - governor.noteIdleTurn( - inferenceDone(overThreshold + 2 * resumeDelta, echo), - [{ type: "reply", content: "done" }], - ); + governor.noteIdleTurn(inferenceDone(overThreshold + 2 * wideDelta, echo), [ + { type: "reply", content: "done" }, + ]); expect( governor.interceptIdleContinuation(emptyMessage(), capabilities), ).toBeNull(); + // ... and tool-call occupancy does not reopen either rail. governor.noteInferenceDone( - inferenceDone(overThreshold + 3 * resumeDelta, "real work"), + inferenceDoneWithTools(overThreshold + 3 * wideDelta), tenTurns, ); expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); + // Fold evidence restores the rails: under the watermark, then a fresh + // crossing arms immediately with no gap required. + governor.noteInferenceDone(inferenceDone(1000, "real work"), tenTurns); governor.noteInferenceDone( - inferenceDoneWithTools(overThreshold + 4 * resumeDelta), + inferenceDone(overThreshold, "real work"), tenTurns, ); expect( @@ -937,10 +942,9 @@ describe("compaction governor", () => { }); describe("post-compact above-threshold latch (CL-9006)", () => { - // Wide resume gap: twice the old resume delta — a full warning band of - // growth past the post-compact measurement before a still-over session may - // fold again (danger-anchored; see COMPACTION_WIDE_RESUME_FRACTION). - const wideDelta = resumeDelta * 2; + // Half the wide gap: growth that used to re-arm under the old resume delta + // must no longer re-arm on its own. + const smallGrowth = Math.floor(wideDelta / 2); test("resume-delta-scale growth while still over threshold does not re-arm", () => { const governor = createCompactionGovernor(() => undefined); @@ -951,16 +955,16 @@ describe("post-compact above-threshold latch (CL-9006)", () => { // Post-compact measurement stays over the high watermark: the latch sets. governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); - // Growth by the old resume delta must NOT re-arm on its own. + // Growth by half the wide gap must NOT re-arm on its own. governor.noteInferenceDone( - inferenceDone(overThreshold + resumeDelta), + inferenceDone(overThreshold + smallGrowth), tenTurns, ); expect( governor.interceptActions(toolDone(), inferAction, capabilities), ).toBeNull(); // The idle path shares the same latch. - governor.noteIdleTurn(inferenceDone(overThreshold + resumeDelta), [ + governor.noteIdleTurn(inferenceDone(overThreshold + smallGrowth), [ { type: "reply", content: "done" }, ]); expect( @@ -1043,7 +1047,9 @@ describe("post-compact above-threshold latch (CL-9006)", () => { expect(governor.resumeAfterCompact(emptyMessage())).toBe("infer"); governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); - expect(governor.interceptOverflow(overflowError(), capabilities)).toBeNull(); + expect( + governor.interceptOverflow(overflowError(), capabilities), + ).toBeNull(); }); test("an under-threshold fold restores the overflow budget", () => { @@ -1074,7 +1080,7 @@ describe("post-compact above-threshold latch (CL-9006)", () => { // re-arm the automatic path. governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); governor.noteInferenceDone( - inferenceDone(overThreshold + resumeDelta), + inferenceDone(overThreshold + smallGrowth), tenTurns, ); expect( @@ -1205,10 +1211,10 @@ describe("cache expiry never folds (CL-8914)", () => { ).toBeNull(); }); - test("the hysteresis gap does not fold on cache expiry", () => { + test("the latched gap does not fold on cache expiry", () => { // After a threshold compact, a post-compact infer at the same usage - // clears `pending` via growth hysteresis — the exact re-entry where the - // removed TTL path used to fire. + // clears `pending` via the above-threshold latch — the exact re-entry + // where the removed TTL path used to fire. let nowMs = 70_000_000; const governor = createCompactionGovernor( () => undefined, diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 90bf14ee2..d3951ac5f 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -7,9 +7,10 @@ import type { ToolDefinition, } from "@intx/types/runtime"; import { - compactionResumeDeltaFor, compactionThresholdFor, contextTokensFromUsage, + hasWideResumeGap, + isAtOrUnderCompactThreshold, } from "../provider/context-window.js"; import { COMPACTOR_KEEP_RECENT_TURNS, @@ -32,10 +33,12 @@ const COMPACTOR_NAME = "pruning-compactor"; const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS); const MAX_OVERFLOW_RECOVERIES = 2; // Last-ditch bound on compact→infer→compact when the post-compact infer never -// occupies the loop. Reset on tool-call occupancy or when a post-compact -// measurement lands at or under the high watermark (that infer is not itself -// a compact). Do not reset merely because assistant text ≠ spacer. Overflow -// recoveries (above) reset on any successful inference.done instead. +// gets under the high watermark. Counts consecutive threshold compacts with no +// under-watermark relief between them — tool-call occupancy does not reset it, +// or every few tool messages would re-enable the loop. Cleared only when a +// measurement lands at or under the high watermark. Overflow recoveries +// (above) share that same under-watermark reset instead of clearing on any +// successful inference.done. const MAX_CONSECUTIVE_THRESHOLD_COMPACTS = 2; // A compact action runs in its own reactor cycle, after which the reactor @@ -163,10 +166,11 @@ export function createCompactionGovernor( let usingEstimate = false; let lastModel: string | undefined; let turnCount = 0; - // Growth hysteresis after a compact that remained over the high watermark: - // snapshot the post-compact infer's usage, then do not re-arm until usage - // grows by resumeDelta. Cleared once usage drops back to or under high. - // Overflow recovery ignores this and arms regardless. + // Latch after a compact that remained over the high watermark: snapshot the + // post-compact infer's usage, then do not re-arm on growth alone — only a + // wide resume gap past the snapshot (hasWideResumeGap) or usage back at or + // under the threshold clears it. Every compact sets this latch (threshold, + // operator, and overflow alike); only fold evidence moves past it. let tokensAtLastCompact: number | undefined; let awaitingPostCompactMeasurement = false; @@ -192,10 +196,7 @@ export function createCompactionGovernor( const high = compactionThresholdFor(lastModel); if (contextTokens <= high) return false; if (tokensAtLastCompact !== undefined) { - return ( - contextTokens >= - tokensAtLastCompact + compactionResumeDeltaFor(lastModel) - ); + return hasWideResumeGap(tokensAtLastCompact, contextTokens, lastModel); } return true; } @@ -255,10 +256,6 @@ export function createCompactionGovernor( event: Extract, turns: readonly ConversationTurn[], ): void { - overflowRecoveries = 0; - if (event.turn.content.some((block) => block.type === "tool_call")) { - consecutiveThresholdCompacts = 0; - } syncFromTurns(turns); lastModel = event.source?.model; const reportedTokens = contextTokensFromUsage(event.usage); @@ -270,9 +267,14 @@ export function createCompactionGovernor( tokensAtLastCompact = contextTokens; awaitingPostCompactMeasurement = false; } - if (contextTokens <= compactionThresholdFor(lastModel)) { + // Fold evidence: usage back at or under the threshold clears the latch + // and restores both rails (consecutive threshold compacts, overflow + // recoveries). Nothing else resets them — neither tool-call occupancy nor + // a still-over measurement — or compact→infer→compact would loop forever. + if (isAtOrUnderCompactThreshold(contextTokens, lastModel)) { tokensAtLastCompact = undefined; consecutiveThresholdCompacts = 0; + overflowRecoveries = 0; } // 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 diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index f5ab76be5..e7dd72823 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -156,16 +156,20 @@ export function contextWindowFor(model: string): number { // warning threshold so the color shift matches when compaction starts. export const COMPACTION_WINDOW_FRACTION = 0.6; -// After a compact that remains over the high watermark, the governor waits for -// usage to grow by this fraction of the window before re-arming. Growth -// hysteresis, not a low watermark: dropping under 60% is not required. -export const COMPACTION_RESUME_FRACTION = 0.1; - // Status-bar meter turns danger at this fraction of the window — past // compaction and approaching hard overflow at 1.0. Inclusive integer bands // keep 80 in warning and start danger at 81. export const CONTEXT_METER_DANGER_FRACTION = 0.8; +// Wide resume gap after a compact that stayed over the high watermark: the +// governor does not re-arm on growth alone until usage climbs this fraction +// of the window past the post-compact measurement. Anchored to the meter +// bands — danger (0.8) minus threshold (0.6) — so a still-over session must +// climb a full warning band before another fold instead of looping a compact +// on every small growth step. Re-arming sooner requires fold evidence: usage +// back at or under the threshold (isAtOrUnderCompactThreshold). +export const COMPACTION_WIDE_RESUME_FRACTION = 0.2; + export type ContextMeterBand = "quiet" | "warning" | "danger"; /** @@ -187,9 +191,37 @@ export function compactionThresholdFor(model: string | undefined): number { return Math.floor(window * COMPACTION_WINDOW_FRACTION); } -/** Tokens of growth past the last post-compact measurement before re-arming. */ -export function compactionResumeDeltaFor(model: string | undefined): number { +/** Tokens of growth past the last post-compact measurement before a + * still-over session may re-arm. */ +export function compactionWideResumeDeltaFor( + model: string | undefined, +): number { const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW; - return Math.floor(window * COMPACTION_RESUME_FRACTION); + return Math.floor(window * COMPACTION_WIDE_RESUME_FRACTION); +} + +/** Low-watermark clear for the post-compact latch: usage back at or under the + * compaction threshold is fold evidence — the summarizing fold got under — so + * the governor re-arms on the next crossing with no gap required. */ +export function isAtOrUnderCompactThreshold( + contextTokens: number, + model: string | undefined, +): boolean { + return contextTokens <= compactionThresholdFor(model); +} + +/** Shared re-arm rule for a session latched above the threshold after a + * compact (automatic, operator, or overflow recovery): growth alone never + * re-arms — only a wide resume gap past the post-compact measurement does. + * The proactive threshold path and the overflow path route through this single + * predicate; the governor owns clearing the latch underneath it. */ +export function hasWideResumeGap( + postCompactTokens: number, + contextTokens: number, + model: string | undefined, +): boolean { + return ( + contextTokens >= postCompactTokens + compactionWideResumeDeltaFor(model) + ); } diff --git a/tests/unit/context-window.test.ts b/tests/unit/context-window.test.ts index 3a02d5f69..7498c3352 100644 --- a/tests/unit/context-window.test.ts +++ b/tests/unit/context-window.test.ts @@ -3,11 +3,13 @@ import type { TokenUsage } from "@intx/types/runtime"; import { contextWindowFor, compactionThresholdFor, - compactionResumeDeltaFor, + compactionWideResumeDeltaFor, + hasWideResumeGap, + isAtOrUnderCompactThreshold, contextTokensFromUsage, contextMeterBand, COMPACTION_WINDOW_FRACTION, - COMPACTION_RESUME_FRACTION, + COMPACTION_WIDE_RESUME_FRACTION, CONTEXT_METER_DANGER_FRACTION, setModelContextWindows, setProviderContextWindowOverrides, @@ -72,19 +74,54 @@ describe("compactionThresholdFor", () => { }); }); -describe("compactionResumeDeltaFor", () => { - test("is 10 percent of the model window", () => { - expect(COMPACTION_RESUME_FRACTION).toBe(0.1); - expect(compactionResumeDeltaFor("claude-sonnet-4-6")).toBe(20_000); +describe("compactionWideResumeDeltaFor", () => { + test("is a full warning band of the model window, danger-anchored", () => { + expect(COMPACTION_WIDE_RESUME_FRACTION).toBe(0.2); + expect(compactionWideResumeDeltaFor("claude-sonnet-4-6")).toBe(40_000); }); test("uses models.dev window when available", () => { setModelContextWindows({ "small-model": 32_000 }); - expect(compactionResumeDeltaFor("small-model")).toBe(3_200); + expect(compactionWideResumeDeltaFor("small-model")).toBe(6_400); }); test("falls back to the default window when the model is unknown", () => { - expect(compactionResumeDeltaFor(undefined)).toBe(12_800); + expect(compactionWideResumeDeltaFor(undefined)).toBe(25_600); + }); +}); + +describe("hasWideResumeGap", () => { + test("growth below the wide gap does not re-arm", () => { + const postCompact = compactionThresholdFor("m") + 1; + expect( + hasWideResumeGap( + postCompact, + postCompact + compactionWideResumeDeltaFor("m") - 1, + "m", + ), + ).toBe(false); + }); + + test("a wide gap past the post-compact measurement re-arms", () => { + const postCompact = compactionThresholdFor("m") + 1; + expect( + hasWideResumeGap( + postCompact, + postCompact + compactionWideResumeDeltaFor("m"), + "m", + ), + ).toBe(true); + }); +}); + +describe("isAtOrUnderCompactThreshold", () => { + test("the threshold itself counts as fold evidence", () => { + expect(isAtOrUnderCompactThreshold(compactionThresholdFor("m"), "m")).toBe( + true, + ); + expect( + isAtOrUnderCompactThreshold(compactionThresholdFor("m") + 1, "m"), + ).toBe(false); }); }); From 169d11cb0d004a94a8d9e383aa8b00e65c890d83 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 19:44:46 -0700 Subject: [PATCH 3/3] docs(context-window): correct hasWideResumeGap comment (proactive path only) --- src/provider/context-window.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index e7dd72823..4e589ff69 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -214,8 +214,9 @@ export function isAtOrUnderCompactThreshold( /** Shared re-arm rule for a session latched above the threshold after a * compact (automatic, operator, or overflow recovery): growth alone never * re-arms — only a wide resume gap past the post-compact measurement does. - * The proactive threshold path and the overflow path route through this single - * predicate; the governor owns clearing the latch underneath it. */ + * The proactive threshold path routes through this single predicate; the + * overflow path shares the reset rule (under-threshold folds restore the + * recovery budget) but fires on overflow errors regardless of this latch. */ export function hasWideResumeGap( postCompactTokens: number, contextTokens: number,