Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions pydantic_ai_slim/pydantic_ai/builtin_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,33 @@ def unique_id(self) -> str:
return ':'.join([self.kind, self.id])


@dataclass(kw_only=True)
class ToolSearchTool(AbstractBuiltinTool):
"""A builtin tool that searches for tools during dynamic tool discovery.

To defer loading a tool's definition until the model finds it, mark it as `defer_loading=True`.

Note that only models with `ModelProfile.supports_tool_search` use this builtin tool. These models receive all tool
definitions and natively implement search and loading. All other models rely on `SearchableToolset` instead.

Supported by:

* Anthropic

"""

search_type: Literal['regex', 'bm25'] | None = None
"""Custom search type to use for tool discovery. Currently only supported by Anthropic models.

See https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/tool-search-tool for more info.

- `'regex'`: Constructs Python `re.search()` patterns. Max 200 characters per query. Case-sensitive by default.
- `'bm25'`: Uses natural language queries with semantic matching across tool metadata.
"""

kind: str = 'tool_search'


def _tool_discriminator(tool_data: dict[str, Any] | AbstractBuiltinTool) -> str:
if isinstance(tool_data, dict):
return tool_data.get('kind', AbstractBuiltinTool.kind)
Expand Down
44 changes: 41 additions & 3 deletions pydantic_ai_slim/pydantic_ai/models/anthropic.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations as _annotations

import logging
import io
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator
from contextlib import asynccontextmanager
Expand All @@ -13,7 +14,7 @@
from .. import ModelHTTPError, UnexpectedModelBehavior, _utils, usage
from .._run_context import RunContext
from .._utils import guard_tool_call_id as _guard_tool_call_id
from ..builtin_tools import CodeExecutionTool, MCPServerTool, MemoryTool, WebFetchTool, WebSearchTool
from ..builtin_tools import CodeExecutionTool, MCPServerTool, MemoryTool, ToolSearchTool, WebFetchTool, WebSearchTool
from ..exceptions import ModelAPIError, UserError
from ..messages import (
BinaryContent,
Expand Down Expand Up @@ -114,6 +115,9 @@
BetaToolParam,
BetaToolResultBlockParam,
BetaToolUnionParam,
BetaToolSearchToolBm25_20251119Param,
BetaToolSearchToolRegex20251119Param,
BetaToolSearchToolResultBlock,
BetaToolUseBlock,
BetaToolUseBlockParam,
BetaWebFetchTool20250910Param,
Expand All @@ -125,6 +129,7 @@
BetaWebSearchToolResultBlockParam,
BetaWebSearchToolResultBlockParamContentParam,
)
from anthropic.types.beta.beta_tool_search_tool_result_block import BetaToolSearchToolResultBlock
from anthropic.types.beta.beta_web_fetch_tool_result_block_param import (
Content as WebFetchToolResultBlockParamContent,
)
Expand Down Expand Up @@ -511,6 +516,9 @@ def _process_response(self, response: BetaMessage) -> ModelResponse:
elif isinstance(item, BetaMCPToolResultBlock):
call_part = builtin_tool_calls.get(item.tool_use_id)
items.append(_map_mcp_server_result_block(item, call_part, self.system))
elif isinstance(item, BetaToolSearchToolResultBlock):
call_part = builtin_tool_calls.get(item.tool_use_id)
items.append(_map_mcp_server_result_block(item, call_part, self.system))
else:
assert isinstance(item, BetaToolUseBlock), f'unexpected item type {type(item)}'
items.append(
Expand Down Expand Up @@ -577,6 +585,8 @@ def _add_builtin_tools(
) -> tuple[list[BetaToolUnionParam], list[BetaRequestMCPServerURLDefinitionParam], set[str]]:
beta_features: set[str] = set()
mcp_servers: list[BetaRequestMCPServerURLDefinitionParam] = []
tool_search_type: Literal['regex', 'bm25'] | None = None

for tool in model_request_parameters.builtin_tools:
if isinstance(tool, WebSearchTool):
user_location = UserLocation(type='approximate', **tool.user_location) if tool.user_location else None
Expand Down Expand Up @@ -629,10 +639,32 @@ def _add_builtin_tools(
mcp_server_url_definition_param['authorization_token'] = tool.authorization_token
mcp_servers.append(mcp_server_url_definition_param)
beta_features.add('mcp-client-2025-04-04')
elif isinstance(tool, ToolSearchTool):
tool_search_type = tool.search_type
else: # pragma: no cover
raise UserError(
f'`{tool.__class__.__name__}` is not supported by `AnthropicModel`. If it should be, please file an issue.'
)

needs_tool_search = any(tool.get('defer_loading') for tool in tools)

if needs_tool_search:
beta_features.add('advanced-tool-use-2025-11-20')
if tool_search_type == 'bm25':
tools.append(
BetaToolSearchToolBm25_20251119Param(
name='tool_search_tool_bm25',
type='tool_search_tool_bm25_20251119',
)
)
else:
tools.append(
BetaToolSearchToolRegex20251119Param(
name='tool_search_tool_regex',
type='tool_search_tool_regex_20251119',
)
)

return tools, mcp_servers, beta_features

def _infer_tool_choice(
Expand Down Expand Up @@ -1062,6 +1094,8 @@ def _map_tool_definition(self, f: ToolDefinition) -> BetaToolParam:
'description': f.description or '',
'input_schema': f.parameters_json_schema,
}
if f.defer_loading:
tool_param['defer_loading'] = True
if f.strict and self.profile.supports_json_schema_output:
tool_param['strict'] = f.strict
return tool_param
Expand Down Expand Up @@ -1297,8 +1331,12 @@ def _map_server_tool_use_block(item: BetaServerToolUseBlock, provider_name: str)
elif item.name in ('bash_code_execution', 'text_editor_code_execution'): # pragma: no cover
raise NotImplementedError(f'Anthropic built-in tool {item.name!r} is not currently supported.')
elif item.name in ('tool_search_tool_regex', 'tool_search_tool_bm25'): # pragma: no cover
# NOTE this is being implemented in https://github.com/pydantic/pydantic-ai/pull/3550
raise NotImplementedError(f'Anthropic built-in tool {item.name!r} is not currently supported.')
return BuiltinToolCallPart(
provider_name=provider_name,
tool_name=ToolSearchTool.kind,
args=cast(dict[str, Any], item.input) or None,
tool_call_id=item.id,
)
else:
assert_never(item.name)

Expand Down
3 changes: 3 additions & 0 deletions pydantic_ai_slim/pydantic_ai/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class ModelProfile:
This is currently only used by `OpenAIChatModel`, `HuggingFaceModel`, and `GroqModel`.
"""

supports_tool_search: bool = False
"""Whether the model has native support for tool search (builtin ToolSearchTool) and defer loading tools."""

@classmethod
def from_profile(cls, profile: ModelProfile | None) -> Self:
"""Build a ModelProfile subclass instance from a ModelProfile instance."""
Expand Down
1 change: 1 addition & 0 deletions pydantic_ai_slim/pydantic_ai/profiles/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def anthropic_model_profile(model_name: str) -> ModelProfile | None:
thinking_tags=('<thinking>', '</thinking>'),
supports_json_schema_output=supports_json_schema_output,
json_schema_transformer=AnthropicJsonSchemaTransformer,
supports_tool_search=True,
)


Expand Down
13 changes: 13 additions & 0 deletions pydantic_ai_slim/pydantic_ai/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ class Tool(Generic[ToolAgentDepsT]):
requires_approval: bool
metadata: dict[str, Any] | None
function_schema: _function_schema.FunctionSchema
defer_loading: bool
"""
The base JSON schema for the tool's parameters.

Expand All @@ -297,6 +298,7 @@ def __init__(
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
function_schema: _function_schema.FunctionSchema | None = None,
defer_loading: bool = False,
):
"""Create a new tool instance.

Expand Down Expand Up @@ -353,6 +355,7 @@ async def prep_my_tool(
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
function_schema: The function schema to use for the tool. If not provided, it will be generated.
defer_loading: If True, hide the tool by default and only activate it when the model searches for tools.
"""
self.function = function
self.function_schema = function_schema or _function_schema.function_schema(
Expand All @@ -373,6 +376,7 @@ async def prep_my_tool(
self.sequential = sequential
self.requires_approval = requires_approval
self.metadata = metadata
self.defer_loading = defer_loading

@classmethod
def from_schema(
Expand Down Expand Up @@ -429,6 +433,7 @@ def tool_def(self):
sequential=self.sequential,
metadata=self.metadata,
kind='unapproved' if self.requires_approval else 'function',
defer_loading=self.defer_loading,
)

async def prepare_tool_def(self, ctx: RunContext[ToolAgentDepsT]) -> ToolDefinition | None:
Expand Down Expand Up @@ -514,6 +519,14 @@ class ToolDefinition:
For MCP tools, this contains the `meta`, `annotations`, and `output_schema` fields from the tool definition.
"""

defer_loading: bool = False
"""Whether to defer loading this tool until it is discovered via tool search.

When `True`, this tool will not be loaded into the model's context initially.

Instead, the model will discover it on-demand when needed, reducing token usage.
"""

@property
def defer(self) -> bool:
"""Whether calls to this tool will be deferred.
Expand Down
14 changes: 14 additions & 0 deletions pydantic_ai_slim/pydantic_ai/toolsets/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class FunctionToolset(AbstractToolset[AgentDepsT]):
docstring_format: DocstringFormat
require_parameter_descriptions: bool
schema_generator: type[GenerateJsonSchema]
defer_loading: bool

def __init__(
self,
Expand All @@ -53,6 +54,7 @@ def __init__(
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
id: str | None = None,
defer_loading: bool = False,
):
"""Build a new function toolset.

Expand All @@ -78,6 +80,7 @@ def __init__(
Applies to all tools, unless overridden when adding a tool, which will be merged with the toolset's metadata.
id: An optional unique ID for the toolset. A toolset needs to have an ID in order to be used in a durable execution environment like Temporal,
in which case the ID will be used to identify the toolset's activities within the workflow.
defer_loading: If True, default to hiding each tool and only activate it when the model searches for tools.
"""
self.max_retries = max_retries
self._id = id
Expand All @@ -88,6 +91,7 @@ def __init__(
self.sequential = sequential
self.requires_approval = requires_approval
self.metadata = metadata
self.defer_loading = defer_loading

self.tools = {}
for tool in tools:
Expand Down Expand Up @@ -137,6 +141,7 @@ def tool(
sequential: bool | None = None,
requires_approval: bool | None = None,
metadata: dict[str, Any] | None = None,
defer_loading: bool | None = None,
) -> Any:
"""Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument.

Expand Down Expand Up @@ -193,6 +198,8 @@ async def spam(ctx: RunContext[str], y: float) -> float:
If `None`, the default value is determined by the toolset.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
If `None`, the default value is determined by the toolset. If provided, it will be merged with the toolset's metadata.
defer_loading: If True, hide the tool by default and only activate it when the model searches for tools.
If `None`, the default value is determined by the toolset.
"""

def tool_decorator(
Expand All @@ -213,6 +220,7 @@ def tool_decorator(
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
defer_loading=defer_loading,
)
return func_

Expand All @@ -233,6 +241,7 @@ def add_function(
sequential: bool | None = None,
requires_approval: bool | None = None,
metadata: dict[str, Any] | None = None,
defer_loading: bool | None = None,
) -> None:
"""Add a function as a tool to the toolset.

Expand Down Expand Up @@ -267,6 +276,8 @@ def add_function(
If `None`, the default value is determined by the toolset.
metadata: Optional metadata for the tool. This is not sent to the model but can be used for filtering and tool behavior customization.
If `None`, the default value is determined by the toolset. If provided, it will be merged with the toolset's metadata.
defer_loading: If True, hide the tool by default and only activate it when the model searches for tools.
If `None`, the default value is determined by the toolset.
"""
if docstring_format is None:
docstring_format = self.docstring_format
Expand All @@ -280,6 +291,8 @@ def add_function(
sequential = self.sequential
if requires_approval is None:
requires_approval = self.requires_approval
if defer_loading is None:
defer_loading = self.defer_loading

tool = Tool[AgentDepsT](
func,
Expand All @@ -295,6 +308,7 @@ def add_function(
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
defer_loading=defer_loading,
)
self.add_tool(tool)

Expand Down
Loading