Let the model handle tool failures¶
Some failures are part of the job: a page that does not exist, a server that is down, a request that timed out. The model should hear about them and try something else. Other failures are bugs in your code, and the model cannot fix those. A tool says which is which, and anything it does not name stops the run.
Branch on the failure¶
This tool reads a JSON API with httpx. Its exception_handler turns the HTTP
failures the model can work around into messages, one per kind:
import httpx
from alpineagents import Agent, tool
client = httpx.Client(base_url="https://api.example.com")
def http_errors(error: httpx.HTTPError) -> str:
if isinstance(error, httpx.HTTPStatusError):
status, url = error.response.status_code, error.request.url
if 400 <= status < 500:
return f"HTTP {status} for {url}. Check the path or id and try another request"
if status >= 500:
return f"HTTP {status} for {url}. The server failed. Find the information another way"
if isinstance(error, httpx.TimeoutException):
return f"Timed out: {error.request.url}. Try again later"
raise error
@tool(exception_handler=http_errors)
def get_json(path: str) -> dict:
"""GET a JSON endpoint of the API
Args:
path: A path such as /users/42
"""
return client.get(path).raise_for_status().json()
agent = Agent(model="claude-sonnet-5", tools=[get_json])
What happens in get_json |
Taken by http_errors? |
Result |
|---|---|---|
| 404 | Yes | The model gets HTTP 404 for ... Check the path or id .... The run continues |
| 503 | Yes | The model gets HTTP 503 for ... The server failed .... The run continues |
| Timeout | Yes | The model gets Timed out: .... The run continues |
| 302 | Yes, but the handler raises it again | The run stops with httpx.HTTPStatusError |
| A 200 response that is not JSON | No, JSONDecodeError is not an httpx.HTTPError |
The run stops with JSONDecodeError |
The type hint httpx.HTTPError decides which exceptions reach the handler. Inside it, raise error sends one back
out, for a failure the model can do nothing about.
What you see¶
When the handler takes a failure, the terminal shows error and the run goes on:
[turn 1] thinking
tool get_json(path="/users/999")
error get_json: HTTP 404 for https://api.example.com/users/999. Check the path or id and try another request
[turn 2] thinking
tool get_json(path="/orders")
tool get_json(path="/slow")
error get_json: HTTP 503 for https://api.example.com/orders. The server failed. Find the information another way
error get_json: Timed out: https://api.example.com/slow. Try again later
[turn 3] thinking
tool get_json(path="/users/42")
done 25B
[turn 4] thinking
User 42 is Ana. The orders could not be fetched because the server failed.
done: stopped by waiting_for_user (4 turns)
When an exception gets through, the terminal shows aborted and run raises it:
[turn 1] thinking
tool get_json(path="/legacy")
aborted get_json: (aborted: JSONDecodeError)
done: error JSONDecodeError: Expecting value: line 1 column 1 (char 0) (1 turn)
When an exception gets through¶
run raises the exception as it is, so except catches it by its own type. It has a note naming the call, and the
State keeps what happened:
import json
from alpineagents import Agent, Message, State, tool
LEGACY_PAGE = "<html>Moved</html>"
@tool
def get_json(path: str) -> dict:
"""GET a JSON endpoint of the API
Args:
path: A path such as /users/42
"""
return json.loads(LEGACY_PAGE) # the legacy API answers with a web page
agent = Agent(model="claude-sonnet-5", tools=[get_json])
state = State(messages=[Message.user("Get the settings from the legacy API")])
try:
agent.run(state)
except Exception as error:
print(error.__notes__) # ['exception raised in tool get_json(path="/legacy")']
print(state)
[turn 0] context_change import: 1 messages
[turn 0] run_start: anthropic/claude-sonnet-5
[turn 1] model_request: anthropic/claude-sonnet-5
[turn 1] model_reply: get_json(path="/legacy")
[turn 1] error get_json: JSONDecodeError: Expecting value: line 1 column 1 (char 0)
[turn 1] tool_result get_json (error): (aborted: JSONDecodeError)
The call is closed with (aborted: JSONDecodeError), so you can call agent.run(state) again. The model then sees
that result and continues from there.
Decide where a failure belongs¶
When an aborted line shows up, decide whether it is a bug or a failure the model should know about:
| The failure | Where to handle it |
|---|---|
| Only this tool knows it, like a response that is not JSON | Catch it in the tool and raise ToolError |
| Exceptions of a library, raised by several tools | The type hint of an exception_handler |
| A bug in your code | Nowhere. Let it stop the run, and fix the code |
For the JSON case, the tool knows what went wrong and can say so:
import httpx
from alpineagents import Agent, ToolError, tool
client = httpx.Client(base_url="https://api.example.com")
def http_errors(error: httpx.HTTPError) -> str:
if isinstance(error, httpx.TimeoutException):
return f"Timed out: {error.request.url}. Try again later"
raise error
@tool(exception_handler=http_errors)
def get_json(path: str) -> dict:
"""GET a JSON endpoint of the API
Args:
path: A path such as /users/42
"""
response = client.get(path).raise_for_status()
try:
return response.json()
except ValueError as error:
content_type = response.headers.get("content-type")
raise ToolError(f"{path} did not return JSON (content-type: {content_type})") from error
agent = Agent(model="claude-sonnet-5", tools=[get_json])
tool get_json(path="/legacy")
error get_json: /legacy did not return JSON (content-type: text/html)
Adding ValueError to the handler's type hint would work too, but a ValueError from a bug in the tool would then
reach the model instead of you. Keep the type hint to the exceptions you expect.
Related¶
- Tool calls: when a call goes wrong
- Errors and interruptions
- Progress and questions:
outcome.kind == "error"in your own Reporter