diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md index f10c03831..6777f9e12 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/changes.md @@ -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 diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts index b6e3cc211..5f11d23cb 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts @@ -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 { @@ -284,6 +284,7 @@ export function createRequiredCompactionFallback( ...(taskIntent ? { taskIntent } : {}), }; let candidateCount = 0; + let sawUnsafeRetainedContent = false; const syntheticCompaction: CompactionEntry = { type: "compaction", @@ -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", { @@ -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; } diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts index a14f2d44e..fbbf71702 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/extension-wiring.ts @@ -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; @@ -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 { diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts index ead8f8bc5..4bd9b1d1b 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/index.ts @@ -82,9 +82,9 @@ import { getPromptContextWindow, isAbortedAssistantMessage, isMonitorableMessageEvent, - isRequiredCompactionFallbackReason, linkAbortSignal, recentCheckpoint, + requiresDeterministicCompactionFallback, withAdditionalTokens, } from "./extension-wiring.ts"; import { isIneffectiveCompaction } from "./yield.ts"; @@ -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 && @@ -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 ) { diff --git a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts index 67a083c97..933dc94ec 100644 --- a/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts +++ b/packages/coding-agent/test/compaction/required-compaction-deterministic-fallback.test.ts @@ -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 }); @@ -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 [ diff --git a/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts b/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts index 374e01fb9..5b636cede 100644 --- a/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts +++ b/packages/coding-agent/test/compaction/summarization-tooluse-retry.test.ts @@ -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 () => {