Skip to content
Closed
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
45 changes: 38 additions & 7 deletions src/boundaries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { activeBlocks, blockById } from "./state.js";
import { isSummaryMessageId, summaryMessageId } from "./prune.js";
import type {
CompressionBlock,
CompressionState,
CoreMessage,
ResolvedBoundary,
Expand Down Expand Up @@ -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 =
Expand All @@ -115,7 +120,7 @@ export function resolveBoundaries(
const nestedBlockIds: string[] = [];
const nestedSeen = new Set<string>();
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);
Expand Down Expand Up @@ -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 };
}
Expand All @@ -209,14 +214,20 @@ 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.`,
);
}

/**
* 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,
Expand All @@ -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;
}
}
Expand All @@ -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<string, number>,
): 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<string, number>,
Expand Down
12 changes: 6 additions & 6 deletions src/compress.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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<string, number>();
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);
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
37 changes: 36 additions & 1 deletion src/prune.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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]!);
}

Expand All @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions src/sync.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { summaryMessageId } from "./prune.js";
import type { CompressionState, CoreMessage } from "./types.js";

export interface SyncResult {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading