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
63 changes: 63 additions & 0 deletions packages/agent-runtime/src/compaction-retry.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
105 changes: 85 additions & 20 deletions packages/agent-runtime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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<ReturnType<typeof compact>>;
Expand Down Expand Up @@ -5045,28 +5085,53 @@ Delegation rules:
): Promise<boolean> {
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<void> {
Expand Down