Errors¶
There are two kinds of failures, and they go to different places:
- A tool fails: the model reads what went wrong and decides what to do next. The run goes on.
- Anything else fails (the API, the network, a hook, a history store): the run raises an
AgentError.
When a tool fails¶
Raise ToolFailed to tell the model exactly what happened. Write the message for the model: what went wrong and
what it could try instead.
from pyox_agents import ToolFailed, tool
@tool
def search_flights(origin: str, date: str) -> list[str]:
"""Search flights."""
flights = find_flights(origin, date)
if not flights:
raise ToolFailed(f"No flights from {origin} on {date}. Try another date.")
return flights
Any other exception is a bug or something you did not expect. The model reads it with its type, like
FileNotFoundError: No such file: 'app.yml', and pyox logs it as a warning (with the traceback) on the
pyox_agents logger, so it does not go unnoticed.
Either way, ToolFinished.is_error is True and the run continues.
Choosing what the model reads¶
The default text may say too much (a file path, a SQL query) or too little. on_tool_error decides, for every
tool of an agent. It gets the exception itself, and returns the text for the model, or None to keep the
default:
from pyox_agents import Agent, Hooks, RunContext, ToolCall
def friendly_errors(ctx: RunContext, call: ToolCall, error: Exception) -> str | None:
if isinstance(error, PermissionError):
return "You are not allowed to do that. Tell the user."
if isinstance(error, ConnectionError):
return "The service is down. Try again later."
return None # the default text
agent = Agent(model="gpt-5-mini", tools=[...], hooks=[Hooks(on_tool_error=friendly_errors)])
A single tool can have its own, which is asked first:
def hide_paths(ctx: RunContext, call: ToolCall, error: Exception) -> str | None:
return "The file could not be read." if isinstance(error, OSError) else None
@tool(on_error=hide_paths)
def read_report(name: str) -> str:
"""Read a report."""
return open(f"/srv/reports/{name}.txt").read()
on_tool_error is called for every failed call: exceptions from the tool, bad arguments, an
unknown tool name, a timeout. It may be async. If it raises, the run ends with a HookError.
Ending the run¶
Some failures should stop everything: expired credentials, a payment service that is down. Raise StopRun in a
tool or in any hook:
from pyox_agents import StopRun
def stop_on_auth_errors(ctx: RunContext, call: ToolCall, error: Exception) -> None:
if isinstance(error, PermissionError):
raise StopRun("The credentials expired") from error
The run raises it. Like every AgentError, it has the conversation so far, which you can continue later:
try:
result = await agent.run("Pay my bills")
except StopRun as error:
print(f"Stopped: {error}")
await save(error.messages) # then `agent.run("Try again", history=error.messages)`
Bad arguments¶
Models sometimes call a tool with arguments that do not fit: a missing field, a string for a number, a value out of range. pyox checks the arguments against the tool's type hints (and pydantic validates its models) before calling it. The model reads one short line per problem:
Invalid arguments for book_trip:
- trip.days: Input should be less than or equal to 7 (got 10)
- class_: expected one of "economy", "business", got "first"
- seats: missing
The model then decides whether to call the tool again. pyox never retries for it: no hidden loops, and every
attempt is a normal tool call you can see in the stream and in traces. max_turns (20 by default) is the limit
for a model that keeps trying.
To stop sooner, count failures in on_tool_error. The exception is an InvalidArguments (a ToolFailed) with
the problems in error.errors:
from collections import Counter
from pyox_agents import InvalidArguments
def two_tries(ctx: RunContext[Counter[str]], call: ToolCall, error: Exception) -> str | None:
if isinstance(error, InvalidArguments):
ctx.deps[call["name"]] += 1
if ctx.deps[call["name"]] >= 2:
return f"{error}\nDo not call {call['name']} again. Tell the user what is missing."
return None
await agent.run("Book me a trip", deps=Counter())
Timeouts¶
A tool that may hang (a slow website, a stuck query) can have a time limit:
After 30 seconds the model reads TimeoutError: Tool "fetch_page" timed out after 30 seconds, and an async tool
is cancelled. A sync tool cannot be interrupted: it finishes in the background and its result is dropped.
Errors the run raises¶
Everything pyox raises is an AgentError, so one except catches them all:
| Exception | Raised when |
|---|---|
ApiError |
the API answered with an error |
AuthenticationError |
the API key is missing or wrong (an ApiError) |
RateLimitError |
the API is rate limiting you, after the retries (an ApiError) |
RequestTimeoutError |
a request took longer than its timeout |
NetworkError |
the server could not be reached |
InvalidResponseError |
the API sent something that could not be parsed |
MaxTurnsExceededError |
the run made more than max_turns model requests |
HookError |
a hook or on_request raised, or returned something invalid |
HistoryError |
a history store failed |
StopRun |
a tool or hook ended the run |
When your own code raised (a hook, a history store), its exception is the __cause__, so the traceback shows
both. Every AgentError raised by a run has messages (the conversation so far) and usage (the tokens spent).
from pyox_agents import AgentError, ApiError, RateLimitError
try:
result = await agent.run("Hi")
except RateLimitError as error:
print(f"Slow down, try again in {error.retry_after_seconds} seconds")
except ApiError as error:
print(error.status_code, error.code, error.request_id)
except AgentError as error:
print(f"The run failed: {error}")
An ApiError has the details as attributes, so there is no message to parse:
| Attribute | What it holds |
|---|---|
status_code |
the HTTP status (None for an error that came inside a stream) |
code |
OpenAI's error code, like "context_length_exceeded", if it gave one |
body |
the error body, as parsed JSON when it is JSON ({"error": {...}}) |
request_id |
OpenAI's id for the request, for support |
retry_after_seconds |
how long the server asked to wait, if it said |
Rate limits and server errors are retried before they raise (see Client: retries).
A few things are not AgentErrors, on purpose:
- Mistakes in your setup raise right away, when you create the agent or the client:
TypeErrororValueError, like@tool(timeout_seconds=0). - Cancelling the task that reads the run (
asyncio.CancelledError) or Ctrl+C (KeyboardInterrupt) stops the run and its tools, and goes up as usual.