/** * Workflow-specific error types. */ export enum WorkflowErrorCode { /** Agent exceeded timeout. */ AGENT_TIMEOUT = "AGENT_TIMEOUT", /** Workflow was aborted by user. */ WORKFLOW_ABORTED = "WORKFLOW_ABORTED", /** Workflow exceeded its wall-clock runtime timeout. */ WORKFLOW_TIMEOUT = "WORKFLOW_TIMEOUT", /** Agent limit exceeded. */ AGENT_LIMIT_EXCEEDED = "AGENT_LIMIT_EXCEEDED", /** A selected harness runtime/config is not wired to this runtime (e.g. opencode/hermes). */ HARNESS_NOT_WIRED = "HARNESS_NOT_WIRED", /** Token budget exhausted. */ TOKEN_BUDGET_EXHAUSTED = "TOKEN_BUDGET_EXHAUSTED", /** Subagent exceeded its configured/effective context-window guardrail. */ CONTEXT_WINDOW_EXCEEDED = "CONTEXT_WINDOW_EXCEEDED", /** * The provider's subscription/usage/quota/rate limit was hit. Distinct from the * user's self-imposed TOKEN_BUDGET_EXHAUSTED: a provider limit refills on its own, * so the run is checkpointed (paused) and replayed by resume() rather than failed. */ PROVIDER_USAGE_LIMIT = "PROVIDER_USAGE_LIMIT", /** Script validation failed. */ SCRIPT_VALIDATION_ERROR = "SCRIPT_VALIDATION_ERROR", /** A schema agent never produced valid structured_output (after repair + extraction). */ SCHEMA_NONCOMPLIANCE = "SCHEMA_NONCOMPLIANCE", /** A non-schema agent completed without any assistant text output. */ AGENT_EMPTY_OUTPUT = "AGENT_EMPTY_OUTPUT", /** Agent execution failed. */ AGENT_EXECUTION_ERROR = "AGENT_EXECUTION_ERROR", /** Run state persistence failed. */ PERSISTENCE_ERROR = "PERSISTENCE_ERROR", /** Unknown error. */ UNKNOWN = "UNKNOWN", } export class WorkflowError extends Error { readonly code: WorkflowErrorCode; readonly recoverable: boolean; readonly agentLabel?: string; readonly details?: unknown; /** For PROVIDER_USAGE_LIMIT: the provider's human reset hint, e.g. "Resets in ~3h" (verbatim). */ readonly resetHint?: string; constructor( message: string, code: WorkflowErrorCode, options: { recoverable?: boolean; agentLabel?: string; details?: unknown; resetHint?: string } = {}, ) { super(message); this.name = "WorkflowError"; this.code = code; this.recoverable = options.recoverable ?? false; this.agentLabel = options.agentLabel; this.details = options.details; this.resetHint = options.resetHint; } } export function isWorkflowError(error: unknown): error is WorkflowError { return error instanceof WorkflowError; } export function isProviderUsageLimit(error: unknown): error is WorkflowError { return isWorkflowError(error) && error.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT; } /** * Detect a provider subscription/usage/quota/rate-limit exhaustion from free-form * error text, and extract the provider's human reset hint when present. * * The pi SDK does NOT throw these — it records them as an assistant message with * stopReason "error" and an errorMessage like "Codex usage limit reached (plus * plan). Resets in ~3h.". Callers reading message metadata MUST gate on * stopReason === "error" before trusting this, so a task whose own output merely * mentions "rate limit" is never misclassified. Patterns mirror the SDK's own * non-retryable-limit table. Deliberately excludes transient overloaded/5xx * errors, which stay recoverable and keep retrying. */ export function classifyProviderLimit(text: string | undefined): { matched: boolean; resetHint?: string } { if (!text) return { matched: false }; const matched = /usage limit|limit reached|insufficient[_\s]?quota|quota exceeded|exceeded your current quota|out of budget|available balance|\bquota\b|rate.?limit|too many requests|\b429\b|GoUsageLimitError|FreeUsageLimitError|\bbilling\b/i.test( text, ); if (!matched) return { matched: false }; const reset = text.match(/resets?\s+(?:in|at)\s+[^.\n]+/i); return { matched: true, resetHint: reset?.[0]?.trim() }; } export function isAbortError(error: unknown): boolean { if (!(error instanceof Error)) return false; return /\babort(?:ed)?\b/i.test(error.message); } export function isTimeoutError(error: unknown): boolean { if (!(error instanceof Error)) return false; return /\btimeout\b/i.test(error.message) || error.name === "TimeoutError"; } /** * Wrap an unknown error into a WorkflowError with appropriate classification. */ export function wrapError(error: unknown, context?: { agentLabel?: string }): WorkflowError { if (isWorkflowError(error)) return error; if (isAbortError(error)) { return new WorkflowError( error instanceof Error ? error.message : "Workflow was aborted", WorkflowErrorCode.WORKFLOW_ABORTED, { recoverable: true }, ); } if (isTimeoutError(error)) { return new WorkflowError( error instanceof Error ? error.message : "Agent timed out", WorkflowErrorCode.AGENT_TIMEOUT, { recoverable: true, agentLabel: context?.agentLabel }, ); } // Defense-in-depth: today the SDK buries provider usage/quota limits in an // assistant message (detected in agent.ts), but a future SDK might throw them. // Classify a thrown limit here too — recoverable:false so the run checkpoints // (paused) instead of being retried into the same wall or silently nulled. if (error instanceof Error) { const limit = classifyProviderLimit(error.message); if (limit.matched) { return new WorkflowError(error.message, WorkflowErrorCode.PROVIDER_USAGE_LIMIT, { recoverable: false, agentLabel: context?.agentLabel, resetHint: limit.resetHint, }); } } return new WorkflowError( error instanceof Error ? error.message : String(error), WorkflowErrorCode.AGENT_EXECUTION_ERROR, { recoverable: true, agentLabel: context?.agentLabel, details: error }, ); }