← Back to DevBytes

How to Handle Tool Execution Failures in LLM Agents

How to Handle Tool Execution Failures in LLM Agents

LLM agents are powerful because they can call external tools — search APIs, databases, code interpreters, file systems — to extend their reasoning with real-world actions. But every tool call is a potential point of failure. APIs rate-limit you, network connections drop, JSON payloads are malformed, and permission errors lurk behind every corner. If your agent doesn't handle these failures gracefully, a single transient error can derail an entire task, waste tokens, or worse, cause the agent to hallucinate a result it never actually obtained.

This tutorial walks through what tool execution failures are, why they matter, and how to build robust failure-handling strategies into your LLM agent loop. We'll use Python for the examples, but the patterns apply to any language.

What Is a Tool Execution Failure?

A tool execution failure is any condition where the agent's attempt to invoke a tool does not produce the expected successful result. Failures generally fall into four categories:

Each category requires a different response strategy. Treating them all the same — for example, blindly retrying a validation error — wastes resources and confuses the model.

Why Failure Handling Matters

Without explicit failure handling, your agent will likely do one of two things, both bad:

Good failure handling gives the LLM the context it needs to recover: it tells the model what went wrong, whether retrying makes sense, and what alternative approach it might try. This transforms failures from dead ends into learning signals that steer the agent toward a correct solution.

Anatomy of a Robust Tool Wrapper

The cleanest way to handle failures is to wrap every tool in a function that catches exceptions, normalizes the error into a structured message, and returns that message back to the LLM as if it were a tool result. The agent loop then continues, giving the model a chance to react.

Here's a minimal but complete agent loop with failure-aware tool execution:

import json
import time
import functools
from typing import Any, Callable

class ToolError(Exception):
    """Raised when a tool execution fails in a way the agent should know about."""
    def __init__(self, message: str, retryable: bool = False):
        super().__init__(message)
        self.message = message
        self.retryable = retryable

def with_error_handling(max_retries: int = 3, base_delay: float = 1.0):
    """Decorator that wraps a tool function with retry and error normalization."""
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(*args, **kwargs) -> Any:
            last_error = None
            for attempt in range(1, max_retries + 1):
                try:
                    return func(*args, **kwargs)
                except ToolError as e:
                    last_error = e
                    if not e.retryable or attempt == max_retries:
                        # Non-retryable, or we've exhausted retries.
                        # Return a structured error the LLM can read.
                        return {
                            "status": "error",
                            "error_type": "tool_error",
                            "message": e.message,
                            "retryable": e.retryable,
                            "attempts": attempt,
                        }
                    delay = base_delay * (2 ** (attempt - 1))
                    time.sleep(delay)
                except Exception as e:
                    # Unexpected error — don't retry, just report.
                    return {
                        "status": "error",
                        "error_type": "unexpected_error",
                        "message": f"{type(e).__name__}: {str(e)}",
                        "retryable": False,
                        "attempts": attempt,
                    }
            return {
                "status": "error",
                "error_type": "max_retries_exceeded",
                "message": str(last_error),
                "retryable": False,
                "attempts": max_retries,
            }
        return wrapper
    return decorator

The key design choice here is that errors are returned, not raised, once they reach the agent loop. The LLM consumes them as ordinary tool results. This keeps the loop simple and gives the model full agency to decide what to do next.

Defining Tools with Typed Failures

Now let's define a couple of real tools that use the wrapper. Notice how each tool distinguishes between retryable and non-retryable failures:

import requests

@with_error_handling(max_retries=3, base_delay=1.0)
def search_web(query: str, num_results: int = 5) -> dict:
    if not isinstance(query, str) or not query.strip():
        raise ToolError("query must be a non-empty string", retryable=False)
    if not isinstance(num_results, int) or num_results < 1 or num_results > 20:
        raise ToolError("num_results must be an integer between 1 and 20", retryable=False)

    try:
        resp = requests.get(
            "https://api.example-search.com/v1/search",
            params={"q": query, "count": num_results},
            headers={"Authorization": "Bearer MY_TOKEN"},
            timeout=10,
        )
    except requests.Timeout:
        raise ToolError("Search request timed out", retryable=True)
    except requests.ConnectionError:
        raise ToolError("Could not connect to search service", retryable=True)

    if resp.status_code == 429:
        raise ToolError("Rate limited by search API", retryable=True)
    if resp.status_code == 401:
        raise ToolError("Authentication failed — token may be expired", retryable=False)
    if resp.status_code >= 500:
        raise ToolError(f"Search service error (HTTP {resp.status_code})", retryable=True)

    data = resp.json()
    results = data.get("results", [])
    if not results:
        # Logical error: tool worked, but no results. Still useful to surface.
        return {"status": "success", "results": [], "message": "No results found."}
    return {"status": "success", "results": results}

@with_error_handling(max_retries=1, base_delay=0.5)
def query_database(sql: str) -> dict:
    if not sql.strip().lower().startswith("select"):
        raise ToolError("Only SELECT queries are permitted", retryable=False)
    # ... execute query ...
    return {"status": "success", "rows": []}

Feeding Errors Back to the LLM

The agent loop calls tools, formats their results (success or error) into messages, and appends them to the conversation. The LLM then decides whether to retry, try a different tool, or give up and answer the user.

def run_agent(user_prompt: str, llm_client, tools: dict, max_steps: int = 10):
    messages = [
        {"role": "system", "content": (
            "You are a helpful agent with access to tools. "
            "When a tool returns an error, read the 'message' field carefully. "
            "If 'retryable' is true, you may try again with corrected arguments. "
            "If 'retryable' is false, do not repeat the same call — try a different approach "
            "or explain the limitation to the user."
        )},
        {"role": "user", "content": user_prompt},
    ]

    for step in range(max_steps):
        response = llm_client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=[tool_schema for tool_schema in TOOL_SCHEMAS],
        )
        choice = response.choices[0]
        messages.append(choice.message)

        if not choice.message.tool_calls:
            # No tool calls — agent is done, return final text.
            return choice.message.content

        for call in choice.message.tool_calls:
            tool_name = call.function.name
            args = json.loads(call.function.arguments)

            if tool_name not in tools:
                result = {
                    "status": "error",
                    "error_type": "unknown_tool",
                    "message": f"Tool '{tool_name}' does not exist.",
                    "retryable": False,
                }
            else:
                result = tools[tool_name](**args)

            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "name": tool_name,
                "content": json.dumps(result),
            })

    return "Agent reached the maximum number of steps without finishing."

Notice the system prompt explicitly instructs the model on how to interpret error results. This is critical — without guidance, models often either ignore errors or get stuck in retry loops.

Best Practices

Conclusion

Handling tool execution failures is what separates a toy agent from a production-ready one. By wrapping every tool in error-aware logic, classifying failures as retryable or not, and feeding structured error messages back into the conversation, you give the LLM the information it needs to recover gracefully. The result is an agent that degrades smoothly under real-world conditions — retrying when it should, pivoting when it must, and always keeping the user informed rather than crashing or hallucinating. Build failure handling in from the start, and your agents will be dramatically more reliable when it matters.

— Ad —

Google AdSense will appear here after approval

← Back to all articles