From 53a790d51a90afad063652fd84f8e97856a2ae7c Mon Sep 17 00:00:00 2001 From: jonnyparris <6400000+jonnyparris@users.noreply.github.com> Date: Sun, 24 May 2026 21:03:44 +0100 Subject: [PATCH] fix(compaction): force/truncated bypass the minMessages guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autocompaction was unreachable on the first turn even when the model ballooned the input past 200k tokens, because shouldCompact()'s minMessages>=6 guard returned false BEFORE the force/contextWasTruncated short-circuit ran. Symptom in production: 3 separate sessions ran a single assistant turn with many tool calls (clone + explore + read…) that pushed totalTokenInput past 199k. messageCount stayed at 2 (user + assistant). compactionCount stayed at 0. The 'wrap-up' system injection fired when the budget warning hit ~80%, the model emitted a 'context exhausted' summary, and the session went idle without ever compacting. Fix: move the force/contextWasTruncated check above the minMessages guard. They are emergency signals — the caller has already determined compaction is warranted. We still require >=2 real messages because compacting one or zero messages is a no-op or crash. Tests: - New regression test: force=true with 2 messages now returns true - New regression test: contextWasTruncated=true with 3 messages now returns true - New edge case: force=true with 1 message still returns false - All 21 existing compaction tests still pass --- src/compaction-policy.ts | 18 +++++++++--- test/compaction-policy-unit.test.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) 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", () => {