From 8a545dcdcc9371d7e0e81f14db933908ccc006f8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 20 Mar 2025 13:40:51 +0000 Subject: [PATCH 1/2] Omit state from the Assist LLM prompts --- .../components/mcp_server/llm_api.py | 41 ------------------- homeassistant/components/mcp_server/server.py | 10 ++--- homeassistant/helpers/llm.py | 30 ++++++++------ tests/helpers/test_llm.py | 24 +++-------- 4 files changed, 27 insertions(+), 78 deletions(-) delete mode 100644 homeassistant/components/mcp_server/llm_api.py diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py deleted file mode 100644 index f7dd44214800c5..00000000000000 --- a/homeassistant/components/mcp_server/llm_api.py +++ /dev/null @@ -1,41 +0,0 @@ -"""LLM API for MCP Server. - -This is a modified version of the AssistAPI that does not include the home state -in the prompt. This API is not registered with the LLM API registry since it is -only used by the MCP Server. The MCP server will substitute this API when the -user selects the Assist API. -""" - -from homeassistant.core import callback -from homeassistant.helpers import llm -from homeassistant.util import yaml as yaml_util - -EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"} - - -class StatelessAssistAPI(llm.AssistAPI): - """LLM API for MCP Server that provides the Assist API without state information in the prompt. - - Syncing the state information is possible, but may put unnecessary load on - the system so we are instead providing the prompt without entity state. Since - actions don't care about the current state, there is little quality loss. - """ - - @callback - def _async_get_exposed_entities_prompt( - self, llm_context: llm.LLMContext, exposed_entities: dict | None - ) -> list[str]: - """Return the prompt for the exposed entities.""" - prompt = [] - - if exposed_entities and exposed_entities["entities"]: - prompt.append( - "An overview of the areas and the devices in this smart home:" - ) - entities = [ - {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} - for entity_info in exposed_entities["entities"].values() - ] - prompt.append(yaml_util.dump(list(entities))) - - return prompt diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index 307fcdda8f3141..88b179ae7c27e7 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -22,7 +22,6 @@ from homeassistant.helpers import llm from .const import STATELESS_LLM_API -from .llm_api import StatelessAssistAPI _LOGGER = logging.getLogger(__name__) @@ -50,15 +49,14 @@ async def create_server( A Model Context Protocol Server object is associated with a single session. The MCP SDK handles the details of the protocol. """ + if llm_api_id == STATELESS_LLM_API: + llm_api_id = llm.LLM_API_ASSIST server = Server("home-assistant") async def get_api_instance() -> llm.APIInstance: - """Substitute the StatelessAssistAPI for the Assist API if selected.""" - if llm_api_id in (STATELESS_LLM_API, llm.LLM_API_ASSIST): - api = StatelessAssistAPI(hass) - return await api.async_get_api_instance(llm_context) - + """Get the LLM API selected.""" + # Backwards compatibility with old MCP Server config return await llm.async_get_api(hass, llm_api_id, llm_context) @server.list_prompts() # type: ignore[no-untyped-call, misc] diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5995543914f3c1..7f6fe22ec70e9c 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -316,7 +316,7 @@ async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: """Return the instance of the API.""" if llm_context.assistant: exposed_entities: dict | None = _get_exposed_entities( - self.hass, llm_context.assistant + self.hass, llm_context.assistant, include_state=False ) else: exposed_entities = None @@ -463,7 +463,9 @@ def _async_get_tools( def _get_exposed_entities( - hass: HomeAssistant, assistant: str + hass: HomeAssistant, + assistant: str, + include_state: bool = True, ) -> dict[str, dict[str, dict[str, Any]]]: """Get exposed entities. @@ -524,24 +526,28 @@ def _get_exposed_entities( info: dict[str, Any] = { "names": ", ".join(names), "domain": state.domain, - "state": state.state, } + if include_state: + info["state"] = state.state + if description: info["description"] = description if area_names: info["areas"] = ", ".join(area_names) - if attributes := { - attr_name: ( - str(attr_value) - if isinstance(attr_value, (Enum, Decimal, int)) - else attr_value - ) - for attr_name, attr_value in state.attributes.items() - if attr_name in interesting_attributes - }: + if include_state and ( + attributes := { + attr_name: ( + str(attr_value) + if isinstance(attr_value, (Enum, Decimal, int)) + else attr_value + ) + for attr_name, attr_value in state.attributes.items() + if attr_name in interesting_attributes + } + ): info["attributes"] = attributes if state.domain in data: diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 45ed009fcf1c82..9f73fa4d06eed5 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -575,52 +575,38 @@ def create_entity( suggested_area="Test Area 2", ) ) - exposed_entities_prompt = """An overview of the areas and the devices in this smart home: + stateless_exposed_entities_prompt = """An overview of the areas and the devices in this smart home: - names: Kitchen domain: light - state: 'on' - attributes: - temperature: '0.9' - humidity: '65' - names: Living Room domain: light - state: 'on' areas: Test Area, Alternative name - names: Test Device, my test light domain: light - state: unavailable areas: Test Area, Alternative name - names: Test Service domain: light - state: unavailable areas: Test Area, Alternative name - names: Test Service domain: light - state: unavailable areas: Test Area, Alternative name - names: Test Service domain: light - state: unavailable areas: Test Area, Alternative name - names: Test Device 2 domain: light - state: unavailable areas: Test Area 2 - names: Test Device 3 domain: light - state: unavailable areas: Test Area 2 - names: Test Device 4 domain: light - state: unavailable areas: Test Area 2 - names: Unnamed Device domain: light - state: unavailable areas: Test Area 2 - names: '1' domain: light - state: unavailable areas: Test Area 2 """ first_part_prompt = ( @@ -640,7 +626,7 @@ def create_entity( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Verify that the get_home_state tool returns the same results as the exposed_entities_prompt @@ -663,7 +649,7 @@ def create_entity( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Add floor @@ -678,7 +664,7 @@ def create_entity( f"""{first_part_prompt} {area_prompt} {no_timer_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) # Register device for timers @@ -689,7 +675,7 @@ def create_entity( assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} -{exposed_entities_prompt}""" +{stateless_exposed_entities_prompt}""" ) From 1f806db333401ae4d0d4990c317fd9fbd5a099a2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 21 Mar 2025 02:42:35 +0000 Subject: [PATCH 2/2] Add back the stateful prompt --- tests/helpers/test_llm.py | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 9f73fa4d06eed5..19ada40755010c 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -575,6 +575,54 @@ def create_entity( suggested_area="Test Area 2", ) ) + exposed_entities_prompt = """An overview of the areas and the devices in this smart home: +- names: Kitchen + domain: light + state: 'on' + attributes: + temperature: '0.9' + humidity: '65' +- names: Living Room + domain: light + state: 'on' + areas: Test Area, Alternative name +- names: Test Device, my test light + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Service + domain: light + state: unavailable + areas: Test Area, Alternative name +- names: Test Device 2 + domain: light + state: unavailable + areas: Test Area 2 +- names: Test Device 3 + domain: light + state: unavailable + areas: Test Area 2 +- names: Test Device 4 + domain: light + state: unavailable + areas: Test Area 2 +- names: Unnamed Device + domain: light + state: unavailable + areas: Test Area 2 +- names: '1' + domain: light + state: unavailable + areas: Test Area 2 +""" stateless_exposed_entities_prompt = """An overview of the areas and the devices in this smart home: - names: Kitchen domain: light