From e9424fa0a0ed04ec7103d5dc95e676fd70e07c43 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Jul 2026 20:04:04 +0200 Subject: [PATCH] Add vacuum LLM tools platform (#175525) Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/vacuum/llm.py | 41 ++++++++++++++++++++ tests/components/vacuum/test_llm.py | 52 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 homeassistant/components/vacuum/llm.py create mode 100644 tests/components/vacuum/test_llm.py diff --git a/homeassistant/components/vacuum/llm.py b/homeassistant/components/vacuum/llm.py new file mode 100644 index 000000000000..da37732137f5 --- /dev/null +++ b/homeassistant/components/vacuum/llm.py @@ -0,0 +1,41 @@ +"""LLM tools for the vacuum integration.""" + +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 IntentTool, LLMContext, Tool + +from .const import DOMAIN +from .intent import ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + +# Intents owned by this integration that are exposed as LLM tools. +LLM_INTENTS = ( + INTENT_VACUUM_CLEAN_AREA, + INTENT_VACUUM_RETURN_TO_BASE, + INTENT_VACUUM_START, +) + + +@callback +def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools: + """Return LLM tools for the integration's intents when its domain is exposed.""" + if not llm_context.assistant: + return LLMTools(tools=[]) + + if not any( + async_should_expose(hass, llm_context.assistant, state.entity_id) + for state in hass.states.async_all(DOMAIN) + ): + return LLMTools(tools=[]) + + tools: list[Tool] = [ + IntentTool(handler.intent_type, handler) + for handler in intent.async_get(hass) + if handler.intent_type in LLM_INTENTS + ] + return LLMTools(tools=tools) diff --git a/tests/components/vacuum/test_llm.py b/tests/components/vacuum/test_llm.py new file mode 100644 index 000000000000..ec3cc8e7c8a9 --- /dev/null +++ b/tests/components/vacuum/test_llm.py @@ -0,0 +1,52 @@ +"""Tests for the vacuum LLM tools platform.""" + +import pytest + +from homeassistant.components import llm as llm_component +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import llm +from homeassistant.setup import async_setup_component + +ENTITY_ID = "vacuum.test" +INTENTS = {"HassVacuumCleanArea", "HassVacuumReturnToBase", "HassVacuumStart"} + + +@pytest.fixture(autouse=True) +async def setup_integrations(hass: HomeAssistant) -> None: + """Set up the integrations and expose a vacuum entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + assert await async_setup_component(hass, "vacuum", {}) + assert await async_setup_component(hass, "llm", {}) + hass.states.async_set(ENTITY_ID, "on", {"friendly_name": "Test vacuum"}) + async_expose_entity(hass, "conversation", ENTITY_ID, True) + await hass.async_block_till_done() + + +def _llm_context() -> llm.LLMContext: + """Return an LLM context for the conversation assistant.""" + return llm.LLMContext( + platform="test_platform", + context=Context(), + language="*", + assistant="conversation", + device_id=None, + ) + + +async def _tool_names(hass: HomeAssistant) -> set[str]: + """Return the names of the tools offered by the vacuum platform.""" + result = await llm_component.async_get_tools(hass, _llm_context()) + return {tool.name for tool in result.tools} + + +async def test_intent_tool_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is offered for an exposed vacuum entity.""" + assert await _tool_names(hass) >= INTENTS + + +async def test_intent_tool_not_exposed(hass: HomeAssistant) -> None: + """Test the intent tool is hidden when no vacuum entity is exposed.""" + async_expose_entity(hass, "conversation", ENTITY_ID, False) + assert not INTENTS & await _tool_names(hass)