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
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,33 @@

# changes.md — builtin compaction policy

## Recover stalled pre-prompt compaction across unsafe split turns (2026-09-16)

### What changed

- `pre_prompt` compaction now enters deterministic failure recovery only when
known usage has reached the effective hard cap. A failed proactive
below-cap attempt remains fail-closed and preserves the full context.
- When every prepared or earlier boundary retains unsafe split-turn content,
deterministic recovery now scans forward and selects the earliest suffix that
already passes the existing replay-safety, atomic tool-chain, and effective
token-budget checks.

### Why

- A resumed session at its hard cap could stall during provider summarization,
reject compaction, and then fail admission without attempting the
deterministic fallback. Below the cap, provider admission remains possible,
so destructive recovery would lose context without a liveness benefit.
- Long split turns can contain an unsafe historical tool result with no later
user boundary. Scanning only prepared, user, and earlier boundaries retains
that unsafe result forever even when a later assistant boundary is safe.

### Expected merge conflict zones

- `extension-wiring.ts`: required fallback reason classification.
- `deterministic-fallback.ts`: retained-suffix candidate ordering.

## Deterministic resume slice for an over-window restored context (2026-09-10)

### What changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ interface DeterministicFallbackDetails {
origin: "required-compaction-recovery";
failureKind: RequiredCompactionFallbackFailure;
taskIntent?: string;
retainedSuffix?: "prepared" | "latest-user-turn" | "earlier-safe-boundary";
retainedSuffix?: "prepared" | "latest-user-turn" | "earlier-safe-boundary" | "later-safe-boundary";
}

export interface DeterministicFallbackDiagnostic {
Expand Down Expand Up @@ -284,6 +284,7 @@ export function createRequiredCompactionFallback(
...(taskIntent ? { taskIntent } : {}),
};
let candidateCount = 0;
let sawUnsafeRetainedContent = false;

const syntheticCompaction: CompactionEntry = {
type: "compaction",
Expand Down Expand Up @@ -455,6 +456,7 @@ export function createRequiredCompactionFallback(
if (startIndex === undefined) return reject("context-reconstruction-failed");
const retainedStart = Math.min(startIndex, projectedMessages.length);
if (unsafeSuffix[retainedStart]) {
sawUnsafeRetainedContent = true;
const unsafeMessageIndex = unsafeIndexSuffix[retainedStart];
const unsafeMessage = projectedMessages[unsafeMessageIndex];
return reject("unsafe-retained-content", {
Expand Down Expand Up @@ -528,6 +530,23 @@ export function createRequiredCompactionFallback(
break;
}

// A split turn can contain an unsafe persisted tool payload after the
// prepared boundary and no later user message. Advance to the earliest safe
// suffix rather than retaining that payload forever. `projectCandidate`
// still rejects orphaned tool results, incomplete call chains, unsafe
// content, and over-budget suffixes.
if (sawUnsafeRetainedContent) {
for (let index = preparedBoundaryIndex + 1; index < branchEntries.length; index++) {
const entry = branchEntries[index];
if (entry.type === "compaction") continue;
const laterSafe = projectCandidate(entry.id, "later-safe-boundary");
if (laterSafe) {
if (diagnostics) diagnostics.candidatesChecked = candidateCount;
return laterSafe;
}
}
}

if (diagnostics) diagnostics.candidatesChecked = candidateCount;
return undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { ContextUsage, ExtensionContext, MessageEndEvent, SessionBeforeCompactEvent } from "../../types.ts";
import * as checkpointState from "./checkpoint-state.ts";
import { resolveEffectiveReserveTokens } from "./policy.ts";
import type { SpeculativeCompactionResult, SpeculativeCompactionSnapshot } from "./speculative.ts";

const IMAGE_PROMPT_TOKEN_ESTIMATE = 1_200;
Expand Down Expand Up @@ -43,8 +44,16 @@ export function isAbortedAssistantMessage(event: { message: AgentMessage }): boo
return event.message.role === "assistant" && "stopReason" in event.message && event.message.stopReason === "aborted";
}

export function isRequiredCompactionFallbackReason(reason: SessionBeforeCompactEvent["reason"]): boolean {
return reason === "manual" || reason === "threshold" || reason === "overflow";
export function requiresDeterministicCompactionFallback(
event: SessionBeforeCompactEvent,
usage: ContextUsage | undefined,
): boolean {
if (event.reason === "manual" || event.reason === "threshold" || event.reason === "overflow") return true;
if (event.reason !== "pre_prompt" || usage === undefined || usage.tokens === null || usage.contextWindow <= 0) {
return false;
}
const reserveTokens = resolveEffectiveReserveTokens(usage.contextWindow, event.preparation.settings);
return usage.tokens >= usage.contextWindow - reserveTokens;
}

export function recentCheckpoint(ctx: ExtensionContext): checkpointState.AgentCheckpoint | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ import {
getPromptContextWindow,
isAbortedAssistantMessage,
isMonitorableMessageEvent,
isRequiredCompactionFallbackReason,
linkAbortSignal,
recentCheckpoint,
requiresDeterministicCompactionFallback,
withAdditionalTokens,
} from "./extension-wiring.ts";
import { isIneffectiveCompaction } from "./yield.ts";
Expand Down Expand Up @@ -684,7 +684,7 @@ export default function compactionExtension(
}
if (
warmFailure !== undefined &&
isRequiredCompactionFallbackReason(event.reason) &&
requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) &&
classifyRequiredCompactionFallbackFailure(warmFailure) !== undefined &&
!event.signal.aborted &&
speculativeGeneration === claimedGeneration &&
Expand Down Expand Up @@ -717,7 +717,7 @@ export default function compactionExtension(
const message = error instanceof Error ? error.message : String(error);
const failureKind = classifyRequiredCompactionFallbackFailure(error);
if (
isRequiredCompactionFallbackReason(event.reason) &&
requiresDeterministicCompactionFallback(event, ctx.getContextUsage()) &&
failureKind !== undefined &&
!event.signal.aborted
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ describe("required compaction deterministic fallback", () => {
});

it("fails closed for every non-required reason even when typed truncation recovery would fit", async () => {
const nonRequiredReasons = ["pre_prompt", "branch", "extension"] satisfies CompactionReason[];
const nonRequiredReasons = ["branch", "extension"] satisfies CompactionReason[];
for (const reason of nonRequiredReasons) {
const handlers = createCompactionHandlers();
const harness = createBlockingContext({ usageTokens: 9_900 });
Expand Down Expand Up @@ -222,12 +222,123 @@ describe("required compaction deterministic fallback", () => {
}
});

it("recovers mandatory pre-prompt compaction after a typed summarizer failure", async () => {
const handlers = createCompactionHandlers();
const harness = createBlockingContext({ usageTokens: 9_600 });
harness.registration.setResponses([
fauxAssistantMessage("", {
stopReason: "error",
errorMessage: "upstream_stream_truncated: Responses stream ended before a terminal event",
}),
]);
const branchEntries = harness.ctx.sessionManager.getBranch();
const preparation = {
...prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!,
firstKeptEntryId: branchEntries.at(-1)?.id ?? "",
};

const result = await handlers.sessionBeforeCompact(
{
type: "session_before_compact",
reason: "pre_prompt",
willRetry: false,
requestId: "pre-prompt-required-recovery",
preparation,
branchEntries,
signal: new AbortController().signal,
},
harness.ctx,
);

expect(result).toMatchObject({
compaction: {
firstKeptEntryId: preparation.firstKeptEntryId,
details: { retainedSuffix: "prepared" },
},
});
expect(result).not.toHaveProperty("cancel");
expect(harness.registration.getCallLog()).toHaveLength(1);
});

it("preserves full context when proactive pre-prompt compaction fails below the hard cap", async () => {
const handlers = createCompactionHandlers();
const harness = createBlockingContext({ usageTokens: 9_599 });
harness.registration.setResponses([
fauxAssistantMessage("", {
stopReason: "error",
errorMessage: "upstream_stream_truncated: Responses stream ended before a terminal event",
}),
]);
const branchEntries = harness.ctx.sessionManager.getBranch();
const preparation = {
...prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!,
firstKeptEntryId: branchEntries.at(-1)?.id ?? "",
};

const result = await handlers.sessionBeforeCompact(
{
type: "session_before_compact",
reason: "pre_prompt",
willRetry: false,
requestId: "pre-prompt-proactive-fail-closed",
preparation,
branchEntries,
signal: new AbortController().signal,
},
harness.ctx,
);

expect(result).toMatchObject({ cancel: true });
expect(result).not.toHaveProperty("compaction");
expect(harness.ctx.sessionManager.getBranch()).toEqual(branchEntries);
expect(harness.registration.getCallLog()).toHaveLength(1);
});

it("classifies a duration watchdog without sleeping", () => {
expect(classifyRequiredCompactionFallbackFailure(new StreamDurationBudgetError(120_000))).toBe(
"summarization-timeout",
);
});

it("advances past unsafe split-turn content to the earliest replay-safe suffix", () => {
const harness = createBlockingContext({ usageTokens: 9_900 });
const preparedBoundaryId = harness.sessionManager.appendMessage({
role: "user",
content: "Continue the current turn.",
timestamp: 4,
});
harness.sessionManager.appendMessage({
...fauxAssistantMessage("", { timestamp: 5, stopReason: "toolUse" }),
content: [{ type: "toolCall", id: "unsafe-tool", name: "read", arguments: { path: "image.png" } }],
});
harness.sessionManager.appendMessage({
role: "toolResult",
toolCallId: "unsafe-tool",
toolName: "read",
content: [
{ type: "text", text: "Malformed image result" },
{ type: "image", mimeType: "image/png" },
] as never,
isError: false,
timestamp: 6,
});
const safeTailId = harness.sessionManager.appendMessage(
fauxAssistantMessage("Work continued safely.", { timestamp: 7 }),
);
const branchEntries = harness.sessionManager.getBranch();
const preparation = {
...prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true)!,
firstKeptEntryId: preparedBoundaryId,
};

const result = createRequiredCompactionFallback(preparation, 100_000, "summarization-timeout", {}, branchEntries);

expect(result).toMatchObject({
firstKeptEntryId: safeTailId,
details: { retainedSuffix: "later-safe-boundary" },
});
});

it("rejects truncation-looking generic errors and requires structured summary-request provenance", () => {
const truncationMessage = "upstream_stream_truncated: Responses stream ended before a terminal event";
for (const error of [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,28 +260,26 @@ describe("required compaction recovery from summarizer tool-call hijack", () =>
}
});

it("keeps idle and speculative-origin failures fail-closed", async () => {
for (const reason of ["pre_prompt", "extension"] as const) {
const handlers = createCompactionHandlers();
const harness = createBlockingContext({ usageTokens: 9_900 });
harness.registration.setResponses([bareToolCallResponse()]);
const branchEntries = harness.ctx.sessionManager.getBranch();
const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true);
const result = await handlers.sessionBeforeCompact(
{
type: "session_before_compact",
reason,
willRetry: false,
requestId: `fail-closed-${reason}`,
preparation: preparation!,
branchEntries,
signal: new AbortController().signal,
},
harness.ctx,
);
expect(result).toMatchObject({ cancel: true });
expect(result).not.toHaveProperty("compaction");
}
it("keeps speculative-origin failures fail-closed", async () => {
const handlers = createCompactionHandlers();
const harness = createBlockingContext({ usageTokens: 9_900 });
harness.registration.setResponses([bareToolCallResponse()]);
const branchEntries = harness.ctx.sessionManager.getBranch();
const preparation = prepareCompaction(branchEntries, harness.ctx.getCompactionSettings(), true);
const result = await handlers.sessionBeforeCompact(
{
type: "session_before_compact",
reason: "extension",
willRetry: false,
requestId: "fail-closed-extension",
preparation: preparation!,
branchEntries,
signal: new AbortController().signal,
},
harness.ctx,
);
expect(result).toMatchObject({ cancel: true });
expect(result).not.toHaveProperty("compaction");
});

it("keeps manual auth failures fail-closed", async () => {
Expand Down