# 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`.

```ts
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](https://zidane.si/docs/skills.md)) |
| `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](https://zidane.si/docs/sessions.md#compaction)). Decisions return failures unless you ask for `DecisionError` ([decisions](https://zidane.si/docs/decisions.md#failures-never-throw)).

## 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.

```ts
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](https://zidane.si/docs/providers.md#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](https://zidane.si/docs/approvals.md#limits-and-the-sandbox)). Per-tool caps are on [hooks](https://zidane.si/docs/hooks.md#budgets-and-guards).

## Reference

- [`zidane/errors`](https://zidane.si/docs/reference/errors.md)
