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.
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.
Summary
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.
Recipe
- Wrap every tool execution in host-side try/except with timeouts.
- Normalize successes and failures into one JSON envelope.
- Classify errors:
invalid_args,not_found,timeout,rate_limited,auth,upstream,unknown_tool. - Mark
retryablehonestly; auth misconfig is rarely retryable mid-run. - Always append a tool message for each call id, including failures.
- Enforce host retry budgets (per call signature and per run), not infinite model hope.
- On retryable failures, optionally sleep/jitter once in the host before re-inviting the model.
- Strip secrets from error messages before they enter context or logs.
- Detect no-progress loops (same tool + same args + same error) and stop or escalate.
- Surface
stop_reasonand error metrics to ops; keep user-facing copy calm and non-leaky.
Working Example
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"Deep Dive
Errors are observations
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.
Validate before side effects
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.
Classification drives policy
| 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 |
Host retries vs model retries
- Host soft-retry: transparent second attempt on pure transport blips before observing failure (use sparingly, log both attempts).
- Model retry: model sees the error and chooses new args or another tool.
- Hard stop: budgets exhausted, auth broken, or non-progress detected.
Do not double-count blindly. If the host already retried twice, the observation should say so.
Identical signature detection
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.
Partial batches
When parallel tool calls partially fail, return success and error observations for each id. See Parallel Tool Calls.
User-facing vs model-facing text
| 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 |
Framework hooks
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.
Gotchas
- Raising through the loop. Uncaught exceptions drop trajectories and confuse orchestrators.
- Lying
retryable: trueon auth errors. Burns budget and can lock accounts. - Huge stack traces in context. Costly and sometimes sensitive; truncate.
- Silent host retries without logging. Looks like model latency; hides upstream pain.
- Empty catch blocks. Worst case: model invents a successful world.
- No global error budget. Per-tool caps alone still allow death-by-many-tools.
- Treating
not_foundas transport failure. Causes useless identical retries.
Alternatives
| 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 |
FAQs
Should validation errors be tool messages or re-prompts without a tool result?
Prefer a tool message bound to the call id. The protocol expects a result per call; skipping it breaks many APIs and models.
How many retries is enough?
Often 1-2 per identical signature and a small run-level error budget. Tune with production traces.
Do I return HTTP status codes raw?
Map them into your error_type plus a short message. Raw headers waste tokens and can leak internals.
What if the model ignores retryable false?
Host must refuse further identical executions. Do not rely on the model alone.
How do errors interact with max turns?
Error turns still consume turns. Budgets should stop thrashing before max turns alone saves you.
Should successful tools use the same envelope?
Yes. Uniform ok parsing simplifies host code and model instructions.
Can I ask the model to "fix" bad JSON arguments?
Better to return invalid_args details and let it emit a new tool call. Silent host guessing can execute wrong actions.
How do I test this?
Stub tools to raise timeout/not_found/invalid args; assert observation shape, budget stops, and that finals do not claim success after hard failures.
Where do circuit breakers fit?
At the host, per upstream dependency. When open, short-circuit tools with retryable: false and a clear outage message. See reliability section pages.
Related
- How Function Calling Actually Works Under the Hood - where observations re-enter
- Tool Use & Function Calling Basics - dispatch basics
- JSON Schema for Tool Parameters: A Reference Guide - preventing invalid_args
- Parallel Tool Calls: When and How Models Call Multiple Tools at Once - partial batch failures
- Tool Use & Function Calling Best Practices - broader habits
- Fallback Chains: Degrading Gracefully When a Model or Tool Fails - higher-level degrade paths
- Circuit Breakers for Agent Tool Calls - systemic outages
- Stopping Conditions: How an Agent Knows When It's Done - exit reasons
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.