diff --git a/src/boundaries.ts b/src/boundaries.ts index e4f1de1..ff7d9f3 100644 --- a/src/boundaries.ts +++ b/src/boundaries.ts @@ -1,5 +1,7 @@ import { activeBlocks, blockById } from "./state.js"; +import { isSummaryMessageId, summaryMessageId } from "./prune.js"; import type { + CompressionBlock, CompressionState, CoreMessage, ResolvedBoundary, @@ -106,7 +108,10 @@ export function resolveBoundaries( const messageIds: string[] = []; for (let index = startIndex; index <= endIndex; index++) { const message = input.messages[index]; - if (message) messageIds.push(message.id); + // Synthetic summary messages are transient view representations, not + // compressible content: exclude them so they never leak into a new + // block's effectiveMessageIds/directMessageIds. + if (message && !isSummaryMessageId(message.id)) messageIds.push(message.id); } const boundaryKind: BoundaryKind = @@ -115,7 +120,7 @@ export function resolveBoundaries( const nestedBlockIds: string[] = []; const nestedSeen = new Set(); for (const block of activeBlocks(input.state)) { - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const anchor = visibleBlockAnchor(block, indexByRawId); if (anchor !== null && anchor >= startIndex && anchor <= endIndex) { if (!nestedSeen.has(block.blockId)) { nestedSeen.add(block.blockId); @@ -187,7 +192,7 @@ function resolveAnchorIndex( ); } if (block.active) { - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const anchor = visibleBlockAnchor(block, indexByRawId); if (anchor !== null) { return { index: anchor, snapped: null }; } @@ -209,7 +214,7 @@ function resolveAnchorIndex( throw new BoundaryNotFoundError( "consumed", endpoint, - `${label}="b${boundary.numericId}" not found in visible context (block messages consumed by a higher-tier block).`, + `${label}="b${boundary.numericId}" is an active block but none of its content (raw messages or rendered summary) is visible in the current context — run acp_status to verify.`, ); } @@ -217,6 +222,12 @@ function resolveAnchorIndex( * Snap a consumed anchor to the active block that now owns its content. * Throwing instead dead-ends compress calls that follow nudge instructions * with older (already-distilled) refs — the livelock in dog/billion-context-pi#32. + * + * Only TRUE ANCESTORS qualify: an active block that INHERITED the content by + * consuming another block. If the active block DIRECTLY compressed the + * message itself, callers must get the "already compressed — retry with the + * block's bN ref" guidance instead of a silent snap (which would turn a + * message-range retry into a same-tier duplicate block). */ function activeOwnerAnchor( state: CompressionState, @@ -228,10 +239,14 @@ function activeOwnerAnchor( let best: number | null = null; for (const block of state.blocks) { if (!block.active) continue; - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const direct = new Set(block.directMessageIds); + const ownsInherited = block.effectiveMessageIds.some( + (id) => owned.has(id) && !direct.has(id), + ); + if (!ownsInherited) continue; + const anchor = visibleBlockAnchor(block, indexByRawId); if (anchor === null) continue; - const ownsContent = block.effectiveMessageIds.some((id) => owned.has(id)); - if (ownsContent && (best === null || anchor < best)) { + if (best === null || anchor < best) { best = anchor; } } @@ -242,6 +257,22 @@ function formatPaddedRef(index: number): string { return `m${String(index).padStart(5, "0")}`; } +/** + * Visible anchor index for a block: its rendered summary message if present + * (post-prune views replace raw messages with `acp_summary_bN`), else the + * earliest visible raw message it covers. Without the summary fallback an + * active block whose raws were pruned becomes unresolvable and cannot be + * promoted (dog/billion-context-pi#195). + */ +export function visibleBlockAnchor( + block: CompressionBlock, + indexByMessageId: Map, +): number | null { + const summaryIndex = indexByMessageId.get(summaryMessageId(block.blockId)); + if (summaryIndex !== undefined) return summaryIndex; + return earliestIndexOfIds(block.effectiveMessageIds, indexByMessageId); +} + export function earliestIndexOfIds( ids: string[], indexByRawId: Map, diff --git a/src/compress.ts b/src/compress.ts index 8a18fbe..6bdc695 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -1,5 +1,5 @@ import { assignRefs, highestUsedIndex } from "./refs.js"; -import { prune } from "./prune.js"; +import { prune, isSummaryMessageId } from "./prune.js"; import { syncBlocks } from "./sync.js"; import { advanceSurvival, activeBlocks, blockById } from "./state.js"; import { @@ -12,7 +12,7 @@ import { validateConfig } from "./config.js"; import { BoundaryNotFoundError, resolveBoundaries, - earliestIndexOfIds, + visibleBlockAnchor, } from "./boundaries.js"; import type { ResolvedRange } from "./boundaries.js"; import { truncateLargeToolOutputs } from "./truncate-tools.js"; @@ -608,19 +608,19 @@ function applySingleRange(input: SingleRangeInput): SingleRangeOutcome { const rangeMessageIds = applyPairBoundaryAdjustments( resolved, input.messages, - ); + ).filter((id) => !isSummaryMessageId(id)); // Re-scan for nested blocks in the ADJUSTED range (tool-pair extension may // have pulled in messages that are anchors of existing blocks). if (rangeMessageIds.length > resolved.messageIds.length) { const indexByRawId = new Map(); input.messages.forEach((m, i) => indexByRawId.set(m.id, i)); - const adjustedStart = indexByRawId.get(rangeMessageIds[0]!) ?? resolved.startIndex; - const adjustedEnd = indexByRawId.get(rangeMessageIds[rangeMessageIds.length - 1]!) ?? resolved.endIndex; + const adjustedStart = rangeMessageIds.length > 0 ? (indexByRawId.get(rangeMessageIds[0]!) ?? resolved.startIndex) : resolved.startIndex; + const adjustedEnd = rangeMessageIds.length > 0 ? (indexByRawId.get(rangeMessageIds[rangeMessageIds.length - 1]!) ?? resolved.endIndex) : resolved.endIndex; const nestedSeen = new Set(resolved.nestedBlockIds); for (const block of activeBlocks(input.state)) { if (nestedSeen.has(block.blockId)) continue; - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const anchor = visibleBlockAnchor(block, indexByRawId); if (anchor !== null && anchor >= adjustedStart && anchor <= adjustedEnd) { nestedSeen.add(block.blockId); resolved.nestedBlockIds.push(block.blockId); diff --git a/src/index.ts b/src/index.ts index fb0d6f0..f708ba6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,9 +27,9 @@ export { rawForRef, BLOCKED_REF, } from "./refs.js"; -export { prune, SUMMARY_HEADER } from "./prune.js"; +export { prune, SUMMARY_HEADER, summaryMessageId, isSummaryMessageId } from "./prune.js"; export { syncBlocks } from "./sync.js"; -export { resolveBoundaries, parseBoundary, BoundaryNotFoundError } from "./boundaries.js"; +export { resolveBoundaries, parseBoundary, BoundaryNotFoundError, visibleBlockAnchor } from "./boundaries.js"; export { defaultCountTokens, estimateTokensFast, createBpeTokenizer } from "./tokenize.js"; export type { TokenCountFn } from "./tokenize.js"; export { renderNudgeText, formatRanges } from "./nudge-text.js"; diff --git a/src/prune.ts b/src/prune.ts index 2bfbf35..45dc41a 100644 --- a/src/prune.ts +++ b/src/prune.ts @@ -3,6 +3,22 @@ import type { CompressionState, CoreMessage } from "./types.js"; export const SUMMARY_HEADER = "[Compressed conversation section]"; +const SUMMARY_ID_PREFIX = "acp_summary_"; + +/** + * The transient visible id of an active block's rendered summary message. + * This is a VIEW-ONLY representation: it must never be persisted into + * `effectiveMessageIds`/`directMessageIds` (the durable coverage is the + * block's raw message ids). + */ +export function summaryMessageId(blockId: string): string { + return `${SUMMARY_ID_PREFIX}${blockId}`; +} + +export function isSummaryMessageId(id: string): boolean { + return id.startsWith(SUMMARY_ID_PREFIX); +} + export interface PruneOptions { injectSummaries?: boolean; } @@ -47,6 +63,19 @@ function collectSummaryAnchors( ): SummaryAnchor[] { const anchors: SummaryAnchor[] = []; for (const block of activeBlocks(state)) { + // Prefer the position of an already-rendered summary (hosts may pass a + // previously-pruned view): keeps the summary stable in place instead of + // jumping to index 0 when the raw ids are no longer in the input. + const existingIndex = indexById.get(summaryMessageId(block.blockId)); + if (existingIndex !== undefined) { + anchors.push({ + blockId: block.blockId, + summary: block.summary, + topic: block.topic, + insertAt: existingIndex, + }); + continue; + } let earliest: number | null = null; for (const id of block.effectiveMessageIds) { const index = indexById.get(id); @@ -73,6 +102,9 @@ function rebuildMessages( ): CoreMessage[] { const result: CoreMessage[] = []; const pending = [...anchors]; + const anchoredSummaryIds = new Set( + anchors.map((anchor) => summaryMessageId(anchor.blockId)), + ); for (let index = 0; index < messages.length; index++) { while (pending.length > 0 && pending[0]!.insertAt === index) { @@ -83,6 +115,9 @@ function rebuildMessages( continue; } if (covered.has(messages[index]!.id)) continue; + // A stale copy of this block's summary from a previously-pruned view: + // the freshly rendered one above replaces it. + if (anchoredSummaryIds.has(messages[index]!.id)) continue; result.push(messages[index]!); } @@ -100,7 +135,7 @@ function renderSummary(anchor: SummaryAnchor): CoreMessage { : SUMMARY_HEADER; const text = body.length === 0 ? topicLine : `${topicLine}\n${body}`; return { - id: `acp_summary_${anchor.blockId}`, + id: summaryMessageId(anchor.blockId), role: "system", contentType: "text", text, diff --git a/src/sync.ts b/src/sync.ts index 520ed2a..452d5d8 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,3 +1,4 @@ +import { summaryMessageId } from "./prune.js"; import type { CompressionState, CoreMessage } from "./types.js"; export interface SyncResult { @@ -63,9 +64,13 @@ export function syncBlocks( continue; } block.active = true; - const stillPresent = block.effectiveMessageIds.some((id) => - presentIds.has(id), - ); + // A block whose raw messages were replaced by its rendered summary + // (pruned view) is still present — the summary IS the block's visible + // representation. Without this, hosts passing pruned views would lose + // block activity every turn. + const stillPresent = + block.effectiveMessageIds.some((id) => presentIds.has(id)) || + presentIds.has(summaryMessageId(block.blockId)); if (!stillPresent) { block.active = false; deactivated.push(block.blockId); diff --git a/tests/regression-promote-after-prune.test.ts b/tests/regression-promote-after-prune.test.ts new file mode 100644 index 0000000..a7444e4 --- /dev/null +++ b/tests/regression-promote-after-prune.test.ts @@ -0,0 +1,262 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { prune, summaryMessageId } from "../src/prune.js"; +import { syncBlocks } from "../src/sync.js"; +import type { CompressionState, Config, CoreMessage } from "../src/types.js"; + +// Regression tests for dog/billion-context-pi#195: compress cannot promote an +// active block after pruning replaces its raw messages with the synthetic +// `acp_summary_bN` message. Each test FAILS against the pre-fix code. + +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: 0, maxSummaryLength: 0, minSummaryLength: 0 }, + protectedTools: [], + preserveRecentMessages: 5, + preserveRecentTokens: 0, + modelContextLimit: 100000, + ...overrides, + }; +} + +function makeState( + specs: { blockId: string; effectiveMessageIds: string[] }[], + nextBlockId: number, +): CompressionState { + const state = createInitialState(); + state.blocks = specs.map((spec) => ({ + blockId: spec.blockId, + runId: "r1", + tier: 1 as const, + topic: undefined, + summary: `T1 summary for ${spec.blockId}.`, + directMessageIds: [...spec.effectiveMessageIds], + effectiveMessageIds: [...spec.effectiveMessageIds], + directBlockIds: [], + compressedTokens: 100, + createdAt: Date.now(), + survivedCount: 0, + generation: "young" as const, + active: true, + })); + state.nextBlockId = nextBlockId; + return state; +} + +test("promote-after-prune: single T1 block promotes to T2 after its raws were replaced by the summary", () => { + const core = createCore(); + const cfg = config(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + + const turn = core.processTurn({ messages, state, config: cfg, tokenCount: 5000 }); + const visibleIds = turn.messages.map((m) => m.id); + // Prune replaced the covered raws with the synthetic summary… + assert.ok(visibleIds.includes(summaryMessageId("b2")), "summary should be visible"); + assert.ok(!visibleIds.includes("raw-3"), "raw-3 should be hidden"); + assert.ok(!visibleIds.includes("raw-4"), "raw-4 should be hidden"); + // …and sync must NOT have deactivated the block just because its raws vanished. + const b2 = turn.state.blocks.find((b) => b.blockId === "b2")!; + assert.equal(b2.active, true, "b2 must stay active post-prune"); + + const applied = core.applyCompression({ + ranges: [{ startRef: "b2", endRef: "b2", summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual(applied.result.errors, [], `unexpected errors: ${applied.result.errors.join("; ")}`); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b2"]); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), ["raw-3", "raw-4"]); + // The synthetic id must never leak into durable coverage. + for (const list of [newBlock.effectiveMessageIds, newBlock.directMessageIds]) { + for (const id of list) { + assert.ok(!id.startsWith("acp_summary_"), `synthetic id leaked into block: ${id}`); + } + } + assert.equal(applied.state.blocks.find((b) => b.blockId === "b2")!.active, false); +}); + +test("promote-after-prune: two T1 blocks promote into one T2 consuming both", () => { + const core = createCore(); + const cfg = config(); + const messages = Array.from({ length: 11 }, (_, i) => + msg(`raw-${i + 1}`, `m${i + 1} `.repeat(200), i % 2 === 0 ? "user" : "assistant"), + ); + const state = makeState( + [ + { blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }, + { blockId: "b3", effectiveMessageIds: ["raw-5", "raw-6"] }, + ], + 4, + ); + + const turn = core.processTurn({ messages, state, config: cfg, tokenCount: 9000 }); + const applied = core.applyCompression({ + ranges: [{ startRef: "b2", endRef: "b3", summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual(applied.result.errors, [], `unexpected errors: ${applied.result.errors.join("; ")}`); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b2", "b3"]); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), ["raw-3", "raw-4", "raw-5", "raw-6"]); +}); + +test("promote-after-prune: inclusive bN..bM selection consumes every active block in the span, including ones visible only via their summary", () => { + const core = createCore(); + const cfg = config({ preserveRecentMessages: 3 }); + const messages = Array.from({ length: 9 }, (_, i) => + msg(`raw-${i + 1}`, `m${i + 1} `.repeat(200), i % 2 === 0 ? "user" : "assistant"), + ); + // raw-1 is the first user message (always kept visible by prune), so + // coverage starts at raw-3. + const state = makeState( + [ + { blockId: "b1", effectiveMessageIds: ["raw-3", "raw-4"] }, + { blockId: "b2", effectiveMessageIds: ["raw-5", "raw-6"] }, + { blockId: "b3", effectiveMessageIds: ["raw-7", "raw-8"] }, + ], + 4, + ); + + const turn = core.processTurn({ messages, state, config: cfg, tokenCount: 8000 }); + const visibleIds = turn.messages.map((m) => m.id); + for (const id of ["raw-3", "raw-4", "raw-5", "raw-6", "raw-7", "raw-8"]) { + assert.ok(!visibleIds.includes(id), `${id} should be hidden`); + } + for (const id of ["acp_summary_b1", "acp_summary_b2", "acp_summary_b3"]) { + assert.ok(visibleIds.includes(id), `${id} should be visible`); + } + + const applied = core.applyCompression({ + ranges: [{ startRef: "b1", endRef: "b3", summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual(applied.result.errors, [], `unexpected errors: ${applied.result.errors.join("; ")}`); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b1", "b2", "b3"]); + assert.deepEqual( + newBlock.effectiveMessageIds.sort(), + ["raw-3", "raw-4", "raw-5", "raw-6", "raw-7", "raw-8"], + ); + for (const id of ["b1", "b2", "b3"]) { + assert.equal(applied.state.blocks.find((b) => b.blockId === id)!.active, false); + } +}); + +test("control: compressing a block BEFORE pruning still works (no behavior change)", () => { + const core = createCore(); + const cfg = config(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + + const applied = core.applyCompression({ + ranges: [{ startRef: "b2", endRef: "b2", summary: "S".repeat(80) }], + messages, + state, + config: cfg, + }); + assert.deepEqual(applied.result.errors, []); + assert.equal(applied.result.blocksCreated, 1); + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b2"]); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), ["raw-3", "raw-4"]); +}); + +test("syncBlocks: a block whose only visible representation is its rendered summary stays active", () => { + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + const prunedView = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + { id: summaryMessageId("b2"), role: "system" as const, contentType: "text" as const, text: "[Compressed conversation section]\nsum" }, + msg("raw-5", "u5 ".repeat(200)), + ]; + const { state: synced, deactivated } = syncBlocks(prunedView, state); + assert.deepEqual(deactivated, []); + assert.equal(synced.blocks.find((b) => b.blockId === "b2")!.active, true); +}); + +test("prune idempotency: re-pruning an already-pruned view keeps the summary in place without duplicating it", () => { + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + const full = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const once = prune(full, state); + const twice = prune(once, state); + assert.deepEqual(twice.map((m) => m.id), once.map((m) => m.id)); + const summaries = twice.filter((m) => m.id === summaryMessageId("b2")); + assert.equal(summaries.length, 1); + assert.equal(twice.indexOf(summaries[0]!), once.indexOf(once.find((m) => m.id === summaryMessageId("b2"))!)); +});