Tool Schemas, Validation & Error Handling

9 min read Module 2 of 10 Topic 6 of 30

What you'll learn

  • Write precise tool schemas that minimize hallucinated arguments
  • Implement input validation before tool execution using Pydantic
  • Return structured error responses that help the model self-correct
  • Use tenacity to add exponential backoff retries to external tool calls
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The Gap Between Schema and Reality

A well-written tool schema is your primary defence against model hallucination in tool arguments. Vague schemas produce vague arguments. Precise schemas produce precise arguments.

Here’s the transformation from a bad schema to a good one for the same tool:

Bad schema:

{
  "name": "search_orders",
  "description": "Search orders",
  "parameters": {
    "properties": {
      "customer": {"type": "string"},
      "date": {"type": "string"},
      "status": {"type": "string"}
    }
  }
}

Good schema:

{
  "name": "search_orders",
  // Clear "when to use" description helps the model pick the right tool
  "description": "Search customer orders in the database. Returns a list of matching orders. Use this when the user asks about their orders, delivery status, or order history.",
  "parameters": {
    "type": "object",
    "properties": {
      "customer_email": {
        "type": "string",
        "format": "email",
        // Concrete example prevents the model from guessing the format
        "description": "Customer's email address (lowercase). Example: [email protected]"
      },
      "date_from": {
        "type": "string",
        // Regex pattern constrains the model to exactly YYYY-MM-DD, no "January 1st" variants
        "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
        "description": "Start date in ISO 8601 format (YYYY-MM-DD). Example: 2024-01-01"
      },
      "date_to": {
        "type": "string",
        "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
        // Cross-field constraint stated in prose, JSON Schema can't enforce this, but the model reads it
        "description": "End date in ISO 8601 format (YYYY-MM-DD). Must be >= date_from."
      },
      "status": {
        "type": "string",
        // enum eliminates free-text hallucination, the model must pick one of these exact strings
        "enum": ["pending", "shipped", "delivered", "cancelled"],
        "description": "Filter by order status. Omit to return all statuses."
      },
      "limit": {
        "type": "integer",
        // minimum/maximum enforce numeric bounds at the schema level
        "minimum": 1,
        "maximum": 50,
        "default": 10,
        "description": "Maximum number of orders to return (1-50)."
      }
    },
    // required list tells the model which fields it must always provide
    "required": ["customer_email"],
    // additionalProperties: false rejects any key not defined above, prevents hallucinated extra fields
    "additionalProperties": false
  }
}

The good schema gives the model everything it needs: exact formats with examples, enum constraints for status (no guessing), and a clear description of when to use this tool.


Pydantic-based Tool Argument Validation

Even a well-defined schema can’t catch every bad argument the model might generate, semantic validation (e.g. date range order) must happen in code. Pydantic lets you express these rules as a model class that runs before any database call.

# src/agents/tools/order_search.py
from datetime import date, datetime
from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
from typing import Literal

class OrderSearchArgs(BaseModel):
    # EmailStr validates format at the type level: no regex needed in the function body
    customer_email: EmailStr
    # date type means Pydantic auto-parses "2024-01-15" strings into Python date objects
    date_from: date | None = None
    date_to: date | None = None
    # Literal enforces the same enum constraint as the JSON schema, now in Python
    status: Literal["pending", "shipped", "delivered", "cancelled"] | None = None
    # Field(ge=1, le=50) mirrors the JSON schema minimum/maximum: double enforcement
    limit: int = Field(default=10, ge=1, le=50)
    
    @model_validator(mode='after')
    def validate_date_range(self) -> 'OrderSearchArgs':
        # Cross-field validation: JSON Schema cannot enforce this, Pydantic can
        if self.date_from and self.date_to:
            if self.date_to < self.date_from:
                raise ValueError(
                    f"date_to ({self.date_to}) must be >= date_from ({self.date_from})"
                )
        return self

async def search_orders(
    customer_email: str,
    date_from: str | None = None,
    date_to: str | None = None,
    status: str | None = None,
    limit: int = 10,
) -> dict:
    """Search customer orders. Returns validated list or error."""
    
    # Validate arguments before hitting the database
    try:
        args = OrderSearchArgs(
            customer_email=customer_email,
            date_from=date_from,
            date_to=date_to,
            status=status,
            limit=limit,
        )
    except ValueError as e:
        # Return structured error: model can self-correct by reading the message and hint
        return {
            "success": False,
            "error": "invalid_arguments",
            "message": str(e),
            "hint": "Check date format (YYYY-MM-DD) and ensure date_to >= date_from"
        }
    
    # Safe to proceed with validated args
    try:
        results = await db_search_orders(args)
        return {"success": True, "orders": results, "count": len(results)}
    except Exception as e:
        # retriable: True signals to the model that trying again is worthwhile
        return {
            "success": False,
            "error": "database_error",
            "message": "Failed to query orders database",
            "retriable": True
        }

Retry Logic with Tenacity

External tools fail. APIs rate-limit. Networks timeout. Use tenacity for structured retry behaviour:

# src/agents/tools/web_search.py
import asyncio
import httpx
from tenacity import (
    retry,
    stop_after_attempt,
    wait_exponential,
    retry_if_exception_type,
    before_sleep_log,
)
import logging

logger = logging.getLogger(__name__)

# @retry decorator wraps the function: failures trigger automatic retries without changing call sites
@retry(
    # stop_after_attempt(3): give up after 3 total tries (1 original + 2 retries)
    stop=stop_after_attempt(3),
    # wait_exponential: first retry waits ~1s, second ~2s, capped at 10s: avoids hammering the API
    wait=wait_exponential(multiplier=1, min=1, max=10),
    # only retry on transient network errors: don't retry 400 Bad Request or auth failures
    retry=retry_if_exception_type((httpx.TimeoutException, httpx.HTTPStatusError)),
    # logs a WARNING before each sleep so you can see retry attempts in production logs
    before_sleep=before_sleep_log(logger, logging.WARNING),
)
async def search_tavily(query: str, num_results: int = 5) -> list[dict]:
    """Search web using Tavily API with automatic retries."""
    async with httpx.AsyncClient(timeout=10.0) as client:
        response = await client.post(
            "https://api.tavily.com/search",
            json={
                "api_key": settings.tavily_api_key,
                "query": query,
                "num_results": num_results,
                "search_depth": "advanced",
            }
        )
        
        if response.status_code == 429:  # rate limited
            # Respect the server's own retry guidance before raising to trigger the tenacity retry
            retry_after = int(response.headers.get("Retry-After", 5))
            await asyncio.sleep(retry_after)
            response.raise_for_status()  # trigger retry
        
        response.raise_for_status()
        return response.json()["results"]

The Error Response Pattern

When a tool fails, what you return matters. Returning a helpful error object lets the model reason about what happened and potentially adapt:

# Pattern: always return a dict with a 'success' key
# Consistent shape means the model can reliably check success before reading data
class ToolResult:
    @staticmethod
    def ok(data: dict | list) -> dict:
        return {"success": True, "data": data}
    
    @staticmethod
    def error(
        code: str,
        message: str,
        retriable: bool = False,
        suggestion: str = "",
    ) -> dict:
        # structured error codes let the model distinguish "bad input" from "try again later"
        return {
            "success": False,
            "error": {"code": code, "message": message},
            # retriable tells the model whether it's worth calling this tool again
            "retriable": retriable,
            # suggestion gives the model a concrete next action to try
            "suggestion": suggestion,
        }

# Usage in a tool
async def get_stock_price(ticker: str) -> dict:
    # Validate input before any network call: fail fast with a clear message
    if not ticker.isalpha() or len(ticker) > 5:
        return ToolResult.error(
            code="invalid_ticker",
            message=f"'{ticker}' is not a valid stock ticker",
            suggestion="Use a standard stock symbol like 'AAPL', 'MSFT', or 'GOOGL'"
        )
    
    try:
        price = await fetch_price_from_api(ticker.upper())
        return ToolResult.ok({"ticker": ticker.upper(), "price": price, "currency": "USD"})
    except TickerNotFoundError:
        # retriable=False: retrying with the same ticker won't help
        return ToolResult.error(
            code="ticker_not_found",
            message=f"No price data found for '{ticker}'",
            suggestion="Verify the ticker symbol is correct"
        )
    except RateLimitError:
        # retriable=True: the model can try again later or switch data sources
        return ToolResult.error(
            code="rate_limited",
            message="Price API is rate limited",
            retriable=True,
            suggestion="Try again in 60 seconds or use a different data source"
        )

Tool Timeout Guards

Always set timeouts. An agent waiting forever for a hung API call will exhaust its iteration budget:

import asyncio

async def safe_tool_call(tool_func, *args, timeout_seconds: float = 15.0, **kwargs) -> dict:
    """Execute a tool with a hard timeout."""
    try:
        # asyncio.wait_for raises TimeoutError if tool_func doesn't complete in time
        return await asyncio.wait_for(
            tool_func(*args, **kwargs),
            timeout=timeout_seconds
        )
    except asyncio.TimeoutError:
        # Return a retriable error: the model can decide whether to retry or move on
        return {
            "success": False,
            "error": {"code": "timeout", "message": f"Tool timed out after {timeout_seconds}s"},
            "retriable": True,
            "suggestion": "The operation took too long. Try a more specific query."
        }

Checklist: Production-ready Tool Definition

Before shipping any tool to production, verify:

  • Description explains when to use this tool (not just what it does)
  • All parameter descriptions include format examples (YYYY-MM-DD, ISO 4217 currency code)
  • Enum values are used wherever the input is constrained
  • additionalProperties: false prevents unexpected argument keys
  • Input is validated with Pydantic before hitting external systems
  • All code paths return a dict (never raises uncaught exceptions)
  • External HTTP calls have timeouts ≤ 15s
  • Rate-limited APIs have retry logic with exponential backoff
  • Error returns include retriable and suggestion fields
  • The tool is unit-tested with valid inputs, invalid inputs, and simulated failures

Knowledge Check

3 questions to test your understanding

1 A tool schema parameter has description: 'The date'. What problem does this create?

2 When a tool fails due to a transient error (e.g. API timeout), what should you return to the model?

3 Why is input validation inside the tool function important even though the LLM generated the arguments from your schema?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan