Use ToolResult in the LLM helper, llm and homeassistant (#182538)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-09-18 15:56:17 +00:00
committed by GitHub
co-authored by Claude
parent a0eb7e30f0
commit 9835762807
8 changed files with 117 additions and 126 deletions
+18 -14
View File
@@ -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
+6 -8
View File
@@ -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
@@ -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 [
+4 -4
View File
@@ -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})
+80 -81
View File
@@ -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(
+4 -5
View File
@@ -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",
},
}
}
)
+1 -1
View File
@@ -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:
+3 -12
View File
@@ -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 = {