diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 4d47b45a68502..8927ce6310b3e 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -293,9 +293,14 @@ class ToolResultContent: agent_id: str tool_call_id: str tool_name: str - tool_result: JsonObjectType + result: llm.ToolResult created: datetime = field(init=False, default_factory=utcnow) + @property + def tool_result(self) -> JsonObjectType: + """Return the data of the result.""" + return self.result.data + def as_dict(self) -> dict[str, Any]: """Return a dictionary representation of the content.""" return { @@ -303,7 +308,8 @@ def as_dict(self) -> dict[str, Any]: "agent_id": self.agent_id, "tool_call_id": self.tool_call_id, "tool_name": self.tool_name, - "tool_result": self.tool_result, + "result": asdict(self.result), + "tool_result": self.result.data, "created": self.created, } @@ -327,6 +333,7 @@ class ToolResultContentDeltaDict(TypedDict, total=False): role: Literal["tool_result"] tool_call_id: str tool_name: str + result: llm.ToolResult tool_result: JsonObjectType @@ -460,16 +467,17 @@ async def async_add_assistant_content( try: tool_result = await tool_call_tasks[tool_input.id] except (HomeAssistantError, probatio.Invalid) as e: - tool_result = {"error": type(e).__name__} + error_data: JsonObjectType = {"error": type(e).__name__} if str(e): - tool_result["error_text"] = str(e) + error_data["error_text"] = str(e) + tool_result = llm.ToolResult(data=error_data, error=True) LOGGER.debug("Tool response: %s", tool_result) response_content = ToolResultContent( agent_id=content.agent_id, tool_call_id=tool_input.id, tool_name=tool_input.tool_name, - tool_result=tool_result, + result=tool_result, ) self.content.append(response_content) _async_notify_subscribers( @@ -567,7 +575,7 @@ async def async_add_delta_content_stream( ): yield tool_result if self.delta_listener: - self.delta_listener(self, asdict(tool_result)) + self.delta_listener(self, tool_result.as_dict()) current_content = "" current_thinking_content = "" current_native = None @@ -585,15 +593,17 @@ async def async_add_delta_content_stream( }: self.delta_listener(self, filtered_delta) elif delta["role"] == "tool_result": + if (result := delta.get("result")) is None: + result = llm.ToolResult(data=delta["tool_result"]) content = ToolResultContent( agent_id=agent_id, tool_call_id=delta["tool_call_id"], tool_name=delta["tool_name"], - tool_result=delta["tool_result"], + result=result, ) yield content if self.delta_listener: - self.delta_listener(self, asdict(content)) + self.delta_listener(self, content.as_dict()) self.async_add_assistant_content_without_tools(content) else: raise ValueError( @@ -620,7 +630,7 @@ async def async_add_delta_content_stream( ): yield tool_result if self.delta_listener: - self.delta_listener(self, asdict(tool_result)) + self.delta_listener(self, tool_result.as_dict()) async def _async_expand_prompt_template( self, diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c3c4e34ec32bf..7f13c4804f7ff 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -692,7 +692,7 @@ async def _async_execute_intent( agent_id=user_input.agent_id, tool_call_id=tool_input.id, tool_name=tool_input.tool_name, - tool_result=tool_result, + result=llm.ToolResult(data=tool_result), ) ) @@ -1583,7 +1583,7 @@ async def _handle_trigger_result( agent_id=user_input.agent_id, tool_call_id=tool_input.id, tool_name=tool_input.tool_name, - tool_result=tool_result, + result=llm.ToolResult(data=tool_result), ) ) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 6247ac8b97348..ef8d2631588f7 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "gazetteer-matcher==1.1.0", "hassil==3.12.1", - "home-assistant-intents==2026.8.28" + "home-assistant-intents==2026.9.17" ] } diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py index d7c87807fd04a..5d003874bdca0 100644 --- a/homeassistant/components/conversation/util.py +++ b/homeassistant/components/conversation/util.py @@ -18,10 +18,10 @@ def async_get_result_from_chat_log( ) -> ConversationResult: """Get the result from the chat log.""" tool_results = [ - content.tool_result + content.result.data for content in chat_log.content[chat_log.llm_input_provided_index :] if isinstance(content, ToolResultContent) - and isinstance(content.tool_result, llm.IntentResponseDict) + and isinstance(content.result.data, llm.IntentResponseDict) ] if tool_results: diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index ff6ad96804e61..e99f6f24cc34f 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -147,12 +147,12 @@ async def handle_read_resource(uri: AnyUrl) -> Sequence[ReadResourceContents]: tool_response = await llm_api.async_call_tool( llm.ToolInput(tool_name=LIVE_CONTEXT_TOOL_NAME, tool_args={}) ) - if not tool_response.get("success"): - raise HomeAssistantError(cast(str, tool_response["error"])) + if not tool_response.data.get("success"): + raise HomeAssistantError(cast(str, tool_response.data["error"])) return [ ReadResourceContents( - content=cast(str, tool_response["result"]), + content=cast(str, tool_response.data["result"]), mime_type=SNAPSHOT_RESOURCE_MIME_TYPE, ) ] @@ -164,7 +164,7 @@ async def list_tools() -> list[types.Tool]: return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] @server.call_tool() # type: ignore[untyped-decorator] - async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]: + async def call_tool(name: str, arguments: dict) -> types.CallToolResult: """Handle calling tools.""" llm_api = await get_api_instance() tool_input = llm.ToolInput(tool_name=name, tool_args=arguments) @@ -174,11 +174,14 @@ async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]: tool_response = await llm_api.async_call_tool(tool_input) except (HomeAssistantError, probatio.Invalid) as e: raise HomeAssistantError(f"Error calling tool: {e}") from e - return [ - types.TextContent( - type="text", - text=json.dumps(tool_response, ensure_ascii=False), - ) - ] + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=json.dumps(tool_response.data, ensure_ascii=False), + ) + ], + isError=tool_response.error, + ) return server diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index ed2bb2904cd0c..40ca699f1bfe6 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -53,9 +53,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo try: info = await async_interview(host) except TimeoutError as exc: - raise ConfigEntryNotReady(f"Timed out interviewing: {host}") from exc + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="interview_timeout", + translation_placeholders={"host": host}, + ) from exc except OSError as exc: - raise ConfigEntryNotReady(f"Unexpected exception interviewing: {host}") from exc + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="interview_error", + translation_placeholders={"host": host}, + ) from exc manager = ReceiverManager(hass, entry, info) @@ -75,7 +83,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo try: await error except OSError as exc: - raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": host}, + ) from exc return True diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 16f20c2b296cd..365b39c5dbfc6 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -65,7 +65,7 @@ rules: entity-device-class: todo entity-disabled-by-default: done entity-translations: todo - exception-translations: todo + exception-translations: done icon-translations: todo reconfiguration-flow: done repair-issues: done diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index 57c1172bf174e..3d6a1bba007fd 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -51,6 +51,15 @@ } }, "exceptions": { + "cannot_connect": { + "message": "Could not connect to the receiver at {host}." + }, + "interview_error": { + "message": "Unexpected error while interviewing the receiver at {host}." + }, + "interview_timeout": { + "message": "Timed out while interviewing the receiver at {host}." + }, "invalid_sound_mode": { "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}." }, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 517b9fbe85bdd..76c153bec2e11 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -154,6 +154,14 @@ class ToolInput: external: bool = False +@dataclass(slots=True) +class ToolResult: + """Result of a tool call.""" + + data: JsonObjectType + error: bool = False + + class Tool: """LLM Tool base class.""" @@ -164,7 +172,7 @@ class Tool: @abstractmethod async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult | JsonObjectType: """Call the tool.""" raise NotImplementedError @@ -184,7 +192,7 @@ class APIInstance: tools: list[Tool] custom_serializer: Callable[[Any], Any] | None = None - async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: + async def async_call_tool(self, tool_input: ToolInput) -> ToolResult: """Call a LLM tool, validate args and return the response.""" from homeassistant.components.conversation import ( # noqa: PLC0415 ConversationTraceEventType, @@ -202,7 +210,10 @@ async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: else: raise HomeAssistantError(f'Tool "{tool_input.tool_name}" not found') - return await tool.async_call(self.api.hass, tool_input, self.llm_context) + result = await tool.async_call(self.api.hass, tool_input, self.llm_context) + if isinstance(result, ToolResult): + return result + return ToolResult(data=result) @dataclass(slots=True, kw_only=True) @@ -331,7 +342,7 @@ def __init__(self, namespace: str, tool: Tool) -> None: @override async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult | JsonObjectType: """Handle the intent.""" return await self.tool.async_call( hass, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 22604f319e745..e499cd6194f05 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -41,7 +41,7 @@ hass-nabucasa==2.7.0 hassil==3.12.1 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260826.7 -home-assistant-intents==2026.8.28 +home-assistant-intents==2026.9.17 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements.txt b/requirements.txt index cf5b37df4461b..e27870c0bcaf2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==2.7.0 hassil==3.12.1 home-assistant-bluetooth==2.0.0 -home-assistant-intents==2026.8.28 +home-assistant-intents==2026.9.17 httpx==0.28.1 ifaddr==0.2.0 infrared-protocols==10.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 60d1d591565f2..155990c3e65ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1327,7 +1327,7 @@ holidays==0.104 home-assistant-frontend==20260826.7 # homeassistant.components.conversation -home-assistant-intents==2026.8.28 +home-assistant-intents==2026.9.17 # homeassistant.components.homekit homekit-audio-proxy==1.2.1 diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 85c387ff966f7..5f81ce18156e6 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -28,20 +28,23 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'content': list([ + ]), + 'return_code': 0, + 'stderr': '', + 'stdout': ''' + 3268 + + ''', + 'type': 'bash_code_execution_result', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'bash_code_execution', - 'tool_result': dict({ - 'content': list([ - ]), - 'return_code': 0, - 'stderr': '', - 'stdout': ''' - 3268 - - ''', - 'type': 'bash_code_execution_result', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -133,13 +136,16 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'error_code': 'unavailable', + 'type': 'bash_code_execution_tool_result_error', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'bash_code_execution', - 'tool_result': dict({ - 'error_code': 'unavailable', - 'type': 'bash_code_execution_tool_result_error', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -434,10 +440,13 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2024, 6, 3, 23, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test response', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM', 'tool_name': 'test_tool', - 'tool_result': 'Test response', }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -1105,13 +1114,16 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'is_file_update': False, + 'type': 'text_editor_code_execution_create_result', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'text_editor_code_execution', - 'tool_result': dict({ - 'is_file_update': False, - 'type': 'text_editor_code_execution_create_result', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -1201,22 +1213,25 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'lines': list([ + '-3268', + '\\ No newline at end of file', + '+8623', + '\\ No newline at end of file', + ]), + 'new_lines': 1, + 'new_start': 1, + 'old_lines': 1, + 'old_start': 1, + 'type': 'text_editor_code_execution_str_replace_result', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'text_editor_code_execution', - 'tool_result': dict({ - 'lines': list([ - '-3268', - '\\ No newline at end of file', - '+8623', - '\\ No newline at end of file', - ]), - 'new_lines': 1, - 'new_start': 1, - 'old_lines': 1, - 'old_start': 1, - 'type': 'text_editor_code_execution_str_replace_result', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -1314,17 +1329,20 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'content': '8623', + 'file_type': 'text', + 'num_lines': 1, + 'start_line': 1, + 'total_lines': 1, + 'type': 'text_editor_code_execution_view_result', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'text_editor_code_execution', - 'tool_result': dict({ - 'content': '8623', - 'file_type': 'text', - 'num_lines': 1, - 'start_line': 1, - 'total_lines': 1, - 'type': 'text_editor_code_execution_view_result', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -1415,14 +1433,17 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'error_code': 'unavailable', + 'error_message': 'Tool response parsing error for view: Failed to parse tool response as JSON: unexpected character: line 1 column 1 (char 0)', + 'type': 'text_editor_code_execution_tool_result_error', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'text_editor_code_execution', - 'tool_result': dict({ - 'error_code': 'unavailable', - 'error_message': 'Tool response parsing error for view: Failed to parse tool response as JSON: unexpected character: line 1 column 1 (char 0)', - 'type': 'text_editor_code_execution_tool_result_error', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -1516,34 +1537,37 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'tool_references': list([ + dict({ + 'tool_name': 'HassHumidifierSetpoint', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassHumidifierMode', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassClimateSetTemperature', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassFanSetSpeed', + 'type': 'tool_reference', + }), + dict({ + 'tool_name': 'HassSetVolume', + 'type': 'tool_reference', + }), + ]), + 'type': 'tool_search_tool_search_result', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'tool_search', - 'tool_result': dict({ - 'tool_references': list([ - dict({ - 'tool_name': 'HassHumidifierSetpoint', - 'type': 'tool_reference', - }), - dict({ - 'tool_name': 'HassHumidifierMode', - 'type': 'tool_reference', - }), - dict({ - 'tool_name': 'HassClimateSetTemperature', - 'type': 'tool_reference', - }), - dict({ - 'tool_name': 'HassFanSetSpeed', - 'type': 'tool_reference', - }), - dict({ - 'tool_name': 'HassSetVolume', - 'type': 'tool_reference', - }), - ]), - 'type': 'tool_search_tool_search_result', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -1667,13 +1691,16 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'error_code': 'too_many_requests', + 'type': 'tool_search_tool_result_error', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'tool_search', - 'tool_result': dict({ - 'error_code': 'too_many_requests', - 'type': 'tool_search_tool_result_error', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -1796,29 +1823,32 @@ 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_fetch', - 'tool_result': dict({ - 'content': dict({ - 'citations': None, - 'source': dict({ - 'data': ''' - Home Assistant new version is out! - Many new features. - Anthropic integration now supports web fetch tool. - Enjoy the release! - ''', - 'media_type': 'text/plain', - 'type': 'text', + 'result': dict({ + 'data': dict({ + 'content': dict({ + 'citations': None, + 'source': dict({ + 'data': ''' + Home Assistant new version is out! + Many new features. + Anthropic integration now supports web fetch tool. + Enjoy the release! + ''', + 'media_type': 'text/plain', + 'type': 'text', + }), + 'title': 'Latest Home Assistant Release Notes', + 'type': 'document', }), - 'title': 'Latest Home Assistant Release Notes', - 'type': 'document', + 'retrieved_at': '2025-10-31T12:00:00.637242Z', + 'type': 'web_fetch_result', + 'url': 'https://www.home-assistant.io/latest-release-notes/', }), - 'retrieved_at': '2025-10-31T12:00:00.637242Z', - 'type': 'web_fetch_result', - 'url': 'https://www.home-assistant.io/latest-release-notes/', + 'error': False, }), + 'role': 'tool_result', + 'tool_call_id': 'srvtoolu_12345ABC', + 'tool_name': 'web_fetch', }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -2002,13 +2032,16 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'error_code': 'url_not_allowed', + 'type': 'web_fetch_tool_result_error', + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'web_fetch', - 'tool_result': dict({ - 'error_code': 'url_not_allowed', - 'type': 'web_fetch_tool_result_error', - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -2098,27 +2131,30 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'content': list([ + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': '2 days ago', + 'title': "Today's News - Example.com", + 'type': 'web_search_result', + 'url': 'https://www.example.com/todays-news', + }), + dict({ + 'encrypted_content': 'ABCDEFG', + 'page_age': None, + 'title': 'Breaking News - NewsSite.com', + 'type': 'web_search_result', + 'url': 'https://www.newssite.com/breaking-news', + }), + ]), + }), + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'srvtoolu_12345ABC', 'tool_name': 'web_search', - 'tool_result': dict({ - 'content': list([ - dict({ - 'encrypted_content': 'ABCDEFG', - 'page_age': '2 days ago', - 'title': "Today's News - Example.com", - 'type': 'web_search_result', - 'url': 'https://www.example.com/todays-news', - }), - dict({ - 'encrypted_content': 'ABCDEFG', - 'page_age': None, - 'title': 'Breaking News - NewsSite.com', - 'type': 'web_search_result', - 'url': 'https://www.newssite.com/breaking-news', - }), - ]), - }), }), dict({ 'agent_id': 'conversation.claude_conversation', @@ -2358,42 +2394,48 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 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/', + }), + ]), + }), + 'error': False, + }), '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), + 'result': dict({ + 'data': dict({ + 'content': list([ + ]), + 'encrypted_stdout': 'EuQJCioIDRgCIiRj', + 'return_code': 0, + 'stderr': '', + 'type': 'encrypted_code_execution_result', + }), + 'error': False, + }), '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', @@ -2527,13 +2569,16 @@ dict({ 'agent_id': 'conversation.claude_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': dict({ + 'error_code': 'too_many_requests', + 'type': 'web_search_tool_result_error', + }), + 'error': False, + }), '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', diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index d8afbe64dffa7..787e81e541c75 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -2165,13 +2165,17 @@ async def test_container_reused( agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id", tool_name="HassTurnOff", - tool_result={"success": True, "response": "Lights are off."}, + result=llm.ToolResult( + data={"success": True, "response": "Lights are off."} + ), ), conversation.chat_log.ToolResultContent( agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id-2", tool_name="MakeCoffee", - tool_result={"success": False, "response": "Not enough milk."}, + result=llm.ToolResult( + data={"success": False, "response": "Not enough milk."} + ), ), conversation.chat_log.AssistantContent( agent_id="conversation.claude_conversation", @@ -2206,24 +2210,26 @@ async def test_container_reused( agent_id="conversation.claude_conversation", tool_call_id="srvtoolu_12345ABC", tool_name="web_search", - tool_result={ - "content": [ - { - "type": "web_search_result", - "title": "Today's News - Example.com", - "url": "https://www.example.com/todays-news", - "page_age": "2 days ago", - "encrypted_content": "ABCDEFG", - }, - { - "type": "web_search_result", - "title": "Breaking News - NewsSite.com", - "url": "https://www.newssite.com/breaking-news", - "page_age": None, - "encrypted_content": "ABCDEFG", - }, - ] - }, + result=llm.ToolResult( + data={ + "content": [ + { + "type": "web_search_result", + "title": "Today's News - Example.com", + "url": "https://www.example.com/todays-news", + "page_age": "2 days ago", + "encrypted_content": "ABCDEFG", + }, + { + "type": "web_search_result", + "title": "Breaking News - NewsSite.com", + "url": "https://www.newssite.com/breaking-news", + "page_age": None, + "encrypted_content": "ABCDEFG", + }, + ] + } + ), ), conversation.chat_log.AssistantContent( agent_id="conversation.claude_conversation", @@ -2303,21 +2309,23 @@ async def test_container_reused( agent_id="conversation.claude_conversation", tool_call_id="srvtoolu_12345ABC", tool_name="web_fetch", - tool_result={ - "type": "web_fetch_result", - "url": "https://www.home-assistant.io/latest-release-notes/", - "content": { - "type": "document", - "source": { - "type": "text", - "media_type": "text/plain", - "data": "Home Assistant new version is out!\nMany new features.\nAnthropic integration now supports web fetch tool.\nEnjoy the release!", + result=llm.ToolResult( + data={ + "type": "web_fetch_result", + "url": "https://www.home-assistant.io/latest-release-notes/", + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": "Home Assistant new version is out!\nMany new features.\nAnthropic integration now supports web fetch tool.\nEnjoy the release!", + }, + "title": "Latest Home Assistant Release Notes", + "citations": {"enabled": True}, }, - "title": "Latest Home Assistant Release Notes", - "citations": {"enabled": True}, - }, - "retrieved_at": "2026-04-04T10:30:00Z", - }, + "retrieved_at": "2026-04-04T10:30:00Z", + } + ), ), conversation.chat_log.AssistantContent( agent_id="conversation.claude_conversation", @@ -2363,10 +2371,12 @@ async def test_container_reused( agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id", tool_name="GetCurrentTime", - tool_result={ - "speech_slots": {"time": datetime.time(14, 30, 0)}, - "message": "Current time retrieved", - }, + result=llm.ToolResult( + data={ + "speech_slots": {"time": datetime.time(14, 30, 0)}, + "message": "Current time retrieved", + } + ), ), conversation.chat_log.AssistantContent( agent_id="conversation.claude_conversation", @@ -2395,22 +2405,27 @@ async def test_container_reused( agent_id="conversation.claude_conversation", tool_call_id="srvtoolu_015vXmtZNASLa7n9RsoDfcBC", tool_name="tool_search", - tool_result={ - "tool_references": [ - { - "tool_name": "HassHumidifierSetpoint", - "type": "tool_reference", - }, - {"tool_name": "HassHumidifierMode", "type": "tool_reference"}, - { - "tool_name": "HassClimateSetTemperature", - "type": "tool_reference", - }, - {"tool_name": "HassFanSetSpeed", "type": "tool_reference"}, - {"tool_name": "HassSetVolume", "type": "tool_reference"}, - ], - "type": "tool_search_tool_search_result", - }, + result=llm.ToolResult( + data={ + "tool_references": [ + { + "tool_name": "HassHumidifierSetpoint", + "type": "tool_reference", + }, + { + "tool_name": "HassHumidifierMode", + "type": "tool_reference", + }, + { + "tool_name": "HassClimateSetTemperature", + "type": "tool_reference", + }, + {"tool_name": "HassFanSetSpeed", "type": "tool_reference"}, + {"tool_name": "HassSetVolume", "type": "tool_reference"}, + ], + "type": "tool_search_tool_search_result", + } + ), ), conversation.chat_log.AssistantContent( agent_id="conversation.claude_conversation", @@ -2427,16 +2442,18 @@ async def test_container_reused( agent_id="conversation.claude_conversation", tool_call_id="toolu_01KNRWb3ZFufCa7WXtzCakhc", tool_name="HassHumidifierSetpoint", - tool_result={ - "speech": { - "plain": { - "speech": "The Hygrostat is set to 50%", - "extra_data": None, - } - }, - "response_type": "action_done", - "data": {"success": [], "failed": []}, - }, + result=llm.ToolResult( + data={ + "speech": { + "plain": { + "speech": "The Hygrostat is set to 50%", + "extra_data": None, + } + }, + "response_type": "action_done", + "data": {"success": [], "failed": []}, + } + ), ), conversation.chat_log.AssistantContent( agent_id="conversation.claude_conversation", diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index 1a9e37a62d1bc..bdc90ace9a3d5 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -490,6 +490,10 @@ 'chat_log_delta': dict({ 'agent_id': 'test-agent', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test response', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'test_tool_id', 'tool_name': 'test_tool', diff --git a/tests/components/climate/test_llm.py b/tests/components/climate/test_llm.py index 1259f36a5fa6f..65c4d17376f43 100644 --- a/tests/components/climate/test_llm.py +++ b/tests/components/climate/test_llm.py @@ -93,7 +93,7 @@ async def test_set_temperature_omits_empty_optional_targets( "entity_id": ENTITY_ID, ATTR_TEMPERATURE: 25, } - assert response["response_type"] == "action_done" + assert response.data["response_type"] == "action_done" async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: diff --git a/tests/components/cloud/test_entity.py b/tests/components/cloud/test_entity.py index 49803cb270f2e..7c522f8483d11 100644 --- a/tests/components/cloud/test_entity.py +++ b/tests/components/cloud/test_entity.py @@ -244,12 +244,14 @@ async def test_prepare_chat_for_generation_passes_messages_through( agent_id="agent", tool_call_id="mock-tool-call-id", tool_name="HassGetCurrentTime", - tool_result={ - "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, - "response_type": "action_done", - "speech_slots": {"time": datetime.time(12, 0)}, - "data": {"success": [], "failed": []}, - }, + result=llm.ToolResult( + data={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + } + ), ) ) chat_log.async_add_assistant_content_without_tools( diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index 3b1bd5ef9010d..41e9f00f5ffe3 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -17,7 +17,7 @@ async_expose_entity, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import chat_session, intent +from homeassistant.helpers import chat_session, intent, llm class MockAgent(conversation.AbstractConversationAgent): @@ -89,7 +89,7 @@ async def async_call_tool(tool_input): """Call tool.""" if tool_input.id not in self._mock_tool_results: raise ValueError(f"Tool {tool_input.id} not found") - return self._mock_tool_results[tool_input.id] + return llm.ToolResult(data=self._mock_tool_results[tool_input.id]) self._llm_api.async_call_tool = async_call_tool diff --git a/tests/components/conversation/snapshots/test_chat_log.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr index 2c0dc6d996d20..78a5de3179fc8 100644 --- a/tests/components/conversation/snapshots/test_chat_log.ambr +++ b/tests/components/conversation/snapshots/test_chat_log.ambr @@ -40,10 +40,13 @@ dict({ 'agent_id': 'mock-agent-id', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test Result', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'mock-tool-call-id', 'tool_name': 'test_tool', - 'tool_result': 'Test Result', }), ]) # --- @@ -105,10 +108,13 @@ dict({ 'agent_id': 'mock-agent-id', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test Param 1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'mock-tool-call-id', 'tool_name': 'test_tool', - 'tool_result': 'Test Param 1', }), ]) # --- @@ -135,10 +141,13 @@ dict({ 'agent_id': 'mock-agent-id', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test Param 1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'mock-tool-call-id', 'tool_name': 'test_tool', - 'tool_result': 'Test Param 1', }), ]) # --- @@ -165,10 +174,13 @@ dict({ 'agent_id': 'mock-agent-id', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test Param 1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'mock-tool-call-id', 'tool_name': 'test_tool', - 'tool_result': 'Test Param 1', }), dict({ 'agent_id': 'mock-agent-id', @@ -212,18 +224,24 @@ dict({ 'agent_id': 'mock-agent-id', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test Param 1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'mock-tool-call-id', 'tool_name': 'test_tool', - 'tool_result': 'Test Param 1', }), dict({ 'agent_id': 'mock-agent-id', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'Test Param 2', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'mock-tool-call-id-2', 'tool_name': 'test_tool', - 'tool_result': 'Test Param 2', }), ]) # --- diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 948c440e353b4..222056be5add6 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -24,6 +24,7 @@ 'fa', 'fi', 'fr', + 'ga', 'gl', 'gu', 'he', @@ -60,6 +61,7 @@ 'sr-Latn', 'sv', 'sw', + 'ta', 'te', 'th', 'tr', diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index cbe22e8ae5e8b..249388456b71b 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -1,6 +1,5 @@ """Test the conversation session.""" -from dataclasses import asdict from datetime import timedelta from pathlib import Path from typing import Any @@ -494,13 +493,13 @@ async def test_tool_call( assert results[0] == ToolResultContent( agent_id=mock_conversation_input.agent_id, tool_call_id="mock-tool-call-id", - tool_result="Test response", + result=llm.ToolResult(data="Test response"), tool_name="test_tool", ) assert results[1] == ToolResultContent( agent_id=mock_conversation_input.agent_id, tool_call_id="mock-tool-call-id-2", - tool_result="Test response", + result=llm.ToolResult(data="Test response"), tool_name="test_tool", ) @@ -554,7 +553,10 @@ async def test_tool_call_exception( assert result == ToolResultContent( agent_id=mock_conversation_input.agent_id, tool_call_id="mock-tool-call-id", - tool_result={"error": "HomeAssistantError", "error_text": "Test error"}, + result=llm.ToolResult( + data={"error": "HomeAssistantError", "error_text": "Test error"}, + error=True, + ), tool_name="test_tool", ) @@ -680,7 +682,7 @@ async def test_tool_call_exception( "role": "tool_result", "tool_call_id": "mock-tool-call-id", "tool_name": "test_tool", - "tool_result": "Test Result", + "result": llm.ToolResult(data="Test Result"), }, ], ], @@ -749,7 +751,7 @@ async def stream(): # Interweave the tool results with the source deltas into expected_delta if content.role == "tool_result": - expected_delta.append(asdict(content)) + expected_delta.append(content.as_dict()) assert captured_deltas == expected_delta assert results == snapshot @@ -957,13 +959,17 @@ def event_callback( agent_id="test-agent", tool_call_id="test-tool-call-123", tool_name="test_tool", - tool_result="Tool execution completed successfully", + result=llm.ToolResult(data="Tool execution completed successfully"), ) ) # Check tool result content event assert received_events[-1][1] == ChatLogEventType.CONTENT_ADDED tool_result_event = received_events[-1][2]["content"] assert tool_result_event["tool_name"] == "test_tool" + assert tool_result_event["result"] == { + "data": "Tool execution completed successfully", + "error": False, + } assert ( tool_result_event["tool_result"] == "Tool execution completed successfully" ) @@ -1034,3 +1040,44 @@ def event_callback( # Verify no new events were received after unsubscribing assert len(received_events) == events_before_unsubscribe + + +async def test_tool_result_content_deprecated_property() -> None: + """Test the deprecated tool_result property returns the result data.""" + content = ToolResultContent( + agent_id="mock-agent-id", + tool_call_id="mock-tool-call-id", + tool_name="test_tool", + result=llm.ToolResult(data={"answer": 42}), + ) + + assert content.tool_result == {"answer": 42} + + +async def test_add_delta_content_stream_deprecated_tool_result( + hass: HomeAssistant, + mock_conversation_input: ConversationInput, +) -> None: + """Test a delta carrying the deprecated tool_result key is still accepted.""" + + async def stream(): + """Yield a tool result delta using the deprecated key.""" + yield { + "role": "tool_result", + "tool_call_id": "mock-tool-call-id", + "tool_name": "test_tool", + "tool_result": {"answer": 42}, + } + + with ( + chat_session.async_get_chat_session(hass) as session, + async_get_chat_log(hass, session, mock_conversation_input) as chat_log, + ): + results = [ + content + async for content in chat_log.async_add_delta_content_stream( + "mock-agent-id", stream() + ) + ] + + assert results[0].result == llm.ToolResult(data={"answer": 42}) diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 6210642937556..bd743eb4ccd11 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -15,6 +15,7 @@ vacuum, valve, ) +from homeassistant.components.climate import intent as climate_intent from homeassistant.components.conversation import DOMAIN from homeassistant.components.cover import intent as cover_intent from homeassistant.components.homeassistant.exposed_entities import async_expose_entity @@ -511,6 +512,36 @@ async def test_climate_turn_on_off( assert call.data == {"entity_id": [entity_id]} +@pytest.mark.usefixtures("init_components") +async def test_climate_set_fan_mode(hass: HomeAssistant) -> None: + """Test setting the fan mode of a climate device by name.""" + await climate_intent.async_setup_intents(hass) + + entity_id = f"{climate.DOMAIN}.thermostat" + hass.states.async_set( + entity_id, + climate.HVACMode.COOL, + attributes={ + ATTR_SUPPORTED_FEATURES: climate.ClimateEntityFeature.FAN_MODE, + climate.ATTR_FAN_MODE: "low", + climate.ATTR_FAN_MODES: ["auto", "low", "high"], + }, + ) + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_SET_FAN_MODE) + result = await conversation.async_converse( + hass, "set thermostat fan to high", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.data == {"entity_id": entity_id, "fan_mode": "high"} + + @pytest.mark.freeze_time( datetime( year=2013, diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py index b57bc5aea02f5..d7ab37251dff1 100644 --- a/tests/components/conversation/test_util.py +++ b/tests/components/conversation/test_util.py @@ -24,7 +24,7 @@ async def test_async_get_result_from_chat_log( agent_id="mock-agent-id", tool_call_id="mock-tool-call-id", tool_name="mock-tool-name", - tool_result=tool_result, + result=llm.ToolResult(data=tool_result), ), conversation.AssistantContent( agent_id="mock-agent-id", diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 8d397de7a79a5..a1ba7bde51d71 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -23,7 +23,7 @@ ) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import intent +from homeassistant.helpers import intent, llm from homeassistant.helpers.llm import ToolInput from . import API_ERROR_500, CLIENT_ERROR_BAD_REQUEST @@ -115,12 +115,14 @@ async def test_function_call( agent_id=agent_id, tool_call_id="01KGW7TFC1VVVK7ANHVMDA4DJ6", tool_name="HassGetCurrentTime", - tool_result={ - "speech": {"plain": {"speech": "4:24 PM", "extra_data": None}}, - "response_type": "action_done", - "speech_slots": {"time": datetime.time(16, 24, 17, 813343)}, - "data": {"success": [], "failed": []}, - }, + result=llm.ToolResult( + data={ + "speech": {"plain": {"speech": "4:24 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(16, 24, 17, 813343)}, + "data": {"success": [], "failed": []}, + } + ), ) ) mock_chat_log.async_add_assistant_content_without_tools( diff --git a/tests/components/litellm/snapshots/test_conversation.ambr b/tests/components/litellm/snapshots/test_conversation.ambr index a905dbed6dcea..946c7232c8d29 100644 --- a/tests/components/litellm/snapshots/test_conversation.ambr +++ b/tests/components/litellm/snapshots/test_conversation.ambr @@ -154,27 +154,30 @@ dict({ 'agent_id': 'conversation.gpt_3_5_turbo', 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), - 'role': 'tool_result', - 'tool_call_id': 'mock_tool_call_id', - 'tool_name': 'HassGetCurrentTime', - 'tool_result': dict({ + 'result': dict({ 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - }), - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': '12:00 PM', + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), }), }), - 'speech_slots': dict({ - 'time': datetime.time(12, 0), - }), + 'error': False, }), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', }), dict({ 'agent_id': 'conversation.gpt_3_5_turbo', @@ -212,10 +215,13 @@ dict({ 'agent_id': 'conversation.gpt_3_5_turbo', 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'value1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', - 'tool_result': 'value1', }), dict({ 'agent_id': 'conversation.gpt_3_5_turbo', diff --git a/tests/components/litellm/test_conversation.py b/tests/components/litellm/test_conversation.py index 1f3660a3b98d8..5e13f09ccadd4 100644 --- a/tests/components/litellm/test_conversation.py +++ b/tests/components/litellm/test_conversation.py @@ -21,7 +21,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry as er, intent -from homeassistant.helpers.llm import ToolInput +from homeassistant.helpers.llm import ToolInput, ToolResult from . import setup_integration @@ -201,12 +201,14 @@ async def test_function_call( agent_id=AGENT_ID, tool_call_id="mock_tool_call_id", tool_name="HassGetCurrentTime", - tool_result={ - "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, - "response_type": "action_done", - "speech_slots": {"time": datetime.time(12, 0)}, - "data": {"success": [], "failed": []}, - }, + result=ToolResult( + data={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + } + ), ) ) mock_chat_log.async_add_assistant_content_without_tools( diff --git a/tests/components/llama_cpp/conftest.py b/tests/components/llama_cpp/conftest.py index 876a1e9bca9d2..bd2ac68fed1db 100644 --- a/tests/components/llama_cpp/conftest.py +++ b/tests/components/llama_cpp/conftest.py @@ -127,7 +127,7 @@ async def async_call_tool(tool_input: llm.ToolInput) -> llm.ToolResult: raise ValueError( f"Tool {tool_input.id} not found ({self._mock_tool_results})" ) - return self._mock_tool_results[tool_input.id] + return llm.ToolResult(data=self._mock_tool_results[tool_input.id]) self._llm_api.async_call_tool = async_call_tool diff --git a/tests/components/llama_cpp/snapshots/test_conversation.ambr b/tests/components/llama_cpp/snapshots/test_conversation.ambr index d5853b783cf72..6a00ad4a9514c 100644 --- a/tests/components/llama_cpp/snapshots/test_conversation.ambr +++ b/tests/components/llama_cpp/snapshots/test_conversation.ambr @@ -47,10 +47,13 @@ dict({ 'agent_id': 'conversation.llama_cpp_conversation', 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'value1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', - 'tool_result': 'value1', }), dict({ 'agent_id': 'conversation.llama_cpp_conversation', diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 51b2777f6932e..391eedac5b942 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -902,7 +902,9 @@ async def test_mcp_tool_call_unicode( mock_api.api.name = "Assist" mock_api.tools = [] mock_api.custom_serializer = None - mock_api.async_call_tool.return_value = {"message": "这是一个测试"} + mock_api.async_call_tool.return_value = llm.ToolResult( + data={"message": "这是一个测试"} + ) # We need to ensure when the server calls llm.async_get_api, it gets our mock # async_get_api is awaited, so we need an AsyncMock @@ -926,6 +928,43 @@ async def test_mcp_tool_call_unicode( assert "\\u" not in response_text +@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST]) +@pytest.mark.parametrize( + ("tool_result", "expected_is_error"), + [ + pytest.param(llm.ToolResult(data={"ok": True}), False, id="success"), + pytest.param( + llm.ToolResult(data={"error": "nope"}, error=True), True, id="error" + ), + ], +) +async def test_mcp_tool_call_error_flag( + hass: HomeAssistant, + setup_integration: None, + mcp_url: str, + mcp_client: Any, + hass_supervisor_access_token: str, + tool_result: llm.ToolResult, + expected_is_error: bool, +) -> None: + """Test a failed tool result is reported to the client as an error.""" + mock_api = AsyncMock() + mock_api.api.name = "Assist" + mock_api.tools = [] + mock_api.custom_serializer = None + mock_api.async_call_tool.return_value = tool_result + + with patch( + "homeassistant.helpers.llm.async_get_api", new_callable=AsyncMock + ) as mock_get_api: + mock_get_api.return_value = mock_api + async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session: + result = await session.call_tool(name="AnyTool", arguments={}) + + assert result.isError == expected_is_error + assert result.content[0].text == json.dumps(tool_result.data) + + async def test_streamable_api_id_exposes_registered_api( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 0e8b589a94bb4..acfded42862ad 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -497,12 +497,14 @@ async def test_history_conversion( agent_id=agent_id, tool_call_id="01KGW7TFC1VVVK7ANHVMDA4DJ6", tool_name="HassGetCurrentTime", - tool_result={ - "speech": {"plain": {"speech": "4:24 PM", "extra_data": None}}, - "response_type": "action_done", - "speech_slots": {"time": datetime.time(16, 24, 17, 813343)}, - "data": {"success": [], "failed": []}, - }, + result=llm.ToolResult( + data={ + "speech": {"plain": {"speech": "4:24 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(16, 24, 17, 813343)}, + "data": {"success": [], "failed": []}, + } + ), ) ) mock_chat_log.async_add_assistant_content_without_tools( diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 1f9fbdc363565..4a26d617b1bb3 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -3,13 +3,13 @@ import asyncio from unittest.mock import AsyncMock -from aioonkyo import Status +from aioonkyo import ReceiverInfo, Status import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import mock_discovery, setup_integration +from . import RECEIVER_INFO, mock_discovery, setup_integration from tests.common import MockConfigEntry @@ -31,22 +31,27 @@ async def test_load_unload_entry( @pytest.mark.parametrize( - "receiver_infos", + ("receiver_infos", "translation_key"), [ - None, - [], + pytest.param(None, "interview_error", id="interview_error"), + pytest.param([], "interview_timeout", id="interview_timeout"), ], ) async def test_initialization_failure( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - receiver_infos, + receiver_infos: list[ReceiverInfo] | None, + translation_key: str, ) -> None: """Test initialization failure.""" with mock_discovery(receiver_infos): await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.error_reason_translation_key == translation_key + assert mock_config_entry.error_reason_translation_placeholders == { + "host": RECEIVER_INFO.host + } async def test_connection_failure( @@ -60,6 +65,10 @@ async def test_connection_failure( await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.error_reason_translation_key == "cannot_connect" + assert mock_config_entry.error_reason_translation_placeholders == { + "host": RECEIVER_INFO.host + } @pytest.mark.usefixtures("mock_receiver") diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr index bc0c50452a618..d6e0c5508ddd9 100644 --- a/tests/components/open_router/snapshots/test_conversation.ambr +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -154,27 +154,30 @@ dict({ 'agent_id': 'conversation.gpt_3_5_turbo', 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), - 'role': 'tool_result', - 'tool_call_id': 'mock_tool_call_id', - 'tool_name': 'HassGetCurrentTime', - 'tool_result': dict({ + 'result': dict({ 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - }), - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': '12:00 PM', + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), }), }), - 'speech_slots': dict({ - 'time': datetime.time(12, 0), - }), + 'error': False, }), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', }), dict({ 'agent_id': 'conversation.gpt_3_5_turbo', @@ -212,10 +215,13 @@ dict({ 'agent_id': 'conversation.gpt_3_5_turbo', 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'value1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', - 'tool_result': 'value1', }), dict({ 'agent_id': 'conversation.gpt_3_5_turbo', diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py index 5ec1064321584..fbf175e20eec9 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -19,7 +19,7 @@ from homeassistant.const import Platform from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry as er, intent -from homeassistant.helpers.llm import ToolInput +from homeassistant.helpers.llm import ToolInput, ToolResult from . import setup_integration @@ -217,12 +217,14 @@ async def test_function_call( agent_id="conversation.gpt_3_5_turbo", tool_call_id="mock_tool_call_id", tool_name="HassGetCurrentTime", - tool_result={ - "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, - "response_type": "action_done", - "speech_slots": {"time": datetime.time(12, 0)}, - "data": {"success": [], "failed": []}, - }, + result=ToolResult( + data={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + } + ), ) ) mock_chat_log.async_add_assistant_content_without_tools( diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index b71d0b21f8d00..11e7ad1f0848b 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -62,27 +62,30 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), - 'role': 'tool_result', - 'tool_call_id': 'mock-tool-call-id', - 'tool_name': 'HassGetCurrentTime', - 'tool_result': dict({ + 'result': dict({ 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - }), - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': '12:00 PM', + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), }), }), - 'speech_slots': dict({ - 'time': datetime.time(12, 0), - }), + 'error': False, }), + 'role': 'tool_result', + 'tool_call_id': 'mock-tool-call-id', + 'tool_name': 'HassGetCurrentTime', }), dict({ 'agent_id': 'conversation.openai_conversation', @@ -138,10 +141,13 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'value1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', - 'tool_result': 'value1', }), dict({ 'agent_id': 'conversation.openai_conversation', @@ -164,10 +170,13 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'value2', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'call_call_2', 'tool_name': 'test_tool', - 'tool_result': 'value2', }), dict({ 'agent_id': 'conversation.openai_conversation', @@ -281,10 +290,13 @@ dict({ 'agent_id': 'conversation.openai_conversation', 'created': HAFakeDatetime(2025, 10, 31, 18, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'value1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', - 'tool_result': 'value1', }), dict({ 'agent_id': 'conversation.openai_conversation', diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index c1be02036498f..515aab22c91e0 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -41,7 +41,7 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent -from homeassistant.helpers.llm import ToolInput +from homeassistant.helpers.llm import ToolInput, ToolResult from homeassistant.setup import async_setup_component from . import ( @@ -284,12 +284,14 @@ async def test_function_call( agent_id="conversation.openai_conversation", tool_call_id="mock-tool-call-id", tool_name="HassGetCurrentTime", - tool_result={ - "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, - "response_type": "action_done", - "speech_slots": {"time": datetime.time(12, 0, 0, 0)}, - "data": {"success": [], "failed": []}, - }, + result=ToolResult( + data={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0, 0, 0)}, + "data": {"success": [], "failed": []}, + } + ), ) ) mock_chat_log.async_add_assistant_content_without_tools( diff --git a/tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr b/tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr index 724b40399b853..307105078748d 100644 --- a/tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr +++ b/tests/components/ovhcloud_ai_endpoints/snapshots/test_conversation.ambr @@ -154,27 +154,30 @@ dict({ 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), - 'role': 'tool_result', - 'tool_call_id': 'mock_tool_call_id', - 'tool_name': 'HassGetCurrentTime', - 'tool_result': dict({ + 'result': dict({ 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - ]), - }), - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': '12:00 PM', + 'data': dict({ + 'failed': list([ + ]), + 'success': list([ + ]), + }), + 'response_type': 'action_done', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': '12:00 PM', + }), + }), + 'speech_slots': dict({ + 'time': datetime.time(12, 0), }), }), - 'speech_slots': dict({ - 'time': datetime.time(12, 0), - }), + 'error': False, }), + 'role': 'tool_result', + 'tool_call_id': 'mock_tool_call_id', + 'tool_name': 'HassGetCurrentTime', }), dict({ 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', @@ -212,10 +215,13 @@ dict({ 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', 'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc), + 'result': dict({ + 'data': 'value1', + 'error': False, + }), 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', - 'tool_result': 'value1', }), dict({ 'agent_id': 'conversation.meta_llama_3_3_70b_instruct', diff --git a/tests/components/ovhcloud_ai_endpoints/test_conversation.py b/tests/components/ovhcloud_ai_endpoints/test_conversation.py index c0ffd69154f89..01d58ced0c8c0 100644 --- a/tests/components/ovhcloud_ai_endpoints/test_conversation.py +++ b/tests/components/ovhcloud_ai_endpoints/test_conversation.py @@ -24,7 +24,7 @@ from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, intent +from homeassistant.helpers import entity_registry as er, intent, llm from homeassistant.helpers.llm import ToolInput from . import setup_integration @@ -355,12 +355,14 @@ async def test_function_call( agent_id="conversation.meta_llama_3_3_70b_instruct", tool_call_id="mock_tool_call_id", tool_name="HassGetCurrentTime", - tool_result={ - "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, - "response_type": "action_done", - "speech_slots": {"time": datetime.time(12, 0)}, - "data": {"success": [], "failed": []}, - }, + result=llm.ToolResult( + data={ + "speech": {"plain": {"speech": "12:00 PM", "extra_data": None}}, + "response_type": "action_done", + "speech_slots": {"time": datetime.time(12, 0)}, + "data": {"success": [], "failed": []}, + } + ), ) ) mock_chat_log.async_add_assistant_content_without_tools( diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index b39a68ed89f0d..972b9adb5998c 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -157,10 +157,42 @@ async def test_call_non_intent_tool_preserves_blank_arguments( result = await instance.async_call_tool(llm.ToolInput(tool.name, tool_args)) - assert result == {"tool_args": tool_args} + assert result.data == {"tool_args": tool_args} assert tool.async_call.await_args.args[1].tool_args is tool_args +@pytest.mark.parametrize( + ("tool_return_value", "expected"), + [ + pytest.param( + {"answer": 42}, + llm.ToolResult(data={"answer": 42}), + id="plain-json-object", + ), + pytest.param( + llm.ToolResult(data={"answer": 42}, error=True), + llm.ToolResult(data={"answer": 42}, error=True), + id="tool-result", + ), + ], +) +async def test_call_tool_result( + hass: HomeAssistant, + llm_context: llm.LLMContext, + tool_return_value: llm.ToolResult | JsonObjectType, + expected: llm.ToolResult, +) -> None: + """Test a tool result is returned as is and a JSON object is wrapped.""" + tool = MagicMock(spec=llm.Tool) + tool.name = "test_tool" + tool.async_call = AsyncMock(return_value=tool_return_value) + instance = llm.APIInstance( + MyAPI(hass=hass, id="test", name="Test"), "", llm_context, [tool] + ) + + assert await instance.async_call_tool(llm.ToolInput(tool.name, {})) == expected + + @pytest.mark.parametrize("namespaced", [False, True]) async def test_intent_tool_omits_blank_arguments( hass: HomeAssistant, llm_context: llm.LLMContext, namespaced: bool @@ -295,7 +327,7 @@ class MyIntentHandler(intent.IntentHandler): assistant="conversation", device_id=None, ) - assert response == { + assert response.data == { "data": { "failed": [], "success": [], @@ -353,7 +385,7 @@ class MyIntentHandler(intent.IntentHandler): assistant="conversation", device_id=device.id, ) - assert response == { + assert response.data == { "data": { "failed": [], "success": [], @@ -789,7 +821,7 @@ def create_entity( result = await api.async_call_tool( llm.ToolInput(tool_name="homeassistant__GetLiveContext", tool_args={}) ) - assert result == { + assert result.data == { "success": True, "result": exposed_entities_prompt, } @@ -970,7 +1002,7 @@ async def test_action_tool( blocking=True, return_response=True, ) - assert response == { + assert response.data == { "success": True, "result": {"drinks": 2}, } @@ -995,7 +1027,7 @@ async def test_action_tool( blocking=True, return_response=True, ) - assert response == { + assert response.data == { "success": True, "result": {}, } @@ -1394,12 +1426,12 @@ async def async_call( result = await instance.async_call_tool( llm.ToolInput(tool_name="api-1__Tool_1", tool_args={"arg1": "value1"}) ) - assert result == {"result": {"Tool_1": {"arg1": "value1"}}} + assert result.data == {"result": {"Tool_1": {"arg1": "value1"}}} result = await instance.async_call_tool( llm.ToolInput(tool_name="api-2__Tool_2", tool_args={"arg2": "value2"}) ) - assert result == {"result": {"Tool_2": {"arg2": "value2"}}} + assert result.data == {"result": {"Tool_2": {"arg2": "value2"}}} async def test_deprecated_async_render_no_api_prompt(