[Go to site: main page, start]

Skip to content

Agent error handling

Genkit agent failures have different recovery paths depending on when they happen. A request can be rejected before an invocation starts, a turn can fail after state has been loaded, or a tool can return a domain-level result that the model can handle.

  • Init misuse throws before a turn starts. Fix the caller, such as by not sending state to a store-backed agent.
  • Failed turns throw AgentError with response, status, details, state, and snapshotId. Resume from the last-good state or snapshot.
  • Foreground aborts resolve with finishReason: 'aborted'. Let the user retry or revise the request.
  • Background failures appear as snapshot status failed, aborted, or expired. Show the status, inspect snapshot error details, and retry from a completed snapshot when possible.
  • Domain-level tool problems should be structured tool output when the model can recover. Let the model explain the issue or ask the user for corrected input.
import { AgentError } from 'genkit/beta/client';
try {
const res = await chat.send('Look up order 123.');
console.log(res.text);
} catch (err) {
if (err instanceof AgentError) {
console.error(err.status);
console.error(err.details);
console.error(err.snapshotId);
console.error(err.state);
const recoveryChat = err.snapshotId
? await agent.loadChat({ snapshotId: err.snapshotId })
: agent.chat({ state: err.response.raw.state });
await recoveryChat.send('Try again with order 456.');
} else {
throw err;
}
}

For streaming turns, catch errors around stream consumption and the final response. The stream rethrows failed-turn errors after yielding any available chunks.

const turn = chat.sendStream('Write a report.');
try {
for await (const chunk of turn.stream) {
render(chunk);
}
await turn.response;
} catch (err) {
showFailure(err);
}

Throw from a tool when the system cannot safely continue, such as a database outage, auth failure, or invariant violation.

const lookupOrder = ai.defineTool(
{
name: 'lookupOrder',
description: 'Looks up an order by ID.',
inputSchema: z.object({ orderId: z.string() }),
},
async ({ orderId }) => {
const order = await db.orders.find(orderId);
if (!order) {
throw new Error(`Order ${orderId} was not found.`);
}
return order;
},
);

Return structured data when the model can recover:

return {
ok: false,
reason: 'ORDER_NOT_FOUND',
message: 'Ask the user to check the order ID.',
};

Call res.assertValid() when a caller requires a model message and wants blocked responses to throw.

const res = await chat.send('Write the summary.');
res.assertValid();
  • Rejected init returns a non-nil Go error from Connect, Run, RunText, or RunDetached before any turn runs. Fix the caller or the selected resume source.
  • Failed turns return an AgentOutput whose FinishReason is AgentFinishReasonFailed and whose Error is non-nil. The tool rounds the turn completed are kept, and the output names the resume point: SnapshotID for a server-managed agent, State for a client-managed one.
  • Stopped runs return the error that stopped them together with an AgentOutput whose FinishReason is AgentFinishReasonAborted and whose Error carries the same stop, classified. A cancelled context, an expired deadline, a closed transport, a limit such as ai.WithMaxTurns, and Abort on a detached run all count. The snapshot keeps the turns that finished.
  • Background failures appear as snapshot status failed, aborted, or expired. The first two resume like their foreground counterparts. An expired run is lost; restart it from the snapshot’s ParentID.
  • Tool domain problems should return structured tool output when the orchestrator or model can recover.

An unrecognized sessionId is not an error. The agent starts a new conversation under that ID and every snapshot it writes carries it, so there is no ErrSessionNotFound sentinel. aix.ErrSnapshotNotFound applies only to an unknown snapshotId.

The rejected-init cases from Run, RunText, and Connect are narrow: sending a sessionId to a client-managed agent (one defined without WithSessionStore) is a status.FailedPrecondition, and sending state to a store-backed agent is rejected the same way. ai/exp ships three sentinels in total: aix.ErrSnapshotNotFound, aix.ErrSessionStoreNotConfigured, and aix.ErrSessionIDRequired.

An agent reports failure through two channels. A non-nil Go error means the invocation was rejected, could not produce an output, or was stopped by its caller. A failed turn is in-band, so the output carries the resume point. A stopped run sets both, so read out before acting on err.

AgentOutput.Error is a *status.Error from github.com/firebase/genkit/go/core/status, and it is nil in the ordinary case. Its Status keeps the classification the failure was raised with, so branch on that rather than on message text. See Error types for the status vocabulary and the sentinels each package ships.

import (
aix "github.com/firebase/genkit/go/ai/exp"
"github.com/firebase/genkit/go/core/status"
)
out, err := agent.RunText(ctx, "Look up order 123.")
if err != nil {
if out != nil {
// The caller stopped the run. out.SnapshotID is the resume point.
return fmt.Errorf("agent stopped at %s: %w", out.SnapshotID, err)
}
return fmt.Errorf("agent invocation did not start: %w", err)
}
if out.FinishReason == aix.AgentFinishReasonFailed && out.Error != nil {
switch out.Error.Status {
case status.Unavailable, status.ResourceExhausted:
// Overloaded. Re-attempt the turn from out.SnapshotID in a moment.
return nil
case status.InvalidArgument:
// The model or a tool rejected the request. Rephrase it.
return nil
default:
return fmt.Errorf("agent turn failed: %s: %s", out.Error.Status, out.Error.Message)
}
}
fmt.Println(out.Message.Text())

Return from each recovery arm rather than falling through. A failed turn may have ended before any model response, in which case out.Message is nil. Message.Text() is nil-safe and returns "", so falling through prints a blank line instead of the answer the caller expected.

What the rest of AgentOutput holds depends on how the invocation finished:

FinishReasonErrorSnapshotIDStateMessage
failednon-nilThe failed turn’s own snapshot: the tool rounds it completed, ending at a turn seam. Resumable.What the turn committed.May be nil.
abortednon-nilThe aborted snapshot, holding the turns that finished before the stop. Resumable.Last-good client-managed state.May be nil.
detachednilThe pending snapshot.Nil; detach needs a store.May be nil.
anything elsenilThe most recent turn-end snapshot, or empty with no store.Client-managed final state.The last model message.

A turn rejected before it reaches the model, such as an invalid input or a render failure, commits nothing: the resume point stays the turn before it, and SnapshotID reports that. Either way the newest snapshot of the session is the latest resumable state. The basic-agents sample drives the same switch from its CLI, suggesting a different recovery per status.

Reads and aborts classify too. Match them with errors.Is against the sentinels in ai/exp rather than by inspecting the message:

if _, err := agent.GetSnapshot(ctx, snapshotID); err != nil {
switch {
case errors.Is(err, aix.ErrSnapshotNotFound):
// Nothing was ever written under that ID.
case errors.Is(err, aix.ErrSessionStoreNotConfigured):
// The agent is client-managed, so there is no snapshot to read.
}
}

A failed or aborted snapshot ends at a turn seam, so the model can be called on it again. Send an input with no payload to re-attempt the turn as it stood. The turn runs again on the committed messages, so the tool calls that already succeeded are not repeated:

retried, err := agent.Run(ctx, &aix.AgentInput{},
aix.WithSnapshotID[OrderState](out.SnapshotID),
)

Send a new message on the same snapshot to change course instead, or resume from the snapshot’s ParentID to rewind past the turn altogether:

retry, err := agent.RunText(ctx, "Try order 456.",
aix.WithSnapshotID[OrderState](out.SnapshotID),
)

For client-managed agents the failed output’s State is the same resume point inline. Pass it back with aix.WithState, with an empty input or a new message:

retry, err := agent.RunText(ctx, "Try order 456.",
aix.WithState(out.State),
)

Whether a failure is worth another attempt is the caller’s decision. The runtime records the status on the row and never classifies it: a RESOURCE_EXHAUSTED wants a wait, an INVALID_ARGUMENT wants a different message, and a FAILED_PRECONDITION from a tool guard may want neither.

Three statuses are not resume points. pending and aborting describe work that is still settling, so wait for it. expired means the worker died; resume from the row’s ParentID. A custom agent commits a failed turn only when it opts in, as described in Custom orchestration.

Return a Go error when the tool cannot safely produce a meaningful result. Classify it once, where the failure mode is known, with status.Errorf and a sentinel; add context further up with fmt.Errorf and %w, which preserves the classification.

// A subtype keeps its parent's status and matches errors.Is at either
// granularity: ErrOrderNotFound for this failure, status.ErrNotFound for any.
var ErrOrderNotFound = status.ErrNotFound.Subtype("order not found")
func lookupOrder(ctx *ai.ToolContext, input LookupOrderInput) (LookupOrderOutput, error) {
order, err := db.LookupOrder(ctx, input.OrderID)
if err != nil {
// The lookup itself failed (e.g. the database is unreachable); this is
// a tool failure, distinct from a found-but-empty result below.
return LookupOrderOutput{}, fmt.Errorf("could not look up order %q: %w", input.OrderID, err)
}
if order == nil {
return LookupOrderOutput{}, status.Errorf(ErrOrderNotFound, "order %q not found", input.OrderID)
}
return LookupOrderOutput{OK: true, Order: order}, nil
}

A tool error fails the turn. The round it belonged to is discarded whole, including the model message that requested it and any sibling tools that succeeded, because a conversation cannot end on an unanswered tool request. The rounds before it are what the failed snapshot keeps. The basic-errors sample works the whole pattern through, including what reaches an HTTP client and what stays in the server log.

Return structured output when the model should recover:

if order == nil {
return LookupOrderOutput{
OK: false,
Reason: "ORDER_NOT_FOUND",
Message: "Ask the user to check the order ID.",
}, nil
}

State and stream transforms fail closed. If a transform returns an error, the read or invocation fails instead of exposing unredacted data. Use this behavior for authorization-dependent redaction where returning raw state would leak sensitive information.

  • Init misuse throws an immediate exception (e.g., AgentInitError or GenkitException) before a turn starts. Fix the parameters, such as by not sending state to a store-backed agent.
  • Failed turns throw AgentError containing status, details, the latest snapshot ID (err.snapshotId), and the recoverable last-good state (err.response.raw.state holds the full SessionState; err.state holds the typed custom state).
  • Aborted turns (a cancelled request or hitting maxTurns) resolve to a response with finishReason: AgentFinishReason.aborted rather than throwing AgentError, and also commit a rerunnable snapshot (res.snapshotId) with the last-good state.
  • Background/detached failures appear as snapshot status failed, aborted, or expired. Inspect the snapshot’s error property and rerun from it.
  • Tool domain problems should return structured tool outputs when the model can recover. Let the model explain the issue or ask the user for corrected input.

A failed or aborted turn commits a rerunnable snapshot on server-managed agents: it carries the structured error (on failure) plus the last-good history, ending before the turn that failed or aborted. Resume that snapshot with chat(snapshotId: ...), then send an empty message to re-drive the same turn, or send new text to steer the retry.

try {
final res = await chat.send(text: 'Look up order 123.');
if (res.finishReason == AgentFinishReason.aborted && res.snapshotId != null) {
// Aborted turn (cancelled or hit maxTurns): rerun from the committed snapshot.
final rerun = agent.chat(snapshotId: res.snapshotId);
await rerun.send(); // or: rerun.send(text: 'Try a shorter summary.')
} else {
print(res.text);
}
} on AgentError catch (err) {
print('Turn failed: ${err.status}');
print('Error details: ${err.message}');
if (err.snapshotId != null) {
// Server-managed agent: rerun the failed snapshot. send() with no message
// re-drives the same turn; pass text to steer the retry.
final rerun = agent.chat(snapshotId: err.snapshotId);
await rerun.send(); // or: rerun.send(text: 'Try again with order 456.')
} else {
// Client-managed agent: the existing `chat` instance automatically rolls
// back the failed turn's user message, or you can construct a fresh chat
// from the complete SessionState (`err.response.raw.state`).
final recoveryChat = agent.chat(state: err.response.raw.state);
await recoveryChat.send(text: 'Try again with order 456.');
}
}

For streaming turns, catch errors around the chunk stream consumption and the final response Future. The stream rethrows failed-turn errors after yielding any chunks that arrived before the failure occurred.

final turn = chat.sendStream(text: 'Write a long report.');
try {
await for (final chunk in turn.stream) {
render(chunk);
}
await turn.response;
} on AgentError catch (err) {
showFailure(err);
}

Throw an exception from a tool when the system cannot safely proceed, such as a database outage or auth failure:

final lookupOrder = ai.defineTool(
name: 'lookupOrder',
description: 'Looks up an order by ID.',
inputSchema: LookupOrderInput.$schema,
outputSchema: Order.$schema,
fn: (input, _) async {
final order = await db.orders.find(input.orderId);
if (order == null) {
throw Exception('Order ${input.orderId} was not found.');
}
return order;
},
);

When the error is soft and the model has a chance to recover (e.g. invalid user input), return structured output:

if (order == null) {
return {
'ok': false,
'reason': 'ORDER_NOT_FOUND',
'message': 'Ask the user to check the order ID.',
};
}
  • Init misuse raises AgentInitError (a GenkitError) before a turn starts. Fix the parameters, such as by not sending state to a store-backed agent.
  • Failed turns raise AgentError containing status, details, the latest snapshot ID, and the recoverable last-good state.
  • Background/detached failures appear as snapshot status failed, aborted, or expired. Inspect the snapshot’s error property and retry from the last-known completed snapshot.
  • Tool domain problems should return structured tool outputs when the model can recover. Let the model explain the issue or ask the user for corrected input.
from genkit.agent import AgentError
try:
res = await chat.send('Look up order 123.')
print(res.text)
except AgentError as err:
print('Turn failed:', err.status)
print('Error details:', err.message)
recovery_chat = (
await agent.load_chat(snapshot_id=err.snapshot_id)
if err.snapshot_id
else agent.chat(
messages=err.response.messages if err.response else [],
state=err.state,
artifacts=err.response.raw.state.artifacts
if (err.response and err.response.raw and err.response.raw.state)
else None,
)
)
await recovery_chat.send('Try again with order 456.')

For streaming turns, catch errors around stream consumption and the final response. The stream rethrows failed-turn errors after yielding any chunks that arrived before the failure.

turn = chat.send_stream('Write a long report.')
try:
async for chunk in turn.stream:
render(chunk)
await turn.response
except AgentError as err:
show_failure(err)

Raise from a tool when the system cannot safely proceed, such as a database outage or auth failure:

from pydantic import BaseModel
from genkit import GenkitError
class LookupOrderInput(BaseModel):
order_id: str
@ai.tool()
async def lookup_order(input: LookupOrderInput) -> dict:
"""Looks up an order by ID."""
order = await db.orders.find(input.order_id)
if order is None:
raise GenkitError(
status='NOT_FOUND',
message=f'Order {input.order_id} was not found.',
)
return order

When the model can recover (for example, invalid user input), return structured output instead:

if order is None:
return {
'ok': False,
'reason': 'ORDER_NOT_FOUND',
'message': 'Ask the user to check the order ID.',
}