Compare commits

...

6 Commits

Author SHA1 Message Date
Paulus Schoutsen 18229b0998 Trim llm integration module docstring
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:37:51 -04:00
Paulus Schoutsen 39a1e6dfcc Load LLM tools platforms lazily and pull tools on demand
Switch from a push model (platforms register providers at setup via an eager
async_process_integration_platforms) to a pull model using the new
LazyIntegrationPlatforms helper: an <integration>/llm.py platform exposes an
async_get_tools(hass, llm_context) hook, and the platform is imported and
queried only when tools are requested.

This drops the async_register_tool_provider registry. async_get_tools is now
async, loads each integration's platform lazily, and merges their tools and
prompt fragments. Results are sorted by domain so tool and prompt order does
not depend on integration load order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 11:32:48 -04:00
Paulus Schoutsen bc8bf27e79 Merge remote-tracking branch 'origin/dev' into llm-integration 2026-06-20 11:30:41 -04:00
Paulus Schoutsen 42dbf09f43 Address review: isolate provider failures and guard registration
- Add homeassistant/components/llm to .core_files.yaml.
- Isolate per-provider failures in async_get_tools so one raising provider
  no longer blanks out every other provider's tools and prompt.
- Raise on duplicate provider registration and make unregister idempotent,
  matching the defensiveness of the API registry in helpers/llm.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:33:55 -04:00
Paulus Schoutsen 3c36f3809a Move tool provider registry into the llm integration
Keep homeassistant.helpers.llm unchanged: the tool provider registry now lives
in the llm integration instead of the helper. Platforms register through
homeassistant.components.llm.async_register_tool_provider and the integration
exposes async_get_tools to read the merged tools and prompt. The framework
(Tool, LLMContext, the APIs) stays in the helper; the integration owns the
registry and lifecycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:58:45 -04:00
Paulus Schoutsen b6bf1c83ab Add llm integration and tool provider registry
Introduce the plumbing for per-integration LLM tools, with no consumer yet.

helpers/llm.py gains a tool provider registry: an LLMTools result (tools plus
an optional prompt fragment), a provider callback type, and
async_register_tool_provider, which appends to a single global registry.
_async_get_registered_tools merges every provider's tools and prompt. Nothing
reads the registry yet, so there is no behavior change.

The new system llm integration owns the LLM tools platform: its async_setup
drives async_process_integration_platforms(hass, "llm", ...) so integrations can
ship an <integration>/llm.py with an async_setup_tools hook to register tools,
mirroring the intent helper/integration split. The framework (registry, Tool,
the APIs) stays in homeassistant.helpers.llm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:51:29 -04:00
8 changed files with 217 additions and 0 deletions
+1
View File
@@ -95,6 +95,7 @@ components: &components
- homeassistant/components/input_select/**
- homeassistant/components/input_text/**
- homeassistant/components/labs/**
- homeassistant/components/llm/**
- homeassistant/components/logbook/**
- homeassistant/components/logger/**
- homeassistant/components/lovelace/**
Generated
+2
View File
@@ -1028,6 +1028,8 @@ CLAUDE.md @home-assistant/core
/tests/components/litterrobot/ @natekspencer @tkdrob
/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet
/tests/components/livisi/ @StefanIacobLivisi @planbnet
/homeassistant/components/llm/ @home-assistant/core
/tests/components/llm/ @home-assistant/core
/homeassistant/components/local_calendar/ @allenporter
/tests/components/local_calendar/ @allenporter
/homeassistant/components/local_ip/ @issacg
+79
View File
@@ -0,0 +1,79 @@
"""The LLM integration.
Owns the LLM tools platform: integrations contribute tools to the LLM APIs
through an ``<integration>/llm.py`` platform with an ``async_get_tools`` hook.
The platforms are loaded lazily and queried per request. The framework
(``Tool``, the APIs) lives in ``homeassistant.helpers.llm``.
"""
from dataclasses import dataclass
import logging
from typing import Protocol
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.integration_platform import LazyIntegrationPlatforms
from homeassistant.helpers.llm import LLMContext, Tool
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
DATA_PLATFORMS: HassKey[LazyIntegrationPlatforms[LLMToolsPlatformProtocol]] = HassKey(
"llm_platforms"
)
@dataclass(slots=True)
class LLMTools:
"""Tools and an optional prompt fragment contributed by a platform."""
tools: list[Tool]
prompt: str | None = None
class LLMToolsPlatformProtocol(Protocol):
"""Define the format that LLM tools platforms can have."""
@callback
def async_get_tools(self, hass: HomeAssistant, llm_context: LLMContext) -> LLMTools:
"""Return the integration's LLM tools for the given context."""
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LLM integration."""
hass.data[DATA_PLATFORMS] = LazyIntegrationPlatforms(
hass, DOMAIN, _process_llm_tools_platform
)
return True
@callback
def _process_llm_tools_platform(
hass: HomeAssistant, domain: str, platform: LLMToolsPlatformProtocol
) -> LLMToolsPlatformProtocol:
"""Process an integration's LLM tools platform."""
return platform
async def async_get_tools(hass: HomeAssistant, llm_context: LLMContext) -> LLMTools:
"""Return the tools and merged prompt from all integration platforms."""
platforms = await hass.data[DATA_PLATFORMS].async_get_platforms()
tools: list[Tool] = []
prompts: list[str] = []
# Sort by domain so the tool and prompt order is independent of load order.
for domain, platform in sorted(platforms.items()):
try:
result = platform.async_get_tools(hass, llm_context)
except Exception:
_LOGGER.exception("Error getting tools from LLM platform %s", domain)
continue
tools.extend(result.tools)
if result.prompt:
prompts.append(result.prompt)
return LLMTools(tools=tools, prompt="\n".join(prompts) if prompts else None)
+3
View File
@@ -0,0 +1,3 @@
"""Constants for the LLM integration."""
DOMAIN = "llm"
@@ -0,0 +1,9 @@
{
"domain": "llm",
"name": "LLM",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/llm",
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal"
}
+1
View File
@@ -2100,6 +2100,7 @@ NO_QUALITY_SCALE = [
"intent_script",
"intent",
"labs",
"llm",
"logbook",
"logger",
"lovelace",
+1
View File
@@ -0,0 +1 @@
"""Tests for the LLM integration."""
+121
View File
@@ -0,0 +1,121 @@
"""Tests for the LLM integration."""
from unittest.mock import Mock
import pytest
from homeassistant.components.llm import DATA_PLATFORMS, LLMTools, async_get_tools
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.setup import async_setup_component
from homeassistant.util.json import JsonObjectType
from tests.common import mock_platform
class _StubTool(llm.Tool):
"""Minimal tool for registry tests."""
def __init__(self, name: str) -> None:
"""Initialize the stub tool."""
self.name = name
self.description = f"{name} description"
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> JsonObjectType:
"""Return an empty result."""
return {}
@pytest.fixture
def llm_context() -> llm.LLMContext:
"""Return an LLM context."""
return llm.LLMContext(
platform="test",
context=None,
language="*",
assistant="conversation",
device_id=None,
)
def _mock_tools_platform(
hass: HomeAssistant, domain: str, tools: LLMTools | Exception
) -> None:
"""Register a mock <integration>/llm.py platform returning the given tools."""
if isinstance(tools, Exception):
async_get_tools = Mock(side_effect=tools)
else:
async_get_tools = Mock(return_value=tools)
hass.config.components.add(domain)
mock_platform(hass, f"{domain}.llm", Mock(async_get_tools=async_get_tools))
async def test_setup(hass: HomeAssistant) -> None:
"""Test the integration sets up."""
assert await async_setup_component(hass, "llm", {})
assert DATA_PLATFORMS in hass.data
async def test_get_tools(hass: HomeAssistant, llm_context: llm.LLMContext) -> None:
"""Test that tools from an integration platform are returned."""
tool = _StubTool("my_tool")
_mock_tools_platform(
hass, "test", LLMTools(tools=[tool], prompt="use my_tool wisely")
)
assert await async_setup_component(hass, "llm", {})
result = await async_get_tools(hass, llm_context)
assert result.tools == [tool]
assert result.prompt == "use my_tool wisely"
async def test_get_tools_empty(
hass: HomeAssistant, llm_context: llm.LLMContext
) -> None:
"""Test that no platforms yields no tools."""
assert await async_setup_component(hass, "llm", {})
result = await async_get_tools(hass, llm_context)
assert result.tools == []
assert result.prompt is None
async def test_get_tools_merges_sorted(
hass: HomeAssistant, llm_context: llm.LLMContext
) -> None:
"""Test that tools and prompts are merged in a load-order-independent order."""
tool_a = _StubTool("tool_a")
tool_b = _StubTool("tool_b")
# Register "test_b" before "test_a" to prove the result is sorted by domain.
_mock_tools_platform(hass, "test_b", LLMTools(tools=[tool_b], prompt="prompt b"))
_mock_tools_platform(hass, "test_a", LLMTools(tools=[tool_a], prompt="prompt a"))
assert await async_setup_component(hass, "llm", {})
result = await async_get_tools(hass, llm_context)
assert result.tools == [tool_a, tool_b]
assert result.prompt == "prompt a\nprompt b"
async def test_get_tools_isolates_failing_platform(
hass: HomeAssistant,
llm_context: llm.LLMContext,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that one failing platform does not drop the others' tools."""
tool = _StubTool("good_tool")
_mock_tools_platform(hass, "test_bad", ValueError("boom"))
_mock_tools_platform(hass, "test_good", LLMTools(tools=[tool], prompt="prompt"))
assert await async_setup_component(hass, "llm", {})
result = await async_get_tools(hass, llm_context)
assert result.tools == [tool]
assert result.prompt == "prompt"
assert "Error getting tools from LLM platform test_bad" in caplog.text