← Back to DevBytes

Tool Use Patterns with Pydantic AI: Complete Guide

Introduction to Tool Use Patterns with Pydantic AI

Pydantic AI is a powerful framework that brings type safety and structured outputs to LLM-based applications. One of its most compelling features is the ability to define tools that language models can call to interact with external systems, fetch data, or perform computations. In this guide, we'll explore the various tool use patterns available in Pydantic AI, from basic function tools to advanced dynamic registration and dependency injection.

What Is Tool Use in Pydantic AI?

Tool use (also known as function calling) is a capability where an LLM can invoke predefined functions to accomplish tasks that go beyond text generation. Pydantic AI wraps this capability in a developer-friendly API that leverages Python's type hints and Pydantic models for validation. Instead of parsing free-form JSON from the model, you define typed Python functions, and Pydantic AI handles the conversion, validation, and execution automatically.

At its core, a tool in Pydantic AI is just a Python function decorated with @agent.tool or registered through the Agent constructor. The framework inspects the function signature, generates a JSON schema, sends it to the LLM, and when the model decides to call the tool, Pydantic AI validates the arguments, runs the function, and feeds the result back into the conversation.

Why Tool Use Matters

LLMs are powerful reasoners, but they have inherent limitations: they cannot access real-time data, perform precise arithmetic reliably, query databases, or interact with APIs. Tools bridge this gap. By equipping an agent with tools, you transform it from a static text generator into an autonomous actor that can:

Pydantic AI's approach is particularly valuable because it enforces type safety at every boundary. If the LLM produces malformed arguments, Pydantic catches the error before your function executes, preventing runtime crashes and unexpected behavior.

Getting Started: Basic Tool Definition

Let's start with a simple example. We'll create an agent that can calculate shipping costs based on weight and destination.

from pydantic_ai import Agent
from pydantic import BaseModel

agent = Agent('openai:gpt-4o')

@agent.tool
def calculate_shipping(ctx, weight_kg: float, destination: str) -> dict:
    """Calculate shipping cost based on weight and destination zone.
    
    Args:
        weight_kg: The weight of the package in kilograms.
        destination: The destination country code (e.g., 'US', 'UK', 'JP').
    
    Returns:
        A dictionary with the shipping cost and estimated delivery days.
    """
    base_rate = 5.00
    zone_rates = {"US": 1.0, "UK": 1.5, "JP": 2.0, "AU": 2.5}
    zone_days = {"US": 3, "UK": 5, "JP": 7, "AU": 10}
    
    rate = zone_rates.get(destination, 3.0)
    cost = base_rate + (weight_kg * rate * 2)
    days = zone_days.get(destination, 14)
    
    return {"cost_usd": round(cost, 2), "estimated_days": days}

result = agent.run_sync("How much does it cost to ship a 2.5kg package to Japan?")
print(result.data)

Notice how the docstring serves double duty: it documents the function for developers and provides the LLM with context about when and how to use the tool. Pydantic AI automatically extracts the parameter types from the function signature and generates the appropriate JSON schema for the model.

Pattern 1: Tools with Dependencies

Real-world applications often need access to shared resources like database connections, HTTP clients, or configuration objects. Pydantic AI supports dependency injection through a typed context parameter. You define a dependencies type when creating the agent, and Pydantic AI passes an instance of that type to every tool via the ctx argument.

from dataclasses import dataclass
from pydantic_ai import Agent, RunContext

@dataclass
class AppDependencies:
    db_connection: str  # In practice, a real DB connection object
    api_key: str
    user_id: str

agent = Agent(
    'openai:gpt-4o',
    deps_type=AppDependencies,
    system_prompt="You are a helpful assistant with access to user data."
)

@agent.tool
def get_user_orders(ctx: RunContext[AppDependencies], limit: int = 10) -> list[dict]:
    """Retrieve recent orders for the authenticated user.
    
    Args:
        limit: Maximum number of orders to return (default 10).
    """
    # In a real app, query the database using ctx.deps.db_connection
    return [
        {"order_id": "ORD-001", "total": 49.99, "status": "shipped"},
        {"order_id": "ORD-002", "total": 129.50, "status": "processing"},
    ][:limit]

@agent.tool
def get_user_profile(ctx: RunContext[AppDependencies]) -> dict:
    """Get the current user's profile information."""
    return {
        "user_id": ctx.deps.user_id,
        "name": "Jane Doe",
        "email": "jane@example.com",
        "member_since": "2022-03-15",
    }

deps = AppDependencies(
    db_connection="postgresql://localhost/shop",
    api_key="sk-xxx",
    user_id="USR-789"
)

result = agent.run_sync("What are my 3 most recent orders?", deps=deps)
print(result.data)

The RunContext generic type ensures that ctx.deps is properly typed, giving you IDE autocompletion and static analysis support. This pattern keeps your tools clean and testable — you can mock the dependencies in unit tests without touching the tool logic.

Pattern 2: Tools Returning Pydantic Models

While returning dictionaries works, returning Pydantic models gives you stronger guarantees about the shape of your data and makes the tool's output self-documenting.

from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext

class WeatherReport(BaseModel):
    location: str = Field(description="The queried location")
    temperature_celsius: float = Field(description="Current temperature")
    humidity_percent: float = Field(description="Relative humidity")
    conditions: str = Field(description="Weather conditions description")
    wind_speed_kph: float = Field(description="Wind speed in km/h")

agent = Agent('openai:gpt-4o')

@agent.tool
def get_weather(ctx, location: str) -> WeatherReport:
    """Get current weather for a given location.
    
    Args:
        location: City name or coordinates.
    """
    # Simulated API response
    return WeatherReport(
        location=location,
        temperature_celsius=22.5,
        humidity_percent=65.0,
        conditions="Partly cloudy with chance of rain",
        wind_speed_kph=12.0,
    )

result = agent.run_sync("What's the weather like in Tokyo right now?")
print(result.data)

When a tool returns a Pydantic model, Pydantic AI serializes it to JSON before sending it back to the LLM. The model's field descriptions also help the LLM understand the structure of the data it receives, leading to more accurate follow-up reasoning.

Pattern 3: Dynamic Tool Registration

Sometimes you need to decide which tools to expose at runtime based on user permissions, configuration, or other dynamic factors. Pydantic AI allows you to register tools dynamically when running the agent.

from pydantic_ai import Agent, RunContext

agent = Agent('openai:gpt-4o')

def admin_only_tool(ctx: RunContext[None], action: str) -> str:
    """Perform an administrative action.
    
    Args:
        action: The admin action to perform.
    """
    return f"Admin action '{action}' executed successfully."

def read_only_tool(ctx: RunContext[None], query: str) -> str:
    """Search the knowledge base.
    
    Args:
        query: The search query.
    """
    return f"Results for '{query}': [doc1, doc2, doc3]"

def run_agent(user_role: str, prompt: str):
    tools = []
    if user_role == "admin":
        tools.append(admin_only_tool)
    tools.append(read_only_tool)
    
    result = agent.run_sync(prompt, toolset=tools)
    return result.data

# Admin user gets access to both tools
print(run_agent("admin", "Search for 'deployment guide' and then run cleanup"))

# Regular user only gets the read-only tool
print(run_agent("user", "Search for 'deployment guide'"))

This pattern is especially useful in multi-tenant applications where different users have different capabilities. By filtering tools at runtime, you ensure the LLM never even sees tools the user isn't authorized to use, preventing prompt injection attacks from bypassing access controls.

Pattern 4: Multi-Step Tool Chains

One of the most powerful aspects of tool use is the LLM's ability to chain multiple tool calls together to solve complex problems. Pydantic AI handles this automatically — when a tool returns a result, the model can decide to call another tool based on that result, creating a multi-step reasoning loop.

from pydantic_ai import Agent

agent = Agent(
    'openai:gpt-4o',
    system_prompt=(
        "You are a research assistant. Use the available tools to "
        "gather information and synthesize answers. Always cite your sources."
    )
)

@agent.tool
def search_database(ctx, query: str) -> list[dict]:
    """Search the product database.
    
    Args:
        query: Natural language search query.
    """
    return [
        {"id": "P100", "name": "Wireless Mouse", "price": 29.99, "stock": 45},
        {"id": "P101", "name": "Mechanical Keyboard", "price": 89.99, "stock": 12},
        {"id": "P102", "name": "USB-C Hub", "price": 39.99, "stock": 0},
    ]

@agent.tool
def get_product_details(ctx, product_id: str) -> dict:
    """Get detailed information about a specific product.
    
    Args:
        product_id: The unique product identifier.
    """
    details = {
        "P100": {"name": "Wireless Mouse", "specs": "2.4GHz, 1600 DPI", "warranty": "2 years"},
        "P101": {"name": "Mechanical Keyboard", "specs": "Cherry MX Blue, RGB", "warranty": "3 years"},
    }
    return details.get(product_id, {"error": "Product not found"})

@agent.tool
def check_compatibility(ctx, product_id: str, device: str) -> dict:
    """Check if a product is compatible with a specific device.
    
    Args:
        product_id: The product to check.
        device: The target device (e.g., 'MacBook Pro', 'Windows PC').
    """
    return {
        "product_id": product_id,
        "device": device,
        "compatible": True,
        "notes": "Requires USB-A port or adapter for older devices."
    }

result = agent.run_sync(
    "Find a wireless mouse, get its full details, and check if it's "
    "compatible with a MacBook Pro."
)
print(result.data)

In this example, the LLM will first call search_database, identify the wireless mouse, then call get_product_details for that product, and finally call check_compatibility. All of this happens within a single run_sync call, with Pydantic AI managing the conversation loop.

Pattern 5: Tools with Error Handling

Tools can fail — APIs go down, databases timeout, inputs are invalid. How you handle errors affects the LLM's ability to recover gracefully. The recommended approach is to return structured error information rather than raising exceptions, so the model can reason about the failure and try an alternative approach.

from pydantic_ai import Agent, RunContext
from pydantic import BaseModel
from typing import Literal

class ToolResult(BaseModel):
    success: bool
    data: dict | None = None
    error: str | None = None
    retry_recommended: bool = False

agent = Agent('openai:gpt-4o')

@agent.tool
def fetch_external_api(ctx, endpoint: str, timeout_seconds: int = 30) -> ToolResult:
    """Fetch data from an external API endpoint.
    
    Args:
        endpoint: The API endpoint path to call.
        timeout_seconds: Request timeout in seconds.
    """
    try:
        # Simulated API call
        if "invalid" in endpoint:
            return ToolResult(
                success=False,
                error=f"Endpoint '{endpoint}' does not exist.",
                retry_recommended=False
            )
        
        # Simulate occasional timeout
        if timeout_seconds < 5:
            return ToolResult(
                success=False,
                error="Request timed out. Try increasing the timeout.",
                retry_recommended=True
            )
        
        return ToolResult(
            success=True,
            data={"endpoint": endpoint, "result": "sample data payload"}
        )
    except Exception as e:
        return ToolResult(
            success=False,
            error=f"Unexpected error: {str(e)}",
            retry_recommended=True
        )

result = agent.run_sync("Fetch data from the /users endpoint. If it fails, try again with a longer timeout.")
print(result.data)

By returning structured error results, you give the LLM the information it needs to make intelligent decisions about retries, fallbacks, or reporting the issue to the user. The retry_recommended field explicitly guides the model on whether retrying makes sense.

Pattern 6: Async Tools

For I/O-bound operations like HTTP requests or database queries, async tools allow your application to remain responsive. Pydantic AI fully supports async tool definitions.

import asyncio
from pydantic_ai import Agent, RunContext

agent = Agent('openai:gpt-4o')

@agent.tool
async def fetch_webpage(ctx, url: str) -> str:
    """Fetch the content of a webpage.
    
    Args:
        url: The URL to fetch.
    """
    # Simulated async HTTP request
    await asyncio.sleep(0.5)
    return f"<html><body>Content from {url}</body></html>"

@agent.tool
async def query_api(ctx, endpoint: str, params: str = "") -> dict:
    """Query an async API endpoint.
    
    Args:
        endpoint: The API endpoint to query.
        params: Optional query parameters as a string.
    """
    await asyncio.sleep(0.3)
    return {"endpoint": endpoint, "params": params, "data": {"result": "ok"}}

async def main():
    result = await agent.run("Fetch the content of https://example.com and summarize it.")
    print(result.data)

asyncio.run(main())

Async tools work seamlessly alongside sync tools — Pydantic AI handles the execution model internally. You can mix and match as needed, though for consistency and performance in I/O-heavy applications, prefer async tools throughout.

Pattern 7: Tool Groups with Toolsets

As your agent grows, you may want to organize tools into logical groups. Pydantic AI supports toolsets that let you bundle related tools together and attach them to agents as a unit.

from pydantic_ai import Agent, RunContext
from pydantic_ai.toolsets import FunctionToolset

# Create a toolset for file operations
file_toolset = FunctionToolset()

@file_toolset.tool
def read_file(ctx: RunContext[None], path: str) -> str:
    """Read the contents of a file.
    
    Args:
        path: The file path to read.
    """
    try:
        with open(path, 'r') as f:
            return f.read()
    except FileNotFoundError:
        return f"Error: File '{path}' not found."

@file_toolset.tool
def write_file(ctx: RunContext[None], path: str, content: str) -> str:
    """Write content to a file.
    
    Args:
        path: The file path to write to.
        content: The content to write.
    """
    with open(path, 'w') as f:
        f.write(content)
    return f"Successfully wrote {len(content)} characters to {path}"

@file_toolset.tool
def list_directory(ctx: RunContext[None], path: str = ".") -> list[str]:
    """List files in a directory.
    
    Args:
        path: The directory path to list.
    """
    import os
    return os.listdir(path)

# Create a toolset for web operations
web_toolset = FunctionToolset()

@web_toolset.tool
def http_get(ctx: RunContext[None], url: str) -> str:
    """Make an HTTP GET request.
    
    Args:
        url: The URL to request.
    """
    import urllib.request
    try:
        with urllib.request.urlopen(url) as response:
            return response.read().decode('utf-8')[:5000]
    except Exception as e:
        return f"Request failed: {str(e)}"

# Combine toolsets into a single agent
agent = Agent(
    'openai:gpt-4o',
    toolsets=[file_toolset, web_toolset],
    system_prompt="You are a helpful assistant with file and web access."
)

result = agent.run_sync("List the files in the current directory and tell me what you find.")
print(result.data)

Toolsets promote code organization and reusability. You can define a toolset once and attach it to multiple agents, or compose different toolsets to create agents with varying capabilities.

Best Practices for Tool Use in Pydantic AI

Write Clear, Descriptive Docstrings

The LLM relies entirely on your tool's docstring and parameter descriptions to decide when and how to use it. Be specific about what the tool does, what each parameter means, and what the return value represents. Ambiguous descriptions lead to incorrect tool selection or malformed arguments.

Keep Tools Focused and Composable

Each tool should do one thing well. Instead of creating a monolithic process_order tool that handles validation, payment, shipping, and notifications, create separate tools for each step. This gives the LLM flexibility to compose them as needed and makes your tools easier to test and maintain.

Use Strict Type Annotations

Pydantic AI generates JSON schemas from your type annotations. Use precise types like list[dict] instead of list, and str instead of Any. For constrained values, use Literal types or Pydantic's constr with regex patterns to guide the LLM toward valid inputs.

from typing import Literal
from pydantic import constr

@agent.tool
def set_temperature(
    ctx,
    room: constr(pattern=r'^[A-Z]{2}-\d{3}$'),  # e.g., "LR-001"
    mode: Literal["heat", "cool", "auto"],
    target_celsius: float
) -> dict:
    """Set the target temperature for a room.
    
    Args:
        room: Room identifier in format XX-NNN (e.g., LR-001 for Living Room).
        mode: Heating mode - 'heat', 'cool', or 'auto'.
        target_celsius: Target temperature in Celsius (16-30).
    """
    return {"room": room, "mode": mode, "target": target_celsius, "status": "set"}

Limit the Number of Tools

While Pydantic AI can handle many tools, LLMs perform best with a focused set. Too many tools can confuse the model and increase token costs. If you have dozens of tools, consider breaking your agent into multiple specialized agents or using dynamic tool registration to expose only relevant tools per context.

Validate and Sanitize Inputs

Even though Pydantic validates types, you should still validate business rules inside your tools. Check for valid ranges, sanitize strings, and verify permissions before performing side-effecting operations. Never trust LLM-generated inputs blindly.

Log Tool Calls for Debugging

When tools misbehave, you need visibility into what the LLM requested and what your tool returned. Use Pydantic AI's logging capabilities or wrap your tools with logging decorators to capture every call.

import logging
from functools import wraps

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("tool_calls")

def log_tool_call(func):
    @wraps(func)
    def wrapper(ctx, *args, **kwargs):
        logger.info(f"Tool '{func.__name__}' called with args={args}, kwargs={kwargs}")
        try:
            result = func(ctx, *args, **kwargs)
            logger.info(f"Tool '{func.__name__}' returned: {str(result)[:200]}")
            return result
        except Exception as e:
            logger.error(f"Tool '{func.__name__}' failed: {e}")
            raise
    return wrapper

@agent.tool
@log_tool_call
def search_products(ctx, query: str, max_results: int = 10) -> list[dict]:
    """Search for products matching a query."""
    return [{"id": "1", "name": "Sample Product", "price": 19.99}]

Handle Rate Limits and Retries

If your tools call external APIs, implement rate limiting and retry logic. The LLM may call a tool multiple times in rapid succession, and you don't want to overwhelm external services or get your API key suspended.

Testing Tools Independently

One of the key advantages of Pydantic AI's tool design is that tools are just Python functions. You can unit test them in isolation without involving the LLM at all.

import pytest
from pydantic_ai import RunContext

def test_calculate_shipping_domestic():
    ctx = RunContext(deps=None, retry=0, tool_name="test", model=None, usage=None)
    result = calculate_shipping(ctx, weight_kg=2.5, destination="US")
    assert result["cost_usd"] == 10.0
    assert result["estimated_days"] == 3

def test_calculate_shipping_international():
    ctx = RunContext(deps=None, retry=0, tool_name="test", model=None, usage=None)
    result = calculate_shipping(ctx, weight_kg=2.5, destination="JP")
    assert result["cost_usd"] == 15.0
    assert result["estimated_days"] == 7

def test_calculate_shipping_unknown_destination():
    ctx = RunContext(deps=None, retry=0, tool_name="test", model=None, usage=None)
    result = calculate_shipping(ctx, weight_kg=1.0, destination="ZZ")
    assert result["estimated_days"] == 14  # Default fallback

For integration testing, you can use Pydantic AI's TestModel to simulate LLM responses and verify that your agent calls the right tools with the right arguments.

from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel

agent = Agent('test')

@agent.tool
def get_temperature(ctx, city: str) -> float:
    """Get temperature for a city."""
    return 22.5

# Configure the test model to call the tool
test_model = TestModel()
test_model.set_tool_calls([
    {"tool_name": "get_temperature", "args": {"city": "Berlin"}}
])

result = agent.run_sync("What's the temperature in Berlin?", model=test_model)
print(result.data)

Conclusion

Tool use is the mechanism that transforms LLMs from text generators into capable agents that can interact with the real world. Pydantic AI's approach — combining Python's type system, Pydantic's validation, and a clean decorator-based API — makes tool definition both ergonomic and robust. By following the patterns outlined in this guide, from basic function tools through dependency injection, dynamic registration, async support, and organized toolsets, you can build agents that are reliable, maintainable, and safe. The key principles to remember are: write clear docstrings, keep tools focused, enforce strict types, handle errors gracefully, and test thoroughly. With these practices in place, Pydantic AI gives you a foundation for building production-grade AI applications that leverage the full power of tool-augmented language models.

— Ad —

Google AdSense will appear here after approval

← Back to all articles