Skip to content
Merged
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
42 changes: 17 additions & 25 deletions packages/agent-harness/src/detect-usage-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,12 @@
*/

const USAGE_LIMIT_PATTERNS = [
// "You've hit your <session|fast|weekly|monthly spend|5-hour|usage> limit"
/hit your (?:session|usage|account|weekly|monthly|fast|5[- ]?hour) (?:spend )?limit/i,
// "You have reached your usage limit"
/reached your (?:usage|session|account|weekly|monthly|fast) (?:spend )?limit/i,
// "usage limit reached", "fast limit reached", "session limit reached"
/(?:usage|session|account|fast|usage credit) limit reached/i,
// generic Claude/Anthropic phrasing
/claude (?:ai )?usage limit/i,
// Keep subscription messages anchored to the whole line. Codex writes its
// tool transcript to stderr, so a substring match also sees source such as
// `super("Agent usage limit reached")` as though it were a CLI diagnostic.
/^(?:error:\s*)?you(?:'|’)ve hit your (?:session|usage|account|weekly|monthly|fast|5[- ]?hour) (?:spend )?limit(?:\s*(?:[.·—-]\s*)?(?:resets?|try again|available again|retry[- ]after)\b[^\n]*)?[.!]?$/i,
/^(?:error:\s*)?you have reached your (?:usage|session|account|weekly|monthly|fast) (?:spend )?limit(?:\s*(?:[.·—-]\s*)?(?:resets?|try again|available again|retry[- ]after)\b[^\n]*)?[.!]?$/i,
/^(?:error:\s*)?(?:(?:usage|session|account|fast|usage credit) limit reached|claude (?:ai )?usage limit(?: reached)?)(?:\s*(?:[.·—-]\s*)?(?:resets?|try again|available again|retry[- ]after)\b[^\n]*)?[.!]?$/i,
] as const;

// These are intentionally evaluated line-by-line and only when the line looks
Expand All @@ -63,12 +61,9 @@ const PROVIDER_LIMIT_PATTERNS = [

const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCodePoint(0x1b)}\\[[0-?]*[ -/]*[@-~]`, "g");

type OutputStream = "stdout" | "stderr";

interface OutputLine {
raw: string;
normalized: string;
stream: OutputStream;
}

const RESET_PATTERNS = [
Expand Down Expand Up @@ -115,13 +110,12 @@ function stripAnsi(text: string): string {
}

/**
* Split one captured stream into matchable lines with their source stream.
* Split one captured stream into matchable lines.
*/
function outputLines(text: string, stream: OutputStream): OutputLine[] {
function outputLines(text: string): OutputLine[] {
return text.split(/\r?\n/).map((raw) => ({
raw,
normalized: stripAnsi(raw),
stream,
}));
}

Expand All @@ -135,11 +129,13 @@ function isSourceOrDiffLine(line: string): boolean {
const trimmed = line.trim();
return (
/^(?:diff --git|index\b|---\s|\+\+\+\s|@@\s)/i.test(trimmed) ||
/^[+-]{1,3}\s/.test(trimmed) ||
/^[+-](?![+-])/.test(trimmed) ||
/^(?:\/\/|\/\*|\*|#|>|```|~~~)/.test(trimmed) ||
// `rg -n`, grep, and compiler-style locations: path:line[:column]:content.
/^(?:(?:\.?\.?\/|\/)?(?:[^:\s]+\/)+[^:\s]+|[^:\s]+\.[a-z\d]+):\d+(?::\d+)?:/i.test(trimmed) ||
/^(?:const|let|var|function|class|import|export|return)\b/.test(trimmed) ||
/^(?:super|throw\s+new\s+Error|[\w$.]+\.(?:error|warn|log))\s*\(/.test(trimmed) ||
/^(?:["'`]).*(?:["'`])[,;)]?$/.test(trimmed) ||
/\b(?:includes|startsWith|endsWith|\.match|\.test)\s*\(/.test(trimmed) ||
/=>/.test(trimmed)
);
Expand All @@ -156,19 +152,15 @@ function isLikelyProviderDiagnostic(line: OutputLine): boolean {
return false;
}

// Provider CLIs commonly write transport failures to stderr.
if (line.stream === "stderr") {
return true;
}

// Also accept structured or explicitly diagnostic stdout from SDK-backed
// harnesses (for example AI_RetryError and JSON error responses).
// Accept structured or explicitly diagnostic output from SDK-backed
// harnesses (for example AI_RetryError and JSON error responses). Do not
// inherently trust stderr: Codex uses it for its complete tool transcript.
if (
/^(?:error|fatal|warning)\b/i.test(trimmed) ||
/^(?:(?:api|provider)\s+)?(?:error|fatal|warning)\b/i.test(trimmed) ||
/^(?:AI_RetryError|Too Many Requests)\b/i.test(trimmed) ||
/^HTTP\s*429\b/i.test(trimmed) ||
/^\s*[{"[].*(?:rate_limit|quota).*[}\]]\s*$/i.test(trimmed) ||
/\b(?:error|exception|failed|failure|last error|provider|response|returned|status|retry)\b/i.test(
/\b(?:last error|provider (?:error|response)|response status|returned (?:an? )?(?:error|status)|request failed|retrying)\b/i.test(
trimmed,
)
) {
Expand All @@ -189,7 +181,7 @@ function isLikelyProviderDiagnostic(line: OutputLine): boolean {
function findUsageLimitLine(stdout: string, stderr: string): OutputLine | undefined {
// Check stderr first because it is the conventional diagnostic channel, then
// inspect stdout for explicit subscription-limit and structured SDK errors.
const lines = [...outputLines(stderr, "stderr"), ...outputLines(stdout, "stdout")];
const lines = [...outputLines(stderr), ...outputLines(stdout)];

return lines.find((line) => {
const normalized = line.normalized.trim();
Expand Down
30 changes: 30 additions & 0 deletions packages/agent-harness/tests/detect-usage-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ describe("detectUsageLimit", () => {
expect(detectUsageLimit("", "Error: 429 Too Many Requests").limited).toBe(true);
});

test("detects an API error prefix on stderr", () => {
expect(detectUsageLimit("", "API Error: 429 Too Many Requests").limited).toBe(true);
});

test("detects an HTTP 429 diagnostic on stdout", () => {
expect(detectUsageLimit("HTTP 429: Too Many Requests", "").limited).toBe(true);
});
Expand Down Expand Up @@ -107,6 +111,32 @@ describe("detectUsageLimit", () => {
expect(detectUsageLimit("", source).limited).toBe(false);
});

test("ignores usage-limit phrases in source emitted by Codex tools", () => {
const source = [
' super(`Agent usage limit reached${resetHint ? ` (resets ${resetHint})` : ""}`);',
'throw new Error("Claude usage limit reached");',
'"usage limit reached"',
"The previous run reported Claude usage limit reached but recovered.",
"Agent usage limit reached. Stopping; will retry on the next scheduled run.",
].join("\n");

expect(detectUsageLimit(source, "").limited).toBe(false);
// Codex writes its formatted tool transcript to stderr.
expect(detectUsageLimit("", source).limited).toBe(false);
});

test("ignores compact diff additions without whitespace after the marker", () => {
const diff = '+throw new Error("Claude usage limit reached");';

expect(detectUsageLimit("", diff).limited).toBe(false);
});

test("ignores provider-like prose on stderr", () => {
const source = "The test expects Too Many Requests but received a socket error.";

expect(detectUsageLimit("", source).limited).toBe(false);
});

test("ignores a test count containing 429", () => {
expect(detectUsageLimit("Ran 429 tests across 47 files.", "").limited).toBe(false);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2938,6 +2938,9 @@ async function runEstimation(

const usage = detectUsageLimit(stdoutOutput, stderrOutput);
if (usage.limited) {
if (usage.matchedLine) {
console.log(` Matched output: ${usage.matchedLine}`);
}
reject(new UsageLimitError(usage.resetsAt));
return;
}
Expand Down Expand Up @@ -3422,6 +3425,9 @@ async function runAgentHarness(
usage.resetsAt ? ` (resets ${usage.resetsAt})` : ""
}`,
);
if (usage.matchedLine) {
console.log(` Matched output: ${usage.matchedLine}`);
}
reject(new UsageLimitError(usage.resetsAt));
return;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/code/src/webhook-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,9 @@ async function runAgentHarnessForReview(
maxTurnsReached,
});
} else if (usage.limited) {
if (usage.matchedLine) {
console.log(` Matched output: ${usage.matchedLine}`);
}
// A usage/rate limit is account-global — surface it so the caller can
// pause the queue until reset rather than treating it as a task failure.
resolve({
Expand Down
Loading