diff --git a/homeassistant/components/homeassistant/llm.py b/homeassistant/components/homeassistant/llm.py index 9272f2d79bd4..587ea02c2f01 100644 --- a/homeassistant/components/homeassistant/llm.py +++ b/homeassistant/components/homeassistant/llm.py @@ -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( diff --git a/homeassistant/components/intent/llm.py b/homeassistant/components/intent/llm.py index 0825890df23f..543e0feba0f8 100644 --- a/homeassistant/components/intent/llm.py +++ b/homeassistant/components/intent/llm.py @@ -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 diff --git a/homeassistant/components/llm/llm.py b/homeassistant/components/llm/llm.py index 01a7a705dba8..86b31cda6d37 100644 --- a/homeassistant/components/llm/llm.py +++ b/homeassistant/components/llm/llm.py @@ -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( diff --git a/tests/components/intent/test_llm.py b/tests/components/intent/test_llm.py index f87d6fea0928..62f40e64f995 100644 --- a/tests/components/intent/test_llm.py +++ b/tests/components/intent/test_llm.py @@ -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) diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index d764dc87f3fb..d343dc6ebcc7 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -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, )