Handling Tool Errors Gracefully Inside an Agent Loop
When a tool fails, the worst default is crashing the worker or swallowing the error into an empty string.
Search across all documentation pages
When a tool fails, the worst default is crashing the worker or swallowing the error into an empty string.
The durable pattern is: catch at the host boundary, convert the failure into a structured tool observation, enforce a retry budget, and let the model adapt - or stop with a clear reason when budgets expire.
Treat tool failures as first-class observations (ok: false, typed error, retryable flag, short message).
Validate before execute, never omit a tool result for a proposed call id, cap identical retries in the host, and escalate when the model cannot make progress.
invalid_args, not_found, timeout, rate_limited, auth, upstream, unknown_tool.retryable honestly; auth misconfig is rarely retryable mid-run.stop_reason and error metrics to ops; keep user-facing copy calm and non-leaky.import json
import time
from dataclasses import dataclass
from typing import Any, Callable
@dataclass
class ToolObservation:
ok: bool
data: Any = None
error_type: str | None = None
retryable: bool = False
message: str | None = None
def to_content(self) -> str:
return json.dumps(
{
"ok": self.ok,
"data": self.data,
"error_type": self.error_type,
"retryable": self.retryable,
"message": self.message,
}
)
class RetryBudget:
def __init__(self, max_per_signature: int = 2, max_tool_errors: int = 5):
self.max_per_signature = max_per_signature
self.max_tool_errors = max_tool_errors
self.sig_counts: dict[str, int] = {}
self.total_errors = 0
def allow(self, signature: str) -> bool:
if self.total_errors >= self.max_tool_errors:
return False
return self.sig_counts.get(signature, 0) < self.max_per_signature
def record_error(self, signature: str) -> None:
self.sig_counts[signature] = self.sig_counts.get(signature, 0) + 1
self.total_errors += 1
def run_tool(
name: str,
args: dict,
impl: Callable[..., Any],
budget: RetryBudget,
) -> ToolObservation:
signature = f"{name}:{json.dumps(args, sort_keys=True)}"
if name == "get_order" and "order_id" not in args:
return ToolObservation(
ok=False,
error_type="invalid_args",
retryable=True,
message="Missing required order_id (integer).",
)
if not budget.allow(signature):
return ToolObservation(
ok=False,
error_type="retry_budget_exhausted",
retryable=False,
message="Host blocked another retry of this identical call.",
)
try:
data = impl(**args)
return ToolObservation(ok=True, data=data)
except TimeoutError:
budget.record_error(signature)
return ToolObservation(
ok=False,
error_type="timeout",
retryable=True,
message="Upstream timed out after 10s; try once more or simplify the request.",
)
except KeyError as exc:
budget.record_error(signature)
return ToolObservation(
ok=False,
error_type="not_found",
retryable=False,
message=f"Resource not found: {exc}",
)
except Exception as exc: # narrow per tool in production
budget.record_error(signature)
return ToolObservation(
ok=False,
error_type="upstream",
retryable=False,
message=f"{type(exc).__name__}: {str(exc)[:200]}",
)
# After a model tool_call:
# obs = run_tool(name, args, REGISTRY[name], budget)
# messages.append({"role": "tool", "tool_call_id": call_id, "content": obs.to_content()})
# if obs.error_type == "retry_budget_exhausted": stop_reason = "tool_error_budget"The model cannot adapt to a failure it never sees. An empty tool result looks like success with no data, which invites hallucination.
Always return a parseable object with ok and a short, actionable message.
def preflight(name: str, args: dict) -> ToolObservation | None:
if name not in REGISTRY:
return ToolObservation(
ok=False,
error_type="unknown_tool",
retryable=False,
message=f"Unknown tool {name}. Available: {sorted(REGISTRY)}",
)
# jsonschema.validate(args, SCHEMAS[name]) → invalid_args
return NoneCatching bad args before writes prevents partial mutations and useless upstream load.
| error_type | Model guidance | Host policy |
|---|---|---|
invalid_args | Fix fields and retry | Count toward signature budget |
not_found | Ask user / try search tool | Usually non-retryable as-is |
timeout | Retry once or degrade | Host may soft-retry with backoff |
rate_limited | Wait / reduce fan-out | Honor Retry-After when present |
auth | Do not spin | Page ops; fail run |
retry_budget_exhausted | Stop tool thrash | Finalize or escalate |
Do not double-count blindly. If the host already retried twice, the observation should say so.
def is_no_progress(history: list[tuple[str, str, str]]) -> bool:
# history entries: (name, args_json, error_type)
return len(history) >= 3 and len(set(history[-3:])) == 1Three identical failures in a row is a loop smell.
Stop with stop_reason=no_progress rather than spending another frontier-model turn.
When parallel tool calls partially fail, return success and error observations for each id. See Parallel Tool Calls.
| Audience | Content |
|---|---|
| Model (tool message) | Typed, specific, fix hints, no secrets |
| Logs | Full detail, redacted credentials, correlation ids |
| End user | Short apology / next step; no stack traces |
LangGraph, OpenAI Agents SDK, CrewAI, Pydantic AI, and similar stacks offer tool error hooks or exception-to-result helpers. Whatever the framework, keep the envelope consistent so evals and traces stay comparable.
retryable: true on auth errors. Burns budget and can lock accounts.not_found as transport failure. Causes useless identical retries.| Strategy | When it wins | When it loses |
|---|---|---|
| Structured tool errors (this recipe) | Most agent loops | - |
| Fail the whole run on any tool error | Strict transactional flows | Fragile UX for read tools |
| Host-only retries, hide errors | Pure idempotent GETs with blips | Argument mistakes need model repair |
| Human escalation on first write failure | High-stakes money/PII | Slow for routine cases |
| Circuit breaker open → degrade tools | Systemic upstream outage | Needs good health signals |
Prefer a tool message bound to the call id. The protocol expects a result per call; skipping it breaks many APIs and models.
Often 1-2 per identical signature and a small run-level error budget. Tune with production traces.
Map them into your error_type plus a short message. Raw headers waste tokens and can leak internals.
Host must refuse further identical executions. Do not rely on the model alone.
Error turns still consume turns. Budgets should stop thrashing before max turns alone saves you.
Yes. Uniform ok parsing simplifies host code and model instructions.
Better to return invalid_args details and let it emit a new tool call. Silent host guessing can execute wrong actions.
Stub tools to raise timeout/not_found/invalid args; assert observation shape, budget stops, and that finals do not claim success after hard failures.
At the host, per upstream dependency. When open, short-circuit tools with retryable: false and a clear outage message. See reliability section pages.
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.
Reviewed by Chris St. John·Last updated Jul 16, 2026