Skip to content

Commit fc57acc

Browse files
committed
Derive the compaction floor from the compactor's own config
MIN_TURNS_TO_COMPACT was an independent literal that happened to match createPruningCompactor's keepRecentTurns, itself duplicated as a third literal in the session and sub-agent compactor registrations. The independent copy was also off by one: the compactor's own no-op condition is keepRecentTurns + 1, not keepRecentTurns, so the governor could arm a compaction at the exact turn count the compactor was guaranteed to no-op on. All three call sites now share one exported constant, and the governor computes its floor with the same function the compactor uses internally.
1 parent 8545ccd commit fc57acc

7 files changed

Lines changed: 118 additions & 15 deletions

File tree

src/agent/compaction.test.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,25 @@ import type {
88
} from "@intx/types/runtime";
99
import { createCompactionGovernor } from "./compaction.js";
1010
import { compactionThresholdFor } from "../provider/context-window.js";
11+
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
1112

1213
const capabilities = {
1314
infer: (options?: unknown) => ({ type: "infer", ...(options !== undefined ? { options } : {}) }),
1415
compact: (compactor: string, reason: string) => ({ type: "compact", compactor, reason }),
1516
} as unknown as ReactorCapabilities;
1617

18+
// Distinct, non-zero cacheRead/cacheWrite so a test asserting on the total
19+
// would fail if compaction.ts ever stopped routing through the shared
20+
// contextTokensFromUsage and summed only `input` again.
1721
function usage(input: number): TokenUsage {
18-
return { input, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
22+
return { input, output: 0, cacheRead: 3, cacheWrite: 5, thinking: 0 };
23+
}
24+
25+
// A provider that truly omits usage reports every field as zero, not just
26+
// `input` — distinct from usage(0), which still carries the fixture's
27+
// non-zero cache values above.
28+
function zeroUsage(): TokenUsage {
29+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
1930
}
2031

2132
function turnsOfLength(count: number, textLength: number): ConversationTurn[] {
@@ -39,7 +50,7 @@ function inferenceDoneWithoutUsage(): Extract<ReactorInboundEvent, { type: "infe
3950
return {
4051
type: "inference.done",
4152
turn: { role: "assistant", content: [{ type: "text", text: "ok" }] },
42-
usage: usage(0),
53+
usage: zeroUsage(),
4354
source: { sourceId: "s", provider: "p", model: "m" },
4455
} as unknown as Extract<ReactorInboundEvent, { type: "inference.done" }>;
4556
}
@@ -281,4 +292,43 @@ describe("compaction governor", () => {
281292
expect(actions).not.toBeNull();
282293
expect(actions?.some((a) => a.type === "compact")).toBe(true);
283294
});
295+
296+
test("never arms at the exact turn count createPruningCompactor no-ops on", () => {
297+
// createPruningCompactor's own no-op floor (session/compactor.ts) is
298+
// compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS). Arming at or below it
299+
// would spend a reactor cycle that is guaranteed to shrink nothing.
300+
const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
301+
const governor = createCompactionGovernor(() => {});
302+
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(floor, 1));
303+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
304+
});
305+
306+
test("arms one turn past the floor createPruningCompactor no-ops on", () => {
307+
const floor = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
308+
const governor = createCompactionGovernor(() => {});
309+
governor.noteInferenceDone(inferenceDone(overThreshold), turnsOfLength(floor + 1, 1));
310+
const actions = governor.interceptActions(toolDone(), inferAction, capabilities);
311+
expect(actions).not.toBeNull();
312+
expect(actions?.some((a) => a.type === "compact")).toBe(true);
313+
});
314+
315+
test("does not catch a huge tool result mid-cycle when the provider reported real usage", () => {
316+
// Disclosed, accepted gap: the live tool.done re-check only re-derives
317+
// arming from the local estimate when the last inference.done snapshot
318+
// came from that same estimate (usingEstimate). When the provider
319+
// reported real usage under threshold, that snapshot is trusted as
320+
// authoritative until the next inference.done — a huge tool result
321+
// arriving in between is not caught until then, unlike the
322+
// usage-omitted case covered above.
323+
const governor = createCompactionGovernor(() => {});
324+
governor.noteInferenceDone(inferenceDone(1000), tenTurns);
325+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
326+
327+
const overThresholdChars = (compactionThresholdFor("m") + 1) * 4;
328+
governor.syncFromTurns(turnsOfLength(10, Math.ceil(overThresholdChars / 10)));
329+
330+
// Still null: the live estimate is now over threshold, but the last
331+
// arming decision trusted reported usage, so it is not re-checked here.
332+
expect(governor.interceptActions(toolDone(), inferAction, capabilities)).toBeNull();
333+
});
284334
});

src/agent/compaction.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ import type {
66
ToolDefinition,
77
} from "@intx/types/runtime";
88
import { compactionThresholdFor, contextTokensFromUsage } from "../provider/context-window.js";
9+
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "../session/compactor.js";
910
import { createContextEstimate, estimateOverheadTokens } from "./context-estimate.js";
1011

1112
const COMPACTOR_NAME = "pruning-compactor";
12-
const MIN_TURNS_TO_COMPACT = 6;
13+
// The exact turn count `createPruningCompactor` (session/compactor.ts) is
14+
// guaranteed to no-op on. Derived from the same keepRecentTurns both real
15+
// registrations (session, sub-agent) use, so this floor cannot silently
16+
// drift from what the compactor will actually do — arming at or below it
17+
// would spend a reactor cycle that shrinks nothing.
18+
const MIN_TURNS_TO_COMPACT = compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS);
1319
const MAX_OVERFLOW_RECOVERIES = 2;
1420

1521
// A compact action runs in its own reactor cycle, after which the reactor
@@ -55,10 +61,6 @@ export function createCompactionGovernor(
5561
return estimate.syncFromTurns(turns);
5662
}
5763

58-
// `createPruningCompactor` (session/compactor.ts) is the only layer that
59-
// knows whether a history is actually shrinkable — it no-ops below its own
60-
// keepRecentTurns floor. MIN_TURNS_TO_COMPACT mirrors that floor so the
61-
// governor never arms a compaction the compactor is guaranteed to no-op.
6264
function isOverThreshold(contextTokens: number): boolean {
6365
return contextTokens > compactionThresholdFor(lastModel) && turnCount > MIN_TURNS_TO_COMPACT;
6466
}

src/context-compactor.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, test, expect } from "bun:test";
22
import {
33
createPruningCompactor,
4+
compactorNoOpFloor,
45
buildContextEnvelope,
56
formatPlan,
67
classifyTaskBoundary,
@@ -46,6 +47,30 @@ describe("createPruningCompactor", () => {
4647
expect(result.output).toBe(turns); // Same reference when no compaction needed
4748
});
4849

50+
test("compactorNoOpFloor names the exact turn count apply() no-ops on", async () => {
51+
// The compaction governor (agent/compaction.ts) derives its arming floor
52+
// from this function so it never arms a compaction guaranteed to no-op.
53+
// Anyone changing apply()'s no-op condition without updating
54+
// compactorNoOpFloor accordingly breaks that guarantee silently.
55+
const keepRecentTurns = 3;
56+
const compactor = createPruningCompactor({ keepRecentTurns, summaryMaxChars: 500 });
57+
const floor = compactorNoOpFloor(keepRecentTurns);
58+
59+
const atFloor = Array.from({ length: floor }, (_, i) =>
60+
makeTurn({ role: i % 2 === 0 ? "user" : "assistant" }),
61+
);
62+
const pastFloor = Array.from({ length: floor + 1 }, (_, i) =>
63+
makeTurn({ role: i % 2 === 0 ? "user" : "assistant" }),
64+
);
65+
66+
expect((await compactor.apply(atFloor, mockStrategyCtx)).record.reason).toBe(
67+
"no compaction needed",
68+
);
69+
expect((await compactor.apply(pastFloor, mockStrategyCtx)).record.reason).not.toBe(
70+
"no compaction needed",
71+
);
72+
});
73+
4974
test("compacts old turns and preserves recent ones", async () => {
5075
const compactor = createPruningCompactor({ keepRecentTurns: 2, summaryMaxChars: 500 });
5176
const turns: ConversationTurn[] = [

src/director.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createChatDirector } from "./agent/director.js";
33
import { createAgentToolset } from "./agent/tools.js";
44
import { advertisedTools, createActivatedToolTracker } from "./agent/tool-search.js";
55
import { createPermissionGate } from "./permission/gate.js";
6+
import { COMPACTOR_KEEP_RECENT_TURNS, compactorNoOpFloor } from "./session/compactor.js";
67
import type { SessionMetadata, TaskBoundary } from "./session/compactor.js";
78
import type { ExtendedInferenceOptions } from "@intx/inference";
89
import type { ReactorState, ReactorCapabilities, ReactorAction, ReactorInboundEvent } from "@intx/types/runtime";
@@ -275,7 +276,14 @@ describe("chatDirector compaction", () => {
275276
const director = createChatDirector("", [], undefined, undefined, undefined, undefined, undefined, undefined, () => {
276277
continuations++;
277278
});
278-
const longState = { turns: Array.from({ length: 7 }, () => ({ role: "user", content: [], timestamp: 0 })) } as unknown as ReactorState;
279+
// One turn past createPruningCompactor's own no-op floor (session/compactor.ts),
280+
// so the arming check finds a history actually worth compacting.
281+
const longState = {
282+
turns: Array.from(
283+
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
284+
() => ({ role: "user", content: [], timestamp: 0 }),
285+
),
286+
} as unknown as ReactorState;
279287

280288
const replyActions = actionsArray(await director.decide(textInferenceDone(999_999), longState, mockCapabilities));
281289
expect(replyActions.some((a) => a.type === "reply")).toBe(true);
@@ -288,7 +296,13 @@ describe("chatDirector compaction", () => {
288296
]);
289297
});
290298

291-
const longState = { turns: Array.from({ length: 7 }, () => ({ role: "user", content: [], timestamp: 0 })) } as unknown as ReactorState;
299+
// One turn past createPruningCompactor's own no-op floor (session/compactor.ts).
300+
const longState = {
301+
turns: Array.from(
302+
{ length: compactorNoOpFloor(COMPACTOR_KEEP_RECENT_TURNS) + 1 },
303+
() => ({ role: "user", content: [], timestamp: 0 }),
304+
),
305+
} as unknown as ReactorState;
292306

293307
function overThresholdToolTurn(): ReactorInboundEvent {
294308
return {

src/session/compactor.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,19 @@ const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
217217
stripResultContent: false,
218218
};
219219

220+
// Recent turns kept verbatim by both real pruning-compactor registrations
221+
// (the main session and sub-agents). Exported so callers that need to know
222+
// in advance whether a compaction would do anything — the compaction
223+
// governor's arming floor — derive it from this value instead of carrying
224+
// an independent literal that can silently drift out of sync.
225+
export const COMPACTOR_KEEP_RECENT_TURNS = 6;
226+
227+
// `apply` below no-ops at or below this turn count: keeping `keepRecentTurns`
228+
// turns plus at least one more is what makes pruning worth doing at all.
229+
export function compactorNoOpFloor(keepRecentTurns: number): number {
230+
return keepRecentTurns + 1;
231+
}
232+
220233
// Minimum anchor score for a turn to be pulled forward past the summary boundary.
221234
const ANCHOR_SCORE_THRESHOLD = 5;
222235

@@ -425,7 +438,7 @@ export function createPruningCompactor(
425438
// leave the inference-facing context as soon as they exit the recent window.
426439
const aged = await ageImagesOutsideRecentWindow(turns, cfg.keepRecentTurns);
427440

428-
if (aged.turns.length <= cfg.keepRecentTurns + 1) {
441+
if (aged.turns.length <= compactorNoOpFloor(cfg.keepRecentTurns)) {
429442
return {
430443
output: aged.turns,
431444
record: {

src/session/runtime-assembly.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
import type { Approval, GrantScope } from "../permission/types.js";
3939
import type { ReasoningEffort } from "../provider/reasoning-effort.js";
4040
import type { SubAgentProvider } from "../subagent/index.js";
41-
import { createPruningCompactor } from "./compactor.js";
41+
import { COMPACTOR_KEEP_RECENT_TURNS, createPruningCompactor } from "./compactor.js";
4242

4343
// ---------------------------------------------------------------------------
4444
// 1. Sub-agent provider literal
@@ -238,7 +238,6 @@ export function buildSessionSourcesFromConfig(
238238
// 6. Pruning-compactor config
239239
// ---------------------------------------------------------------------------
240240

241-
const SESSION_COMPACTOR_KEEP_RECENT = 6;
242241
const SESSION_COMPACTOR_SUMMARY_MAX_CHARS = 2500;
243242

244243
export type SessionPruningCompactorArgs = {
@@ -251,7 +250,7 @@ export function createSessionPruningCompactor(
251250
args: SessionPruningCompactorArgs,
252251
): Compactor {
253252
return createPruningCompactor({
254-
keepRecentTurns: SESSION_COMPACTOR_KEEP_RECENT,
253+
keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS,
255254
summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS,
256255
...(args.compactionMode !== "pruning"
257256
? { summarize: args.summarize }

src/subagent/run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { shouldApplyGrokAntiThrash } from "./provider-family.js";
4040
import { resolveModelFamilyPolicy } from "../agent/model-family-policy.js";
4141
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
4242

43-
import { createPruningCompactor } from "../session/compactor.js";
43+
import { COMPACTOR_KEEP_RECENT_TURNS, createPruningCompactor } from "../session/compactor.js";
4444
import { createAttachmentRehydrateTransform } from "../session/attachment-store.js";
4545
import { createModelSummarizer } from "../session/summarizer.js";
4646
import { gatherEnvironment } from "../agent/environment.js";
@@ -496,7 +496,7 @@ async function runSubAgentInner(params: RunSubAgentParams): Promise<string> {
496496
}),
497497
compactors: {
498498
"pruning-compactor": createPruningCompactor({
499-
keepRecentTurns: 6,
499+
keepRecentTurns: COMPACTOR_KEEP_RECENT_TURNS,
500500
summaryMaxChars: 2500,
501501
stripResultContent: true,
502502
// A structured model summary keeps sub-agent context useful across a

0 commit comments

Comments
 (0)