From 8a5f46322b662da5eb069fb147d33d5a914a209e Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Mon, 8 Dec 2025 10:46:37 -0500 Subject: [PATCH] feat(anthropic): tool search support (#34119) --- .../langchain_anthropic/chat_models.py | 95 ++++++++++ .../integration_tests/test_chat_models.py | 68 +++++++ .../tests/unit_tests/test_chat_models.py | 166 ++++++++++++++++++ libs/partners/anthropic/uv.lock | 2 +- 4 files changed, 330 insertions(+), 1 deletion(-) diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index e04485c6970..13c8c6a54d4 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -138,6 +138,8 @@ _TOOL_TYPE_TO_BETA: dict[str, str] = { "code_execution_20250522": "code-execution-2025-05-22", "code_execution_20250825": "code-execution-2025-08-25", "memory_20250818": "context-management-2025-06-27", + "tool_search_tool_regex_20251119": "advanced-tool-use-2025-11-20", + "tool_search_tool_bm25_20251119": "advanced-tool-use-2025-11-20", } @@ -161,6 +163,7 @@ def _is_builtin_tool(tool: Any) -> bool: "web_fetch_", "code_execution_", "memory_", + "tool_search_", ] return any(tool_type.startswith(prefix) for prefix in _builtin_tool_prefixes) @@ -534,7 +537,31 @@ def _format_messages( if k in ("type", "cache_control", "data") }, ) + elif ( + block["type"] == "tool_result" + and isinstance(block.get("content"), list) + and any( + isinstance(item, dict) + and item.get("type") == "tool_reference" + for item in block["content"] + ) + ): + # Tool search results with tool_reference blocks + content.append( + { + k: v + for k, v in block.items() + if k + in ( + "type", + "content", + "tool_use_id", + "cache_control", + ) + }, + ) elif block["type"] == "tool_result": + # Regular tool results that need content formatting tool_content = _format_messages( [HumanMessage(block["content"])], )[1][0]["content"] @@ -1578,6 +1605,74 @@ class ChatAnthropic(BaseChatModel): See the [Claude docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/text-editor-tool) for more info. + + ??? example "Tool search" + + Tool search enables Claude to dynamically discover and load tools on-demand + instead of loading all tool definitions upfront. See the + [LangChain docs](https://docs.langchain.com/oss/python/integrations/chat/anthropic#tool-search) + for more detail. + + ```python hl_lines="8-11 26 36" + from langchain_anthropic import ChatAnthropic + + model = ChatAnthropic( + model="claude-sonnet-4-5-20250929", + ) + + tools = [ + { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search_tool_regex", + }, + { + "name": "get_weather", + "description": "Get the current weather for a location", + "input_schema": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name"}, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + }, + }, + "required": ["location"], + }, + "defer_loading": True, # Tool is loaded on-demand + }, + { + "name": "search_files", + "description": "Search through files in the workspace", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + }, + "required": ["query"], + }, + "defer_loading": True, # Tool is loaded on-demand + }, + ..., + ] + + model_with_tools = model.bind_tools(tools) + response = model_with_tools.invoke("What's the weather in San Francisco?") + ``` + + !!! note "Automatic beta header" + + The required `advanced-tool-use-2025-11-20` beta header is automatically + appended to the request when using tool search tools. + + !!! tip "Best practices" + + - Tools with `defer_loading: True` are only loaded when Claude discovers them via search + - Keep your 3-5 most frequently used tools as non-deferred for optimal performance + - Both variants search tool names, descriptions, argument names, and argument descriptions + + See the [Claude docs](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool) + for more info. """ # noqa: E501 model_config = ConfigDict( diff --git a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py index 127a0f19191..743e39016ec 100644 --- a/libs/partners/anthropic/tests/integration_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/integration_tests/test_chat_models.py @@ -2039,6 +2039,74 @@ def test_context_management() -> None: assert full.response_metadata.get("context_management") +def test_tool_search() -> None: + """Test tool search functionality with both regex and BM25 variants.""" + # Test with regex variant + llm = ChatAnthropic( + model="claude-opus-4-5-20251101", # type: ignore[call-arg] + ) + + # Define tools with defer_loading + tools = [ + { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search_tool_regex", + }, + { + "name": "get_weather", + "description": "Get the current weather for a location", + "input_schema": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name"}, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "Temperature unit", + }, + }, + "required": ["location"], + }, + "defer_loading": True, + }, + { + "name": "search_files", + "description": "Search through files in the workspace", + "input_schema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + }, + "required": ["query"], + }, + "defer_loading": True, + }, + ] + + llm_with_tools = llm.bind_tools(tools) # type: ignore[arg-type] + + # Test with a query that should trigger tool search + input_message = { + "role": "user", + "content": "What's the weather in San Francisco?", + } + response = llm_with_tools.invoke([input_message]) + + # Verify response contains expected block types + assert all(isinstance(block, (str, dict)) for block in response.content) + + # Check for server_tool_use (tool search) and tool_result blocks + block_types = { + block["type"] + for block in response.content + if isinstance(block, dict) and "type" in block + } + + # Response should contain server_tool_use for tool search + # and potentially tool_result with tool_reference blocks + assert "server_tool_use" in block_types or "tool_use" in block_types + + def test_async_shared_client() -> None: llm = ChatAnthropic(model=MODEL_NAME) # type: ignore[call-arg] _ = asyncio.run(llm.ainvoke("Hello")) diff --git a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index d0da0d0b0fd..dced9b63b7a 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -24,6 +24,7 @@ from langchain_anthropic.chat_models import ( _create_usage_metadata, _format_image, _format_messages, + _is_builtin_tool, _merge_messages, convert_to_anthropic_tool, ) @@ -1794,6 +1795,171 @@ def test_auto_append_betas_for_tool_types() -> None: } +def test_tool_search_is_builtin_tool() -> None: + """Test that tool search tools are recognized as built-in tools.""" + # Test regex variant + regex_tool = { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search_tool_regex", + } + assert _is_builtin_tool(regex_tool) + + # Test BM25 variant + bm25_tool = { + "type": "tool_search_tool_bm25_20251119", + "name": "tool_search_tool_bm25", + } + assert _is_builtin_tool(bm25_tool) + + # Test non-builtin tool + regular_tool = { + "name": "get_weather", + "description": "Get weather", + "input_schema": {"type": "object", "properties": {}}, + } + assert not _is_builtin_tool(regular_tool) + + +def test_tool_search_beta_headers() -> None: + """Test that tool search tools auto-append the correct beta headers.""" + # Test regex variant + model = ChatAnthropic(model=MODEL_NAME) # type: ignore[call-arg] + regex_tool = { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search_tool_regex", + } + model_with_tools = model.bind_tools([regex_tool]) + payload = model_with_tools._get_request_payload( # type: ignore[attr-defined] + "test", + **model_with_tools.kwargs, # type: ignore[attr-defined] + ) + assert payload["betas"] == ["advanced-tool-use-2025-11-20"] + + # Test BM25 variant + model = ChatAnthropic(model=MODEL_NAME) # type: ignore[call-arg] + bm25_tool = { + "type": "tool_search_tool_bm25_20251119", + "name": "tool_search_tool_bm25", + } + model_with_tools = model.bind_tools([bm25_tool]) + payload = model_with_tools._get_request_payload( # type: ignore[attr-defined] + "test", + **model_with_tools.kwargs, # type: ignore[attr-defined] + ) + assert payload["betas"] == ["advanced-tool-use-2025-11-20"] + + # Test merging with existing betas + model = ChatAnthropic( + model=MODEL_NAME, + betas=["mcp-client-2025-04-04"], # type: ignore[call-arg] + ) + model_with_tools = model.bind_tools([regex_tool]) + payload = model_with_tools._get_request_payload( # type: ignore[attr-defined] + "test", + **model_with_tools.kwargs, # type: ignore[attr-defined] + ) + assert payload["betas"] == [ + "mcp-client-2025-04-04", + "advanced-tool-use-2025-11-20", + ] + + +def test_tool_search_with_deferred_tools() -> None: + """Test that `defer_loading` works correctly with tool search.""" + llm = ChatAnthropic( + model="claude-opus-4-5-20251101", # type: ignore[call-arg] + ) + + # Create tools with defer_loading + tools = [ + { + "type": "tool_search_tool_bm25_20251119", + "name": "tool_search_tool_bm25", + }, + { + "name": "calculator", + "description": "Perform mathematical calculations", + "input_schema": { + "type": "object", + "properties": { + "expression": { + "type": "string", + "description": "Mathematical expression", + }, + }, + "required": ["expression"], + }, + "defer_loading": True, + }, + ] + + llm_with_tools = llm.bind_tools(tools) # type: ignore[arg-type] + + # Verify the payload includes tools with defer_loading + payload = llm_with_tools._get_request_payload( # type: ignore[attr-defined] + "test", + **llm_with_tools.kwargs, # type: ignore[attr-defined] + ) + + # Find the calculator tool in the payload + calculator_tool = None + for tool in payload["tools"]: + if isinstance(tool, dict) and tool.get("name") == "calculator": + calculator_tool = tool + break + + assert calculator_tool is not None + assert calculator_tool.get("defer_loading") is True + + +def test_tool_search_result_formatting() -> None: + """Test that `tool_result` blocks with `tool_reference` are handled correctly.""" + # Tool search result with tool_reference blocks + messages = [ + HumanMessage("What tools can help with weather?"), # type: ignore[misc] + AIMessage( # type: ignore[misc] + [ + { + "type": "server_tool_use", + "id": "srvtoolu_123", + "name": "tool_search_tool_regex", + "input": {"query": "weather"}, + }, + { + "type": "tool_result", + "tool_use_id": "srvtoolu_123", + "content": [ + {"type": "tool_reference", "tool_name": "get_weather"}, + {"type": "tool_reference", "tool_name": "weather_forecast"}, + ], + }, + ], + ), + ] + + _, formatted = _format_messages(messages) + + # Verify the tool_result block is preserved correctly + assistant_msg = formatted[1] + assert assistant_msg["role"] == "assistant" + + # Find the tool_result block + tool_result_block = None + for block in assistant_msg["content"]: + if isinstance(block, dict) and block.get("type") == "tool_result": + tool_result_block = block + break + + assert tool_result_block is not None + assert tool_result_block["tool_use_id"] == "srvtoolu_123" + assert isinstance(tool_result_block["content"], list) + assert len(tool_result_block["content"]) == 2 + assert tool_result_block["content"][0]["type"] == "tool_reference" + assert tool_result_block["content"][0]["tool_name"] == "get_weather" + assert tool_result_block["content"][1]["type"] == "tool_reference" + assert tool_result_block["content"][1]["tool_name"] == "weather_forecast" + + def test_auto_append_betas_for_mcp_servers() -> None: """Test that `mcp-client-2025-11-20` beta is automatically appended for `mcp_servers`.""" diff --git a/libs/partners/anthropic/uv.lock b/libs/partners/anthropic/uv.lock index 43376f3c173..bfe614c81aa 100644 --- a/libs/partners/anthropic/uv.lock +++ b/libs/partners/anthropic/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10.0, <4.0.0" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'",