compaction & branch summarization follow retry policy
fixes #6647 compaction (auto & manual) and branch summarization retry on transient failures. use the same retry policy from settings. emit events for the tui to show indication of retries
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
isContextOverflow,
|
||||
isRetryableAssistantError,
|
||||
modelsAreEqual,
|
||||
type RetryCallbacks,
|
||||
resetApiProviders,
|
||||
streamSimple,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
@@ -161,7 +162,21 @@ export type AgentSessionEvent =
|
||||
errorMessage?: string;
|
||||
}
|
||||
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
||||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
|
||||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
||||
| {
|
||||
type: "summarization_retry_start";
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
delayMs: number;
|
||||
errorMessage: string;
|
||||
}
|
||||
| { type: "summarization_retry_attempt_start"; source: "branchSummary" }
|
||||
| {
|
||||
type: "summarization_retry_attempt_start";
|
||||
source: "compaction";
|
||||
reason: "manual" | "threshold" | "overflow";
|
||||
}
|
||||
| { type: "summarization_retry_end" };
|
||||
|
||||
/** Listener function for agent session events */
|
||||
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
||||
@@ -1838,6 +1853,8 @@ export class AgentSession {
|
||||
this.thinkingLevel,
|
||||
this.agent.streamFunction,
|
||||
env,
|
||||
this.settingsManager.getRetrySettings(),
|
||||
this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),
|
||||
);
|
||||
summary = result.summary;
|
||||
firstKeptEntryId = result.firstKeptEntryId;
|
||||
@@ -2114,6 +2131,8 @@ export class AgentSession {
|
||||
this.thinkingLevel,
|
||||
this.agent.streamFunction,
|
||||
env,
|
||||
this.settingsManager.getRetrySettings(),
|
||||
this._summarizationRetryCallbacks({ source: "compaction", reason }),
|
||||
);
|
||||
summary = compactResult.summary;
|
||||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||
@@ -2620,6 +2639,37 @@ export class AgentSession {
|
||||
return isRetryableAssistantError(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry policy + callbacks shared by compaction and branch-summary summarization calls.
|
||||
* Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient
|
||||
* stream drop no longer fails the whole operation. `source` carries the context
|
||||
* the TUI needs to render the retry and recreate the underlying indicator.
|
||||
*/
|
||||
private _summarizationRetryCallbacks(
|
||||
source: { source: "branchSummary" } | { source: "compaction"; reason: "manual" | "threshold" | "overflow" },
|
||||
): RetryCallbacks {
|
||||
return {
|
||||
onRetry: (attempt, maxAttempts, delayMs, errorMessage) => {
|
||||
this._emit({
|
||||
type: "summarization_retry_start",
|
||||
attempt,
|
||||
maxAttempts,
|
||||
delayMs,
|
||||
errorMessage,
|
||||
});
|
||||
},
|
||||
onRetryAttemptStart: () => {
|
||||
this._emit({
|
||||
type: "summarization_retry_attempt_start",
|
||||
...source,
|
||||
});
|
||||
},
|
||||
onRetryEnd: () => {
|
||||
this._emit({ type: "summarization_retry_end" });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a retryable error for continuation with exponential backoff.
|
||||
* @returns true if the caller should continue the agent, false otherwise
|
||||
@@ -2934,6 +2984,8 @@ export class AgentSession {
|
||||
replaceInstructions,
|
||||
reserveTokens: branchSummarySettings.reserveTokens,
|
||||
streamFn: this.agent.streamFunction,
|
||||
retry: this.settingsManager.getRetrySettings(),
|
||||
callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
|
||||
});
|
||||
if (result.aborted) {
|
||||
return { cancelled: true, aborted: true };
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
*/
|
||||
|
||||
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import type { RetryCallbacks, RetryPolicy } from "@earendil-works/pi-ai";
|
||||
import { contentText } from "@earendil-works/pi-ai";
|
||||
import type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
|
||||
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
||||
import {
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
createCustomMessage,
|
||||
} from "../messages.ts";
|
||||
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
|
||||
import { estimateTokens } from "./compaction.ts";
|
||||
import { completeSummarization, estimateTokens } from "./compaction.ts";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
@@ -83,6 +83,10 @@ export interface GenerateBranchSummaryOptions {
|
||||
reserveTokens?: number;
|
||||
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
|
||||
streamFn?: StreamFn;
|
||||
/** Retry policy for transient summarization errors. Reuses coding-agent's `settings.retry`. */
|
||||
retry?: RetryPolicy;
|
||||
/** Optional callbacks for retry reporting (e.g. TUI retry indicators). */
|
||||
callbacks?: RetryCallbacks;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -300,6 +304,8 @@ export async function generateBranchSummary(
|
||||
replaceInstructions,
|
||||
reserveTokens = 16384,
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
} = options;
|
||||
|
||||
// Token budget = context window minus reserved space for prompt + response
|
||||
@@ -338,12 +344,11 @@ export async function generateBranchSummary(
|
||||
|
||||
// Call LLM for summarization. Prefer the session stream function so SDK
|
||||
// request behavior (timeouts, retries, attribution headers) stays consistent
|
||||
// without running through agent state/events.
|
||||
// without running through agent state/events. Retried via completeSummarization
|
||||
// so transient stream drops reuse the configured retry policy.
|
||||
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
|
||||
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
|
||||
const response = streamFn
|
||||
? await (await streamFn(model, context, requestOptions)).result()
|
||||
: await completeSimple(model, context, requestOptions);
|
||||
const response = await completeSummarization(model, context, requestOptions, streamFn, retry, callbacks);
|
||||
|
||||
// Check if aborted or errored
|
||||
if (response.stopReason === "aborted") {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { contentText } from "@earendil-works/pi-ai";
|
||||
import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall } from "@earendil-works/pi-ai";
|
||||
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
|
||||
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
||||
import { convertToLlm } from "../messages.ts";
|
||||
@@ -552,17 +552,24 @@ function createSummarizationOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
async function completeSummarization(
|
||||
/**
|
||||
* Shared choke point for every compaction/branch-summary summarization call. Wraps the
|
||||
* single LLM call in {@link retryAssistantCall} so transient stream drops (e.g.
|
||||
* `terminated`, socket close) honor the configured retry policy instead of failing
|
||||
* the whole compaction on the first attempt. Deterministic errors and aborts return
|
||||
* immediately (see {@link retryAssistantCall}).
|
||||
*/
|
||||
export async function completeSummarization(
|
||||
model: Model<any>,
|
||||
context: Context,
|
||||
options: SimpleStreamOptions,
|
||||
streamFn?: StreamFn,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<AssistantMessage> {
|
||||
if (!streamFn) {
|
||||
return completeSimple(model, context, options);
|
||||
}
|
||||
const stream = await streamFn(model, context, options);
|
||||
return stream.result();
|
||||
const produce = async (): Promise<AssistantMessage> =>
|
||||
streamFn ? (await streamFn(model, context, options)).result() : completeSimple(model, context, options);
|
||||
return retryAssistantCall(produce, retry, options.signal, callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,6 +588,8 @@ export async function generateSummary(
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
env?: Record<string, string>,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<string> {
|
||||
return (
|
||||
await generateSummaryWithUsage(
|
||||
@@ -595,6 +604,8 @@ export async function generateSummary(
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
env,
|
||||
retry,
|
||||
callbacks,
|
||||
)
|
||||
).text;
|
||||
}
|
||||
@@ -612,6 +623,8 @@ export async function generateSummaryWithUsage(
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
env?: Record<string, string>,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<{ text: string; usage: Usage }> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
@@ -651,6 +664,8 @@ export async function generateSummaryWithUsage(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
completionOptions,
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
@@ -801,6 +816,8 @@ export async function compact(
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
env?: Record<string, string>,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<CompactionResult> {
|
||||
const {
|
||||
firstKeptEntryId,
|
||||
@@ -833,6 +850,8 @@ export async function compact(
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
env,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
historyText = historyResult.text;
|
||||
historyUsage = historyResult.usage;
|
||||
@@ -847,6 +866,8 @@ export async function compact(
|
||||
signal,
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
// Merge into single summary
|
||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;
|
||||
@@ -865,6 +886,8 @@ export async function compact(
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
env,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
summary = result.text;
|
||||
summaryUsage = result.usage;
|
||||
@@ -900,6 +923,8 @@ async function generateTurnPrefixSummary(
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<{ text: string; usage: Usage }> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
@@ -921,6 +946,8 @@ async function generateTurnPrefixSummary(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
|
||||
@@ -3110,6 +3110,32 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
break;
|
||||
}
|
||||
|
||||
case "summarization_retry_start": {
|
||||
this.showError(event.errorMessage);
|
||||
this.showStatusIndicator(
|
||||
new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs),
|
||||
);
|
||||
this.ui.requestRender();
|
||||
break;
|
||||
}
|
||||
|
||||
case "summarization_retry_attempt_start": {
|
||||
this.clearStatusIndicator("retry");
|
||||
if (event.source === "branchSummary") {
|
||||
this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui));
|
||||
} else {
|
||||
this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason));
|
||||
}
|
||||
this.ui.requestRender();
|
||||
break;
|
||||
}
|
||||
|
||||
case "summarization_retry_end": {
|
||||
this.clearStatusIndicator("retry");
|
||||
this.ui.requestRender();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
/**
|
||||
* Regression for #6647: compaction runs a single non-retried summarization call, so a
|
||||
* transient mid-stream socket death (`terminated`) failed the whole compaction.
|
||||
* Verifies that summarization now reuses `settings.retry` (bounded retries with
|
||||
* exponential backoff gated on isRetryableAssistantError), emits
|
||||
* `summarization_retry_*` events, and that aborts / non-retryable errors are not retried.
|
||||
*/
|
||||
describe("#6647 compaction retries transient summarization failures", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
function createUsage(totalTokens: number) {
|
||||
return {
|
||||
input: totalTokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function seedCompactableSession(harness: Harness): void {
|
||||
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
|
||||
const now = Date.now();
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "message to compact" }],
|
||||
timestamp: now - 1000,
|
||||
});
|
||||
const model = harness.getModel();
|
||||
const assistant: AssistantMessage = {
|
||||
...fauxAssistantMessage("", { stopReason: "stop", timestamp: now - 500 }),
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createUsage(100),
|
||||
};
|
||||
assistant.content = [{ type: "text", text: "assistant response to compact" }];
|
||||
harness.sessionManager.appendMessage(assistant);
|
||||
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
|
||||
}
|
||||
|
||||
/** streamFn that responds with the given sequence of assistant messages across calls. */
|
||||
function useScriptedStreamFn(harness: Harness, script: AssistantMessage[]): () => number {
|
||||
let callCount = 0;
|
||||
const streamFunction: StreamFn = (model) => {
|
||||
const message = script[callCount] ?? script[script.length - 1]!;
|
||||
callCount++;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: message.stopReason,
|
||||
error: { ...message, api: model.api, provider: model.provider, model: model.id },
|
||||
});
|
||||
} else {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: message.stopReason,
|
||||
message: { ...message, api: model.api, provider: model.provider, model: model.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
harness.session.agent.streamFunction = streamFunction;
|
||||
return () => callCount;
|
||||
}
|
||||
|
||||
it("retries a transient `terminated` summarization error and compacts successfully", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
seedCompactableSession(harness);
|
||||
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
|
||||
|
||||
const model = harness.getModel();
|
||||
const error = (errorMessage: string): AssistantMessage => ({
|
||||
...fauxAssistantMessage("", { stopReason: "error", errorMessage }),
|
||||
usage: createUsage(10),
|
||||
});
|
||||
const success: AssistantMessage = {
|
||||
...fauxAssistantMessage("recovered summary"),
|
||||
usage: createUsage(10),
|
||||
};
|
||||
const getCallCount = useScriptedStreamFn(harness, [error("terminated"), error("terminated"), success]);
|
||||
|
||||
const result = await harness.session.compact();
|
||||
|
||||
expect(result.summary).toContain("recovered summary");
|
||||
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
|
||||
const starts = harness.eventsOfType("summarization_retry_start");
|
||||
const ends = harness.eventsOfType("summarization_retry_end");
|
||||
expect(starts).toHaveLength(2);
|
||||
expect(ends).toHaveLength(1);
|
||||
expect(starts[0]).toMatchObject({ attempt: 1, maxAttempts: 3, errorMessage: "terminated" });
|
||||
expect(starts[1]).toMatchObject({ attempt: 2, maxAttempts: 3 });
|
||||
expect(ends[0]).toMatchObject({ type: "summarization_retry_end" });
|
||||
// model.* referenced to keep imports honest
|
||||
expect(model.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not retry a non-retryable error (insufficient_quota)", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
seedCompactableSession(harness);
|
||||
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
|
||||
|
||||
const error: AssistantMessage = {
|
||||
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
|
||||
usage: createUsage(10),
|
||||
};
|
||||
const getCallCount = useScriptedStreamFn(harness, [error]);
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow("insufficient_quota");
|
||||
expect(getCallCount()).toBe(1);
|
||||
expect(harness.eventsOfType("summarization_retry_start")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not retry when retry is disabled", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
seedCompactableSession(harness);
|
||||
harness.settingsManager.applyOverrides({ retry: { enabled: false, maxRetries: 3, baseDelayMs: 0 } });
|
||||
|
||||
const error: AssistantMessage = {
|
||||
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
|
||||
usage: createUsage(10),
|
||||
};
|
||||
const getCallCount = useScriptedStreamFn(harness, [error]);
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow("terminated");
|
||||
expect(getCallCount()).toBe(1);
|
||||
expect(harness.eventsOfType("summarization_retry_start")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stops retrying after maxRetries and reports failure", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
seedCompactableSession(harness);
|
||||
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 2, baseDelayMs: 0 } });
|
||||
|
||||
const error: AssistantMessage = {
|
||||
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
|
||||
usage: createUsage(10),
|
||||
};
|
||||
const getCallCount = useScriptedStreamFn(harness, [error, error, error]);
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow("terminated");
|
||||
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
|
||||
const starts = harness.eventsOfType("summarization_retry_start");
|
||||
const ends = harness.eventsOfType("summarization_retry_end");
|
||||
expect(starts).toHaveLength(2);
|
||||
expect(ends).toHaveLength(1);
|
||||
expect(ends[0]).toMatchObject({ type: "summarization_retry_end" });
|
||||
});
|
||||
|
||||
it("aborts an in-flight retry backoff via abortCompaction", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
seedCompactableSession(harness);
|
||||
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 5, baseDelayMs: 30_000 } });
|
||||
|
||||
const error: AssistantMessage = {
|
||||
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
|
||||
usage: createUsage(10),
|
||||
};
|
||||
useScriptedStreamFn(harness, [error, error, error]);
|
||||
|
||||
const compactPromise = harness.session.compact();
|
||||
// Let the first error resolve and the retry backoff sleep start.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
harness.session.abortCompaction();
|
||||
|
||||
// The aborted retry backoff rejects with an AbortError (matching the real SDK
|
||||
// abort path), which compaction classifies as aborted.
|
||||
await expect(compactPromise).rejects.toThrow();
|
||||
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
|
||||
expect(compactionEnd).toMatchObject({ aborted: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user