`CombinedToolset.call_tool` dispatches through a stale cached tool, dropping `tool_def` mutations made by outer wrapper toolsets
Initial Checks
- I'm using the latest version of Pydantic AI
- I've searched for my issue in the issue tracker before opening this issue
Description
CombinedToolset.get_tools() wraps each inner tool in a _CombinedToolsetTool that caches a copy
of it as source_tool. At call time, CombinedToolset.call_tool dispatches through
tool.source_tool — the copy captured at get_tools() time — instead of the tool it actually
receives as an argument:
# pydantic_ai_slim/pydantic_ai/toolsets/combined.py
async def call_tool(
self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT]
) -> Any:
assert isinstance(tool, _CombinedToolsetTool)
return await tool.source_toolset.call_tool(name, tool_args, ctx, tool.source_tool)Any wrapper toolset sitting outside the CombinedToolset — PreparedToolset (and anything built
on it, e.g. Agent(prepare_tools=...) or a SetToolMetadata-style wrapper) — mutates the outer
_CombinedToolsetTool.tool_def after that cache was already built. That mutation updates
tool.tool_def, but never reaches tool.source_tool.tool_def, so it's invisible by the time the
call actually happens: the leaf toolset's call_tool receives a tool whose tool_def reflects the
state from before the outer wrapper ran (e.g. metadata=None) instead of the current one.
This silently drops anything set via ToolDefinition.metadata, strict, requires_approval, or
any other field a prepare_func changes, whenever the affected toolset is combined with at least
one other toolset (which is the common case — an Agent builds a CombinedToolset as soon as it
has more than one toolset source).
PrefixedToolset.call_tool already gets this right — it rebuilds the tool it passes down from the
tool argument it received, not from a cached copy:
# pydantic_ai_slim/pydantic_ai/toolsets/prefixed.py
async def call_tool(
self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT]
) -> Any:
original_name = name.removeprefix(self.prefix + '_')
ctx = replace(ctx, tool_name=original_name)
tool = replace(tool, tool_def=replace(tool.tool_def, name=original_name))
return await super().call_tool(original_name, tool_args, ctx, tool)CombinedToolset.call_tool is the odd one out.
Suggested fix
Propagate the up-to-date tool_def from the tool actually passed in onto source_tool before
delegating (replace is already imported in combined.py):
async def call_tool(
self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT]
) -> Any:
assert isinstance(tool, _CombinedToolsetTool)
- return await tool.source_toolset.call_tool(name, tool_args, ctx, tool.source_tool)
+ # Dispatch with the up-to-date `tool_def` from the *passed* tool, not the copy cached in
+ # `source_tool` at `get_tools()` time: a wrapper toolset sitting outside this
+ # `CombinedToolset` (e.g. `PreparedToolset`) mutates `_CombinedToolsetTool.tool_def` after
+ # we built it, but never touches the cached `source_tool`.
+ source_tool = replace(tool.source_tool, tool_def=tool.tool_def)
+ return await tool.source_toolset.call_tool(name, tool_args, ctx, source_tool)replace(tool.source_tool, tool_def=tool.tool_def) keeps the concrete type of source_tool (e.g.
FunctionToolsetTool) and all its other fields (args_validator, original_name, …), only
swapping tool_def. I confirmed this fix resolves the reproduction below without touching any
other test in tests/toolsets/. Happy to submit this as a PR with a regression test if that's
preferred over the pydantic-ai bot picking it up.
Minimal, Reproducible Example
from dataclasses import replace
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.test import TestModel
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import CombinedToolset, FunctionToolset, PreparedToolset
received_metadata = []
class SpyToolset(FunctionToolset):
"""Leaf toolset that records the `tool_def` it actually receives in `call_tool`."""
async def call_tool(self, name, tool_args, ctx, tool):
received_metadata.append(tool.tool_def.metadata)
return await super().call_tool(name, tool_args, ctx, tool)
spy = SpyToolset()
@spy.tool_plain
def my_tool(x: int) -> int:
return x
async def prepare(ctx: RunContext[None], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
# Stand-in for e.g. a `SetToolMetadata`-style wrapper tagging a tool for special dispatch.
return [replace(td, metadata={'temporal': {'child_workflow': True}}) for td in tool_defs]
combined = CombinedToolset([spy]) # what `Agent` builds internally once there's >1 toolset source
prepared = PreparedToolset(combined, prepare)
agent = Agent('test', toolsets=[prepared])
agent.run_sync('call my_tool', model=TestModel(call_tools=['my_tool']))
print('received metadata:', received_metadata)
assert received_metadata == [{'temporal': {'child_workflow': True}}], (
'metadata set by PreparedToolset was lost by the time CombinedToolset dispatched the call'
)Actual output:
Exit code 1
received metadata: [None]
Traceback (most recent call last):
assert received_metadata == [{'temporal': {'child_workflow': True}}], (
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: metadata set by PreparedToolset was lost by the time CombinedToolset dispatched the callExpected: received metadata: [{'temporal': {'child_workflow': True}}].
Minimal, Reproducible Example
from dataclasses import replace
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.test import TestModel
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import CombinedToolset, FunctionToolset, PreparedToolset
received_metadata = []
class SpyToolset(FunctionToolset):
"""Leaf toolset that records the `tool_def` it actually receives in `call_tool`."""
async def call_tool(self, name, tool_args, ctx, tool):
received_metadata.append(tool.tool_def.metadata)
return await super().call_tool(name, tool_args, ctx, tool)
spy = SpyToolset()
@spy.tool_plain
def my_tool(x: int) -> int:
return x
async def prepare(ctx: RunContext[None], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
# Stand-in for e.g. a `SetToolMetadata`-style wrapper tagging a tool for special dispatch.
return [replace(td, metadata={'temporal': {'child_workflow': True}}) for td in tool_defs]
combined = CombinedToolset([spy]) # what `Agent` builds internally once there's >1 toolset source
prepared = PreparedToolset(combined, prepare)
agent = Agent('test', toolsets=[prepared])
agent.run_sync('call my_tool', model=TestModel(call_tools=['my_tool']))
print('received metadata:', received_metadata)
assert received_metadata == [{'temporal': {'child_workflow': True}}], (
'metadata set by PreparedToolset was lost by the time CombinedToolset dispatched the call'
)Logfire Trace
No response
Python, Pydantic AI & LLM client version
- Python: 3.13.12
- Pydantic AI:
v2.27.1(also reproduces onmain@fc6a3ac5and earlier —combined.pyis unchanged since #4977 introducedTemporalDurability/capabilities, so this isn't specific to any recent commit) - LLM provider SDK: N/A (reproduces with
TestModel, no network calls)
Source: pydantic/pydantic-ai