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\./, ); });