add branch/compact retries to agent-harness
This commit is contained in:
@@ -102,11 +102,16 @@ export interface RetryPolicy {
|
||||
/** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */
|
||||
export interface RetryCallbacks {
|
||||
/** Emitted before the backoff sleep of each retry attempt (1-indexed). */
|
||||
onRetry?: (attempt: number, maxAttempts: number, delayMs: number, errorMessage: string) => void;
|
||||
onRetryScheduled?: (
|
||||
attempt: number,
|
||||
maxAttempts: number,
|
||||
delayMs: number,
|
||||
errorMessage: string,
|
||||
) => void | Promise<void>;
|
||||
/** Emitted after the backoff sleep, immediately before the retried call starts. */
|
||||
onRetryAttemptStart?: () => void;
|
||||
onRetryAttemptStart?: () => void | Promise<void>;
|
||||
/** Emitted once when the loop ends: success if a later call returned a non-error message. */
|
||||
onRetryEnd?: (success: boolean, attempt: number, finalError?: string) => void;
|
||||
onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
class RetrySleepAbortError extends Error {
|
||||
@@ -143,9 +148,9 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
* - A non-retryable error (per {@link isRetryableAssistantError}, including quota/
|
||||
* billing exhaustion) is returned immediately so deterministic errors fail fast.
|
||||
* - Otherwise retries up to `maxRetries` times with exponential backoff, emitting
|
||||
* `onRetry` before each sleep, `onRetryAttemptStart` after each sleep before the
|
||||
* retried call starts, and `onRetryEnd` once at the end (whether the loop ends in
|
||||
* success, exhausted retries, or an aborted backoff).
|
||||
* `onRetryScheduled` before each sleep, `onRetryAttemptStart` after each sleep before
|
||||
* the retried call starts, and `onRetryFinished` once at the end (whether the loop
|
||||
* ends in success, exhausted retries, or an aborted backoff).
|
||||
*
|
||||
* When `policy` is undefined or disabled, the first response is returned unchanged
|
||||
* (equivalent to calling `produce()` directly).
|
||||
@@ -165,33 +170,33 @@ export async function retryAssistantCall(
|
||||
|
||||
// Success or abort: never retry an aborted message; non-error returns as-is.
|
||||
if (response.stopReason !== "error") {
|
||||
if (lastRetry) callbacks?.onRetryEnd?.(true, lastRetry.attempt);
|
||||
if (lastRetry) await callbacks?.onRetryFinished?.(true, lastRetry.attempt);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Non-retryable, or budget exhausted: return the final error message.
|
||||
if (attempt >= maxAttempts || !isRetryableAssistantError(response)) {
|
||||
if (lastRetry) callbacks?.onRetryEnd?.(false, lastRetry.attempt, response.errorMessage);
|
||||
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt, response.errorMessage);
|
||||
return response;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" };
|
||||
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
|
||||
callbacks?.onRetry?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
|
||||
await callbacks?.onRetryScheduled?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
|
||||
|
||||
// Normalize aborts during retry backoff to the same AssistantMessage shape as
|
||||
// provider stream aborts, so callers do not need to care when cancellation happened.
|
||||
try {
|
||||
await sleep(delayMs, signal);
|
||||
} catch (error) {
|
||||
callbacks?.onRetryEnd?.(false, attempt, lastRetry.errorMessage);
|
||||
await callbacks?.onRetryFinished?.(false, attempt, lastRetry.errorMessage);
|
||||
if (error instanceof RetrySleepAbortError) {
|
||||
return { ...response, stopReason: "aborted", errorMessage: undefined };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
callbacks?.onRetryAttemptStart?.();
|
||||
await callbacks?.onRetryAttemptStart?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,35 +80,35 @@ describe("retryAssistantCall", () => {
|
||||
|
||||
it("does not retry an aborted message", async () => {
|
||||
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "aborted" }));
|
||||
const onRetry = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry });
|
||||
const onRetryScheduled = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled });
|
||||
expect(res.stopReason).toBe("aborted");
|
||||
expect(produce).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
expect(onRetryScheduled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry a non-retryable error (quota/billing)", async () => {
|
||||
const produce = vi.fn(async () =>
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
|
||||
);
|
||||
const onRetry = vi.fn();
|
||||
const onRetryEnd = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryEnd });
|
||||
const onRetryScheduled = vi.fn();
|
||||
const onRetryFinished = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
|
||||
expect(res.stopReason).toBe("error");
|
||||
expect(produce).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
expect(onRetryEnd).not.toHaveBeenCalled();
|
||||
expect(onRetryScheduled).not.toHaveBeenCalled();
|
||||
expect(onRetryFinished).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries a transient error up to maxRetries then returns the final error", async () => {
|
||||
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
||||
const onRetry = vi.fn();
|
||||
const onRetryEnd = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryEnd });
|
||||
const onRetryScheduled = vi.fn();
|
||||
const onRetryFinished = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
|
||||
expect(res.stopReason).toBe("error");
|
||||
expect(produce).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
||||
expect(onRetry).toHaveBeenCalledTimes(3);
|
||||
expect(onRetryEnd).toHaveBeenCalledWith(false, 3, "terminated");
|
||||
expect(onRetryScheduled).toHaveBeenCalledTimes(3);
|
||||
expect(onRetryFinished).toHaveBeenCalledWith(false, 3, "terminated");
|
||||
});
|
||||
|
||||
it("stops retrying once a call succeeds", async () => {
|
||||
@@ -119,22 +119,22 @@ describe("retryAssistantCall", () => {
|
||||
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
||||
: fauxAssistantMessage("recovered");
|
||||
});
|
||||
const onRetryEnd = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryEnd });
|
||||
const onRetryFinished = vi.fn();
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished });
|
||||
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
||||
expect(produce).toHaveBeenCalledTimes(3);
|
||||
expect(onRetryEnd).toHaveBeenCalledWith(true, 2);
|
||||
expect(onRetryFinished).toHaveBeenCalledWith(true, 2);
|
||||
});
|
||||
|
||||
it("does not retry when policy is disabled", async () => {
|
||||
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
||||
const onRetry = vi.fn();
|
||||
const onRetryEnd = vi.fn();
|
||||
const res = await retryAssistantCall(produce, disabled, undefined, { onRetry, onRetryEnd });
|
||||
const onRetryScheduled = vi.fn();
|
||||
const onRetryFinished = vi.fn();
|
||||
const res = await retryAssistantCall(produce, disabled, undefined, { onRetryScheduled, onRetryFinished });
|
||||
expect(res.stopReason).toBe("error");
|
||||
expect(produce).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
expect(onRetryEnd).not.toHaveBeenCalled();
|
||||
expect(onRetryScheduled).not.toHaveBeenCalled();
|
||||
expect(onRetryFinished).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits onRetryAttemptStart after backoff before each retried call", async () => {
|
||||
@@ -147,15 +147,15 @@ describe("retryAssistantCall", () => {
|
||||
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
||||
: fauxAssistantMessage("recovered");
|
||||
});
|
||||
const onRetry = vi.fn((attempt: number) => {
|
||||
const onRetryScheduled = vi.fn((attempt: number) => {
|
||||
events.push(`retry:${attempt}`);
|
||||
});
|
||||
const onRetryAttemptStart = vi.fn(() => {
|
||||
events.push("attempt-start");
|
||||
});
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetry, onRetryAttemptStart });
|
||||
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryAttemptStart });
|
||||
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
||||
expect(onRetry).toHaveBeenCalledTimes(2);
|
||||
expect(onRetryScheduled).toHaveBeenCalledTimes(2);
|
||||
expect(onRetryAttemptStart).toHaveBeenCalledTimes(2);
|
||||
expect(events).toEqual([
|
||||
"produce:0",
|
||||
@@ -168,12 +168,12 @@ describe("retryAssistantCall", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts backoff sleep via signal, returns an aborted message, and emits onRetryEnd(false)", async () => {
|
||||
it("aborts backoff sleep via signal, returns an aborted message, and emits onRetryFinished(false)", async () => {
|
||||
const controller = new AbortController();
|
||||
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
||||
const policy: RetryPolicy = { enabled: true, maxRetries: 5, baseDelayMs: 10_000 };
|
||||
const onRetryEnd = vi.fn();
|
||||
const p = retryAssistantCall(produce, policy, controller.signal, { onRetryEnd });
|
||||
const onRetryFinished = vi.fn();
|
||||
const p = retryAssistantCall(produce, policy, controller.signal, { onRetryFinished });
|
||||
// Let one error call resolve and the first backoff sleep start, then abort.
|
||||
await vi.waitFor(() => expect(produce).toHaveBeenCalled());
|
||||
controller.abort();
|
||||
@@ -181,6 +181,6 @@ describe("retryAssistantCall", () => {
|
||||
expect(res.stopReason).toBe("aborted");
|
||||
expect(res.errorMessage).toBeUndefined();
|
||||
expect(produce).toHaveBeenCalledTimes(1);
|
||||
expect(onRetryEnd).toHaveBeenCalledWith(false, 1, "terminated");
|
||||
expect(onRetryFinished).toHaveBeenCalledWith(false, 1, "terminated");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user