Tool-Use Error Recovery for Agents

AI AgentsLLMArchitectureBackend
Share on LinkedIn

Tools fail. APIs timeout, return 404, reject malformed arguments, and rate-limit at the worst moment. The difference between a fragile agent demo and a production agent is what happens next — does it retry intelligently, try an alternative, explain the problem to the user, or loop the same failing call until the budget runs out? I've seen agents burn $4 retrying a misspelled order ID eleven times because the error message was "Error: failed" and the model had nothing to work with. Structured error recovery is not optional infrastructure.

Structured error responses

Never return opaque errors to the model:

@dataclass
class ToolError:
    tool_name: str
    error_type: Literal["not_found", "validation", "timeout", "permission", "rate_limit", "internal"]
    message: str
    recoverable: bool
    suggestion: str | None = None

def format_error_for_llm(error: ToolError) -> str:
    return json.dumps({
        "status": "error",
        "type": error.error_type,
        "message": error.message,
        "recoverable": error.recoverable,
        "suggestion": error.suggestion,
    })

Example the model can act on:

{
  "status": "error",
  "type": "not_found",
  "message": "Order 4521 not found for user u-123",
  "recoverable": true,
  "suggestion": "Verify the order ID with the user or search orders by date range"
}

Compare that to "Tool execution failed." — one enables recovery, the other enables looping.

Two-layer retry strategy

Executor layer — automatic, invisible to the model:

RETRYABLE = {"timeout", "rate_limit", "internal"}

async def execute_with_retry(tool_name, args, max_retries=2):
    for attempt in range(max_retries + 1):
        try:
            return await execute_tool(tool_name, args)
        except TransientError as e:
            if attempt < max_retries:
                await asyncio.sleep(2 ** attempt)
                continue
            return ToolError(tool_name, "timeout", str(e), recoverable=True,
                           suggestion="The service is temporarily unavailable. Try again or inform the user.")

Model layer — for logical failures the model must reason about:

The model receives the structured error and decides: fix args and retry, try a different tool, ask the user for clarification, or give up gracefully.

Fallback tool chains

Define alternatives for critical operations:

FALLBACK_CHAINS = {
    "search_orders": ["search_orders_by_email", "list_recent_orders"],
    "get_weather": ["get_weather_backup_api"],
}

async def execute_with_fallback(tool_name, args):
    result = await execute_tool(tool_name, args)
    if result.is_error and tool_name in FALLBACK_CHAINS:
        for fallback in FALLBACK_CHAINS[tool_name]:
            result = await execute_tool(fallback, args)
            if not result.is_error:
                return result
    return result

Tell the model about fallbacks in tool descriptions: "If search_orders returns not_found, try search_orders_by_email with the user's email."

Loop detection

Track call signatures across turns:

class LoopDetector:
    def __init__(self, max_identical=3):
        self.history: list[str] = []
        self.max_identical = max_identical

    def record(self, tool_name: str, args: dict) -> None:
        sig = f"{tool_name}:{hash(json.dumps(args, sort_keys=True))}"
        self.history.append(sig)

    def is_looping(self) -> bool:
        if len(self.history) < self.max_identical:
            return False
        return len(set(self.history[-self.max_identical:])) == 1

When looping is detected, inject a system message: "You have called the same tool with the same arguments 3 times. Stop retrying and explain the issue to the user." Hard-stop if it continues.

Graceful degradation

Not every failure needs a perfect recovery. Teach the agent to partial-succeed:

If a non-critical tool fails after one retry, proceed with available data
and note what's missing in your response. Only halt for critical failures
that make the task impossible (e.g., cannot authenticate, cannot find any
orders for this user).

Mark tools as critical or optional in their definitions. The orchestrator enforces: critical failure → stop; optional failure → continue with warning.

Error recovery in eval

Test failure paths explicitly in your eval dataset:

These scenarios catch more production bugs than happy-path evals.

Error taxonomy for tools

Classify errors before deciding recovery strategy:

Error type Example Recovery
Transient 503, timeout Retry with backoff
Client 400 invalid args Fix args, retry once
Auth 401, 403 Stop, ask user to re-auth
Not found 404 Clarify with user
Rate limit 429 Backoff, respect Retry-After
Permanent 501 not implemented Degrade gracefully

Map HTTP status codes in tool wrappers — don't pass raw JSON errors to the model without classification.

User-visible error messages

Translate tool failures into actionable user text:

ERROR_MESSAGES = {
    "ORDER_NOT_FOUND": "I couldn't find order {order_id}. Please check the number and try again.",
    "PAYMENT_DECLINED": "The payment was declined. The card may need updating.",
    "RATE_LIMITED": "I'm processing many requests right now. Please wait a moment.",
}

def format_tool_error(error: ToolError) -> str:
    template = ERROR_MESSAGES.get(error.code, "Something went wrong. Our team has been notified.")
    return template.format(**error.context)

Never expose internal error codes or stack traces to end users.

Circuit breaker for flaky tools

After N consecutive failures on an external API, stop calling it:

breaker = CircuitBreaker(fail_max=5, reset_timeout=60)

@breaker
async def call_crm_api(query: str):
    return await crm.search(query)

Inject breaker state into agent context: "CRM search is temporarily unavailable." Prevents burning tokens on doomed retries.

Pair with agent tool selection routing to exclude broken tools from the routed set automatically.

Common production mistakes

Teams get tool use error recovery wrong in predictable ways:

Agent systems using tool use error recovery loop infinitely when tool errors are swallowed, subagent budgets have no hard cap, and human-in-the-loop gates are bypassed under latency pressure.

Resources

Frequently asked questions

How should agents handle tool failures?

Return structured error information to the model — error type, message, and suggested recovery action — rather than generic failure text. Let the model decide whether to retry with corrected arguments, try an alternative tool, or tell the user what's wrong. Cap retries at 2–3 per tool call to prevent loops.

Should agents automatically retry failed tool calls?

Auto-retry transient failures (timeouts, 503s) with exponential backoff in the executor layer — the model doesn't need to know about infra blips. For logical failures (404, validation error, permission denied), return the error to the model and let it decide how to proceed. Never auto-retry errors that modify state.

How do you prevent agents from retrying the same failed call indefinitely?

Track retry count per tool call signature (tool name + args hash). After 2–3 identical failures, halt and return a user-facing error. Also detect when the model calls the same tool with the same args across turns — that's a loop, not recovery.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →