diff --git a/packages/agent-runtime/src/compaction-retry.test.ts b/packages/agent-runtime/src/compaction-retry.test.ts new file mode 100644 index 000000000..3edec5c54 --- /dev/null +++ b/packages/agent-runtime/src/compaction-retry.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +/** + * Pure-logic coverage for the #543 compaction retry policy. + * Runtime wiring: packages/agent-runtime/src/runtime.ts + * (performCompaction / shrinkPreparationForSummary). + */ + +const COMPACTION_SUMMARY_MAX_ATTEMPTS = 3; +const COMPACTION_SUMMARY_RETRY_BASE_MS = 2_000; +const COMPACTION_SUMMARY_RETRY_MAX_MS = 8_000; + +function retryDelayMs(attempt: number): number { + return Math.min( + COMPACTION_SUMMARY_RETRY_MAX_MS, + COMPACTION_SUMMARY_RETRY_BASE_MS * 2 ** (attempt - 1), + ); +} + +function wouldExceed(historyTokens: number, limit: number): boolean { + return historyTokens >= limit; +} + +/** Mirror of shrinkPreparationForSummary budget loop. */ +function shrink(historyTokens: number[], limit: number): number[] | undefined { + const messages = [...historyTokens]; + if (messages.length <= 1) return undefined; + while (messages.length > 1) { + messages.shift(); + const total = messages.reduce((a, b) => a + b, 0); + if (!wouldExceed(total, limit)) return messages; + } + return undefined; +} + +describe("compaction summary retry policy (#543)", () => { + it("caps retries at three attempts before retained-tail fallback", () => { + expect(COMPACTION_SUMMARY_MAX_ATTEMPTS).toBe(3); + }); + + it("backs off exponentially and caps the delay", () => { + expect(retryDelayMs(1)).toBe(2_000); + expect(retryDelayMs(2)).toBe(4_000); + expect(retryDelayMs(3)).toBe(8_000); + }); + + it("shrinks oversized payloads until the summary input fits", () => { + const history = [10_000, 8_000, 5_000, 2_000]; + const reduced = shrink(history, 9_000); + expect(reduced).toBeDefined(); + expect(reduced!.reduce((a, b) => a + b, 0)).toBeLessThan(9_000); + }); + + it("returns undefined when even a minimal payload cannot fit", () => { + const history = [50_000, 40_000]; + expect(shrink(history, 10_000)).toBeUndefined(); + }); + + it("keeps at least one message so compact() still has input", () => { + const history = [3_000, 3_000]; + expect(shrink(history, 1_000)).toBeUndefined(); + }); +}); diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 015eead9f..0f37e61c8 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -412,6 +412,17 @@ const COMPACTION_FALLBACK_MAX_SUMMARY_CHARS = 12_000; const COMPACTION_SUMMARY_PROMPT_SAFETY_TOKENS = 2_048; const COMPACTION_FALLBACK_MARKER = "[automatic context recovery: older context was omitted after summary generation failed]"; + +/** + * Transient summary failures retry the same compaction request this many times + * before retained-tail recovery (#543). Budget-exceeded attempts use a reduced + * payload and still count against the same cap. + */ +const COMPACTION_SUMMARY_MAX_ATTEMPTS = 3; +/** Backoff between summary retries; capped so a long turn is not pinned. */ +const COMPACTION_SUMMARY_RETRY_BASE_MS = 2_000; +const COMPACTION_SUMMARY_RETRY_MAX_MS = 8_000; + /** Path-scoped rules are best-effort and must not stall a file tool turn. */ export const PATH_INSTRUCTION_RESOLUTION_TIMEOUT_MS = 2_000; const PATH_SCOPED_INSTRUCTION_TOOLS = new Set([ @@ -4621,7 +4632,7 @@ Delegation rules: const summary = [ previousSummary, COMPACTION_FALLBACK_MARKER, - "The automatic summary request did not complete. Older messages before this checkpoint are omitted from the next model request.", + "Summary generation failed after retries. Older messages before this checkpoint are omitted from the next model request.", `The complete transcript remains available in the session. ${continuation}`, ].join("\n\n"); return this.createCheckpoint( @@ -4638,6 +4649,28 @@ Delegation rules: ); } + /** + * Drop oldest summarize-eligible messages until the summary input fits the + * provider budget (#543). Returns undefined when even a minimal payload + * cannot fit — the caller then falls back to retained-tail recovery. + */ + private shrinkPreparationForSummary( + preparation: ShapedPreparation, + budget: { hardLimit: number; requestHeadroom: number }, + ): ShapedPreparation | undefined { + const messages = [...preparation.messagesToSummarize]; + if (messages.length <= 1) return undefined; + // Keep at least one message so compact() still has something to summarize. + while (messages.length > 1) { + messages.shift(); + const candidate: ShapedPreparation = { ...preparation, messagesToSummarize: messages }; + if (!this.compactionSummaryWouldExceedBudget(candidate, budget)) { + return candidate; + } + } + return undefined; + } + /** * The recovery path retains less than a normal checkpoint: its summary is a * carried-forward one rather than a fresh one, so the retained messages are @@ -4890,15 +4923,22 @@ Delegation rules: } if (this.compactionSummaryWouldExceedBudget(preparation.value, budget)) { - return { - ok: false, - entries, - budget, - preparation: preparation.value, - tokensBefore: preparation.value.tokensBefore, - message: "Compaction summary input exceeds the safe model budget", - recoverable: true, - }; + // Do not skip the model on the first oversized attempt (#543). Shrink + // the summary input by dropping oldest messages until the budget guard + // clears, then summarize that reduced payload. + const reduced = this.shrinkPreparationForSummary(preparation.value, budget); + if (!reduced) { + return { + ok: false, + entries, + budget, + preparation: preparation.value, + tokensBefore: preparation.value.tokensBefore, + message: "Compaction summary input exceeds the safe model budget", + recoverable: true, + }; + } + preparation = { ok: true, value: reduced }; } let result: Awaited>; @@ -5045,28 +5085,53 @@ Delegation rules: ): Promise { this.emit({ type: "compaction_start", reason }); this.compactionAbort = new AbortController(); - let build: CheckpointBuild; + let build: CheckpointBuild | undefined; + let lastMessage = ""; try { - build = await this.buildCheckpoint(this.compactionAbort.signal, retentionMode); + // Retry transient summary failures before retained-tail recovery (#543). + // Budget-exceeded paths also retry: the next attempt uses a reduced + // payload instead of skipping the model outright. + for (let attempt = 1; attempt <= COMPACTION_SUMMARY_MAX_ATTEMPTS; attempt++) { + if (this.compactionAbort.signal.aborted) break; + build = await this.buildCheckpoint(this.compactionAbort.signal, retentionMode); + if (build.ok) break; + lastMessage = build.message; + if (!build.recoverable) break; + if (attempt === COMPACTION_SUMMARY_MAX_ATTEMPTS) break; + const delayMs = Math.min( + COMPACTION_SUMMARY_RETRY_MAX_MS, + COMPACTION_SUMMARY_RETRY_BASE_MS * 2 ** (attempt - 1), + ); + try { + await delayWithAbort(delayMs, this.compactionAbort.signal); + } catch { + break; + } + } } finally { this.compactionAbort = undefined; } - if (!build.ok) { - if (!build.recoverable) { - this.emitCompactionFailure(reason, build.tokensBefore, build.message); + const finalBuild = build; + if (!finalBuild || !finalBuild.ok) { + if (!finalBuild || !finalBuild.recoverable) { + this.emitCompactionFailure( + reason, + finalBuild?.tokensBefore, + finalBuild?.message ?? lastMessage, + ); return false; } return await this.recoverCompactionFailure( - build.entries, - build.budget, + finalBuild.entries, + finalBuild.budget, reason, willRetry, - build.preparation, - build.message, + finalBuild.preparation, + lastMessage || finalBuild.message, retentionMode, ); } - return await this.installCheckpoint(build, reason, willRetry, retentionMode); + return await this.installCheckpoint(finalBuild, reason, willRetry, retentionMode); } async compactManually(): Promise {