mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Add tool metadata to the LLM API (#182614)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
40fcd7dc6b
commit
7fb69f2e6c
@@ -55,8 +55,15 @@ def _format_tool(
|
||||
mcp_schema["required"] = required
|
||||
return types.Tool(
|
||||
name=tool.name,
|
||||
title=tool.title,
|
||||
description=tool.description or "",
|
||||
inputSchema=mcp_schema,
|
||||
annotations=types.ToolAnnotations(
|
||||
readOnlyHint=tool.annotations.read_only,
|
||||
destructiveHint=tool.annotations.destructive,
|
||||
idempotentHint=tool.annotations.idempotent,
|
||||
openWorldHint=tool.annotations.open_world,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -163,12 +163,29 @@ class ToolResult:
|
||||
error: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ToolAnnotations:
|
||||
"""Properties describing how a tool behaves.
|
||||
|
||||
The defaults describe the least safe case, so a tool that declares nothing
|
||||
is taken to write, to be destructive, and to reach outside Home Assistant.
|
||||
"""
|
||||
|
||||
read_only: bool = False
|
||||
destructive: bool = True
|
||||
idempotent: bool = False
|
||||
open_world: bool = True
|
||||
|
||||
|
||||
class Tool:
|
||||
"""LLM Tool base class."""
|
||||
|
||||
name: str
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
parameters: probatio.Schema = probatio.Schema({})
|
||||
annotations: ToolAnnotations = ToolAnnotations()
|
||||
integration: str | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def async_call(
|
||||
@@ -259,9 +276,16 @@ class IntentTool(Tool):
|
||||
self,
|
||||
name: str,
|
||||
intent_handler: intent.IntentHandler,
|
||||
*,
|
||||
title: str | None = None,
|
||||
integration: str | None = None,
|
||||
annotations: ToolAnnotations = ToolAnnotations(),
|
||||
) -> None:
|
||||
"""Init the class."""
|
||||
self.name = name
|
||||
self.title = title
|
||||
self.integration = integration
|
||||
self.annotations = annotations
|
||||
self.intent_type = intent_handler.intent_type
|
||||
self.description = (
|
||||
intent_handler.description
|
||||
@@ -357,8 +381,11 @@ class NamespacedTool(Tool):
|
||||
"""Init the class."""
|
||||
self.namespace = namespace
|
||||
self.name = f"{namespace}__{tool.name}"
|
||||
self.title = tool.title
|
||||
self.description = tool.description
|
||||
self.parameters = tool.parameters
|
||||
self.annotations = tool.annotations
|
||||
self.integration = tool.integration
|
||||
self.tool = tool
|
||||
|
||||
@override
|
||||
@@ -664,6 +691,7 @@ class ActionTool(Tool):
|
||||
self._domain = domain
|
||||
self._action = action
|
||||
self.name = f"{domain}__{action}"
|
||||
self.integration = domain
|
||||
# Note: _get_cached_action_parameters only works for services which
|
||||
# add their description directly to the service description cache.
|
||||
# This is not the case for most services, but it is for scripts.
|
||||
|
||||
@@ -631,6 +631,13 @@ async def test_mcp_tools_list(
|
||||
assert tool.inputSchema.get("type") == "object"
|
||||
properties = tool.inputSchema.get("properties")
|
||||
assert properties.get("name") == {"type": "string"}
|
||||
# A tool that declares no annotations is advertised as unsafe.
|
||||
assert tool.annotations == mcp.types.ToolAnnotations(
|
||||
readOnlyHint=False,
|
||||
destructiveHint=True,
|
||||
idempotentHint=False,
|
||||
openWorldHint=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID])
|
||||
@@ -680,6 +687,47 @@ async def test_mcp_tools_list_required_parameters(
|
||||
assert tool.inputSchema.get("required") == expected_required
|
||||
|
||||
|
||||
@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID])
|
||||
async def test_mcp_tools_list_metadata(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: None,
|
||||
mcp_url: str,
|
||||
mcp_client: MCPClientFactory,
|
||||
hass_supervisor_access_token: str,
|
||||
) -> None:
|
||||
"""Test the tools list advertises the tool title and annotations."""
|
||||
|
||||
class _AnnotatedTool(_StubTool):
|
||||
"""Tool that declares it only reads."""
|
||||
|
||||
title = "Test tool"
|
||||
annotations = llm.ToolAnnotations(
|
||||
read_only=True, destructive=False, idempotent=True, open_world=False
|
||||
)
|
||||
|
||||
llm.async_register_api(
|
||||
hass,
|
||||
MockLLMAPI(
|
||||
hass=hass,
|
||||
id=TEST_LLM_API_ID,
|
||||
name="Test API",
|
||||
tools=[_AnnotatedTool(probatio.Schema({}))],
|
||||
),
|
||||
)
|
||||
|
||||
async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session:
|
||||
result = await session.list_tools()
|
||||
|
||||
tool = next(iter(tool for tool in result.tools if tool.name == "test_tool"))
|
||||
assert tool.title == "Test tool"
|
||||
assert tool.annotations == mcp.types.ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API])
|
||||
async def test_mcp_tool_call(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -230,6 +230,74 @@ async def test_call_tool_deprecated_json_object_custom_integration(
|
||||
assert "returns a JSON object from a tool" in caplog.text
|
||||
|
||||
|
||||
def test_tool_metadata_defaults() -> None:
|
||||
"""Test a tool that declares no metadata is taken to be unsafe."""
|
||||
|
||||
class MyTool(llm.Tool):
|
||||
name = "test_tool"
|
||||
|
||||
async def async_call(
|
||||
self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext
|
||||
) -> llm.ToolResult:
|
||||
return llm.ToolResult(data={})
|
||||
|
||||
tool = MyTool()
|
||||
assert tool.title is None
|
||||
assert tool.integration is None
|
||||
assert tool.annotations == llm.ToolAnnotations(
|
||||
read_only=False, destructive=True, idempotent=False, open_world=True
|
||||
)
|
||||
|
||||
|
||||
def test_intent_tool_metadata() -> None:
|
||||
"""Test an intent tool takes the metadata of the integration exposing it."""
|
||||
|
||||
class MyIntentHandler(intent.IntentHandler):
|
||||
intent_type = "test_intent"
|
||||
|
||||
annotations = llm.ToolAnnotations(read_only=True, open_world=False)
|
||||
tool = llm.IntentTool(
|
||||
"test_tool",
|
||||
MyIntentHandler(),
|
||||
title="Test tool",
|
||||
integration="my_integration",
|
||||
annotations=annotations,
|
||||
)
|
||||
|
||||
assert tool.title == "Test tool"
|
||||
assert tool.integration == "my_integration"
|
||||
assert tool.annotations == annotations
|
||||
|
||||
# An intent tool that declares nothing keeps the unsafe defaults.
|
||||
tool = llm.IntentTool("test_tool", MyIntentHandler())
|
||||
assert tool.title is None
|
||||
assert tool.integration is None
|
||||
assert tool.annotations == llm.ToolAnnotations()
|
||||
|
||||
|
||||
def test_namespaced_tool_keeps_metadata() -> None:
|
||||
"""Test a namespaced tool carries the metadata of the tool it wraps."""
|
||||
|
||||
class MyTool(llm.Tool):
|
||||
name = "test_tool"
|
||||
title = "Test tool"
|
||||
annotations = llm.ToolAnnotations(read_only=True, open_world=False)
|
||||
integration = "my_integration"
|
||||
|
||||
async def async_call(
|
||||
self, hass: HomeAssistant, tool_input: llm.ToolInput, _: llm.LLMContext
|
||||
) -> llm.ToolResult:
|
||||
return llm.ToolResult(data={})
|
||||
|
||||
tool = MyTool()
|
||||
namespaced = llm.NamespacedTool("test_api", tool)
|
||||
|
||||
assert namespaced.name == "test_api__test_tool"
|
||||
assert namespaced.title == tool.title
|
||||
assert namespaced.annotations == tool.annotations
|
||||
assert namespaced.integration == tool.integration
|
||||
|
||||
|
||||
@pytest.mark.parametrize("namespaced", [False, True])
|
||||
async def test_intent_tool_omits_blank_arguments(
|
||||
hass: HomeAssistant, llm_context: llm.LLMContext, namespaced: bool
|
||||
|
||||
Reference in New Issue
Block a user