`CombinedToolset.call_tool` dispatches through a stale cached tool, dropping `tool_def` mutations made by outer wrapper toolsets

Author: YHallouardCreated Aug 11, 2026Updated Sep 18, 2026
Labelsbugtoolsdurable execpydanty:bugp:2-high

Initial Checks

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:

python
# 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 CombinedToolsetPreparedToolset (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:

python
# 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):

diff
     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

python
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 call

Expected: received metadata: [{'temporal': {'child_workflow': True}}].

Minimal, Reproducible Example

python
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 on main @ fc6a3ac5 and earlier — combined.py is unchanged since #4977 introduced TemporalDurability/capabilities, so this isn't specific to any recent commit)
  • LLM provider SDK: N/A (reproduces with TestModel, no network calls)