Set tool metadata in the LLM helper, llm, homeassistant and intent (#182700)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-09-24 21:37:48 +02:00
committed by GitHub
co-authored by Claude
parent 2f51c3936c
commit aed8090cbb
5 changed files with 111 additions and 21 deletions
@@ -27,11 +27,13 @@ from homeassistant.helpers.llm import (
LLM_API_ASSIST,
LLMContext,
Tool,
ToolAnnotations,
ToolInput,
ToolResult,
)
from homeassistant.util import dt as dt_util, yaml as yaml_util
from .const import DOMAIN
from .exposed_entities import async_should_expose
# Domains bucketed out of the exposed-entity overview.
@@ -217,6 +219,7 @@ class GetLiveContextTool(Tool):
"""
name = "homeassistant__GetLiveContext"
title = "Get live context"
description = (
"Provides real-time information about the"
" CURRENT state, value, or mode of devices,"
@@ -234,6 +237,8 @@ class GetLiveContextTool(Tool):
"Prefer filtering by domain when searching"
" for multiple devices of the same type."
)
annotations = ToolAnnotations(read_only=True, open_world=False)
integration = DOMAIN
parameters = probatio.Schema(
{
probatio.Optional(
+58 -18
View File
@@ -14,30 +14,63 @@ from homeassistant.helpers import (
floor_registry as fr,
intent,
)
from homeassistant.helpers.llm import LLM_API_ASSIST, IntentTool, LLMContext, Tool
from homeassistant.helpers.llm import (
LLM_API_ASSIST,
IntentTool,
LLMContext,
Tool,
ToolAnnotations,
)
from .const import DOMAIN
from .timers import async_device_supports_timers
# Generic intents exposed as LLM tools regardless of a timer-capable device.
LLM_INTENTS = (
intent.INTENT_TURN_ON,
intent.INTENT_TURN_OFF,
intent.INTENT_CANCEL_ALL_TIMERS,
intent.INTENT_SET_POSITION,
intent.INTENT_STOP_MOVING,
)
LLM_INTENTS = {
intent.INTENT_TURN_ON: "Turn on",
intent.INTENT_TURN_OFF: "Turn off",
intent.INTENT_CANCEL_ALL_TIMERS: "Cancel all timers",
intent.INTENT_SET_POSITION: "Set position",
intent.INTENT_STOP_MOVING: "Stop moving",
}
# Timer intents, only exposed for a device that supports timers.
TIMER_INTENTS = (
intent.INTENT_START_TIMER,
intent.INTENT_CANCEL_TIMER,
intent.INTENT_INCREASE_TIMER,
intent.INTENT_DECREASE_TIMER,
intent.INTENT_PAUSE_TIMER,
intent.INTENT_UNPAUSE_TIMER,
intent.INTENT_TIMER_STATUS,
)
TIMER_INTENTS = {
intent.INTENT_START_TIMER: "Start timer",
intent.INTENT_CANCEL_TIMER: "Cancel timer",
intent.INTENT_INCREASE_TIMER: "Add time to timer",
intent.INTENT_DECREASE_TIMER: "Remove time from timer",
intent.INTENT_PAUSE_TIMER: "Pause timer",
intent.INTENT_UNPAUSE_TIMER: "Resume timer",
intent.INTENT_TIMER_STATUS: "Get timer status",
}
INTENT_TITLES = LLM_INTENTS | TIMER_INTENTS
# Every intent here acts on Home Assistant's own entities and timers, so none
# of them reaches an open world.
_CONTROL = ToolAnnotations(idempotent=True, open_world=False)
_REPEATS = ToolAnnotations(open_world=False)
_ADDS = ToolAnnotations(destructive=False, open_world=False)
_READ_ONLY = ToolAnnotations(read_only=True, open_world=False)
INTENT_ANNOTATIONS = {
# Turning on a button entity presses it, which acts again on every call.
intent.INTENT_TURN_ON: _REPEATS,
intent.INTENT_TURN_OFF: _CONTROL,
intent.INTENT_SET_POSITION: _CONTROL,
intent.INTENT_STOP_MOVING: _CONTROL,
intent.INTENT_CANCEL_ALL_TIMERS: _CONTROL,
intent.INTENT_CANCEL_TIMER: _CONTROL,
intent.INTENT_PAUSE_TIMER: _CONTROL,
intent.INTENT_UNPAUSE_TIMER: _CONTROL,
# A started timer can carry a command to run when it finishes, so it is
# not only additive. Adding time to a timer is.
intent.INTENT_START_TIMER: _REPEATS,
intent.INTENT_INCREASE_TIMER: _ADDS,
intent.INTENT_DECREASE_TIMER: _REPEATS,
intent.INTENT_TIMER_STATUS: _READ_ONLY,
}
DEVICE_CONTROL_TOOL_USAGE_PROMPT = (
"When controlling Home Assistant always call the intent tools. "
@@ -76,7 +109,14 @@ def async_get_tools(
]
tools: list[Tool] = [
IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) for handler in handlers
IntentTool(
f"{DOMAIN}__{handler.intent_type}",
handler,
title=INTENT_TITLES[handler.intent_type],
integration=DOMAIN,
annotations=INTENT_ANNOTATIONS[handler.intent_type],
)
for handler in handlers
]
if not tools:
return None
+11 -1
View File
@@ -3,17 +3,27 @@
from typing import override
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.llm import LLMContext, Tool, ToolInput, ToolResult
from homeassistant.helpers.llm import (
LLMContext,
Tool,
ToolAnnotations,
ToolInput,
ToolResult,
)
from homeassistant.util import dt as dt_util
from . import LLMTools
from .const import DOMAIN
class GetDateTimeTool(Tool):
"""Tool for getting the current date and time."""
name = "llm__GetDateTime"
title = "Get date and time"
description = "Provides the current date and time."
annotations = ToolAnnotations(read_only=True, open_world=False)
integration = DOMAIN
@override
async def async_call(
+36
View File
@@ -111,6 +111,42 @@ async def test_timer_intents_offered_for_timer_device(hass: HomeAssistant) -> No
assert "intent__HassTimerStatus" in names
async def test_tool_annotations(hass: HomeAssistant) -> None:
"""Test the intent tools declare how they behave."""
@callback
def handle_timer(*args: object) -> None:
pass
async_register_timer_handler(hass, "test_device", handle_timer)
result = await llm_component.async_get_tools(
hass, _llm_context(device_id="test_device"), "assist"
)
tools = {tool.name: tool for tool in result.tools}
assert tools["intent__HassTurnOn"].title == "Turn on"
assert tools["intent__HassTurnOn"].integration == "intent"
# Turning on a button entity presses it, so the call is not idempotent.
assert tools["intent__HassTurnOn"].annotations == llm.ToolAnnotations(
open_world=False
)
assert tools["intent__HassTurnOff"].annotations == llm.ToolAnnotations(
idempotent=True, open_world=False
)
# Adding time only adds, and has an effect on every call.
assert tools["intent__HassIncreaseTimer"].annotations == llm.ToolAnnotations(
destructive=False, open_world=False
)
# A started timer can carry a command to run when it finishes.
assert tools["intent__HassStartTimer"].annotations == llm.ToolAnnotations(
open_world=False
)
assert tools["intent__HassTimerStatus"].annotations == llm.ToolAnnotations(
read_only=True, open_world=False
)
async def test_set_position_requires_exposed_cover(hass: HomeAssistant) -> None:
"""Test intent__HassSetPosition is only exposed when a cover/valve is exposed."""
assert "intent__HassSetPosition" in await _tool_names(hass)
+1 -2
View File
@@ -631,12 +631,11 @@ async def test_mcp_tools_list(
assert tool.inputSchema.get("type") == "object"
properties = tool.inputSchema.get("properties")
assert properties.get("name") == {"type": "string"}
# A tool that declares no annotations is advertised as unsafe.
assert tool.annotations == mcp.types.ToolAnnotations(
readOnlyHint=False,
destructiveHint=True,
idempotentHint=False,
openWorldHint=True,
openWorldHint=False,
)