Function Tools
Function tools provide a mechanism for models to retrieve extra information to help them generate a response.
They're useful when it is impractical or impossible to put all the context an agent might need into the system prompt, 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.
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 PydanticAI Tools and RAG is RAG is synonymous with vector search, while PydanticAI 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)
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
@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(
'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_die() -> 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.data)
#> 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 a dynamic system prompt 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.",
part_kind='system-prompt',
),
UserPromptPart(
content='My guess is 4',
timestamp=datetime.datetime(...),
part_kind='user-prompt',
),
],
kind='request',
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='roll_die',
args=ArgsDict(args_dict={}),
tool_call_id=None,
part_kind='tool-call',
)
],
timestamp=datetime.datetime(...),
kind='response',
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='roll_die',
content='4',
tool_call_id=None,
timestamp=datetime.datetime(...),
part_kind='tool-return',
)
],
kind='request',
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='get_player_name',
args=ArgsDict(args_dict={}),
tool_call_id=None,
part_kind='tool-call',
)
],
timestamp=datetime.datetime(...),
kind='response',
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_player_name',
content='Anne',
tool_call_id=None,
timestamp=datetime.datetime(...),
part_kind='tool-return',
)
],
kind='request',
),
ModelResponse(
parts=[
TextPart(
content="Congratulations Anne, you guessed correctly! You're a winner!",
part_kind='text',
)
],
timestamp=datetime.datetime(...),
kind='response',
),
]
"""
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_die()
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 Function Tools via kwarg
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 re-use tools, and can also give more fine-grained control over the tools.
import random
from pydantic_ai import Agent, RunContext, Tool
def roll_die() -> 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(
'gemini-1.5-flash',
deps_type=str,
tools=[roll_die, get_player_name], # (1)!
)
agent_b = Agent(
'gemini-1.5-flash',
deps_type=str,
tools=[ # (2)!
Tool(roll_die, takes_ctx=False),
Tool(get_player_name, takes_ctx=True),
],
)
dice_result = agent_b.run_sync('My guess is 4', deps='Anne')
print(dice_result.data)
#> 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")
Function Tools vs. Structured Results
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 responses, thus a model might have access to many tools, some of which call function tools while others end the run and return a result.
Function tools and 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, PydanticAI 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, and PydanticAI will infer the format to use based on the docstring. We plan to add support in the future to explicitly set the style to use, and warn/error if not all parameters are documented; see #59.
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
from pydantic_ai.models.function import AgentInfo, FunctionModel
agent = Agent()
@agent.tool_plain
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)
"""
{
'properties': {
'a': {'description': 'apple pie', 'title': 'A', 'type': 'integer'},
'b': {'description': 'banana cake', 'title': 'B', 'type': 'string'},
'c': {
'additionalProperties': {'items': {'type': 'number'}, 'type': 'array'},
'description': 'carrot smoothie',
'title': 'C',
'type': 'object',
},
},
'required': ['a', 'b', 'c'],
'type': 'object',
'additionalProperties': False,
}
"""
return ModelResponse.from_text(content='foobar')
agent.run_sync('hello', model=FunctionModel(print_schema))
(This example is complete, it can be run "as is")
The return type of tool can be anything which Pydantic can serialize to JSON as some models (e.g. Gemini) support semi-structured return values, 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.
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, we use TestModel.agent_model_function_tools
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.data)
#> {"foobar":"x=0 y='a' z=3.14"}
print(test_model.agent_model_function_tools)
"""
[
ToolDefinition(
name='foobar',
description='This is a Foobar',
parameters_json_schema={
'properties': {
'x': {'title': 'X', 'type': 'integer'},
'y': {'title': 'Y', 'type': 'string'},
'z': {'default': 3.14, 'title': 'Z', 'type': 'number'},
},
'required': ['x', 'y'],
'title': 'Foobar',
'type': 'object',
},
outer_typed_dict_key=None,
)
]
"""
(This example is complete, it can be run "as is")
Dynamic Function 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 typing import Union
from pydantic_ai import Agent, RunContext
from pydantic_ai.tools import ToolDefinition
agent = Agent('test')
async def only_if_42(
ctx: RunContext[int], tool_def: ToolDefinition
) -> Union[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.data)
#> success (no tool calls)
result = agent.run_sync('testing...', deps=42)
print(result.data)
#> {"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
from pydantic_ai.models.test import TestModel
from pydantic_ai.tools import Tool, ToolDefinition
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.data)
#> {"greet":"hello a"}
print(test_model.agent_model_function_tools)
"""
[
ToolDefinition(
name='greet',
description='',
parameters_json_schema={
'properties': {
'name': {
'title': 'Name',
'type': 'string',
'description': 'Name of the human to greet.',
}
},
'required': ['name'],
'type': 'object',
'additionalProperties': False,
},
outer_typed_dict_key=None,
)
]
"""
(This example is complete, it can be run "as is")