Typed Search Tools for Pydantic AI Agents

Pydantic AI validates tool arguments and results at the boundary. Here's how to model Serply's search responses so bad data fails loudly instead of quietly.

Profile picture of Serply
Serply
A schema diagram validating a search result payload

Most agent tool code returns a formatted string and hopes for the best. That works right up until an endpoint returns a shape you didn’t expect — an empty array, a null where you assumed a number — and your agent starts confidently reasoning about None.

Pydantic AI’s pitch is that tools are typed on both sides. Arguments are validated before your function runs; results are validated before the model sees them. For search APIs, where the response shape varies with what the page actually contained, that second half matters a lot.

Modeling the response

Serply’s Google Search endpoint returns an object with a results array plus a set of SERP-feature arrays that are populated only when the page had them. Model the parts you rely on and let the rest pass:

from pydantic import BaseModel, Field, HttpUrl


class ResultMetadata(BaseModel):
    display_url: str | None = None


class SearchResult(BaseModel):
    title: str
    link: str
    description: str = ""
    position: int
    result_type: str = "organic"
    metadata: ResultMetadata = Field(default_factory=ResultMetadata)


class SearchResponse(BaseModel):
    results: list[SearchResult] = Field(default_factory=list)
    query: str = ""
    ts: float | None = None
    total: int | None = None

Two deliberate choices. total is int | None because Google only surfaces an estimated result count on some pages — Serply passes through whatever it got, which is usually null. And link is a plain str, not HttpUrl: results served from certain buckets carry Google’s own tracking parameters, and being strict there buys you validation errors on links that work fine.

The tool

import os
import requests
from urllib.parse import quote_plus
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass


@dataclass
class Deps:
    api_key: str
    proxy_location: str = "US"


agent = Agent(
    "openai:gpt-4o",
    deps_type=Deps,
    system_prompt=(
        "You answer questions using live web search. "
        "Search before answering anything time-sensitive. Cite links."
    ),
)


@agent.tool
def search_web(ctx: RunContext[Deps], query: str, num: int = 10) -> SearchResponse:
    """Search Google for current information.

    Args:
        query: What to search for, phrased as a Google query.
        num: Number of results, 1-100.
    """
    resp = requests.get(
        f"https://api.serply.io/v1/search/q={quote_plus(query)}&num={num}",
        headers={
            "X-Api-Key": ctx.deps.api_key,
            "X-Proxy-Location": ctx.deps.proxy_location,
        },
        timeout=30,
    )
    resp.raise_for_status()
    return SearchResponse.model_validate(resp.json())

Returning a SearchResponse rather than a string is the whole trick. Pydantic AI serializes it into the tool result for the model, and if the API ever returns something that doesn’t fit the model, you get a ValidationError at the boundary — a stack trace pointing at the real problem, instead of an agent quietly hallucinating around a missing field.

Adding a second endpoint

The same pattern extends cleanly. eBay results nest their commerce fields under metadata:

class EbayMetadata(BaseModel):
    price: str | None = None
    was_price: str | None = None
    condition: str | None = None
    seller: str | None = None
    seller_feedback: str | None = None
    image: str | None = None
    attributes: list[str] = Field(default_factory=list)


class EbayListing(BaseModel):
    title: str
    link: str
    position: int
    metadata: EbayMetadata = Field(default_factory=EbayMetadata)


class EbayResponse(BaseModel):
    results: list[EbayListing] = Field(default_factory=list)
    total: int | None = None
    query: str = ""


@agent.tool
def search_ebay(ctx: RunContext[Deps], query: str) -> EbayResponse:
    """Search eBay listings for a product."""
    resp = requests.get(
        f"https://api.serply.io/v1/ebay/search/q={quote_plus(query)}",
        headers={"X-Api-Key": ctx.deps.api_key},
        timeout=30,
    )
    resp.raise_for_status()
    return EbayResponse.model_validate(resp.json())

Prices come back as strings, not numbers — they’re scraped display values and carry currency symbols and formatting. Resist the urge to declare price: float and let Pydantic coerce; you’ll get validation failures on perfectly good listings. Parse deliberately, downstream, where you can handle the ambiguity.

Structured final output

Since you’re already typed end to end, type the answer too:

class Finding(BaseModel):
    claim: str
    source_url: str


class Answer(BaseModel):
    summary: str
    findings: list[Finding]


agent = Agent(
    "openai:gpt-4o",
    deps_type=Deps,
    output_type=Answer,
    system_prompt="Answer from search results only. Every finding needs a source URL.",
)

result = agent.run_sync(
    "What's the current state of EU AI Act enforcement?",
    deps=Deps(api_key=os.environ["SERPLY_API_KEY"]),
)

for f in result.output.findings:
    print(f"{f.claim}\n{f.source_url}\n")

Requiring a source_url on every finding is a structural constraint, not a polite request in a prompt. The model cannot emit a finding without filling that field, which makes uncited claims a schema violation rather than a style problem.

On retries

Pydantic AI will re-prompt the model when a tool call fails validation, which is usually what you want for bad arguments. It is not what you want for a 429 — retrying a rate limit immediately just burns another attempt. Catch it and raise ModelRetry with a delay, or better, return an explicit “rate limited, proceed without this” message so the run degrades instead of dying.