chore: import upstream snapshot with attribution
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 13:27:52 +08:00
commit 9201ef759e
2096 changed files with 1232387 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) Pydantic Services Inc. 2024 to present
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# Pydantic AI Examples
[![CI](https://github.com/pydantic/pydantic-ai/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/pydantic/pydantic-ai/actions/workflows/ci.yml?query=branch%3Amain)
[![Coverage](https://coverage-badge.samuelcolvin.workers.dev/pydantic/pydantic-ai.svg)](https://coverage-badge.samuelcolvin.workers.dev/redirect/pydantic/pydantic-ai)
[![PyPI](https://img.shields.io/pypi/v/pydantic-ai.svg)](https://pypi.python.org/pypi/pydantic-ai)
[![versions](https://img.shields.io/pypi/pyversions/pydantic-ai.svg)](https://github.com/pydantic/pydantic-ai)
[![license](https://img.shields.io/github/license/pydantic/pydantic-ai.svg?v)](https://github.com/pydantic/pydantic-ai/blob/main/LICENSE)
Examples of how to use Pydantic AI and what it can do.
For full documentation of these examples and how to run them, see [ai.pydantic.dev/examples/](https://ai.pydantic.dev/examples/).
+67
View File
@@ -0,0 +1,67 @@
"""Very simply CLI to aid in copying examples code to a new directory.
To run examples in place, run:
uv run -m pydantic_ai_examples.<example_module_name>
For examples:
uv run -m pydantic_ai_examples.pydantic_model
To copy all examples to a new directory, run:
uv run -m pydantic_ai_examples --copy-to <destination_path>
See https://ai.pydantic.dev/examples/ for more information.
"""
import argparse
import sys
from pathlib import Path
def cli():
this_dir = Path(__file__).parent
parser = argparse.ArgumentParser(
prog='pydantic_ai_examples',
description=__doc__,
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
'-v', '--version', action='store_true', help='show the version and exit'
)
parser.add_argument(
'--copy-to', dest='DEST', help='Copy all examples to a new directory'
)
args = parser.parse_args()
if args.version:
from pydantic_ai import __version__
print(f'pydantic_ai v{__version__}')
elif args.DEST:
copy_to(this_dir, Path(args.DEST))
else:
parser.print_help()
def copy_to(this_dir: Path, dst: Path):
if dst.exists():
print(f'Error: destination path "{dst}" already exists', file=sys.stderr)
sys.exit(1)
dst.mkdir(parents=True)
count = 0
for file in this_dir.glob('*.*'):
with open(file, 'rb') as src_file:
with open(dst / file.name, 'wb') as dst_file:
dst_file.write(src_file.read())
count += 1
print(f'Copied {count} example files to "{dst}"')
if __name__ == '__main__':
cli()
@@ -0,0 +1,43 @@
"""Example usage of the AG-UI adapter for Pydantic AI.
This provides a FastAPI application that demonstrates how to use the
Pydantic AI agent with the AG-UI protocol. It includes examples for
each of the AG-UI dojo features:
- Agentic Chat
- Human in the Loop
- Agentic Generative UI
- Tool Based Generative UI
- Shared State
- Predictive State Updates
"""
from __future__ import annotations
from fastapi import FastAPI
from .api import (
agentic_chat_app,
agentic_generative_ui_app,
human_in_the_loop_app,
predictive_state_updates_app,
shared_state_app,
tool_approval_app,
tool_based_generative_ui_app,
)
app = FastAPI(title='Pydantic AI AG-UI server')
app.mount('/agentic_chat', agentic_chat_app, 'Agentic Chat')
app.mount('/agentic_generative_ui', agentic_generative_ui_app, 'Agentic Generative UI')
app.mount('/human_in_the_loop', human_in_the_loop_app, 'Human in the Loop')
app.mount(
'/predictive_state_updates',
predictive_state_updates_app,
'Predictive State Updates',
)
app.mount('/shared_state', shared_state_app, 'Shared State')
app.mount('/tool_approval', tool_approval_app, 'Tool Approval (interrupts)')
app.mount(
'/tool_based_generative_ui',
tool_based_generative_ui_app,
'Tool Based Generative UI',
)
@@ -0,0 +1,9 @@
"""Very simply CLI to run the AG-UI example.
See https://ai.pydantic.dev/examples/ag-ui/ for more information.
"""
if __name__ == '__main__':
import uvicorn
uvicorn.run('pydantic_ai_examples.ag_ui:app', port=9000)
@@ -0,0 +1,21 @@
"""Example API for a AG-UI compatible Pydantic AI Agent UI."""
from __future__ import annotations
from .agentic_chat import app as agentic_chat_app
from .agentic_generative_ui import app as agentic_generative_ui_app
from .human_in_the_loop import app as human_in_the_loop_app
from .predictive_state_updates import app as predictive_state_updates_app
from .shared_state import app as shared_state_app
from .tool_approval import app as tool_approval_app
from .tool_based_generative_ui import app as tool_based_generative_ui_app
__all__ = [
'agentic_chat_app',
'agentic_generative_ui_app',
'human_in_the_loop_app',
'predictive_state_updates_app',
'shared_state_app',
'tool_approval_app',
'tool_based_generative_ui_app',
]
@@ -0,0 +1,37 @@
"""Agentic Chat feature."""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from pydantic_ai import Agent
from pydantic_ai.ui.ag_ui import AGUIAdapter
agent = Agent('openai:gpt-5-mini')
@agent.tool_plain
async def current_time(timezone: str = 'UTC') -> str:
"""Get the current time in ISO format.
Args:
timezone: The timezone to use.
Returns:
The current time in ISO format string.
"""
tz: ZoneInfo = ZoneInfo(timezone)
return datetime.now(tz=tz).isoformat()
async def run_agent(request: Request) -> Response:
return await AGUIAdapter.dispatch_request(request, agent=agent)
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
@@ -0,0 +1,130 @@
"""Agentic Generative UI feature."""
from __future__ import annotations
from textwrap import dedent
from typing import Any, Literal
from pydantic import BaseModel, Field
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from ag_ui.core import EventType, StateDeltaEvent, StateSnapshotEvent
from pydantic_ai import Agent
from pydantic_ai.ui.ag_ui import AGUIAdapter
StepStatus = Literal['pending', 'completed']
class Step(BaseModel):
"""Represents a step in a plan."""
description: str = Field(description='The description of the step')
status: StepStatus = Field(
default='pending',
description='The status of the step (e.g., pending, completed)',
)
class Plan(BaseModel):
"""Represents a plan with multiple steps."""
steps: list[Step] = Field(
default_factory=list[Step], description='The steps in the plan'
)
class JSONPatchOp(BaseModel):
"""A class representing a JSON Patch operation (RFC 6902)."""
op: Literal['add', 'remove', 'replace', 'move', 'copy', 'test'] = Field(
description='The operation to perform: add, remove, replace, move, copy, or test',
)
path: str = Field(description='JSON Pointer (RFC 6901) to the target location')
value: Any = Field(
default=None,
description='The value to apply (for add, replace operations)',
)
from_: str | None = Field(
default=None,
alias='from',
description='Source path (for move, copy operations)',
)
agent = Agent(
'openai:gpt-5-mini',
instructions=dedent(
"""
When planning use tools only, without any other messages.
IMPORTANT:
- Use the `create_plan` tool to set the initial state of the steps
- Use the `update_plan_step` tool to update the status of each step
- Do NOT repeat the plan or summarise it in a message
- Do NOT confirm the creation or updates in a message
- Do NOT ask the user for additional information or next steps
Only one plan can be active at a time, so do not call the `create_plan` tool
again until all the steps in current plan are completed.
"""
),
)
@agent.tool_plain
async def create_plan(steps: list[str]) -> StateSnapshotEvent:
"""Create a plan with multiple steps.
Args:
steps: List of step descriptions to create the plan.
Returns:
StateSnapshotEvent containing the initial state of the steps.
"""
plan: Plan = Plan(
steps=[Step(description=step) for step in steps],
)
return StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot=plan.model_dump(),
)
@agent.tool_plain
async def update_plan_step(
index: int, description: str | None = None, status: StepStatus | None = None
) -> StateDeltaEvent:
"""Update the plan with new steps or changes.
Args:
index: The index of the step to update.
description: The new description for the step.
status: The new status for the step.
Returns:
StateDeltaEvent containing the changes made to the plan.
"""
changes: list[JSONPatchOp] = []
if description is not None:
changes.append(
JSONPatchOp(
op='replace', path=f'/steps/{index}/description', value=description
)
)
if status is not None:
changes.append(
JSONPatchOp(op='replace', path=f'/steps/{index}/status', value=status)
)
return StateDeltaEvent(
type=EventType.STATE_DELTA,
delta=changes,
)
async def run_agent(request: Request) -> Response:
return await AGUIAdapter.dispatch_request(request, agent=agent)
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
@@ -0,0 +1,37 @@
"""Human in the Loop Feature.
No special handling is required for this feature.
"""
from __future__ import annotations
from textwrap import dedent
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from pydantic_ai import Agent
from pydantic_ai.ui.ag_ui import AGUIAdapter
agent = Agent(
'openai:gpt-5-mini',
instructions=dedent(
"""
When planning tasks use tools only, without any other messages.
IMPORTANT:
- Use the `generate_task_steps` tool to display the suggested steps to the user
- Never repeat the plan, or send a message detailing steps
- If accepted, confirm the creation of the plan and the number of selected (enabled) steps only
- If not accepted, ask the user for more information, DO NOT use the `generate_task_steps` tool again
"""
),
)
async def run_agent(request: Request) -> Response:
return await AGUIAdapter.dispatch_request(request, agent=agent)
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
@@ -0,0 +1,91 @@
"""Predictive State feature."""
from __future__ import annotations
from dataclasses import replace
from textwrap import dedent
from pydantic import BaseModel
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from ag_ui.core import CustomEvent, EventType
from pydantic_ai import Agent, RunContext
from pydantic_ai.ui import StateDeps
from pydantic_ai.ui.ag_ui import AGUIAdapter
class DocumentState(BaseModel):
"""State for the document being written."""
document: str = ''
agent = Agent('openai:gpt-5-mini', deps_type=StateDeps[DocumentState])
# Tools which return AG-UI events will be sent to the client as part of the
# event stream, single events and iterables of events are supported.
@agent.tool_plain
async def document_predict_state() -> list[CustomEvent]:
"""Enable document state prediction.
Returns:
CustomEvent containing the event to enable state prediction.
"""
return [
CustomEvent(
type=EventType.CUSTOM,
name='PredictState',
value=[
{
'state_key': 'document',
'tool': 'write_document',
'tool_argument': 'document',
},
],
),
]
@agent.instructions()
async def story_instructions(ctx: RunContext[StateDeps[DocumentState]]) -> str:
"""Provide instructions for writing document if present.
Args:
ctx: The run context containing document state information.
Returns:
Instructions string for the document writing agent.
"""
return dedent(
f"""You are a helpful assistant for writing documents.
Before you start writing, you MUST call the `document_predict_state`
tool to enable state prediction.
To present the document to the user for review, you MUST use the
`write_document` tool.
When you have written the document, DO NOT repeat it as a message.
If accepted briefly summarize the changes you made, 2 sentences
max, otherwise ask the user to clarify what they want to change.
This is the current document:
{ctx.deps.state.document}
"""
)
deps = StateDeps(DocumentState())
async def run_agent(request: Request) -> Response:
# `dispatch_request` mutates `deps.state` from the request, so give each request its own copy.
return await AGUIAdapter.dispatch_request(request, agent=agent, deps=replace(deps))
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
@@ -0,0 +1,152 @@
"""Shared State feature."""
from __future__ import annotations
from dataclasses import replace
from enum import Enum
from textwrap import dedent
from pydantic import BaseModel, Field
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from ag_ui.core import EventType, StateSnapshotEvent
from pydantic_ai import Agent, RunContext
from pydantic_ai.ui import StateDeps
from pydantic_ai.ui.ag_ui import AGUIAdapter
class SkillLevel(str, Enum):
"""The level of skill required for the recipe."""
BEGINNER = 'Beginner'
INTERMEDIATE = 'Intermediate'
ADVANCED = 'Advanced'
class SpecialPreferences(str, Enum):
"""Special preferences for the recipe."""
HIGH_PROTEIN = 'High Protein'
LOW_CARB = 'Low Carb'
SPICY = 'Spicy'
BUDGET_FRIENDLY = 'Budget-Friendly'
ONE_POT_MEAL = 'One-Pot Meal'
VEGETARIAN = 'Vegetarian'
VEGAN = 'Vegan'
class CookingTime(str, Enum):
"""The cooking time of the recipe."""
FIVE_MIN = '5 min'
FIFTEEN_MIN = '15 min'
THIRTY_MIN = '30 min'
FORTY_FIVE_MIN = '45 min'
SIXTY_PLUS_MIN = '60+ min'
class Ingredient(BaseModel):
"""A class representing an ingredient in a recipe."""
icon: str = Field(
default='ingredient',
description="The icon emoji (not emoji code like '\x1f35e', but the actual emoji like 🥕) of the ingredient",
)
name: str
amount: str
class Recipe(BaseModel):
"""A class representing a recipe."""
skill_level: SkillLevel = Field(
default=SkillLevel.BEGINNER,
description='The skill level required for the recipe',
)
special_preferences: list[SpecialPreferences] = Field(
default_factory=list[SpecialPreferences],
description='Any special preferences for the recipe',
)
cooking_time: CookingTime = Field(
default=CookingTime.FIVE_MIN, description='The cooking time of the recipe'
)
ingredients: list[Ingredient] = Field(
default_factory=list[Ingredient],
description='Ingredients for the recipe',
)
instructions: list[str] = Field(
default_factory=list[str], description='Instructions for the recipe'
)
class RecipeSnapshot(BaseModel):
"""A class representing the state of the recipe."""
recipe: Recipe = Field(
default_factory=Recipe, description='The current state of the recipe'
)
agent = Agent('openai:gpt-5-mini', deps_type=StateDeps[RecipeSnapshot])
@agent.tool_plain
async def display_recipe(recipe: Recipe) -> StateSnapshotEvent:
"""Display the recipe to the user.
Args:
recipe: The recipe to display.
Returns:
StateSnapshotEvent containing the recipe snapshot.
"""
return StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot={'recipe': recipe},
)
@agent.instructions
async def recipe_instructions(ctx: RunContext[StateDeps[RecipeSnapshot]]) -> str:
"""Instructions for the recipe generation agent.
Args:
ctx: The run context containing recipe state information.
Returns:
Instructions string for the recipe generation agent.
"""
return dedent(
f"""
You are a helpful assistant for creating recipes.
IMPORTANT:
- Create a complete recipe using the existing ingredients
- Append new ingredients to the existing ones
- Use the `display_recipe` tool to present the recipe to the user
- Do NOT repeat the recipe in the message, use the tool instead
- Do NOT run the `display_recipe` tool multiple times in a row
Once you have created the updated recipe and displayed it to the user,
summarise the changes in one sentence, don't describe the recipe in
detail or send it as a message to the user.
The current state of the recipe is:
{ctx.deps.state.recipe.model_dump_json(indent=2)}
""",
)
deps = StateDeps(RecipeSnapshot())
async def run_agent(request: Request) -> Response:
# `dispatch_request` mutates `deps.state` from the request, so give each request its own copy.
return await AGUIAdapter.dispatch_request(request, agent=agent, deps=replace(deps))
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
@@ -0,0 +1,38 @@
"""Tool approval with AG-UI interrupts.
Demonstrates the AG-UI interrupt lifecycle: a tool declared with `requires_approval=True`
pauses the run when the model proposes a call. The adapter emits `RUN_FINISHED` with
`outcome.type == "interrupt"`; the client renders an approval UI from `outcome.interrupts[]`
and posts a follow-up `RunAgentInput` carrying `resume[]` to approve, deny, or edit the call.
Requires `ag-ui-protocol >= 0.1.19`. See https://docs.ag-ui.com/concepts/interrupts.
"""
from __future__ import annotations
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from pydantic_ai import Agent
from pydantic_ai.tools import DeferredToolRequests
from pydantic_ai.ui.ag_ui import AGUIAdapter
# `output_type` must include `DeferredToolRequests` so the run can pause on a pending approval
# instead of erroring when the model proposes a `requires_approval=True` tool.
agent = Agent('openai:gpt-5-mini', output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
"""Delete a file. The run pauses here and waits for the user to approve before executing."""
# Real implementation would actually delete the file.
return f'deleted {path}'
async def run_agent(request: Request) -> Response:
return await AGUIAdapter.dispatch_request(request, agent=agent)
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
@@ -0,0 +1,23 @@
"""Tool Based Generative UI feature.
No special handling is required for this feature.
"""
from __future__ import annotations
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
from pydantic_ai import Agent
from pydantic_ai.ui.ag_ui import AGUIAdapter
agent = Agent('openai:gpt-5-mini')
async def run_agent(request: Request) -> Response:
return await AGUIAdapter.dispatch_request(request, agent=agent)
app = Starlette(routes=[Route('/', run_agent, methods=['POST'])])
@@ -0,0 +1,101 @@
"""Small but complete example of using Pydantic AI to build a support agent for a bank.
Run with:
uv run -m pydantic_ai_examples.bank_support
"""
import sqlite3
from dataclasses import dataclass
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
@dataclass
class DatabaseConn:
"""A wrapper over the SQLite connection."""
sqlite_conn: sqlite3.Connection
async def customer_name(self, *, id: int) -> str | None:
res = cur.execute('SELECT name FROM customers WHERE id=?', (id,))
row = res.fetchone()
if row:
return row[0]
return None
async def customer_balance(self, *, id: int) -> float:
res = cur.execute('SELECT balance FROM customers WHERE id=?', (id,))
row = res.fetchone()
if row:
return row[0]
else:
raise ValueError('Customer not found')
@dataclass
class SupportDependencies:
customer_id: int
db: DatabaseConn
class SupportOutput(BaseModel):
support_advice: str
"""Advice returned to the customer"""
block_card: bool
"""Whether to block their card or not"""
risk: int
"""Risk level of query"""
support_agent = Agent(
'openai:gpt-5.2',
deps_type=SupportDependencies,
output_type=SupportOutput,
instructions=(
'You are a support agent in our bank, give the '
'customer support and judge the risk level of their query. '
"Reply using the customer's name."
),
)
@support_agent.instructions
async def add_customer_name(ctx: RunContext[SupportDependencies]) -> str:
customer_name = await ctx.deps.db.customer_name(id=ctx.deps.customer_id)
return f"The customer's name is {customer_name!r}"
@support_agent.tool
async def customer_balance(ctx: RunContext[SupportDependencies]) -> str:
"""Returns the customer's current account balance."""
balance = await ctx.deps.db.customer_balance(
id=ctx.deps.customer_id,
)
return f'${balance:.2f}'
if __name__ == '__main__':
with sqlite3.connect(':memory:') as con:
cur = con.cursor()
cur.execute('CREATE TABLE customers(id, name, balance)')
cur.execute("""
INSERT INTO customers VALUES
(123, 'John', 123.45)
""")
con.commit()
deps = SupportDependencies(customer_id=123, db=DatabaseConn(sqlite_conn=con))
result = support_agent.run_sync('What is my balance?', deps=deps)
print(result.output)
"""
support_advice='Hello John, your current account balance, including pending transactions, is $123.45.' block_card=False risk=1
"""
result = support_agent.run_sync('I just lost my card!', deps=deps)
print(result.output)
"""
support_advice="I'm sorry to hear that, John. We are temporarily blocking your card to prevent unauthorized transactions." block_card=True risk=8
"""
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat App</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
main {
max-width: 700px;
}
#conversation .user::before {
content: 'You asked: ';
font-weight: bold;
display: block;
}
#conversation .model::before {
content: 'AI Response: ';
font-weight: bold;
display: block;
}
#spinner {
opacity: 0;
transition: opacity 500ms ease-in;
width: 30px;
height: 30px;
border: 3px solid #222;
border-bottom-color: transparent;
border-radius: 50%;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#spinner.active {
opacity: 1;
}
</style>
</head>
<body>
<main class="border rounded mx-auto my-5 p-4">
<h1>Chat App</h1>
<p>Ask me anything...</p>
<div id="conversation" class="px-2"></div>
<div class="d-flex justify-content-center mb-3">
<div id="spinner"></div>
</div>
<form method="post">
<input id="prompt-input" name="prompt" class="form-control"/>
<div class="d-flex justify-content-end">
<button class="btn btn-primary mt-2">Send</button>
</div>
</form>
<div id="error" class="d-none text-danger">
Error occurred, check the browser developer console for more information.
</div>
</main>
</body>
</html>
<script src="https://cdnjs.cloudflare.com/ajax/libs/typescript/5.6.3/typescript.min.js" integrity="sha512-TvPkf1JgpB7FBf8dpYVD+2FXCy+Wn4sHIIWyTQijjOOt8Z20BAbwm0Si991w2k++oXFHp5NlOfWYud/R1sJUNA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="module">
// to let me write TypeScript, without adding the burden of npm we do a dirty, non-production-ready hack
// and transpile the TypeScript code in the browser
// this is (arguably) A neat demo trick, but not suitable for production!
async function loadTs() {
const response = await fetch('/chat_app.ts');
const tsCode = await response.text();
const jsCode = window.ts.transpile(tsCode, { target: "es2015" });
let script = document.createElement('script');
script.type = 'module';
script.text = jsCode;
document.body.appendChild(script);
}
loadTs().catch((e) => {
console.error(e);
document.getElementById('error').classList.remove('d-none');
document.getElementById('spinner').classList.remove('active');
});
</script>
+227
View File
@@ -0,0 +1,227 @@
"""Simple chat app example build with FastAPI.
Run with:
uv run -m pydantic_ai_examples.chat_app
"""
from __future__ import annotations as _annotations
import asyncio
import json
import sqlite3
from collections.abc import AsyncGenerator, Callable
from concurrent.futures.thread import ThreadPoolExecutor
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Annotated, Any, Literal, TypeVar
import fastapi
import logfire
from fastapi import Depends, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from typing_extensions import LiteralString, ParamSpec, TypedDict
from pydantic_ai import (
Agent,
ModelMessage,
ModelMessagesTypeAdapter,
ModelRequest,
ModelResponse,
TextPart,
UnexpectedModelBehavior,
UserPromptPart,
)
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()
agent = Agent('openai:gpt-5.2')
THIS_DIR = Path(__file__).parent
@asynccontextmanager
async def lifespan(_app: fastapi.FastAPI):
async with Database.connect() as db:
yield {'db': db}
app = fastapi.FastAPI(lifespan=lifespan)
logfire.instrument_fastapi(app)
@app.get('/')
async def index() -> FileResponse:
return FileResponse((THIS_DIR / 'chat_app.html'), media_type='text/html')
@app.get('/chat_app.ts')
async def main_ts() -> FileResponse:
"""Get the raw typescript code, it's compiled in the browser, forgive me."""
return FileResponse((THIS_DIR / 'chat_app.ts'), media_type='text/plain')
async def get_db(request: Request) -> Database:
return request.state.db
@app.get('/chat/')
async def get_chat(database: Database = Depends(get_db)) -> Response:
msgs = await database.get_messages()
return Response(
b'\n'.join(json.dumps(to_chat_message(m)).encode('utf-8') for m in msgs),
media_type='text/plain',
)
class ChatMessage(TypedDict):
"""Format of messages sent to the browser."""
role: Literal['user', 'model']
timestamp: str
content: str
def to_chat_message(m: ModelMessage) -> ChatMessage:
first_part = m.parts[0]
if isinstance(m, ModelRequest):
if isinstance(first_part, UserPromptPart):
assert isinstance(first_part.content, str)
return {
'role': 'user',
'timestamp': first_part.timestamp.isoformat(),
'content': first_part.content,
}
elif isinstance(m, ModelResponse):
if isinstance(first_part, TextPart):
return {
'role': 'model',
'timestamp': m.timestamp.isoformat(),
'content': first_part.content,
}
raise UnexpectedModelBehavior(f'Unexpected message type for chat app: {m}')
@app.post('/chat/')
async def post_chat(
prompt: Annotated[str, fastapi.Form()], database: Database = Depends(get_db)
) -> StreamingResponse:
async def stream_messages():
"""Streams new line delimited JSON `Message`s to the client."""
# stream the user prompt so that can be displayed straight away
yield (
json.dumps(
{
'role': 'user',
'timestamp': datetime.now(tz=timezone.utc).isoformat(),
'content': prompt,
}
).encode('utf-8')
+ b'\n'
)
# get the chat history so far to pass as context to the agent
messages = await database.get_messages()
# run the agent with the user prompt and the chat history
async with agent.run_stream(prompt, message_history=messages) as result:
async for text in result.stream_output(debounce_by=0.01):
# text here is a `str` and the frontend wants
# JSON encoded ModelResponse, so we create one
m = ModelResponse(parts=[TextPart(text)], timestamp=result.timestamp)
yield json.dumps(to_chat_message(m)).encode('utf-8') + b'\n'
# add new messages (e.g. the user prompt and the agent response in this case) to the database
await database.add_messages(result.new_messages_json())
return StreamingResponse(stream_messages(), media_type='text/plain')
P = ParamSpec('P')
R = TypeVar('R')
@dataclass
class Database:
"""Rudimentary database to store chat messages in SQLite.
The SQLite standard library package is synchronous, so we
use a thread pool executor to run queries asynchronously.
"""
con: sqlite3.Connection
_loop: asyncio.AbstractEventLoop
_executor: ThreadPoolExecutor
@classmethod
@asynccontextmanager
async def connect(
cls, file: Path = THIS_DIR / '.chat_app_messages.sqlite'
) -> AsyncGenerator[Database]:
with logfire.span('connect to DB'):
loop = asyncio.get_running_loop()
executor = ThreadPoolExecutor(max_workers=1)
con = await loop.run_in_executor(executor, cls._connect, file)
slf = cls(con, loop, executor)
try:
yield slf
finally:
await slf._asyncify(con.close)
@staticmethod
def _connect(file: Path) -> sqlite3.Connection:
con = sqlite3.connect(str(file))
con = logfire.instrument_sqlite3(con)
cur = con.cursor()
cur.execute(
'CREATE TABLE IF NOT EXISTS messages (id INT PRIMARY KEY, message_list TEXT);'
)
con.commit()
return con
async def add_messages(self, messages: bytes):
await self._asyncify(
self._execute,
'INSERT INTO messages (message_list) VALUES (?);',
messages,
commit=True,
)
await self._asyncify(self.con.commit)
async def get_messages(self) -> list[ModelMessage]:
c = await self._asyncify(
self._execute, 'SELECT message_list FROM messages order by id'
)
rows = await self._asyncify(c.fetchall)
messages: list[ModelMessage] = []
for row in rows:
messages.extend(ModelMessagesTypeAdapter.validate_json(row[0]))
return messages
def _execute(
self, sql: LiteralString, *args: Any, commit: bool = False
) -> sqlite3.Cursor:
cur = self.con.cursor()
cur.execute(sql, args)
if commit:
self.con.commit()
return cur
async def _asyncify(
self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs
) -> R:
return await self._loop.run_in_executor( # type: ignore
self._executor,
partial(func, **kwargs),
*args, # type: ignore
)
if __name__ == '__main__':
import uvicorn
uvicorn.run(
'pydantic_ai_examples.chat_app:app', reload=True, reload_dirs=[str(THIS_DIR)]
)
+90
View File
@@ -0,0 +1,90 @@
// BIG FAT WARNING: to avoid the complexity of npm, this typescript is compiled in the browser
// there's currently no static type checking
import { marked } from 'https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.0/lib/marked.esm.js'
const convElement = document.getElementById('conversation')
const promptInput = document.getElementById('prompt-input') as HTMLInputElement
const spinner = document.getElementById('spinner')
// stream the response and render messages as each chunk is received
// data is sent as newline-delimited JSON
async function onFetchResponse(response: Response): Promise<void> {
let text = ''
let decoder = new TextDecoder()
if (response.ok) {
const reader = response.body.getReader()
while (true) {
const {done, value} = await reader.read()
if (done) {
break
}
text += decoder.decode(value)
addMessages(text)
spinner.classList.remove('active')
}
addMessages(text)
promptInput.disabled = false
promptInput.focus()
} else {
const text = await response.text()
console.error(`Unexpected response: ${response.status}`, {response, text})
throw new Error(`Unexpected response: ${response.status}`)
}
}
// The format of messages, this matches pydantic-ai both for brevity and understanding
// in production, you might not want to keep this format all the way to the frontend
interface Message {
role: string
content: string
timestamp: string
}
// take raw response text and render messages into the `#conversation` element
// Message timestamp is assumed to be a unique identifier of a message, and is used to deduplicate
// hence you can send data about the same message multiple times, and it will be updated
// instead of creating a new message elements
function addMessages(responseText: string) {
const lines = responseText.split('\n')
const messages: Message[] = lines.filter(line => line.length > 1).map(j => JSON.parse(j))
for (const message of messages) {
// we use the timestamp as a crude element id
const {timestamp, role, content} = message
const id = `msg-${timestamp}`
let msgDiv = document.getElementById(id)
if (!msgDiv) {
msgDiv = document.createElement('div')
msgDiv.id = id
msgDiv.title = `${role} at ${timestamp}`
msgDiv.classList.add('border-top', 'pt-2', role)
convElement.appendChild(msgDiv)
}
msgDiv.innerHTML = marked.parse(content)
}
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
}
function onError(error: any) {
console.error(error)
document.getElementById('error').classList.remove('d-none')
document.getElementById('spinner').classList.remove('active')
}
async function onSubmit(e: SubmitEvent): Promise<void> {
e.preventDefault()
spinner.classList.add('active')
const body = new FormData(e.target as HTMLFormElement)
promptInput.value = ''
promptInput.disabled = true
const response = await fetch('/chat/', {method: 'POST', body})
await onFetchResponse(response)
}
// call onSubmit when the form is submitted (e.g. user clicks the send button or hits Enter)
document.querySelector('form').addEventListener('submit', (e) => onSubmit(e).catch(onError))
// load messages on page load
fetch('/chat/').then(onFetchResponse).catch(onError)
@@ -0,0 +1,107 @@
from dataclasses import dataclass, field
import datasets
import duckdb
import pandas as pd
from pydantic_ai import Agent, ModelRetry, RunContext
@dataclass
class AnalystAgentDeps:
output: dict[str, pd.DataFrame] = field(default_factory=dict[str, pd.DataFrame])
def store(self, value: pd.DataFrame) -> str:
"""Store the output in deps and return the reference such as Out[1] to be used by the LLM."""
ref = f'Out[{len(self.output) + 1}]'
self.output[ref] = value
return ref
def get(self, ref: str) -> pd.DataFrame:
if ref not in self.output:
raise ModelRetry(
f'Error: {ref} is not a valid variable reference. Check the previous messages and try again.'
)
return self.output[ref]
analyst_agent = Agent(
'openai:gpt-5.2',
deps_type=AnalystAgentDeps,
instructions='You are a data analyst and your job is to analyze the data according to the user request.',
)
@analyst_agent.tool
def load_dataset(
ctx: RunContext[AnalystAgentDeps],
path: str,
split: str = 'train',
) -> str:
"""Load the `split` of dataset `dataset_name` from huggingface.
Args:
ctx: Pydantic AI agent RunContext
path: name of the dataset in the form of `<user_name>/<dataset_name>`
split: load the split of the dataset (default: "train")
"""
# begin load data from hf
builder = datasets.load_dataset_builder(path) # pyright: ignore[reportUnknownMemberType]
splits: dict[str, datasets.SplitInfo] = builder.info.splits or {}
if split not in splits:
raise ModelRetry(
f'{split} is not valid for dataset {path}. Valid splits are {",".join(splits.keys())}'
)
builder.download_and_prepare() # pyright: ignore[reportUnknownMemberType]
dataset = builder.as_dataset(split=split)
assert isinstance(dataset, datasets.Dataset)
dataframe = dataset.to_pandas()
assert isinstance(dataframe, pd.DataFrame)
# end load data from hf
# store the dataframe in the deps and get a ref like "Out[1]"
ref = ctx.deps.store(dataframe)
# construct a summary of the loaded dataset
output = [
f'Loaded the dataset as `{ref}`.',
f'Description: {dataset.info.description}'
if dataset.info.description
else None,
f'Features: {dataset.info.features!r}' if dataset.info.features else None,
]
return '\n'.join(filter(None, output))
@analyst_agent.tool
def run_duckdb(ctx: RunContext[AnalystAgentDeps], dataset: str, sql: str) -> str:
"""Run DuckDB SQL query on the DataFrame.
Note that the virtual table name used in DuckDB SQL must be `dataset`.
Args:
ctx: Pydantic AI agent RunContext
dataset: reference string to the DataFrame
sql: the query to be executed using DuckDB
"""
data = ctx.deps.get(dataset)
result = duckdb.query_df(df=data, virtual_table_name='dataset', sql_query=sql)
# pass the result as ref (because DuckDB SQL can select many rows, creating another huge dataframe)
ref = ctx.deps.store(result.df())
return f'Executed SQL, result is `{ref}`'
@analyst_agent.tool
def display(ctx: RunContext[AnalystAgentDeps], name: str) -> str:
"""Display at most 5 rows of the dataframe."""
dataset = ctx.deps.get(name)
return dataset.head().to_string() # pyright: ignore[reportUnknownMemberType]
if __name__ == '__main__':
deps = AnalystAgentDeps()
result = analyst_agent.run_sync(
user_prompt='Count how many negative comments are there in the dataset `cornell-movie-review-data/rotten_tomatoes`',
deps=deps,
)
print(result.output)
@@ -0,0 +1,2 @@
from .agent import infer_time_range as infer_time_range
from .models import TimeRangeResponse as TimeRangeResponse
@@ -0,0 +1,48 @@
from __future__ import annotations as _annotations
from dataclasses import dataclass, field
from datetime import datetime
from pydantic_ai import Agent, RunContext
from .models import TimeRangeInputs, TimeRangeResponse
@dataclass
class TimeRangeDeps:
"""Dependencies for the time range inference agent.
While we could just get the current time using datetime.now() directly in the tools or system prompt, passing it
via deps makes it easier to use a repeatable value during testing. While there are packages like `time-machine`
that can do this for you, that kind of monkey-patching approach can become unwieldy as things get more complex.
"""
now: datetime = field(default_factory=lambda: datetime.now().astimezone())
time_range_agent = Agent[TimeRangeDeps, TimeRangeResponse](
'gpt-5.2',
output_type=TimeRangeResponse, # type: ignore # we can't yet annotate something as receiving a TypeForm
deps_type=TimeRangeDeps,
system_prompt="Convert the user's request into a structured time range.",
retries=1,
instrument=True,
)
@time_range_agent.tool
def get_current_time(ctx: RunContext[TimeRangeDeps]) -> str:
"""Get the user's current time and timezone in the format 'Friday, November 22, 2024 11:15:14 PST'."""
# (The following comment is not in the docstring because the tool docstring is included in model requests.)
# In practice, you might unconditionally include this in the system prompt, but using a tool for this helps
# demonstrate some evaluation capabilities, such as checking whether a specific tool was called (or wasn't).
now_str = ctx.deps.now.strftime(
'%A, %B %d, %Y %H:%M:%S %Z'
) # Format like: Friday, November 22, 2024 11:15:14 PST
return f"The user's current time is {now_str}."
async def infer_time_range(inputs: TimeRangeInputs) -> TimeRangeResponse:
"""Infer a time range from a user prompt."""
deps = TimeRangeDeps(now=inputs['now'])
return (await time_range_agent.run(inputs['prompt'], deps=deps)).output
@@ -0,0 +1,69 @@
from dataclasses import dataclass
from datetime import timedelta
from pydantic_ai_examples.evals.models import (
TimeRangeBuilderSuccess,
TimeRangeInputs,
TimeRangeResponse,
)
from pydantic_evals.evaluators import (
Evaluator,
EvaluatorContext,
EvaluatorOutput,
)
from pydantic_evals.otel import SpanQuery
@dataclass
class ValidateTimeRange(Evaluator[TimeRangeInputs, TimeRangeResponse]):
def evaluate(
self, ctx: EvaluatorContext[TimeRangeInputs, TimeRangeResponse]
) -> EvaluatorOutput:
if isinstance(ctx.output, TimeRangeBuilderSuccess):
window_end = ctx.output.max_timestamp_with_offset
window_size = window_end - ctx.output.min_timestamp_with_offset
return {
'window_is_not_too_long': window_size <= timedelta(days=30),
'window_is_not_in_the_future': window_end <= ctx.inputs['now'],
}
return {} # No evaluation needed for errors
@dataclass
class UserMessageIsConcise(Evaluator[TimeRangeInputs, TimeRangeResponse]):
async def evaluate(
self,
ctx: EvaluatorContext[TimeRangeInputs, TimeRangeResponse],
) -> EvaluatorOutput:
if isinstance(ctx.output, TimeRangeBuilderSuccess):
user_facing_message = ctx.output.explanation
else:
user_facing_message = ctx.output.error_message
if user_facing_message is not None:
return len(user_facing_message.split()) < 50
else:
return {}
@dataclass
class AgentCalledTool(Evaluator[object, object, object]):
agent_name: str
tool_name: str
def evaluate(self, ctx: EvaluatorContext[object, object, object]) -> bool:
return ctx.span_tree.any(
SpanQuery(
name_equals='agent run',
has_attributes={'agent_name': self.agent_name},
stop_recursing_when=SpanQuery(name_equals='agent run'),
some_descendant_has=SpanQuery(
name_equals='running tool',
has_attributes={'gen_ai.tool.name': self.tool_name},
),
)
)
CUSTOM_EVALUATOR_TYPES = (ValidateTimeRange, UserMessageIsConcise, AgentCalledTool)
@@ -0,0 +1,107 @@
# yaml-language-server: $schema=time_range_v1_schema.json
cases:
- name: Single day mention
inputs:
prompt: I want to see logs from 2021-05-08
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2021-05-08T00:00:00Z'
max_timestamp_with_offset: '2021-05-08T23:59:59Z'
explanation: You mentioned a single day (2021-05-08). The entire day is used.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Ambiguous mention
inputs:
prompt: Check logs from last week or so, around early May
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-10-21T09:30:00Z'
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
explanation: We interpret the mention of early May as extraneous, focusing on
'last week or so' from the current time.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- LLMJudge: We want to interpret conflicting references by default to the more recent
timeframe; confirm the explanation addresses ignoring early May.
- name: Single datetime mention
inputs:
prompt: Show me the logs at 2023-10-27 2:00pm
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-10-27T13:50:00Z'
max_timestamp_with_offset: '2023-10-27T14:10:00Z'
explanation: You only mentioned a single point in time, so a 10-minute window
around that time is used.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Relative mention without date
inputs:
prompt: Check logs from 2 hours ago
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-10-28T07:30:00Z'
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
explanation: You requested logs starting from 2 hours prior to the current time.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Impossible range
inputs:
prompt: Check logs from 2025, but make sure they are also from 2020
now: '2023-10-28T09:30:00Z'
expected_output:
error_message: 'Conflicting time instructions: 2025 and 2020 cannot both apply.'
evaluators:
- IsInstance: TimeRangeBuilderError
- name: No mention
inputs:
prompt: Show me some logs
now: '2023-10-28T09:30:00Z'
expected_output:
error_message: No timeframe could be inferred from your request.
evaluators:
- IsInstance: TimeRangeBuilderError
- name: Ambiguous elliptical mention
inputs:
prompt: Check logs from around the start of last quarter
now: '2023-07-15T08:00:00Z'
expected_output:
min_timestamp_with_offset: '2023-04-01T00:00:00Z'
max_timestamp_with_offset: '2023-04-05T23:59:59Z'
explanation: We interpret 'around the start of last quarter' as the first few
days of Q2 2023.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Far future mention
inputs:
prompt: Check logs from January 3050
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '3050-01-01T00:00:00Z'
max_timestamp_with_offset: '3050-01-31T23:59:59Z'
explanation: You requested logs from January 3050. The entire month is used.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Confusing relative references
inputs:
prompt: Check logs from yesterday but also last year
now: '2023-10-28T09:30:00Z'
expected_output:
error_message: 'Conflicting instructions: ''yesterday'' versus ''last year'' could
not be reconciled.'
evaluators:
- IsInstance: TimeRangeBuilderError
- name: Range from speech
inputs:
prompt: I want the logs from December 25th to December 26th, so I can see what
happened on Christmas day. But also it might be earlier.
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-12-25T00:00:00Z'
max_timestamp_with_offset: '2023-12-26T23:59:59Z'
explanation: You asked specifically for December 25th to December 26th. The mention
of an earlier date is ignored since a range was provided.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
evaluators:
- LLMJudge: Ensure the explanation or error_message fields are truly appropriate for
user display, in a second-person or friendly style.
@@ -0,0 +1,697 @@
{
"$defs": {
"Case": {
"additionalProperties": false,
"properties": {
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Name"
},
"inputs": {
"$ref": "#/$defs/TimeRangeInputs"
},
"metadata": {
"default": null,
"title": "Metadata",
"type": "null"
},
"expected_output": {
"anyOf": [
{
"$ref": "#/$defs/TimeRangeBuilderSuccess"
},
{
"$ref": "#/$defs/TimeRangeBuilderError"
},
{
"type": "null"
}
],
"default": null,
"title": "Expected Output"
},
"evaluators": {
"default": [],
"items": {
"anyOf": [
{
"$ref": "#/$defs/short_evaluator_Equals"
},
{
"const": "EqualsExpected",
"type": "string"
},
{
"$ref": "#/$defs/short_evaluator_Contains"
},
{
"$ref": "#/$defs/evaluator_Contains"
},
{
"$ref": "#/$defs/short_evaluator_IsInstance"
},
{
"$ref": "#/$defs/short_evaluator_MaxDuration"
},
{
"$ref": "#/$defs/short_evaluator_LLMJudge"
},
{
"$ref": "#/$defs/evaluator_LLMJudge"
},
{
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
}
]
},
"title": "Evaluators",
"type": "array"
}
},
"required": [
"inputs"
],
"title": "Case",
"type": "object"
},
"KnownModelName": {
"enum": [
"anthropic:claude-3-7-sonnet-latest",
"anthropic:claude-3-5-haiku-latest",
"anthropic:claude-3-5-sonnet-latest",
"anthropic:claude-3-opus-latest",
"claude-3-7-sonnet-latest",
"claude-3-5-haiku-latest",
"bedrock:amazon.titan-tg1-large",
"bedrock:amazon.titan-text-lite-v1",
"bedrock:amazon.titan-text-express-v1",
"bedrock:us.amazon.nova-pro-v1:0",
"bedrock:us.amazon.nova-lite-v1:0",
"bedrock:us.amazon.nova-micro-v1:0",
"bedrock:anthropic.claude-3-5-sonnet-20241022-v2:0",
"bedrock:us.anthropic.claude-3-5-sonnet-20241022-v2:0",
"bedrock:anthropic.claude-3-5-haiku-20241022-v1:0",
"bedrock:us.anthropic.claude-3-5-haiku-20241022-v1:0",
"bedrock:anthropic.claude-instant-v1",
"bedrock:anthropic.claude-v2:1",
"bedrock:anthropic.claude-v2",
"bedrock:anthropic.claude-3-sonnet-20240229-v1:0",
"bedrock:us.anthropic.claude-3-sonnet-20240229-v1:0",
"bedrock:anthropic.claude-3-haiku-20240307-v1:0",
"bedrock:us.anthropic.claude-3-haiku-20240307-v1:0",
"bedrock:anthropic.claude-3-opus-20240229-v1:0",
"bedrock:us.anthropic.claude-3-opus-20240229-v1:0",
"bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0",
"bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0",
"bedrock:anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock:us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock:cohere.command-text-v14",
"bedrock:cohere.command-r-v1:0",
"bedrock:cohere.command-r-plus-v1:0",
"bedrock:cohere.command-light-text-v14",
"bedrock:meta.llama3-8b-instruct-v1:0",
"bedrock:meta.llama3-70b-instruct-v1:0",
"bedrock:meta.llama3-1-8b-instruct-v1:0",
"bedrock:us.meta.llama3-1-8b-instruct-v1:0",
"bedrock:meta.llama3-1-70b-instruct-v1:0",
"bedrock:us.meta.llama3-1-70b-instruct-v1:0",
"bedrock:meta.llama3-1-405b-instruct-v1:0",
"bedrock:us.meta.llama3-2-11b-instruct-v1:0",
"bedrock:us.meta.llama3-2-90b-instruct-v1:0",
"bedrock:us.meta.llama3-2-1b-instruct-v1:0",
"bedrock:us.meta.llama3-2-3b-instruct-v1:0",
"bedrock:us.meta.llama3-3-70b-instruct-v1:0",
"bedrock:mistral.mistral-7b-instruct-v0:2",
"bedrock:mistral.mixtral-8x7b-instruct-v0:1",
"bedrock:mistral.mistral-large-2402-v1:0",
"bedrock:mistral.mistral-large-2407-v1:0",
"claude-3-5-sonnet-latest",
"claude-3-opus-latest",
"cohere:c4ai-aya-expanse-32b",
"cohere:c4ai-aya-expanse-8b",
"cohere:command",
"cohere:command-light",
"cohere:command-light-nightly",
"cohere:command-nightly",
"cohere:command-r",
"cohere:command-r-03-2024",
"cohere:command-r-08-2024",
"cohere:command-r-plus",
"cohere:command-r-plus-04-2024",
"cohere:command-r-plus-08-2024",
"cohere:command-r7b-12-2024",
"deepseek:deepseek-chat",
"deepseek:deepseek-reasoner",
"google-gla:gemini-1.0-pro",
"google-gla:gemini-1.5-flash",
"google-gla:gemini-1.5-flash-8b",
"google-gla:gemini-1.5-pro",
"google-gla:gemini-2.0-flash-exp",
"google-gla:gemini-2.0-flash-thinking-exp-01-21",
"google-gla:gemini-exp-1206",
"google-gla:gemini-2.0-flash",
"google-gla:gemini-2.0-flash-lite-preview-02-05",
"google-gla:gemini-2.0-pro-exp-02-05",
"google-vertex:gemini-1.0-pro",
"google-vertex:gemini-1.5-flash",
"google-vertex:gemini-1.5-flash-8b",
"google-vertex:gemini-1.5-pro",
"google-vertex:gemini-2.0-flash-exp",
"google-vertex:gemini-2.0-flash-thinking-exp-01-21",
"google-vertex:gemini-exp-1206",
"google-vertex:gemini-2.0-flash",
"google-vertex:gemini-2.0-flash-lite-preview-02-05",
"google-vertex:gemini-2.0-pro-exp-02-05",
"gpt-3.5-turbo",
"gpt-3.5-turbo-0125",
"gpt-3.5-turbo-0301",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-16k-0613",
"gpt-4",
"gpt-4-0125-preview",
"gpt-4-0314",
"gpt-4-0613",
"gpt-4-1106-preview",
"gpt-4-32k",
"gpt-4-32k-0314",
"gpt-4-32k-0613",
"gpt-4-turbo",
"gpt-4-turbo-2024-04-09",
"gpt-4-turbo-preview",
"gpt-4-vision-preview",
"gpt-4o",
"gpt-4o-2024-05-13",
"gpt-4o-2024-08-06",
"gpt-4o-2024-11-20",
"gpt-4o-audio-preview",
"gpt-4o-audio-preview-2024-10-01",
"gpt-4o-audio-preview-2024-12-17",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"gpt-4o-mini-audio-preview",
"gpt-4o-mini-audio-preview-2024-12-17",
"gpt-4o-mini-search-preview",
"gpt-4o-mini-search-preview-2025-03-11",
"gpt-4o-search-preview",
"gpt-4o-search-preview-2025-03-11",
"groq:distil-whisper-large-v3-en",
"groq:gemma2-9b-it",
"groq:llama-3.3-70b-versatile",
"groq:llama-3.1-8b-instant",
"groq:llama-guard-3-8b",
"groq:llama3-70b-8192",
"groq:llama3-8b-8192",
"groq:whisper-large-v3",
"groq:whisper-large-v3-turbo",
"groq:playai-tts",
"groq:playai-tts-arabic",
"groq:qwen-qwq-32b",
"groq:mistral-saba-24b",
"groq:qwen-2.5-coder-32b",
"groq:qwen-2.5-32b",
"groq:deepseek-r1-distill-qwen-32b",
"groq:deepseek-r1-distill-llama-70b",
"groq:llama-3.3-70b-specdec",
"groq:llama-3.2-1b-preview",
"groq:llama-3.2-3b-preview",
"groq:llama-3.2-11b-vision-preview",
"groq:llama-3.2-90b-vision-preview",
"mistral:codestral-latest",
"mistral:mistral-large-latest",
"mistral:mistral-moderation-latest",
"mistral:mistral-small-latest",
"o1",
"o1-2024-12-17",
"o1-mini",
"o1-mini-2024-09-12",
"o1-preview",
"o1-preview-2024-09-12",
"o3-mini",
"o3-mini-2025-01-31",
"openai:chatgpt-4o-latest",
"openai:gpt-3.5-turbo",
"openai:gpt-3.5-turbo-0125",
"openai:gpt-3.5-turbo-0301",
"openai:gpt-3.5-turbo-0613",
"openai:gpt-3.5-turbo-1106",
"openai:gpt-3.5-turbo-16k",
"openai:gpt-3.5-turbo-16k-0613",
"openai:gpt-4",
"openai:gpt-4-0125-preview",
"openai:gpt-4-0314",
"openai:gpt-4-0613",
"openai:gpt-4-1106-preview",
"openai:gpt-4-32k",
"openai:gpt-4-32k-0314",
"openai:gpt-4-32k-0613",
"openai:gpt-4-turbo",
"openai:gpt-4-turbo-2024-04-09",
"openai:gpt-4-turbo-preview",
"openai:gpt-4-vision-preview",
"openai:gpt-4o",
"openai:gpt-4o-2024-05-13",
"openai:gpt-4o-2024-08-06",
"openai:gpt-4o-2024-11-20",
"openai:gpt-4o-audio-preview",
"openai:gpt-4o-audio-preview-2024-10-01",
"openai:gpt-4o-audio-preview-2024-12-17",
"openai:gpt-4o-mini",
"openai:gpt-4o-mini-2024-07-18",
"openai:gpt-4o-mini-audio-preview",
"openai:gpt-4o-mini-audio-preview-2024-12-17",
"openai:gpt-4o-mini-search-preview",
"openai:gpt-4o-mini-search-preview-2025-03-11",
"openai:gpt-4o-search-preview",
"openai:gpt-4o-search-preview-2025-03-11",
"openai:o1",
"openai:o1-2024-12-17",
"openai:o1-mini",
"openai:o1-mini-2024-09-12",
"openai:o1-preview",
"openai:o1-preview-2024-09-12",
"openai:o3-mini",
"openai:o3-mini-2025-01-31",
"test"
],
"type": "string"
},
"SpanQuery": {
"description": "A serializable query for filtering SpanNodes based on various conditions.\n\nAll fields are optional and combined with AND logic by default.",
"properties": {
"name_equals": {
"title": "Name Equals",
"type": "string"
},
"name_contains": {
"title": "Name Contains",
"type": "string"
},
"name_matches_regex": {
"title": "Name Matches Regex",
"type": "string"
},
"has_attributes": {
"title": "Has Attributes",
"type": "object"
},
"has_attribute_keys": {
"items": {
"type": "string"
},
"title": "Has Attribute Keys",
"type": "array"
},
"min_duration": {
"anyOf": [
{
"format": "duration",
"type": "string"
},
{
"type": "number"
}
],
"title": "Min Duration"
},
"max_duration": {
"anyOf": [
{
"format": "duration",
"type": "string"
},
{
"type": "number"
}
],
"title": "Max Duration"
},
"not_": {
"$ref": "#/$defs/SpanQuery"
},
"and_": {
"items": {
"$ref": "#/$defs/SpanQuery"
},
"title": "And",
"type": "array"
},
"or_": {
"items": {
"$ref": "#/$defs/SpanQuery"
},
"title": "Or",
"type": "array"
},
"min_child_count": {
"title": "Min Child Count",
"type": "integer"
},
"max_child_count": {
"title": "Max Child Count",
"type": "integer"
},
"some_child_has": {
"$ref": "#/$defs/SpanQuery"
},
"all_children_have": {
"$ref": "#/$defs/SpanQuery"
},
"no_child_has": {
"$ref": "#/$defs/SpanQuery"
},
"stop_recursing_when": {
"$ref": "#/$defs/SpanQuery"
},
"min_descendant_count": {
"title": "Min Descendant Count",
"type": "integer"
},
"max_descendant_count": {
"title": "Max Descendant Count",
"type": "integer"
},
"some_descendant_has": {
"$ref": "#/$defs/SpanQuery"
},
"all_descendants_have": {
"$ref": "#/$defs/SpanQuery"
},
"no_descendant_has": {
"$ref": "#/$defs/SpanQuery"
},
"min_depth": {
"title": "Min Depth",
"type": "integer"
},
"max_depth": {
"title": "Max Depth",
"type": "integer"
},
"some_ancestor_has": {
"$ref": "#/$defs/SpanQuery"
},
"all_ancestors_have": {
"$ref": "#/$defs/SpanQuery"
},
"no_ancestor_has": {
"$ref": "#/$defs/SpanQuery"
}
},
"title": "SpanQuery",
"type": "object"
},
"TimeRangeBuilderError": {
"description": "Response when a time range cannot not be generated.",
"properties": {
"error_message": {
"title": "Error Message",
"type": "string"
}
},
"required": [
"error_message"
],
"title": "TimeRangeBuilderError",
"type": "object"
},
"TimeRangeBuilderSuccess": {
"description": "Response when a time range could be successfully generated.",
"properties": {
"min_timestamp_with_offset": {
"description": "A datetime in ISO format with timezone offset.",
"format": "date-time",
"title": "Min Timestamp With Offset",
"type": "string"
},
"max_timestamp_with_offset": {
"description": "A datetime in ISO format with timezone offset.",
"format": "date-time",
"title": "Max Timestamp With Offset",
"type": "string"
},
"explanation": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "A brief explanation of the time range that was selected.\n\nFor example, if a user only mentions a specific point in time, you might explain that you selected a 10 minute\nwindow around that time.",
"title": "Explanation"
}
},
"required": [
"min_timestamp_with_offset",
"max_timestamp_with_offset",
"explanation"
],
"title": "TimeRangeBuilderSuccess",
"type": "object"
},
"TimeRangeInputs": {
"description": "The inputs for the time range inference agent.",
"properties": {
"prompt": {
"title": "Prompt",
"type": "string"
},
"now": {
"format": "date-time",
"title": "Now",
"type": "string"
}
},
"required": [
"prompt",
"now"
],
"title": "TimeRangeInputs",
"type": "object"
},
"evaluator_Contains": {
"additionalProperties": false,
"properties": {
"Contains": {
"$ref": "#/$defs/evaluator_params_Contains"
}
},
"required": [
"Contains"
],
"title": "evaluator_Contains",
"type": "object"
},
"evaluator_LLMJudge": {
"additionalProperties": false,
"properties": {
"LLMJudge": {
"$ref": "#/$defs/evaluator_params_LLMJudge"
}
},
"required": [
"LLMJudge"
],
"title": "evaluator_LLMJudge",
"type": "object"
},
"evaluator_params_Contains": {
"additionalProperties": false,
"properties": {
"value": {
"title": "Value"
},
"case_sensitive": {
"title": "Case Sensitive",
"type": "boolean"
},
"as_strings": {
"title": "As Strings",
"type": "boolean"
}
},
"required": [
"value"
],
"title": "evaluator_params_Contains",
"type": "object"
},
"evaluator_params_LLMJudge": {
"additionalProperties": false,
"properties": {
"rubric": {
"title": "Rubric",
"type": "string"
},
"model": {
"$ref": "#/$defs/KnownModelName",
"title": "Model"
},
"include_input": {
"title": "Include Input",
"type": "boolean"
}
},
"required": [
"rubric"
],
"title": "evaluator_params_LLMJudge",
"type": "object"
},
"short_evaluator_Contains": {
"additionalProperties": false,
"properties": {
"Contains": {
"title": "Contains"
}
},
"required": [
"Contains"
],
"title": "short_evaluator_Contains",
"type": "object"
},
"short_evaluator_Equals": {
"additionalProperties": false,
"properties": {
"Equals": {
"title": "Equals"
}
},
"required": [
"Equals"
],
"title": "short_evaluator_Equals",
"type": "object"
},
"short_evaluator_HasMatchingSpan": {
"additionalProperties": false,
"properties": {
"HasMatchingSpan": {
"$ref": "#/$defs/SpanQuery"
}
},
"required": [
"HasMatchingSpan"
],
"title": "short_evaluator_HasMatchingSpan",
"type": "object"
},
"short_evaluator_IsInstance": {
"additionalProperties": false,
"properties": {
"IsInstance": {
"title": "Isinstance",
"type": "string"
}
},
"required": [
"IsInstance"
],
"title": "short_evaluator_IsInstance",
"type": "object"
},
"short_evaluator_LLMJudge": {
"additionalProperties": false,
"properties": {
"LLMJudge": {
"title": "Llmjudge",
"type": "string"
}
},
"required": [
"LLMJudge"
],
"title": "short_evaluator_LLMJudge",
"type": "object"
},
"short_evaluator_MaxDuration": {
"additionalProperties": false,
"properties": {
"MaxDuration": {
"anyOf": [
{
"type": "number"
},
{
"format": "duration",
"type": "string"
}
],
"title": "Maxduration"
}
},
"required": [
"MaxDuration"
],
"title": "short_evaluator_MaxDuration",
"type": "object"
}
},
"additionalProperties": false,
"properties": {
"cases": {
"items": {
"$ref": "#/$defs/Case"
},
"title": "Cases",
"type": "array"
},
"evaluators": {
"default": [],
"items": {
"anyOf": [
{
"$ref": "#/$defs/short_evaluator_Equals"
},
{
"const": "EqualsExpected",
"type": "string"
},
{
"$ref": "#/$defs/short_evaluator_Contains"
},
{
"$ref": "#/$defs/evaluator_Contains"
},
{
"$ref": "#/$defs/short_evaluator_IsInstance"
},
{
"$ref": "#/$defs/short_evaluator_MaxDuration"
},
{
"$ref": "#/$defs/short_evaluator_LLMJudge"
},
{
"$ref": "#/$defs/evaluator_LLMJudge"
},
{
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
}
]
},
"title": "Evaluators",
"type": "array"
},
"$schema": {
"type": "string"
}
},
"required": [
"cases"
],
"title": "Dataset",
"type": "object"
}
@@ -0,0 +1,112 @@
# yaml-language-server: $schema=time_range_v2_schema.json
cases:
- name: Single day mention
inputs:
prompt: I want to see logs from 2021-05-08
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2021-05-08T00:00:00Z'
max_timestamp_with_offset: '2021-05-08T23:59:59Z'
explanation: You mentioned a single day (2021-05-08). The entire day is used.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Ambiguous mention
inputs:
prompt: Check logs from last week or so, around early May
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-10-21T09:30:00Z'
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
explanation: We interpret the mention of early May as extraneous, focusing on
'last week or so' from the current time.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- LLMJudge: We want to interpret conflicting references by default to the more recent
timeframe; confirm the explanation addresses ignoring early May.
- name: Single datetime mention
inputs:
prompt: Show me the logs at 2023-10-27 2:00pm
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-10-27T13:50:00Z'
max_timestamp_with_offset: '2023-10-27T14:10:00Z'
explanation: You only mentioned a single point in time, so a 10-minute window
around that time is used.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Relative mention without date
inputs:
prompt: Check logs from 2 hours ago
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-10-28T07:30:00Z'
max_timestamp_with_offset: '2023-10-28T09:30:00Z'
explanation: You requested logs starting from 2 hours prior to the current time.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- AgentCalledTool:
agent_name: time_range_agent
tool_name: get_current_time
- name: Impossible range
inputs:
prompt: Check logs from 2025, but make sure they are also from 2020
now: '2023-10-28T09:30:00Z'
expected_output:
error_message: 'Conflicting time instructions: 2025 and 2020 cannot both apply.'
evaluators:
- IsInstance: TimeRangeBuilderError
- name: No mention
inputs:
prompt: Show me some logs
now: '2023-10-28T09:30:00Z'
expected_output:
error_message: No timeframe could be inferred from your request.
evaluators:
- IsInstance: TimeRangeBuilderError
- name: Ambiguous elliptical mention
inputs:
prompt: Check logs from around the start of last quarter
now: '2023-07-15T08:00:00Z'
expected_output:
min_timestamp_with_offset: '2023-04-01T00:00:00Z'
max_timestamp_with_offset: '2023-04-05T23:59:59Z'
explanation: We interpret 'around the start of last quarter' as the first few
days of Q2 2023.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Far future mention
inputs:
prompt: Check logs from January 3050
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '3050-01-01T00:00:00Z'
max_timestamp_with_offset: '3050-01-31T23:59:59Z'
explanation: You requested logs from January 3050. The entire month is used.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
- name: Confusing relative references
inputs:
prompt: Check logs from yesterday but also last year
now: '2023-10-28T09:30:00Z'
expected_output:
error_message: 'Conflicting instructions: ''yesterday'' versus ''last year'' could
not be reconciled.'
evaluators:
- IsInstance: TimeRangeBuilderError
- name: Range from speech
inputs:
prompt: I want the logs from December 25th to December 26th, so I can see what
happened on Christmas day. But also it might be earlier.
now: '2023-10-28T09:30:00Z'
expected_output:
min_timestamp_with_offset: '2023-12-25T00:00:00Z'
max_timestamp_with_offset: '2023-12-26T23:59:59Z'
explanation: You asked specifically for December 25th to December 26th. The mention
of an earlier date is ignored since a range was provided.
evaluators:
- IsInstance: TimeRangeBuilderSuccess
evaluators:
- LLMJudge: Ensure the explanation or error_message fields are truly appropriate for
user display, in a second-person or friendly style.
- ValidateTimeRange
- UserMessageIsConcise
@@ -0,0 +1,751 @@
{
"$defs": {
"Case": {
"additionalProperties": false,
"properties": {
"name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Name"
},
"inputs": {
"$ref": "#/$defs/TimeRangeInputs"
},
"metadata": {
"default": null,
"title": "Metadata",
"type": "null"
},
"expected_output": {
"anyOf": [
{
"$ref": "#/$defs/TimeRangeBuilderSuccess"
},
{
"$ref": "#/$defs/TimeRangeBuilderError"
},
{
"type": "null"
}
],
"default": null,
"title": "Expected Output"
},
"evaluators": {
"default": [],
"items": {
"anyOf": [
{
"const": "ValidateTimeRange",
"type": "string"
},
{
"const": "UserMessageIsConcise",
"type": "string"
},
{
"$ref": "#/$defs/evaluator_AgentCalledTool"
},
{
"$ref": "#/$defs/short_evaluator_Equals"
},
{
"const": "EqualsExpected",
"type": "string"
},
{
"$ref": "#/$defs/short_evaluator_Contains"
},
{
"$ref": "#/$defs/evaluator_Contains"
},
{
"$ref": "#/$defs/short_evaluator_IsInstance"
},
{
"$ref": "#/$defs/short_evaluator_MaxDuration"
},
{
"$ref": "#/$defs/short_evaluator_LLMJudge"
},
{
"$ref": "#/$defs/evaluator_LLMJudge"
},
{
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
}
]
},
"title": "Evaluators",
"type": "array"
}
},
"required": [
"inputs"
],
"title": "Case",
"type": "object"
},
"KnownModelName": {
"enum": [
"anthropic:claude-3-7-sonnet-latest",
"anthropic:claude-3-5-haiku-latest",
"anthropic:claude-3-5-sonnet-latest",
"anthropic:claude-3-opus-latest",
"claude-3-7-sonnet-latest",
"claude-3-5-haiku-latest",
"bedrock:amazon.titan-tg1-large",
"bedrock:amazon.titan-text-lite-v1",
"bedrock:amazon.titan-text-express-v1",
"bedrock:us.amazon.nova-pro-v1:0",
"bedrock:us.amazon.nova-lite-v1:0",
"bedrock:us.amazon.nova-micro-v1:0",
"bedrock:anthropic.claude-3-5-sonnet-20241022-v2:0",
"bedrock:us.anthropic.claude-3-5-sonnet-20241022-v2:0",
"bedrock:anthropic.claude-3-5-haiku-20241022-v1:0",
"bedrock:us.anthropic.claude-3-5-haiku-20241022-v1:0",
"bedrock:anthropic.claude-instant-v1",
"bedrock:anthropic.claude-v2:1",
"bedrock:anthropic.claude-v2",
"bedrock:anthropic.claude-3-sonnet-20240229-v1:0",
"bedrock:us.anthropic.claude-3-sonnet-20240229-v1:0",
"bedrock:anthropic.claude-3-haiku-20240307-v1:0",
"bedrock:us.anthropic.claude-3-haiku-20240307-v1:0",
"bedrock:anthropic.claude-3-opus-20240229-v1:0",
"bedrock:us.anthropic.claude-3-opus-20240229-v1:0",
"bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0",
"bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0",
"bedrock:anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock:us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock:cohere.command-text-v14",
"bedrock:cohere.command-r-v1:0",
"bedrock:cohere.command-r-plus-v1:0",
"bedrock:cohere.command-light-text-v14",
"bedrock:meta.llama3-8b-instruct-v1:0",
"bedrock:meta.llama3-70b-instruct-v1:0",
"bedrock:meta.llama3-1-8b-instruct-v1:0",
"bedrock:us.meta.llama3-1-8b-instruct-v1:0",
"bedrock:meta.llama3-1-70b-instruct-v1:0",
"bedrock:us.meta.llama3-1-70b-instruct-v1:0",
"bedrock:meta.llama3-1-405b-instruct-v1:0",
"bedrock:us.meta.llama3-2-11b-instruct-v1:0",
"bedrock:us.meta.llama3-2-90b-instruct-v1:0",
"bedrock:us.meta.llama3-2-1b-instruct-v1:0",
"bedrock:us.meta.llama3-2-3b-instruct-v1:0",
"bedrock:us.meta.llama3-3-70b-instruct-v1:0",
"bedrock:mistral.mistral-7b-instruct-v0:2",
"bedrock:mistral.mixtral-8x7b-instruct-v0:1",
"bedrock:mistral.mistral-large-2402-v1:0",
"bedrock:mistral.mistral-large-2407-v1:0",
"claude-3-5-sonnet-latest",
"claude-3-opus-latest",
"cohere:c4ai-aya-expanse-32b",
"cohere:c4ai-aya-expanse-8b",
"cohere:command",
"cohere:command-light",
"cohere:command-light-nightly",
"cohere:command-nightly",
"cohere:command-r",
"cohere:command-r-03-2024",
"cohere:command-r-08-2024",
"cohere:command-r-plus",
"cohere:command-r-plus-04-2024",
"cohere:command-r-plus-08-2024",
"cohere:command-r7b-12-2024",
"deepseek:deepseek-chat",
"deepseek:deepseek-reasoner",
"google-gla:gemini-1.0-pro",
"google-gla:gemini-1.5-flash",
"google-gla:gemini-1.5-flash-8b",
"google-gla:gemini-1.5-pro",
"google-gla:gemini-2.0-flash-exp",
"google-gla:gemini-2.0-flash-thinking-exp-01-21",
"google-gla:gemini-exp-1206",
"google-gla:gemini-2.0-flash",
"google-gla:gemini-2.0-flash-lite-preview-02-05",
"google-gla:gemini-2.0-pro-exp-02-05",
"google-vertex:gemini-1.0-pro",
"google-vertex:gemini-1.5-flash",
"google-vertex:gemini-1.5-flash-8b",
"google-vertex:gemini-1.5-pro",
"google-vertex:gemini-2.0-flash-exp",
"google-vertex:gemini-2.0-flash-thinking-exp-01-21",
"google-vertex:gemini-exp-1206",
"google-vertex:gemini-2.0-flash",
"google-vertex:gemini-2.0-flash-lite-preview-02-05",
"google-vertex:gemini-2.0-pro-exp-02-05",
"gpt-3.5-turbo",
"gpt-3.5-turbo-0125",
"gpt-3.5-turbo-0301",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-16k-0613",
"gpt-4",
"gpt-4-0125-preview",
"gpt-4-0314",
"gpt-4-0613",
"gpt-4-1106-preview",
"gpt-4-32k",
"gpt-4-32k-0314",
"gpt-4-32k-0613",
"gpt-4-turbo",
"gpt-4-turbo-2024-04-09",
"gpt-4-turbo-preview",
"gpt-4-vision-preview",
"gpt-4o",
"gpt-4o-2024-05-13",
"gpt-4o-2024-08-06",
"gpt-4o-2024-11-20",
"gpt-4o-audio-preview",
"gpt-4o-audio-preview-2024-10-01",
"gpt-4o-audio-preview-2024-12-17",
"gpt-4o-mini",
"gpt-4o-mini-2024-07-18",
"gpt-4o-mini-audio-preview",
"gpt-4o-mini-audio-preview-2024-12-17",
"gpt-4o-mini-search-preview",
"gpt-4o-mini-search-preview-2025-03-11",
"gpt-4o-search-preview",
"gpt-4o-search-preview-2025-03-11",
"groq:distil-whisper-large-v3-en",
"groq:gemma2-9b-it",
"groq:llama-3.3-70b-versatile",
"groq:llama-3.1-8b-instant",
"groq:llama-guard-3-8b",
"groq:llama3-70b-8192",
"groq:llama3-8b-8192",
"groq:whisper-large-v3",
"groq:whisper-large-v3-turbo",
"groq:playai-tts",
"groq:playai-tts-arabic",
"groq:qwen-qwq-32b",
"groq:mistral-saba-24b",
"groq:qwen-2.5-coder-32b",
"groq:qwen-2.5-32b",
"groq:deepseek-r1-distill-qwen-32b",
"groq:deepseek-r1-distill-llama-70b",
"groq:llama-3.3-70b-specdec",
"groq:llama-3.2-1b-preview",
"groq:llama-3.2-3b-preview",
"groq:llama-3.2-11b-vision-preview",
"groq:llama-3.2-90b-vision-preview",
"mistral:codestral-latest",
"mistral:mistral-large-latest",
"mistral:mistral-moderation-latest",
"mistral:mistral-small-latest",
"o1",
"o1-2024-12-17",
"o1-mini",
"o1-mini-2024-09-12",
"o1-preview",
"o1-preview-2024-09-12",
"o3-mini",
"o3-mini-2025-01-31",
"openai:chatgpt-4o-latest",
"openai:gpt-3.5-turbo",
"openai:gpt-3.5-turbo-0125",
"openai:gpt-3.5-turbo-0301",
"openai:gpt-3.5-turbo-0613",
"openai:gpt-3.5-turbo-1106",
"openai:gpt-3.5-turbo-16k",
"openai:gpt-3.5-turbo-16k-0613",
"openai:gpt-4",
"openai:gpt-4-0125-preview",
"openai:gpt-4-0314",
"openai:gpt-4-0613",
"openai:gpt-4-1106-preview",
"openai:gpt-4-32k",
"openai:gpt-4-32k-0314",
"openai:gpt-4-32k-0613",
"openai:gpt-4-turbo",
"openai:gpt-4-turbo-2024-04-09",
"openai:gpt-4-turbo-preview",
"openai:gpt-4-vision-preview",
"openai:gpt-4o",
"openai:gpt-4o-2024-05-13",
"openai:gpt-4o-2024-08-06",
"openai:gpt-4o-2024-11-20",
"openai:gpt-4o-audio-preview",
"openai:gpt-4o-audio-preview-2024-10-01",
"openai:gpt-4o-audio-preview-2024-12-17",
"openai:gpt-4o-mini",
"openai:gpt-4o-mini-2024-07-18",
"openai:gpt-4o-mini-audio-preview",
"openai:gpt-4o-mini-audio-preview-2024-12-17",
"openai:gpt-4o-mini-search-preview",
"openai:gpt-4o-mini-search-preview-2025-03-11",
"openai:gpt-4o-search-preview",
"openai:gpt-4o-search-preview-2025-03-11",
"openai:o1",
"openai:o1-2024-12-17",
"openai:o1-mini",
"openai:o1-mini-2024-09-12",
"openai:o1-preview",
"openai:o1-preview-2024-09-12",
"openai:o3-mini",
"openai:o3-mini-2025-01-31",
"test"
],
"type": "string"
},
"SpanQuery": {
"description": "A serializable query for filtering SpanNodes based on various conditions.\n\nAll fields are optional and combined with AND logic by default.",
"properties": {
"name_equals": {
"title": "Name Equals",
"type": "string"
},
"name_contains": {
"title": "Name Contains",
"type": "string"
},
"name_matches_regex": {
"title": "Name Matches Regex",
"type": "string"
},
"has_attributes": {
"title": "Has Attributes",
"type": "object"
},
"has_attribute_keys": {
"items": {
"type": "string"
},
"title": "Has Attribute Keys",
"type": "array"
},
"min_duration": {
"anyOf": [
{
"format": "duration",
"type": "string"
},
{
"type": "number"
}
],
"title": "Min Duration"
},
"max_duration": {
"anyOf": [
{
"format": "duration",
"type": "string"
},
{
"type": "number"
}
],
"title": "Max Duration"
},
"not_": {
"$ref": "#/$defs/SpanQuery"
},
"and_": {
"items": {
"$ref": "#/$defs/SpanQuery"
},
"title": "And",
"type": "array"
},
"or_": {
"items": {
"$ref": "#/$defs/SpanQuery"
},
"title": "Or",
"type": "array"
},
"min_child_count": {
"title": "Min Child Count",
"type": "integer"
},
"max_child_count": {
"title": "Max Child Count",
"type": "integer"
},
"some_child_has": {
"$ref": "#/$defs/SpanQuery"
},
"all_children_have": {
"$ref": "#/$defs/SpanQuery"
},
"no_child_has": {
"$ref": "#/$defs/SpanQuery"
},
"stop_recursing_when": {
"$ref": "#/$defs/SpanQuery"
},
"min_descendant_count": {
"title": "Min Descendant Count",
"type": "integer"
},
"max_descendant_count": {
"title": "Max Descendant Count",
"type": "integer"
},
"some_descendant_has": {
"$ref": "#/$defs/SpanQuery"
},
"all_descendants_have": {
"$ref": "#/$defs/SpanQuery"
},
"no_descendant_has": {
"$ref": "#/$defs/SpanQuery"
},
"min_depth": {
"title": "Min Depth",
"type": "integer"
},
"max_depth": {
"title": "Max Depth",
"type": "integer"
},
"some_ancestor_has": {
"$ref": "#/$defs/SpanQuery"
},
"all_ancestors_have": {
"$ref": "#/$defs/SpanQuery"
},
"no_ancestor_has": {
"$ref": "#/$defs/SpanQuery"
}
},
"title": "SpanQuery",
"type": "object"
},
"TimeRangeBuilderError": {
"description": "Response when a time range cannot not be generated.",
"properties": {
"error_message": {
"title": "Error Message",
"type": "string"
}
},
"required": [
"error_message"
],
"title": "TimeRangeBuilderError",
"type": "object"
},
"TimeRangeBuilderSuccess": {
"description": "Response when a time range could be successfully generated.",
"properties": {
"min_timestamp_with_offset": {
"description": "A datetime in ISO format with timezone offset.",
"format": "date-time",
"title": "Min Timestamp With Offset",
"type": "string"
},
"max_timestamp_with_offset": {
"description": "A datetime in ISO format with timezone offset.",
"format": "date-time",
"title": "Max Timestamp With Offset",
"type": "string"
},
"explanation": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "A brief explanation of the time range that was selected.\n\nFor example, if a user only mentions a specific point in time, you might explain that you selected a 10 minute\nwindow around that time.",
"title": "Explanation"
}
},
"required": [
"min_timestamp_with_offset",
"max_timestamp_with_offset",
"explanation"
],
"title": "TimeRangeBuilderSuccess",
"type": "object"
},
"TimeRangeInputs": {
"description": "The inputs for the time range inference agent.",
"properties": {
"prompt": {
"title": "Prompt",
"type": "string"
},
"now": {
"format": "date-time",
"title": "Now",
"type": "string"
}
},
"required": [
"prompt",
"now"
],
"title": "TimeRangeInputs",
"type": "object"
},
"evaluator_AgentCalledTool": {
"additionalProperties": false,
"properties": {
"AgentCalledTool": {
"$ref": "#/$defs/evaluator_params_AgentCalledTool"
}
},
"required": [
"AgentCalledTool"
],
"title": "evaluator_AgentCalledTool",
"type": "object"
},
"evaluator_Contains": {
"additionalProperties": false,
"properties": {
"Contains": {
"$ref": "#/$defs/evaluator_params_Contains"
}
},
"required": [
"Contains"
],
"title": "evaluator_Contains",
"type": "object"
},
"evaluator_LLMJudge": {
"additionalProperties": false,
"properties": {
"LLMJudge": {
"$ref": "#/$defs/evaluator_params_LLMJudge"
}
},
"required": [
"LLMJudge"
],
"title": "evaluator_LLMJudge",
"type": "object"
},
"evaluator_params_AgentCalledTool": {
"additionalProperties": false,
"properties": {
"agent_name": {
"title": "Agent Name",
"type": "string"
},
"tool_name": {
"title": "Tool Name",
"type": "string"
}
},
"required": [
"agent_name",
"tool_name"
],
"title": "evaluator_params_AgentCalledTool",
"type": "object"
},
"evaluator_params_Contains": {
"additionalProperties": false,
"properties": {
"value": {
"title": "Value"
},
"case_sensitive": {
"title": "Case Sensitive",
"type": "boolean"
},
"as_strings": {
"title": "As Strings",
"type": "boolean"
}
},
"required": [
"value"
],
"title": "evaluator_params_Contains",
"type": "object"
},
"evaluator_params_LLMJudge": {
"additionalProperties": false,
"properties": {
"rubric": {
"title": "Rubric",
"type": "string"
},
"model": {
"$ref": "#/$defs/KnownModelName",
"title": "Model"
},
"include_input": {
"title": "Include Input",
"type": "boolean"
}
},
"required": [
"rubric"
],
"title": "evaluator_params_LLMJudge",
"type": "object"
},
"short_evaluator_Contains": {
"additionalProperties": false,
"properties": {
"Contains": {
"title": "Contains"
}
},
"required": [
"Contains"
],
"title": "short_evaluator_Contains",
"type": "object"
},
"short_evaluator_Equals": {
"additionalProperties": false,
"properties": {
"Equals": {
"title": "Equals"
}
},
"required": [
"Equals"
],
"title": "short_evaluator_Equals",
"type": "object"
},
"short_evaluator_HasMatchingSpan": {
"additionalProperties": false,
"properties": {
"HasMatchingSpan": {
"$ref": "#/$defs/SpanQuery"
}
},
"required": [
"HasMatchingSpan"
],
"title": "short_evaluator_HasMatchingSpan",
"type": "object"
},
"short_evaluator_IsInstance": {
"additionalProperties": false,
"properties": {
"IsInstance": {
"title": "Isinstance",
"type": "string"
}
},
"required": [
"IsInstance"
],
"title": "short_evaluator_IsInstance",
"type": "object"
},
"short_evaluator_LLMJudge": {
"additionalProperties": false,
"properties": {
"LLMJudge": {
"title": "Llmjudge",
"type": "string"
}
},
"required": [
"LLMJudge"
],
"title": "short_evaluator_LLMJudge",
"type": "object"
},
"short_evaluator_MaxDuration": {
"additionalProperties": false,
"properties": {
"MaxDuration": {
"anyOf": [
{
"type": "number"
},
{
"format": "duration",
"type": "string"
}
],
"title": "Maxduration"
}
},
"required": [
"MaxDuration"
],
"title": "short_evaluator_MaxDuration",
"type": "object"
}
},
"additionalProperties": false,
"properties": {
"cases": {
"items": {
"$ref": "#/$defs/Case"
},
"title": "Cases",
"type": "array"
},
"evaluators": {
"default": [],
"items": {
"anyOf": [
{
"const": "ValidateTimeRange",
"type": "string"
},
{
"const": "UserMessageIsConcise",
"type": "string"
},
{
"$ref": "#/$defs/evaluator_AgentCalledTool"
},
{
"$ref": "#/$defs/short_evaluator_Equals"
},
{
"const": "EqualsExpected",
"type": "string"
},
{
"$ref": "#/$defs/short_evaluator_Contains"
},
{
"$ref": "#/$defs/evaluator_Contains"
},
{
"$ref": "#/$defs/short_evaluator_IsInstance"
},
{
"$ref": "#/$defs/short_evaluator_MaxDuration"
},
{
"$ref": "#/$defs/short_evaluator_LLMJudge"
},
{
"$ref": "#/$defs/evaluator_LLMJudge"
},
{
"$ref": "#/$defs/short_evaluator_HasMatchingSpan"
}
]
},
"title": "Evaluators",
"type": "array"
},
"$schema": {
"type": "string"
}
},
"required": [
"cases"
],
"title": "Dataset",
"type": "object"
}
@@ -0,0 +1,48 @@
import asyncio
from pathlib import Path
from types import NoneType
from pydantic_ai_examples.evals.models import TimeRangeInputs, TimeRangeResponse
from pydantic_evals import Dataset
from pydantic_evals.generation import generate_dataset
async def main():
dataset = await generate_dataset(
dataset_type=Dataset[TimeRangeInputs, TimeRangeResponse, NoneType],
model='openai:gpt-5.2', # Use a smarter model since this is a more complex task that is only run once
n_examples=10,
extra_instructions="""
Generate a dataset of test cases for the time range inference agent.
Include a variety of inputs that might be given to the agent, including some where the only
reasonable response is a `TimeRangeBuilderError`, and some where a `TimeRangeBuilderSuccess` is
expected. Make use of the `IsInstance` evaluator to ensure that the inputs and outputs are of the appropriate
type.
When appropriate, use the `LLMJudge` evaluator to provide a more precise description of the time range the
agent should have inferred. In particular, it's good if the example user inputs are somewhat ambiguous, to
reflect realistic (difficult-to-handle) user questions, but the LLMJudge evaluator can help ensure that the
agent's output is still judged based on precisely what the desired behavior is even for somewhat ambiguous
user questions. You do not need to include LLMJudge evaluations for all cases (in particular, for cases where
the expected output is unambiguous from the user's question), but you should include at least one or two
examples that do benefit from an LLMJudge evaluation (and include it).
To be clear, the LLMJudge rubrics should be concise and reflect only information that is NOT ALREADY PRESENT
in the user prompt for the example.
Leave the model and include_input arguments to LLMJudge as their default values (null).
Also add a dataset-wide LLMJudge evaluator to ensure that the 'explanation' or 'error_message' fields are
appropriate to be displayed to the user (e.g., written in second person, etc.).
""",
)
dataset.to_file(
Path(__file__).parent / 'datasets' / 'time_range_v1.yaml',
fmt='yaml',
)
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,35 @@
from pathlib import Path
from types import NoneType
from pydantic_ai_examples.evals.custom_evaluators import (
CUSTOM_EVALUATOR_TYPES,
AgentCalledTool,
UserMessageIsConcise,
ValidateTimeRange,
)
from pydantic_ai_examples.evals.models import (
TimeRangeInputs,
TimeRangeResponse,
)
from pydantic_evals import Dataset
def main():
dataset_path = Path(__file__).parent / 'datasets' / 'time_range_v1.yaml'
dataset = Dataset[TimeRangeInputs, TimeRangeResponse, NoneType].from_file(
dataset_path
)
dataset.add_evaluator(ValidateTimeRange())
dataset.add_evaluator(UserMessageIsConcise())
dataset.add_evaluator(
AgentCalledTool('time_range_agent', 'get_current_time'),
specific_case='Relative mention without date',
)
dataset.to_file(
Path(__file__).parent / 'datasets' / 'time_range_v2.yaml',
custom_evaluator_types=CUSTOM_EVALUATOR_TYPES,
)
if __name__ == '__main__':
main()
@@ -0,0 +1,42 @@
from pathlib import Path
from types import NoneType
import logfire
from pydantic_ai_examples.evals import infer_time_range
from pydantic_ai_examples.evals.custom_evaluators import (
CUSTOM_EVALUATOR_TYPES,
)
from pydantic_ai_examples.evals.models import (
TimeRangeInputs,
TimeRangeResponse,
)
from pydantic_evals import Dataset
logfire.configure(
send_to_logfire='if-token-present',
environment='development',
service_name='evals',
)
logfire.instrument_pydantic_ai()
def evaluate_dataset():
dataset_path = Path(__file__).parent / 'datasets' / 'time_range_v2.yaml'
dataset = Dataset[TimeRangeInputs, TimeRangeResponse, NoneType].from_file(
dataset_path, custom_evaluator_types=CUSTOM_EVALUATOR_TYPES
)
report = dataset.evaluate_sync(infer_time_range)
print(report)
averages = report.averages()
assert averages is not None
assertion_pass_rate = averages.assertions
assert assertion_pass_rate is not None, 'There should be at least one assertion'
assert assertion_pass_rate > 0.9, (
f'The assertion pass rate was {assertion_pass_rate:.1%}; it should be above 90%.'
)
if __name__ == '__main__':
evaluate_dataset()
@@ -0,0 +1,38 @@
from pathlib import Path
from types import NoneType
import logfire
from pydantic_ai_examples.evals import infer_time_range
from pydantic_ai_examples.evals.agent import time_range_agent
from pydantic_ai_examples.evals.custom_evaluators import (
CUSTOM_EVALUATOR_TYPES,
)
from pydantic_ai_examples.evals.models import (
TimeRangeInputs,
TimeRangeResponse,
)
from pydantic_evals import Dataset
logfire.configure(
send_to_logfire='if-token-present',
environment='development',
service_name='evals',
)
logfire.instrument_pydantic_ai()
def compare_models():
dataset_path = Path(__file__).parent / 'datasets' / 'time_range_v2.yaml'
dataset = Dataset[TimeRangeInputs, TimeRangeResponse, NoneType].from_file(
dataset_path, custom_evaluator_types=CUSTOM_EVALUATOR_TYPES
)
with logfire.span('Comparing different models for time_range_agent'):
with time_range_agent.override(model='openai:gpt-5.1'):
dataset.evaluate_sync(infer_time_range, name='openai:gpt-5.1')
with time_range_agent.override(model='openai:gpt-5.2'):
dataset.evaluate_sync(infer_time_range, name='openai:gpt-5.2')
if __name__ == '__main__':
compare_models()
@@ -0,0 +1,57 @@
from __future__ import annotations as _annotations
from pydantic import AwareDatetime, BaseModel
from typing_extensions import TypedDict
class TimeRangeBuilderSuccess(BaseModel, use_attribute_docstrings=True):
"""Response when a time range could be successfully generated."""
min_timestamp_with_offset: AwareDatetime
"""A datetime in ISO format with timezone offset."""
max_timestamp_with_offset: AwareDatetime
"""A datetime in ISO format with timezone offset."""
explanation: str | None
"""
A brief explanation of the time range that was selected.
For example, if a user only mentions a specific point in time, you might explain that you selected a 10 minute
window around that time.
"""
def __str__(self):
readable_min_timestamp = self.min_timestamp_with_offset.strftime(
'%A, %B %d, %Y %H:%M:%S %Z'
)
readable_max_timestamp = self.max_timestamp_with_offset.strftime(
'%A, %B %d, %Y %H:%M:%S %Z'
)
lines = [
'TimeRangeBuilderSuccess:',
f'* min_timestamp_with_offset: {readable_min_timestamp}',
f'* max_timestamp_with_offset: {readable_max_timestamp}',
]
if self.explanation is not None:
lines.append(f'* explanation: {self.explanation}')
return '\n'.join(lines)
class TimeRangeBuilderError(BaseModel):
"""Response when a time range cannot not be generated."""
error_message: str
def __str__(self):
return f'TimeRangeBuilderError:\n* {self.error_message}'
TimeRangeResponse = TimeRangeBuilderSuccess | TimeRangeBuilderError
class TimeRangeInputs(TypedDict):
"""The inputs for the time range inference agent."""
prompt: str
now: AwareDatetime
@@ -0,0 +1,246 @@
"""Example of a multi-agent flow where one agent delegates work to another.
In this scenario, a group of agents work together to find flights for a user.
"""
import datetime
from dataclasses import dataclass
from typing import Literal
import logfire
from pydantic import BaseModel, Field
from rich.prompt import Prompt
from pydantic_ai import (
Agent,
ModelMessage,
ModelRetry,
RunContext,
RunUsage,
UsageLimits,
)
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()
class FlightDetails(BaseModel):
"""Details of the most suitable flight."""
flight_number: str
price: int
origin: str = Field(description='Three-letter airport code')
destination: str = Field(description='Three-letter airport code')
date: datetime.date
class NoFlightFound(BaseModel):
"""When no valid flight is found."""
@dataclass
class Deps:
web_page_text: str
req_origin: str
req_destination: str
req_date: datetime.date
# This agent is responsible for controlling the flow of the conversation.
search_agent = Agent[Deps, FlightDetails | NoFlightFound](
'openai:gpt-5.2',
output_type=FlightDetails | NoFlightFound, # type: ignore
retries=4,
system_prompt=(
'Your job is to find the cheapest flight for the user on the given date. '
),
)
# This agent is responsible for extracting flight details from web page text.
extraction_agent = Agent(
'openai:gpt-5.2',
output_type=list[FlightDetails],
system_prompt='Extract all the flight details from the given text.',
)
@search_agent.tool
async def extract_flights(ctx: RunContext[Deps]) -> list[FlightDetails]:
"""Get details of all flights."""
# we pass the usage to the search agent so requests within this agent are counted
result = await extraction_agent.run(ctx.deps.web_page_text, usage=ctx.usage)
logfire.info('found {flight_count} flights', flight_count=len(result.output))
return result.output
@search_agent.output_validator
async def validate_output(
ctx: RunContext[Deps], output: FlightDetails | NoFlightFound
) -> FlightDetails | NoFlightFound:
"""Procedural validation that the flight meets the constraints."""
if isinstance(output, NoFlightFound):
return output
errors: list[str] = []
if output.origin != ctx.deps.req_origin:
errors.append(
f'Flight should have origin {ctx.deps.req_origin}, not {output.origin}'
)
if output.destination != ctx.deps.req_destination:
errors.append(
f'Flight should have destination {ctx.deps.req_destination}, not {output.destination}'
)
if output.date != ctx.deps.req_date:
errors.append(f'Flight should be on {ctx.deps.req_date}, not {output.date}')
if errors:
raise ModelRetry('\n'.join(errors))
else:
return output
class SeatPreference(BaseModel):
row: int = Field(ge=1, le=30)
seat: Literal['A', 'B', 'C', 'D', 'E', 'F']
class Failed(BaseModel):
"""Unable to extract a seat selection."""
# This agent is responsible for extracting the user's seat selection
seat_preference_agent = Agent[object, SeatPreference | Failed](
'openai:gpt-5.2',
output_type=SeatPreference | Failed,
system_prompt=(
"Extract the user's seat preference. "
'Seats A and F are window seats. '
'Row 1 is the front row and has extra leg room. '
'Rows 14, and 20 also have extra leg room. '
),
)
# in reality this would be downloaded from a booking site,
# potentially using another agent to navigate the site
flights_web_page = """
1. Flight SFO-AK123
- Price: $350
- Origin: San Francisco International Airport (SFO)
- Destination: Ted Stevens Anchorage International Airport (ANC)
- Date: January 10, 2025
2. Flight SFO-AK456
- Price: $370
- Origin: San Francisco International Airport (SFO)
- Destination: Fairbanks International Airport (FAI)
- Date: January 10, 2025
3. Flight SFO-AK789
- Price: $400
- Origin: San Francisco International Airport (SFO)
- Destination: Juneau International Airport (JNU)
- Date: January 20, 2025
4. Flight NYC-LA101
- Price: $250
- Origin: San Francisco International Airport (SFO)
- Destination: Ted Stevens Anchorage International Airport (ANC)
- Date: January 10, 2025
5. Flight CHI-MIA202
- Price: $200
- Origin: Chicago O'Hare International Airport (ORD)
- Destination: Miami International Airport (MIA)
- Date: January 12, 2025
6. Flight BOS-SEA303
- Price: $120
- Origin: Boston Logan International Airport (BOS)
- Destination: Ted Stevens Anchorage International Airport (ANC)
- Date: January 12, 2025
7. Flight DFW-DEN404
- Price: $150
- Origin: Dallas/Fort Worth International Airport (DFW)
- Destination: Denver International Airport (DEN)
- Date: January 10, 2025
8. Flight ATL-HOU505
- Price: $180
- Origin: Hartsfield-Jackson Atlanta International Airport (ATL)
- Destination: George Bush Intercontinental Airport (IAH)
- Date: January 10, 2025
"""
# restrict how many requests this app can make to the LLM
usage_limits = UsageLimits(request_limit=15)
async def main():
deps = Deps(
web_page_text=flights_web_page,
req_origin='SFO',
req_destination='ANC',
req_date=datetime.date(2025, 1, 10),
)
message_history: list[ModelMessage] | None = None
usage: RunUsage = RunUsage()
# run the agent until a satisfactory flight is found
while True:
result = await search_agent.run(
f'Find me a flight from {deps.req_origin} to {deps.req_destination} on {deps.req_date}',
deps=deps,
usage=usage,
message_history=message_history,
usage_limits=usage_limits,
)
if isinstance(result.output, NoFlightFound):
print('No flight found')
break
else:
flight = result.output
print(f'Flight found: {flight}')
answer = Prompt.ask(
'Do you want to buy this flight, or keep searching? (buy/*search)',
choices=['buy', 'search', ''],
show_choices=False,
)
if answer == 'buy':
seat = await find_seat(usage)
await buy_tickets(flight, seat)
break
else:
message_history = result.all_messages(
output_tool_return_content='Please suggest another flight'
)
async def find_seat(usage: RunUsage) -> SeatPreference:
message_history: list[ModelMessage] | None = None
while True:
answer = Prompt.ask('What seat would you like?')
result = await seat_preference_agent.run(
answer,
message_history=message_history,
usage=usage,
usage_limits=usage_limits,
)
if isinstance(result.output, SeatPreference):
return result.output
else:
print('Could not understand seat preference. Please try again.')
message_history = result.all_messages()
async def buy_tickets(flight_details: FlightDetails, seat: SeatPreference):
print(f'Purchasing flight {flight_details=!r} {seat=!r}...')
if __name__ == '__main__':
import asyncio
asyncio.run(main())
@@ -0,0 +1,331 @@
"""Medical Triage System with Agent Delegation.
The `triage_agent` acts as the central decision-maker and orchestrator.
The instructions helps it to "call tools to consult specialists or a senior doctor."
It delegates the actual medical work (diagnosis or treatment planning) to other agents.
The two core functions act as the delegation mechanism:
- consult_specialist: This tool routes the complaint to a specific Specialist Agent
(cardiology_agent, neurology_agent, etc.). This is Level 1 Delegation: Routing to expertise.
- consult_senior_doctor: This tool routes the complaint to a Senior Agent (senior_doctor_agent).
This is Level 2 Delegation: Escalation for critical decision-making.
Demonstrates:
- Master agent coordinating specialized sub-agents
- Dynamic routing and delegation based on symptom analysis
- Structured output
Run with:
uv run -m pydantic_ai_examples.medical_agent_delegation
"""
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from textwrap import dedent
from typing import Any
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from pydantic_ai import Agent, ModelHTTPError, RunContext
MODEL = 'openai:gpt-5.2'
# Structured Outputs
class Specialty(str, Enum):
general = 'general'
cardiology = 'cardiology'
neurology = 'neurology'
class MedicalReport(BaseModel):
diagnosis: list[str]
differential: list[str]
recommended_tests: list[str]
immediate_actions: list[str]
estimated_time_minutes: int
class TreatmentPlan(BaseModel):
plan_summary: str = Field(
description='The structured treatment plan from the senior doctor'
)
refer_to_specialist: Specialty | None = Field(
description='Specialty to route the patient to for further treatment, if necessary'
)
follow_up_days: int
class TriageFinalOutput(BaseModel):
"""The final structured output containing the result of the entire flow."""
specialty: Specialty | None = None
final_report: MedicalReport | None = None
treatment_plan: TreatmentPlan | None = None
final_status: str = Field(
..., description="Status: 'resolved_by_specialist' or 'escalated'"
)
# Shared Dependency
@dataclass
class PatientInfo:
patient_id: str
age: int
known_conditions: list[str]
class TestPatient(TypedDict):
complaint: str
patient: PatientInfo
class MedicalHistoryRecord(TypedDict):
timestamp: str
patient_id: str
path: str
specialty: Specialty | None
report_summary: list[str] | str
treatment_summary: str
# Specialist and Senior Agents
gp_agent = Agent(
MODEL,
output_type=MedicalReport,
deps_type=PatientInfo,
instructions=dedent(
"""
You are a general practitioner.
"""
),
)
cardiology_agent = Agent(
MODEL,
output_type=MedicalReport,
deps_type=PatientInfo,
instructions=dedent(
"""
You are a cardiology specialist.
"""
),
)
neurology_agent = Agent(
MODEL,
output_type=MedicalReport,
deps_type=PatientInfo,
instructions=dedent(
"""
You are a neurology specialist.
"""
),
)
senior_doctor_agent = Agent(
MODEL,
output_type=TreatmentPlan,
deps_type=PatientInfo,
instructions=dedent(
"""
You are a senior clinician overseeing complex or ambiguous cases.
Integrate all prior findings to produce a clear treatment plan.
"""
),
)
SPECIALIST_MAP = {
'general': gp_agent,
'cardiology': cardiology_agent,
'neurology': neurology_agent,
}
# Agent-as-Orchestrator: triage_agent with Delegation Tools
triage_agent = Agent(
MODEL,
output_type=TriageFinalOutput,
deps_type=PatientInfo,
instructions=dedent(
"""
You are a triage clinician coordinating medical workflow.
You can call tools to consult specialists or a senior doctor.
AVAILABLE SPECIALTIES:
- "general": General practitioner for common issues
- "cardiology": For heart, chest pain, cardiac symptoms
- "neurology": For brain, nerve, stroke, headache symptoms
ESCALATION RULES — call consult_senior_doctor immediately if ANY of:
- "Worst headache of my life" (possible subarachnoid hemorrhage)
- Sudden weakness, numbness, or paralysis in limbs (possible stroke)
- Sudden vision loss or blurry vision alongside other symptoms
- Loss of consciousness or repeated fainting
- Multi-system symptoms (e.g. neuro + cardiac combined)
- You are uncertain which specialist is appropriate
For all other cases, route to the appropriate specialist via consult_specialist.
Always produce a structured TriageFinalOutput.
"""
),
)
@triage_agent.tool
async def consult_specialist(
ctx: RunContext[PatientInfo],
specialty: Specialty,
question: str,
) -> TriageFinalOutput | str:
"""Consult the appropriate specialist for expert consultation."""
specialist_agent = SPECIALIST_MAP.get(specialty)
print(f'Proceed with specialist - {specialty}')
if not specialist_agent:
print('Selected specialist does not exists!')
return f'No specialist found for {specialty.name}.'
result = await specialist_agent.run(f'Consultation: {question}', deps=ctx.deps)
report: MedicalReport = result.output
return TriageFinalOutput(
final_status='resolved_by_specialist',
specialty=specialty,
final_report=report,
)
@triage_agent.tool
async def consult_senior_doctor(
ctx: RunContext[PatientInfo], reason_for_escalation: str, initial_complaint: str
) -> TriageFinalOutput:
"""Consult senior doctor in case of escalation and emergency cases.
Immediately escalates the case to the senior clinician for severe cases and for a final TreatmentPlan.
Use this for high severity, critical, or ambiguous cases.
Args:
ctx: Pydantic AI agent RunContext
reason_for_escalation: Summary of why the case must be escalated (e.g., "Severe pain, possible cardiac event").
initial_complaint: The patient's original complaint.
"""
patient = ctx.deps
senior_note = f'Reason: {reason_for_escalation}\nComplaint and context:\n{initial_complaint}\nPatient: {patient.patient_id}, age {patient.age}\n'
print('Direct escalation triggered by Triage LLM.')
treatment_plan = None
try:
result = await senior_doctor_agent.run(
f'Consultation for: {senior_note}', deps=ctx.deps
)
treatment_plan = result.output
except ModelHTTPError as e:
# Handle case where LLM fails to provide TreatmentPlan structure
treatment_plan = TreatmentPlan(
plan_summary=f'Consultation failed due to API error: {e.status_code}. Requires manual review.',
refer_to_specialist=None,
follow_up_days=1,
)
return TriageFinalOutput(
final_status='escalated',
treatment_plan=treatment_plan,
)
# Coordinator System
class MedicalTriageSystem:
"""Coordinator that invokes triage_agent as the orchestrator."""
def __init__(self):
self.triage = triage_agent
self.medical_history: list[MedicalHistoryRecord] = []
async def handle_patient(
self, complaint: str, patient: PatientInfo
) -> dict[str, Any]:
timestamp = datetime.now(tz=timezone.utc).isoformat()
print(f'\n[{timestamp}] Processing complaint: {complaint}')
triage_prompt = (
f'Patient {patient.patient_id}, age {patient.age}\n'
f'Complaint: {complaint}\n'
f'Known conditions: {patient.known_conditions}\n'
f'If necessary, use your tools to consult specialists or senior doctor.'
)
triage_result = await self.triage.run(triage_prompt, deps=patient)
final_output: TriageFinalOutput = triage_result.output
record: MedicalHistoryRecord = {
'timestamp': timestamp,
'patient_id': patient.patient_id,
'path': final_output.final_status,
'specialty': final_output.specialty,
'report_summary': final_output.final_report.diagnosis
if final_output.final_report
else 'N/A',
'treatment_summary': final_output.treatment_plan.plan_summary
if final_output.treatment_plan
else 'N/A',
}
self.medical_history.append(record)
return final_output.model_dump()
async def demo_medical_triage():
system = MedicalTriageSystem()
test_patients: list[TestPatient] = [
{
'complaint': 'Sudden severe chest pain radiating to left arm and shortness of breath.',
'patient': PatientInfo(
patient_id='P001', age=64, known_conditions=['hypertension']
),
},
{
'complaint': 'Intermittent headaches for 2 weeks, mild nausea, no weakness.',
'patient': PatientInfo(patient_id='P002', age=34, known_conditions=[]),
},
{
'complaint': 'Unresponsive patient, suspected multi-organ failure, unknown history.',
'patient': PatientInfo(
patient_id='P004',
age=85,
known_conditions=['heart failure', 'renal failure'],
),
},
{
'complaint': 'Hard to breath and faint every few minutes.',
'patient': PatientInfo(patient_id='P003', age=71, known_conditions=[]),
},
{
'complaint': "Sudden onset of the worst headache of my life, followed by blurry vision and now I can't feel my left leg. I took aspirin an hour ago.",
'patient': PatientInfo(
patient_id='P003',
age=71,
known_conditions=['Type 2 Diabetes', 'Chronic Migraines'],
),
},
]
for entry in test_patients:
print(f'Processing patient {entry["patient"].patient_id}')
result = await system.handle_patient(entry['complaint'], entry['patient'])
print('Result:', result)
print('\nMEDICAL HISTORY SUMMARY:')
for history in system.medical_history:
print(
f'- {history["timestamp"]} | Patient {history["patient_id"]} | Path: {history["path"]}'
)
if __name__ == '__main__':
asyncio.run(demo_medical_triage())
@@ -0,0 +1,32 @@
"""Simple example of using Pydantic AI to construct a Pydantic model from a text input.
Run with:
uv run -m pydantic_ai_examples.pydantic_model
"""
import os
import logfire
from pydantic import BaseModel
from pydantic_ai import Agent
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()
class MyModel(BaseModel):
city: str
country: str
model = os.getenv('PYDANTIC_AI_MODEL', 'openai:gpt-5.2')
print(f'Using model: {model}')
agent = Agent(model, output_type=MyModel)
if __name__ == '__main__':
result = agent.run_sync('The windy city in the US of A.')
print(result.output)
print(result.usage)
+259
View File
@@ -0,0 +1,259 @@
"""RAG example with pydantic-ai — using vector search to augment a chat agent.
Run pgvector with:
mkdir postgres-data
docker run --rm -e POSTGRES_PASSWORD=postgres \
-p 54320:5432 \
-v `pwd`/postgres-data:/var/lib/postgresql/data \
pgvector/pgvector:pg17
Build the search DB with:
uv run -m pydantic_ai_examples.rag build
Ask the agent a question with:
uv run -m pydantic_ai_examples.rag search "How do I configure logfire to work with FastAPI?"
"""
from __future__ import annotations as _annotations
import asyncio
import re
import sys
import unicodedata
from contextlib import asynccontextmanager
from dataclasses import dataclass
import asyncpg
import httpx
import logfire
import pydantic_core
from anyio import create_task_group
from openai import AsyncOpenAI
from pydantic import TypeAdapter
from typing_extensions import AsyncGenerator
from pydantic_ai import Agent, RunContext
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_asyncpg()
logfire.instrument_pydantic_ai()
@dataclass
class Deps:
openai: AsyncOpenAI
pool: asyncpg.Pool
agent = Agent('openai:gpt-5.2', deps_type=Deps)
@agent.tool
async def retrieve(context: RunContext[Deps], search_query: str) -> str:
"""Retrieve documentation sections based on a search query.
Args:
context: The call context.
search_query: The search query.
"""
with logfire.span(
'create embedding for {search_query=}', search_query=search_query
):
embedding = await context.deps.openai.embeddings.create(
input=search_query,
model='text-embedding-3-small',
)
assert len(embedding.data) == 1, (
f'Expected 1 embedding, got {len(embedding.data)}, doc query: {search_query!r}'
)
embedding = embedding.data[0].embedding
embedding_json = pydantic_core.to_json(embedding).decode()
rows = await context.deps.pool.fetch(
'SELECT url, title, content FROM doc_sections ORDER BY embedding <-> $1 LIMIT 8',
embedding_json,
)
return '\n\n'.join(
f'# {row["title"]}\nDocumentation URL:{row["url"]}\n\n{row["content"]}\n'
for row in rows
)
async def run_agent(question: str):
"""Entry point to run the agent and perform RAG based question answering."""
openai = AsyncOpenAI()
logfire.instrument_openai(openai)
logfire.info('Asking "{question}"', question=question)
async with database_connect(False) as pool:
deps = Deps(openai=openai, pool=pool)
answer = await agent.run(question, deps=deps)
print(answer.output)
#######################################################
# The rest of this file is dedicated to preparing the #
# search database, and some utilities. #
#######################################################
# JSON document from
# https://gist.github.com/samuelcolvin/4b5bb9bb163b1122ff17e29e48c10992
DOCS_JSON = (
'https://gist.githubusercontent.com/'
'samuelcolvin/4b5bb9bb163b1122ff17e29e48c10992/raw/'
'80c5925c42f1442c24963aaf5eb1a324d47afe95/logfire_docs.json'
)
async def build_search_db():
"""Build the search database."""
async with httpx.AsyncClient() as client:
response = await client.get(DOCS_JSON)
response.raise_for_status()
sections = sections_ta.validate_json(response.content)
openai = AsyncOpenAI()
logfire.instrument_openai(openai)
async with database_connect(True) as pool:
with logfire.span('create schema'):
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute(DB_SCHEMA)
sem = asyncio.Semaphore(10)
async with create_task_group() as tg:
for section in sections:
tg.start_soon(insert_doc_section, sem, openai, pool, section)
async def insert_doc_section(
sem: asyncio.Semaphore,
openai: AsyncOpenAI,
pool: asyncpg.Pool,
section: DocsSection,
) -> None:
async with sem:
url = section.url()
exists = await pool.fetchval('SELECT 1 FROM doc_sections WHERE url = $1', url)
if exists:
logfire.info('Skipping {url=}', url=url)
return
with logfire.span('create embedding for {url=}', url=url):
embedding = await openai.embeddings.create(
input=section.embedding_content(),
model='text-embedding-3-small',
)
assert len(embedding.data) == 1, (
f'Expected 1 embedding, got {len(embedding.data)}, doc section: {section}'
)
embedding = embedding.data[0].embedding
embedding_json = pydantic_core.to_json(embedding).decode()
await pool.execute(
'INSERT INTO doc_sections (url, title, content, embedding) VALUES ($1, $2, $3, $4)',
url,
section.title,
section.content,
embedding_json,
)
@dataclass
class DocsSection:
id: int
parent: int | None
path: str
level: int
title: str
content: str
def url(self) -> str:
url_path = re.sub(r'\.md$', '', self.path)
return (
f'https://logfire.pydantic.dev/docs/{url_path}/#{slugify(self.title, "-")}'
)
def embedding_content(self) -> str:
return '\n\n'.join((f'path: {self.path}', f'title: {self.title}', self.content))
sections_ta = TypeAdapter(list[DocsSection])
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownVariableType=false
@asynccontextmanager
async def database_connect(
create_db: bool = False,
) -> AsyncGenerator[asyncpg.Pool, None]:
server_dsn, database = (
'postgresql://postgres:postgres@localhost:54320',
'pydantic_ai_rag',
)
if create_db:
with logfire.span('check and create DB'):
conn = await asyncpg.connect(server_dsn)
try:
db_exists = await conn.fetchval(
'SELECT 1 FROM pg_database WHERE datname = $1', database
)
if not db_exists:
await conn.execute(f'CREATE DATABASE {database}')
finally:
await conn.close()
pool = await asyncpg.create_pool(f'{server_dsn}/{database}')
try:
yield pool
finally:
await pool.close()
DB_SCHEMA = """
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS doc_sections (
id serial PRIMARY KEY,
url text NOT NULL UNIQUE,
title text NOT NULL,
content text NOT NULL,
-- text-embedding-3-small returns a vector of 1536 floats
embedding vector(1536) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_doc_sections_embedding ON doc_sections USING hnsw (embedding vector_l2_ops);
"""
def slugify(value: str, separator: str, unicode: bool = False) -> str:
"""Slugify a string, to make it URL friendly."""
# Taken unchanged from https://github.com/Python-Markdown/markdown/blob/3.7/markdown/extensions/toc.py#L38
if not unicode:
# Replace Extended Latin characters with ASCII, i.e. `žlutý` => `zluty`
value = unicodedata.normalize('NFKD', value)
value = value.encode('ascii', 'ignore').decode('ascii')
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
return re.sub(rf'[{separator}\s]+', separator, value)
if __name__ == '__main__':
action = sys.argv[1] if len(sys.argv) > 1 else None
if action == 'build':
asyncio.run(build_search_db())
elif action == 'search':
if len(sys.argv) == 3:
q = sys.argv[2]
else:
q = 'How do I configure logfire to work with FastAPI?'
asyncio.run(run_agent(q))
else:
print(
'uv run --extra examples -m pydantic_ai_examples.rag build|search',
file=sys.stderr,
)
sys.exit(1)
@@ -0,0 +1,67 @@
"""Example demonstrating how to use Pydantic AI to create a simple roulette game.
Run with:
uv run -m pydantic_ai_examples.roulette_wheel
"""
from __future__ import annotations as _annotations
import asyncio
from dataclasses import dataclass
from typing import Literal
from pydantic_ai import Agent, RunContext
# Define the dependencies class
@dataclass
class Deps:
winning_number: int
# Create the agent with proper typing
roulette_agent = Agent(
'groq:llama-3.3-70b-versatile',
deps_type=Deps,
retries=3,
output_type=bool,
system_prompt=(
'Use the `roulette_wheel` function to determine if the customer has won based on the number they bet on.'
),
)
@roulette_agent.tool
async def roulette_wheel(
ctx: RunContext[Deps], square: int
) -> Literal['winner', 'loser']:
"""Check if the bet square is a winner.
Args:
ctx: The context containing the winning number.
square: The number the player bet on.
"""
return 'winner' if square == ctx.deps.winning_number else 'loser'
async def main():
# Set up dependencies
winning_number = 18
deps = Deps(winning_number=winning_number)
# Run some example bets using streaming
async with roulette_agent.run_stream(
'Put my money on square eighteen', deps=deps
) as response:
result = await response.get_output()
print('Bet on 18:', result)
async with roulette_agent.run_stream(
'I bet five is the winner', deps=deps
) as response:
result = await response.get_output()
print('Bet on 5:', result)
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,47 @@
from textwrap import dedent
from types import NoneType
import logfire
### [imports]
from pydantic_ai import Agent, NativeOutput
from pydantic_ai.common_tools.duckduckgo import duckduckgo_search_tool ### [/imports]
from .models import Analysis, Profile
### [agent]
agent = Agent(
'openai:gpt-5.2',
instructions=dedent(
"""
When a new person joins our public Slack, please put together a brief snapshot so we can be most useful to them.
**What to include**
1. **Who they are:** Any details about their professional role or projects (e.g. LinkedIn, GitHub, company bio).
2. **Where they work:** Name of the organisation and its domain.
3. **How we can help:** On a scale of 15, estimate how likely they are to benefit from **Pydantic Logfire**
(our paid observability tool) based on factors such as company size, product maturity, or AI usage.
*1 = probably not relevant, 5 = very strong fit.*
**Our products (for context only)**
• **Pydantic Validation** Python data-validation (open source)
• **Pydantic AI** Python agent framework (open source)
• **Pydantic Logfire** Observability for traces, logs & metrics with first-class AI support (commercial)
**How to research**
• Use the provided DuckDuckGo search tool to research the person and the organization they work for, based on the email domain or what you find on e.g. LinkedIn and GitHub.
• If you can't find enough to form a reasonable view, return **None**.
"""
),
tools=[duckduckgo_search_tool()],
output_type=NativeOutput([Analysis, NoneType]),
) ### [/agent]
### [analyze_profile]
@logfire.instrument('Analyze profile')
async def analyze_profile(profile: Profile) -> Analysis | None:
result = await agent.run(profile.as_prompt())
return result.output ### [/analyze_profile]
@@ -0,0 +1,36 @@
from typing import Any
import logfire
from fastapi import FastAPI, HTTPException, status
from logfire.propagate import get_context
from .models import Profile
### [process_slack_member]
def process_slack_member(profile: Profile):
from .modal import process_slack_member as _process_slack_member
_process_slack_member.spawn(
profile.model_dump(), logfire_ctx=get_context()
) ### [/process_slack_member]
### [app]
app = FastAPI()
logfire.instrument_fastapi(app, capture_headers=True)
@app.post('/')
async def process_webhook(payload: dict[str, Any]) -> dict[str, Any]:
if payload['type'] == 'url_verification':
return {'challenge': payload['challenge']}
elif (
payload['type'] == 'event_callback' and payload['event']['type'] == 'team_join'
):
profile = Profile.model_validate(payload['event']['user']['profile'])
process_slack_member(profile)
return {'status': 'OK'}
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) ### [/app]
@@ -0,0 +1,85 @@
import logfire
### [imports]
from .agent import analyze_profile
from .models import Profile
### [imports-daily_summary]
from .slack import send_slack_message
from .store import AnalysisStore ### [/imports,/imports-daily_summary]
### [constant-new_lead_channel]
NEW_LEAD_CHANNEL = '#new-slack-leads'
### [/constant-new_lead_channel]
### [constant-daily_summary_channel]
DAILY_SUMMARY_CHANNEL = '#daily-slack-leads-summary'
### [/constant-daily_summary_channel]
### [process_slack_member]
@logfire.instrument('Process Slack member')
async def process_slack_member(profile: Profile):
analysis = await analyze_profile(profile)
logfire.info('Analysis', analysis=analysis)
if analysis is None:
return
await AnalysisStore().add(analysis)
await send_slack_message(
NEW_LEAD_CHANNEL,
[
{
'type': 'header',
'text': {
'type': 'plain_text',
'text': f'New Slack member with score {analysis.relevance}/5',
},
},
{
'type': 'divider',
},
*analysis.as_slack_blocks(),
],
) ### [/process_slack_member]
### [send_daily_summary]
@logfire.instrument('Send daily summary')
async def send_daily_summary():
analyses = await AnalysisStore().list()
logfire.info('Analyses', analyses=analyses)
if len(analyses) == 0:
return
sorted_analyses = sorted(analyses, key=lambda x: x.relevance, reverse=True)
top_analyses = sorted_analyses[:5]
blocks = [
{
'type': 'header',
'text': {
'type': 'plain_text',
'text': f'Top {len(top_analyses)} new Slack members from the last 24 hours',
},
},
]
for analysis in top_analyses:
blocks.extend(
[
{
'type': 'divider',
},
*analysis.as_slack_blocks(include_relevance=True),
]
)
await send_slack_message(
DAILY_SUMMARY_CHANNEL,
blocks,
)
await AnalysisStore().clear() ### [/send_daily_summary]
@@ -0,0 +1,66 @@
from typing import Any
### [setup_modal]
import modal
image = modal.Image.debian_slim(python_version='3.13').pip_install(
'pydantic',
'pydantic_ai_slim[openai,duckduckgo]',
'logfire[httpx,fastapi]',
'fastapi[standard]',
'httpx',
)
app = modal.App(
name='slack-lead-qualifier',
image=image,
secrets=[
modal.Secret.from_name('logfire'),
modal.Secret.from_name('openai'),
modal.Secret.from_name('slack'),
],
) ### [/setup_modal]
### [setup_logfire]
def setup_logfire():
import logfire
logfire.configure(service_name=app.name)
logfire.instrument_pydantic_ai()
logfire.instrument_httpx(capture_all=True) ### [/setup_logfire]
### [web_app]
@app.function(min_containers=1)
@modal.asgi_app() # type: ignore
def web_app():
setup_logfire()
from .app import app as _app
return _app ### [/web_app]
### [process_slack_member]
@app.function()
async def process_slack_member(profile_raw: dict[str, Any], logfire_ctx: Any):
setup_logfire()
from logfire.propagate import attach_context
from .functions import process_slack_member as _process_slack_member
from .models import Profile
with attach_context(logfire_ctx):
profile = Profile.model_validate(profile_raw)
await _process_slack_member(profile) ### [/process_slack_member]
### [send_daily_summary]
@app.function(schedule=modal.Cron('0 8 * * *')) # Every day at 8am UTC
async def send_daily_summary():
setup_logfire()
from .functions import send_daily_summary as _send_daily_summary
await _send_daily_summary() ### [/send_daily_summary]
@@ -0,0 +1,46 @@
from typing import Annotated, Any
from annotated_types import Ge, Le
from pydantic import BaseModel
### [import-format_as_xml]
from pydantic_ai import format_as_xml ### [/import-format_as_xml]
### [profile,profile-intro]
class Profile(BaseModel): ### [/profile-intro]
first_name: str | None = None
last_name: str | None = None
display_name: str | None = None
email: str ### [/profile]
### [profile-as_prompt]
def as_prompt(self) -> str:
return format_as_xml(self, root_tag='profile') ### [/profile-as_prompt]
### [analysis,analysis-intro]
class Analysis(BaseModel): ### [/analysis-intro]
profile: Profile
organization_name: str
organization_domain: str
job_title: str
relevance: Annotated[int, Ge(1), Le(5)]
"""Estimated fit for Pydantic Logfire: 1 = low, 5 = high"""
summary: str
"""One-sentence welcome note summarising who they are and how we might help""" ### [/analysis]
### [analysis-as_slack_blocks]
def as_slack_blocks(self, include_relevance: bool = False) -> list[dict[str, Any]]:
profile = self.profile
relevance = f'({self.relevance}/5)' if include_relevance else ''
return [
{
'type': 'markdown',
'text': f'[{profile.display_name}](mailto:{profile.email}), {self.job_title} at [**{self.organization_name}**](https://{self.organization_domain}) {relevance}',
},
{
'type': 'markdown',
'text': self.summary,
},
] ### [/analysis-as_slack_blocks]
@@ -0,0 +1,30 @@
import os
from typing import Any
import httpx
import logfire
### [send_slack_message]
API_KEY = os.getenv('SLACK_API_KEY')
assert API_KEY, 'SLACK_API_KEY is not set'
@logfire.instrument('Send Slack message')
async def send_slack_message(channel: str, blocks: list[dict[str, Any]]):
client = httpx.AsyncClient()
response = await client.post(
'https://slack.com/api/chat.postMessage',
json={
'channel': channel,
'blocks': blocks,
},
headers={
'Authorization': f'Bearer {API_KEY}',
},
timeout=5,
)
response.raise_for_status()
result = response.json()
if not result.get('ok', False):
error = result.get('error', 'Unknown error')
raise Exception(f'Failed to send to Slack: {error}') ### [/send_slack_message]
@@ -0,0 +1,31 @@
import logfire
### [import-modal]
import modal ### [/import-modal]
from .models import Analysis
### [analysis_store]
class AnalysisStore:
@classmethod
@logfire.instrument('Add analysis to store')
async def add(cls, analysis: Analysis):
await cls._get_store().put.aio(analysis.profile.email, analysis.model_dump())
@classmethod
@logfire.instrument('List analyses from store')
async def list(cls) -> list[Analysis]:
return [
Analysis.model_validate(analysis)
async for analysis in cls._get_store().values.aio()
]
@classmethod
@logfire.instrument('Clear analyses from store')
async def clear(cls):
await cls._get_store().clear.aio()
@classmethod
def _get_store(cls) -> modal.Dict:
return modal.Dict.from_name('analyses', create_if_missing=True) # type: ignore ### [/analysis_store]
+180
View File
@@ -0,0 +1,180 @@
"""Example demonstrating how to use Pydantic AI to generate SQL queries based on user input.
Run postgres with:
mkdir postgres-data
docker run --rm -e POSTGRES_PASSWORD=postgres -p 54320:5432 postgres
Run with:
uv run -m pydantic_ai_examples.sql_gen "show me logs from yesterday, with level 'error'"
"""
import asyncio
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import date
from typing import Annotated, Any, TypeAlias
import asyncpg
import logfire
from annotated_types import MinLen
from devtools import debug
from pydantic import BaseModel, Field
from pydantic_ai import Agent, ModelRetry, RunContext, format_as_xml
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_asyncpg()
logfire.instrument_pydantic_ai()
DB_SCHEMA = """
CREATE TABLE records (
created_at timestamptz,
start_timestamp timestamptz,
end_timestamp timestamptz,
trace_id text,
span_id text,
parent_span_id text,
level log_level,
span_name text,
message text,
attributes_json_schema text,
attributes jsonb,
tags text[],
is_exception boolean,
otel_status_message text,
service_name text
);
"""
SQL_EXAMPLES = [
{
'request': 'show me records where foobar is false',
'response': "SELECT * FROM records WHERE attributes->>'foobar' = false",
},
{
'request': 'show me records where attributes include the key "foobar"',
'response': "SELECT * FROM records WHERE attributes ? 'foobar'",
},
{
'request': 'show me records from yesterday',
'response': "SELECT * FROM records WHERE start_timestamp::date > CURRENT_TIMESTAMP - INTERVAL '1 day'",
},
{
'request': 'show me error records with the tag "foobar"',
'response': "SELECT * FROM records WHERE level = 'error' and 'foobar' = ANY(tags)",
},
]
@dataclass
class Deps:
conn: asyncpg.Connection
class Success(BaseModel):
"""Response when SQL could be successfully generated."""
sql_query: Annotated[str, MinLen(1)]
explanation: str = Field(
'', description='Explanation of the SQL query, as markdown'
)
class InvalidRequest(BaseModel):
"""Response the user input didn't include enough information to generate SQL."""
error_message: str
Response: TypeAlias = Success | InvalidRequest
agent = Agent[Deps, Response](
'google:gemini-3-flash-preview',
# Type ignore while we wait for PEP-0747, nonetheless unions will work fine everywhere else
output_type=Response, # type: ignore
deps_type=Deps,
)
@agent.system_prompt
async def system_prompt() -> str:
return f"""\
Given the following PostgreSQL table of records, your job is to
write a SQL query that suits the user's request.
Database schema:
{DB_SCHEMA}
today's date = {date.today()}
{format_as_xml(SQL_EXAMPLES)}
"""
@agent.output_validator
async def validate_output(ctx: RunContext[Deps], output: Response) -> Response:
if isinstance(output, InvalidRequest):
return output
# gemini often adds extraneous backslashes to SQL
output.sql_query = output.sql_query.replace('\\', '')
if not output.sql_query.upper().startswith('SELECT'):
raise ModelRetry('Please create a SELECT query')
try:
await ctx.deps.conn.execute(f'EXPLAIN {output.sql_query}')
except asyncpg.exceptions.PostgresError as e:
raise ModelRetry(f'Invalid query: {e}') from e
else:
return output
async def main():
if len(sys.argv) == 1:
prompt = 'show me logs from yesterday, with level "error"'
else:
prompt = sys.argv[1]
async with database_connect(
'postgresql://postgres:postgres@localhost:54320', 'pydantic_ai_sql_gen'
) as conn:
deps = Deps(conn)
result = await agent.run(prompt, deps=deps)
debug(result.output)
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownVariableType=false
@asynccontextmanager
async def database_connect(server_dsn: str, database: str) -> AsyncGenerator[Any, None]:
with logfire.span('check and create DB'):
conn = await asyncpg.connect(server_dsn)
try:
db_exists = await conn.fetchval(
'SELECT 1 FROM pg_database WHERE datname = $1', database
)
if not db_exists:
await conn.execute(f'CREATE DATABASE {database}')
finally:
await conn.close()
conn = await asyncpg.connect(f'{server_dsn}/{database}')
try:
with logfire.span('create schema'):
async with conn.transaction():
if not db_exists:
await conn.execute(
"CREATE TYPE log_level AS ENUM ('debug', 'info', 'warning', 'error', 'critical')"
)
await conn.execute(DB_SCHEMA)
yield conn
finally:
await conn.close()
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,77 @@
"""This example shows how to stream markdown from an agent, using the `rich` library to display the markdown.
Run with:
uv run -m pydantic_ai_examples.stream_markdown
"""
import asyncio
import os
import logfire
from rich.console import Console, ConsoleOptions, RenderResult
from rich.live import Live
from rich.markdown import CodeBlock, Markdown
from rich.syntax import Syntax
from rich.text import Text
from pydantic_ai import Agent
from pydantic_ai.models import KnownModelName
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()
agent = Agent()
# models to try, and the appropriate env var
models: list[tuple[KnownModelName, str]] = [
('google:gemini-3-flash-preview', 'GEMINI_API_KEY'),
('openai:gpt-5-mini', 'OPENAI_API_KEY'),
('groq:llama-3.3-70b-versatile', 'GROQ_API_KEY'),
]
async def main():
prettier_code_blocks()
console = Console()
prompt = 'Show me a short example of using Pydantic.'
console.log(f'Asking: {prompt}...', style='cyan')
for model, env_var in models:
if env_var in os.environ:
console.log(f'Using model: {model}')
with Live('', console=console, vertical_overflow='visible') as live:
async with agent.run_stream(prompt, model=model) as result:
async for message in result.stream_output():
live.update(Markdown(message))
console.log(result.usage)
else:
console.log(f'{model} requires {env_var} to be set.')
def prettier_code_blocks():
"""Make rich code blocks prettier and easier to copy.
From https://github.com/samuelcolvin/aicli/blob/v0.8.0/samuelcolvin_aicli.py#L22
"""
class SimpleCodeBlock(CodeBlock):
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
code = str(self.text).rstrip()
yield Text(self.lexer_name, style='dim')
yield Syntax(
code,
self.lexer_name,
theme=self.theme,
background_color='default',
word_wrap=True,
)
yield Text(f'/{self.lexer_name}', style='dim')
Markdown.elements['fence'] = SimpleCodeBlock
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,82 @@
"""Information about whales — an example of streamed structured response validation.
This script streams structured responses about whales, validates the data
and displays it as a dynamic table using Rich as the data is received.
Run with:
uv run -m pydantic_ai_examples.stream_whales
"""
from typing import Annotated
import logfire
from pydantic import Field
from rich.console import Console
from rich.live import Live
from rich.table import Table
from typing_extensions import NotRequired, TypedDict
from pydantic_ai import Agent
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()
class Whale(TypedDict):
name: str
length: Annotated[
float, Field(description='Average length of an adult whale in meters.')
]
weight: NotRequired[
Annotated[
float,
Field(description='Average weight of an adult whale in kilograms.', ge=50),
]
]
ocean: NotRequired[str]
description: NotRequired[Annotated[str, Field(description='Short Description')]]
agent = Agent('openai:gpt-5.2', output_type=list[Whale])
async def main():
console = Console()
with Live('\n' * 36, console=console) as live:
console.print('Requesting data...', style='cyan')
async with agent.run_stream(
'Generate me details of 5 species of Whale.'
) as result:
console.print('Response:', style='green')
async for whales in result.stream_output(debounce_by=0.01):
table = Table(
title='Species of Whale',
caption='Streaming Structured responses from OpenAI',
width=120,
)
table.add_column('ID', justify='right')
table.add_column('Name')
table.add_column('Avg. Length (m)', justify='right')
table.add_column('Avg. Weight (kg)', justify='right')
table.add_column('Ocean')
table.add_column('Description', justify='right')
for wid, whale in enumerate(whales, start=1):
table.add_row(
str(wid),
whale['name'],
f'{whale["length"]:0.0f}',
f'{w:0.0f}' if (w := whale.get('weight')) else '',
whale.get('ocean') or '',
whale.get('description') or '',
)
live.update(table)
if __name__ == '__main__':
import asyncio
asyncio.run(main())
@@ -0,0 +1,90 @@
"""Example of a Pydantic AI agent that understands video using TwelveLabs Pegasus.
In this case the idea is a "video analyst" agent — the user can ask questions about a
video (given its URL), and the agent will use the `analyze_video` tool to call
[TwelveLabs](https://twelvelabs.io) Pegasus, a video-understanding model, to answer.
This shows how to wrap a third-party multimodal API as a Pydantic AI tool: the LLM
decides *what* to ask about the video, and Pegasus does the actual video understanding.
Run with:
uv run -m pydantic_ai_examples.twelvelabs_video_agent
"""
from __future__ import annotations as _annotations
import asyncio
import os
from dataclasses import dataclass
import logfire
from twelvelabs import AsyncTwelveLabs
from twelvelabs.types import VideoContext_Url
from pydantic_ai import Agent, RunContext
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()
# A public sample video used when the user doesn't provide one. The URL must point at a
# video file TwelveLabs can fetch directly; set VIDEO_URL to use your own.
DEFAULT_VIDEO_URL = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4'
@dataclass
class Deps:
twelvelabs: AsyncTwelveLabs
video_url: str
video_agent = Agent(
'openai:gpt-5-mini',
instructions=(
'You help users understand a video. '
'Use the `analyze_video` tool to ask the video-understanding model questions, '
'then answer the user concisely based on what it returns.'
),
deps_type=Deps,
retries=2,
)
@video_agent.tool
async def analyze_video(ctx: RunContext[Deps], prompt: str) -> str:
"""Analyze the video with TwelveLabs Pegasus and return a text answer.
Args:
ctx: The context.
prompt: What to ask about the video, e.g. "Summarize this video" or
"What objects appear in the first 10 seconds?".
"""
response = await ctx.deps.twelvelabs.analyze(
model_name='pegasus1.5',
video=VideoContext_Url(url=ctx.deps.video_url),
prompt=prompt,
max_tokens=2048,
)
return response.data or ''
async def main():
api_key = os.environ.get('TWELVELABS_API_KEY')
if not api_key:
raise RuntimeError(
'Set TWELVELABS_API_KEY to run this example. '
'Grab a free key at https://twelvelabs.io.'
)
video_url = os.environ.get('VIDEO_URL', DEFAULT_VIDEO_URL)
async with AsyncTwelveLabs(api_key=api_key) as client:
deps = Deps(twelvelabs=client, video_url=video_url)
result = await video_agent.run(
'Give me a one-sentence summary of this video.', deps=deps
)
print('Response:', result.output)
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,105 @@
"""Example of Pydantic AI with multiple tools which the LLM needs to call in turn to answer a question.
In this case the idea is a "weather" agent — the user can ask for the weather in multiple cities,
the agent will use the `get_lat_lng` tool to get the latitude and longitude of the locations, then use
the `get_weather` tool to get the weather.
Run with:
uv run -m pydantic_ai_examples.weather_agent
"""
from __future__ import annotations as _annotations
import asyncio
from dataclasses import dataclass
from typing import Any
import logfire
from httpx import AsyncClient
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext
# 'if-token-present' means nothing will be sent (and the example will work) if you don't have logfire configured
logfire.configure(send_to_logfire='if-token-present')
logfire.instrument_pydantic_ai()
@dataclass
class Deps:
client: AsyncClient
weather_agent = Agent(
'openai:gpt-5-mini',
# 'Be concise, reply with one sentence.' is enough for some models (like openai) to use
# the below tools appropriately, but others like anthropic and gemini require a bit more direction.
instructions='Be concise, reply with one sentence.',
deps_type=Deps,
retries=2,
)
class LatLng(BaseModel):
lat: float
lng: float
@weather_agent.tool
async def get_lat_lng(ctx: RunContext[Deps], location_description: str) -> LatLng:
"""Get the latitude and longitude of a location.
Args:
ctx: The context.
location_description: A description of a location.
"""
# NOTE: the response here will be random, and is not related to the location description.
r = await ctx.deps.client.get(
'https://demo-endpoints.pydantic.workers.dev/latlng',
params={'location': location_description},
)
r.raise_for_status()
return LatLng.model_validate_json(r.content)
@weather_agent.tool
async def get_weather(ctx: RunContext[Deps], lat: float, lng: float) -> dict[str, Any]:
"""Get the weather at a location.
Args:
ctx: The context.
lat: Latitude of the location.
lng: Longitude of the location.
"""
# NOTE: the responses here will be random, and are not related to the lat and lng.
temp_response, descr_response = await asyncio.gather(
ctx.deps.client.get(
'https://demo-endpoints.pydantic.workers.dev/number',
params={'min': 10, 'max': 30},
),
ctx.deps.client.get(
'https://demo-endpoints.pydantic.workers.dev/weather',
params={'lat': lat, 'lng': lng},
),
)
temp_response.raise_for_status()
descr_response.raise_for_status()
return {
'temperature': f'{temp_response.text} °C',
'description': descr_response.text,
}
async def main():
async with AsyncClient() as client:
logfire.instrument_httpx(client, capture_all=True)
deps = Deps(client=client)
result = await weather_agent.run(
'What is the weather like in London and in Wiltshire?', deps=deps
)
print('Response:', result.output)
if __name__ == '__main__':
asyncio.run(main())
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations as _annotations
import json
from httpx import AsyncClient
from pydantic import BaseModel
from pydantic_ai import ToolCallPart, ToolReturnPart
from pydantic_ai_examples.weather_agent import Deps, weather_agent
try:
import gradio as gr
except ImportError as e:
raise ImportError(
'Please install gradio with `pip install gradio`. You must use python>=3.10.'
) from e
TOOL_TO_DISPLAY_NAME = {'get_lat_lng': 'Geocoding API', 'get_weather': 'Weather API'}
client = AsyncClient()
deps = Deps(client=client)
async def stream_from_agent(prompt: str, chatbot: list[dict], past_messages: list):
chatbot.append({'role': 'user', 'content': prompt})
yield gr.Textbox(interactive=False, value=''), chatbot, gr.skip()
async with weather_agent.run_stream(
prompt, deps=deps, message_history=past_messages
) as result:
for message in result.new_messages():
for call in message.parts:
if isinstance(call, ToolCallPart):
call_args = call.args_as_json_str()
metadata = {
'title': f'🛠️ Using {TOOL_TO_DISPLAY_NAME[call.tool_name]}',
}
if call.tool_call_id is not None:
metadata['id'] = call.tool_call_id
gr_message = {
'role': 'assistant',
'content': 'Parameters: ' + call_args,
'metadata': metadata,
}
chatbot.append(gr_message)
if isinstance(call, ToolReturnPart):
for gr_message in chatbot:
if (gr_message.get('metadata') or {}).get(
'id', ''
) == call.tool_call_id:
if isinstance(call.content, BaseModel):
json_content = call.content.model_dump_json()
else:
json_content = json.dumps(call.content)
gr_message['content'] += f'\nOutput: {json_content}'
yield gr.skip(), chatbot, gr.skip()
chatbot.append({'role': 'assistant', 'content': ''})
async for message in result.stream_text():
chatbot[-1]['content'] = message
yield gr.skip(), chatbot, gr.skip()
past_messages = result.all_messages()
yield gr.Textbox(interactive=True), gr.skip(), past_messages
async def handle_retry(chatbot, past_messages: list, retry_data: gr.RetryData):
new_history = chatbot[: retry_data.index]
previous_prompt = chatbot[retry_data.index]['content']
past_messages = past_messages[: retry_data.index]
async for update in stream_from_agent(previous_prompt, new_history, past_messages):
yield update
def undo(chatbot, past_messages: list, undo_data: gr.UndoData):
new_history = chatbot[: undo_data.index]
past_messages = past_messages[: undo_data.index]
return chatbot[undo_data.index]['content'], new_history, past_messages
def select_data(message: gr.SelectData) -> str:
return message.value['text']
with gr.Blocks() as demo:
gr.HTML(
"""
<div style="display: flex; justify-content: center; align-items: center; gap: 2rem; padding: 1rem; width: 100%">
<img src="https://ai.pydantic.dev/img/logo-white.svg" style="max-width: 200px; height: auto">
<div>
<h1 style="margin: 0 0 1rem 0">Weather Assistant</h1>
<h3 style="margin: 0 0 0.5rem 0">
This assistant answer your weather questions.
</h3>
</div>
</div>
"""
)
past_messages = gr.State([])
chatbot = gr.Chatbot(
label='Packing Assistant',
avatar_images=(None, 'https://ai.pydantic.dev/img/logo-white.svg'),
examples=[
{'text': 'What is the weather like in Miami?'},
{'text': 'What is the weather like in London?'},
],
)
with gr.Row():
prompt = gr.Textbox(
lines=1,
show_label=False,
placeholder='What is the weather like in New York City?',
)
generation = prompt.submit(
stream_from_agent,
inputs=[prompt, chatbot, past_messages],
outputs=[prompt, chatbot, past_messages],
)
chatbot.example_select(select_data, None, [prompt])
chatbot.retry(
handle_retry, [chatbot, past_messages], [prompt, chatbot, past_messages]
)
chatbot.undo(undo, [chatbot, past_messages], [prompt, chatbot, past_messages])
if __name__ == '__main__':
demo.launch()
+80
View File
@@ -0,0 +1,80 @@
[build-system]
requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "uv-dynamic-versioning"
[tool.uv-dynamic-versioning]
vcs = "git"
style = "pep440"
bump = true
[project]
name = "pydantic-ai-examples"
dynamic = ["version", "dependencies"]
description = "Examples of how to use Pydantic AI and what it can do."
authors = [
{ name = "Samuel Colvin", email = "samuel@pydantic.dev" },
{ name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" },
{ name = "David Montague", email = "david@pydantic.dev" },
{ name = "Alex Hall", email = "alex@pydantic.dev" },
{ name = "Douwe Maan", email = "douwe@pydantic.dev" },
]
license = "MIT"
readme = "README.md"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: Unix",
"Operating System :: POSIX :: Linux",
"Environment :: Console",
"Environment :: MacOS X",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
]
requires-python = ">=3.10"
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
dependencies = [
"pydantic-ai-slim[openai,google,groq,anthropic,ag-ui]=={{ version }}",
"pydantic-evals=={{ version }}",
"asyncpg>=0.31.0",
"fastapi>=0.117.0",
"logfire[asyncpg,fastapi,sqlite3,httpx]>=3.14.1",
"python-multipart>=0.0.17",
"rich>=13.9.2",
"uvicorn>=0.32.0",
"devtools>=0.12.2",
"gradio>=6.7.0",
"mcp[cli]>=1.25.0,<2.0",
"modal>=1.0.4",
"duckdb>=1.4.2",
"datasets>=4.0.0",
"pandas>=2.3.3",
"twelvelabs>=1.2.8",
# On Python 3.14, the locked numpy 2.2.6 has no cp314 wheel and would be
# source-built in CI. numpy 2.3.2+ ships cp314 wheels, so require it there.
"numpy>=2.3.2 ; python_version >= '3.14'",
]
[tool.hatch.build.targets.wheel]
packages = ["pydantic_ai_examples"]
[tool.uv.sources]
pydantic-ai-slim = { workspace = true }
[tool.ruff]
extend = "../pyproject.toml"
line-length = 88