Ask a typical agent framework to extract a structured record and it hands you a dictionary — whatever the model happened to emit, passed straight through. That is convenient right up until it is not: a missing field becomes a KeyError three functions away from the LLM call, a string 'true' masquerades as a boolean, and your type checker cannot warn you because, as far as it knows, the entire result is dict[str, Any]. In frameworks like LangChain or CrewAI, validation is something you bolt on after the fact, at every call site, forever.
PydanticAI — built by the team behind Pydantic itself — flips that default. The types are not decoration; they are the contract. Declare an output_type and every run is validated against it by the same core engine that validates your API payloads. Write tools as ordinary typed functions and their signatures become JSON Schema whose arguments are validated before your code executes. Dependencies arrive through a typed RunContext, so “give me the database handle” is solved with constructor-style typing instead of globals or string keys.
This post is a source tour of the GitHub main branch as of November 2026. Following a single agent.run_sync() call end to end reveals a small core: a pydantic-graph state machine with just a few node types that loops between model requests and tool dispatch until the output validates. The two diagrams below are generated from the real repository tree; the overview shows the moving parts, the detail map shows classes and line numbers. By the end you will know exactly where retries come from — and why each one costs a full extra model round-trip.
PydanticAI at a glance: your app talks to Agent, which runs a graph of request and tool-call nodes; models resolve through providers, results flow through message parts, output validation, and usage accounting.
Reading the overview from left to right:
- Your application constructs
Agent, L531and callsrun,run_sync, oriter— all inherited fromAbstractAgent.run, L484in the siblingabstract.py. - A run is literally a graph:
build_agent_graph, L4893wires nodes overGraphAgentState, L755andGraphAgentDeps, L875from the separate pydantic-graph package. ModelRequestNode, L2263turns history into parameters and calls the model — checking limits first viaUsageLimits, L548.- The
Model, L762ABC is resolved from strings likeopenai:gpt-5byinfer_model, L2901; clients come frominfer_provider, L432. FallbackModel, L78andInstrumentedModel, L466are optional wrappers — failover and tracing — around whichever model you picked.- Every hop is a typed part in
messages.py:ModelRequest,ModelResponse,ToolCallPart,ToolReturnPart. CallToolsNode, L3784dispatches tool calls toTool, L355objects, each carrying aToolDefinition, L700with the args schema.- Final output flows through
OutputSchema, L553andOutputValidator, L519before becoming theFinalResult, L1236you awaited.
The detail view: each box links to the source file. Note the two retry edges — one for failed tool arguments, one for failed output validation — both re-enter ModelRequestNode.
The Run Graph: Two Nodes in a Loop
PydanticAI does not hand-roll a while-loop; a run is a real state machine on the pydantic-graph engine. BaseNode, L33 subclasses each return the next node, and End, L62 terminates the run carrying the final result. The state and per-run dependencies travel in GraphRunContext, L24. If you have read our LangGraph internals tour, the shape is familiar — explicit nodes, explicit edges — except PydanticAI needs only a handful of node types because the domain is narrower.
Budget enforcement lives inside the loop, not around it. ModelRequestNode calls check_before_request before dispatching (L2977 and L3054) and re-checks after every response, accumulating RunUsage (L420) from per-call RequestUsage at L326. A run that exceeds its declared limits dies mid-graph with UsageLimitExceeded, L545 rather than completing with a warning.
The sequence: UserPromptNode, L1084 appends your prompt as a UserPromptPart, L1655 and loads instructions; ModelRequestNode.run, L2303 prepares parameters (the heavy lifting is in _prepare_request, L2901) and awaits the model. The response routes to CallToolsNode, L3784, which either executes tool calls and returns control to ModelRequestNode, or recognizes final output and hands it to SetFinalResult, L4307 before End. Streaming is the same loop with events: model_request_stream at L1989 feeds AgentStream, L58 and StreamedRunResult at L608. Where AutoGen drives a conversation between actors, PydanticAI’s loop is a function call cycle — arguably simpler to reason about, and the same philosophy CrewAI externalizes as tasks is compressed into one typed node.
How a Tool Call Becomes a Validated Function Call
A tool is an ordinary Python function wrapped by Tool, L355; the from_schema factory at L508 builds one from an explicit schema, while the @agent.tool decorator derives everything from the signature. The ToolDefinition, L700 dataclass is what the model actually sees: name, description, JSON Schema of the arguments, and flags like requires_approval.
At runtime process_tool_calls, L326 walks the response’s tool calls through processor classes (early, graceful, or exhaustive — L1204 onward) so error policy is pluggable. Arguments are parsed and validated against the schema before your function runs; if they fail, a RetryPromptPart, L2854 goes back to the model and each Tool honors its own max_retries (validated at L344). Successful results become a ToolReturnPart, L2711 appended to history. Tools can also arrive in bulk rather than one by one: anything implementing the toolset protocol — including MCPToolset, L776 for MCP servers — contributes tools through the same dispatch path, so the agent never special-cases where a tool came from. And the definition carries policy, not just shape: a tool flagged for human approval is serialized with kind unapproved (L577) so the model can be cut off before side effects happen. The first argument, when the function declares it, is RunContext, L224 — assembled per run by build_run_context at L4409 with your typed deps, live usage, and retry counters.
Output Modes and the Retry Loop
output_type accepts a plain Pydantic model or explicit mode markers in output.py: ToolOutput, L105 (answer via a tool call), NativeOutput, L182 (provider-native structured output), PromptedOutput, L239 (schema-in-prompt), TextOutput, L370, and StructuredDict at L401 for schemaless JSON. By default, the wrapper in OutputSchema, L553 resolves the mode and — in the tool mode — publishes the expected answer as an extra tool via OutputToolset, L1632, so “the model returns its answer” becomes “the model calls the answer into validation.”
That is the trick behind type safety: validation happens at a chokepoint. output_type may also be a union, in which case UnionOutputProcessor, L1256 wraps the alternatives in a synthetic model and validates whichever shape arrives; ToolOutputSchema at L887 handles fully manual schemas. OutputValidator, L519 instances run user checks, and on any ValidationError or ModelRetry, _make_retry_signal at L213 converts the failure into a retry rather than an exception. The retry is a message (RetryFeedbackPart, L2959) plus a fresh pass through ModelRequestNode — a real second API call. Only when validation passes does the output get wrapped in FinalResult, L1236 and returned as AgentRunResult, L938, usage included.
Providers, Profiles, and the Fallback Wrapper
The model layer is deliberately thin. A string like openai:gpt-5 is split by infer_model, L2901; the provider half goes through the Provider, L60 ABC and infer_provider, L432, which instantiates e.g. OpenAIProvider, L38 with the right SDK client. Per-model quirks — whether the vendor supports this feature, how tokens are counted — live in ModelProfile, L56, and request knobs like temperature live in ModelSettings, L130. Concrete chat models such as OpenAIChatModel, L1636 translate message parts into SDK calls.
Around any of these you can wrap behavior. FallbackModel, L78 implements request at L232 to try models in order on terminal failures. InstrumentedModel, L466 emits OpenTelemetry spans (configured via InstrumentationSettings at L83). TestModel, L62 answers deterministically for CI. MCP servers integrate as MCPToolset, L776 — they surface as ordinary toolsets to the same dispatch path. Usage accrues in RequestUsage/RunUsage (L326, L420) and is enforced by ModelRequestNode._enforce_usage_limits at L3743.
From Install to Your First Model
The repository README’s data-extraction example is the shortest honest demo. Install with uv add pydantic-ai (the core ships as the pydantic-ai-slim package, with optional extras per provider), then:
from typing import Literal
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
class Sentiment(BaseModel):
label: Literal['positive', 'negative', 'neutral']
score: float = Field(ge=-1, le=1)
agent = Agent('openai:gpt-6.1-sol', output_type=Sentiment)
@agent.tool
def recent_reviews(ctx: RunContext, product: str) -> list[str]:
"""Fetch recent review snippets for a product."""
return ['The new release fixed everything I complained about!']
result = agent.run_sync('How are people feeling about the Extract app?')
print(result.output)
#> label='positive' score=0.9
Everything the tour explained is visible in eleven lines: the model string resolves through infer_model and infer_provider; the Sentiment output type becomes a validated contract; the recent_reviews signature and docstring become the tool schema; RunContext is where your dependencies would arrive; and result.output is a real Sentiment instance, not a dict — the README guarantees the run returns that type or an error, never raw text.
Honest Limits
- Retries cost API calls. Every validation failure re-enters
ModelRequestNodewith retry feedback — a full extra round-trip per failure. Generousmax_retrieson flaky schemas can quietly multiply latency and spend. - The retry surface is mid-migration.
RetryPromptPartis marked for removal —_output.pycarries aTODO(v3): remove ... along with RetryPromptPartcomment atL188— in favor ofRetryFeedbackPart. Older snippets using the legacy part will not survive v3 unchanged. - MCP naming has moved. The classic
MCPServerclasses are gone from this codebase; servers integrate asMCPToolset. Tutorials that predate the change will not match what you find here. - Cost limits can degrade to warnings. Token and request limits raise through
UsageLimits.check_tokensatL681, but the enforcement path callscheck_cost(..., warn_if_cost_unavailable=False)— when price data is missing, the run warns instead of failing. - Main moves fast. This tour is a snapshot of
mainas of 2026-11-08. The package has visibly restructured recently — there is noagent.pyat the package root anymore (the class lives in theagent/subpackage), so pinned line numbers and older blog snippets age quickly.
Enjoyed this post? Never miss out on future posts by following us