Files
fusion/packages/engine/src/usage-limit-detector.ts
gsxdsm ceaddc6663 fix: adapt to AgentState.error → errorMessage rename
pi-coding-agent 0.70 replaced the mutable \`AgentState.error\` field with
a readonly \`AgentState.errorMessage\`. \`session.prompt()\` still does
not throw when retries are exhausted, so we still need to re-raise the
stored error after each prompt.

- checkSessionError (usage-limit-detector): widen parameter to accept
  either key; prefer errorMessage so new sessions work, fall back to
  error so we can deploy without forcing everyone's caches to rebuild.
- agent-reflection: same widening at the call site.
- pi.ts helpers: read both keys, best-effort clear both (the new field
  is readonly, so the write is a no-op on 0.70 sessions but still
  matters for mock sessions in tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:02:35 -07:00

119 lines
4.4 KiB
TypeScript

/**
* Usage Limit Detector — classifies API errors as usage-limit-related
* and triggers the global pause mechanism when detected.
*
* Usage-limit errors indicate systemic conditions (rate limits, quota exceeded,
* billing issues, overloaded APIs) where continued retrying across multiple
* agents is wasteful. Transient server errors (500, timeout, connection refused)
* are NOT classified as usage-limit errors — they are temporary and may resolve
* on their own via per-session retry.
*/
import type { TaskStore } from "@fusion/core";
import { createLogger } from "./logger.js";
const log = createLogger("usage-limit");
/**
* Patterns that indicate API usage/capacity/billing limits.
* These are checked case-insensitively against error messages.
*/
const USAGE_LIMIT_PATTERNS: RegExp[] = [
/overloaded/i,
/rate[_\s]?limit/i,
/too many requests/i,
/\b429\b/,
/\b529\b/,
/quota/i,
/billing/i,
/\bcredit/i,
/insufficient.*(quota|credit|balance|fund)/i,
];
/**
* Classify whether an error message indicates a usage-limit condition.
*
* Returns `true` for rate limits, overloaded errors, quota/billing issues —
* conditions where all agents should stop. Returns `false` for transient
* server errors (500/502/503/504, timeout, connection refused) that may
* resolve on their own.
*/
export function isUsageLimitError(errorMessage: string): boolean {
return USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(errorMessage));
}
/**
* Lightweight coordinator that agents call when they detect usage-limit errors.
* Triggers the global pause mechanism by calling
* `store.updateSettings({ globalPause: true, globalPauseReason: "rate-limit" })`.
*
* **Idempotency:** Tracks an internal `paused` flag so that multiple concurrent
* agents hitting limits only trigger one pause. The flag resets when `globalPause`
* is externally set back to `false` (detected by reading settings before pausing).
*/
/**
* Check if an agent session resolved with an error after exhausting retries.
*
* pi-coding-agent's `session.prompt()` does **not** throw when retries are
* exhausted — it resolves normally and stores the error on
* `session.state.errorMessage` (was `session.state.error` prior to
* pi-coding-agent 0.70). Call this immediately after every
* `await session.prompt(...)` to re-raise the swallowed error so existing
* `catch` blocks (with `isUsageLimitError` checks) can detect rate-limit
* conditions and trigger `UsageLimitPauser`.
*
* @param session — The agent session (or any object with `state.errorMessage?: string`)
* @throws {Error} If `session.state.errorMessage` is set and non-empty
*/
export function checkSessionError(session: { state: { errorMessage?: string; error?: string } }): void {
const state = session.state;
const error = state?.errorMessage ?? state?.error;
if (error) {
throw new Error(error);
}
}
export class UsageLimitPauser {
private paused = false;
constructor(private store: TaskStore) {}
/**
* Called by agents when a usage-limit error is detected after retries are exhausted.
* Triggers global pause if not already paused.
*
* @param agentType - The type of agent that hit the limit (e.g., "executor", "triage", "merger")
* @param taskId - The task that was being processed when the limit was hit
* @param errorMessage - The error message from the API
*/
async onUsageLimitHit(agentType: string, taskId: string, errorMessage: string): Promise<void> {
// If we already triggered a pause, check if it was externally reset
if (this.paused) {
const settings = await this.store.getSettings();
if (settings.globalPause) {
// Still paused — no need to trigger again
log.log(`Global pause already active — ignoring duplicate from ${agentType}/${taskId}`);
return;
}
// External reset detected — allow re-triggering
this.paused = false;
}
this.paused = true;
log.warn(`${agentType} hit usage limit on ${taskId}: ${errorMessage}`);
log.warn(`Matched pattern in error: "${errorMessage.slice(0, 200)}"`);
// Log the triggering error on the task
await this.store.logEntry(
taskId,
`Usage limit detected (${agentType}): ${errorMessage}`,
);
// Activate global pause
await this.store.updateSettings({ globalPause: true, globalPauseReason: "rate-limit" });
log.warn("⚠ Global pause activated — all automated activity will halt");
}
}