Errors
Typed errors, retry with backoff, and run-level budgets.
agent.run() wraps provider failures in typed errors, so you match on instanceof instead of parsing provider strings.
Typed errors#
Each provider's classifyError(err) returns a ClassifiedError or null:
kind | Thrown as |
|---|---|
'context_exceeded' | AgentContextExceededError |
'provider_error', or null | AgentProviderError |
'aborted' | AgentAbortedError, also for agent.abort() or an aborted AbortSignal, whatever the provider reports |
A rate limit, a spent quota or an overload also sets limit. It has reason ('rate_limit', 'quota' or 'overloaded'), provider, and retryAfterMs when the server says when it resets. The message is then one sentence from describeProviderLimit(limit), such as Claude rate limit reached. Resets in 1 h 45 min. The thrown AgentProviderError keeps the limit on err.limit.
import { AgentAbortedError, AgentBudgetExceededError, AgentContextExceededError, AgentProviderError } from 'zidane'
try {
await agent.run({ prompt })
}
catch (err) {
if (err instanceof AgentContextExceededError) { /* prune history, retry */ }
else if (err instanceof AgentAbortedError) { /* user cancelled */ }
else if (err instanceof AgentBudgetExceededError) {
console.error(`Hit ${err.limit} cap: ${err.actualValue} > ${err.limitValue}`)
}
else if (err instanceof AgentProviderError) {
console.error(`${err.provider}: ${err.message} (${err.providerCode})`)
}
}The loop throws three more:
| Error | Thrown when |
|---|---|
AgentToolNotAllowedError | The model calls a tool outside the active skill's allowed-tools (skills) |
AgentLengthResumeExhaustedError | resumeOnMaxTokens keeps resuming replies that hit the output cap with no text and no tool calls. err.consecutiveEmptyTurns holds the count. |
AgentToolPairingError | behavior.strictToolPairing is on and a tool call has no result. err.repairs lists the repairs that zidane skipped. |
toTypedError(err) converts a native error by hand. Compaction throws CompactInvalidInputError and CompactPromptTooLongError (compaction). Decisions return failures unless you ask for DecisionError (decisions).
Retry with backoff#
zidane retries transient provider failures (429, 5xx, Anthropic overloaded_error mid-stream, truncated OpenAI-compatible streams) with exponential backoff and full jitter.
await agent.run({
prompt: '...',
behavior: {
retry: { maxAttempts: 3, initialDelayMs: 1000, maxDelayMs: 30_000 }, // defaults
},
})Retry n waits a random time up to initialDelayMs * 2^(n-1), capped at maxDelayMs: with the defaults, up to 1 s, then up to 2 s. A retry-after header replaces the computed delay, with the same cap. maxAttempts: 1 fails fast.
zidane does not retry after the turn streamed text, tool-call content or provider-side tool activity, because a replay corrupts the transcript. Reasoning alone does not block a retry: the failed attempt's reasoning is dropped, the new attempt generates its own, and stream:retry carries discardedThinking: true so a host can drop what it showed. stream:retry fires per attempt (attempt, nextAttempt, maxAttempts, delayMs, err, statusCode?, requestId?, discardedThinking?). stream:error fires only on the final failure, which goes to the model fallback chain under the same rule.
A stream that goes silent after its first event is caught only by providerStreamIdleTimeoutMs, off by default because some providers send nothing while a server-side tool runs. The chat apps set it to 5 minutes; set it on your agent to get the same retry and fallback on a stalled stream.
AgentContextExceededError does not go to the fallback chain. The loop stubs older media and tool output up to a cutoff and re-issues the stream (watch context:overflow). Each new attempt moves the cutoff further forward. The loop remembers the cutoff, so later turns and runs send the stubs up front. A request whose text alone is too long still fails.
Run-level budgets#
The loop reads these soft caps on behavior after each turn, so a run can go over by up to one turn. Unset or 0 means no cap. maxWallMs also caps the provider stream timeouts.
| Option | err.limit | Counts |
|---|---|---|
maxCostUsd | 'cost' | Sum of per-turn cost in USD |
maxTotalTokens | 'tokens' | Input plus output tokens, without cache reads and writes |
maxWallMs | 'wallClock' | Elapsed run time in ms |
A breach throws AgentBudgetExceededError and closes the session run as 'aborted'. When several trip on one turn, cost wins, then tokens.
behavior.maxTurns caps the turn count. maxTurnsWarning: n tells the model to wrap up when n turns remain. maxTurnsWarningMessage replaces that text.
The apps show these caps as Run limits, with run time in minutes (limits in the apps). Per-tool caps are on hooks.