PydanticAI: Toolsets
Toolsets
Section titled “Toolsets”Verified against pydantic-ai==1.103.0 — source modules: pydantic_ai.toolsets.*.
A toolset is a reusable, named collection of tools with a shared policy (retries, timeout, metadata, instructions). PydanticAI ships 10+ toolset wrappers that let you filter, rename, combine, gate, or lazy-load tools without rewriting the functions. They’re the supported way to attach non-code tool sources — MCP servers, remote APIs, human approval — to an agent.
Minimal runnable example
Section titled “Minimal runnable example”from pydantic_ai import Agent, FunctionToolset, RunContext
tools = FunctionToolset[int]() # generic deps type
@tools.tooldef multiply(ctx: RunContext[int], x: int) -> int: return ctx.deps * x
agent = Agent('openai:gpt-5.2', deps_type=int, toolsets=[tools])print(agent.run_sync('Multiply my deps by 3', deps=7).output)#> 21toolsets=[...] lives next to tools=[...]. Tools registered via @agent.tool / @agent.tool_plain are included automatically; toolsets=[...] adds extra toolsets on top of those.
The toolset catalogue
Section titled “The toolset catalogue”All of these live in pydantic_ai.toolsets and are exported from pydantic_ai directly.
| Toolset | Role |
|---|---|
FunctionToolset | Wraps Python callables as tools. The primitive building block. |
CombinedToolset | Merges several toolsets into one (preserves ordering). |
PrefixedToolset | Prepends a string to every tool name. Avoids collisions when combining. |
RenamedToolset | Per-tool rename map. |
FilteredToolset | Drops tools via a (ctx, tool_def) -> bool predicate, evaluated per run step. |
PreparedToolset | Runs a (ctx, defs) -> defs hook per step to mutate tool definitions. |
ApprovalRequiredToolset | Wraps a toolset so some/all calls raise ApprovalRequired until approved. |
DeferredLoadingToolset | Hides tools until discovered via tool search. |
ExternalToolset | Declares tool schemas whose execution happens outside the agent (deferred). |
IncludeReturnSchemasToolset | Sets include_return_schema=True on every wrapped tool. |
SetMetadataToolset | Merges metadata onto every wrapped tool. |
WrapperToolset / AbstractToolset | Base classes for custom toolsets. |
MCPServer* (in pydantic_ai.mcp) | Toolsets backed by MCP stdio/SSE/HTTP. |
FunctionToolset — the primitive
Section titled “FunctionToolset — the primitive”toolsets/function.py:44. Constructor args (verified at :60):
| Arg | Default | Notes |
|---|---|---|
tools | [] | `Sequence[Tool |
max_retries | 1 | Per-tool retry budget. |
timeout | None | Seconds per tool call (per-tool override available). |
docstring_format | 'auto' | `‘google' |
require_parameter_descriptions | False | If True, missing param doc raises at registration. |
schema_generator | GenerateToolJsonSchema | Override Pydantic JSON-schema generator. |
strict | None | Forward strict hint to OpenAI. |
sequential | False | Tools in this set must run serially. |
requires_approval | False | All tools require HITL approval. |
metadata | None | Merged into each tool’s metadata. |
defer_loading | False | Hide from model until tool search surfaces them. |
include_return_schema | None | Include tool return schemas in definitions. |
id | None | Required when using under durable execution (Temporal). |
instructions | None | Auto-injected instruction string(s) when any tool is active. |
Register tools three ways:
tools = FunctionToolset[None]()
@tools.tooldef ping(ctx: RunContext[None]) -> str: # decorator with ctx return 'pong'
@tools.tool_plain # no RunContext neededdef square(x: int) -> int: return x * x
tools.add_function(lambda x: x + 1, name='inc') # programmatic addComposition examples
Section titled “Composition examples”CombinedToolset — layering
Section titled “CombinedToolset — layering”from pydantic_ai import CombinedToolset, FunctionToolset
core = FunctionToolset([...])extras = FunctionToolset([...])combined = CombinedToolset([core, extras])agent = Agent('openai:gpt-5.2', toolsets=[combined])Tool-name collisions raise at construction time; PrefixedToolset solves that.
PrefixedToolset — namespaces
Section titled “PrefixedToolset — namespaces”from pydantic_ai import PrefixedToolset
agent = Agent('openai:gpt-5.2', toolsets=[ PrefixedToolset(db_tools, prefix='db_'), PrefixedToolset(kb_tools, prefix='kb_'),])# model sees: db_search, db_write, kb_search, ...RenamedToolset — per-tool rename
Section titled “RenamedToolset — per-tool rename”from pydantic_ai import RenamedToolset
renamed = RenamedToolset(tools, name_map={'lookup': 'find_customer'})FilteredToolset — conditional visibility
Section titled “FilteredToolset — conditional visibility”from pydantic_ai import FilteredToolset
def visible(ctx, tool_def): # only expose write tools to admins return tool_def.metadata.get('scope') != 'write' or ctx.deps.user.is_admin
agent = Agent('openai:gpt-5.2', deps_type=Deps, toolsets=[FilteredToolset(tools, filter_func=visible)])Evaluated every step — you can hide a tool once a certain state is reached.
PreparedToolset — mutate definitions on the fly
Section titled “PreparedToolset — mutate definitions on the fly”from pydantic_ai import PreparedToolsetfrom pydantic_ai.tools import ToolDefinition
async def strict_openai(ctx, defs: list[ToolDefinition]) -> list[ToolDefinition]: return [d._replace(strict=True) for d in defs]
prep = PreparedToolset(tools, prepare_func=strict_openai)Use cases: toggling strict, swapping descriptions per locale, overriding schemas in a migration.
ApprovalRequiredToolset — human-in-the-loop
Section titled “ApprovalRequiredToolset — human-in-the-loop”from pydantic_ai import ApprovalRequiredToolset, DeferredToolRequests, DeferredToolResults, ToolApproved
def needs_approval(ctx, tool_def, args) -> bool: return tool_def.name.startswith('delete_')
agent = Agent( 'openai:gpt-5.2', output_type=[str, DeferredToolRequests], toolsets=[ApprovalRequiredToolset(write_tools, approval_required_func=needs_approval)],)
result1 = agent.run_sync('Delete old records.')if isinstance(result1.output, DeferredToolRequests): # Show result1.output.approvals to the user ... approvals = {call.tool_call_id: ToolApproved() for call in result1.output.approvals} result2 = agent.run_sync( message_history=result1.all_messages(), deferred_tool_results=DeferredToolResults(approvals=approvals), )approval_required_func defaults to lambda ctx, tool_def, args: True — every call requires approval. Return False to skip approval. On approval, the original tool runs; rejection sends ToolDenied(message=...) back to the model.
DeferredLoadingToolset — tool search integration
Section titled “DeferredLoadingToolset — tool search integration”from pydantic_ai import DeferredLoadingToolset
big_library = FunctionToolset([...])hidden = DeferredLoadingToolset(big_library) # all tools hiddenagent = Agent('openai:gpt-5.2', toolsets=[hidden])Combined with the built-in tool search capability (pydantic_ai.capabilities.ToolSearch), only tools the model asks for via search get surfaced — saves tokens on large libraries.
ExternalToolset — execute outside the agent
Section titled “ExternalToolset — execute outside the agent”from pydantic_ai import ExternalToolsetfrom pydantic_ai.tools import ToolDefinition
external = ExternalToolset([ ToolDefinition( name='slack_post', description='Post to a Slack channel.', parameters_json_schema={'type': 'object', 'properties': {'channel': {'type': 'string'}, 'text': {'type': 'string'}}, 'required': ['channel', 'text']}, ),])
agent = Agent('openai:gpt-5.2', output_type=[str, DeferredToolRequests], toolsets=[external])
result = agent.run_sync('Announce the release to #eng.')if isinstance(result.output, DeferredToolRequests): for call in result.output.calls: # hand to your backend worker worker.enqueue(call.tool_name, call.args)When all external calls complete you feed results back with DeferredToolResults(calls={tool_call_id: ToolReturn(...)}).
IncludeReturnSchemasToolset — inject return schemas
Section titled “IncludeReturnSchemasToolset — inject return schemas”Forces every tool’s return schema into the definition sent to the model. Useful for providers that use return type hints to guide structured tool usage:
from pydantic_ai import Agent, IncludeReturnSchemasToolset, FunctionToolsetfrom pydantic_ai.tools import RunContextfrom pydantic import BaseModel
class Product(BaseModel): id: int name: str price: float
tools = FunctionToolset[None]()
@tools.tool_plaindef get_product(product_id: int) -> Product: """Retrieve a product by ID.""" return Product(id=product_id, name='Widget', price=9.99)
# OpenAI and Google models can use the Product schema as a hintagent = Agent('openai:gpt-4o', toolsets=[IncludeReturnSchemasToolset(tools)])SetMetadataToolset — bulk-tag tools
Section titled “SetMetadataToolset — bulk-tag tools”Merges a metadata dictionary onto every tool in the wrapped toolset. Combine with FilteredToolset to create dynamic access control:
from pydantic_ai import ( Agent, FunctionToolset, SetMetadataToolset, FilteredToolset, CombinedToolset)from dataclasses import dataclass
@dataclassclass UserDeps: role: str # 'admin' | 'reader'
# Two toolsets, tagged with their access levelread_tools = FunctionToolset[UserDeps]()write_tools = FunctionToolset[UserDeps]()
@read_tools.tool_plaindef list_records() -> list[str]: return ['record_1', 'record_2']
@write_tools.tool_plaindef delete_record(record_id: str) -> str: return f'Deleted {record_id}'
# Tag all write tools as requiring admin accesstagged_write = SetMetadataToolset(write_tools, metadata={'requires_role': 'admin'})
# Filter based on user's role at runtimedef role_filter(ctx, tool_def) -> bool: required = tool_def.metadata and tool_def.metadata.get('requires_role') if required is None: return True return ctx.deps.role == required
agent = Agent( 'openai:gpt-4o', deps_type=UserDeps, toolsets=[ read_tools, FilteredToolset(tagged_write, filter_func=role_filter), ],)
# Admin sees all tools; reader only sees read toolsresult_admin = agent.run_sync('Delete record_1', deps=UserDeps(role='admin'))result_reader = agent.run_sync('Delete record_1', deps=UserDeps(role='reader'))Instructions that follow a toolset
Section titled “Instructions that follow a toolset”tools = FunctionToolset( [...], instructions='When using DB tools, prefer read-only unless the user explicitly asks to write.',)The string is automatically appended to the model’s instructions when any tool in this set is active. You can also pass a callable (ctx) -> str or an async one.
Using an agent as a toolset
Section titled “Using an agent as a toolset”from pydantic_ai import Agent
sub = Agent('openai:gpt-5.2-mini', name='citations')
@sub.tool_plaindef lookup_citation(key: str) -> str: ...
parent = Agent('openai:gpt-5.2', toolsets=[sub.toolset])Every Agent exposes a .toolset (an internal FunctionToolset) for reuse.
Building custom toolsets with WrapperToolset and AbstractToolset
Section titled “Building custom toolsets with WrapperToolset and AbstractToolset”WrapperToolset — decorate an existing toolset
Section titled “WrapperToolset — decorate an existing toolset”WrapperToolset wraps another toolset and delegates all calls. Override get_tools or call_tool to add cross-cutting behaviour without rebuilding from scratch:
from dataclasses import dataclassfrom typing import Anyfrom pydantic_ai import Agent, FunctionToolsetfrom pydantic_ai.toolsets.wrapper import WrapperToolsetfrom pydantic_ai.tools import RunContext, ToolDefinitionfrom pydantic_ai.toolsets.abstract import ToolsetToolimport time
@dataclassclass TimedToolset(WrapperToolset): """A toolset that logs execution time for every tool call."""
async def call_tool( self, name: str, tool_args: dict[str, Any], ctx: RunContext, tool: ToolsetTool, ) -> Any: t0 = time.perf_counter() try: result = await super().call_tool(name, tool_args, ctx, tool) elapsed = time.perf_counter() - t0 print(f'[{name}] completed in {elapsed:.3f}s → {result!r}') return result except Exception as e: elapsed = time.perf_counter() - t0 print(f'[{name}] failed in {elapsed:.3f}s: {e}') raise
# Wrap any existing toolsetbase_tools = FunctionToolset[None]()
@base_tools.tool_plaindef slow_operation(n: int) -> int: import time; time.sleep(0.1) return n * 2
agent = Agent('openai:gpt-4o', toolsets=[TimedToolset(wrapped=base_tools)])AbstractToolset — build from scratch
Section titled “AbstractToolset — build from scratch”Implement AbstractToolset when you need full control over tool definitions and execution — for example, wrapping a database schema or a remote API registry:
from abc import ABCfrom dataclasses import dataclassfrom typing import Anyimport jsonfrom pydantic_core import SchemaValidator, core_schemafrom pydantic_ai.toolsets.abstract import AbstractToolset, ToolsetToolfrom pydantic_ai.tools import RunContext, ToolDefinition
@dataclassclass DatabaseToolset(AbstractToolset): """Dynamically exposes SQL tables as tools at runtime."""
db_url: str _tables: dict[str, dict] | None = None
@property def id(self) -> str | None: return f'db:{self.db_url}'
async def __aenter__(self): # Connect to DB and introspect schema self._tables = await self._introspect_schema() return self
async def __aexit__(self, *args): self._tables = None
async def _introspect_schema(self) -> dict[str, dict]: # Returns {'users': {'id': 'int', 'name': 'str'}, ...} return {'users': {'id': 'integer', 'name': 'text'}}
async def get_tools(self, ctx: RunContext) -> dict[str, ToolsetTool]: tables = self._tables or {} result = {} for table, columns in tables.items(): props = {col: {'type': 'string', 'description': f'{dtype} column'} for col, dtype in columns.items()} tool_def = ToolDefinition( name=f'query_{table}', description=f'Query the {table} table.', parameters_json_schema={ 'type': 'object', 'properties': {'filter': {'type': 'string', 'description': 'SQL WHERE clause'}}, 'required': [], }, ) validator = SchemaValidator(core_schema.dict_schema()) result[tool_def.name] = ToolsetTool( toolset=self, tool_def=tool_def, max_retries=1, args_validator=validator, ) return result
async def call_tool( self, name: str, tool_args: dict[str, Any], ctx: RunContext, tool: ToolsetTool, ) -> Any: table = name.removeprefix('query_') where = tool_args.get('filter', '1=1') # Execute: SELECT * FROM {table} WHERE {where} return [{'id': 1, 'name': 'Alice'}] # placeholder
agent = Agent('openai:gpt-4o', toolsets=[DatabaseToolset(db_url='postgresql://...')])async with agent: result = await agent.run('List all users')Dynamic toolsets — @agent.toolset
Section titled “Dynamic toolsets — @agent.toolset”agent/__init__.py:2237. Register a factory that builds a toolset per run based on RunContext:
@agent.toolsetasync def per_tenant(ctx: RunContext[TenantDeps]) -> AbstractToolset[TenantDeps]: return FunctionToolset([load_tools_for(ctx.deps.tenant_id)])PreparedToolset — advanced patterns
Section titled “PreparedToolset — advanced patterns”Internationalization: per-locale tool descriptions
Section titled “Internationalization: per-locale tool descriptions”import asyncioimport dataclassesfrom dataclasses import dataclassfrom pydantic_ai import Agent, FunctionToolset, PreparedToolset, RunContextfrom pydantic_ai.tools import ToolDefinition
DESCRIPTIONS = { 'en': { 'search_products': 'Search the product catalogue.', 'get_order': 'Retrieve an order by ID.', }, 'es': { 'search_products': 'Buscar en el catálogo de productos.', 'get_order': 'Recuperar un pedido por ID.', }, 'ja': { 'search_products': '商品カタログを検索します。', 'get_order': 'IDで注文を取得します。', },}
@dataclassclass UserDeps: locale: str = 'en'
tools = FunctionToolset[UserDeps]()
@tools.tool_plaindef search_products(query: str) -> list[str]: return [f'Product: {query}']
@tools.tool_plaindef get_order(order_id: str) -> dict: return {'id': order_id, 'status': 'shipped'}
def localise_descriptions(ctx: RunContext[UserDeps], defs: list[ToolDefinition]) -> list[ToolDefinition]: locale_map = DESCRIPTIONS.get(ctx.deps.locale, DESCRIPTIONS['en']) return [ dataclasses.replace(d, description=locale_map.get(d.name, d.description)) for d in defs ]
agent = Agent( 'openai:gpt-4o', deps_type=UserDeps, toolsets=[PreparedToolset(tools, prepare_func=localise_descriptions)],)
async def main(): result = await agent.run('¿Puedes buscar laptops?', deps=UserDeps(locale='es')) print(result.output)
asyncio.run(main())Progressively restrict tools as workflow advances
Section titled “Progressively restrict tools as workflow advances”import asynciofrom dataclasses import dataclassfrom pydantic_ai import Agent, FunctionToolset, PreparedToolset, RunContextfrom pydantic_ai.tools import ToolDefinition
@dataclassclass WorkflowDeps: phase: str = 'init' # 'init' → 'validated' → 'committed'
tools = FunctionToolset[WorkflowDeps]()
@tools.tooldef validate_data(ctx: RunContext[WorkflowDeps], data: str) -> str: ctx.deps.phase = 'validated' return f'Validated: {data}'
@tools.tooldef commit_transaction(ctx: RunContext[WorkflowDeps], transaction_id: str) -> str: ctx.deps.phase = 'committed' return f'Committed: {transaction_id}'
@tools.tooldef rollback(ctx: RunContext[WorkflowDeps], reason: str) -> str: ctx.deps.phase = 'init' return f'Rolled back: {reason}'
# Phase-gated tool visibilityPHASE_TOOLS: dict[str, set[str]] = { 'init': {'validate_data'}, 'validated': {'commit_transaction', 'rollback'}, 'committed': set(), # no tools after commit}
def phase_filter(ctx: RunContext[WorkflowDeps], defs: list[ToolDefinition]) -> list[ToolDefinition]: allowed = PHASE_TOOLS.get(ctx.deps.phase, set()) return [d for d in defs if d.name in allowed]
agent = Agent( 'openai:gpt-4o', deps_type=WorkflowDeps, toolsets=[PreparedToolset(tools, prepare_func=phase_filter)],)
async def main(): deps = WorkflowDeps(phase='init') result = await agent.run('Process data "order_42" and commit if valid.', deps=deps) print(f'Final phase: {deps.phase}') print(result.output)
asyncio.run(main())DeferredLoadingToolset — advanced patterns
Section titled “DeferredLoadingToolset — advanced patterns”Gradual tool discovery with tool search
Section titled “Gradual tool discovery with tool search”When using DeferredLoadingToolset with the ToolSearch capability, the model discovers tools through search rather than seeing them all upfront. This is especially powerful for agents with 50+ tools:
import asynciofrom pydantic_ai import Agent, FunctionToolset, DeferredLoadingToolset
# Large library with many specialised toolsanalytics_tools = FunctionToolset[None]()
@analytics_tools.tool_plaindef cohort_analysis(cohort_id: str, metric: str) -> dict: """Run a cohort analysis for the given metric.""" return {'cohort': cohort_id, 'metric': metric, 'value': 42.5}
@analytics_tools.tool_plaindef funnel_report(funnel_name: str, date_range: str) -> dict: """Generate a conversion funnel report.""" return {'funnel': funnel_name, 'conversion_rate': 0.23}
@analytics_tools.tool_plaindef retention_curve(product_id: str, cohort_weeks: int) -> list[float]: """Compute a retention curve for a product cohort.""" return [1.0, 0.8, 0.65, 0.55, 0.48]
@analytics_tools.tool_plaindef ab_test_significance(test_id: str) -> dict: """Calculate statistical significance for an A/B test.""" return {'test_id': test_id, 'p_value': 0.03, 'significant': True}
# Defer ALL analytics tools — the model must search for themdeferred = DeferredLoadingToolset(analytics_tools)agent = Agent('openai:gpt-4o', toolsets=[deferred])
async def main(): # Without deferred loading, all 4 tools appear in every prompt. # With deferred loading, only tools the model searches for are loaded. result = await agent.run('Is A/B test "checkout_v2" statistically significant?') print(result.output)
asyncio.run(main())Mixing deferred and always-visible tools
Section titled “Mixing deferred and always-visible tools”Expose lightweight utility tools immediately; defer heavy/specialised ones:
from pydantic_ai import Agent, FunctionToolset, DeferredLoadingToolset
# Always-visible: fast, cheap, universally neededquick_tools = FunctionToolset[None]()
@quick_tools.tool_plaindef get_current_date() -> str: from datetime import date return date.today().isoformat()
@quick_tools.tool_plaindef format_number(n: float, decimals: int = 2) -> str: return f'{n:,.{decimals}f}'
# Deferred: expensive or rarely neededheavy_tools = FunctionToolset[None]()
@heavy_tools.tool_plaindef run_ml_model(model_name: str, input_data: dict) -> dict: """Run inference on a large ML model.""" return {'prediction': 0.87}
@heavy_tools.tool_plaindef generate_report(report_type: str, parameters: dict) -> str: """Generate a complex analytical report.""" return f'{report_type} report generated'
agent = Agent( 'openai:gpt-4o', toolsets=[ quick_tools, # always visible DeferredLoadingToolset(heavy_tools), # discovered on demand ],)Gotchas
Section titled “Gotchas”- Enter before use: toolsets may hold resources (processes, HTTP clients, MCP sessions). Using an agent as an async context manager (
async with agent: ...) enters every toolset. - Naming collisions:
CombinedToolsetraises if two toolsets expose the same tool name. Wrap withPrefixedToolsetorRenamedToolsetto disambiguate. requires_approval=TruewithoutDeferredToolRequestsinoutput_typeraises at runtime. Always addDeferredToolRequeststo the output union.ExternalToolset+ streaming: external deferrals terminate the stream early. HandleDeferredToolRequestsas a normal output value.- Durable execution: every toolset must have an
idwhen running under Temporal/Prefect/DBOS so activities can be routed. PreparedToolsetconstraint: the prepare function cannot add or rename tools. Reducing or modifying definitions is fine; useRenamedToolsetfor renaming andFunctionToolset.add_function()for additions.DeferredLoadingToolset+ non-search agent: if the agent doesn’t have aToolSearchcapability, deferred tools are simply never offered to the model. Make sureToolSearchor the built-in tool search is active.
Patterns
Section titled “Patterns”1. Tenant-scoped toolset with filtering
Section titled “1. Tenant-scoped toolset with filtering”def own_tenant(ctx, tool_def): return tool_def.metadata.get('tenant') == ctx.deps.tenant_idagent = Agent(..., toolsets=[FilteredToolset(all_tools, filter_func=own_tenant)])2. Write-operations behind HITL
Section titled “2. Write-operations behind HITL”ApprovalRequiredToolset(write_tools, approval_required_func=lambda ctx, d, a: d.metadata.get('destructive', False))3. MCP server alongside local tools
Section titled “3. MCP server alongside local tools”from pydantic_ai.mcp import MCPServerStdio
server = MCPServerStdio('uv', args=['run', 'mcp-run-python', 'stdio'])agent = Agent('openai:gpt-5.2', toolsets=[local_tools, PrefixedToolset(server, prefix='mcp_')])async with agent: result = await agent.run('run this python snippet safely')4. Progressive disclosure with DeferredLoadingToolset
Section titled “4. Progressive disclosure with DeferredLoadingToolset”deep_library = FunctionToolset([...]) # 120 toolsagent = Agent('openai:gpt-5.2', toolsets=[DeferredLoadingToolset(deep_library)])Combined with ToolSearch capability, only searched tools appear in the step.
5. External tool execution dispatched to a queue
Section titled “5. External tool execution dispatched to a queue”external = ExternalToolset([ToolDefinition(...)])agent = Agent(..., output_type=[str, DeferredToolRequests], toolsets=[external])result = agent.run_sync(prompt)if isinstance(result.output, DeferredToolRequests): for call in result.output.calls: queue.push({'id': call.tool_call_id, 'name': call.tool_name, 'args': call.args})6. Full-featured multi-source toolset with approval and scoping
Section titled “6. Full-featured multi-source toolset with approval and scoping”This end-to-end example shows CombinedToolset, PrefixedToolset, FilteredToolset, and ApprovalRequiredToolset working together for a multi-tenant CRUD agent.
import asynciofrom dataclasses import dataclassfrom pydantic_ai import Agentfrom pydantic_ai import FunctionToolset, CombinedToolset, PrefixedToolset, FilteredToolset, ApprovalRequiredToolsetfrom pydantic_ai.tools import DeferredToolRequests, DeferredToolResults, ToolApproved, ToolDenied, RunContext
@dataclassclass UserDeps: user_id: str is_admin: bool tenant_id: str
# --- Read tools ---read_tools = FunctionToolset[UserDeps](metadata={'scope': 'read'})
@read_tools.tooldef list_records(ctx: RunContext[UserDeps], limit: int = 10) -> list[str]: """List records for the current tenant.""" return [f'record-{ctx.deps.tenant_id}-{i}' for i in range(limit)]
@read_tools.tooldef get_record(ctx: RunContext[UserDeps], record_id: str) -> dict: """Fetch a single record.""" return {'id': record_id, 'tenant': ctx.deps.tenant_id}
# --- Write tools (require admin approval) ---write_tools = FunctionToolset[UserDeps](metadata={'scope': 'write'})
@write_tools.tooldef delete_record(ctx: RunContext[UserDeps], record_id: str) -> str: """Permanently delete a record.""" return f'Deleted {record_id}'
@write_tools.tooldef bulk_update(ctx: RunContext[UserDeps], field: str, value: str) -> str: """Update a field on all records for this tenant.""" return f'Updated {field}={value} on all records'
# Gate write operations behind human approvalgated_write_tools = ApprovalRequiredToolset(write_tools)
# Prefix both toolsets to avoid name collisionscombined = CombinedToolset([ PrefixedToolset(read_tools, prefix='read_'), PrefixedToolset(gated_write_tools, prefix='write_'),])
# Filter: non-admins only see read toolsdef admin_filter(ctx: RunContext[UserDeps], tool_def) -> bool: if tool_def.name.startswith('write_') and not ctx.deps.is_admin: return False return True
agent = Agent( 'openai:gpt-4o', deps_type=UserDeps, output_type=[str, DeferredToolRequests], toolsets=[FilteredToolset(combined, filter_func=admin_filter)],)
async def main(): admin = UserDeps(user_id='u1', is_admin=True, tenant_id='acme') result = await agent.run('Delete record r-42 and list remaining records.', deps=admin)
if isinstance(result.output, DeferredToolRequests): print('Awaiting approval for:') for call in result.output.approvals: print(f' {call.tool_name}({call.args_as_dict()})')
# Admin approves deletions approvals = {c.tool_call_id: ToolApproved() for c in result.output.approvals} final = await agent.run( 'continue', deps=admin, message_history=result.all_messages(), deferred_tool_results=DeferredToolResults(approvals=approvals), ) print(final.output) else: print(result.output)
asyncio.run(main())7. FunctionToolset with instructions and per-toolset timeout
Section titled “7. FunctionToolset with instructions and per-toolset timeout”import asynciofrom pydantic_ai import Agent, FunctionToolset, RunContext
db_tools = FunctionToolset[None]( timeout=5.0, # Any tool taking >5s gets a ModelRetry prompt instructions=( 'When querying the database, always filter by active=True unless ' 'the user explicitly asks for inactive records.' ),)
@db_tools.tool_plaindef query_users(filter_active: bool = True) -> list[str]: """Query users from the database.""" import time; time.sleep(0.1) # simulate DB latency return ['alice', 'bob'] if filter_active else ['alice', 'bob', 'charlie_inactive']
@db_tools.tool_plaindef count_records(table: str) -> int: """Count rows in a database table.""" return {'users': 2, 'orders': 15}.get(table, 0)
agent = Agent('openai:gpt-4o', toolsets=[db_tools])result = agent.run_sync('How many users are there and who are they?')print(result.output)8. FilteredToolset with async predicate
Section titled “8. FilteredToolset with async predicate”import asynciofrom dataclasses import dataclassfrom pydantic_ai import Agent, FunctionToolset, FilteredToolset, RunContext
@dataclassclass RequestDeps: user_token: str
def search_web(ctx: RunContext[RequestDeps], query: str) -> str: return f'Results for: {query}'
def send_notification(ctx: RunContext[RequestDeps], message: str) -> str: return f'Notification sent: {message}'
all_tools = FunctionToolset[RequestDeps]([search_web, send_notification])
async def permission_check(ctx: RunContext[RequestDeps], tool_def) -> bool: """Async filter — could call an auth service.""" if tool_def.name == 'send_notification': # Simulate checking permissions via an API await asyncio.sleep(0.01) return ctx.deps.user_token.startswith('premium-') return True
agent = Agent( 'openai:gpt-4o', deps_type=RequestDeps, toolsets=[FilteredToolset(all_tools, filter_func=permission_check)],)
async def main(): free_user = RequestDeps(user_token='free-abc') result = await agent.run('Search for Python news and notify the team.', deps=free_user) print(result.output) # Can search but not notify
asyncio.run(main())Deep dives — source-verified class details
Section titled “Deep dives — source-verified class details”PrefixedToolset — namespace isolation
Section titled “PrefixedToolset — namespace isolation”Source: toolsets/prefixed.py
PrefixedToolset prepends a string to every tool name in the wrapped toolset, using {prefix}_{original_name} as the separator. It handles both name translation and call routing back to the original name.
from pydantic_ai import Agent, FunctionToolset, PrefixedToolset, CombinedToolset, RunContextfrom dataclasses import dataclass
@dataclassclass Deps: user: str
# Two toolsets with a name collision: both have a "search" toolweb_tools = FunctionToolset[Deps]()db_tools = FunctionToolset[Deps]()
@web_tools.tool_plaindef search(query: str) -> str: """Search the web for information.""" return f'Web results for: {query}'
@db_tools.tool_plaindef search(query: str) -> str: # noqa: F811 — same name, different toolset """Search the internal database.""" return f'DB results for: {query}'
# Without prefixing, CombinedToolset would raise on the name collision.# Prefix them:agent = Agent( 'openai:gpt-4o', deps_type=Deps, toolsets=[ CombinedToolset([ PrefixedToolset(web_tools, prefix='web'), PrefixedToolset(db_tools, prefix='db'), ]) ],)# Model now sees: web_search, db_search — no collision.result = agent.run_sync('Search the web for Python 3.13 news.', deps=Deps(user='alice'))print(result.output)Key implementation detail (toolsets/prefixed.py): PrefixedToolset.call_tool strips the prefix before forwarding the call, so the underlying tool function still receives the original (unprefixed) name in RunContext.tool_name.
# The model calls "web_search"; the underlying function sees tool_name="search"# in its RunContext. This is intentional — it keeps the underlying tool# independent of whatever prefix is applied.tool_name_conflict_hint — if a collision still occurs after prefixing, the error message says “Change the prefix attribute to avoid name conflicts.” You can customise this hint on a subclass:
class MyPrefixed(PrefixedToolset): @property def tool_name_conflict_hint(self) -> str: return 'Rename the conflicting tool in the underlying FunctionToolset.'FilteredToolset — per-step conditional visibility
Section titled “FilteredToolset — per-step conditional visibility”Source: toolsets/filtered.py
FilteredToolset calls filter_func(ctx, tool_def) -> bool on every agent step before handing the tool list to the model. The filter is re-evaluated at each step, so you can dynamically hide or reveal tools based on conversation state.
Both sync and async filter functions are accepted.
from dataclasses import dataclassfrom pydantic_ai import Agent, FunctionToolset, FilteredToolset, RunContext
@dataclassclass UserContext: role: str # 'admin' | 'viewer' subscription: str # 'free' | 'pro'
tools = FunctionToolset[UserContext]()
@tools.tool_plaindef list_reports() -> list[str]: """List available reports.""" return ['q1_report', 'q2_report']
@tools.tool_plaindef delete_report(report_id: str) -> str: """Delete a report permanently. Admin only.""" return f'Deleted {report_id}'
@tools.tool_plaindef export_csv(report_id: str) -> str: """Export a report as CSV. Pro subscribers only.""" return f'Exported {report_id} as CSV'
# Sync filter — called per step, has access to ctx.depsdef rbac_filter(ctx: RunContext[UserContext], tool_def) -> bool: if tool_def.name == 'delete_report' and ctx.deps.role != 'admin': return False if tool_def.name == 'export_csv' and ctx.deps.subscription != 'pro': return False return True
agent = Agent('openai:gpt-4o', deps_type=UserContext, toolsets=[FilteredToolset(tools, filter_func=rbac_filter)])
viewer = UserContext(role='viewer', subscription='free')result = agent.run_sync('List reports and delete report q1.', deps=viewer)# viewer only sees list_reports — the model can't call delete_report or export_csvprint(result.output)Async filter — useful for fetching permissions from an external service:
import asynciofrom dataclasses import dataclassfrom pydantic_ai import Agent, FunctionToolset, FilteredToolset, RunContextfrom pydantic_ai.tools import ToolDefinition
@dataclassclass AuthDeps: session_token: str
async def permission_filter(ctx: RunContext[AuthDeps], tool_def: ToolDefinition) -> bool: """Check an auth service — only awaited if needed.""" if not tool_def.metadata.get('requires_permission'): return True # no auth check needed for unprotected tools # Simulate async permission check await asyncio.sleep(0.001) permission = tool_def.metadata['requires_permission'] # Replace with real auth service call: return ctx.deps.session_token.startswith('admin-') or permission == 'read'
tools = FunctionToolset[AuthDeps]()
@tools.tool_plaindef read_data() -> str: """Read data.""" return 'data'
# Mark the write tool with required permissionfrom pydantic_ai.tools import Toolimport functools
@tools.tool_plaindef write_data(value: str) -> str: """Write data. Requires write permission.""" return f'Written: {value}'
# Attach metadata at registration time via FunctionToolset.add_functiontools2 = FunctionToolset[AuthDeps](metadata={'requires_permission': 'write'})
@tools2.tool_plaindef admin_action() -> str: """Admin-only action.""" return 'Done'
from pydantic_ai import CombinedToolsetagent = Agent('openai:gpt-4o', deps_type=AuthDeps, toolsets=[FilteredToolset(CombinedToolset([tools, tools2]), filter_func=permission_filter)])State-dependent filtering — use the filter to hide a tool once a certain condition is reached:
from pydantic_ai.messages import ModelRequestfrom pydantic_ai import Agent, FunctionToolset, FilteredToolset, RunContext
tools = FunctionToolset[None]()
@tools.tool_plaindef confirm_purchase() -> str: """Confirm the purchase. Only available once the cart is non-empty.""" return 'Purchase confirmed!'
@tools.tool_plaindef add_to_cart(item: str) -> str: """Add an item to the cart.""" return f'{item} added to cart.'
# Count how many items were added based on tool historydef cart_filter(ctx: RunContext[None], tool_def) -> bool: if tool_def.name != 'confirm_purchase': return True # Only show confirm_purchase if add_to_cart was called at least once cart_calls = sum( 1 for msg in ctx.messages for part in msg.parts if hasattr(part, 'tool_name') and part.tool_name == 'add_to_cart' ) return cart_calls > 0
agent = Agent('openai:gpt-4o', toolsets=[FilteredToolset(tools, filter_func=cart_filter)])ApprovalRequiredToolset — human-in-the-loop
Section titled “ApprovalRequiredToolset — human-in-the-loop”Source: toolsets/approval_required.py
ApprovalRequiredToolset wraps any toolset so that when the model calls one of those tools, a ApprovalRequired exception is raised. The agent catches this and — if the output type includes DeferredToolRequests — surfaces it as a structured value that your application can use to ask a human for approval.
Full HITL workflow
Section titled “Full HITL workflow”import asynciofrom dataclasses import dataclassfrom pydantic_ai import Agent, FunctionToolset, ApprovalRequiredToolset, RunContextfrom pydantic_ai.output import DeferredToolRequestsfrom pydantic_ai.tools import DeferredToolResults, ToolApproved, ToolDenied
@dataclassclass AdminDeps: admin_email: str
# Tools that always need approvaldangerous_tools = FunctionToolset[AdminDeps]()
@dangerous_tools.tooldef delete_user(ctx: RunContext[AdminDeps], user_id: str) -> str: """Permanently delete a user account.""" return f'User {user_id} deleted by {ctx.deps.admin_email}'
@dangerous_tools.tooldef bulk_export(ctx: RunContext[AdminDeps], table: str) -> str: """Export an entire database table to CSV.""" return f'Exported table {table}'
# Wrap with approval gategated = ApprovalRequiredToolset(dangerous_tools)
agent = Agent( 'openai:gpt-4o', deps_type=AdminDeps, output_type=[str, DeferredToolRequests], # <-- critical: allows DeferredToolRequests output toolsets=[gated],)
async def main(): deps = AdminDeps(admin_email='ops@example.com') result1 = await agent.run('Delete user u-42 and export the audit_log table.', deps=deps)
if isinstance(result1.output, DeferredToolRequests): print('Model wants to call these tools (awaiting approval):') for call in result1.output.approvals: print(f' [{call.tool_call_id}] {call.tool_name}({call.args_as_dict()})')
# Human reviews and decides per call human_decisions: dict[str, bool | ToolApproved | ToolDenied] = {} for call in result1.output.approvals: answer = input(f'Approve {call.tool_name}({call.args_as_dict()})? [y/N] ') if answer.lower() == 'y': human_decisions[call.tool_call_id] = ToolApproved() else: human_decisions[call.tool_call_id] = ToolDenied(message='Operation not approved by operator.')
# Resume the run with the decisions result2 = await agent.run( '', # no new user message needed deps=deps, message_history=result1.all_messages(), deferred_tool_results=DeferredToolResults(approvals=human_decisions), ) print(result2.output) else: print(result1.output)
asyncio.run(main())Selective approval — approval_required_func
Section titled “Selective approval — approval_required_func”By default, ApprovalRequiredToolset requires approval for every call. Pass approval_required_func to gate only specific tools:
from pydantic_ai import ApprovalRequiredToolset, RunContextfrom pydantic_ai.tools import ToolDefinition
def only_destructive(ctx: RunContext, tool_def: ToolDefinition, tool_args: dict) -> bool: """Require approval only for destructive operations.""" return tool_def.name.startswith('delete_') or tool_def.name.startswith('bulk_')
gated_selective = ApprovalRequiredToolset( dangerous_tools, approval_required_func=only_destructive,)ToolApproved — override args
Section titled “ToolApproved — override args”ToolApproved accepts an optional override_args to substitute different arguments before the tool actually runs. This lets an operator correct or sanitise the model’s arguments:
from pydantic_ai.tools import ToolApproved
# Model wanted to delete u-99, operator redirects to a safer test userdecisions = { call.tool_call_id: ToolApproved(override_args={'user_id': 'test-user-sandbox'}) for call in result1.output.approvals if call.tool_name == 'delete_user'}DeferredToolRequests.build_results convenience method
Section titled “DeferredToolRequests.build_results convenience method”# Approve all pending requests at oncedeferred_results = result1.output.build_results(approve_all=True)
# Or approve some, deny othersdeferred_results = result1.output.build_results( approvals={ call.tool_call_id: ToolApproved() for call in result1.output.approvals if call.tool_name != 'bulk_export' },)DeferredLoadingToolset — progressive tool disclosure
Section titled “DeferredLoadingToolset — progressive tool disclosure”Source: toolsets/deferred_loading.py
DeferredLoadingToolset marks tools with defer_loading=True on their ToolDefinition, hiding them from the model until the search_tools function (or a native provider search) discovers them. This is the recommended way to work with large tool libraries (100+ tools) without overwhelming the context window.
from pydantic_ai import Agent, FunctionToolset, DeferredLoadingToolsetfrom pydantic_ai.capabilities import ToolSearch
# A large library of 50+ toolsbig_library = FunctionToolset[None]()
for i in range(20): name = f'operation_{i}' desc = f'Performs operation {i} on the dataset.' # Register dynamically for this example big_library.add_function( lambda ctx, i=i: f'result of operation {i}', name=name, description=desc, )
# Hide all tools until tool search surfaces themhidden = DeferredLoadingToolset(big_library)
# ToolSearch capability adds a search_tools function tool that discovers deferred toolsagent = Agent( 'openai:gpt-4o', toolsets=[hidden], capabilities=[ToolSearch()], # enables the search_tools built-in)result = agent.run_sync('Run operation 5 on the dataset.')print(result.output)Selectively hide only some tools
Section titled “Selectively hide only some tools”Pass tool_names to DeferredLoadingToolset to hide only specific tools; others remain visible:
from pydantic_ai import FunctionToolset, DeferredLoadingToolset
tools = FunctionToolset[None]()
@tools.tool_plaindef get_weather(city: str) -> str: """Get current weather.""" return f'Sunny in {city}'
@tools.tool_plaindef send_alert(message: str) -> str: """Send an emergency alert. Rarely needed.""" return f'Alert sent: {message}'
@tools.tool_plaindef list_sensors() -> list[str]: """List active sensors.""" return ['sensor-1', 'sensor-2']
# Only hide the rarely-used send_alert; get_weather and list_sensors stay visiblepartial_deferred = DeferredLoadingToolset( tools, tool_names=frozenset({'send_alert'}),)How deferral works under the hood
Section titled “How deferral works under the hood”DeferredLoadingToolset (toolsets/deferred_loading.py) installs a prepare_func that calls ToolDefinition.replace(defer_loading=True) on the matching tools. The framework then:
- Native path (providers that support it like Anthropic/OpenAI): keeps all deferred tools in the wire payload with a provider-specific
defer_loading=Trueflag, so the provider handles server-side discovery. - Local path: drops deferred tools from the wire until the model calls
search_tools, which returns the matching tool names. Discovered tools are promoted (setdefer_loading=False) for subsequent steps.
ExternalToolset — tools executed outside the agent run
Section titled “ExternalToolset — tools executed outside the agent run”Source: toolsets/external.py
ExternalToolset advertises tool schemas to the model but does not execute them. The agent pauses on a DeferredToolRequests output containing the model’s calls; your application routes those calls to an external system (a queue, another process, a UI) and resumes the agent with the results.
This pattern is useful for:
- Long-running operations (file processing, slow APIs) that shouldn’t block the agent.
- Operations that require UI interaction (file upload dialogs, OAuth flows).
- Durable execution contexts where the agent must survive a process restart.
import asynciofrom pydantic_ai import Agentfrom pydantic_ai.toolsets import ExternalToolsetfrom pydantic_ai.tools import ToolDefinition, DeferredToolResultsfrom pydantic_ai.output import DeferredToolRequests
# Define tool schemas — no Python implementation neededexternal = ExternalToolset([ ToolDefinition( name='upload_file', description='Upload a file to the document store. Returns the file ID.', parameters_json_schema={ 'type': 'object', 'properties': { 'filename': {'type': 'string'}, 'content_type': {'type': 'string'}, }, 'required': ['filename'], }, ), ToolDefinition( name='run_etl_job', description='Trigger a long-running ETL job. Returns job ID.', parameters_json_schema={ 'type': 'object', 'properties': { 'source_table': {'type': 'string'}, 'target_table': {'type': 'string'}, }, 'required': ['source_table', 'target_table'], }, ),])
agent = Agent( 'openai:gpt-4o', output_type=[str, DeferredToolRequests], toolsets=[external],)
async def dispatch_to_queue(calls) -> dict[str, str]: """Simulate dispatching to an external job queue.""" results = {} for call in calls: if call.tool_name == 'upload_file': args = call.args_as_dict() results[call.tool_call_id] = f'file_id_{args["filename"].replace(".", "_")}' elif call.tool_name == 'run_etl_job': args = call.args_as_dict() results[call.tool_call_id] = f'job_{args["source_table"]}_to_{args["target_table"]}' return results
async def main(): result1 = await agent.run('Upload report.csv and run an ETL from raw_data to warehouse.')
if isinstance(result1.output, DeferredToolRequests): print('External calls requested:') for call in result1.output.calls: print(f' {call.tool_name}({call.args_as_dict()})')
# Execute externally and collect results external_results = await dispatch_to_queue(result1.output.calls)
# Resume agent with the external results result2 = await agent.run( '', message_history=result1.all_messages(), deferred_tool_results=DeferredToolResults(calls=external_results), ) print(result2.output) else: print(result1.output)
asyncio.run(main())DeferredToolset is deprecated — ExternalToolset is the replacement. The old name is still importable but emits a DeprecationWarning.
Reference
Section titled “Reference”AbstractToolset—toolsets/abstract.pyFunctionToolset—toolsets/function.py:44CombinedToolset—toolsets/combined.py:26PrefixedToolset—toolsets/prefixed.pyRenamedToolset—toolsets/renamed.pyFilteredToolset—toolsets/filtered.pyPreparedToolset—toolsets/prepared.pyApprovalRequiredToolset—toolsets/approval_required.pyDeferredLoadingToolset—toolsets/deferred_loading.pyExternalToolset—toolsets/external.py(replaces deprecatedDeferredToolset)IncludeReturnSchemasToolset—toolsets/include_return_schemas.pySetMetadataToolset—toolsets/set_metadata.pyDeferredToolRequests—pydantic_ai.outputDeferredToolResults—pydantic_ai.toolsToolApproved/ToolDenied—pydantic_ai.toolsToolSearchcapability —pydantic_ai.capabilities