Back to AI App Dev Series

PydanticAI SDK Track Part 2: Agents, Dependencies & Output

May 24, 2026 Wasil Zafar 40 min read

Deep dive into agent architecture — typed dependency injection for runtime context, structured output types with Pydantic models, capabilities configuration, and result validation patterns.

Table of Contents

  1. Agent Architecture
  2. Dependency Injection
  3. Structured Output Types
  4. Capabilities Configuration
  5. Result Handling & Validation
What You’ll Learn: Agents are the core abstraction in PydanticAI — they wrap an LLM with typed inputs, structured outputs, tools, and system prompts. This article dives deep into agent configuration: model selection, retry strategies, result validators, and the lifecycle of an agent run. Understanding these mechanics lets you build agents that are both powerful and predictable.

1. Agent Architecture

1.1 The Generic Signature

PydanticAI agents are fully generic over two type parameters: DepsType (the runtime dependencies) and OutputType (the structured result). This signature enables full type safety across your entire agent pipeline:

from pydantic_ai import Agent
from pydantic import BaseModel
from dataclasses import dataclass

# Define the output type
class AnalysisResult(BaseModel):
    summary: str
    sentiment: str
    confidence: float
    key_topics: list[str]

# Define the dependency type
@dataclass
class AnalysisDeps:
    user_id: str
    language: str
    max_topics: int = 5

# Create a fully-typed agent: Agent[DepsType, OutputType]
analysis_agent = Agent(
    "openai:gpt-4o",
    deps_type=AnalysisDeps,
    result_type=AnalysisResult,
    system_prompt="Analyze the given text and return structured analysis."
)

# Run with deps — everything is type-checked
result = analysis_agent.run_sync(
    "PydanticAI is a fantastic framework for building type-safe AI agents.",
    deps=AnalysisDeps(user_id="user_123", language="en")
)
print(f"Sentiment: {result.data.sentiment}")
print(f"Confidence: {result.data.confidence}")
print(f"Topics: {result.data.key_topics}")

1.2 System Prompts (Static & Dynamic)

System prompts can be static strings or dynamic functions that receive the run context (including dependencies):

from pydantic_ai import Agent, RunContext
from dataclasses import dataclass

@dataclass
class UserContext:
    name: str
    role: str
    preferred_language: str

# Static system prompt — simple string
simple_agent = Agent(
    "openai:gpt-4o",
    system_prompt="You are a helpful coding assistant. Be concise."
)

# Dynamic system prompt — function that uses deps
dynamic_agent = Agent(
    "openai:gpt-4o",
    deps_type=UserContext,
)

@dynamic_agent.system_prompt
def build_prompt(ctx: RunContext[UserContext]) -> str:
    return f"""You are a personal assistant for {ctx.deps.name}.
They work as a {ctx.deps.role}.
Always respond in {ctx.deps.preferred_language}.
Be professional and concise."""

# Run with context — prompt is built dynamically
result = dynamic_agent.run_sync(
    "What are the best practices for code reviews?",
    deps=UserContext(name="Alice", role="Senior Engineer", preferred_language="English")
)
print(result.data)
Multiple Prompts: You can register multiple @agent.system_prompt decorators on the same agent. They are concatenated in registration order. This enables composable prompt building from separate concerns (personality, constraints, formatting rules).

2. Dependency Injection

2.1 Defining Dependencies

Dependencies are typed runtime context passed to tools and system prompts. They carry services, configuration, and state without being sent to the model:

from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
from typing import Protocol

# Define a protocol for the database interface
class DatabaseProtocol(Protocol):
    async def get_user(self, user_id: str) -> dict: ...
    async def save_record(self, data: dict) -> str: ...

# Dependencies dataclass — carries runtime services
@dataclass
class AppDeps:
    db: DatabaseProtocol
    api_key: str
    request_id: str
    user_timezone: str = "UTC"

# Agent typed with these deps
support_agent = Agent(
    "openai:gpt-4o",
    deps_type=AppDeps,
    system_prompt="You are a customer support agent. Use the database to look up user information."
)

# In production, you'd pass real services:
# result = await support_agent.run(
#     "Look up order #12345",
#     deps=AppDeps(db=real_db, api_key="key", request_id="req_abc")
# )

2.2 Accessing Dependencies in Tools & Prompts

Tools and dynamic prompts receive dependencies through the RunContext parameter:

from pydantic_ai import Agent, RunContext
from pydantic import BaseModel
from dataclasses import dataclass

@dataclass
class WeatherDeps:
    api_base_url: str
    units: str  # "metric" or "imperial"

class WeatherInfo(BaseModel):
    city: str
    temperature: float
    unit: str
    description: str

weather_agent = Agent(
    "openai:gpt-4o",
    deps_type=WeatherDeps,
    result_type=WeatherInfo,
)

@weather_agent.system_prompt
def weather_prompt(ctx: RunContext[WeatherDeps]) -> str:
    return f"You provide weather information. Use {ctx.deps.units} units."

@weather_agent.tool
async def get_weather(ctx: RunContext[WeatherDeps], city: str) -> str:
    """Fetch current weather for a city."""
    # In production, this would call a real API using ctx.deps.api_base_url
    # For demo, return mock data
    return f"Weather in {city}: 22°C, partly cloudy, humidity 65%"

# Run with dependencies
result = weather_agent.run_sync(
    "What's the weather in London?",
    deps=WeatherDeps(api_base_url="https://api.weather.com", units="metric")
)
print(f"{result.data.city}: {result.data.temperature}° {result.data.unit}")
print(f"Condition: {result.data.description}")
Security Note: Dependencies are never serialized to the model. API keys, database connections, and secrets in your deps dataclass stay server-side. Only the tool results (return values) are included in the model context.

3. Structured Output Types

3.1 Primitive Outputs

For simple use cases, agents can return primitive types directly:

from pydantic_ai import Agent

# String output (default — no result_type needed)
chat_agent = Agent("openai:gpt-4o", system_prompt="Be concise.")
result = chat_agent.run_sync("What is Python?")
print(type(result.data))  # <class 'str'>
print(result.data)

# Integer output
count_agent = Agent(
    "openai:gpt-4o",
    result_type=int,
    system_prompt="Count the number of items mentioned by the user."
)
result = count_agent.run_sync("I bought apples, bananas, oranges, and grapes.")
print(f"Count: {result.data}")  # 4
print(type(result.data))  # <class 'int'>

# Boolean output
fact_checker = Agent(
    "openai:gpt-4o",
    result_type=bool,
    system_prompt="Determine if the given statement is factually correct. Return True or False."
)
result = fact_checker.run_sync("The Earth orbits the Sun.")
print(f"Factual: {result.data}")  # True

3.2 Pydantic Model Outputs

Complex structured data is handled by defining Pydantic models as the output type:

from pydantic_ai import Agent
from pydantic import BaseModel, Field
from typing import Optional

class CodeReview(BaseModel):
    """Structured code review result."""
    file_name: str = Field(description="Name of the file reviewed")
    quality_score: int = Field(ge=1, le=10, description="Overall quality 1-10")
    issues: list[str] = Field(description="List of issues found")
    suggestions: list[str] = Field(description="Improvement suggestions")
    security_concerns: Optional[list[str]] = Field(default=None, description="Security issues if any")
    approved: bool = Field(description="Whether the code is approved for merge")

review_agent = Agent(

    "openai:gpt-4o",
    result_type=CodeReview,
    system_prompt="""You are a senior code reviewer. Analyze the given code and provide
    a structured review with quality score, issues, suggestions, and approval decision."""
)

code_snippet = """
def get_user(id):
    query = f"SELECT * FROM users WHERE id = {id}"
    return db.execute(query)
"""

result = review_agent.run_sync(f"Review this code:\n{code_snippet}")
review = result.data

print(f"File: {review.file_name}")
print(f"Quality: {review.quality_score}/10")
print(f"Approved: {review.approved}")
print(f"Issues: {review.issues}")
print(f"Security: {review.security_concerns}")
Real-World Application

Automated Resume Screening

A recruitment platform uses PydanticAI agents with strict result types to screen resumes: the output model has fields for years_experience (int), skills (List[str]), education_level (Enum), and fit_score (float 0-1). Type validation ensures no resume produces an unparseable result, enabling fully automated shortlisting for 10,000 applications/day.

RecruitmentStructured Output

3.3 Union Types for Multiple Output Shapes

When an agent might return different response structures based on the input, use Union types:

from pydantic_ai import Agent
from pydantic import BaseModel
from typing import Union

class SuccessResponse(BaseModel):
    status: str = "success"
    result: str
    confidence: float

class ErrorResponse(BaseModel):
    status: str = "error"
    error_code: str
    message: str
    suggestion: str

class ClarificationNeeded(BaseModel):
    status: str = "clarification"
    question: str
    options: list[str]

# Agent that returns one of multiple response types
flexible_agent = Agent(
    "openai:gpt-4o",
    result_type=Union[SuccessResponse, ErrorResponse, ClarificationNeeded],
    system_prompt="""Process the user's request. Return:
    - SuccessResponse if you can answer confidently
    - ErrorResponse if the request is invalid or impossible
    - ClarificationNeeded if you need more information"""
)

result = flexible_agent.run_sync("What is the population of Atlantis?")
response = result.data

# Type narrowing based on status
if isinstance(response, SuccessResponse):
    print(f"Answer: {response.result} (confidence: {response.confidence})")
elif isinstance(response, ErrorResponse):
    print(f"Error [{response.error_code}]: {response.message}")
    print(f"Suggestion: {response.suggestion}")
elif isinstance(response, ClarificationNeeded):
    print(f"Need clarification: {response.question}")
    print(f"Options: {response.options}")

4. Capabilities Configuration

PydanticAI detects model capabilities automatically, but you can override them for custom or self-hosted models:

from pydantic_ai import Agent
from pydantic_ai.models import ModelSettings

# Default: capabilities are auto-detected per model
agent = Agent("openai:gpt-4o")

# Check what capabilities the model reports
# (Useful when debugging tool calling issues)
print("Agent model:", agent.model)

# Override model settings at runtime
result = agent.run_sync(
    "Generate a creative story about a robot.",
    model_settings=ModelSettings(
        temperature=0.9,
        max_tokens=500,
        top_p=0.95,
    )
)
print(result.data)
from pydantic_ai import Agent

# For models that don't support function calling natively,
# PydanticAI falls back to prompting for JSON output.
# You can force this behavior:
agent = Agent(
    "openai:gpt-4o",
    system_prompt="You are a helpful assistant.",
)

# Override model at runtime (useful for A/B testing)
result_fast = agent.run_sync(
    "Summarize quantum computing in one sentence.",
    model="openai:gpt-4o-mini"  # Use cheaper model for simple tasks
)
print(f"Fast model: {result_fast.data}")

result_deep = agent.run_sync(
    "Explain the philosophical implications of quantum entanglement.",
    model="anthropic:claude-sonnet-4-20250514"  # Use stronger model for complex tasks
)
print(f"Deep model: {result_deep.data}")
Model Override Pattern: Define your agent once with a default model, then override at runtime with the model= parameter. This enables cost optimization (cheap model for simple queries, powerful model for complex reasoning) without creating multiple agent instances.

5. Result Handling & Validation

5.1 RunResult Attributes

The RunResult object provides full access to the agent run metadata:

from pydantic_ai import Agent
from pydantic import BaseModel

class Summary(BaseModel):
    title: str
    points: list[str]
    word_count: int

agent = Agent(
    "openai:gpt-4o",
    result_type=Summary,
    system_prompt="Summarize the given text into structured points."
)

result = agent.run_sync(
    "Python is a high-level programming language known for its readability. "
    "It supports multiple paradigms including procedural, object-oriented, and functional. "
    "Python has a vast ecosystem of libraries for web development, data science, and AI."
)

# Access typed data
print(f"Title: {result.data.title}")
print(f"Points: {result.data.points}")
print(f"Word count: {result.data.word_count}")

# Access full message history
messages = result.all_messages()
print(f"\nTotal messages: {len(messages)}")

# Access usage/cost information
usage = result.usage()
print(f"Request tokens: {usage.request_tokens}")
print(f"Response tokens: {usage.response_tokens}")
print(f"Total tokens: {usage.total_tokens}")

5.2 Output Validators

Add custom validation logic that runs after the model produces output. If validation fails, the agent retries with the error message as feedback:

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

class Recipe(BaseModel):
    name: str
    ingredients: list[str]
    steps: list[str]
    prep_time_minutes: int
    servings: int

recipe_agent = Agent(
    "openai:gpt-4o",
    result_type=Recipe,
    system_prompt="Generate a recipe based on the user's request.",
)

@recipe_agent.result_validator
def validate_recipe(ctx: RunContext, result: Recipe) -> Recipe:
    """Validate the recipe meets our quality standards."""
    if len(result.ingredients) < 3:
        raise ModelRetry("Recipe must have at least 3 ingredients. Please add more.")
    if len(result.steps) < 3:
        raise ModelRetry("Recipe must have at least 3 steps. Please be more detailed.")
    if result.prep_time_minutes <= 0:
        raise ModelRetry("Prep time must be positive. Please provide a realistic estimate.")
    if result.servings <= 0:
        raise ModelRetry("Servings must be at least 1.")
    return result

# If the model returns an invalid recipe, it auto-retries with feedback
result = recipe_agent.run_sync("Give me a quick pasta recipe")
recipe = result.data
print(f"Recipe: {recipe.name}")
print(f"Ingredients ({len(recipe.ingredients)}): {recipe.ingredients}")
print(f"Steps ({len(recipe.steps)}): {recipe.steps[:2]}...")
print(f"Prep time: {recipe.prep_time_minutes} min, Serves: {recipe.servings}")
Retry Budget: By default, PydanticAI retries up to 3 times when validation fails. Each retry includes the validation error message so the model can self-correct. You can configure retries on the Agent constructor to change this limit.
Try It Yourself: Create an agent with a custom result validator that rejects responses containing hallucinated data. The agent should extract company information from a text passage; the validator checks that mentioned revenue figures are within a plausible range (not negative, not > $1T). Test with passages containing both valid and obviously wrong numbers.

Next in the PydanticAI SDK Track

In Part 3: Messages, Chat History & Model Requests, we’ll manage conversation state with typed message objects, implement multi-turn chat, make direct model requests for low-level control, and build stateful conversational agents.