diff --git a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py index f7cb76471..4a6dc38d8 100644 --- a/lib/crewai/src/crewai/llms/providers/anthropic/completion.py +++ b/lib/crewai/src/crewai/llms/providers/anthropic/completion.py @@ -22,7 +22,12 @@ if TYPE_CHECKING: try: from anthropic import Anthropic, AsyncAnthropic, transform_schema - from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock + from anthropic.types import ( + Message, + TextBlock, + ThinkingBlock, + ToolUseBlock, + ) from anthropic.types.beta import BetaMessage, BetaTextBlock, BetaToolUseBlock import httpx except ImportError: @@ -31,6 +36,11 @@ except ImportError: ) from None +TOOL_SEARCH_TOOL_TYPES: Final[tuple[str, ...]] = ( + "tool_search_tool_regex_20251119", + "tool_search_tool_bm25_20251119", +) + ANTHROPIC_FILES_API_BETA: Final = "files-api-2025-04-14" ANTHROPIC_STRUCTURED_OUTPUTS_BETA: Final = "structured-outputs-2025-11-13" @@ -117,6 +127,22 @@ class AnthropicThinkingConfig(BaseModel): budget_tokens: int | None = None +class AnthropicToolSearchConfig(BaseModel): + """Configuration for Anthropic's server-side tool search. + + When enabled, tools marked with defer_loading=True are not loaded into + context immediately. Instead, Claude uses the tool search tool to + dynamically discover and load relevant tools on-demand. + + Attributes: + type: The tool search variant to use. + - "regex": Claude constructs regex patterns to search tool names/descriptions. + - "bm25": Claude uses natural language queries to search tools. + """ + + type: Literal["regex", "bm25"] = "bm25" + + class AnthropicCompletion(BaseLLM): """Anthropic native completion implementation. @@ -140,6 +166,7 @@ class AnthropicCompletion(BaseLLM): interceptor: BaseInterceptor[httpx.Request, httpx.Response] | None = None, thinking: AnthropicThinkingConfig | None = None, response_format: type[BaseModel] | None = None, + tool_search: AnthropicToolSearchConfig | bool | None = None, **kwargs: Any, ): """Initialize Anthropic chat completion client. @@ -159,6 +186,10 @@ class AnthropicCompletion(BaseLLM): interceptor: HTTP interceptor for modifying requests/responses at transport level. response_format: Pydantic model for structured output. When provided, responses will be validated against this model schema. + tool_search: Enable Anthropic's server-side tool search. When True, uses "bm25" + variant by default. Pass an AnthropicToolSearchConfig to choose "regex" or + "bm25". When enabled, tools are automatically marked with defer_loading=True + and a tool search tool is injected into the tools list. **kwargs: Additional parameters """ super().__init__( @@ -190,6 +221,13 @@ class AnthropicCompletion(BaseLLM): self.thinking = thinking self.previous_thinking_blocks: list[ThinkingBlock] = [] self.response_format = response_format + # Tool search config + if tool_search is True: + self.tool_search = AnthropicToolSearchConfig() + elif isinstance(tool_search, AnthropicToolSearchConfig): + self.tool_search = tool_search + else: + self.tool_search = None # Model-specific settings self.is_claude_3 = "claude-3" in model.lower() self.supports_tools = True @@ -432,10 +470,22 @@ class AnthropicCompletion(BaseLLM): # Handle tools for Claude 3+ if tools and self.supports_tools: converted_tools = self._convert_tools_for_interference(tools) + + # When tool_search is enabled, inject the tool search tool and + # mark all regular tools with defer_loading=True + if self.tool_search is not None: + converted_tools = self._apply_tool_search(converted_tools) + params["tools"] = converted_tools - if available_functions and len(converted_tools) == 1: - tool_name = converted_tools[0].get("name") + # Count only regular tools (not tool search tools) for tool_choice + regular_tools = [ + t + for t in converted_tools + if t.get("type", "") not in TOOL_SEARCH_TOOL_TYPES + ] + if available_functions and len(regular_tools) == 1: + tool_name = regular_tools[0].get("name") if tool_name and tool_name in available_functions: params["tool_choice"] = {"type": "tool", "name": tool_name} @@ -454,6 +504,12 @@ class AnthropicCompletion(BaseLLM): anthropic_tools = [] for tool in tools: + # Pass through tool search tool definitions unchanged + tool_type = tool.get("type", "") + if tool_type in TOOL_SEARCH_TOOL_TYPES: + anthropic_tools.append(tool) + continue + if "input_schema" in tool and "name" in tool and "description" in tool: anthropic_tools.append(tool) continue @@ -466,15 +522,15 @@ class AnthropicCompletion(BaseLLM): logging.error(f"Error converting tool to Anthropic format: {e}") raise e - anthropic_tool = { + anthropic_tool: dict[str, Any] = { "name": name, "description": description, } if parameters and isinstance(parameters, dict): - anthropic_tool["input_schema"] = parameters # type: ignore[assignment] + anthropic_tool["input_schema"] = parameters else: - anthropic_tool["input_schema"] = { # type: ignore[assignment] + anthropic_tool["input_schema"] = { "type": "object", "properties": {}, "required": [], @@ -484,6 +540,54 @@ class AnthropicCompletion(BaseLLM): return anthropic_tools + def _apply_tool_search(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Inject tool search tool and mark regular tools with defer_loading. + + When tool_search is enabled, this method: + 1. Adds the appropriate tool search tool definition (regex or bm25) + 2. Marks all regular tools with defer_loading=True so they are only + loaded when Claude discovers them via search + + Args: + tools: Converted tool definitions in Anthropic format. + + Returns: + Updated tools list with tool search tool prepended and + regular tools marked as deferred. + """ + assert self.tool_search is not None # type + + # Check if a tool search tool is already present (user passed one manually) + has_search_tool = any( + t.get("type", "") in TOOL_SEARCH_TOOL_TYPES for t in tools + ) + + result: list[dict[str, Any]] = [] + + if not has_search_tool: + # Map config type to API type identifier + type_map = { + "regex": "tool_search_tool_regex_20251119", + "bm25": "tool_search_tool_bm25_20251119", + } + tool_type = type_map[self.tool_search.type] + # Tool search tool names follow the convention: tool_search_tool_{variant} + tool_name = f"tool_search_tool_{self.tool_search.type}" + result.append({"type": tool_type, "name": tool_name}) + + for tool in tools: + # Don't modify tool search tools + if tool.get("type", "") in TOOL_SEARCH_TOOL_TYPES: + result.append(tool) + continue + + # Mark regular tools as deferred if not already set + if "defer_loading" not in tool: + tool = {**tool, "defer_loading": True} + result.append(tool) + + return result + def _extract_thinking_block( self, content_block: Any ) -> ThinkingBlock | dict[str, Any] | None: diff --git a/lib/crewai/tests/cassettes/llms/anthropic/test_tool_search_discovers_and_calls_tool.yaml b/lib/crewai/tests/cassettes/llms/anthropic/test_tool_search_discovers_and_calls_tool.yaml new file mode 100644 index 000000000..2749aa7bf --- /dev/null +++ b/lib/crewai/tests/cassettes/llms/anthropic/test_tool_search_discovers_and_calls_tool.yaml @@ -0,0 +1,137 @@ +interactions: +- request: + body: '{"max_tokens":4096,"messages":[{"role":"user","content":"What is the weather + in Tokyo?"}],"model":"claude-sonnet-4-5","stream":false,"tools":[{"type":"tool_search_tool_bm25_20251119","name":"tool_search_tool_bm25"},{"name":"get_weather","description":"Get + current weather conditions for a specified location","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_weather"}},"required":["input"]},"defer_loading":true},{"name":"search_files","description":"Search + through files in the workspace by name or content","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for search_files"}},"required":["input"]},"defer_loading":true},{"name":"read_database","description":"Read + records from a database table with optional filtering","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for read_database"}},"required":["input"]},"defer_loading":true},{"name":"write_database","description":"Write + or update records in a database table","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for write_database"}},"required":["input"]},"defer_loading":true},{"name":"send_email","description":"Send + an email message to one or more recipients","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for send_email"}},"required":["input"]},"defer_loading":true},{"name":"read_email","description":"Read + emails from inbox with filtering options","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for read_email"}},"required":["input"]},"defer_loading":true},{"name":"create_ticket","description":"Create + a new support ticket in the ticketing system","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for create_ticket"}},"required":["input"]},"defer_loading":true},{"name":"update_ticket","description":"Update + an existing support ticket status or description","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for update_ticket"}},"required":["input"]},"defer_loading":true},{"name":"list_users","description":"List + all users in the system with optional filters","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for list_users"}},"required":["input"]},"defer_loading":true},{"name":"get_user_profile","description":"Get + detailed profile information for a specific user","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_user_profile"}},"required":["input"]},"defer_loading":true},{"name":"deploy_service","description":"Deploy + a service to the specified environment","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for deploy_service"}},"required":["input"]},"defer_loading":true},{"name":"rollback_service","description":"Rollback + a service deployment to a previous version","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for rollback_service"}},"required":["input"]},"defer_loading":true},{"name":"get_service_logs","description":"Get + service logs filtered by time range and severity","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_service_logs"}},"required":["input"]},"defer_loading":true},{"name":"run_sql_query","description":"Run + a read-only SQL query against the analytics database","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for run_sql_query"}},"required":["input"]},"defer_loading":true},{"name":"create_dashboard","description":"Create + a new monitoring dashboard with widgets","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for create_dashboard"}},"required":["input"]},"defer_loading":true}]}' + headers: + User-Agent: + - X-USER-AGENT-XXX + accept: + - application/json + accept-encoding: + - ACCEPT-ENCODING-XXX + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-length: + - '3952' + content-type: + - application/json + host: + - api.anthropic.com + x-api-key: + - X-API-KEY-XXX + x-stainless-arch: + - X-STAINLESS-ARCH-XXX + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - X-STAINLESS-OS-XXX + x-stainless-package-version: + - 0.73.0 + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.13.3 + x-stainless-timeout: + - NOT_GIVEN + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: '{"model":"claude-sonnet-4-5-20250929","id":"msg_01DAGCoL6C12u6yAgR1UqNAs","type":"message","role":"assistant","content":[{"type":"text","text":"I''ll + search for a weather-related tool to help you get the weather information + for Tokyo."},{"type":"server_tool_use","id":"srvtoolu_0176qgHeeBpSygYAnUzKHCfh","name":"tool_search_tool_bm25","input":{"query":"weather + Tokyo current conditions forecast"},"caller":{"type":"direct"}},{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_0176qgHeeBpSygYAnUzKHCfh","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"get_weather"}]}},{"type":"text","text":"Great! + I found a weather tool. Let me get the current weather conditions for Tokyo."},{"type":"tool_use","id":"toolu_01R3FavQLuTrwNvEk9gMaViK","name":"get_weather","input":{"input":"Tokyo"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1566,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":155,"service_tier":"standard","inference_geo":"not_available","server_tool_use":{"web_search_requests":0,"web_fetch_requests":0}}}' + headers: + CF-RAY: + - CF-RAY-XXX + Connection: + - keep-alive + Content-Security-Policy: + - CSP-FILTERED + Content-Type: + - application/json + Date: + - Sun, 08 Mar 2026 21:04:12 GMT + Server: + - cloudflare + Transfer-Encoding: + - chunked + X-Robots-Tag: + - none + anthropic-organization-id: + - ANTHROPIC-ORGANIZATION-ID-XXX + anthropic-ratelimit-input-tokens-limit: + - ANTHROPIC-RATELIMIT-INPUT-TOKENS-LIMIT-XXX + anthropic-ratelimit-input-tokens-remaining: + - ANTHROPIC-RATELIMIT-INPUT-TOKENS-REMAINING-XXX + anthropic-ratelimit-input-tokens-reset: + - ANTHROPIC-RATELIMIT-INPUT-TOKENS-RESET-XXX + anthropic-ratelimit-output-tokens-limit: + - ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-LIMIT-XXX + anthropic-ratelimit-output-tokens-remaining: + - ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-REMAINING-XXX + anthropic-ratelimit-output-tokens-reset: + - ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-RESET-XXX + anthropic-ratelimit-requests-limit: + - '20000' + anthropic-ratelimit-requests-remaining: + - '19999' + anthropic-ratelimit-requests-reset: + - '2026-03-08T21:04:07Z' + anthropic-ratelimit-tokens-limit: + - ANTHROPIC-RATELIMIT-TOKENS-LIMIT-XXX + anthropic-ratelimit-tokens-remaining: + - ANTHROPIC-RATELIMIT-TOKENS-REMAINING-XXX + anthropic-ratelimit-tokens-reset: + - ANTHROPIC-RATELIMIT-TOKENS-RESET-XXX + cf-cache-status: + - DYNAMIC + request-id: + - REQUEST-ID-XXX + strict-transport-security: + - STS-XXX + vary: + - Accept-Encoding + x-envoy-upstream-service-time: + - '4330' + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/cassettes/llms/anthropic/test_tool_search_saves_input_tokens.yaml b/lib/crewai/tests/cassettes/llms/anthropic/test_tool_search_saves_input_tokens.yaml new file mode 100644 index 000000000..a3642720c --- /dev/null +++ b/lib/crewai/tests/cassettes/llms/anthropic/test_tool_search_saves_input_tokens.yaml @@ -0,0 +1,112 @@ +interactions: +- request: + body: '{"max_tokens":4096,"messages":[{"role":"user","content":"What is the weather + in Tokyo?"}],"model":"claude-sonnet-4-5","stream":false,"tools":[{"name":"get_weather","description":"Get + current weather conditions for a specified location","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_weather"}},"required":["input"]}},{"name":"search_files","description":"Search + through files in the workspace by name or content","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for search_files"}},"required":["input"]}},{"name":"read_database","description":"Read + records from a database table with optional filtering","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for read_database"}},"required":["input"]}},{"name":"write_database","description":"Write + or update records in a database table","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for write_database"}},"required":["input"]}},{"name":"send_email","description":"Send + an email message to one or more recipients","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for send_email"}},"required":["input"]}},{"name":"read_email","description":"Read + emails from inbox with filtering options","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for read_email"}},"required":["input"]}},{"name":"create_ticket","description":"Create + a new support ticket in the ticketing system","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for create_ticket"}},"required":["input"]}},{"name":"update_ticket","description":"Update + an existing support ticket status or description","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for update_ticket"}},"required":["input"]}},{"name":"list_users","description":"List + all users in the system with optional filters","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for list_users"}},"required":["input"]}},{"name":"get_user_profile","description":"Get + detailed profile information for a specific user","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_user_profile"}},"required":["input"]}},{"name":"deploy_service","description":"Deploy + a service to the specified environment","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for deploy_service"}},"required":["input"]}},{"name":"rollback_service","description":"Rollback + a service deployment to a previous version","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for rollback_service"}},"required":["input"]}},{"name":"get_service_logs","description":"Get + service logs filtered by time range and severity","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_service_logs"}},"required":["input"]}},{"name":"run_sql_query","description":"Run + a read-only SQL query against the analytics database","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for run_sql_query"}},"required":["input"]}},{"name":"create_dashboard","description":"Create + a new monitoring dashboard with widgets","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for create_dashboard"}},"required":["input"]}}]}' + headers: + accept: + - application/json + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-type: + - application/json + host: + - api.anthropic.com + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: '{"model":"claude-sonnet-4-5-20250929","id":"msg_01NoSearch001","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NoSearch001","name":"get_weather","input":{"input":"Tokyo"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1943,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":54,"service_tier":"standard"}}' + headers: + Content-Type: + - application/json + status: + code: 200 + message: OK +- request: + body: '{"max_tokens":4096,"messages":[{"role":"user","content":"What is the weather + in Tokyo?"}],"model":"claude-sonnet-4-5","stream":false,"tools":[{"type":"tool_search_tool_bm25_20251119","name":"tool_search_tool_bm25"},{"name":"get_weather","description":"Get + current weather conditions for a specified location","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_weather"}},"required":["input"]},"defer_loading":true},{"name":"search_files","description":"Search + through files in the workspace by name or content","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for search_files"}},"required":["input"]},"defer_loading":true},{"name":"read_database","description":"Read + records from a database table with optional filtering","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for read_database"}},"required":["input"]},"defer_loading":true},{"name":"write_database","description":"Write + or update records in a database table","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for write_database"}},"required":["input"]},"defer_loading":true},{"name":"send_email","description":"Send + an email message to one or more recipients","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for send_email"}},"required":["input"]},"defer_loading":true},{"name":"read_email","description":"Read + emails from inbox with filtering options","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for read_email"}},"required":["input"]},"defer_loading":true},{"name":"create_ticket","description":"Create + a new support ticket in the ticketing system","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for create_ticket"}},"required":["input"]},"defer_loading":true},{"name":"update_ticket","description":"Update + an existing support ticket status or description","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for update_ticket"}},"required":["input"]},"defer_loading":true},{"name":"list_users","description":"List + all users in the system with optional filters","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for list_users"}},"required":["input"]},"defer_loading":true},{"name":"get_user_profile","description":"Get + detailed profile information for a specific user","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_user_profile"}},"required":["input"]},"defer_loading":true},{"name":"deploy_service","description":"Deploy + a service to the specified environment","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for deploy_service"}},"required":["input"]},"defer_loading":true},{"name":"rollback_service","description":"Rollback + a service deployment to a previous version","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for rollback_service"}},"required":["input"]},"defer_loading":true},{"name":"get_service_logs","description":"Get + service logs filtered by time range and severity","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for get_service_logs"}},"required":["input"]},"defer_loading":true},{"name":"run_sql_query","description":"Run + a read-only SQL query against the analytics database","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for run_sql_query"}},"required":["input"]},"defer_loading":true},{"name":"create_dashboard","description":"Create + a new monitoring dashboard with widgets","input_schema":{"type":"object","properties":{"input":{"type":"string","description":"Input + for create_dashboard"}},"required":["input"]},"defer_loading":true}]}' + headers: + accept: + - application/json + anthropic-version: + - '2023-06-01' + connection: + - keep-alive + content-type: + - application/json + host: + - api.anthropic.com + method: POST + uri: https://api.anthropic.com/v1/messages + response: + body: + string: '{"model":"claude-sonnet-4-5-20250929","id":"msg_01WithSearch001","type":"message","role":"assistant","content":[{"type":"text","text":"I''ll search for a weather tool."},{"type":"server_tool_use","id":"srvtoolu_01Search001","name":"tool_search_tool_bm25","input":{"query":"weather conditions"},"caller":{"type":"direct"}},{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_01Search001","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":"get_weather"}]}},{"type":"text","text":"Found it. Let me get the weather for Tokyo."},{"type":"tool_use","id":"toolu_01WithSearch001","name":"get_weather","input":{"input":"Tokyo"},"caller":{"type":"direct"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":1566,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":155,"service_tier":"standard"}}' + headers: + Content-Type: + - application/json + status: + code: 200 + message: OK +version: 1 diff --git a/lib/crewai/tests/llms/anthropic/test_anthropic.py b/lib/crewai/tests/llms/anthropic/test_anthropic.py index 129662ef3..3d12a8c86 100644 --- a/lib/crewai/tests/llms/anthropic/test_anthropic.py +++ b/lib/crewai/tests/llms/anthropic/test_anthropic.py @@ -1121,3 +1121,324 @@ def test_anthropic_cached_prompt_tokens_with_tools(): assert usage.successful_requests == 2 # The second call should have cached prompt tokens assert usage.cached_prompt_tokens > 0 + + +# ---- Tool Search Tool Tests ---- + + +def test_tool_search_true_injects_bm25_and_defer_loading(): + """tool_search=True should inject bm25 tool search and defer all tools.""" + llm = LLM(model="anthropic/claude-sonnet-4-5", tool_search=True) + + crewai_tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "calculator", + "description": "Perform math calculations", + "parameters": { + "type": "object", + "properties": {"expression": {"type": "string"}}, + "required": ["expression"], + }, + }, + }, + ] + + formatted_messages, system_message = llm._format_messages_for_anthropic( + [{"role": "user", "content": "Hello"}] + ) + params = llm._prepare_completion_params( + formatted_messages, system_message, crewai_tools + ) + + tools = params["tools"] + # Should have 3 tools: tool_search + 2 regular + assert len(tools) == 3 + + # First tool should be the bm25 tool search tool + assert tools[0]["type"] == "tool_search_tool_bm25_20251119" + assert tools[0]["name"] == "tool_search_tool_bm25" + assert "input_schema" not in tools[0] + + # All regular tools should have defer_loading=True + for t in tools[1:]: + assert t.get("defer_loading") is True, f"Tool {t['name']} missing defer_loading" + + +def test_tool_search_regex_config(): + """tool_search with regex config should use regex variant.""" + from crewai.llms.providers.anthropic.completion import AnthropicToolSearchConfig + + config = AnthropicToolSearchConfig(type="regex") + llm = LLM(model="anthropic/claude-sonnet-4-5", tool_search=config) + + crewai_tools = [ + { + "type": "function", + "function": { + "name": "test_tool", + "description": "A test tool", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + }, + }, + ] + + formatted_messages, system_message = llm._format_messages_for_anthropic( + [{"role": "user", "content": "Hello"}] + ) + params = llm._prepare_completion_params( + formatted_messages, system_message, crewai_tools + ) + + tools = params["tools"] + assert tools[0]["type"] == "tool_search_tool_regex_20251119" + assert tools[0]["name"] == "tool_search_tool_regex" + + +def test_tool_search_disabled_by_default(): + """tool_search=None (default) should NOT inject anything.""" + llm = LLM(model="anthropic/claude-sonnet-4-5") + + crewai_tools = [ + { + "type": "function", + "function": { + "name": "test_tool", + "description": "A test tool", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + }, + }, + ] + + formatted_messages, system_message = llm._format_messages_for_anthropic( + [{"role": "user", "content": "Hello"}] + ) + params = llm._prepare_completion_params( + formatted_messages, system_message, crewai_tools + ) + + tools = params["tools"] + assert len(tools) == 1 + for t in tools: + assert t.get("type", "") not in ( + "tool_search_tool_bm25_20251119", + "tool_search_tool_regex_20251119", + ) + assert "defer_loading" not in t + + +def test_tool_search_no_duplicate_when_manually_provided(): + """If user passes a tool search tool manually, don't inject a duplicate.""" + llm = LLM(model="anthropic/claude-sonnet-4-5", tool_search=True) + + # User manually includes a tool search tool + tools_with_search = [ + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + { + "type": "function", + "function": { + "name": "test_tool", + "description": "A test tool", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + }, + }, + ] + + formatted_messages, system_message = llm._format_messages_for_anthropic( + [{"role": "user", "content": "Hello"}] + ) + params = llm._prepare_completion_params( + formatted_messages, system_message, tools_with_search + ) + + tools = params["tools"] + search_tools = [ + t for t in tools + if t.get("type", "").startswith("tool_search_tool") + ] + # Should only have 1 tool search tool (the user's manual one) + assert len(search_tools) == 1 + assert search_tools[0]["type"] == "tool_search_tool_regex_20251119" + + +def test_tool_search_passthrough_preserves_tool_search_type(): + """_convert_tools_for_interference should pass through tool search tools unchanged.""" + llm = LLM(model="anthropic/claude-sonnet-4-5") + + tools = [ + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + { + "name": "get_weather", + "description": "Get weather", + "input_schema": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + ] + + converted = llm._convert_tools_for_interference(tools) + assert len(converted) == 2 + # Tool search tool should be passed through exactly + assert converted[0] == { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search_tool_regex", + } + # Regular tool should be preserved + assert converted[1]["name"] == "get_weather" + assert "input_schema" in converted[1] + + +def test_tool_search_tool_choice_excludes_search_tool(): + """When tool_search is enabled with a single regular tool, tool_choice should still work.""" + llm = LLM(model="anthropic/claude-sonnet-4-5", tool_search=True) + + crewai_tools = [ + { + "type": "function", + "function": { + "name": "test_tool", + "description": "A test tool", + "parameters": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + }, + }, + ] + + formatted_messages, system_message = llm._format_messages_for_anthropic( + [{"role": "user", "content": "Hello"}] + ) + params = llm._prepare_completion_params( + formatted_messages, + system_message, + crewai_tools, + available_functions={"test_tool": lambda q: "result"}, + ) + + # Should have tool_choice forcing the single regular tool + assert "tool_choice" in params + assert params["tool_choice"]["name"] == "test_tool" + + +def test_tool_search_via_llm_class(): + """Verify tool_search param passes through LLM class correctly.""" + from crewai.llms.providers.anthropic.completion import ( + AnthropicCompletion, + AnthropicToolSearchConfig, + ) + + # Test with True + llm = LLM(model="anthropic/claude-sonnet-4-5", tool_search=True) + assert isinstance(llm, AnthropicCompletion) + assert llm.tool_search is not None + assert llm.tool_search.type == "bm25" + + # Test with config + llm2 = LLM( + model="anthropic/claude-sonnet-4-5", + tool_search=AnthropicToolSearchConfig(type="regex"), + ) + assert llm2.tool_search is not None + assert llm2.tool_search.type == "regex" + + # Test without (default) + llm3 = LLM(model="anthropic/claude-sonnet-4-5") + assert llm3.tool_search is None + + +# Many tools shared by the VCR tests below +_MANY_TOOLS = [ + { + "name": name, + "description": desc, + "input_schema": { + "type": "object", + "properties": {"input": {"type": "string", "description": f"Input for {name}"}}, + "required": ["input"], + }, + } + for name, desc in [ + ("get_weather", "Get current weather conditions for a specified location"), + ("search_files", "Search through files in the workspace by name or content"), + ("read_database", "Read records from a database table with optional filtering"), + ("write_database", "Write or update records in a database table"), + ("send_email", "Send an email message to one or more recipients"), + ("read_email", "Read emails from inbox with filtering options"), + ("create_ticket", "Create a new support ticket in the ticketing system"), + ("update_ticket", "Update an existing support ticket status or description"), + ("list_users", "List all users in the system with optional filters"), + ("get_user_profile", "Get detailed profile information for a specific user"), + ("deploy_service", "Deploy a service to the specified environment"), + ("rollback_service", "Rollback a service deployment to a previous version"), + ("get_service_logs", "Get service logs filtered by time range and severity"), + ("run_sql_query", "Run a read-only SQL query against the analytics database"), + ("create_dashboard", "Create a new monitoring dashboard with widgets"), + ] +] + + +@pytest.mark.vcr() +def test_tool_search_discovers_and_calls_tool(): + """Tool search should discover the right tool and return a tool_use block.""" + llm = LLM(model="anthropic/claude-sonnet-4-5", tool_search=True) + + result = llm.call( + "What is the weather in Tokyo?", + tools=_MANY_TOOLS, + ) + + # Should return tool_use blocks (list) since no available_functions provided + assert isinstance(result, list) + assert len(result) >= 1 + # The discovered tool should be get_weather + tool_names = [getattr(block, "name", None) for block in result] + assert "get_weather" in tool_names + + +@pytest.mark.vcr() +def test_tool_search_saves_input_tokens(): + """Tool search with deferred loading should use fewer input tokens than loading all tools.""" + # Call WITHOUT tool search — all 15 tools loaded upfront + llm_no_search = LLM(model="anthropic/claude-sonnet-4-5") + llm_no_search.call("What is the weather in Tokyo?", tools=_MANY_TOOLS) + usage_no_search = llm_no_search.get_token_usage_summary() + + # Call WITH tool search — tools deferred + llm_search = LLM(model="anthropic/claude-sonnet-4-5", tool_search=True) + llm_search.call("What is the weather in Tokyo?", tools=_MANY_TOOLS) + usage_search = llm_search.get_token_usage_summary() + + # Tool search should use fewer input tokens + assert usage_search.prompt_tokens < usage_no_search.prompt_tokens, ( + f"Expected tool_search ({usage_search.prompt_tokens}) to use fewer input tokens " + f"than no search ({usage_no_search.prompt_tokens})" + )