Skip to content

Commit d757830

Browse files
committed
feat(compaction): fold to a token-capped tail, not recent turns
1 parent 90d02ae commit d757830

13 files changed

Lines changed: 344 additions & 306 deletions

‎e2e/compaction-read-paging.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ function logLines(count: number): string {
4141
async function openFoldSession(): Promise<E2ESession> {
4242
const session = await openE2ESession({
4343
permissionGate: e2ePermissionGate(),
44-
// A tiny tail budget keeps the fold honest: the default 7500-token
44+
// A tiny tail budget keeps the fold honest: the default 2500-token
4545
// budget would absorb this small scenario into the live tail and the
4646
// compactor would correctly no-op.
47-
compactionShape: { tailBudgetTokens: 10 },
47+
// Budget fits the paged read windows in the live tail but not the bulky
48+
// pad turns, so the fold still fires.
49+
compactionShape: { tailBudgetTokens: 500 },
4850
// Echo the summarized turns verbatim: the spine must exist, and this
4951
// scenario asserts on the kept live bodies, not the summary text.
5052
compactionCompletion: async (turns) =>
@@ -78,7 +80,7 @@ async function lastRequestBody(session: E2ESession): Promise<string> {
7880
async function padTurns(session: E2ESession, count: number): Promise<void> {
7981
for (let i = 0; i < count; i++) {
8082
session.harness.scenario.replyOnce("anthropic", {
81-
text: `Acknowledged ${i}.`,
83+
text: `Acknowledged ${i}. ${"pad".repeat(800)}`,
8284
headUsage: LOW_USAGE,
8385
});
8486
await runUntilDone(session, `Pad turn ${i}.`);

‎evals/compaction/fixtures.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const BASELINE = {
1111
syntheticLowInput: 100,
1212
syntheticTriggerInput: 200000,
1313
outputTokens: 1,
14-
keepRecentTurns: 6,
14+
tailBudgetTokens: 2500,
1515
summaryMaxChars: 4000,
1616
wallTimeoutMs: 30000,
1717
} as const;

‎src/agent/compaction.test.ts‎

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
compactionThresholdFor,
1818
} from "../provider/context-window.js";
1919
import {
20-
COMPACTOR_KEEP_RECENT_TURNS,
2120
COMPACT_SPACER_TEXT,
2221
LEGACY_COMPACT_SPACER_TEXT,
2322
compactorNoOpFloor,
@@ -149,7 +148,6 @@ const overThreshold = compactionThresholdFor("m") + 1;
149148
const wideDelta = compactionWideResumeDeltaFor("m");
150149
const inferAction: ReactorAction[] = [{ type: "infer" }];
151150
const tenTurns = turnsOfLength(10, 1);
152-
const threeTurns = turnsOfLength(3, 1);
153151

154152
describe("compaction governor", () => {
155153
test("stays inert below the threshold or with few turns", () => {
@@ -159,7 +157,7 @@ describe("compaction governor", () => {
159157
governor.interceptActions(toolDone(), inferAction, capabilities),
160158
).toBeNull();
161159

162-
governor.noteInferenceDone(inferenceDone(overThreshold), threeTurns);
160+
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(1, 1));
163161
expect(
164162
governor.interceptActions(toolDone(), inferAction, capabilities),
165163
).toBeNull();
@@ -474,13 +472,12 @@ describe("compaction governor", () => {
474472
});
475473

476474
test("stays inert below the minimum-turn floor no matter how far over threshold", () => {
477-
// Two turns is well under MIN_TURNS_TO_COMPACT. createPruningCompactor
478-
// no-ops at the same floor (see session/compactor.ts), so arming here
479-
// would spend a reactor cycle that cannot shrink anything.
475+
// A single turn is the compactor no-op floor. Arming here would spend a
476+
// reactor cycle that cannot shrink anything.
480477
const governor = createCompactionGovernor(() => undefined);
481478
governor.noteInferenceDone(
482479
inferenceDone(overThreshold * 10),
483-
turnsOfLength(2, 1),
480+
turnsOfLength(1, 1),
484481
);
485482
expect(
486483
governor.interceptActions(toolDone(), inferAction, capabilities),
@@ -514,9 +511,9 @@ describe("compaction governor", () => {
514511

515512
test("never arms at the exact turn count createPruningCompactor no-ops on", () => {
516513
// createPruningCompactor's own no-op floor (session/compactor.ts) is
517-
// compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS). Arming at or below it
518-
// would spend a reactor cycle that is guaranteed to shrink nothing.
519-
const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
514+
// compactorNoOpFloor(). Arming at or below it would spend a reactor cycle
515+
// that is guaranteed to shrink nothing.
516+
const floor = compactorNoOpFloor();
520517
const governor = createCompactionGovernor(() => undefined);
521518
governor.noteInferenceDone(
522519
inferenceDone(overThreshold),
@@ -528,7 +525,7 @@ describe("compaction governor", () => {
528525
});
529526

530527
test("arms one turn past the floor createPruningCompactor no-ops on", () => {
531-
const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
528+
const floor = compactorNoOpFloor();
532529
const governor = createCompactionGovernor(() => undefined);
533530
governor.noteInferenceDone(
534531
inferenceDone(overThreshold),
@@ -708,7 +705,7 @@ describe("compaction governor", () => {
708705

709706
test("manual compact no-ops below the compactor floor", () => {
710707
const governor = createCompactionGovernor(undefined);
711-
governor.noteInferenceDone(inferenceDone(overThreshold), threeTurns);
708+
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(1, 1));
712709
expect(governor.requestManual("focus on tests")).toBe("noop");
713710
expect(governor.extraInstructions).toBeUndefined();
714711
expect(
@@ -828,25 +825,23 @@ describe("post-compact above-threshold latch (CL-9006)", () => {
828825
).toBeNull();
829826
});
830827

831-
test("a wide resume gap while still over threshold re-arms", () => {
828+
test("a still-over fold reports non-convergence instead of re-arming on a wide gap", () => {
832829
const governor = createCompactionGovernor(() => undefined);
833830
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
834831
expect(
835832
governor.interceptActions(toolDone(), inferAction, capabilities),
836833
).not.toBeNull();
837834

838835
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
836+
expect(governor.foldNonConverged).toBe(true);
839837
governor.noteInferenceDone(
840838
inferenceDone(overThreshold + wideDelta),
841839
tenTurns,
842840
);
843-
const actions = governor.interceptActions(
844-
toolDone(),
845-
inferAction,
846-
capabilities,
847-
);
848-
expect(actions).not.toBeNull();
849-
expect(actions?.some((a) => a.type === "compact")).toBe(true);
841+
expect(
842+
governor.interceptActions(toolDone(), inferAction, capabilities),
843+
).toBeNull();
844+
expect(governor.foldNonConverged).toBe(true);
850845
});
851846

852847
test("the consecutive-compact cap holds across tool-call occupancy", () => {
@@ -856,29 +851,17 @@ describe("post-compact above-threshold latch (CL-9006)", () => {
856851
governor.interceptActions(toolDone(), inferAction, capabilities),
857852
).not.toBeNull();
858853

854+
// Post-compact measurement still over: the fold is non-converged and the
855+
// cap is spent, even past a wide gap and tool-call occupancy.
859856
governor.noteInferenceDone(inferenceDone(overThreshold), tenTurns);
860-
governor.noteInferenceDone(
861-
inferenceDone(overThreshold + wideDelta),
862-
tenTurns,
863-
);
864-
expect(
865-
governor.interceptActions(toolDone(), inferAction, capabilities),
866-
).not.toBeNull();
867-
868-
// Post-compact measurement still over: tool-call occupancy must not reset
869-
// the cap, even past a wide gap.
870-
governor.noteInferenceDone(
871-
inferenceDone(overThreshold + wideDelta),
872-
tenTurns,
873-
);
857+
expect(governor.foldNonConverged).toBe(true);
874858
governor.noteInferenceDone(
875859
inferenceDoneWithTools(overThreshold + 2 * wideDelta),
876860
tenTurns,
877861
);
878862
expect(
879863
governor.interceptActions(toolDone(), inferAction, capabilities),
880864
).toBeNull();
881-
// The idle path shares the same cap.
882865
governor.noteIdleTurn(
883866
inferenceDoneWithTools(overThreshold + 2 * wideDelta),
884867
[{ type: "reply", content: "done" }],
@@ -942,19 +925,17 @@ describe("post-compact above-threshold latch (CL-9006)", () => {
942925
expect(
943926
governor.interceptActions(toolDone(), inferAction, capabilities),
944927
).toBeNull();
928+
expect(governor.foldNonConverged).toBe(true);
945929

946-
// Wide gap re-arms identically to the automatic path.
930+
// Wide gap does not silently re-arm the automatic path after a still-over
931+
// operator fold; the fold already reported non-convergence.
947932
governor.noteInferenceDone(
948933
inferenceDone(overThreshold + wideDelta),
949934
tenTurns,
950935
);
951-
const actions = governor.interceptActions(
952-
toolDone(),
953-
inferAction,
954-
capabilities,
955-
);
956-
expect(actions).not.toBeNull();
957-
expect(actions?.some((a) => a.type === "compact")).toBe(true);
936+
expect(
937+
governor.interceptActions(toolDone(), inferAction, capabilities),
938+
).toBeNull();
958939
});
959940
});
960941

@@ -1012,7 +993,7 @@ describe("handoff arming (/handoff)", () => {
1012993

1013994
test("requestHandoff noops at or below the fold floor and arms nothing", () => {
1014995
const governor = createCompactionGovernor(undefined);
1015-
governor.syncFromTurns(threeTurns);
996+
governor.syncFromTurns(turnsOfLength(1, 1));
1016997
expect(governor.requestHandoff("now do the UI audit")).toBe("noop");
1017998
expect(governor.extraInstructions).toBeUndefined();
1018999
expect(
@@ -1226,7 +1207,7 @@ describe("handoff arming (/handoff)", () => {
12261207
{
12271208
title: "noop then cancelManual does not wipe extras from a prior fold",
12281209
fire: "idle",
1229-
midRequests: [{ turns: threeTurns, text: "wipe this", result: "noop" }],
1210+
midRequests: [{ turns: turnsOfLength(1, 1), text: "wipe this", result: "noop" }],
12301211
},
12311212
{
12321213
title:
@@ -1318,7 +1299,7 @@ describe("handoff arming (/handoff)", () => {
13181299
expect(
13191300
governor.interceptIdleContinuation(emptyMessage(), capabilities),
13201301
).not.toBeNull();
1321-
governor.syncFromTurns(threeTurns);
1302+
governor.syncFromTurns(turnsOfLength(1, 1));
13221303
expect(governor.requestHandoff("wipe this")).toBe("noop");
13231304
governor.cancelManual();
13241305
expect(

‎src/agent/compaction.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
isAtOrUnderCompactThreshold,
1414
} from "../provider/context-window.js";
1515
import {
16-
COMPACTOR_KEEP_RECENT_TURNS,
1716
assistantTextIsCompactSpacerEcho,
1817
compactorNoOpFloor,
1918
isCompactSpacerEchoTurn,
@@ -26,11 +25,10 @@ import { onTurnBoundary } from "./reactor-events.js";
2625

2726
const COMPACTOR_NAME = "pruning-compactor";
2827
// The exact turn count `createPruningCompactor` (session/compactor.ts) is
29-
// guaranteed to no-op on. Derived from the same keepRecentTurns both real
30-
// registrations (session, sub-agent) use, so this floor cannot silently
31-
// drift from what the compactor will actually do — arming at or below it
32-
// would spend a reactor cycle that shrinks nothing.
33-
const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
28+
// guaranteed to no-op on. Derived from the same helper so this floor cannot
29+
// silently drift from what the compactor will actually do — arming at or
30+
// below it would spend a reactor cycle that shrinks nothing.
31+
const MIN_TURNS_TO_COMPACT = compactorNoOpFloor();
3432
const MAX_OVERFLOW_RECOVERIES = 2;
3533
// Last-ditch bound on compact→infer→compact when the post-compact infer never
3634
// gets under the high watermark. Counts consecutive threshold compacts with no
@@ -173,6 +171,11 @@ export function createCompactionGovernor(
173171
// operator, and overflow alike); only fold evidence moves past it.
174172
let tokensAtLastCompact: number | undefined;
175173
let awaitingPostCompactMeasurement = false;
174+
// Set when a fold's post-compact measurement is still at or above the
175+
// threshold. The CL-9006 latch still holds (small growth does not re-arm);
176+
// the consecutive cap is spent immediately so a still-over fold reports
177+
// non-convergence instead of silently saw-toothing on the wide-gap re-arm.
178+
let foldNonConverged = false;
176179

177180
// Running local estimate of the turns we send, plus the fixed system-prompt
178181
// and tool-schema overhead every request carries. Providers that omit usage
@@ -266,6 +269,10 @@ export function createCompactionGovernor(
266269
if (awaitingPostCompactMeasurement) {
267270
tokensAtLastCompact = contextTokens;
268271
awaitingPostCompactMeasurement = false;
272+
if (!isAtOrUnderCompactThreshold(contextTokens, lastModel)) {
273+
foldNonConverged = true;
274+
consecutiveThresholdCompacts = MAX_CONSECUTIVE_THRESHOLD_COMPACTS;
275+
}
269276
}
270277
// Fold evidence: usage back at or under the threshold clears the latch
271278
// and restores both rails (consecutive threshold compacts, overflow
@@ -275,6 +282,7 @@ export function createCompactionGovernor(
275282
tokensAtLastCompact = undefined;
276283
consecutiveThresholdCompacts = 0;
277284
overflowRecoveries = 0;
285+
foldNonConverged = false;
278286
}
279287
// Assign, don't OR: an under-threshold follow-up must disarm a sticky
280288
// pending left from an earlier over-threshold turn (e.g. after the
@@ -579,6 +587,9 @@ export function createCompactionGovernor(
579587
get compactTurnCount(): number {
580588
return turnCount;
581589
},
590+
get foldNonConverged(): boolean {
591+
return foldNonConverged;
592+
},
582593
requestManual,
583594
restoreExtraInstructions,
584595
restoreCacheWrite,

‎src/agent/director.test.ts‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { createAgentToolset } from "./tools.js";
2121
import { createAdvertisedToolset } from "../session/assemble-runtime.js";
2222
import { createPermissionGate } from "../permission/gate.js";
2323
import {
24-
COMPACTOR_KEEP_RECENT_TURNS,
2524
COMPACT_SPACER_TEXT,
2625
LEGACY_COMPACT_SPACER_TEXT,
2726
compactorNoOpFloor,
@@ -803,7 +802,7 @@ function makeToolErrorEvent(callId: string, content: string) {
803802
// so the arming check finds a history actually worth compacting.
804803
const longState = {
805804
turns: Array.from(
806-
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
805+
{ length: compactorNoOpFloor() + 1 },
807806
() => ({
808807
role: "user",
809808
content: [],
@@ -1594,14 +1593,11 @@ describe("chatDirector compaction", () => {
15941593

15951594
test("idle empty compact makes the post-compact estimate authoritative without inferring", async () => {
15961595
const director = createChatDirector("", [], {});
1597-
const largeTurns = Array.from(
1598-
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
1599-
(_, i) => ({
1600-
role: i % 2 === 0 ? "user" : "assistant",
1601-
content: [{ type: "text", text: "x".repeat(200) }],
1602-
timestamp: i,
1603-
}),
1604-
);
1596+
const largeTurns = Array.from({ length: 8 }, (_, i) => ({
1597+
role: i % 2 === 0 ? "user" : "assistant",
1598+
content: [{ type: "text", text: "x".repeat(200) }],
1599+
timestamp: i,
1600+
}));
16051601
const longTurnsState = { turns: largeTurns } as unknown as ReactorState;
16061602

16071603
await director.decide(

0 commit comments

Comments
 (0)