From 9835762807adb0b8b74ba02eb05156187d82d19f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 18 Sep 2026 11:56:17 -0400 Subject: [PATCH] Use ToolResult in the LLM helper, llm and homeassistant (#182538) Co-authored-by: Claude --- homeassistant/components/homeassistant/llm.py | 32 ++-- homeassistant/components/llm/llm.py | 14 +- homeassistant/components/mcp_server/server.py | 2 +- homeassistant/helpers/llm.py | 8 +- tests/components/homeassistant/test_llm.py | 161 +++++++++--------- tests/components/llm/test_tools.py | 9 +- tests/components/script/test_llm.py | 2 +- tests/helpers/test_llm.py | 15 +- 8 files changed, 117 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index c2fbd6a3976b..2dc81095cf5c 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -22,9 +22,14 @@ from homeassistant.helpers import ( entity_registry as er, intent, ) -from homeassistant.helpers.llm import LLM_API_ASSIST, LLMContext, Tool, ToolInput +from homeassistant.helpers.llm import ( + LLM_API_ASSIST, + LLMContext, + Tool, + ToolInput, + ToolResult, +) from homeassistant.util import dt as dt_util, yaml as yaml_util -from homeassistant.util.json import JsonObjectType from .exposed_entities import async_should_expose @@ -247,13 +252,13 @@ class GetLiveContextTool(Tool): hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext, - ) -> JsonObjectType: + ) -> ToolResult: """Get the current state of exposed entities.""" args = self.parameters(tool_input.tool_args) exposed_entities = async_get_exposed_entities(hass, llm_context.assistant) if not exposed_entities: - return {"success": False, "error": NO_ENTITIES_PROMPT} + return ToolResult(data={"error": NO_ENTITIES_PROMPT}, error=True) name_filter = args.get("name") area_filter = args.get("area") @@ -290,12 +295,14 @@ class GetLiveContextTool(Tool): ) if not match_result.is_match: - return { - "success": False, - "error": _live_context_match_error( - match_result, name_filter, area_filter, domain_filter - ), - } + return ToolResult( + data={ + "error": _live_context_match_error( + match_result, name_filter, area_filter, domain_filter + ) + }, + error=True, + ) matched_ids = {state.entity_id for state in match_result.states} entities = [ @@ -311,10 +318,7 @@ class GetLiveContextTool(Tool): " and the devices in this smart home:", yaml_util.dump(entities), ] - return { - "success": True, - "result": "\n".join(prompt), - } + return ToolResult(data={"result": "\n".join(prompt)}) @callback diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py index 8061a06ddad3..01a7a705dba8 100644 --- a/homeassistant/components/llm/llm.py +++ b/homeassistant/components/llm/llm.py @@ -3,9 +3,8 @@ from typing import override from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.llm import LLMContext, Tool, ToolInput +from homeassistant.helpers.llm import LLMContext, Tool, ToolInput, ToolResult from homeassistant.util import dt as dt_util -from homeassistant.util.json import JsonObjectType from . import LLMTools @@ -22,19 +21,18 @@ class GetDateTimeTool(Tool): hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext, - ) -> JsonObjectType: + ) -> ToolResult: """Get the current date and time.""" now = dt_util.now() - return { - "success": True, - "result": { + return ToolResult( + data={ "date": now.strftime("%Y-%m-%d"), "time": now.strftime("%H:%M:%S"), "timezone": now.strftime("%Z"), "weekday": now.strftime("%A"), - }, - } + } + ) @callback diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index e99f6f24cc34..25dc9a6f89b8 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -147,7 +147,7 @@ async def create_server( tool_response = await llm_api.async_call_tool( llm.ToolInput(tool_name=LIVE_CONTEXT_TOOL_NAME, tool_args={}) ) - if not tool_response.data.get("success"): + if tool_response.error: raise HomeAssistantError(cast(str, tool_response.data["error"])) return [ diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 76c153bec2e1..ed96216781ce 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -264,7 +264,7 @@ class IntentTool(Tool): @override async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult: """Handle the intent.""" slots = { key: {"value": val} @@ -305,7 +305,7 @@ class IntentTool(Tool): assistant=llm_context.assistant, device_id=llm_context.device_id, ) - return IntentResponseDict(intent_response) + return ToolResult(data=IntentResponseDict(intent_response)) class IntentResponseDict(dict): @@ -655,7 +655,7 @@ class ActionTool(Tool): @override async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext - ) -> JsonObjectType: + ) -> ToolResult: """Call the action.""" for field, validator in self.parameters.schema.items(): @@ -696,4 +696,4 @@ class ActionTool(Tool): return_response=True, ) - return {"success": True, "result": result} + return ToolResult(data={"result": result}) diff --git a/tests/components/homeassistant/test_llm.py b/tests/components/homeassistant/test_llm.py index cee9388fe25e..a568d79d34d5 100644 --- a/tests/components/homeassistant/test_llm.py +++ b/tests/components/homeassistant/test_llm.py @@ -83,7 +83,9 @@ async def test_get_live_context_no_exposed_entities(hass: HomeAssistant) -> None response = await tool.async_call( hass, llm.ToolInput("homeassistant__GetLiveContext", {}), llm_context ) - assert response == {"success": False, "error": ha_llm.NO_ENTITIES_PROMPT} + assert response == llm.ToolResult( + data={"error": ha_llm.NO_ENTITIES_PROMPT}, error=True + ) async def test_get_live_context_tool(hass: HomeAssistant) -> None: @@ -99,8 +101,8 @@ async def test_get_live_context_tool(hass: HomeAssistant) -> None: response = await tool.async_call( hass, llm.ToolInput("homeassistant__GetLiveContext", {}), llm_context ) - assert response["success"] is True - assert "Kitchen Light" in response["result"] + assert response.error is False + assert "Kitchen Light" in response.data["result"] async def test_get_exposed_entities_timestamp_conversion(hass: HomeAssistant) -> None: @@ -261,145 +263,142 @@ async def test_get_live_context_tool_filter( tools = await llm_component.async_get_tools(hass, llm_context, "assist") tool = next(t for t in tools.tools if t.name == "homeassistant__GetLiveContext") - async def _get_live_context(tool_args: dict) -> dict: + async def _get_live_context(tool_args: dict) -> llm.ToolResult: return await tool.async_call( hass, llm.ToolInput("homeassistant__GetLiveContext", tool_args), llm_context ) # Filter by area and domain (example 1) result = await _get_live_context({"area": "Office", "domain": "light"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Office Switch" not in result.data["result"] + assert "Front Door" not in result.data["result"] # Filter by name (example 2) result = await _get_live_context({"name": "Front Door"}) - assert result["success"] is True - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] + assert result.error is False + assert "Front Door" in result.data["result"] + assert "Office Light" not in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Office Switch" not in result.data["result"] # Name filter is case insensitive result = await _get_live_context({"name": "front door"}) - assert result["success"] is True - assert "Front Door" in result["result"] + assert result.error is False + assert "Front Door" in result.data["result"] # Area filter matches area aliases result = await _get_live_context({"area": "workspace"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Front Door" not in result.data["result"] # Domain filter accepts a list result = await _get_live_context({"domain": ["switch", "lock"]}) - assert result["success"] is True - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] - assert "Office Light" not in result["result"] - assert "Kitchen Light" not in result["result"] + assert result.error is False + assert "Office Switch" in result.data["result"] + assert "Front Door" in result.data["result"] + assert "Office Light" not in result.data["result"] + assert "Kitchen Light" not in result.data["result"] # Domain filter is case insensitive result = await _get_live_context({"domain": "Light"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" in result.data["result"] + assert "Office Switch" not in result.data["result"] + assert "Front Door" not in result.data["result"] # No filters returns all exposed entities result = await _get_live_context({}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Front Door" in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Front Door" in result.data["result"] # Filter that matches nothing returns a descriptive error result = await _get_live_context({"name": "Does Not Exist"}) - assert result == { - "success": False, - "error": "No exposed entities matched name 'Does Not Exist'", - } + assert result == llm.ToolResult( + data={"error": "No exposed entities matched name 'Does Not Exist'"}, + error=True, + ) # Name filter strips surrounding whitespace result = await _get_live_context({"name": " Front Door "}) - assert result["success"] is True - assert "Front Door" in result["result"] + assert result.error is False + assert "Front Door" in result.data["result"] # Area filter strips surrounding whitespace result = await _get_live_context({"area": " Office "}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] # Name filter accepts entity_id result = await _get_live_context({"name": office_light.entity_id}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Office Switch" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Office Switch" not in result.data["result"] # Area filter accepts area_id result = await _get_live_context({"area": office.id}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" in result["result"] - assert "Kitchen Light" not in result["result"] - assert "Front Door" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" in result.data["result"] + assert "Kitchen Light" not in result.data["result"] + assert "Front Door" not in result.data["result"] # Name filter matches entity aliases result = await _get_live_context({"name": "cooking lamp"}) - assert result["success"] is True - assert "Kitchen Light" in result["result"] - assert "Office Light" not in result["result"] + assert result.error is False + assert "Kitchen Light" in result.data["result"] + assert "Office Light" not in result.data["result"] # Combining name + area narrows the result result = await _get_live_context({"name": "Office Light", "area": "Office"}) - assert result["success"] is True - assert "Office Light" in result["result"] - assert "Office Switch" not in result["result"] + assert result.error is False + assert "Office Light" in result.data["result"] + assert "Office Switch" not in result.data["result"] # Combining name + area returns the failing constraint in the error result = await _get_live_context({"name": "Office Light", "area": "Kitchen"}) - assert result == { - "success": False, - "error": "No exposed entities found in area 'Kitchen'", - } + assert result == llm.ToolResult( + data={"error": "No exposed entities found in area 'Kitchen'"}, error=True + ) # Unknown area distinguishes "invalid area" from "no entities in area" result = await _get_live_context({"area": "Garage"}) - assert result == { - "success": False, - "error": "Area 'Garage' does not exist", - } + assert result == llm.ToolResult( + data={"error": "Area 'Garage' does not exist"}, error=True + ) # Unknown domain reports which domain(s) failed result = await _get_live_context({"domain": "fan"}) - assert result == { - "success": False, - "error": "No exposed entities found in domain(s): fan", - } + assert result == llm.ToolResult( + data={"error": "No exposed entities found in domain(s): fan"}, error=True + ) # Entities sharing a name are all returned rather than failing as an # ambiguous match, since this tool only returns context. result = await _get_live_context({"name": "AC"}) - assert result["success"] is True - assert result["result"].count("domain: climate") == 2 - assert "Office" in result["result"] - assert "Kitchen" in result["result"] + assert result.error is False + assert result.data["result"].count("domain: climate") == 2 + assert "Office" in result.data["result"] + assert "Kitchen" in result.data["result"] # Combining a shared name with an area narrows to the single match result = await _get_live_context({"name": "AC", "area": "Kitchen"}) - assert result["success"] is True - assert result["result"].count("domain: climate") == 1 - assert "Kitchen" in result["result"] - assert "Office" not in result["result"] + assert result.error is False + assert result.data["result"].count("domain: climate") == 1 + assert "Kitchen" in result.data["result"] + assert "Office" not in result.data["result"] async def test_get_live_context_schema( diff --git a/tests/components/llm/test_tools.py b/tests/components/llm/test_tools.py index 66d856f6b919..17e3bb6598c1 100644 --- a/tests/components/llm/test_tools.py +++ b/tests/components/llm/test_tools.py @@ -43,12 +43,11 @@ async def test_get_datetime_tool(hass: HomeAssistant) -> None: hass, llm.ToolInput("llm__GetDateTime", {}), llm_context ) - assert response == { - "success": True, - "result": { + assert response == llm.ToolResult( + data={ "date": "2025-09-17", "time": "13:00:00", "timezone": "UTC", "weekday": "Wednesday", - }, - } + } + ) diff --git a/tests/components/script/test_llm.py b/tests/components/script/test_llm.py index 107b5d332aee..b81b81bdcad3 100644 --- a/tests/components/script/test_llm.py +++ b/tests/components/script/test_llm.py @@ -84,7 +84,7 @@ async def test_script_tool_call(hass: HomeAssistant) -> None: response = await tool.async_call( hass, llm.ToolInput("script__test_script", {"beer": 1}), llm_context ) - assert response == {"success": True, "result": {"drinks": 2}} + assert response == llm.ToolResult(data={"result": {"drinks": 2}}) async def test_script_tool_name_not_started_with_digit(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 972b9adb5998..1872a294ddb2 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -821,10 +821,7 @@ Static Context: An overview of the areas and the devices in this smart home: result = await api.async_call_tool( llm.ToolInput(tool_name="homeassistant__GetLiveContext", tool_args={}) ) - assert result.data == { - "success": True, - "result": exposed_entities_prompt, - } + assert result.data == {"result": exposed_entities_prompt} # Fake that request is made from a specific device ID with an area llm_context.device_id = device.id @@ -1002,10 +999,7 @@ async def test_action_tool( blocking=True, return_response=True, ) - assert response.data == { - "success": True, - "result": {"drinks": 2}, - } + assert response.data == {"result": {"drinks": 2}} # Test script with no response tool_input = llm.ToolInput( @@ -1027,10 +1021,7 @@ async def test_action_tool( blocking=True, return_response=True, ) - assert response.data == { - "success": True, - "result": {}, - } + assert response.data == {"result": {}} # Test reload script with new parameters config = {