Pydantic AI Guide¶
See Framework Integration Guides for when to reach for a dedicated agent framework at all, and how it fits alongside
@app.agent(). This page covers what's specific to Pydantic AI: typed dependency injection viadeps_type/RunContext, and its multi-agent patterns.
Pydantic AI API surface
Pydantic AI is a fast-moving external SDK. The import paths and method names below reflect pydantic-ai at the time of writing - check the Pydantic AI documentation for the exact current signatures before copying code verbatim.
This guide does not add anything to fast-agent-stack itself. Pydantic AI is wired directly into a plain FastAPI route, alongside (not through) @app.agent(), sharing the same database, vector store, storage, and Redis/Valkey instance.
Getting Started¶
After scaffolding your project, add Pydantic AI as a dependency:
Your Pydantic AI code lives in the scaffolded ai/ package - ai/agents/, ai/tools/, and ai/prompts/.
The scaffolder pre-populates ai/agents/__init__.py with a register_agents() stub for fast-agent-stack's built-in @app.agent(). Since Pydantic AI replaces that, clear it out:
Wiring Pydantic AI to FastAPI¶
A Pydantic AI agent is invoked from a plain APIRouter route, not @app.agent(). Streaming goes through Starlette's StreamingResponse directly.
1. Create the search tool¶
This tool searches your Qdrant vector store using fast-agent-stack's RagService - the same retrieval pipeline the built-in tutorial uses. Unlike Strands' module-closure @tool, Pydantic AI tools receive dependencies through RunContext - the typed deps object the agent was run with - which is what makes Pydantic AI's dependency injection different from Strands':
# myproject/ai/tools/search.py
from dataclasses import dataclass
from pydantic_ai import RunContext
from fast_agent_stack.rag import RagService
from myproject.ai.tools.ingestion import COLLECTION
@dataclass
class ChatDeps:
rag: RagService
async def search_docs(ctx: RunContext[ChatDeps], query: str) -> str:
"""Search uploaded documents for information relevant to the query."""
try:
chunks = await ctx.deps.rag.retrieve(COLLECTION, query, top_k=5)
except Exception as e:
return f"Error searching documents: {e}"
if not chunks:
return "No relevant documents found."
return "\n\n".join(c.content for c in chunks)
search_docs is a plain function, not decorated - Pydantic AI inspects its signature (the RunContext[ChatDeps] first parameter) when the function is passed to Agent(tools=[...]) below, the same way it would if @agent.tool had decorated it directly.
2. Create the agent¶
OpenAIChatModel plus OpenAIProvider point at any OpenAI-compatible endpoint - Ollama, a LiteLLM proxy, or a real OpenAI-compatible API - using the same llm_base_url/llm_model/llm_api_key settings fields the built-in get_llm() factory reads:
# myproject/ai/agents/chat.py
import json
from collections.abc import AsyncIterator
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from fast_agent_stack.rag import get_embedding_provider, get_vector_store, RagService
from myproject.ai.tools.search import ChatDeps, search_docs
from myproject.settings import get_settings
_settings = get_settings()
_model = OpenAIChatModel(
_settings.llm_model,
provider=OpenAIProvider(base_url=_settings.llm_base_url, api_key=_settings.llm_api_key),
)
_deps = ChatDeps(
rag=RagService(
embedding=get_embedding_provider(_settings),
vector_store=get_vector_store(_settings),
)
)
agent = Agent(
_model,
deps_type=ChatDeps,
tools=[search_docs],
system_prompt="You are a helpful assistant for the document Q&A app.",
)
async def stream_chat(prompt: str, conversation_id: str) -> AsyncIterator[str]:
"""Run the chat agent and format its events as SSE data lines."""
async with agent.run_stream(prompt, deps=_deps, conversation_id=conversation_id) as response:
sent = ""
async for cumulative in response.stream_text():
yield f"data: {json.dumps(cumulative[len(sent):])}\n\n"
sent = cumulative
agent and _deps are built once at import time and reused across every request - unlike Strands' ValkeySessionManager, which is bound into the agent at construction time and needs a fresh Agent per conversation, Pydantic AI keeps conversation state out of the agent entirely (see Conversation Persistence below), so one shared instance is correct here.
response.stream_text() yields the cumulative text so far on each call, not deltas - the loop above tracks what has already been sent (sent) and yields only the new suffix. This is deliberate: stream_text(delta=True) yields true deltas, but it also means "the final result message will NOT be added to result messages" per Pydantic AI's own docs - it would silently break the persistence in the next section. Using the default (non-delta) form keeps history tracking intact.
3. Add the schema and route¶
# myproject/schemas.py (add these classes)
from pydantic import BaseModel
class _MessageIn(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
messages: list[_MessageIn]
conversation_id: str | None = None
# myproject/routes.py (add this route)
from fastapi import Depends
from fastapi.responses import StreamingResponse
from fast_agent_stack.auth import get_current_user
from .ai.agents.chat import stream_chat
from .schemas import ChatRequest
@router.post("/agents/chat")
async def chat(body: ChatRequest, _=Depends(get_current_user)) -> StreamingResponse:
prompt = body.messages[-1].content if body.messages else ""
return StreamingResponse(
stream_chat(prompt, body.conversation_id or "default"),
media_type="text/event-stream",
)
This route doesn't use @app.agent(), stream_sse, or agent_loop - Pydantic AI owns the entire request/response cycle.
4. Test it¶
Restart the dev server and send a question:
TOKEN="eyJhbGci..." # your access_token from Part 3
curl -s -X POST http://127.0.0.1:8000/agents/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages": [{"role": "user", "content": "What topics are covered in the uploaded documents?"}], "conversation_id": "test-session-1"}' \
--no-buffer
You'll see SSE events streaming as the agent responds. If the agent decides to search your documents, it calls search_docs automatically - you'll see the tool invocation in the dev server logs.
Using fast-agent-stack Infra from Pydantic AI's Tools¶
A Pydantic AI tool is a plain async function. It has no request-scoped dependency injection of its own - Depends(...) is unreachable from inside one, whether or not the function declares a RunContext parameter. Each infra type below needs its own access pattern.
Database¶
If you haven't set up database models yet, follow Part 2 - Database & Models. Then generate and apply the migration:
If you need to access the database from inside a tool, use get_async_session() as an async generator (not as a FastAPI Depends()). DatabaseLifespanHook initializes the engine automatically at startup in the web process. In a worker process (see Background Processing), call configure_engine(settings.database_url) explicitly at module import time:
# myproject/ai/tools/documents.py
import uuid
from fast_agent_stack.database import get_async_session
from myproject.models import Document
async def get_document(document_id: str) -> dict:
"""Look up a document's metadata by id."""
async for session in get_async_session():
doc = await session.get(Document, uuid.UUID(document_id))
return {"id": str(doc.id), "title": doc.title} if doc else {}
get_document takes no RunContext - Pydantic AI only inspects it looking for one, and doesn't require it. Add it to Agent(tools=[search_docs, get_document]) the same way as search_docs.
Vector Store¶
The search_docs tool shown above demonstrates vector store access via RagService. It uses get_embedding_provider and get_vector_store from fast_agent_stack.rag - there is no top-level fast_agent_stack.vector package.
Storage¶
# myproject/ai/tools/files.py
from fast_agent_stack.storage import get_storage, KeyNotFoundError
from myproject.settings import get_settings
_settings = get_settings()
async def read_uploaded_file(key: str) -> str:
"""Read the raw bytes of an uploaded file by storage key."""
storage = get_storage(_settings)
try:
data = await storage.download(key)
except KeyNotFoundError:
return ""
return data.decode("utf-8", errors="replace")
Redis / Valkey¶
AsyncRedisDep is a FastAPI dependency - it resolves through request.app.state, which a Pydantic AI tool doesn't have access to. Use a plain, module-level client instead, built from the same redis_url setting:
# myproject/ai/tools/cache.py
from functools import lru_cache
from redis.asyncio import Redis
from myproject.settings import get_settings
@lru_cache
def get_redis() -> Redis:
settings = get_settings()
if not settings.redis_url:
raise RuntimeError("redis_url must be set in Settings")
return Redis.from_url(settings.redis_url)
This is the same settings.redis_url field used for the Dramatiq broker and all of fast-agent-stack's own Redis features - one setting, shared everywhere.
Multi-Agent Patterns¶
Pydantic AI supports two lighter-weight multi-agent patterns directly on Agent: agent delegation, where one agent calls another agent as a tool and control returns to the caller when the delegate finishes (pass ctx.usage into the delegate's run so token usage rolls up correctly), and programmatic agent hand-off, where your own application code decides which agent runs next in sequence. For genuinely complex, cyclic control flow, pydantic-graph (available as from pydantic_graph import ...) provides a typed graph builder with steps, joins, and decisions - it integrates directly with Pydantic AI's agent loop. See the Pydantic AI multi-agent documentation and the Graph documentation for constructing these - fast-agent-stack does not wrap or validate them.
Conversation Persistence¶
Unlike Strands, Pydantic AI has no bundled session manager - persist conversation history in Redis/Valkey instead, reusing the same instance already running for auth and rate limiting.
Changes to myproject/ai/agents/chat.py¶
Add these imports at the top:
from pydantic_ai.messages import ModelMessagesTypeAdapter
from myproject.ai.tools.cache import get_redis
Reuse the get_redis() accessor from Redis / Valkey above rather than opening a second client - add these helpers after the agent = Agent(...) block:
async def _load_history(conversation_id: str):
data = await get_redis().get(f"chat:{conversation_id}")
if data is None:
return []
return ModelMessagesTypeAdapter.validate_json(data)
async def _save_history(conversation_id: str, messages) -> None:
blob = ModelMessagesTypeAdapter.dump_json(messages)
await get_redis().set(f"chat:{conversation_id}", blob)
Update stream_chat to load history before the run and save after:
async def stream_chat(prompt: str, conversation_id: str) -> AsyncIterator[str]:
history = await _load_history(conversation_id)
async with agent.run_stream(prompt, deps=_deps, message_history=history, conversation_id=conversation_id) as response:
sent = ""
async for cumulative in response.stream_text():
yield f"data: {json.dumps(cumulative[len(sent):])}\n\n"
sent = cumulative
await _save_history(conversation_id, response.all_messages())
conversation_id is a plain string key into Redis - if every caller that omits it falls back to the same literal value, every one of those conversations reads and writes the same history, corrupting each other. Generate a fresh id server-side instead of defaulting to a shared placeholder:
# myproject/routes.py (updated: generates a conversation_id on the first turn)
import uuid
from fastapi import Depends
from fastapi.responses import StreamingResponse
from fast_agent_stack.auth import get_current_user
from .ai.agents.chat import stream_chat
from .schemas import ChatRequest
@router.post("/agents/chat")
async def chat(body: ChatRequest, _=Depends(get_current_user)) -> StreamingResponse:
prompt = body.messages[-1].content if body.messages else ""
conversation_id = body.conversation_id or str(uuid.uuid4())
response = StreamingResponse(stream_chat(prompt, conversation_id), media_type="text/event-stream")
response.headers["X-Conversation-Id"] = conversation_id
return response
Omit conversation_id on the first request to start a new conversation - read it back from the X-Conversation-Id response header and pass it on every follow-up request to continue that same conversation.
Background Processing with Dramatiq¶
A Pydantic AI agent runs inside a Dramatiq actor the same way any other async workload does - no special handling needed. See Part 7 - Background Tasks for the full pattern.