Skip to content

Commit a4f7d86

Browse files
Merge pull request #553 from corbitsdev/cl-6914-every-compaction-rewrites-the-prompt-head-100-kv-cache-loss
Keep compacted prompt prefixes byte-stable across passes
2 parents 6ea4085 + 2349ece commit a4f7d86

8 files changed

Lines changed: 333 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
8181
closures count against `maxAnchorTurns`. The LLM summary is workflow-aware
8282
and skips degenerate assistant text.
8383

84+
- **Prefix-stable summaries and growth hysteresis.** Existing compacted user
85+
turns stay byte-identical across later passes; new folds become later summary
86+
turns with an assistant spacer so the prompt prefix can stay in the KV cache.
87+
After a compact that remains over the high watermark, the governor waits for
88+
usage to grow by 10% of the window before re-arming. Overflow recovery still
89+
compacts immediately.
90+
8491
### Plugins
8592

8693
- **`run_shell` no longer defaults to a 15s timeout.** Omitted timeout arms no

docs/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,11 @@ The agent maintains an optional **`manage_tasks`** list (create/update via the h
175175

176176
#### Context compaction (the compaction governor)
177177

178-
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:
178+
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:
179179

180-
- **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.
180+
- **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.
181181
- **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.
182-
- **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.
182+
- **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.
183183

184184
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.
185185

src/agent/compaction.test.ts

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

1313
const capabilities = {
@@ -86,6 +86,7 @@ function overflowError(): ReactorInboundEvent {
8686
}
8787

8888
const overThreshold = compactionThresholdFor("m") + 1;
89+
const resumeDelta = compactionResumeDeltaFor("m");
8990
const inferAction: ReactorAction[] = [{ type: "infer" }];
9091
const tenTurns = turnsOfLength(10, 1);
9192
const threeTurns = turnsOfLength(3, 1);
@@ -335,4 +336,60 @@ describe("compaction governor", () => {
335336
// arming decision trusted reported usage, so it is not re-checked here.
336337
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
337338
});
339+
340+
test("does not re-arm after a compact that remains over the high watermark", () => {
341+
const governor = createCompactionGovernor(() => {});
342+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
343+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
344+
345+
// Post-compact snapshot is still over high; growth hysteresis must hold
346+
// the next arm until usage grows by resumeDelta.
347+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
348+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
349+
});
350+
351+
test("re-arms after usage grows by the resume delta past the last compact", () => {
352+
const governor = createCompactionGovernor(() => {});
353+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
354+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
355+
356+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
357+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
358+
359+
governor.noteInferenceDone(inferenceDone(overThreshold + resumeDelta), tenTurns);
360+
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
361+
expect(actions).not.toBeNull();
362+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
363+
});
364+
365+
test("clears hysteresis once usage drops under the high watermark", () => {
366+
const governor = createCompactionGovernor(() => {});
367+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
368+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
369+
370+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
371+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
372+
373+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
374+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
375+
376+
// Next crossing of high arms immediately — no growth delta required.
377+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
378+
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
379+
expect(actions).not.toBeNull();
380+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
381+
});
382+
383+
test("overflow still compact while hysteresis blocks the proactive path", () => {
384+
const governor = createCompactionGovernor(() => {});
385+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
386+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).not.toBeNull();
387+
388+
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
389+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
390+
391+
const actions = governor.interceptOverflow(overflowError(), capabilities);
392+
expect(actions).not.toBeNull();
393+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
394+
});
338395
});

src/agent/compaction.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import type {
55
ReactorInboundEvent,
66
ToolDefinition,
77
} from "@intx/types/runtime";
8-
import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js";
8+
import {
9+
compactionResumeDeltaFor,
10+
compactionThresholdFor,
11+
contextTokensFromUsage,
12+
} from "../provider/context-window.js";
913
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
1014
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
1115
import { onTurnBoundary } from "./reactor-events.js";
@@ -46,6 +50,12 @@ export function createCompactionGovernor(
4650
// inference cycles (see interceptActions) where the event carries no model.
4751
let lastModel: string | undefined;
4852
let turnCount = 0;
53+
// Growth hysteresis after a compact that remained over the high watermark:
54+
// snapshot the post-compact infer's usage, then do not re-arm until usage
55+
// grows by resumeDelta. Cleared once usage drops back to or under high.
56+
// Overflow recovery ignores this and arms regardless.
57+
let tokensAtLastCompact: number | undefined;
58+
let awaitingPostCompactMeasurement = false;
4959

5060
// Running local estimate of the turns we send, plus the fixed system-prompt
5161
// and tool-schema overhead every request carries. Providers that omit usage
@@ -63,7 +73,17 @@ export function createCompactionGovernor(
6373
}
6474

6575
function isOverThreshold(contextTokens: number): boolean {
66-
return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT;
76+
if (turnCount <= MIN_TURNS_TO_COMPACT) return false;
77+
const high = compactionThresholdFor(lastModel);
78+
if (contextTokens <= high) return false;
79+
if (tokensAtLastCompact !== undefined) {
80+
return contextTokens >= tokensAtLastCompact + compactionResumeDeltaFor(lastModel);
81+
}
82+
return true;
83+
}
84+
85+
function noteCompactIssued(): void {
86+
awaitingPostCompactMeasurement = true;
6787
}
6888

6989
function noteInferenceDone(
@@ -77,6 +97,15 @@ export function createCompactionGovernor(
7797
const reportedTokens = contextTokensFromUsage(event.usage);
7898
usingEstimate = reportedTokens <= 0;
7999
const contextTokens = usingEstimate ? estimate.tokens : reportedTokens;
100+
// Snapshot on the first inference.done after a compact (the post-compact
101+
// infer), not at intercept time — intercept has no fresh usage.
102+
if (awaitingPostCompactMeasurement) {
103+
tokensAtLastCompact = contextTokens;
104+
awaitingPostCompactMeasurement = false;
105+
}
106+
if (contextTokens <= compactionThresholdFor(lastModel)) {
107+
tokensAtLastCompact = undefined;
108+
}
80109
// Assign, don't OR: an under-threshold follow-up must disarm a sticky
81110
// pending left from an earlier over-threshold turn (e.g. after the
82111
// provider reports real usage that lands below the threshold).
@@ -107,6 +136,7 @@ export function createCompactionGovernor(
107136
if (!actions.some((a) => a.type === "infer")) return null;
108137
pending = false;
109138
postCompactInfer = true;
139+
noteCompactIssued();
110140
requestContinuation?.();
111141
return [
112142
...actions.filter((a) => a.type !== "infer"),
@@ -143,6 +173,7 @@ export function createCompactionGovernor(
143173
postCompactInfer = true;
144174
requestContinuation?.();
145175
}
176+
noteCompactIssued();
146177
return [capabilities.compact(COMPACTOR_NAME, "context-threshold")];
147178
}
148179

@@ -161,6 +192,7 @@ export function createCompactionGovernor(
161192
overflowRecoveries++;
162193
pending = false;
163194
postCompactInfer = true;
195+
noteCompactIssued();
164196
requestContinuation();
165197
return [capabilities.compact(COMPACTOR_NAME, "context-overflow")];
166198
}

src/context-compactor.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@ import {
66
formatPlan,
77
classifyTaskBoundary,
88
buildLLMTurnSummary,
9+
COMPACTED_PREFIX,
10+
COMPACT_SPACER_TEXT,
911
type SessionMetadata,
1012
} from "./session/compactor.js";
11-
import type { ConversationTurn, ReactorState, StrategyContext } from "@intx/types/runtime";
13+
import { createModelSummarizer } from "./session/summarizer.js";
14+
import type {
15+
ConversationTurn,
16+
InferenceSource,
17+
ReactorState,
18+
StrategyContext,
19+
} from "@intx/types/runtime";
1220

1321
const mockStrategyCtx: StrategyContext = {
1422
state: {} as ReactorState,
@@ -577,6 +585,104 @@ describe("createPruningCompactor — summarize receives the workflow context (CL
577585
});
578586
});
579587

588+
describe("createPruningCompactor — prefix-stable summaries (CL-6914)", () => {
589+
function firstText(turn: ConversationTurn): string {
590+
const block = turn.content.find((b) => b.type === "text");
591+
return block !== undefined && block.type === "text" ? block.text : "";
592+
}
593+
594+
function compactedTurns(output: ConversationTurn[]): ConversationTurn[] {
595+
return output.filter((t) => firstText(t).startsWith(COMPACTED_PREFIX));
596+
}
597+
598+
function grow(base: ConversationTurn[], count: number, label: string): ConversationTurn[] {
599+
const extra: ConversationTurn[] = [];
600+
for (let i = 0; i < count; i++) {
601+
extra.push(
602+
makeTurn({
603+
role: i % 2 === 0 ? "user" : "assistant",
604+
content: [{ type: "text", text: `${label} ${i}` }],
605+
}),
606+
);
607+
}
608+
return [...base, ...extra];
609+
}
610+
611+
test("second apply leaves output[0] bytes identical and appends a later summary", async () => {
612+
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
613+
const turns = grow([], 16, "round1");
614+
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
615+
expect(firstText(output1[0]!)).toContain(COMPACTED_PREFIX);
616+
617+
const output2 = (await compactor.apply(grow(output1, 16, "round2"), mockStrategyCtx)).output;
618+
619+
expect(firstText(output2[0]!)).toBe(firstText(output1[0]!));
620+
expect(output2[0]).toBe(output1[0]);
621+
const summaries = compactedTurns(output2);
622+
expect(summaries.length).toBeGreaterThanOrEqual(2);
623+
expect(output2.indexOf(summaries[1]!)).toBeGreaterThan(0);
624+
expect(hasConsecutiveSameRole(output2)).toBe(false);
625+
expect(
626+
output2.some((t) => t.role === "assistant" && firstText(t) === COMPACT_SPACER_TEXT),
627+
).toBe(true);
628+
});
629+
630+
test("empty-fold keep-set returns the input unchanged", async () => {
631+
const compactor = createPruningCompactor({
632+
keepRecentTurns: 1,
633+
maxAnchorTurns: 8,
634+
summaryMaxChars: 500,
635+
});
636+
const turns: ConversationTurn[] = [
637+
makeTurn({ role: "user", content: [{ type: "text", text: "the initiating task" }] }),
638+
makeTurn({
639+
role: "assistant",
640+
content: [
641+
{ type: "tool_call", id: "c1", name: "edit_file", arguments: { path: "src/a.ts" } },
642+
],
643+
}),
644+
makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }),
645+
];
646+
const result = await compactor.apply(turns, mockStrategyCtx);
647+
expect(result.output).toBe(turns);
648+
expect(result.record.reason).toBe("no compaction needed");
649+
});
650+
651+
test("failing then succeeding summarizer does not rewrite output[0]", async () => {
652+
const source: InferenceSource = {
653+
id: "test",
654+
provider: "openai",
655+
model: "test-model",
656+
baseURL: "http://localhost:1",
657+
apiKey: "k",
658+
};
659+
let calls = 0;
660+
const summarize = createModelSummarizer({
661+
getSource: () => source,
662+
complete: async () => {
663+
calls++;
664+
if (calls === 1) throw new Error("model unreachable");
665+
return "UNIQUE_SUCCESS_SUMMARY";
666+
},
667+
});
668+
const compactor = createPruningCompactor({
669+
keepRecentTurns: 2,
670+
summaryMaxChars: 500,
671+
summarize,
672+
});
673+
const turns = grow([], 16, "fail");
674+
const output1 = (await compactor.apply(turns, mockStrategyCtx)).output;
675+
expect(firstText(output1[0]!)).toContain("Turns compacted:");
676+
expect(firstText(output1[0]!)).not.toContain("UNIQUE_SUCCESS_SUMMARY");
677+
expect(firstText(output1[0]!)).toContain("Model summary unavailable");
678+
679+
const output2 = (await compactor.apply(grow(output1, 16, "ok"), mockStrategyCtx)).output;
680+
expect(firstText(output2[0]!)).toBe(firstText(output1[0]!));
681+
expect(allText(output2)).toContain("UNIQUE_SUCCESS_SUMMARY");
682+
expect(hasConsecutiveSameRole(output2)).toBe(false);
683+
});
684+
});
685+
580686
describe("buildContextEnvelope", () => {
581687
test("includes active task label", () => {
582688
const result = buildContextEnvelope({

src/provider/context-window.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export function contextWindowFor(model: string): number {
7878
// warning threshold so the color shift matches when compaction starts.
7979
export const COMPACTION_WINDOW_FRACTION = 0.6;
8080

81+
// After a compact that remains over the high watermark, the governor waits for
82+
// usage to grow by this fraction of the window before re-arming. Growth
83+
// hysteresis, not a low watermark: dropping under 60% is not required.
84+
export const COMPACTION_RESUME_FRACTION = 0.1;
85+
8186
// Status-bar meter turns danger at this fraction of the window — past
8287
// compaction and approaching hard overflow at 1.0. Inclusive integer bands
8388
// keep 80 in warning and start danger at 81.
@@ -102,3 +107,9 @@ export function compactionThresholdFor(model: string | undefined): number {
102107
const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW;
103108
return Math.floor(window * COMPACTION_WINDOW_FRACTION);
104109
}
110+
111+
/** Tokens of growth past the last post-compact measurement before re-arming. */
112+
export function compactionResumeDeltaFor(model: string | undefined): number {
113+
const window = model !== undefined ? contextWindowFor(model) : DEFAULT_CONTEXT_WINDOW;
114+
return Math.floor(window * COMPACTION_RESUME_FRACTION);
115+
}

0 commit comments

Comments
 (0)