Skip to content

Commit 13ea417

Browse files
strawgateDouweM
andauthored
Let toolsets be built dynamically based on run context (#2366)
Co-authored-by: Douwe Maan <[email protected]>
1 parent 5745402 commit 13ea417

File tree

8 files changed

+392
-45
lines changed

8 files changed

+392
-45
lines changed

docs/toolsets.md

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ A toolset represents a collection of [tools](tools.md) that can be registered wi
55

66
Toolsets are used (among many other things) to define [MCP servers](mcp/client.md) available to an agent. Pydantic AI includes many kinds of toolsets which are described below, and you can define a [custom toolset](#building-a-custom-toolset) by inheriting from the [`AbstractToolset`][pydantic_ai.toolsets.AbstractToolset] class.
77

8-
The toolsets that will be available during an agent run can be specified in three different ways:
8+
The toolsets that will be available during an agent run can be specified in four different ways:
99

10-
* at agent construction time, via the [`toolsets`][pydantic_ai.Agent.__init__] keyword argument to `Agent`
11-
* at agent run time, via the `toolsets` keyword argument to [`agent.run()`][pydantic_ai.agent.AbstractAgent.run], [`agent.run_sync()`][pydantic_ai.agent.AbstractAgent.run_sync], [`agent.run_stream()`][pydantic_ai.agent.AbstractAgent.run_stream], or [`agent.iter()`][pydantic_ai.Agent.iter]. These toolsets will be additional to those provided to the `Agent` constructor
10+
* at agent construction time, via the [`toolsets`][pydantic_ai.Agent.__init__] keyword argument to `Agent`, which takes toolset instances as well as functions that generate toolsets [dynamically](#dynamically-building-a-toolset) based on the agent [run context][pydantic_ai.tools.RunContext]
11+
* at agent run time, via the `toolsets` keyword argument to [`agent.run()`][pydantic_ai.agent.AbstractAgent.run], [`agent.run_sync()`][pydantic_ai.agent.AbstractAgent.run_sync], [`agent.run_stream()`][pydantic_ai.agent.AbstractAgent.run_stream], or [`agent.iter()`][pydantic_ai.Agent.iter]. These toolsets will be additional to those registered on the `Agent`
12+
* [dynamically](#dynamically-building-a-toolset), via the [`@agent.toolset`][pydantic_ai.Agent.toolset] decorator which lets you build a toolset based on the agent [run context][pydantic_ai.tools.RunContext]
1213
* as a contextual override, via the `toolsets` keyword argument to the [`agent.override()`][pydantic_ai.Agent.iter] context manager. These toolsets will replace those provided at agent construction or run time during the life of the context manager
1314

1415
```python {title="toolsets.py"}
@@ -330,15 +331,11 @@ print(test_model.last_model_request_parameters.function_tools)
330331

331332
1. We're using [`TestModel`][pydantic_ai.models.test.TestModel] here because it makes it easy to see which tools were available on each run.
332333

333-
### Wrapping a Toolset
334+
### Changing Tool Execution
334335

335336
[`WrapperToolset`][pydantic_ai.toolsets.WrapperToolset] wraps another toolset and delegates all responsibility to it.
336337

337-
It is is a no-op by default, but enables some useful abilities:
338-
339-
#### Changing Tool Execution
340-
341-
You can subclass `WrapperToolset` to change the wrapped toolset's tool execution behavior by overriding the [`call_tool()`][pydantic_ai.toolsets.AbstractToolset.call_tool] method.
338+
It is is a no-op by default, but you can subclass `WrapperToolset` to change the wrapped toolset's tool execution behavior by overriding the [`call_tool()`][pydantic_ai.toolsets.AbstractToolset.call_tool] method.
342339

343340
```python {title="logging_toolset.py" requires="function_toolset.py,combined_toolset.py,renamed_toolset.py,prepared_toolset.py"}
344341
import asyncio
@@ -392,47 +389,68 @@ print(LOG)
392389

393390
_(This example is complete, it can be run "as is")_
394391

395-
#### Modifying Toolsets During a Run
392+
## Dynamically Building a Toolset
393+
394+
Toolsets can be built dynamically ahead of each agent run or run step using a function that takes the agent [run context][pydantic_ai.tools.RunContext] and returns a toolset or `None`. This is useful when a toolset (like an MCP server) depends on information specific to an agent run, like its [dependencies](./dependencies.md).
395+
396+
To register a dynamic toolset, you can pass a function that takes [`RunContext`][pydantic_ai.tools.RunContext] to the `toolsets` argument of the `Agent` constructor, or you can wrap a compliant function in the [`@agent.toolset`][pydantic_ai.Agent.toolset] decorator.
396397

397-
You can change the `WrapperToolset`'s `wrapped` property during an agent run to swap out one toolset for another starting at the next run step.
398+
By default, the function will be called again ahead of each agent run step. If you are using the decorator, you can optionally provide a `per_run_step=False` argument to indicate that the toolset only needs to be built once for the entire run.
398399

399-
To add or remove available toolsets, you can wrap a [`CombinedToolset`](#combining-toolsets) and replace it during the run with one that can include fewer, more, or entirely different toolsets.
400+
```python {title="dynamic_toolset.py", requires="function_toolset.py"}
401+
from dataclasses import dataclass
402+
from typing import Literal
400403

401-
```python {title="wrapper_toolset.py" requires="function_toolset.py"}
402404
from function_toolset import weather_toolset, datetime_toolset
403405

404406
from pydantic_ai import Agent, RunContext
405407
from pydantic_ai.models.test import TestModel
406-
from pydantic_ai.toolsets import WrapperToolset
407408

408-
togglable_toolset = WrapperToolset(weather_toolset)
409409

410-
test_model = TestModel() # (1)!
410+
@dataclass
411+
class ToggleableDeps:
412+
active: Literal['weather', 'datetime']
413+
414+
def toggle(self):
415+
if self.active == 'weather':
416+
self.active = 'datetime'
417+
else:
418+
self.active = 'weather'
419+
420+
test_model = TestModel() # (1)!
411421
agent = Agent(
412422
test_model,
413-
deps_type=WrapperToolset # (2)!
423+
deps_type=ToggleableDeps # (2)!
414424
)
415425

416-
@agent.tool
417-
def toggle(ctx: RunContext[WrapperToolset]):
418-
if ctx.deps.wrapped == weather_toolset:
419-
ctx.deps.wrapped = datetime_toolset
426+
@agent.toolset
427+
def toggleable_toolset(ctx: RunContext[ToggleableDeps]):
428+
if ctx.deps.active == 'weather':
429+
return weather_toolset
420430
else:
421-
ctx.deps.wrapped = weather_toolset
431+
return datetime_toolset
432+
433+
@agent.tool
434+
def toggle(ctx: RunContext[ToggleableDeps]):
435+
ctx.deps.toggle()
422436

423-
result = agent.run_sync('Toggle the toolset', deps=togglable_toolset, toolsets=[togglable_toolset])
424-
print([t.name for t in test_model.last_model_request_parameters.function_tools]) # (3)!
437+
deps = ToggleableDeps('weather')
438+
439+
result = agent.run_sync('Toggle the toolset', deps=deps)
440+
print([t.name for t in test_model.last_model_request_parameters.function_tools]) # (3)!
425441
#> ['toggle', 'now']
426442

427-
result = agent.run_sync('Toggle the toolset', deps=togglable_toolset, toolsets=[togglable_toolset])
443+
result = agent.run_sync('Toggle the toolset', deps=deps)
428444
print([t.name for t in test_model.last_model_request_parameters.function_tools])
429445
#> ['toggle', 'temperature_celsius', 'temperature_fahrenheit', 'conditions']
430446
```
431447

432448
1. We're using [`TestModel`][pydantic_ai.models.test.TestModel] here because it makes it easy to see which tools were available on each run.
433-
2. We're using the agent's dependencies to give the `toggle` tool access to the `togglable_toolset` via the `RunContext` argument.
449+
2. We're using the agent's dependencies to give the `toggle` tool access to the `active` via the `RunContext` argument.
434450
3. This shows the available tools _after_ the `toggle` tool was executed, as the "last model request" was the one that returned the `toggle` tool result to the model.
435451

452+
_(This example is complete, it can be run "as is")_
453+
436454
## Building a Custom Toolset
437455

438456
To define a fully custom toolset with its own logic to list available tools and handle them being called, you can subclass [`AbstractToolset`][pydantic_ai.toolsets.AbstractToolset] and implement the [`get_tools()`][pydantic_ai.toolsets.AbstractToolset.get_tools] and [`call_tool()`][pydantic_ai.toolsets.AbstractToolset.call_tool] methods.

pydantic_ai_slim/pydantic_ai/_tool_manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ async def build(cls, toolset: AbstractToolset[AgentDepsT], ctx: RunContext[Agent
4141

4242
async def for_run_step(self, ctx: RunContext[AgentDepsT]) -> ToolManager[AgentDepsT]:
4343
"""Build a new tool manager for the next run step, carrying over the retries from the current run step."""
44+
if ctx.run_step == self.ctx.run_step:
45+
return self
46+
4447
retries = {
4548
failed_tool_name: self.ctx.retries.get(failed_tool_name, 0) + 1 for failed_tool_name in self.failed_tools
4649
}

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from pydantic.json_schema import GenerateJsonSchema
1515
from typing_extensions import TypeVar, deprecated
1616

17-
from pydantic_ai.builtin_tools import AbstractBuiltinTool
1817
from pydantic_graph import Graph
1918

2019
from .. import (
@@ -30,6 +29,7 @@
3029
from .._agent_graph import HistoryProcessor
3130
from .._output import OutputToolset
3231
from .._tool_manager import ToolManager
32+
from ..builtin_tools import AbstractBuiltinTool
3333
from ..models.instrumented import InstrumentationSettings, InstrumentedModel, instrument_model
3434
from ..output import OutputDataT, OutputSpec
3535
from ..profiles import ModelProfile
@@ -50,6 +50,10 @@
5050
ToolsPrepareFunc,
5151
)
5252
from ..toolsets import AbstractToolset
53+
from ..toolsets._dynamic import (
54+
DynamicToolset,
55+
ToolsetFunc,
56+
)
5357
from ..toolsets.combined import CombinedToolset
5458
from ..toolsets.function import FunctionToolset
5559
from ..toolsets.prepared import PreparedToolset
@@ -139,7 +143,7 @@ class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
139143
)
140144
_function_toolset: FunctionToolset[AgentDepsT] = dataclasses.field(repr=False)
141145
_output_toolset: OutputToolset[AgentDepsT] | None = dataclasses.field(repr=False)
142-
_user_toolsets: Sequence[AbstractToolset[AgentDepsT]] = dataclasses.field(repr=False)
146+
_user_toolsets: list[AbstractToolset[AgentDepsT]] = dataclasses.field(repr=False)
143147
_prepare_tools: ToolsPrepareFunc[AgentDepsT] | None = dataclasses.field(repr=False)
144148
_prepare_output_tools: ToolsPrepareFunc[AgentDepsT] | None = dataclasses.field(repr=False)
145149
_max_result_retries: int = dataclasses.field(repr=False)
@@ -171,7 +175,7 @@ def __init__(
171175
builtin_tools: Sequence[AbstractBuiltinTool] = (),
172176
prepare_tools: ToolsPrepareFunc[AgentDepsT] | None = None,
173177
prepare_output_tools: ToolsPrepareFunc[AgentDepsT] | None = None,
174-
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
178+
toolsets: Sequence[AbstractToolset[AgentDepsT] | ToolsetFunc[AgentDepsT]] | None = None,
175179
defer_model_check: bool = False,
176180
end_strategy: EndStrategy = 'early',
177181
instrument: InstrumentationSettings | bool | None = None,
@@ -227,7 +231,7 @@ def __init__(
227231
builtin_tools: Sequence[AbstractBuiltinTool] = (),
228232
prepare_tools: ToolsPrepareFunc[AgentDepsT] | None = None,
229233
prepare_output_tools: ToolsPrepareFunc[AgentDepsT] | None = None,
230-
toolsets: Sequence[AbstractToolset[AgentDepsT]] | None = None,
234+
toolsets: Sequence[AbstractToolset[AgentDepsT] | ToolsetFunc[AgentDepsT]] | None = None,
231235
defer_model_check: bool = False,
232236
end_strategy: EndStrategy = 'early',
233237
instrument: InstrumentationSettings | bool | None = None,
@@ -265,7 +269,8 @@ def __init__(
265269
prepare_output_tools: Custom function to prepare the tool definition of all output tools for each step.
266270
This is useful if you want to customize the definition of multiple output tools or you want to register
267271
a subset of output tools for a given step. See [`ToolsPrepareFunc`][pydantic_ai.tools.ToolsPrepareFunc]
268-
toolsets: Toolsets to register with the agent, including MCP servers.
272+
toolsets: Toolsets to register with the agent, including MCP servers and functions which take a run context
273+
and return a toolset. See [`ToolsetFunc`][pydantic_ai.toolsets.ToolsetFunc] for more information.
269274
defer_model_check: by default, if you provide a [named][pydantic_ai.models.KnownModelName] model,
270275
it's evaluated to create a [`Model`][pydantic_ai.models.Model] instance immediately,
271276
which checks for the necessary environment variables. Set this to `false`
@@ -341,7 +346,12 @@ def __init__(
341346
self._output_toolset.max_retries = self._max_result_retries
342347

343348
self._function_toolset = _AgentFunctionToolset(tools, max_retries=self._max_tool_retries)
344-
self._user_toolsets = toolsets or ()
349+
self._dynamic_toolsets = [
350+
DynamicToolset[AgentDepsT](toolset_func=toolset)
351+
for toolset in toolsets or []
352+
if not isinstance(toolset, AbstractToolset)
353+
]
354+
self._user_toolsets = [toolset for toolset in toolsets or [] if isinstance(toolset, AbstractToolset)]
345355

346356
self.history_processors = history_processors or []
347357

@@ -1138,6 +1148,53 @@ def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams
11381148

11391149
return tool_decorator if func is None else tool_decorator(func)
11401150

1151+
@overload
1152+
def toolset(self, func: ToolsetFunc[AgentDepsT], /) -> ToolsetFunc[AgentDepsT]: ...
1153+
1154+
@overload
1155+
def toolset(
1156+
self,
1157+
/,
1158+
*,
1159+
per_run_step: bool = True,
1160+
) -> Callable[[ToolsetFunc[AgentDepsT]], ToolsetFunc[AgentDepsT]]: ...
1161+
1162+
def toolset(
1163+
self,
1164+
func: ToolsetFunc[AgentDepsT] | None = None,
1165+
/,
1166+
*,
1167+
per_run_step: bool = True,
1168+
) -> Any:
1169+
"""Decorator to register a toolset function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its only argument.
1170+
1171+
Can decorate a sync or async functions.
1172+
1173+
The decorator can be used bare (`agent.toolset`).
1174+
1175+
Example:
1176+
```python
1177+
from pydantic_ai import Agent, RunContext
1178+
from pydantic_ai.toolsets import AbstractToolset, FunctionToolset
1179+
1180+
agent = Agent('test', deps_type=str)
1181+
1182+
@agent.toolset
1183+
async def simple_toolset(ctx: RunContext[str]) -> AbstractToolset[str]:
1184+
return FunctionToolset()
1185+
```
1186+
1187+
Args:
1188+
func: The toolset function to register.
1189+
per_run_step: Whether to re-evaluate the toolset for each run step. Defaults to True.
1190+
"""
1191+
1192+
def toolset_decorator(func_: ToolsetFunc[AgentDepsT]) -> ToolsetFunc[AgentDepsT]:
1193+
self._dynamic_toolsets.append(DynamicToolset(func_, per_run_step=per_run_step))
1194+
return func_
1195+
1196+
return toolset_decorator if func is None else toolset_decorator(func)
1197+
11411198
def _get_model(self, model: models.Model | models.KnownModelName | str | None) -> models.Model:
11421199
"""Create a model configured for this agent.
11431200
@@ -1197,10 +1254,10 @@ def _get_toolset(
11971254

11981255
if some_user_toolsets := self._override_toolsets.get():
11991256
user_toolsets = some_user_toolsets.value
1200-
elif additional is not None:
1201-
user_toolsets = [*self._user_toolsets, *additional]
12021257
else:
1203-
user_toolsets = self._user_toolsets
1258+
# Copy the dynamic toolsets to ensure each run has its own instances
1259+
dynamic_toolsets = [dataclasses.replace(toolset) for toolset in self._dynamic_toolsets]
1260+
user_toolsets = [*self._user_toolsets, *dynamic_toolsets, *(additional or [])]
12041261

12051262
if user_toolsets:
12061263
toolset = CombinedToolset([function_toolset, *user_toolsets])

pydantic_ai_slim/pydantic_ai/tools.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ async def turn_on_strict_if_openai(
118118
Usage `ToolsPrepareFunc[AgentDepsT]`.
119119
"""
120120

121-
122121
DocstringFormat = Literal['google', 'numpy', 'sphinx', 'auto']
123122
"""Supported docstring formats.
124123

pydantic_ai_slim/pydantic_ai/toolsets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ._dynamic import ToolsetFunc
12
from .abstract import AbstractToolset, ToolsetTool
23
from .combined import CombinedToolset
34
from .deferred import DeferredToolset
@@ -10,6 +11,7 @@
1011

1112
__all__ = (
1213
'AbstractToolset',
14+
'ToolsetFunc',
1315
'ToolsetTool',
1416
'CombinedToolset',
1517
'DeferredToolset',
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
from collections.abc import Awaitable
5+
from dataclasses import dataclass, replace
6+
from typing import Any, Callable, Union
7+
8+
from typing_extensions import Self, TypeAlias
9+
10+
from .._run_context import AgentDepsT, RunContext
11+
from .abstract import AbstractToolset, ToolsetTool
12+
13+
ToolsetFunc: TypeAlias = Callable[
14+
[RunContext[AgentDepsT]],
15+
Union[AbstractToolset[AgentDepsT], None, Awaitable[Union[AbstractToolset[AgentDepsT], None]]],
16+
]
17+
"""A sync/async function which takes a run context and returns a toolset."""
18+
19+
20+
@dataclass
21+
class DynamicToolset(AbstractToolset[AgentDepsT]):
22+
"""A toolset that dynamically builds a toolset using a function that takes the run context.
23+
24+
It should only be used during a single agent run as it stores the generated toolset.
25+
To use it multiple times, copy it using `dataclasses.replace`.
26+
"""
27+
28+
toolset_func: ToolsetFunc[AgentDepsT]
29+
per_run_step: bool = True
30+
31+
_toolset: AbstractToolset[AgentDepsT] | None = None
32+
_run_step: int | None = None
33+
34+
@property
35+
def id(self) -> str | None:
36+
return None # pragma: no cover
37+
38+
async def __aenter__(self) -> Self:
39+
return self
40+
41+
async def __aexit__(self, *args: Any) -> bool | None:
42+
try:
43+
if self._toolset is not None:
44+
return await self._toolset.__aexit__(*args)
45+
finally:
46+
self._toolset = None
47+
self._run_step = None
48+
49+
async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]:
50+
if self._toolset is None or (self.per_run_step and ctx.run_step != self._run_step):
51+
if self._toolset is not None:
52+
await self._toolset.__aexit__()
53+
54+
toolset = self.toolset_func(ctx)
55+
if inspect.isawaitable(toolset):
56+
toolset = await toolset
57+
58+
if toolset is not None:
59+
await toolset.__aenter__()
60+
61+
self._toolset = toolset
62+
self._run_step = ctx.run_step
63+
64+
if self._toolset is None:
65+
return {}
66+
67+
return await self._toolset.get_tools(ctx)
68+
69+
async def call_tool(
70+
self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT]
71+
) -> Any:
72+
assert self._toolset is not None
73+
return await self._toolset.call_tool(name, tool_args, ctx, tool)
74+
75+
def apply(self, visitor: Callable[[AbstractToolset[AgentDepsT]], None]) -> None:
76+
if self._toolset is not None:
77+
self._toolset.apply(visitor)
78+
79+
def visit_and_replace(
80+
self, visitor: Callable[[AbstractToolset[AgentDepsT]], AbstractToolset[AgentDepsT]]
81+
) -> AbstractToolset[AgentDepsT]:
82+
if self._toolset is None:
83+
return super().visit_and_replace(visitor)
84+
else:
85+
return replace(self, _toolset=self._toolset.visit_and_replace(visitor))

0 commit comments

Comments
 (0)