diff --git a/README.md b/README.md index 1e82059..522f3f6 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ > lookup for web search uses the subentry's model instead of `gpt-4o-mini` and asks > as a user message (Anthropic needs one); a failed lookup no longer blocks saving > the options, and empty location fields are left out of the web search tool. +> - Requests that Home Assistant's local intents answered (`prefer_local_intents`) are +> described to the model in a note instead of being replayed as its own calls of +> intents like `HassGetCurrentTime`, which are not among its tools; replayed, they +> make it apologise for using tools it does not have. Not reported upstream yet. > > HACS only installs from GitHub, so this fork is installed by copying > `custom_components/openai_compatible` into the Home Assistant config. diff --git a/custom_components/openai_compatible/entity.py b/custom_components/openai_compatible/entity.py index bfbf344..f9b9ebe 100644 --- a/custom_components/openai_compatible/entity.py +++ b/custom_components/openai_compatible/entity.py @@ -180,13 +180,35 @@ def _convert_content_to_param( """Convert any native chat message for this agent to the native format.""" messages: ResponseInputParam = [] reasoning_summary: list[str] = [] + # Requests that Home Assistant's local intents answered without the model + # (prefer_local_intents) are in the chat log as calls of intents like + # HassGetCurrentTime, which are not among the model's tools. Replayed as + # the model's own calls, they make it apologise for using tools it does + # not have, so they are described to it as a note instead. + local_calls: dict[str, llm.ToolInput] = {} for content in chat_content: if isinstance(content, conversation.ToolResultContent): + if (tool_call := local_calls.pop(content.tool_call_id, None)) is not None: + messages.append( + EasyInputMessageParam( + type="message", + role="developer", + content=( + "Home Assistant's local intent recognition answered " + "the last user request itself, without you: it ran " + f"{tool_call.tool_name} with " + f"{json_dumps(tool_call.tool_args)} and got " + f"{json_dumps(content.tool_result)}. The assistant " + "reply after this note is its wording, not yours. " + "That intent is not one of your tools." + ), + ) + ) # Earlier web searches are not replayed: the chat log only keeps # their status, not their results, and a search without results # makes the model disown the answer it based on that search. - if content.tool_name != "web_search_call": + elif content.tool_name != "web_search_call": messages.append( FunctionCallOutput( type="function_call_output", @@ -211,6 +233,9 @@ def _convert_content_to_param( for tool_call in content.tool_calls: if tool_call.external and tool_call.tool_name == "web_search_call": continue + if tool_call.external and tool_call.tool_name != "code_interpreter": + local_calls[tool_call.id] = tool_call + continue messages.append( ResponseFunctionToolCallParam( type="function_call", diff --git a/tests/test_entity.py b/tests/test_entity.py index 7a8a718..358c560 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -135,3 +135,50 @@ async def test_web_search_location_leaves_out_empty_fields( "country": "DE", "timezone": "Europe/Berlin", } + + +def test_local_intent_replayed_as_note() -> None: + """Test a request a local intent answered is described, not replayed as a call. + + With prefer_local_intents, the chat log holds intents like + HassGetCurrentTime as external tool calls. They are not the model's tools, + and replayed as its own calls they make it apologise for using them. + """ + content = [ + conversation.UserContent(content="What time is it?"), + conversation.AssistantContent( + agent_id="conversation.test", + tool_calls=[ + llm.ToolInput( + id="intent_1", + tool_name="HassGetCurrentTime", + tool_args={}, + external=True, + ) + ], + ), + conversation.ToolResultContent( + agent_id="conversation.test", + tool_call_id="intent_1", + tool_name="HassGetCurrentTime", + result=llm.ToolResult(data={"speech": {"plain": {"speech": "10:29"}}}), + ), + conversation.AssistantContent( + agent_id="conversation.test", content="It is 10:29" + ), + conversation.UserContent(content="What is your name?"), + ] + + messages = _convert_content_to_param(content) + + assert [message["type"] for message in messages] == ["message"] * 4 + assert [message["role"] for message in messages] == [ + "user", + "developer", + "assistant", + "user", + ] + note = messages[1]["content"] + assert "HassGetCurrentTime" in note + assert "10:29" in note + assert "not one of your tools" in note