When it fails

Failures live in three layers, and each asks something different of you:

  1. Thrown errors end the turn. Catch them.
  2. Tool problems are outcomes the model sees and adapts to. The turn continues.
  3. Diagnostics stop nothing. Read them when behavior looks off.

Thrown errors end the turn

send() throws AgentSessionError. The cases you handle in practice:

  • .cancelled — you cancelled the turn (see below).
  • .alreadyRunningsend() while a turn was in flight; gate on isRunning.
  • .maxToolCallsExceeded, .maxRoundTripsExceeded, .wallClockTimeoutExceeded — a run limit tripped.
  • .toolChoiceUnsupported, .toolChoiceNotHonored — a tool-choice demand could not be met.
  • .egressRefused — your pre-transmit filter refused the request.
  • .unreadableImageAttachment — a file-URL image failed to load at the send boundary, before anything else happened (see images).

generate() additionally throws StructuredOutputError — a structured request fails loud rather than degrading to free text (see structured output). Providers throw their own typed errors through send() too; the cloud's are below. The complete case tables live in the error reference.

A turn that fails after your message is accepted has one contract: whatever the turn already appended stays in history (your message, plus any completed tool exchanges), nothing is appended after the failure, and the turn's undo transaction rolls back — pre-flight failures throw before any session mutation. Because the user message stays, calling send() again with the same text appends it a second time — the model copes, but trim your rendering if it bothers you.

Tool problems are outcomes

An executor that returns .failed, .denied, or .conflict has not failed the turn. Those are values: the session feeds them back to the model, which reads the message and adapts — retries with better arguments, picks another tool, or explains the problem. send() completes normally.

Only a thrown executor error escalates: it propagates out of send() and the turn rolls back. The rule of thumb — return .failed for anything the model could reasonably react to; throw only for what it can't.

Diagnostics that stop nothing

Two observable properties report problems that did not fail the turn: lastSchemaWarnings (the provider down-converted part of a tool schema) and lastContextDiagnostics (a context source failed to refresh). Both are covered in watch it work.

Cloud errors carry retry guidance

Cloud-backed providers throw BackendRouterError — a typed enum with a metadata accessor for the server's structured details. Never parse the message text. Switch on retryGuidance, which the SDK derives statically from the error code:

Guidance Cases What it means
.refreshAuthThenRetry authExpired the user token expired — mint a fresh one and retry
.retryWithBackoff(afterSeconds:) quotaExceeded, rateLimited, modelUnavailable, providerError, providerUnavailable, serviceUnavailable, internalError, streamTruncated transient — wait, then retry. afterSeconds is the server's retry_after hint when present, else a 1-second floor
.none everything else retrying won't help — check requiresUserAction

isRetryable is shorthand for "guidance is not .none". requiresUserAction is true for exactly two cases: authUserInvalid (the end user must re-authenticate) and entitlementRequired (the end user must upgrade). For display, safeDescription is a stable, safe-to-show summary; unsafeMessage is the server's prose — log it, don't show it.

Retry once, guided

@MainActor
func sendWithRecovery(_ text: String, agent: AgentSession, auth: AuthSession) async throws {
    do {
        try await agent.send(text)
    } catch let error as BackendRouterError {
        switch error.retryGuidance {
        case .refreshAuthThenRetry:
            try await auth.refreshUserToken()
            try await agent.send(text)
        case .retryWithBackoff(let afterSeconds):
            try await Task.sleep(for: .seconds(afterSeconds ?? 1.0))
            try await agent.send(text)
        case .none:
            throw error
        }
    }
}

AuthSession stands in for whatever refreshes your user token. With the static key-and-token configuration, refreshing means rebuilding the provider with the fresh token; a custom signer resolves credentials per request, so the retry picks them up automatically — see AgentKit Cloud.

Cancellation is an error you expect

A cancelled turn throws AgentSessionError.cancelled — the normal "user changed their mind" signal, not a failure to report. Partial text stays in currentText; how a turn works owns the full contract.