From 4c36bd236701800733d633f7e59905a4186ab620 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 10:43:39 -0700 Subject: [PATCH 1/3] Keep compacted prompt prefixes byte-stable across passes --- CHANGELOG.md | 7 ++ docs/ARCHITECTURE.md | 6 +- src/agent/compaction.test.ts | 59 +++++++++++++++- src/agent/compaction.ts | 36 +++++++++- src/context-compactor.test.ts | 109 +++++++++++++++++++++++++++++- src/provider/context-window.ts | 11 +++ src/session/compactor.ts | 99 +++++++++++++++++++++++++-- src/session/summarizer.ts | 9 +-- tests/unit/context-window.test.ts | 18 +++++ 9 files changed, 337 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f676aed65..7a6093c40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename closures count against `maxAnchorTurns`. The LLM summary is workflow-aware and skips degenerate assistant text. +- **Prefix-stable summaries and growth hysteresis.** Existing compacted user + turns stay byte-identical across later passes; new folds become later summary + turns with an assistant spacer so the prompt prefix can stay in the KV cache. + After a compact that remains over the high watermark, the governor waits for + usage to grow by 10% of the window before re-arming. Overflow recovery still + compacts immediately. + ### Plugins - **`run_shell` no longer defaults to a 15s timeout.** Omitted timeout arms no diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 729091f96..02a52bd77 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -173,11 +173,11 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h #### Context compaction (the compaction governor) -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 governor covers three cases: +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 an assistant spacer between them so the prompt head can remain in the provider KV cache. The governor covers three 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. +- **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. - **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. -- **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 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. 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, after emitting `compact`, self-delivers a content-less inbound message (a host-supplied `requestContinuation` callback). 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. diff --git a/src/agent/compaction.test.ts b/src/agent/compaction.test.ts index 6ff099b85..588d4376a 100644 --- a/src/agent/compaction.test.ts +++ b/src/agent/compaction.test.ts @@ -7,7 +7,7 @@ import type { TokenUsage, } from "@intx/types/runtime"; import { createCompactionGovernor } from "./compaction.js"; -import { compactionThresholdFor } from "../provider/context-window.js"; +import { compactionResumeDeltaFor, compactionThresholdFor } from "../provider/context-window.js"; import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js"; const capabilities = { @@ -86,6 +86,7 @@ function overflowError(): ReactorInboundEvent { } const overThreshold = compactionThresholdFor("m") + 1; +const resumeDelta = compactionResumeDeltaFor("m"); const inferAction: ReactorAction[] = [{ type: "infer" }]; const tenTurns = turnsOfLength(10, 1); const threeTurns = turnsOfLength(3, 1); @@ -335,4 +336,60 @@ describe("compaction governor", () => { // arming decision trusted reported usage, so it is not re-checked here. expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); }); + + test("does not re-arm after a compact that remains over the high watermark", () => { + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(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. + 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", () => { + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull(); + + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + + governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta), tenTurns); + const actions = governor.interceptActions(toolDone(), inferAction, capabilities); + expect(actions).not.toBeNull(); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + }); + + test("clears hysteresis once usage drops under the high watermark", () => { + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull(); + + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + + governor.noteInferenceDone(inferenceDone(1000), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + + // Next crossing of high arms immediately — no growth delta required. + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + const actions = governor.interceptActions(toolDone(), inferAction, capabilities); + expect(actions).not.toBeNull(); + expect(actions?.some((a) => a.type === "compact")).toBe(true); + }); + + test("overflow still compact while hysteresis blocks the proactive path", () => { + const governor = createCompactionGovernor(() => {}); + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull(); + + governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns); + expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull(); + + const actions = governor.interceptOverflow(overflowError(), 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 d3d2c606b..2cdeff511 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -5,7 +5,11 @@ import type { ReactorInboundEvent, ToolDefinition, } from "@intx/types/runtime"; -import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js"; +import { + compactionResumeDeltaFor, + compactionThresholdFor, + contextTokensFromUsage, +} from "../provider/context-window.js"; import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js"; import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js"; import { onTurnBoundary } from "./reactor-events.js"; @@ -46,6 +50,12 @@ export function createCompactionGovernor( // inference cycles (see interceptActions) where the event carries no model. 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. + let tokensAtLastCompact: number | undefined; + let awaitingPostCompactMeasurement = false; // Running local estimate of the turns we send, plus the fixed system-prompt // and tool-schema overhead every request carries. Providers that omit usage @@ -63,7 +73,17 @@ export function createCompactionGovernor( } function isOverThreshold(contextTokens: number): boolean { - return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT; + if (turnCount <= MIN_TURNS_TO_COMPACT) return false; + const high = compactionThresholdFor(lastModel); + if (contextTokens <= high) return false; + if (tokensAtLastCompact !== undefined) { + return contextTokens >= tokensAtLastCompact + compactionResumeDeltaFor(lastModel); + } + return true; + } + + function noteCompactIssued(): void { + awaitingPostCompactMeasurement = true; } function noteInferenceDone( @@ -77,6 +97,15 @@ export function createCompactionGovernor( const reportedTokens = contextTokensFromUsage(event.usage); usingEstimate = reportedTokens <= 0; const contextTokens = usingEstimate ? estimate.tokens : reportedTokens; + // Snapshot on the first inference.done after a compact (the post-compact + // infer), not at intercept time — intercept has no fresh usage. + if (awaitingPostCompactMeasurement) { + tokensAtLastCompact = contextTokens; + awaitingPostCompactMeasurement = false; + } + if (contextTokens <= compactionThresholdFor(lastModel)) { + tokensAtLastCompact = undefined; + } // 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). @@ -107,6 +136,7 @@ export function createCompactionGovernor( if (!actions.some((a) => a.type === "infer")) return null; pending = false; postCompactInfer = true; + noteCompactIssued(); requestContinuation?.(); return [ ...actions.filter((a) => a.type !== "infer"), @@ -143,6 +173,7 @@ export function createCompactionGovernor( postCompactInfer = true; requestContinuation?.(); } + noteCompactIssued(); return [capabilities.compact(COMPACTOR_NAME, "context-threshold")]; } @@ -161,6 +192,7 @@ export function createCompactionGovernor( overflowRecoveries++; pending = false; postCompactInfer = true; + noteCompactIssued(); requestContinuation(); return [capabilities.compact(COMPACTOR_NAME, "context-overflow")]; } diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 40b9ea413..e9fe2437d 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -6,9 +6,18 @@ import { formatPlan, classifyTaskBoundary, buildLLMTurnSummary, + buildTurnSummary, + COMPACTED_PREFIX, + COMPACT_SPACER_TEXT, type SessionMetadata, } from "./session/compactor.js"; -import type { ConversationTurn, ReactorState, StrategyContext } from "@intx/types/runtime"; +import { createModelSummarizer } from "./session/summarizer.js"; +import type { + ConversationTurn, + InferenceSource, + ReactorState, + StrategyContext, +} from "@intx/types/runtime"; const mockStrategyCtx: StrategyContext = { state: {} as ReactorState, @@ -577,6 +586,104 @@ describe("createPruningCompactor — summarize receives the workflow context (CL }); }); +describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { + function firstText(turn: ConversationTurn): string { + const block = turn.content.find((b) => b.type === "text"); + return block !== undefined && block.type === "text" ? block.text : ""; + } + + function compactedTurns(output: ConversationTurn[]): ConversationTurn[] { + return output.filter((t) => firstText(t).startsWith(COMPACTED_PREFIX)); + } + + function grow(base: ConversationTurn[], count: number, label: string): ConversationTurn[] { + const extra: ConversationTurn[] = []; + for (let i = 0; i < count; i++) { + extra.push( + makeTurn({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: `${label} ${i}` }], + }), + ); + } + return [...base, ...extra]; + } + + test("second apply leaves output[0] bytes identical and appends a later summary", async () => { + const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 }); + const turns = grow([], 16, "round1"); + const output1 = (await compactor.apply(turns, mockStrategyCtx)).output; + expect(firstText(output1[0]!)).toContain(COMPACTED_PREFIX); + + const output2 = (await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)).output; + + expect(firstText(output2[0]!)).toBe(firstText(output1[0]!)); + expect(output2[0]).toBe(output1[0]); + const summaries = compactedTurns(output2); + expect(summaries.length).toBeGreaterThanOrEqual(2); + expect(output2.indexOf(summaries[1]!)).toBeGreaterThan(0); + expect(hasConsecutiveSameRole(output2)).toBe(false); + expect( + output2.some((t) => t.role === "assistant" && firstText(t) === COMPACT_SPACER_TEXT), + ).toBe(true); + }); + + test("empty-fold keep-set returns the input unchanged", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 1, + maxAnchorTurns: 8, + summaryMaxChars: 500, + }); + const turns: ConversationTurn[] = [ + makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }), + makeTurn({ + role: "assistant", + content: [ + { type: "tool_call", id: "c1", name: "edit_file", arguments: { path: "src/a.ts" } }, + ], + }), + makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(result.output).toBe(turns); + expect(result.record.reason).toBe("no compaction needed"); + }); + + test("failing then succeeding summarizer does not rewrite output[0]", async () => { + const source: InferenceSource = { + id: "test", + provider: "openai", + model: "test-model", + baseURL: "http://localhost:1", + apiKey: "k", + }; + let calls = 0; + const summarize = createModelSummarizer({ + getSource: () => source, + complete: async () => { + calls++; + if (calls === 1) throw new Error("model unreachable"); + return "UNIQUE_SUCCESS_SUMMARY"; + }, + }); + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 500, + summarize, + }); + const turns = grow([], 16, "fail"); + const output1 = (await compactor.apply(turns, mockStrategyCtx)).output; + expect(firstText(output1[0]!)).toContain("Turns compacted:"); + expect(firstText(output1[0]!)).not.toContain("UNIQUE_SUCCESS_SUMMARY"); + expect(firstText(output1[0]!)).not.toContain("Model summary unavailable"); + + const output2 = (await compactor.apply(grow(output1, 16, "ok"), mockStrategyCtx)).output; + expect(firstText(output2[0]!)).toBe(firstText(output1[0]!)); + expect(allText(output2)).toContain("UNIQUE_SUCCESS_SUMMARY"); + expect(hasConsecutiveSameRole(output2)).toBe(false); + }); +}); + describe("buildContextEnvelope", () => { test("includes active task label", () => { const result = buildContextEnvelope({ diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index 93b4c6b80..4584200e3 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -78,6 +78,11 @@ 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. @@ -102,3 +107,9 @@ export function compactionThresholdFor(model: string | undefined): number { const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW; 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 { + const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW; + return Math.floor(window * COMPACTION_RESUME_FRACTION); +} diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 81a5dc31e..4100ac8e5 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -212,6 +212,16 @@ export interface CompactorConfig { // an independent literal that can silently drift out of sync. export const COMPACTOR_KEEP_RECENT_TURNS = 6; +// Marker on every folded-history user turn. Subsequent compact cycles treat a +// leading run of these (plus the assistant spacers between them) as a frozen +// prefix whose object identity and bytes must not change — rewriting the head +// would invalidate the entire prompt-cache KV for that prefix. +export const COMPACTED_PREFIX = "[Compacted prior context]"; + +// Inserted between a frozen prefix that ends on a user summary and a newly +// appended user summary so the assembled history stays role-alternating. +export const COMPACT_SPACER_TEXT = "[compaction]"; + const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = { keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS, summaryMaxChars: 2000, @@ -682,6 +692,45 @@ function coalesceAdjacentTextTurns(turns: ConversationTurn[]): ConversationTurn[ return out; } +function firstTextBlock(turn: ConversationTurn): string | undefined { + for (const block of turn.content) { + if (block.type === "text") return block.text; + } + return undefined; +} + +function isCompactedSummaryTurn(turn: ConversationTurn): boolean { + if (turn.role !== "user") return false; + const text = firstTextBlock(turn); + return text !== undefined && text.startsWith(COMPACTED_PREFIX); +} + +function isCompactSpacerTurn(turn: ConversationTurn): boolean { + if (turn.role !== "assistant") return false; + return firstTextBlock(turn) === COMPACT_SPACER_TEXT; +} + +// Leading run of prior summaries plus the spacers between them. Walks from +// index 0: a compacted user turn, then an immediately following assistant +// spacer when present, then repeat. The newest summary has no trailing spacer +// until the next compact inserts one. +function frozenPrefixLength(turns: readonly ConversationTurn[]): number { + let i = 0; + while (i < turns.length && isCompactedSummaryTurn(turns[i]!)) { + i++; + if (i < turns.length && isCompactSpacerTurn(turns[i]!)) i++; + } + return i; +} + +function compactSpacerTurn(timestamp: number): ConversationTurn { + return { + role: "assistant", + content: [{ type: "text", text: COMPACT_SPACER_TEXT }], + timestamp, + }; +} + export function createPruningCompactor(config: Partial = {}): Compactor { const cfg = { ...DEFAULT_COMPACTOR_CONFIG, ...config }; @@ -692,13 +741,23 @@ export function createPruningCompactor(config: Partial = {}): C turns: ConversationTurn[], _ctx: StrategyContext, ): Promise> { + // Frozen prefix: prior compacted summaries (and spacers) keep their + // object references. Image aging, stubbing, and coalescing run only on + // the live suffix so the prompt-cache KV for the prefix stays valid. + // When there is no prefix, pass `turns` through (not slice(0)) so a + // no-op still returns the same array identity. + const frozenLen = frozenPrefixLength(turns); + const frozen = frozenLen === 0 ? [] : turns.slice(0, frozenLen); + const live = frozenLen === 0 ? turns : turns.slice(frozenLen); + // Eager image aging runs before the compact/no-op branch so base64 pastes // leave the inference-facing context as soon as they exit the recent window. - const aged = await ageImagesOutsideRecentWindow(turns, cfg.keepRecentTurns); + const aged = await ageImagesOutsideRecentWindow(live, cfg.keepRecentTurns); if (aged.turns.length <= compactorNoOpFloor(cfg.keepRecentTurns)) { + const output = frozenLen === 0 ? aged.turns : [...frozen, ...aged.turns]; return { - output: aged.turns, + output, record: { strategy: this.name, version: this.version, @@ -777,6 +836,22 @@ export function createPruningCompactor(config: Partial = {}): C const anchorTurns = sortedAnchorIndices.map((i) => olderTurns[i]!); const summarizedTurns = olderTurns.filter((_, i) => !anchorIndices.has(i)); + // Keep-set covered the whole live suffix: nothing to fold. Leave the + // input (including any frozen prefix) untouched rather than rewriting + // the head with an empty summary. + if (summarizedTurns.length === 0) { + return { + output: turns, + record: { + strategy: this.name, + version: this.version, + parameters: { keepRecentTurns: cfg.keepRecentTurns }, + reason: "no compaction needed", + decisions: { summarizedTurnCount: 0, agedImageCount: aged.agedImageCount }, + }, + }; + } + // Path-dedup only among turns that survive. Supersession over the full // transcript would hollow a kept older read when the newer re-read is only // in the summary (CL-4374 review follow-up). @@ -795,7 +870,7 @@ export function createPruningCompactor(config: Partial = {}): C // content keeps it in the conversation on every provider. const summaryTurn: ConversationTurn = { role: "user", - content: [{ type: "text", text: `[Compacted prior context]\n${summary}` }], + content: [{ type: "text", text: `${COMPACTED_PREFIX}\n${summary}` }], timestamp: olderTurns[olderTurns.length - 1]?.timestamp ?? Date.now(), }; @@ -807,12 +882,28 @@ export function createPruningCompactor(config: Partial = {}): C // turns keep live base64 so a just-pasted screenshot still reaches the model. const process = (t: ConversationTurn): ConversationTurn => stubSupersededReads(t, supersededReads, callIndex); - const output = coalesceAdjacentTextTurns([ + const liveOutput = coalesceAdjacentTextTurns([ summaryTurn, ...anchorTurns.map(process), ...recentTurns.map(process), ]); + // First compact (no frozen prefix): today's shape — summary leads. + // Later cycles append an additional summary after the frozen prefix + // and never splice into output[0]. + let output: ConversationTurn[]; + if (frozenLen === 0) { + output = liveOutput; + } else { + const lastFrozen = frozen[frozen.length - 1]!; + const firstLive = liveOutput[0]; + const spacer = + lastFrozen.role === "user" && firstLive?.role === "user" + ? [compactSpacerTurn(summaryTurn.timestamp)] + : []; + output = [...frozen, ...spacer, ...liveOutput]; + } + return { output, record: { diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 3a4f803f3..4b4a049a2 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -201,10 +201,7 @@ export function createModelSummarizer( const maxChars = options.maxChars ?? 4000; return async (turns, ctx) => { - // The marker tells the model (and anyone reading a transcript) that the - // compacted region is a lossy stats stub, not a real handoff summary. - const fallback = (reason: string): string => - `[Model summary unavailable (${reason}); deterministic fallback]\n${buildTurnSummary(turns, maxChars)}`; + const fallback = (): string => buildTurnSummary(turns, maxChars); try { const promptTurns: ConversationTurn[] = [ { @@ -222,14 +219,14 @@ export function createModelSummarizer( const text = await complete(promptTurns, options.getSource(), signal); if (text.length === 0) { logger.warn("compaction summary call returned empty text; using deterministic fallback"); - return fallback("empty model output"); + return fallback(); } return text.length > maxChars ? text.slice(0, maxChars) : text; } catch (error) { logger.warn("compaction summary call failed; using deterministic fallback: {error}", { error: error instanceof Error ? error.message : String(error), }); - return fallback("summary call failed"); + return fallback(); } }; } diff --git a/tests/unit/context-window.test.ts b/tests/unit/context-window.test.ts index 0aa5be5b2..746d47bbf 100644 --- a/tests/unit/context-window.test.ts +++ b/tests/unit/context-window.test.ts @@ -3,9 +3,11 @@ import type { TokenUsage } from "@intx/types/runtime"; import { contextWindowFor, compactionThresholdFor, + compactionResumeDeltaFor, contextTokensFromUsage, contextMeterBand, COMPACTION_WINDOW_FRACTION, + COMPACTION_RESUME_FRACTION, CONTEXT_METER_DANGER_FRACTION, setModelContextWindows, } from "../../src/provider/context-window.js"; @@ -46,6 +48,22 @@ 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); + }); + + test("uses models.dev window when available", () => { + setModelContextWindows({ "small-model": 32_000 }); + expect(compactionResumeDeltaFor("small-model")).toBe(3_200); + }); + + test("falls back to the default window when the model is unknown", () => { + expect(compactionResumeDeltaFor(undefined)).toBe(12_800); + }); +}); + describe("contextTokensFromUsage", () => { test("sums input plus both cache fields, not just input", () => { // Prompt caching (e.g. Anthropic) bills and counts cache reads/writes From 5bb8283552bcba0d5d0966f66eb08352734de05a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 11:08:32 -0700 Subject: [PATCH 2/3] Keep the failed-summary marker as the only LLM fallback Prefix-stable compaction still uses one extractive shape on LLM failure, but dropping the CL-6906 marker made a lossy stub look like a real handoff summary. Restore the marker and keep the first summary turn byte-identical across later successful passes. --- src/context-compactor.test.ts | 3 +-- src/session/summarizer.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index e9fe2437d..0e1f0a327 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -6,7 +6,6 @@ import { formatPlan, classifyTaskBoundary, buildLLMTurnSummary, - buildTurnSummary, COMPACTED_PREFIX, COMPACT_SPACER_TEXT, type SessionMetadata, @@ -675,7 +674,7 @@ describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => { const output1 = (await compactor.apply(turns, mockStrategyCtx)).output; expect(firstText(output1[0]!)).toContain("Turns compacted:"); expect(firstText(output1[0]!)).not.toContain("UNIQUE_SUCCESS_SUMMARY"); - expect(firstText(output1[0]!)).not.toContain("Model summary unavailable"); + expect(firstText(output1[0]!)).toContain("Model summary unavailable"); const output2 = (await compactor.apply(grow(output1, 16, "ok"), mockStrategyCtx)).output; expect(firstText(output2[0]!)).toBe(firstText(output1[0]!)); diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 4b4a049a2..3c095bcb9 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -201,8 +201,12 @@ export function createModelSummarizer( const maxChars = options.maxChars ?? 4000; return async (turns, ctx) => { - const fallback = (): string => buildTurnSummary(turns, maxChars); + // The marker tells the model (and anyone reading a transcript) that the + // compacted region is a lossy stats stub, not a real handoff summary. + const fallback = (reason: string): string => + `[Model summary unavailable (${reason}); deterministic fallback]\n${buildTurnSummary(turns, maxChars)}`; try { + const promptTurns: ConversationTurn[] = [ { role: "system", @@ -219,14 +223,14 @@ export function createModelSummarizer( const text = await complete(promptTurns, options.getSource(), signal); if (text.length === 0) { logger.warn("compaction summary call returned empty text; using deterministic fallback"); - return fallback(); + return fallback("empty model output"); } return text.length > maxChars ? text.slice(0, maxChars) : text; } catch (error) { logger.warn("compaction summary call failed; using deterministic fallback: {error}", { error: error instanceof Error ? error.message : String(error), }); - return fallback(); + return fallback("summary call failed"); } }; } From 522faba690765d9c5c297ddab99d92cd731b0e2f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 11:14:22 -0700 Subject: [PATCH 3/3] Format the summarizer fallback for prettier --- src/session/summarizer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 3c095bcb9..3a4f803f3 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -206,7 +206,6 @@ export function createModelSummarizer( const fallback = (reason: string): string => `[Model summary unavailable (${reason}); deterministic fallback]\n${buildTurnSummary(turns, maxChars)}`; try { - const promptTurns: ConversationTurn[] = [ { role: "system",