Computational Analysis of Social Complexity
Fall 2025, Spencer Lyon
Prerequisites
- Function calling concepts (L.A2.01)
- Python type hints and type safety
- Basic agent architectures
Outcomes
- Understand type-safe agent development principles
- Implement validation-first agent architectures with Pydantic
- Apply dependency injection patterns for testable agents
- Build production-ready AI agents with PydanticAI
References
Why Type Safety Matters¶
The Problem: Unvalidated Agent Systems¶
- Consider a simple agent that processes user queries and makes database updates
- What could go wrong?
- Wrong data types passed to functions
- Invalid values that violate business logic
- Runtime errors deep in execution
- Silent failures that corrupt data
- These bugs are expensive in production
- Type safety catches entire classes of errors before they happen
A Concrete Example¶
Suppose we’re building a research assistant agent that:
- Takes a research question
- Searches academic databases
- Returns structured citations
Without type safety:
# Python - no validation
def search_papers(query, max_results, min_year):
# What if query is None?
# What if max_results is negative?
# What if min_year is 99999?
...With type safety:
# Python with Pydantic
from pydantic import BaseModel, Field
class SearchRequest(BaseModel):
query: str = Field(min_length=1, max_length=500)
max_results: int = Field(ge=1, le=100)
min_year: int = Field(ge=1900, le=2025)
def search_papers(request: SearchRequest):
# Guaranteed valid inputs!
...Key Benefits¶
Fail Fast, Fail Loud
- Invalid inputs rejected immediately
- Clear error messages
- No silent corruption
Self-Documenting Code
- Types tell you what’s expected
- IDE autocomplete and hints
- Less need for comments
Refactoring Confidence
- Change a type definition
- Compiler/type checker finds all affected code
- Safe to modify large systems
Testing Made Easier
- Don’t need to test invalid types
- Focus on business logic
- Reduce test surface area
The PydanticAI Philosophy¶
What is PydanticAI?¶
- PydanticAI is a Python framework for building production-ready AI agents
- Created by the team behind Pydantic
- Pydantic powers the OpenAI SDK, Anthropic SDK, FastAPI, and countless other production systems
- Philosophy: validation-first development
Why PydanticAI?
- Type safety catches errors before runtime
- Validation ensures data integrity
- Production-ready patterns from day one
- Seamless integration with Python’s type system
- Used by major companies in production
Chef’s Knife and Cutting Board¶
- PydanticAI describes itself as providing a “chef’s knife and cutting board”
- What does this mean?
- NOT a framework with opinions about everything
- IS a toolkit with powerful, composable primitives
- Simple tools that do one thing well
- Combine them however you want
Contrast with LangChain:
- LangChain: Full kitchen with every appliance
- Chains, memory, vector stores, callbacks, etc.
- Complex abstractions
- Steep learning curve
- Hard to customize
- PydanticAI: Essential tools
- Agent, tools, structured outputs
- Simple abstractions
- Easy to understand
- Flexible customization
When to Use Which:
- Use PydanticAI when: building production systems, need type safety, want simplicity
- Use LangChain when: rapid prototyping, need batteries-included features, okay with complexity
Core Principles¶
1. Type Safety First
- All inputs validated before use
- All outputs structured and validated
- Catch errors at design time, not runtime
2. Validation Before Computation
- Never process invalid data
- Fail immediately with clear messages
- Make invalid states unrepresentable
3. Dependency Injection
- Agents don’t create their dependencies
- Dependencies passed in at construction
- Easy to test with mocks
- Easy to swap implementations
4. Explicit Over Implicit
- No magic global state
- No hidden configuration
- Everything visible in function signatures
5. Production Ready
- Designed for real systems, not demos
- Handles errors gracefully
- Observability built-in
- Performance matters
Core Agent Components¶
The Four Essential Pieces¶
Every PydanticAI agent has these components:
1. Agent Definition
- Specifies the LLM model to use
- Defines system prompt
- Registers available tools
2. Tool Functions
- Functions the agent can call
- Type-annotated parameters
- Validated inputs and outputs
3. Structured Outputs
- Define what the agent should return
- Pydantic models for validation
- Guaranteed schema compliance
4. Run Context (Dependencies)
- External services (databases, APIs)
- Configuration
- State that flows through execution
Let’s examine each in detail
1. Agent Definition¶
In PydanticAI (Python):
from pydantic_ai import Agent
agent = Agent(
'anthropic:claude-haiku-4-5',
system_prompt="You are a helpful research assistant.",
)Key aspects:
- Model string: Specifies which LLM to use
- System prompt: Sets agent behavior and role
- Simple constructor: No complex configuration
2. Tool Functions¶
Tools are functions the agent can call:
from pydantic_ai import Agent
agent = Agent('anthropic:claude-haiku-4-5')
@agent.tool
def search_papers(
query: str,
max_results: int = 10
) -> list[dict]:
"""Search academic papers by query."""
# Implementation here
return resultsWhat happens:
- LLM sees tool name and docstring
- LLM decides when to call it
- Parameters are validated against type hints
- Function executes
- Return value validated
- Result given back to LLM
Type safety at every step!
3. Structured Outputs¶
Define exactly what you want back:
from pydantic import BaseModel
from pydantic_ai import Agent
class Citation(BaseModel):
title: str
authors: list[str]
year: int
doi: str | None = None
class ResearchResult(BaseModel):
summary: str
citations: list[Citation]
confidence: float
agent = Agent(
'anthropic:claude-haiku-4-5',
output_type=ResearchResult
)Benefits:
- LLM must return this exact structure
- Automatic validation
- No parsing strings or JSON
- IDE autocomplete on results
4. Run Context (Dependencies)¶
The secret sauce for testability:
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
@dataclass
class Dependencies:
db: DatabaseClient
api_key: str
max_retries: int = 3
agent = Agent(
'anthropic:claude-haiku-4-5',
deps_type=Dependencies
)
@agent.tool
def search_papers(
ctx: RunContext[Dependencies],
query: str
) -> list[dict]:
# Access dependencies via ctx.deps
return ctx.deps.db.search(query)Why this matters:
- No global variables
- Easy to inject mock dependencies for testing
- Explicit about what each tool needs
- Can have different deps for dev/prod
Putting It All Together¶
A complete PydanticAI agent:
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
from dataclasses import dataclass
# 1. Define dependencies
@dataclass
class ResearchDeps:
db: DatabaseClient
api_key: str
# 2. Define output structure
class Citation(BaseModel):
title: str
authors: list[str]
year: int
class ResearchResult(BaseModel):
summary: str
citations: list[Citation]
# 3. Create agent
agent = Agent(
'anthropic:claude-haiku-4-5',
deps_type=ResearchDeps,
result_type=ResearchResult,
system_prompt='You are a research assistant.'
)
# 4. Register tools
@agent.tool
def search_papers(
ctx: RunContext[ResearchDeps],
query: str,
max_results: int = 10
) -> list[dict]:
"""Search academic database."""
return ctx.deps.db.search(query, limit=max_results)
# 5. Run agent
async def run_research(question: str):
deps = ResearchDeps(
db=get_database(),
api_key=get_api_key()
)
result = await agent.run(question, deps=deps)
return result.data # Guaranteed to be ResearchResult!Building a Research Assistant¶
from dotenv import load_dotenv
import nest_asyncio
load_dotenv()
nest_asyncio.apply()
System Design¶
Let’s build a complete research assistant that demonstrates all the PydanticAI concepts we’ve learned:
Features:
- Search academic papers by query
- Get detailed paper information
- Generate structured research summaries
- Type-safe dependencies and outputs
- Multiple tools working together
Architecture:
User Query
↓
PydanticAI Agent (with system prompt)
↓
Tool Selection (agent decides which tools to use)
↓
Tool Execution (with validated inputs via RunContext)
↓
Result Synthesis (agent combines tool outputs)
↓
Structured Output (validated ResearchSummary)This is a realistic pattern you’d use in production.
Step 1: Define Output Structure¶
First, define what we want the agent to return:
from pydantic import BaseModel, Field
from typing import Optional
class Paper(BaseModel):
"""Academic paper with validated fields."""
title: str = Field(min_length=1)
authors: list[str] = Field(min_length=1)
year: int = Field(ge=1800, le=2025)
abstract: str
doi: Optional[str] = None
class ResearchSummary(BaseModel):
"""Final research output with validation."""
query: str = Field(min_length=1)
summary: str = Field(min_length=1, description="Natural language summary")
key_papers: list[Paper] = Field(min_length=1, description="Most relevant papers found")
confidence: float = Field(ge=0.0, le=1.0, description="Agent's confidence that an answer to the user's query has been found")
def _repr_html_(self) -> str:
"""Rich HTML display for Jupyter notebooks."""
papers_html = ""
for i, paper in enumerate(self.key_papers, 1):
authors = ", ".join(paper.authors)
doi_link = f'<a href="https://doi.org/{paper.doi}" target="_blank">DOI</a>' if paper.doi else ""
papers_html += f"""
<div style="margin: 10px 0; padding: 10px; background: #f8f9fa; border-left: 3px solid #007bff;">
<strong>{i}. {paper.title}</strong> ({paper.year}) {doi_link}<br>
<em>{authors}</em><br>
<small style="color: #666;">{paper.abstract[:200]}...</small>
</div>
"""
confidence_color = "#28a745" if self.confidence >= 0.7 else "#ffc107" if self.confidence >= 0.4 else "#dc3545"
confidence_pct = f"{self.confidence:.0%}"
return f"""
<div style="font-family: sans-serif; max-width: 800px;">
<h3 style="color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px;">
📚 Research Results: "{self.query}"
</h3>
<div style="margin: 15px 0; padding: 15px; background: #e7f3ff; border-radius: 5px;">
<strong>Summary:</strong><br>
{self.summary}
</div>
<div style="margin: 10px 0;">
<strong>Confidence:</strong>
<span style="color: {confidence_color}; font-weight: bold;">{confidence_pct}</span>
</div>
<div style="margin-top: 20px;">
<strong>Key Papers ({len(self.key_papers)}):</strong>
{papers_html}
</div>
</div>
"""
def __str__(self) -> str:
"""Plain text representation."""
lines = [
f"Research Results: {self.query}",
"=" * 60,
f"\nSummary:\n{self.summary}",
f"\nConfidence: {self.confidence:.0%}",
f"\nKey Papers ({len(self.key_papers)}):",
]
for i, paper in enumerate(self.key_papers, 1):
lines.append(f"\n{i}. {paper.title} ({paper.year})")
lines.append(f" Authors: {', '.join(paper.authors)}")
if paper.doi:
lines.append(f" DOI: {paper.doi}")
return "\n".join(lines)
# Test that validation works
try:
invalid = Paper(title="", authors=[], year=3000, abstract="test")
except Exception as e:
print(f"✓ Validation caught error: {type(e).__name__}")
# print the error
print(e)
valid = Paper(
title="Attention Is All You Need",
authors=["Vaswani et al."],
year=2017,
abstract="We propose the Transformer..."
)
print(f"✓ Valid paper created: {valid.title}")✓ Validation caught error: ValidationError
3 validation errors for Paper
title
String should have at least 1 character [type=string_too_short, input_value='', input_type=str]
For further information visit https://errors.pydantic.dev/2.12/v/string_too_short
authors
List should have at least 1 item after validation, not 0 [type=too_short, input_value=[], input_type=list]
For further information visit https://errors.pydantic.dev/2.12/v/too_short
year
Input should be less than or equal to 2025 [type=less_than_equal, input_value=3000, input_type=int]
For further information visit https://errors.pydantic.dev/2.12/v/less_than_equal
✓ Valid paper created: Attention Is All You Need
Step 2: Define Dependencies¶
Define external resources the agent needs:
from dataclasses import dataclass
@dataclass
class ResearchDeps:
"""Dependencies for the research agent."""
database_url: str
api_key: str
max_papers_per_search: int = 10
def search_database(self, query: str, limit: int) -> list[Paper]:
"""Simulate database search (in production, would query real DB)."""
print(f" 🔍 Searching database for: '{query}' (limit: {limit})")
# In production, this would be:
# conn = connect(self.database_url, self.api_key)
# results = conn.execute(search_query)
# return [Paper(**row) for row in results]
# For demo, return mock results
return [
Paper(
title="Attention Is All You Need",
authors=["Vaswani, A.", "Shazeer, N.", "Parmar, N."],
year=2017,
abstract="We propose a new simple network architecture, the Transformer...",
doi="10.5555/3295222.3295349"
),
Paper(
title="BERT: Pre-training of Deep Bidirectional Transformers",
authors=["Devlin, J.", "Chang, M.", "Lee, K."],
year=2019,
abstract="We introduce BERT, a method for pre-training language representations...",
doi="10.18653/v1/N19-1423"
),
]
def get_paper_by_id(self, paper_id: str) -> Paper:
"""Retrieve specific paper by ID."""
print(f" 📄 Fetching paper: {paper_id}")
return Paper(
title="Example Paper",
authors=["Author, A."],
year=2023,
abstract="This is an example paper abstract."
)
# Create dependencies
deps = ResearchDeps(
database_url="sqlite3:///papers.db",
api_key="demo-api-key-12345",
max_papers_per_search=5
)
print("✓ Dependencies configured")✓ Dependencies configured
Step 3: Create Agent and Register Tools¶
Now create the agent and give it tools:
from pydantic_ai import Agent, RunContext
# Create agent with system prompt and structured output
research_agent = Agent(
'anthropic:claude-sonnet-4-5',
deps_type=ResearchDeps,
output_type=ResearchSummary,
system_prompt="""You are a helpful research assistant.
When asked to research a topic:
1. Search for relevant papers using the search_papers tool
2. Analyze the results carefully
3. Provide a clear summary with the most important papers
4. Rate your confidence based on result quality and relevance
Be thorough but concise. Focus on the most impactful papers.
IMPORTANT: never, under any circumstances, make up paper titles, authors, years, abstracts, or DOIs.
IMPORTANT: If you cannot find relevant papers, state that clearly in your summary and set confidence to 0.0. Also don't return any key papers.
IMPORTANT: only use details from the papers you find when generating the summary. do not invent information or use general knowledge.
again if you cannot find relevant papers, state that clearly in your summary and set confidence to 0.0.
"""
)
# Register tool 1: Search for papers
@research_agent.tool
def search_papers(
ctx: RunContext[ResearchDeps],
query: str,
max_results: int = 5
) -> list[Paper]:
"""
Search for academic papers by query string.
Args:
query: Search query (keywords, topics, authors)
max_results: Maximum number of papers to return (default 5)
Returns:
List of relevant papers
"""
# Access dependencies via ctx.deps
limit = min(max_results, ctx.deps.max_papers_per_search)
papers = ctx.deps.search_database(query, limit)
return papers
# Register tool 2: Get specific paper details
@research_agent.tool
def get_paper_details(
ctx: RunContext[ResearchDeps],
paper_id: str
) -> Paper:
"""
Get detailed information about a specific paper by its ID.
Args:
paper_id: Unique identifier for the paper
Returns:
Paper object with full details
"""
return ctx.deps.get_paper_by_id(paper_id)
print("✓ Agent created with 2 tools registered")✓ Agent created with 2 tools registered
Step 4: Run the Agent¶
Execute the agent with a research query:
from pydantic_ai import UsageLimitsasync def run_research(question: str):
deps = ResearchDeps(
database_url="sqlite3:///papers.db",
api_key="demo-api-key-12345",
max_papers_per_search=5
)
result = await research_agent.run(
question,
deps=deps,
usage_limits=UsageLimits(tool_calls_limit=5),
)
return result
research_result = await run_research("How many species of monkeys live in north america") 🔍 Searching database for: 'monkeys species North America distribution' (limit: 5)
🔍 Searching database for: 'primate diversity North America native species' (limit: 5)
🔍 Searching database for: 'New World monkeys United States Canada Mexico biogeography' (limit: 5)
🔍 Searching database for: 'non-human primates North American continent native endemic' (limit: 5)
🔍 Searching database for: 'mammal fauna North America primates absence' (limit: 5)
Traceback (most recent call last):
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/IPython/core/interactiveshell.py", line 3697, in run_code
await eval(code_obj, self.user_global_ns, self.user_ns)
File "/var/folders/7d/x0yzvh3172zdzj7wsxtnhhh00000gn/T/ipykernel_4094/1915870946.py", line 14, in <module>
research_result = await run_research("How many species of monkeys live in north america")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/7d/x0yzvh3172zdzj7wsxtnhhh00000gn/T/ipykernel_4094/1915870946.py", line 7, in run_research
result = await research_agent.run(
^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/agent/abstract.py", line 225, in run
async with self.iter(
^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.12/3.12.12/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 231, in __aexit__
await self.gen.athrow(value)
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/agent/__init__.py", line 647, in iter
async with graph.iter(
^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.12/3.12.12/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 231, in __aexit__
await self.gen.athrow(value)
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_graph/beta/graph.py", line 271, in iter
async with GraphRun[StateT, DepsT, OutputT](
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_graph/beta/graph.py", line 400, in __aexit__
await self._async_exit_stack.__aexit__(exc_type, exc_val, exc_tb)
File "/opt/homebrew/Cellar/python@3.12/3.12.12/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 754, in __aexit__
raise exc_details[1]
File "/opt/homebrew/Cellar/python@3.12/3.12.12/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 735, in __aexit__
cb_suppress = cb(*exc_details)
^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.12/3.12.12/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 158, in __exit__
self.gen.throw(value)
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_graph/beta/graph.py", line 939, in _unwrap_exception_groups
raise exception
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_graph/beta/graph.py", line 711, in _run_tracked_task
result = await self._run_task(t_)
^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_graph/beta/graph.py", line 740, in _run_task
output = await node.call(step_context)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_graph/beta/step.py", line 253, in _call_node
return await node.run(GraphRunContext(state=ctx.state, deps=ctx.deps))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/_agent_graph.py", line 544, in run
async with self.stream(ctx):
^^^^^^^^^^^^^^^^
File "/opt/homebrew/Cellar/python@3.12/3.12.12/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 217, in __aexit__
await anext(self.gen)
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/_agent_graph.py", line 558, in stream
async for _event in stream:
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/_agent_graph.py", line 676, in _run_stream
async for event in self._events_iterator:
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/_agent_graph.py", line 637, in _run_stream
async for event in self._handle_tool_calls(ctx, tool_calls):
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/_agent_graph.py", line 692, in _handle_tool_calls
async for event in process_tool_calls(
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/_agent_graph.py", line 888, in process_tool_calls
async for event in _call_tools(
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/_agent_graph.py", line 955, in _call_tools
usage_limits.check_before_tool_call(projected_usage)
File "/Users/sglyon/Teaching/UCF/CAP-6318/book-myst/weekA02/.venv/lib/python3.12/site-packages/pydantic_ai/usage.py", line 393, in check_before_tool_call
raise UsageLimitExceeded(
pydantic_ai.exceptions.UsageLimitExceeded: The next tool call(s) would exceed the tool_calls_limit of 5 (tool_calls=6).
research_result.all_messages()[ModelRequest(parts=[SystemPromptPart(content='You are a helpful research assistant.\n\n When asked to research a topic:\n 1. Search for relevant papers using the search_papers tool\n 2. Analyze the results carefully\n 3. Provide a clear summary with the most important papers\n 4. Rate your confidence based on result quality and relevance\n\n Be thorough but concise. Focus on the most impactful papers.\n IMPORTANT: never, under any circumstances, make up paper titles, authors, years, abstracts, or DOIs.\n\n IMPORTANT: If you cannot find relevant papers, state that clearly in your summary and set confidence to 0.0.\n\n IMPORTANT: only use details from the papers you find when generating the summary. do not invent information or use general knowledge.\n again if you cannot find relevant papers, state that clearly in your summary and set confidence to 0.0.\n\n ', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 8, 103168, tzinfo=datetime.timezone.utc)), UserPromptPart(content='How many species of monkeys live in north america', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 8, 103172, tzinfo=datetime.timezone.utc))]),
ModelResponse(parts=[ToolCallPart(tool_name='search_papers', args={'query': 'monkey species North America distribution native', 'max_results': 10}, tool_call_id='toolu_015iVGTiBwvEf1vSB4GDtH8s')], usage=RequestUsage(input_tokens=1387, output_tokens=62, details={'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 1387, 'output_tokens': 62}), model_name='claude-sonnet-4-5-20250929', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 10, 617960, tzinfo=datetime.timezone.utc), provider_name='anthropic', provider_details={'finish_reason': 'tool_use'}, provider_response_id='msg_01AEYVeiqSynQXnyaVLd4osj', finish_reason='tool_call'),
ModelRequest(parts=[ToolReturnPart(tool_name='search_papers', content=[Paper(title='Attention Is All You Need', authors=['Vaswani, A.', 'Shazeer, N.', 'Parmar, N.'], year=2017, abstract='We propose a new simple network architecture, the Transformer...', doi='10.5555/3295222.3295349'), Paper(title='BERT: Pre-training of Deep Bidirectional Transformers', authors=['Devlin, J.', 'Chang, M.', 'Lee, K.'], year=2019, abstract='We introduce BERT, a method for pre-training language representations...', doi='10.18653/v1/N19-1423')], tool_call_id='toolu_015iVGTiBwvEf1vSB4GDtH8s', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 10, 618748, tzinfo=datetime.timezone.utc))]),
ModelResponse(parts=[ToolCallPart(tool_name='search_papers', args={'query': 'primate diversity North America biogeography', 'max_results': 10}, tool_call_id='toolu_01EBPBpzy5uiXzoc292kcW2M')], usage=RequestUsage(input_tokens=1627, output_tokens=64, details={'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 1627, 'output_tokens': 64}), model_name='claude-sonnet-4-5-20250929', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 13, 304667, tzinfo=datetime.timezone.utc), provider_name='anthropic', provider_details={'finish_reason': 'tool_use'}, provider_response_id='msg_014HCpQLThvjGCvj9HgFs2dH', finish_reason='tool_call'),
ModelRequest(parts=[ToolReturnPart(tool_name='search_papers', content=[Paper(title='Attention Is All You Need', authors=['Vaswani, A.', 'Shazeer, N.', 'Parmar, N.'], year=2017, abstract='We propose a new simple network architecture, the Transformer...', doi='10.5555/3295222.3295349'), Paper(title='BERT: Pre-training of Deep Bidirectional Transformers', authors=['Devlin, J.', 'Chang, M.', 'Lee, K.'], year=2019, abstract='We introduce BERT, a method for pre-training language representations...', doi='10.18653/v1/N19-1423')], tool_call_id='toolu_01EBPBpzy5uiXzoc292kcW2M', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 13, 305500, tzinfo=datetime.timezone.utc))]),
ModelResponse(parts=[ToolCallPart(tool_name='search_papers', args={'query': 'native monkeys Mexico Central America United States', 'max_results': 10}, tool_call_id='toolu_01AVt8UCiMyphHSzmTC8BxYG')], usage=RequestUsage(input_tokens=1869, output_tokens=64, details={'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 1869, 'output_tokens': 64}), model_name='claude-sonnet-4-5-20250929', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 16, 474688, tzinfo=datetime.timezone.utc), provider_name='anthropic', provider_details={'finish_reason': 'tool_use'}, provider_response_id='msg_01YAhvsa4njfepdKacmsotjQ', finish_reason='tool_call'),
ModelRequest(parts=[ToolReturnPart(tool_name='search_papers', content=[Paper(title='Attention Is All You Need', authors=['Vaswani, A.', 'Shazeer, N.', 'Parmar, N.'], year=2017, abstract='We propose a new simple network architecture, the Transformer...', doi='10.5555/3295222.3295349'), Paper(title='BERT: Pre-training of Deep Bidirectional Transformers', authors=['Devlin, J.', 'Chang, M.', 'Lee, K.'], year=2019, abstract='We introduce BERT, a method for pre-training language representations...', doi='10.18653/v1/N19-1423')], tool_call_id='toolu_01AVt8UCiMyphHSzmTC8BxYG', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 16, 476643, tzinfo=datetime.timezone.utc))]),
ModelResponse(parts=[ToolCallPart(tool_name='search_papers', args={'query': 'New World monkeys biogeography geographic range', 'max_results': 10}, tool_call_id='toolu_01KDUPKnpMeDUsvWXGsvnsbW')], usage=RequestUsage(input_tokens=2111, output_tokens=65, details={'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 2111, 'output_tokens': 65}), model_name='claude-sonnet-4-5-20250929', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 20, 354698, tzinfo=datetime.timezone.utc), provider_name='anthropic', provider_details={'finish_reason': 'tool_use'}, provider_response_id='msg_01Ay7fCeJ5QezNvGbetcRw76', finish_reason='tool_call'),
ModelRequest(parts=[ToolReturnPart(tool_name='search_papers', content=[Paper(title='Attention Is All You Need', authors=['Vaswani, A.', 'Shazeer, N.', 'Parmar, N.'], year=2017, abstract='We propose a new simple network architecture, the Transformer...', doi='10.5555/3295222.3295349'), Paper(title='BERT: Pre-training of Deep Bidirectional Transformers', authors=['Devlin, J.', 'Chang, M.', 'Lee, K.'], year=2019, abstract='We introduce BERT, a method for pre-training language representations...', doi='10.18653/v1/N19-1423')], tool_call_id='toolu_01KDUPKnpMeDUsvWXGsvnsbW', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 20, 356167, tzinfo=datetime.timezone.utc))]),
ModelResponse(parts=[ToolCallPart(tool_name='search_papers', args={'query': 'Platyrrhini species richness northern limit', 'max_results': 10}, tool_call_id='toolu_017bNCgZUUaXoTRkJeZbxbLw')], usage=RequestUsage(input_tokens=2354, output_tokens=66, details={'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 2354, 'output_tokens': 66}), model_name='claude-sonnet-4-5-20250929', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 23, 447720, tzinfo=datetime.timezone.utc), provider_name='anthropic', provider_details={'finish_reason': 'tool_use'}, provider_response_id='msg_017djSEjhtoeJ3xUFs5puZ96', finish_reason='tool_call'),
ModelRequest(parts=[ToolReturnPart(tool_name='search_papers', content=[Paper(title='Attention Is All You Need', authors=['Vaswani, A.', 'Shazeer, N.', 'Parmar, N.'], year=2017, abstract='We propose a new simple network architecture, the Transformer...', doi='10.5555/3295222.3295349'), Paper(title='BERT: Pre-training of Deep Bidirectional Transformers', authors=['Devlin, J.', 'Chang, M.', 'Lee, K.'], year=2019, abstract='We introduce BERT, a method for pre-training language representations...', doi='10.18653/v1/N19-1423')], tool_call_id='toolu_017bNCgZUUaXoTRkJeZbxbLw', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 23, 448744, tzinfo=datetime.timezone.utc))]),
ModelResponse(parts=[ToolCallPart(tool_name='final_result', args={'query': 'How many species of monkeys live in north america', 'summary': 'I was unable to find relevant academic papers addressing the question of how many monkey species live in North America. The search queries returned unrelated papers (about machine learning transformers and language models) rather than papers about primate biogeography or species distribution in North America. \n\nWithout access to relevant scientific literature through this search tool, I cannot provide a scientifically-supported answer to this question based on the available search results.', 'key_papers': [{'title': 'Attention Is All You Need', 'authors': ['Vaswani, A.', 'Shazeer, N.', 'Parmar, N.'], 'year': 2017, 'abstract': 'We propose a new simple network architecture, the Transformer...'}], 'confidence': 0.0}, tool_call_id='toolu_01PikVbFar7CsAZBpN2JDEBr')], usage=RequestUsage(input_tokens=2598, output_tokens=264, details={'cache_creation_input_tokens': 0, 'cache_read_input_tokens': 0, 'input_tokens': 2598, 'output_tokens': 264}), model_name='claude-sonnet-4-5-20250929', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 29, 127835, tzinfo=datetime.timezone.utc), provider_name='anthropic', provider_details={'finish_reason': 'tool_use'}, provider_response_id='msg_01TEjdUsCEHBazcEzoEdfoUd', finish_reason='tool_call'),
ModelRequest(parts=[ToolReturnPart(tool_name='final_result', content='Final result processed.', tool_call_id='toolu_01PikVbFar7CsAZBpN2JDEBr', timestamp=datetime.datetime(2025, 11, 11, 0, 53, 29, 128273, tzinfo=datetime.timezone.utc))])]research_result.outputWhat Just Happened?¶
Let’s break down the agent execution:
1. Agent Received Query
- User asked: “transformer architectures in NLP”
- Agent has access to: search_papers and get_paper_details tools
2. Agent Made Decisions
- LLM analyzed the query
- Decided to call
search_papers("transformer architectures in NLP", max_results=5) - PydanticAI validated the parameters
- Tool executed with access to deps via RunContext
3. Tool Returned Validated Data
- Tool returned
list[Paper](type-checked) - Each Paper object validated by Pydantic
- Agent received the results
4. Agent Synthesized Response
- LLM analyzed the papers
- Generated natural language summary
- Selected key papers
- Assigned confidence score
- Returned ResearchSummary (validated!)
5. Type Safety Throughout
- Every input validated before execution
- Every output validated before return
- Impossible to get wrong types
- Clear errors if validation fails
This is the power of type-safe agent development!
Exercise: Add a New Tool¶
Add a compare_papers tool that takes two paper titles and returns a comparison.
Requirements:
- Use
@research_agent.tooldecorator - Accept
RunContext[ResearchDeps]as first parameter - Take two paper titles as strings
- Return a string with the comparison
- Use proper docstring (the agent sees this!)
Hint: The agent will automatically see this new tool and can call it!
@research_agent.tool
def compare_papers(
ctx: RunContext[ResearchDeps],
paper1_title: str,
paper2_title: str
) -> str:
"""
Compare two papers by their titles.
Args:
paper1_title: Title of first paper
paper2_title: Title of second paper
Returns:
Comparison summary
"""
# TODO: Implement comparison logic
# In production, would search for both papers and compare:
# - Publication dates
# - Citation counts
# - Research methods
# - Key contributions
return f"Comparison between '{paper1_title}' and '{paper2_title}': [TODO: implement]"Advanced: Dynamic System Prompts¶
You can also generate system prompts dynamically based on context:
from pydantic_ai import RunContext
# Create agent without static system prompt
adaptive_agent = Agent(
'anthropic:claude-haiku-4-5',
deps_type=ResearchDeps,
output_type=ResearchSummary
)
# Dynamic system prompt based on context
@adaptive_agent.system_prompt(dynamic=True)
def get_system_prompt(ctx: RunContext[ResearchDeps]) -> str:
"""Generate system prompt based on current context."""
max_papers = ctx.deps.max_papers_per_search
return f"""You are a research assistant with access to {max_papers} papers per search.
Focus on quality over quantity. When analyzing papers:
- Prioritize recent publications (last 5 years)
- Look for highly-cited works
- Consider methodological rigor
- Provide balanced summaries
Your database: {ctx.deps.database_url}
"""
print("✓ Adaptive agent created with dynamic system prompt")✓ Adaptive agent created with dynamic system prompt
Why This Pattern Scales¶
This agent architecture works for production systems because:
1. Type Safety
- Catch errors at development time
- Invalid data rejected before processing
- Clear validation errors guide debugging
2. Separation of Concerns
- Agent logic separate from business logic
- Dependencies injected, not hard-coded
- Easy to test each component independently
3. Composability
- Tools are just Python functions
- Add new capabilities by adding new tools
- Mix and match tools for different agents
4. Observability
- Every tool call is logged
- Clear execution trace
- Easy to monitor and debug
5. Maintainability
- Simple, readable code
- Standard Python patterns
- Type hints document the code
This is how you build AI agents that last!
Key Takeaways¶
Type Safety Prevents Errors
- Validate data at construction time
- Invalid states become unrepresentable
- Clear error messages guide debugging
- Catch bugs before they reach production
PydanticAI Core Concepts
- Pydantic Models - Define validated data structures
- Agent - Coordinates LLM and tools
- agent.tool - Register functions as tools
- RunContext[DepsType] - Inject dependencies safely
- result_type - Guarantee output structure
Production-Ready Pattern
# 1. Define structures
class Output(BaseModel): ...
class Deps: ...
# 2. Create agent
agent = Agent(model, deps_type=Deps, result_type=Output)
# 3. Register tools
@agent.tool
def my_tool(ctx: RunContext[Deps], arg: str): ...
# 4. Run
result = await agent.run(query, deps=deps)Why This Works
- Simple, composable primitives
- Type safety throughout
- Easy to test and maintain
- Scales to complex systems
Quick Reference¶
Pydantic Validation:
from pydantic import BaseModel, Field
class MyModel(BaseModel):
name: str = Field(min_length=1)
count: int = Field(ge=0, le=100)Agent Creation:
from pydantic_ai import Agent
agent = Agent(
'anthropic:claude-haiku-4-5',
deps_type=MyDeps,
result_type=MyOutput,
system_prompt="..."
)Tool Registration:
@agent.tool
def my_tool(
ctx: RunContext[MyDeps],
arg: str
) -> MyType:
"""Docstring that LLM sees."""
return ctx.deps.some_method(arg)Dynamic System Prompt:
@agent.system_prompt
def get_prompt(ctx: RunContext[MyDeps]) -> str:
return f"Context: {ctx.deps.value}"Running Agent:
# Async
result = await agent.run(query, deps=my_deps)
output: MyOutput = result.data
# Streaming
async with agent.run_stream(query, deps=my_deps) as response:
async for chunk in response.stream_text():
print(chunk, end='')
final = await response.get_data()Next Steps¶
In L.A2.03: Agent Frameworks Comparison, we’ll:
- Compare different agent frameworks (PydanticAI, LangChain, CrewAI)
- Understand architectural trade-offs
- Learn when to use each approach
- See the same task implemented multiple ways
In Week A03: Multi-Agent Systems, we’ll:
- Build agent swarms with collective intelligence
- Apply game theory to AI agent interactions
- Create digital twins with agent-based models
- Scale from single agents to agent ecosystems
Practice Ideas:
- Add more tools to the research agent (filter by year, compare citations)
- Build an agent for a different domain (finance, healthcare, education)
- Implement streaming responses for better UX
- Add error handling and retry logic
- Create a simple web UI with FastAPI
Further Reading¶
PydanticAI Documentation:
Pydantic Core:
Python Type System:
Production Topics (for later):
- Testing with pytest
- Monitoring with Logfire
- Deployment patterns
- Cost optimization