1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add support for web search dynamic filtering for Anthropic (#164116)

This commit is contained in:
Denis Shulyaka
2026-03-30 20:40:56 +03:00
committed by GitHub
parent 14cb42349a
commit dc111a475e
6 changed files with 631 additions and 56 deletions

View File

@@ -71,6 +71,16 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -19,6 +19,8 @@ from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlockParam,
DocumentBlockParam,
@@ -61,15 +63,16 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultContentParam,
Content as BashCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultContentParam,
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -105,6 +108,7 @@ from .const import (
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
@@ -224,12 +228,22 @@ def _convert_content(
},
),
}
elif content.tool_name == "code_execution":
tool_result_block = {
"type": "code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
CodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "bash_code_execution":
tool_result_block = {
"type": "bash_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
BashCodeExecutionToolResultContentParam, content.tool_result
BashCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "text_editor_code_execution":
@@ -237,7 +251,7 @@ def _convert_content(
"type": "text_editor_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
TextEditorCodeExecutionToolResultContentParam,
TextEditorCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
@@ -368,6 +382,7 @@ def _convert_content(
name=cast(
Literal[
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
],
@@ -379,6 +394,7 @@ def _convert_content(
and tool_call.tool_name
in [
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
]
@@ -470,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input=response.content_block.input or {},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -532,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input={},
input=response.content_block.input or {},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
),
@@ -594,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] = tool_args
current_tool_block["input"] |= tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=tool_args,
tool_args=current_tool_block["input"],
external=current_tool_block["type"] == "server_tool_use",
)
]
@@ -735,19 +752,34 @@ class AnthropicBaseLLMEntity(Entity):
]
if options.get(CONF_CODE_EXECUTION):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",

View File

@@ -1,5 +1,7 @@
"""Tests for the Anthropic integration."""
from typing import Any
from anthropic.types import (
BashCodeExecutionOutputBlock,
BashCodeExecutionResultBlock,
@@ -7,6 +9,9 @@ from anthropic.types import (
BashCodeExecutionToolResultError,
BashCodeExecutionToolResultErrorCode,
CitationsDelta,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockContent,
DirectCaller,
InputJSONDelta,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
@@ -24,7 +29,9 @@ from anthropic.types import (
ToolUseBlock,
WebSearchResultBlock,
WebSearchToolResultBlock,
WebSearchToolResultError,
)
from anthropic.types.server_tool_use_block import Caller
from anthropic.types.text_editor_code_execution_tool_result_block import (
Content as TextEditorCodeExecutionToolResultBlockContent,
)
@@ -138,45 +145,58 @@ def create_tool_use_block(
def create_server_tool_use_block(
index: int, id: str, name: str, args_parts: list[str]
index: int,
id: str,
name: str,
input_parts: list[str] | dict[str, Any],
caller: Caller | None = None,
) -> list[RawMessageStreamEvent]:
"""Create a server tool use block."""
if caller is None:
caller = DirectCaller(type="direct")
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=ServerToolUseBlock(
type="server_tool_use", id=id, input={}, name=name
type="server_tool_use",
id=id,
input=input_parts if isinstance(input_parts, dict) else {},
name=name,
caller=caller,
),
index=index,
),
*[
RawContentBlockDeltaEvent(
delta=InputJSONDelta(type="input_json_delta", partial_json=args_part),
delta=InputJSONDelta(type="input_json_delta", partial_json=input_part),
index=index,
type="content_block_delta",
)
for args_part in args_parts
for input_part in (input_parts if isinstance(input_parts, list) else [])
],
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]
def create_web_search_block(
index: int, id: str, query_parts: list[str]
) -> list[RawMessageStreamEvent]:
"""Create a server tool use block for web search."""
return create_server_tool_use_block(index, id, "web_search", query_parts)
def create_web_search_result_block(
index: int, id: str, results: list[WebSearchResultBlock]
index: int,
id: str,
results: list[WebSearchResultBlock] | WebSearchToolResultError,
caller: Caller | None = None,
) -> list[RawMessageStreamEvent]:
"""Create a server tool result block for web search results."""
if caller is None:
caller = DirectCaller(type="direct")
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=WebSearchToolResultBlock(
type="web_search_tool_result", tool_use_id=id, content=results
type="web_search_tool_result",
tool_use_id=id,
content=results,
caller=caller,
),
index=index,
),
@@ -184,11 +204,20 @@ def create_web_search_result_block(
]
def create_bash_code_execution_block(
index: int, id: str, command_parts: list[str]
def create_code_execution_result_block(
index: int, id: str, content: CodeExecutionToolResultBlockContent
) -> list[RawMessageStreamEvent]:
"""Create a server tool use block for bash code execution."""
return create_server_tool_use_block(index, id, "bash_code_execution", command_parts)
"""Create a server tool result block for code execution results."""
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=CodeExecutionToolResultBlock(
type="code_execution_tool_result", tool_use_id=id, content=content
),
index=index,
),
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]
def create_bash_code_execution_result_block(
@@ -226,15 +255,6 @@ def create_bash_code_execution_result_block(
]
def create_text_editor_code_execution_block(
index: int, id: str, command_parts: list[str]
) -> list[RawMessageStreamEvent]:
"""Create a server tool use block for text editor code execution."""
return create_server_tool_use_block(
index, id, "text_editor_code_execution", command_parts
)
def create_text_editor_code_execution_result_block(
index: int,
id: str,

View File

@@ -215,7 +215,11 @@ def mock_create_stream() -> Generator[AsyncMock]:
isinstance(event, RawContentBlockStartEvent)
and isinstance(event.content_block, ServerToolUseBlock)
and event.content_block.name
in ["bash_code_execution", "text_editor_code_execution"]
in [
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
]
):
container = Container(
id=kwargs.get("container_id", "container_1234567890ABCDEFGHIJKLMN"),

View File

@@ -1508,3 +1508,286 @@
}),
])
# ---
# name: test_web_search_dynamic_filtering
list([
dict({
'attachments': None,
'content': 'Who won the Nobel for Chemistry in 2025?',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': None,
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': dict({
'citation_details': list([
]),
'container': None,
'redacted_thinking': None,
'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
}),
'role': 'assistant',
'thinking_content': 'Let me search for this information.',
'tool_calls': list([
dict({
'external': True,
'id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
'tool_args': dict({
'code': '''
import json
result = await web_search({"query": "Nobel Prize chemistry 2025 winner"})
parsed = json.loads(result)
for r in parsed[:3]:
print(r.get("title", ""))
print(r.get("content", "")[:300])
print("---")
''',
}),
'tool_name': 'code_execution',
}),
dict({
'external': True,
'id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
'tool_args': dict({
'query': 'Nobel Prize chemistry 2025 winner',
}),
'tool_name': 'web_search',
}),
]),
}),
dict({
'agent_id': 'conversation.claude_conversation',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
'tool_name': 'web_search',
'tool_result': dict({
'content': list([
dict({
'encrypted_content': 'ABCDEFG',
'page_age': None,
'title': 'Press release: Nobel Prize in Chemistry 2025 - Example.com',
'type': 'web_search_result',
'url': 'https://www.example.com/prizes/chemistry/2025/press-release/',
}),
dict({
'encrypted_content': 'ABCDEFG',
'page_age': None,
'title': 'Nobel Prize in Chemistry 2025 - NewsSite.com',
'type': 'web_search_result',
'url': 'https://www.newssite.com/prizes/chemistry/2025/summary/',
}),
]),
}),
}),
dict({
'agent_id': 'conversation.claude_conversation',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
'tool_name': 'code_execution',
'tool_result': dict({
'content': list([
]),
'encrypted_stdout': 'EuQJCioIDRgCIiRj',
'return_code': 0,
'stderr': '',
'type': 'encrypted_code_execution_result',
}),
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': 'The 2025 Nobel Prize in Chemistry was awarded jointly to **Susumu Kitagawa**, **Richard Robson**, and **Omar M. Yaghi** "for the development of metalorganic frameworks."',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': dict({
'citation_details': list([
]),
'container': Container(id='container_1234567890ABCDEFGHIJKLMN', expires_at=HAFakeDatetime(2025, 10, 31, 12, 5, tzinfo=datetime.timezone.utc)),
'redacted_thinking': None,
'thinking_signature': None,
}),
'role': 'assistant',
'thinking_content': None,
'tool_calls': None,
}),
])
# ---
# name: test_web_search_dynamic_filtering.1
list([
dict({
'content': 'Who won the Nobel for Chemistry in 2025?',
'role': 'user',
}),
dict({
'content': list([
dict({
'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
'thinking': 'Let me search for this information.',
'type': 'thinking',
}),
dict({
'id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
'input': dict({
'code': '''
import json
result = await web_search({"query": "Nobel Prize chemistry 2025 winner"})
parsed = json.loads(result)
for r in parsed[:3]:
print(r.get("title", ""))
print(r.get("content", "")[:300])
print("---")
''',
}),
'name': 'code_execution',
'type': 'server_tool_use',
}),
dict({
'id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
'input': dict({
'query': 'Nobel Prize chemistry 2025 winner',
}),
'name': 'web_search',
'type': 'server_tool_use',
}),
dict({
'content': list([
dict({
'encrypted_content': 'ABCDEFG',
'page_age': None,
'title': 'Press release: Nobel Prize in Chemistry 2025 - Example.com',
'type': 'web_search_result',
'url': 'https://www.example.com/prizes/chemistry/2025/press-release/',
}),
dict({
'encrypted_content': 'ABCDEFG',
'page_age': None,
'title': 'Nobel Prize in Chemistry 2025 - NewsSite.com',
'type': 'web_search_result',
'url': 'https://www.newssite.com/prizes/chemistry/2025/summary/',
}),
]),
'tool_use_id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
'type': 'web_search_tool_result',
}),
dict({
'content': dict({
'content': list([
]),
'encrypted_stdout': 'EuQJCioIDRgCIiRj',
'return_code': 0,
'stderr': '',
'type': 'encrypted_code_execution_result',
}),
'tool_use_id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
'type': 'code_execution_tool_result',
}),
dict({
'text': 'The 2025 Nobel Prize in Chemistry was awarded jointly to **Susumu Kitagawa**, **Richard Robson**, and **Omar M. Yaghi** "for the development of metalorganic frameworks."',
'type': 'text',
}),
]),
'role': 'assistant',
}),
])
# ---
# name: test_web_search_error
list([
dict({
'attachments': None,
'content': "What's on the news today?",
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': "To get today's news, I'll perform a web search",
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': dict({
'citation_details': list([
]),
'container': None,
'redacted_thinking': None,
'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
}),
'role': 'assistant',
'thinking_content': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.",
'tool_calls': list([
dict({
'external': True,
'id': 'srvtoolu_12345ABC',
'tool_args': dict({
'query': "today's news",
}),
'tool_name': 'web_search',
}),
]),
}),
dict({
'agent_id': 'conversation.claude_conversation',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'tool_result',
'tool_call_id': 'srvtoolu_12345ABC',
'tool_name': 'web_search',
'tool_result': dict({
'error_code': 'too_many_requests',
'type': 'web_search_tool_result_error',
}),
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': 'I am unable to perform the web search at this time.',
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': None,
}),
])
# ---
# name: test_web_search_error.1
list([
dict({
'content': "What's on the news today?",
'role': 'user',
}),
dict({
'content': list([
dict({
'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
'thinking': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.",
'type': 'thinking',
}),
dict({
'text': "To get today's news, I'll perform a web search",
'type': 'text',
}),
dict({
'id': 'srvtoolu_12345ABC',
'input': dict({
'query': "today's news",
}),
'name': 'web_search',
'type': 'server_tool_use',
}),
dict({
'content': dict({
'error_code': 'too_many_requests',
'type': 'web_search_tool_result_error',
}),
'tool_use_id': 'srvtoolu_12345ABC',
'type': 'web_search_tool_result',
}),
dict({
'text': 'I am unable to perform the web search at this time.',
'type': 'text',
}),
]),
'role': 'assistant',
}),
])
# ---

View File

@@ -8,7 +8,9 @@ from anthropic import AuthenticationError, RateLimitError
from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
EncryptedCodeExecutionResultBlock,
Message,
ServerToolCaller20260120,
TextBlock,
TextEditorCodeExecutionCreateResultBlock,
TextEditorCodeExecutionStrReplaceResultBlock,
@@ -16,6 +18,7 @@ from anthropic.types import (
TextEditorCodeExecutionViewResultBlock,
Usage,
WebSearchResultBlock,
WebSearchToolResultError,
)
from anthropic.types.text_editor_code_execution_tool_result_block import (
Content as TextEditorCodeExecutionToolResultBlockContent,
@@ -51,15 +54,14 @@ from homeassistant.setup import async_setup_component
from homeassistant.util import ulid as ulid_util
from . import (
create_bash_code_execution_block,
create_bash_code_execution_result_block,
create_code_execution_result_block,
create_content_block,
create_redacted_thinking_block,
create_text_editor_code_execution_block,
create_server_tool_use_block,
create_text_editor_code_execution_result_block,
create_thinking_block,
create_tool_use_block,
create_web_search_block,
create_web_search_result_block,
)
@@ -864,7 +866,7 @@ async def test_web_search(
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-sonnet-4-5",
CONF_CHAT_MODEL: "claude-sonnet-4-0",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: True,
@@ -909,9 +911,10 @@ async def test_web_search(
*create_content_block(
1, ["To get today's news, I'll perform a web search"]
),
*create_web_search_block(
*create_server_tool_use_block(
2,
"srvtoolu_12345ABC",
"web_search",
["", '{"que', 'ry"', ": \"today's", ' news"}'],
),
*create_web_search_result_block(3, "srvtoolu_12345ABC", web_search_results),
@@ -984,6 +987,226 @@ async def test_web_search(
assert mock_create_stream.call_args.kwargs["messages"] == snapshot
@freeze_time("2025-10-31 12:00:00")
async def test_web_search_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test web search error."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-sonnet-4-0",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: True,
CONF_WEB_SEARCH_CITY: "San Francisco",
CONF_WEB_SEARCH_REGION: "California",
CONF_WEB_SEARCH_COUNTRY: "US",
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
},
)
web_search_results = WebSearchToolResultError(
type="web_search_tool_result_error",
error_code="too_many_requests",
)
mock_create_stream.return_value = [
(
*create_thinking_block(
0,
[
"The user is",
" asking about today's news, which",
" requires current, real-time information",
". This is clearly something that requires recent",
" information beyond my knowledge cutoff.",
" I should use the web",
"_search tool to fin",
"d today's news.",
],
),
*create_content_block(
1, ["To get today's news, I'll perform a web search"]
),
*create_server_tool_use_block(
2,
"srvtoolu_12345ABC",
"web_search",
["", '{"que', 'ry"', ": \"today's", ' news"}'],
),
*create_web_search_result_block(3, "srvtoolu_12345ABC", web_search_results),
*create_content_block(
4,
["I am unable to perform the web search at this time."],
),
)
]
result = await conversation.async_converse(
hass,
"What's on the news today?",
None,
Context(),
agent_id="conversation.claude_conversation",
)
chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get(
result.conversation_id
)
# Don't test the prompt because it's not deterministic
assert chat_log.content[1:] == snapshot
assert mock_create_stream.call_args.kwargs["messages"] == snapshot
@freeze_time("2025-10-31 12:00:00")
async def test_web_search_dynamic_filtering(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test web search with dynamic filtering of the results."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_CODE_EXECUTION: True,
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: True,
CONF_WEB_SEARCH_CITY: "San Francisco",
CONF_WEB_SEARCH_REGION: "California",
CONF_WEB_SEARCH_COUNTRY: "US",
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
},
)
web_search_results = [
WebSearchResultBlock(
type="web_search_result",
title="Press release: Nobel Prize in Chemistry 2025 - Example.com",
url="https://www.example.com/prizes/chemistry/2025/press-release/",
page_age=None,
encrypted_content="ABCDEFG",
),
WebSearchResultBlock(
type="web_search_result",
title="Nobel Prize in Chemistry 2025 - NewsSite.com",
url="https://www.newssite.com/prizes/chemistry/2025/summary/",
page_age=None,
encrypted_content="ABCDEFG",
),
]
content = EncryptedCodeExecutionResultBlock(
type="encrypted_code_execution_result",
content=[],
encrypted_stdout="EuQJCioIDRgCIiRj",
return_code=0,
stderr="",
)
mock_create_stream.return_value = [
(
*create_thinking_block(
0, ["Let", " me search", " for this", " information.", ""]
),
*create_server_tool_use_block(
1,
"srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT",
"code_execution",
[
"",
'{"code": "\\nimport',
" json",
"\\nresult",
" = await",
" web",
'_search({\\"',
'query\\": \\"Nobel Prize chemistry',
" 2025 ",
'winner\\"})\\nparsed',
" = json.loads(result)",
"\\nfor",
" r",
" in parsed[:",
"3",
"]:\\n print(r.",
'get(\\"title',
'\\", \\"\\"))',
'\\n print(r.get(\\"',
"content",
'\\", \\"\\")',
"[:300",
'])\\n print(\\"---\\")',
"\\n",
'"}',
],
),
*create_server_tool_use_block(
2,
"srvtoolu_016vjte6G4Lj6yzLc2ak1vY4",
"web_search",
{"query": "Nobel Prize chemistry 2025 winner"},
caller=ServerToolCaller20260120(
type="code_execution_20260120",
tool_id="srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT",
),
),
*create_web_search_result_block(
3,
"srvtoolu_016vjte6G4Lj6yzLc2ak1vY4",
web_search_results,
caller=ServerToolCaller20260120(
type="code_execution_20260120",
tool_id="srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT",
),
),
*create_code_execution_result_block(
4, "srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT", content
),
*create_content_block(
5,
[
"The ",
"2025 Nobel Prize in Chemistry was",
" awarded jointly to **",
"Susumu Kitagawa**,",
" **",
"Richard Robson**, and **Omar",
' M. Yaghi** "',
"for the development of metalorganic frameworks",
'."',
],
),
)
]
result = await conversation.async_converse(
hass,
"Who won the Nobel for Chemistry in 2025?",
None,
Context(),
agent_id="conversation.claude_conversation",
)
chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get(
result.conversation_id
)
# Don't test the prompt because it's not deterministic
assert chat_log.content[1:] == snapshot
assert mock_create_stream.call_args.kwargs["messages"] == snapshot
@freeze_time("2025-10-31 12:00:00")
async def test_bash_code_execution(
hass: HomeAssistant,
@@ -1014,9 +1237,10 @@ async def test_bash_code_execution(
"tmp/number.txt'.",
],
),
*create_bash_code_execution_block(
*create_server_tool_use_block(
1,
"srvtoolu_12345ABC",
"bash_code_execution",
[
"",
'{"c',
@@ -1093,9 +1317,10 @@ async def test_bash_code_execution_error(
"tmp/number.txt'.",
],
),
*create_bash_code_execution_block(
*create_server_tool_use_block(
1,
"srvtoolu_12345ABC",
"bash_code_execution",
[
"",
'{"c',
@@ -1252,8 +1477,8 @@ async def test_text_editor_code_execution(
mock_create_stream.return_value = [
(
*create_content_block(0, ["I'll do it", "."]),
*create_text_editor_code_execution_block(
1, "srvtoolu_12345ABC", args_parts
*create_server_tool_use_block(
1, "srvtoolu_12345ABC", "text_editor_code_execution", args_parts
),
*create_text_editor_code_execution_result_block(
2, "srvtoolu_12345ABC", content=content
@@ -1287,9 +1512,10 @@ async def test_container_reused(
"""Test that container is reused."""
mock_create_stream.return_value = [
(
*create_bash_code_execution_block(
*create_server_tool_use_block(
0,
"srvtoolu_12345ABC",
"bash_code_execution",
['{"command": "echo $RANDOM"}'],
),
*create_bash_code_execution_result_block(