From c53f36661e2b54f5a21fbc277298fdce468b2bda Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Sat, 22 Aug 2026 07:43:38 +0800 Subject: [PATCH] compress: break identical-rejection retry death-loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session 01a02542 (pi host, qwen3.8-27b via local vLLM/SGLang) re-submitted the SAME rejected compress call 3853 times over 5h11m (~4.9s/call) after every compressible range was consumed. Driver: the min-gate error ended with 'retry with startId/endId set to active block IDs in that span' — a literal retry instruction that weak models follow verbatim while the 'Nothing to do' half is ignored, with no dedup guard anywhere. Track identical consecutive gate rejections per range signature in CompressionState.rejections (optional field, bounded 8-entry FIFO, backward compatible with old persisted states). From the 2nd identical rejection the gate message becomes terminal: states the count, says re-submitting will keep failing, and tells the model to answer the user instead — no retry guidance of any kind. First rejections keep their actionable advice, with the liveHint rephrased to drop the word 'retry' (block-to-block distillation is still offered as the one useful action). --- package-lock.json | 4 +- src/compress.ts | 16 ++++- src/types.ts | 6 ++ tests/compress-rejection-loop.test.ts | 98 +++++++++++++++++++++++++++ tests/compress.test.ts | 2 +- 5 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 tests/compress-rejection-loop.test.ts diff --git a/package-lock.json b/package-lock.json index 54c2129..c542c69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.28", + "version": "0.0.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.28", + "version": "0.0.31", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/src/compress.ts b/src/compress.ts index 8a18fbe..47b8ec3 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -246,21 +246,30 @@ export function createCore(ports: Ports = {}): CompressionCore { } } if (!hasBlockBoundaryRange && totalRangeChars < input.config.compress.minCompressRange) { + const rejectionSpec = input.ranges.map((r) => `${r.startRef}..${r.endRef}`).join(","); + const priorCount = state.rejections?.find((r) => r.spec === rejectionSpec)?.count ?? 0; + const rejectionCount = priorCount + 1; + state.rejections = [ + ...(state.rejections ?? []).filter((r) => r.spec !== rejectionSpec), + { spec: rejectionSpec, count: rejectionCount }, + ].slice(-8); const live = activeBlocks(state) .map((b) => b.blockId) .sort((x, y) => numericBlockId(x) - numericBlockId(y)); const liveHint = live.length > 0 - ? ` Current active blocks span ${live[0]}..${live[live.length - 1]} — retry with startId/endId set to active block IDs in that span.` + ? ` Current active blocks span ${live[0]}..${live[live.length - 1]} — only a block-to-block distillation (startId/endId set to those block IDs) can reclaim more; otherwise nothing remains to compress.` : ""; const gateMessage = - resolvableCount === 0 && consumedRanges.length === 0 && unknownCount > 0 + rejectionCount >= 2 + ? `Identical range(s) rejected ${rejectionCount} times now — nothing new is compressible, and re-submitting the same compress will keep failing. Stop calling compress with these refs and answer the user instead; new content becomes compressible once it exceeds ${input.config.compress.minCompressRange} chars.` + : resolvableCount === 0 && consumedRanges.length === 0 && unknownCount > 0 ? `None of the ${input.ranges.length} requested range(s) resolved — every ref failed with "does not exist in this session". Refs recorded before an earlier compress are stale: each successful compress renumbers the remaining refs. Run acp_status, then re-issue the compress in the same turn using only the refs it reports.` : consumedRanges.length > 0 ? `Requested range(s) already compressed (e.g. ${consumedRanges[0]!.startRef}..${consumedRanges[0]!.endRef}); remaining compressible content ${totalRangeChars} chars < min ${input.config.compress.minCompressRange}. Nothing to do.${liveHint}` : `Total compressible content too small (${totalRangeChars} chars across ${countedRanges} range(s), min ${input.config.compress.minCompressRange}). Combine more messages into your range(s) to meet the threshold.`; return { - state: input.state, + state, result: { blocksCreated: 0, tokensCompressed: 0, @@ -1170,6 +1179,7 @@ function cloneState(state: CompressionState): CompressionState { tokenSnapshot: { ...(state.tokenSnapshot ?? {}) }, nudge: { ...state.nudge, anchors: { ...state.nudge.anchors } }, stats: { ...state.stats }, + rejections: (state.rejections ?? []).map((r) => ({ ...r })), nextBlockId: state.nextBlockId, nextRunId: state.nextRunId, }; diff --git a/src/types.ts b/src/types.ts index bd2af68..511fefe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,6 +73,12 @@ export interface CompressionState { stats: CompressionStats; nextBlockId: number; nextRunId: number; + /** Identical min-gate rejections seen consecutively per range signature + * (bounded FIFO). Weak models re-submit a rejected compress verbatim + * forever when the error keeps suggesting a retry — from the 2nd identical + * rejection the gate message switches to a terminal "stop" form with no + * retry guidance. Optional: old persisted states simply start counting. */ + rejections?: { spec: string; count: number }[]; } export interface TierConfig { diff --git a/tests/compress-rejection-loop.test.ts b/tests/compress-rejection-loop.test.ts new file mode 100644 index 0000000..699efb9 --- /dev/null +++ b/tests/compress-rejection-loop.test.ts @@ -0,0 +1,98 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { assignRefs } from "../src/refs.js"; +import type { Config, CoreMessage } from "../src/types.js"; + +function msg(id: string, text: string, role: CoreMessage["role"] = "user"): CoreMessage { + return { id, role, contentType: "text", text }; +} + +function config(overrides: Partial = {}): Config { + return { + tiers: { enabled: true, tier2Trigger: 5, tier3Trigger: 10 }, + nudge: { + maxContextLimitPct: 0.55, + minContextLimitPct: 0.45, + frequency: 5, + iterationThreshold: 15, + force: "soft", + growthRatio: 0.05, + growthFloor: 6000, + growthCap: 50000, + minGrowthFloor: 5000, + minGrowthRatio: 0.45, + emergencyThresholdPct: 0.98, + }, + promotionThreshold: 5, + truncate: { threshold: 1 }, + merge: { maxSummaryLength: 3000, minOldGenBlocks: 3 }, + compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 }, + protectedTools: [], + preserveRecentMessages: 0, + preserveRecentTokens: 0, + modelContextLimit: 100000, + ...overrides, + }; +} + +function setup() { + const core = createCore(); + const state = createInitialState(); + const messages = [msg("a", "x".repeat(200)), msg("b", "y".repeat(200)), msg("c", "z".repeat(200))]; + state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + return { core, state, messages }; +} + +// Reproduces dog/billion-context-pi session 01a02542: after every compressible +// range is consumed, the model re-submitted the SAME rejected compress 3853 +// times because the gate error kept ending with a retry suggestion. +test("identical rejected compress flips to a terminal message with no retry guidance", () => { + const { core, state, messages } = setup(); + const spec = { startRef: "m00001", endRef: "m00002", summary: "s" }; + const first = core.applyCompression({ ranges: [spec], messages, state, config: config() }); + assert.equal(first.result.blocksCreated, 0); + assert.match(first.result.errors[0]!, /already compressed|too small/); + assert.ok(!/rejected \d+ times/.test(first.result.errors[0]!), "first rejection keeps guidance"); + assert.equal(first.state.rejections?.[0]?.count, 1); + + const second = core.applyCompression({ ranges: [spec], messages, state: first.state, config: config() }); + assert.match(second.result.errors[0]!, /rejected 2 times/); + assert.ok(!/retry/i.test(second.result.errors[0]!), "terminal message must not suggest retry"); + assert.ok(!/re-issue|acp_status/.test(second.result.errors[0]!)); + + const third = core.applyCompression({ ranges: [spec], messages, state: second.state, config: config() }); + assert.match(third.result.errors[0]!, /rejected 3 times/); +}); + +test("a different range after a rejection starts its own counter", () => { + const { core, state, messages } = setup(); + const a = core.applyCompression( + { ranges: [{ startRef: "m00001", endRef: "m00002", summary: "s" }], messages, state, config: config() }, + ); + const b = core.applyCompression( + { ranges: [{ startRef: "m00002", endRef: "m00003", summary: "s" }], messages, state: a.state, config: config() }, + ); + assert.equal(b.state.rejections?.length, 2); + assert.ok(!/rejected \d+ times/.test(b.result.errors[0]!)); +}); + +test("rejection tracking does not mutate the caller's state object", () => { + const { core, state, messages } = setup(); + const out = core.applyCompression( + { ranges: [{ startRef: "m00001", endRef: "m00002", summary: "s" }], messages, state, config: config() }, + ); + assert.equal(state.rejections, undefined, "input state untouched"); + assert.equal(out.state.rejections?.length, 1); +}); + +test("state without the rejections field (old persisted state) counts from 1", () => { + const { core, state, messages } = setup(); + const legacy = { ...state }; + delete (legacy as { rejections?: unknown }).rejections; + const out = core.applyCompression( + { ranges: [{ startRef: "m00001", endRef: "m00002", summary: "s" }], messages, state: legacy, config: config() }, + ); + assert.equal(out.state.rejections?.[0]?.count, 1); +}); diff --git a/tests/compress.test.ts b/tests/compress.test.ts index fb91767..45af9fe 100644 --- a/tests/compress.test.ts +++ b/tests/compress.test.ts @@ -837,6 +837,6 @@ test("gate error names the current active block span when anchors stay consumed" ); assert.match( result.result.errors[0]!, - /Current active blocks span b110\.\.b110 — retry with startId\/endId set to active block IDs in that span\./, + /Current active blocks span b110\.\.b110 — only a block-to-block distillation \(startId\/endId set to those block IDs\) can reclaim more; otherwise nothing remains to compress\./, ); });