From ad076d23a0c5eff4856c2451b0c437617fa9027b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 24 Sep 2026 20:25:15 +0100 Subject: [PATCH] Set tool metadata in todo (#182711) Co-authored-by: Claude --- homeassistant/components/todo/llm.py | 29 ++++++++++++++++++++-- tests/components/todo/test_llm.py | 37 +++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/todo/llm.py b/homeassistant/components/todo/llm.py index 41335100f8a3..04a26e5c9deb 100644 --- a/homeassistant/components/todo/llm.py +++ b/homeassistant/components/todo/llm.py @@ -14,6 +14,7 @@ from homeassistant.helpers.llm import ( IntentTool, LLMContext, Tool, + ToolAnnotations, ToolInput, ToolResult, ) @@ -26,13 +27,27 @@ from .intent import ( ) # Intents owned by this integration that are exposed as LLM tools. -LLM_INTENTS = (INTENT_LIST_ADD_ITEM, INTENT_LIST_COMPLETE_ITEM, INTENT_LIST_REMOVE_ITEM) +LLM_INTENTS = { + INTENT_LIST_ADD_ITEM: "Add to-do list item", + INTENT_LIST_COMPLETE_ITEM: "Complete to-do list item", + INTENT_LIST_REMOVE_ITEM: "Remove to-do list item", +} + +# Adding an item appends to the list and takes nothing away. Completing and +# removing both look for an item that is still there, so a repeated call +# raises instead of having no further effect. +INTENT_ANNOTATIONS = { + INTENT_LIST_ADD_ITEM: ToolAnnotations(destructive=False, open_world=False), + INTENT_LIST_COMPLETE_ITEM: ToolAnnotations(open_world=False), + INTENT_LIST_REMOVE_ITEM: ToolAnnotations(open_world=False), +} class TodoGetItemsTool(Tool): """LLM Tool allowing querying a to-do list.""" name = "todo__get_items" + title = "Get to-do list items" description = ( "Query a to-do list to find out what items are on it. " "Use this to answer questions like " @@ -40,6 +55,10 @@ class TodoGetItemsTool(Tool): "'Read my grocery list'. " "Filters items by status (needs_action, completed, all)." ) + annotations = ToolAnnotations( + read_only=True, destructive=False, idempotent=True, open_world=False + ) + integration = DOMAIN def __init__(self, todo_lists: list[str]) -> None: """Init the get items tool.""" @@ -115,7 +134,13 @@ def async_get_tools( tools: list[Tool] = [TodoGetItemsTool(names)] tools.extend( - 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/todo/test_llm.py b/tests/components/todo/test_llm.py index 547ca17fad9e..af84deac27d4 100644 --- a/tests/components/todo/test_llm.py +++ b/tests/components/todo/test_llm.py @@ -57,6 +57,11 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: tool = next((tool for tool in result.tools if tool.name == "todo__get_items"), None) assert tool is not None assert tool.parameters.schema["todo_list"].container == ["Mock Todo List Name"] + assert tool.title == "Get to-do list items" + assert tool.integration == todo.DOMAIN + assert tool.annotations == llm.ToolAnnotations( + read_only=True, destructive=False, idempotent=True, open_world=False + ) calls = async_mock_service( hass, @@ -124,7 +129,31 @@ async def test_todo_get_items_status_filter( async def test_todo_list_intents_exposed(hass: HomeAssistant) -> None: """Test the todo list intents are exposed as tools when a list is exposed.""" result = await llm_component.async_get_tools(hass, _llm_context(), "assist") - names = {tool.name for tool in result.tools} - assert "todo__HassListAddItem" in names - assert "todo__HassListCompleteItem" in names - assert "todo__HassListRemoveItem" in names + tools = {tool.name: tool for tool in result.tools} + assert "todo__HassListAddItem" in tools + assert "todo__HassListCompleteItem" in tools + assert "todo__HassListRemoveItem" in tools + + # Completing or removing an item raises on a repeat, so neither is + # idempotent. Adding an item takes nothing away. + assert { + name: (tool.title, tool.integration, tool.annotations) + for name, tool in tools.items() + if name.startswith("todo__HassList") + } == { + "todo__HassListAddItem": ( + "Add to-do list item", + todo.DOMAIN, + llm.ToolAnnotations(destructive=False, open_world=False), + ), + "todo__HassListCompleteItem": ( + "Complete to-do list item", + todo.DOMAIN, + llm.ToolAnnotations(open_world=False), + ), + "todo__HassListRemoveItem": ( + "Remove to-do list item", + todo.DOMAIN, + llm.ToolAnnotations(open_world=False), + ), + }