Function Tools
Function tools provide a mechanism for models to perform actions and retrieve extra information to help them generate a response.
They're useful when you want to enable the model to take some action and use the result, when it is impractical or impossible to put all the context an agent might need into the instructions, or when you want to make agents' behavior more deterministic or reliable by deferring some of the logic required to generate a response to another (not necessarily AI-powered) tool.
If you want a model to be able to call a function as its final action, without the result being sent back to the model, you can use an output function instead.
There are a number of ways to register tools with an agent:
- via the
@agent.tool
decorator — for tools that need access to the agent context - via the
@agent.tool_plain
decorator — for tools that do not need access to the agent context - via the
tools
keyword argument toAgent
which can take either plain functions, or instances ofTool
For more advanced use cases, the toolsets feature lets you manage collections of tools (built by you or provided by an MCP server or other third party) and register them with an agent in one go via the toolsets
keyword argument to Agent
. Internally, all tools
and toolsets
are gathered into a single combined toolset that's made available to the model.
Function tools vs. RAG
Function tools are basically the "R" of RAG (Retrieval-Augmented Generation) — they augment what the model can do by letting it request extra information.
The main semantic difference between Pydantic AI Tools and RAG is RAG is synonymous with vector search, while Pydantic AI tools are more general-purpose. (Note: we may add support for vector search functionality in the future, particularly an API for generating embeddings. See #58)
Function Tools vs. Structured Outputs
As the name suggests, function tools use the model's "tools" or "functions" API to let the model know what is available to call. Tools or functions are also used to define the schema(s) for structured output when using the default tool output mode, thus a model might have access to many tools, some of which call function tools while others end the run and produce a final output.
Registering via Decorator
@agent.tool
is considered the default decorator since in the majority of cases tools will need access to the agent context.
Here's an example using both:
import random
from pydantic_ai import Agent, RunContext
agent = Agent(
'google-gla:gemini-1.5-flash', # (1)!
deps_type=str, # (2)!
system_prompt=(
"You're a dice game, you should roll the die and see if the number "
"you get back matches the user's guess. If so, tell them they're a winner. "
"Use the player's name in the response."
),
)
@agent.tool_plain # (3)!
def roll_dice() -> str:
"""Roll a six-sided die and return the result."""
return str(random.randint(1, 6))
@agent.tool # (4)!
def get_player_name(ctx: RunContext[str]) -> str:
"""Get the player's name."""
return ctx.deps
dice_result = agent.run_sync('My guess is 4', deps='Anne') # (5)!
print(dice_result.output)
#> Congratulations Anne, you guessed correctly! You're a winner!
- This is a pretty simple task, so we can use the fast and cheap Gemini flash model.
- We pass the user's name as the dependency, to keep things simple we use just the name as a string as the dependency.
- This tool doesn't need any context, it just returns a random number. You could probably use dynamic instructions in this case.
- This tool needs the player's name, so it uses
RunContext
to access dependencies which are just the player's name in this case. - Run the agent, passing the player's name as the dependency.
(This example is complete, it can be run "as is")
Let's print the messages from that game to see what happened:
from dice_game import dice_result
print(dice_result.all_messages())
"""
[
ModelRequest(
parts=[
SystemPromptPart(
content="You're a dice game, you should roll the die and see if the number you get back matches the user's guess. If so, tell them they're a winner. Use the player's name in the response.",
timestamp=datetime.datetime(...),
),
UserPromptPart(
content='My guess is 4',
timestamp=datetime.datetime(...),
),
]
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='roll_dice', args={}, tool_call_id='pyd_ai_tool_call_id'
)
],
usage=RequestUsage(input_tokens=90, output_tokens=2),
model_name='gemini-1.5-flash',
timestamp=datetime.datetime(...),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='roll_dice',
content='4',
tool_call_id='pyd_ai_tool_call_id',
timestamp=datetime.datetime(...),
)
]
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='get_player_name', args={}, tool_call_id='pyd_ai_tool_call_id'
)
],
usage=RequestUsage(input_tokens=91, output_tokens=4),
model_name='gemini-1.5-flash',
timestamp=datetime.datetime(...),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_player_name',
content='Anne',
tool_call_id='pyd_ai_tool_call_id',
timestamp=datetime.datetime(...),
)
]
),
ModelResponse(
parts=[
TextPart(
content="Congratulations Anne, you guessed correctly! You're a winner!"
)
],
usage=RequestUsage(input_tokens=92, output_tokens=12),
model_name='gemini-1.5-flash',
timestamp=datetime.datetime(...),
),
]
"""
We can represent this with a diagram:
sequenceDiagram
participant Agent
participant LLM
Note over Agent: Send prompts
Agent ->> LLM: System: "You're a dice game..."<br>User: "My guess is 4"
activate LLM
Note over LLM: LLM decides to use<br>a tool
LLM ->> Agent: Call tool<br>roll_dice()
deactivate LLM
activate Agent
Note over Agent: Rolls a six-sided die
Agent -->> LLM: ToolReturn<br>"4"
deactivate Agent
activate LLM
Note over LLM: LLM decides to use<br>another tool
LLM ->> Agent: Call tool<br>get_player_name()
deactivate LLM
activate Agent
Note over Agent: Retrieves player name
Agent -->> LLM: ToolReturn<br>"Anne"
deactivate Agent
activate LLM
Note over LLM: LLM constructs final response
LLM ->> Agent: ModelResponse<br>"Congratulations Anne, ..."
deactivate LLM
Note over Agent: Game session complete
Registering via Agent Argument
As well as using the decorators, we can register tools via the tools
argument to the Agent
constructor. This is useful when you want to reuse tools, and can also give more fine-grained control over the tools.
import random
from pydantic_ai import Agent, RunContext, Tool
system_prompt = """\
You're a dice game, you should roll the die and see if the number
you get back matches the user's guess. If so, tell them they're a winner.
Use the player's name in the response.
"""
def roll_dice() -> str:
"""Roll a six-sided die and return the result."""
return str(random.randint(1, 6))
def get_player_name(ctx: RunContext[str]) -> str:
"""Get the player's name."""
return ctx.deps
agent_a = Agent(
'google-gla:gemini-1.5-flash',
deps_type=str,
tools=[roll_dice, get_player_name], # (1)!
system_prompt=system_prompt,
)
agent_b = Agent(
'google-gla:gemini-1.5-flash',
deps_type=str,
tools=[ # (2)!
Tool(roll_dice, takes_ctx=False),
Tool(get_player_name, takes_ctx=True),
],
system_prompt=system_prompt,
)
dice_result = {}
dice_result['a'] = agent_a.run_sync('My guess is 6', deps='Yashar')
dice_result['b'] = agent_b.run_sync('My guess is 4', deps='Anne')
print(dice_result['a'].output)
#> Tough luck, Yashar, you rolled a 4. Better luck next time.
print(dice_result['b'].output)
#> Congratulations Anne, you guessed correctly! You're a winner!
- The simplest way to register tools via the
Agent
constructor is to pass a list of functions, the function signature is inspected to determine if the tool takesRunContext
. agent_a
andagent_b
are identical — but we can useTool
to reuse tool definitions and give more fine-grained control over how tools are defined, e.g. setting their name or description, or using a customprepare
method.
(This example is complete, it can be run "as is")
Tool Output
Tools can return anything that Pydantic can serialize to JSON, as well as audio, video, image or document content depending on the types of multi-modal input the model supports:
from datetime import datetime
from pydantic import BaseModel
from pydantic_ai import Agent, DocumentUrl, ImageUrl
from pydantic_ai.models.openai import OpenAIResponsesModel
class User(BaseModel):
name: str
age: int
agent = Agent(model=OpenAIResponsesModel('gpt-4o'))
@agent.tool_plain
def get_current_time() -> datetime:
return datetime.now()
@agent.tool_plain
def get_user() -> User:
return User(name='John', age=30)
@agent.tool_plain
def get_company_logo() -> ImageUrl:
return ImageUrl(url='https://iili.io/3Hs4FMg.png')
@agent.tool_plain
def get_document() -> DocumentUrl:
return DocumentUrl(url='https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf')
result = agent.run_sync('What time is it?')
print(result.output)
#> The current time is 10:45 PM on April 17, 2025.
result = agent.run_sync('What is the user name?')
print(result.output)
#> The user's name is John.
result = agent.run_sync('What is the company name in the logo?')
print(result.output)
#> The company name in the logo is "Pydantic."
result = agent.run_sync('What is the main content of the document?')
print(result.output)
#> The document contains just the text "Dummy PDF file."
(This example is complete, it can be run "as is")
Some models (e.g. Gemini) natively support semi-structured return values, while some expect text (OpenAI) but seem to be just as good at extracting meaning from the data. If a Python object is returned and the model expects a string, the value will be serialized to JSON.
Advanced Tool Returns
For scenarios where you need more control over both the tool's return value and the content sent to the model, you can use ToolReturn
. This is particularly useful when you want to:
- Provide rich multi-modal content (images, documents, etc.) to the model as context
- Separate the programmatic return value from the model's context
- Include additional metadata that shouldn't be sent to the LLM
Here's an example of a computer automation tool that captures screenshots and provides visual feedback:
import time
from pydantic_ai import Agent
from pydantic_ai.messages import ToolReturn, BinaryContent
agent = Agent('openai:gpt-4o')
@agent.tool_plain
def click_and_capture(x: int, y: int) -> ToolReturn:
"""Click at coordinates and show before/after screenshots."""
# Take screenshot before action
before_screenshot = capture_screen()
# Perform click operation
perform_click(x, y)
time.sleep(0.5) # Wait for UI to update
# Take screenshot after action
after_screenshot = capture_screen()
return ToolReturn(
return_value=f"Successfully clicked at ({x}, {y})",
content=[
f"Clicked at coordinates ({x}, {y}). Here's the comparison:",
"Before:",
BinaryContent(data=before_screenshot, media_type="image/png"),
"After:",
BinaryContent(data=after_screenshot, media_type="image/png"),
"Please analyze the changes and suggest next steps."
],
metadata={
"coordinates": {"x": x, "y": y},
"action_type": "click_and_capture",
"timestamp": time.time()
}
)
# The model receives the rich visual content for analysis
# while your application can access the structured return_value and metadata
result = agent.run_sync("Click on the submit button and tell me what happened")
print(result.output)
# The model can analyze the screenshots and provide detailed feedback
return_value
: The actual return value used in the tool response. This is what gets serialized and sent back to the model as the tool's result.content
: A sequence of content (text, images, documents, etc.) that provides additional context to the model. This appears as a separate user message.metadata
: Optional metadata that your application can access but is not sent to the LLM. Useful for logging, debugging, or additional processing. Some other AI frameworks call this feature "artifacts".
This separation allows you to provide rich context to the model while maintaining clean, structured return values for your application logic.
Tool Schema
Function parameters are extracted from the function signature, and all parameters except RunContext
are used to build the schema for that tool call.
Even better, Pydantic AI extracts the docstring from functions and (thanks to griffe) extracts parameter descriptions from the docstring and adds them to the schema.
Griffe supports extracting parameter descriptions from google
, numpy
, and sphinx
style docstrings. Pydantic AI will infer the format to use based on the docstring, but you can explicitly set it using docstring_format
. You can also enforce parameter requirements by setting require_parameter_descriptions=True
. This will raise a UserError
if a parameter description is missing.
To demonstrate a tool's schema, here we use FunctionModel
to print the schema a model would receive:
from pydantic_ai import Agent
from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart
from pydantic_ai.models.function import AgentInfo, FunctionModel
agent = Agent()
@agent.tool_plain(docstring_format='google', require_parameter_descriptions=True)
def foobar(a: int, b: str, c: dict[str, list[float]]) -> str:
"""Get me foobar.
Args:
a: apple pie
b: banana cake
c: carrot smoothie
"""
return f'{a} {b} {c}'
def print_schema(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool = info.function_tools[0]
print(tool.description)
#> Get me foobar.
print(tool.parameters_json_schema)
"""
{
'additionalProperties': False,
'properties': {
'a': {'description': 'apple pie', 'type': 'integer'},
'b': {'description': 'banana cake', 'type': 'string'},
'c': {
'additionalProperties': {'items': {'type': 'number'}, 'type': 'array'},
'description': 'carrot smoothie',
'type': 'object',
},
},
'required': ['a', 'b', 'c'],
'type': 'object',
}
"""
return ModelResponse(parts=[TextPart('foobar')])
agent.run_sync('hello', model=FunctionModel(print_schema))
(This example is complete, it can be run "as is")
If a tool has a single parameter that can be represented as an object in JSON schema (e.g. dataclass, TypedDict, pydantic model), the schema for the tool is simplified to be just that object.
Here's an example where we use TestModel.last_model_request_parameters
to inspect the tool schema that would be passed to the model.
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
agent = Agent()
class Foobar(BaseModel):
"""This is a Foobar"""
x: int
y: str
z: float = 3.14
@agent.tool_plain
def foobar(f: Foobar) -> str:
return str(f)
test_model = TestModel()
result = agent.run_sync('hello', model=test_model)
print(result.output)
#> {"foobar":"x=0 y='a' z=3.14"}
print(test_model.last_model_request_parameters.function_tools)
"""
[
ToolDefinition(
name='foobar',
parameters_json_schema={
'properties': {
'x': {'type': 'integer'},
'y': {'type': 'string'},
'z': {'default': 3.14, 'type': 'number'},
},
'required': ['x', 'y'],
'title': 'Foobar',
'type': 'object',
},
description='This is a Foobar',
)
]
"""
(This example is complete, it can be run "as is")
Custom Tool Schema
If you have a function that lacks appropriate documentation (i.e. poorly named, no type information, poor docstring, use of *args or **kwargs and suchlike) then you can still turn it into a tool that can be effectively used by the agent with the Tool.from_schema
function. With this you provide the name, description, JSON schema, and whether the function takes a RunContext
for the function directly:
from pydantic_ai import Agent, Tool
from pydantic_ai.models.test import TestModel
def foobar(**kwargs) -> str:
return kwargs['a'] + kwargs['b']
tool = Tool.from_schema(
function=foobar,
name='sum',
description='Sum two numbers.',
json_schema={
'additionalProperties': False,
'properties': {
'a': {'description': 'the first number', 'type': 'integer'},
'b': {'description': 'the second number', 'type': 'integer'},
},
'required': ['a', 'b'],
'type': 'object',
},
takes_ctx=False,
)
test_model = TestModel()
agent = Agent(test_model, tools=[tool])
result = agent.run_sync('testing...')
print(result.output)
#> {"sum":0}
Please note that validation of the tool arguments will not be performed, and this will pass all arguments as keyword arguments.
Dynamic Tools
Tools can optionally be defined with another function: prepare
, which is called at each step of a run to
customize the definition of the tool passed to the model, or omit the tool completely from that step.
A prepare
method can be registered via the prepare
kwarg to any of the tool registration mechanisms:
@agent.tool
decorator@agent.tool_plain
decoratorTool
dataclass
The prepare
method, should be of type ToolPrepareFunc
, a function which takes RunContext
and a pre-built ToolDefinition
, and should either return that ToolDefinition
with or without modifying it, return a new ToolDefinition
, or return None
to indicate this tools should not be registered for that step.
Here's a simple prepare
method that only includes the tool if the value of the dependency is 42
.
As with the previous example, we use TestModel
to demonstrate the behavior without calling a real model.
from pydantic_ai import Agent, RunContext, ToolDefinition
agent = Agent('test')
async def only_if_42(
ctx: RunContext[int], tool_def: ToolDefinition
) -> ToolDefinition | None:
if ctx.deps == 42:
return tool_def
@agent.tool(prepare=only_if_42)
def hitchhiker(ctx: RunContext[int], answer: str) -> str:
return f'{ctx.deps} {answer}'
result = agent.run_sync('testing...', deps=41)
print(result.output)
#> success (no tool calls)
result = agent.run_sync('testing...', deps=42)
print(result.output)
#> {"hitchhiker":"42 a"}
(This example is complete, it can be run "as is")
Here's a more complex example where we change the description of the name
parameter to based on the value of deps
For the sake of variation, we create this tool using the Tool
dataclass.
from __future__ import annotations
from typing import Literal
from pydantic_ai import Agent, RunContext, Tool, ToolDefinition
from pydantic_ai.models.test import TestModel
def greet(name: str) -> str:
return f'hello {name}'
async def prepare_greet(
ctx: RunContext[Literal['human', 'machine']], tool_def: ToolDefinition
) -> ToolDefinition | None:
d = f'Name of the {ctx.deps} to greet.'
tool_def.parameters_json_schema['properties']['name']['description'] = d
return tool_def
greet_tool = Tool(greet, prepare=prepare_greet)
test_model = TestModel()
agent = Agent(test_model, tools=[greet_tool], deps_type=Literal['human', 'machine'])
result = agent.run_sync('testing...', deps='human')
print(result.output)
#> {"greet":"hello a"}
print(test_model.last_model_request_parameters.function_tools)
"""
[
ToolDefinition(
name='greet',
parameters_json_schema={
'additionalProperties': False,
'properties': {
'name': {'type': 'string', 'description': 'Name of the human to greet.'}
},
'required': ['name'],
'type': 'object',
},
)
]
"""
(This example is complete, it can be run "as is")
Agent-wide Dynamic Tools
In addition to per-tool prepare
methods, you can also define an agent-wide prepare_tools
function. This function is called at each step of a run and allows you to filter or modify the list of all tool definitions available to the agent for that step. This is especially useful if you want to enable or disable multiple tools at once, or apply global logic based on the current context.
The prepare_tools
function should be of type ToolsPrepareFunc
, which takes the RunContext
and a list of ToolDefinition
, and returns a new list of tool definitions (or None
to disable all tools for that step).
Note
The list of tool definitions passed to prepare_tools
includes both regular function tools and tools from any toolsets registered on the agent, but not output tools.
To modify output tools, you can set a prepare_output_tools
function instead.
Here's an example that makes all tools strict if the model is an OpenAI model:
from dataclasses import replace
from pydantic_ai import Agent, RunContext, ToolDefinition
from pydantic_ai.models.test import TestModel
async def turn_on_strict_if_openai(
ctx: RunContext[None], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition] | None:
if ctx.model.system == 'openai':
return [replace(tool_def, strict=True) for tool_def in tool_defs]
return tool_defs
test_model = TestModel()
agent = Agent(test_model, prepare_tools=turn_on_strict_if_openai)
@agent.tool_plain
def echo(message: str) -> str:
return message
agent.run_sync('testing...')
assert test_model.last_model_request_parameters.function_tools[0].strict is None
# Set the system attribute of the test_model to 'openai'
test_model._system = 'openai'
agent.run_sync('testing with openai...')
assert test_model.last_model_request_parameters.function_tools[0].strict
(This example is complete, it can be run "as is")
Here's another example that conditionally filters out the tools by name if the dependency (ctx.deps
) is True
:
from pydantic_ai import Agent, RunContext, Tool, ToolDefinition
def launch_potato(target: str) -> str:
return f'Potato launched at {target}!'
async def filter_out_tools_by_name(
ctx: RunContext[bool], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition] | None:
if ctx.deps:
return [tool_def for tool_def in tool_defs if tool_def.name != 'launch_potato']
return tool_defs
agent = Agent(
'test',
tools=[Tool(launch_potato)],
prepare_tools=filter_out_tools_by_name,
deps_type=bool,
)
result = agent.run_sync('testing...', deps=False)
print(result.output)
#> {"launch_potato":"Potato launched at a!"}
result = agent.run_sync('testing...', deps=True)
print(result.output)
#> success (no tool calls)
(This example is complete, it can be run "as is")
You can use prepare_tools
to:
- Dynamically enable or disable tools based on the current model, dependencies, or other context
- Modify tool definitions globally (e.g., set all tools to strict mode, change descriptions, etc.)
If both per-tool prepare
and agent-wide prepare_tools
are used, the per-tool prepare
is applied first to each tool, and then prepare_tools
is called with the resulting list of tool definitions.
Deferred Tools
There are a few scenarios where the model should be able to call a tool that should not or cannot be executed during the same agent run inside the same Python process:
- it may need to be approved by the user first
- it may depend on an upstream service, frontend, or user to provide the result
- the result could take longer to generate than it's reasonable to keep the agent process running
To support these use cases, Pydantic AI provides the concept of deferred tools, which come in two flavors documented below:
- tools that require approval
- tools that are executed externally
When the model calls a deferred tool, the agent run will end with a DeferredToolRequests
output object containing information about the deferred tool calls. Once the approvals and/or results are ready, a new agent run can then be started with the original run's message history plus a DeferredToolResults
object holding results for each tool call in DeferredToolRequests
, which will continue the original run where it left off.
Note that handling deferred tool calls requires DeferredToolRequests
to be in the Agent
's output_type
so that the possible types of the agent run output are correctly inferred. If your agent can also be used in a context where no deferred tools are available and you don't want to deal with that type everywhere you use the agent, you can instead pass the output_type
argument when you run the agent using agent.run()
, agent.run_sync()
, agent.run_stream()
, or agent.iter()
. Note that the run-time output_type
overrides the one specified at construction time (for type inference reasons), so you'll need to include the original output type explicitly.
Human-in-the-Loop Tool Approval
If a tool function always requires approval, you can pass the requires_approval=True
argument to the @agent.tool
decorator, @agent.tool_plain
decorator, Tool
class, FunctionToolset.tool
decorator, or FunctionToolset.add_function()
method. Inside the function, you can then assume that the tool call has been approved.
If whether a tool function requires approval depends on the tool call arguments or the agent run context (e.g. dependencies or message history), you can raise the ApprovalRequired
exception from the tool function. The RunContext.tool_call_approved
property will be True
if the tool call has already been approved.
To require approval for calls to tools provided by a toolset (like an MCP server), see the ApprovalRequiredToolset
documentation.
When the model calls a tool that requires approval, the agent run will end with a DeferredToolRequests
output object with an approvals
list holding ToolCallPart
s containing the tool name, validated arguments, and a unique tool call ID.
Once you've gathered the user's approvals or denials, you can build a DeferredToolResults
object with an approvals
dictionary that maps each tool call ID to a boolean, a ToolApproved
object (with optional override_args
), or a ToolDenied
object (with an optional custom message
to provide to the model). This DeferredToolResults
object can then be provided to one of the agent run methods as deferred_tool_results
, alongside the original run's message history.
Here's an example that shows how to require approval for all file deletions, and for updates of specific protected files:
from pydantic_ai import (
Agent,
ApprovalRequired,
DeferredToolRequests,
DeferredToolResults,
RunContext,
ToolDenied,
)
agent = Agent('openai:gpt-5', output_type=[str, DeferredToolRequests])
PROTECTED_FILES = {'.env'}
@agent.tool
def update_file(ctx: RunContext, path: str, content: str) -> str:
if path in PROTECTED_FILES and not ctx.tool_call_approved:
raise ApprovalRequired
return f'File {path!r} updated: {content!r}'
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
return f'File {path!r} deleted'
result = agent.run_sync('Delete `__init__.py`, write `Hello, world!` to `README.md`, and clear `.env`')
messages = result.all_messages()
assert isinstance(result.output, DeferredToolRequests)
requests = result.output
print(requests)
"""
DeferredToolRequests(
calls=[],
approvals=[
ToolCallPart(
tool_name='update_file',
args={'path': '.env', 'content': ''},
tool_call_id='update_file_dotenv',
),
ToolCallPart(
tool_name='delete_file',
args={'path': '__init__.py'},
tool_call_id='delete_file',
),
],
)
"""
results = DeferredToolResults()
for call in requests.approvals:
result = False
if call.tool_name == 'update_file':
# Approve all updates
result = True
elif call.tool_name == 'delete_file':
# deny all deletes
result = ToolDenied('Deleting files is not allowed')
results.approvals[call.tool_call_id] = result
result = agent.run_sync(message_history=messages, deferred_tool_results=results)
print(result.output)
"""
I successfully deleted `__init__.py` and updated `README.md`, but was not able to delete `.env`.
"""
print(result.all_messages())
"""
[
ModelRequest(
parts=[
UserPromptPart(
content='Delete `__init__.py`, write `Hello, world!` to `README.md`, and clear `.env`',
timestamp=datetime.datetime(...),
)
]
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='delete_file',
args={'path': '__init__.py'},
tool_call_id='delete_file',
),
ToolCallPart(
tool_name='update_file',
args={'path': 'README.md', 'content': 'Hello, world!'},
tool_call_id='update_file_readme',
),
ToolCallPart(
tool_name='update_file',
args={'path': '.env', 'content': ''},
tool_call_id='update_file_dotenv',
),
],
usage=RequestUsage(input_tokens=63, output_tokens=21),
model_name='gpt-5',
timestamp=datetime.datetime(...),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='delete_file',
content='Deleting files is not allowed',
tool_call_id='delete_file',
timestamp=datetime.datetime(...),
),
ToolReturnPart(
tool_name='update_file',
content="File 'README.md' updated: 'Hello, world!'",
tool_call_id='update_file_readme',
timestamp=datetime.datetime(...),
),
ToolReturnPart(
tool_name='update_file',
content="File '.env' updated: ''",
tool_call_id='update_file_dotenv',
timestamp=datetime.datetime(...),
),
]
),
ModelResponse(
parts=[
TextPart(
content='I successfully deleted `__init__.py` and updated `README.md`, but was not able to delete `.env`.'
)
],
usage=RequestUsage(input_tokens=79, output_tokens=39),
model_name='gpt-5',
timestamp=datetime.datetime(...),
),
]
"""
(This example is complete, it can be run "as is")
External Tool Execution
When the result of a tool call cannot be generated inside the same agent run in which it was called, the tool is considered to be external. Examples of external tools are client-side tools implemented by a web or app frontend, and slow tasks that are passed off to a background worker or external service instead of keeping the agent process running.
If whether a tool call should be executed externally depends on the tool call arguments, the agent run context (e.g. dependencies or message history), or how long the task is expected to take, you can define a tool function and conditionally raise the CallDeferred
exception. Before raising the exception, the tool function would typically schedule some background task and pass along the RunContext.tool_call_id
so that the result can be matched to the deferred tool call later.
If a tool is always executed externally and its definition is provided to your code along with a JSON schema for its arguments, you can use an ExternalToolset
. If the external tools are known up front and you don't have the arguments JSON schema handy, you can also define a tool function with the appropriate signature that does nothing but raise the CallDeferred
exception.
When the model calls an external tool, the agent run will end with a DeferredToolRequests
output object with a calls
list holding ToolCallPart
s containing the tool name, validated arguments, and a unique tool call ID.
Once the tool call results are ready, you can build a DeferredToolResults
object with a calls
dictionary that maps each tool call ID to an arbitrary value to be returned to the model, a ToolReturn
object, or a ModelRetry
exception in case the tool call failed and the model should try again. This DeferredToolResults
object can then be provided to one of the agent run methods as deferred_tool_results
, alongside the original run's message history.
Here's an example that shows how to move a task that takes a while to complete to the background and return the result to the model once the task is complete:
import asyncio
from dataclasses import dataclass
from typing import Any
from pydantic_ai import (
Agent,
CallDeferred,
DeferredToolRequests,
DeferredToolResults,
ModelRetry,
RunContext,
)
@dataclass
class TaskResult:
tool_call_id: str
result: Any
async def calculate_answer_task(tool_call_id: str, question: str) -> TaskResult:
await asyncio.sleep(1)
return TaskResult(tool_call_id=tool_call_id, result=42)
agent = Agent('openai:gpt-5', output_type=[str, DeferredToolRequests])
tasks: list[asyncio.Task[TaskResult]] = []
@agent.tool
async def calculate_answer(ctx: RunContext, question: str) -> str:
assert ctx.tool_call_id is not None
task = asyncio.create_task(calculate_answer_task(ctx.tool_call_id, question)) # (1)!
tasks.append(task)
raise CallDeferred
async def main():
result = await agent.run('Calculate the answer to the ultimate question of life, the universe, and everything')
messages = result.all_messages()
assert isinstance(result.output, DeferredToolRequests)
requests = result.output
print(requests)
"""
DeferredToolRequests(
calls=[
ToolCallPart(
tool_name='calculate_answer',
args={
'question': 'the ultimate question of life, the universe, and everything'
},
tool_call_id='pyd_ai_tool_call_id',
)
],
approvals=[],
)
"""
done, _ = await asyncio.wait(tasks) # (2)!
task_results = [task.result() for task in done]
task_results_by_tool_call_id = {result.tool_call_id: result.result for result in task_results}
results = DeferredToolResults()
for call in requests.calls:
try:
result = task_results_by_tool_call_id[call.tool_call_id]
except KeyError:
result = ModelRetry('No result for this tool call was found.')
results.calls[call.tool_call_id] = result
result = await agent.run(message_history=messages, deferred_tool_results=results)
print(result.output)
#> The answer to the ultimate question of life, the universe, and everything is 42.
print(result.all_messages())
"""
[
ModelRequest(
parts=[
UserPromptPart(
content='Calculate the answer to the ultimate question of life, the universe, and everything',
timestamp=datetime.datetime(...),
)
]
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='calculate_answer',
args={
'question': 'the ultimate question of life, the universe, and everything'
},
tool_call_id='pyd_ai_tool_call_id',
)
],
usage=RequestUsage(input_tokens=63, output_tokens=13),
model_name='gpt-5',
timestamp=datetime.datetime(...),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='calculate_answer',
content=42,
tool_call_id='pyd_ai_tool_call_id',
timestamp=datetime.datetime(...),
)
]
),
ModelResponse(
parts=[
TextPart(
content='The answer to the ultimate question of life, the universe, and everything is 42.'
)
],
usage=RequestUsage(input_tokens=64, output_tokens=28),
model_name='gpt-5',
timestamp=datetime.datetime(...),
),
]
"""
- In reality, you'd likely use Celery or a similar task queue to run the task in the background.
- In reality, this would typically happen in a separate process that polls for the task status or is notified when all pending tasks are complete.
(This example is complete, it can be run "as is" — you'll need to add asyncio.run(main())
to run main
)
Tool Execution and Retries
When a tool is executed, its arguments (provided by the LLM) are first validated against the function's signature using Pydantic. If validation fails (e.g., due to incorrect types or missing required arguments), a ValidationError
is raised, and the framework automatically generates a RetryPromptPart
containing the validation details. This prompt is sent back to the LLM, informing it of the error and allowing it to correct the parameters and retry the tool call.
Beyond automatic validation errors, the tool's own internal logic can also explicitly request a retry by raising the ModelRetry
exception. This is useful for situations where the parameters were technically valid, but an issue occurred during execution (like a transient network error, or the tool determining the initial attempt needs modification).
from pydantic_ai import ModelRetry
def my_flaky_tool(query: str) -> str:
if query == 'bad':
# Tell the LLM the query was bad and it should try again
raise ModelRetry("The query 'bad' is not allowed. Please provide a different query.")
# ... process query ...
return 'Success!'
Raising ModelRetry
also generates a RetryPromptPart
containing the exception message, which is sent back to the LLM to guide its next attempt. Both ValidationError
and ModelRetry
respect the retries
setting configured on the Tool
or Agent
.
Parallel tool calls & concurrency
When a model returns multiple tool calls in one response, Pydantic AI schedules them concurrently using asyncio.create_task
.
Async functions are run on the event loop, while sync functions are offloaded to threads. To get the best performance, always use an async function unless you're doing blocking I/O (and there's no way to use a non-blocking library instead) or CPU-bound work (like numpy
or scikit-learn
operations), so that simple functions are not offloaded to threads unnecessarily.
Third-Party Tools
MCP Tools
See the MCP Client documentation for how to use MCP servers with Pydantic AI as toolsets.
LangChain Tools
If you'd like to use a tool from LangChain's community tool library with Pydantic AI, you can use the tool_from_langchain
convenience method. Note that Pydantic AI will not validate the arguments in this case -- it's up to the model to provide arguments matching the schema specified by the LangChain tool, and up to the LangChain tool to raise an error if the arguments are invalid.
You will need to install the langchain-community
package and any others required by the tool in question.
Here is how you can use the LangChain DuckDuckGoSearchRun
tool, which requires the ddgs
package:
from langchain_community.tools import DuckDuckGoSearchRun
from pydantic_ai import Agent
from pydantic_ai.ext.langchain import tool_from_langchain
search = DuckDuckGoSearchRun()
search_tool = tool_from_langchain(search)
agent = Agent(
'google-gla:gemini-2.0-flash',
tools=[search_tool],
)
result = agent.run_sync('What is the release date of Elden Ring Nightreign?') # (1)!
print(result.output)
#> Elden Ring Nightreign is planned to be released on May 30, 2025.
- The release date of this game is the 30th of May 2025, which is after the knowledge cutoff for Gemini 2.0 (August 2024).
If you'd like to use multiple LangChain tools or a LangChain toolkit, you can use the LangChainToolset
toolset which takes a list of LangChain tools:
from langchain_community.agent_toolkits import SlackToolkit
from pydantic_ai import Agent
from pydantic_ai.ext.langchain import LangChainToolset
toolkit = SlackToolkit()
toolset = LangChainToolset(toolkit.get_tools())
agent = Agent('openai:gpt-4o', toolsets=[toolset])
# ...
ACI.dev Tools
If you'd like to use a tool from the ACI.dev tool library with Pydantic AI, you can use the tool_from_aci
convenience method. Note that Pydantic AI will not validate the arguments in this case -- it's up to the model to provide arguments matching the schema specified by the ACI tool, and up to the ACI tool to raise an error if the arguments are invalid.
You will need to install the aci-sdk
package, set your ACI API key in the ACI_API_KEY
environment variable, and pass your ACI "linked account owner ID" to the function.
Here is how you can use the ACI.dev TAVILY__SEARCH
tool:
import os
from pydantic_ai import Agent
from pydantic_ai.ext.aci import tool_from_aci
tavily_search = tool_from_aci(
'TAVILY__SEARCH',
linked_account_owner_id=os.getenv('LINKED_ACCOUNT_OWNER_ID'),
)
agent = Agent(
'google-gla:gemini-2.0-flash',
tools=[tavily_search],
)
result = agent.run_sync('What is the release date of Elden Ring Nightreign?') # (1)!
print(result.output)
#> Elden Ring Nightreign is planned to be released on May 30, 2025.
- The release date of this game is the 30th of May 2025, which is after the knowledge cutoff for Gemini 2.0 (August 2024).
If you'd like to use multiple ACI.dev tools, you can use the ACIToolset
toolset which takes a list of ACI tool names as well as the linked_account_owner_id
:
import os
from pydantic_ai import Agent
from pydantic_ai.ext.aci import ACIToolset
toolset = ACIToolset(
[
'OPEN_WEATHER_MAP__CURRENT_WEATHER',
'OPEN_WEATHER_MAP__FORECAST',
],
linked_account_owner_id=os.getenv('LINKED_ACCOUNT_OWNER_ID'),
)
agent = Agent('openai:gpt-4o', toolsets=[toolset])