mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Use ToolResult in the LLM helper, llm and homeassistant (#182538)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
a0eb7e30f0
commit
9835762807
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user