diff --git a/src/compaction-policy.ts b/src/compaction-policy.ts index d12d25e..1eec4f9 100644 --- a/src/compaction-policy.ts +++ b/src/compaction-policy.ts @@ -28,14 +28,24 @@ export interface CompactionInputs { /** Should the session trigger a compaction pass? */ export function shouldCompact(inputs: CompactionInputs): boolean { - const minMessages = inputs.minMessages ?? 6; + // Emergency / forced compaction bypasses BOTH the usage check and the + // minMessages guard. Without this short-circuit, a first-turn balloon + // (e.g. a single assistant turn with many tool calls that compound to + // 200k+ input tokens) silently skips compaction because the persisted + // messageCount is still 2 (user + assistant) — well below the default + // minMessages of 6. The same applies after assembleContext() truncates + // history; we need a way to opt back in regardless of message count. + // + // We still require at least 2 real messages to have something to + // summarise — compacting 1 message is a no-op and 0 is a crash. + if (inputs.force || inputs.contextWasTruncated) { + return inputs.realMessageCount >= 2; + } + const minMessages = inputs.minMessages ?? 6; if (inputs.messageCount < minMessages) return false; if (inputs.realMessageCount < minMessages) return false; - // Emergency / forced compaction bypasses the usage check. - if (inputs.force || inputs.contextWasTruncated) return true; - const tokenBudget = Math.floor(inputs.modelContextWindow * 0.8); if (tokenBudget <= 0) return false; diff --git a/test/compaction-policy-unit.test.ts b/test/compaction-policy-unit.test.ts index 87da568..a34448f 100644 --- a/test/compaction-policy-unit.test.ts +++ b/test/compaction-policy-unit.test.ts @@ -58,6 +58,50 @@ describe("shouldCompact", () => { }); expect(result).toBe(true); }); + + // Regression: force used to be gated by the minMessages guard, which + // meant first-turn balloons (one assistant turn with many tool calls + // pushing input tokens past 200k while messageCount stayed at 2) silently + // skipped compaction. The force / contextWasTruncated short-circuit must + // bypass minMessages entirely. See memory/learnings/dodo-orchestrator-model-matters.md. + it("force=true bypasses the minMessages guard (2 messages is enough)", () => { + const result = shouldCompact({ + messageCount: 2, + realMessageCount: 2, + estimatedTokens: 1, + modelContextWindow: 10_000, + thresholdRatio: 0.5, + force: true, + }); + expect(result).toBe(true); + }); + + it("contextWasTruncated=true bypasses the minMessages guard", () => { + const result = shouldCompact({ + messageCount: 3, + realMessageCount: 3, + estimatedTokens: 100, + modelContextWindow: 10_000, + thresholdRatio: 0.5, + contextWasTruncated: true, + }); + expect(result).toBe(true); + }); + + // Edge case: force still needs SOMETHING to compact. Single-message + // sessions (just the user prompt, no assistant turn yet) have no real + // content to summarise. + it("force=true with only 1 real message still returns false (nothing to compact)", () => { + const result = shouldCompact({ + messageCount: 1, + realMessageCount: 1, + estimatedTokens: 50_000, + modelContextWindow: 100_000, + thresholdRatio: 0.5, + force: true, + }); + expect(result).toBe(false); + }); }); describe("pickCutoff", () => {