Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
98 changes: 98 additions & 0 deletions tests/compress-rejection-loop.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
2 changes: 1 addition & 1 deletion tests/compress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\./,
);
});
Loading