Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- Auto-resume no longer replays failed turns. A turn that ended on a provider rate limit now resumes after the wait parsed from the error (plus a 1 min buffer, defaulting to 30 min when the provider gives no duration), and a turn that ended on any other agent error stops the loop with a notification instead of re-sending the resume message into the same failure.

## [1.6.2] - 2026-07-09

### Changed
Expand Down
51 changes: 43 additions & 8 deletions extensions/pi-autoresearch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
buildAutoresearchCompactionSummary,
} from "./compaction.ts";
import { resolveAutoresearchShortcuts } from "./shortcuts.ts";
import { lastAssistantError, rateLimitWaitMs } from "./provider-errors.ts";
import { sessionFilePath, sessionFileCandidates, ensureParentDir, AUTO_DIR } from "./paths.ts";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -187,6 +188,8 @@ interface AutoresearchRuntime {
pendingResumeTimer: ReturnType<typeof setTimeout> | null;
/** Resume message to send when the pending timer fires. */
pendingResumeMessage: string | null;
/** Delay used when (re)scheduling the pending resume — longer while rate limited. */
pendingResumeDelayMs: number;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -723,6 +726,10 @@ function createExperimentState(): ExperimentState {
};
}

// Outlasts pi's internal retry (setTimeout 0) and compaction-continue
// (setTimeout 100); see badlogic/pi-mono#2023, #2110.
const SETTLED_WINDOW_MS = 800;

function createSessionRuntime(): AutoresearchRuntime {
return {
autoresearchMode: false,
Expand All @@ -734,6 +741,7 @@ function createSessionRuntime(): AutoresearchRuntime {
state: createExperimentState(),
pendingResumeTimer: null,
pendingResumeMessage: null,
pendingResumeDelayMs: SETTLED_WINDOW_MS,
};
}

Expand Down Expand Up @@ -1069,9 +1077,6 @@ export default function autoresearchExtension(pi: ExtensionAPI) {
const BENCHMARK_GUARDRAIL =
"Be careful not to overfit to the benchmarks and do not cheat on the benchmarks.";

// Outlasts pi's internal retry (setTimeout 0) and compaction-continue
// (setTimeout 100); see badlogic/pi-mono#2023, #2110.
const SETTLED_WINDOW_MS = 800;
const shortcuts = resolveAutoresearchShortcuts();

const dashboardHintVariants = (): string[] => {
Expand Down Expand Up @@ -1154,18 +1159,25 @@ export default function autoresearchExtension(pi: ExtensionAPI) {
pi.sendUserMessage(message);
};

const schedulePendingResume = (ctx: ExtensionContext, runtime: AutoresearchRuntime, message: string): void => {
const schedulePendingResume = (
ctx: ExtensionContext,
runtime: AutoresearchRuntime,
message: string,
delayMs: number = SETTLED_WINDOW_MS,
): void => {
pausePendingResume(runtime);
runtime.pendingResumeMessage = message;
runtime.pendingResumeDelayMs = delayMs;
runtime.pendingResumeTimer = setTimeout(
() => sendPendingResumeIfReady(ctx, runtime),
SETTLED_WINDOW_MS,
delayMs,
);
};

const reschedulePendingResume = (ctx: ExtensionContext, runtime: AutoresearchRuntime): void => {
if (!hasPendingResume(runtime)) return;
schedulePendingResume(ctx, runtime, runtime.pendingResumeMessage!);
// Keep a rate-limit cooldown intact: rescheduling must not shorten the wait.
schedulePendingResume(ctx, runtime, runtime.pendingResumeMessage!, runtime.pendingResumeDelayMs);
};

const hasRunExperimentsThisSession = (runtime: AutoresearchRuntime): boolean =>
Expand Down Expand Up @@ -1483,6 +1495,7 @@ export default function autoresearchExtension(pi: ExtensionAPI) {
ctx: ExtensionContext,
gate: (runtime: AutoresearchRuntime) => boolean,
composeMessage: (ctx: ExtensionContext) => string = composeResumeMessage,
delayMs?: number,
): void => {
const runtime = getRuntime(ctx);
if (hasPendingResume(runtime)) {
Expand All @@ -1495,7 +1508,7 @@ export default function autoresearchExtension(pi: ExtensionAPI) {
notifyAutoResumeLimitReached(ctx, stopReason);
return;
}
schedulePendingResume(ctx, runtime, composeMessage(ctx));
schedulePendingResume(ctx, runtime, composeMessage(ctx), delayMs);
};

pi.on("session_before_compact", async (event, ctx) => {
Expand All @@ -1507,10 +1520,32 @@ export default function autoresearchExtension(pi: ExtensionAPI) {
ensurePendingResume(ctx, shouldAutoResumeAfterCompact, composeCompactionResumeMessage);
});

pi.on("agent_end", async (_event, ctx) => {
pi.on("agent_end", async (event, ctx) => {
const runtime = getRuntime(ctx);
runtime.runningExperiment = null;
if (overlayTui) overlayTui.requestRender();

// A failed turn ends like any other one, so resuming on the settle window
// replays the failure immediately. Wait out rate limits; stop on the rest.
const error = lastAssistantError(event.messages);
if (error !== null) {
if (!runtime.autoresearchMode) return;
const waitMs = rateLimitWaitMs(error);
if (waitMs === null) {
ctx.ui.notify(
`Autoresearch auto-resume stopped after an agent error: ${error.slice(0, 120)}`,
"warning",
);
return;
}
ctx.ui.notify(
`Rate limited — auto-resume in ${Math.ceil(waitMs / 60_000)}min`,
"warning",
);
ensurePendingResume(ctx, shouldAutoResumeAfterTurn, composeResumeMessage, waitMs);
return;
}

ensurePendingResume(ctx, shouldAutoResumeAfterTurn);
});

Expand Down
44 changes: 44 additions & 0 deletions extensions/pi-autoresearch/provider-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Provider error handling for auto-resume.
*
* A turn that died on a provider 429 ends like any other turn, so the default
* settle-window resume fires straight back into the cooldown. Parse the wait
* out of the error and delay the resume past it; for errors that are not
* transient, don't resume at all — replaying them just burns the loop.
*/

/** Used when the provider says "rate limited" without saying for how long. */
export const DEFAULT_RATE_LIMIT_WAIT_MS = 30 * 60_000;
/** Added to every wait — provider clocks and ours don't agree. */
const BUFFER_MS = 60_000;

interface AssistantMessageLike {
role?: string;
stopReason?: string;
errorMessage?: unknown;
}

const RATE_LIMITED_RE = /rate.?limit|too many requests|usage limit|quota|429/i;
const WAIT_RE = /(?:try again|retry|wait|reset)[^.\n]{0,40}?(\d+)\s*(h|m|s)/i;
const UNIT_MS: Record<string, number> = { h: 3_600_000, m: 60_000, s: 1000 };

/** How long to wait before resuming after `text`, or null if it isn't a rate limit. */
export function rateLimitWaitMs(text: string): number | null {
if (!RATE_LIMITED_RE.test(text)) return null;
const match = text.match(WAIT_RE);
const value = match ? Number.parseInt(match[1], 10) : 0;
if (match && value > 0) return value * UNIT_MS[match[2].toLowerCase()] + BUFFER_MS;
return DEFAULT_RATE_LIMIT_WAIT_MS + BUFFER_MS;
}

/** Error text of the final assistant message, or null if the turn succeeded. */
export function lastAssistantError(messages: AssistantMessageLike[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "assistant") continue;
if (message.stopReason !== "error") return null;
const error = message.errorMessage;
return typeof error === "string" && error ? error : "Assistant turn failed";
}
return null;
}
50 changes: 50 additions & 0 deletions tests/provider-errors.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
DEFAULT_RATE_LIMIT_WAIT_MS,
lastAssistantError,
rateLimitWaitMs,
} from "../extensions/pi-autoresearch/provider-errors.ts";

// Every wait carries a 1 min buffer on top of what the provider said.
const BUFFER = 60_000;

test("parses the wait out of rate limit errors", () => {
assert.equal(rateLimitWaitMs("rate limit exceeded, try again in 17 minutes"), 17 * 60_000 + BUFFER);
assert.equal(rateLimitWaitMs("usage limit reached · resets in 2 hours"), 2 * 3_600_000 + BUFFER);
assert.equal(rateLimitWaitMs('{"type":"rate_limit_error"} retry-after: 45s'), 45_000 + BUFFER);
});

test("falls back to a default wait when no duration is given", () => {
const fallback = DEFAULT_RATE_LIMIT_WAIT_MS + BUFFER;
assert.equal(rateLimitWaitMs("429 Too Many Requests"), fallback);
assert.equal(rateLimitWaitMs("quota exceeded for this key"), fallback);
assert.equal(rateLimitWaitMs("rate limit hit, try again in 0 min"), fallback);
});

test("non rate limit errors are not waits", () => {
assert.equal(rateLimitWaitMs("400 invalid request: context length exceeded"), null);
assert.equal(rateLimitWaitMs("connection reset by peer"), null);
});

test("reads the error off the final assistant message only", () => {
const failed = [
{ role: "assistant", stopReason: "stop" },
{ role: "user", content: "go" },
{ role: "assistant", stopReason: "error", errorMessage: "429 rate limit" },
];
assert.equal(lastAssistantError(failed), "429 rate limit");

const recovered = [
{ role: "assistant", stopReason: "error", errorMessage: "429 rate limit" },
{ role: "assistant", stopReason: "stop" },
];
assert.equal(lastAssistantError(recovered), null);

assert.equal(lastAssistantError([]), null);
assert.equal(
lastAssistantError([{ role: "assistant", stopReason: "error" }]),
"Assistant turn failed",
);
});