diff --git a/homeassistant/components/media_player/llm.py b/homeassistant/components/media_player/llm.py index 11bc45a40936..5c4114472119 100644 --- a/homeassistant/components/media_player/llm.py +++ b/homeassistant/components/media_player/llm.py @@ -4,7 +4,13 @@ from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.llm import LLMTools from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import 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, @@ -20,17 +26,36 @@ from .const import ( ) # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = ( - INTENT_MEDIA_NEXT, - INTENT_MEDIA_PAUSE, - INTENT_PLAYER_MUTE, - INTENT_PLAYER_UNMUTE, - INTENT_MEDIA_PREVIOUS, - INTENT_MEDIA_SEARCH_AND_PLAY, - INTENT_MEDIA_UNPAUSE, - INTENT_SET_VOLUME, - INTENT_SET_VOLUME_RELATIVE, -) +LLM_INTENTS = { + INTENT_MEDIA_NEXT: "Next track", + INTENT_MEDIA_PAUSE: "Pause media", + INTENT_PLAYER_MUTE: "Mute player", + INTENT_PLAYER_UNMUTE: "Unmute player", + INTENT_MEDIA_PREVIOUS: "Previous track", + INTENT_MEDIA_SEARCH_AND_PLAY: "Search and play media", + INTENT_MEDIA_UNPAUSE: "Resume media", + INTENT_SET_VOLUME: "Set volume", + INTENT_SET_VOLUME_RELATIVE: "Change volume", +} + +# Setting a value on the user's own player has no further effect when it is +# repeated. Stepping through tracks or volume has an effect on every call, and +# a search reaches the media the player can read. +_CONTROL = ToolAnnotations(idempotent=True, open_world=False) +_CUMULATIVE = ToolAnnotations(open_world=False) + +INTENT_ANNOTATIONS = { + INTENT_MEDIA_PAUSE: _CONTROL, + # Unpausing clears the players it remembered, so a repeat can resume more. + INTENT_MEDIA_UNPAUSE: _CUMULATIVE, + INTENT_PLAYER_MUTE: _CONTROL, + INTENT_PLAYER_UNMUTE: _CONTROL, + INTENT_SET_VOLUME: _CONTROL, + INTENT_MEDIA_NEXT: _CUMULATIVE, + INTENT_MEDIA_PREVIOUS: _CUMULATIVE, + INTENT_SET_VOLUME_RELATIVE: _CUMULATIVE, + INTENT_MEDIA_SEARCH_AND_PLAY: ToolAnnotations(), +} @callback @@ -51,7 +76,13 @@ def async_get_tools( return None tools: list[Tool] = [ - IntentTool(f"{DOMAIN}__{handler.intent_type}", handler) + IntentTool( + f"{DOMAIN}__{handler.intent_type}", + handler, + title=LLM_INTENTS[handler.intent_type], + integration=DOMAIN, + annotations=INTENT_ANNOTATIONS[handler.intent_type], + ) for handler in intent.async_get(hass) if handler.intent_type in LLM_INTENTS ] diff --git a/tests/components/media_player/test_llm.py b/tests/components/media_player/test_llm.py index fcefaa14d9e3..559cd92b7f3e 100644 --- a/tests/components/media_player/test_llm.py +++ b/tests/components/media_player/test_llm.py @@ -54,7 +54,39 @@ async def _tool_names(hass: HomeAssistant) -> set[str]: async def test_intent_tool_exposed(hass: HomeAssistant) -> None: """Test the intent tool is offered for an exposed media_player entity.""" - assert await _tool_names(hass) >= TOOL_NAMES + result = await llm_component.async_get_tools(hass, _llm_context(), "assist") + tools = {tool.name: tool for tool in result.tools} + assert tools.keys() >= TOOL_NAMES + + control = llm.ToolAnnotations(idempotent=True, open_world=False) + repeats = llm.ToolAnnotations(open_world=False) + assert { + name: (tool.title, tool.integration, tool.annotations) + for name, tool in tools.items() + if name in TOOL_NAMES + } == { + "media_player__HassMediaNext": ("Next track", "media_player", repeats), + "media_player__HassMediaPause": ("Pause media", "media_player", control), + "media_player__HassMediaPlayerMute": ("Mute player", "media_player", control), + "media_player__HassMediaPlayerUnmute": ( + "Unmute player", + "media_player", + control, + ), + "media_player__HassMediaPrevious": ("Previous track", "media_player", repeats), + "media_player__HassMediaSearchAndPlay": ( + "Search and play media", + "media_player", + llm.ToolAnnotations(), + ), + "media_player__HassMediaUnpause": ("Resume media", "media_player", repeats), + "media_player__HassSetVolume": ("Set volume", "media_player", control), + "media_player__HassSetVolumeRelative": ( + "Change volume", + "media_player", + repeats, + ), + } async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: