Skip to content

Commit d2248d2

Browse files
authored
Default conversation agent to store tool calls in chat log (home-assistant#157377)
1 parent 8fe79a8 commit d2248d2

File tree

2 files changed

+200
-1
lines changed

2 files changed

+200
-1
lines changed

homeassistant/components/conversation/default_agent.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
entity_registry as er,
6767
floor_registry as fr,
6868
intent,
69+
llm,
6970
start as ha_start,
7071
template,
7172
translation,
@@ -76,7 +77,7 @@
7677
from homeassistant.util.json import JsonObjectType, json_loads_object
7778

7879
from .agent_manager import get_agent_manager
79-
from .chat_log import AssistantContent, ChatLog
80+
from .chat_log import AssistantContent, ChatLog, ToolResultContent
8081
from .const import (
8182
DOMAIN,
8283
METADATA_CUSTOM_FILE,
@@ -430,6 +431,8 @@ async def _async_handle_message(
430431
) -> ConversationResult:
431432
"""Handle a message."""
432433
response: intent.IntentResponse | None = None
434+
tool_input: llm.ToolInput | None = None
435+
tool_result: dict[str, Any] = {}
433436

434437
# Check if a trigger matched
435438
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
@@ -438,6 +441,16 @@ async def _async_handle_message(
438441
trigger_result, user_input
439442
)
440443

444+
# Create tool result
445+
tool_input = llm.ToolInput(
446+
tool_name="trigger_sentence",
447+
tool_args={},
448+
external=True,
449+
)
450+
tool_result = {
451+
"response": response_text,
452+
}
453+
441454
# Convert to conversation result
442455
response = intent.IntentResponse(
443456
language=user_input.language or self.hass.config.language
@@ -447,10 +460,44 @@ async def _async_handle_message(
447460
if response is None:
448461
# Match intents
449462
intent_result = await self.async_recognize_intent(user_input)
463+
450464
response = await self._async_process_intent_result(
451465
intent_result, user_input
452466
)
453467

468+
if response.response_type != intent.IntentResponseType.ERROR:
469+
assert intent_result is not None
470+
assert intent_result.intent is not None
471+
# Create external tool call for the intent
472+
tool_input = llm.ToolInput(
473+
tool_name=intent_result.intent.name,
474+
tool_args={
475+
entity.name: entity.value or entity.text
476+
for entity in intent_result.entities_list
477+
},
478+
external=True,
479+
)
480+
# Create tool result from intent response
481+
tool_result = llm.IntentResponseDict(response)
482+
483+
# Add tool call and result to chat log if we have one
484+
if tool_input is not None:
485+
chat_log.async_add_assistant_content_without_tools(
486+
AssistantContent(
487+
agent_id=user_input.agent_id,
488+
content=None,
489+
tool_calls=[tool_input],
490+
)
491+
)
492+
chat_log.async_add_assistant_content_without_tools(
493+
ToolResultContent(
494+
agent_id=user_input.agent_id,
495+
tool_call_id=tool_input.id,
496+
tool_name=tool_input.tool_name,
497+
tool_result=tool_result,
498+
)
499+
)
500+
454501
speech: str = response.speech.get("plain", {}).get("speech", "")
455502
chat_log.async_add_assistant_content_without_tools(
456503
AssistantContent(

tests/components/conversation/test_default_agent.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
default_agent,
1818
get_agent_manager,
1919
)
20+
from homeassistant.components.conversation.chat_log import (
21+
AssistantContent,
22+
ToolResultContent,
23+
async_get_chat_log,
24+
)
2025
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
2126
from homeassistant.components.conversation.models import ConversationInput
2227
from homeassistant.components.conversation.trigger import TriggerDetails
@@ -52,6 +57,7 @@
5257
)
5358
from homeassistant.helpers import (
5459
area_registry as ar,
60+
chat_session,
5561
device_registry as dr,
5662
entity_registry as er,
5763
floor_registry as fr,
@@ -3424,3 +3430,149 @@ async def test_fuzzy_matching(
34243430
if slot_name != "preferred_area_id" # context area
34253431
}
34263432
assert actual_slots == slots
3433+
3434+
3435+
@pytest.mark.usefixtures("init_components")
3436+
async def test_intent_tool_call_in_chat_log(hass: HomeAssistant) -> None:
3437+
"""Test that intent tool calls are stored in the chat log."""
3438+
hass.states.async_set(
3439+
"light.test_light", "off", attributes={ATTR_FRIENDLY_NAME: "Test Light"}
3440+
)
3441+
async_mock_service(hass, "light", "turn_on")
3442+
3443+
result = await conversation.async_converse(
3444+
hass, "turn on test light", None, Context(), None
3445+
)
3446+
3447+
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
3448+
3449+
with (
3450+
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
3451+
async_get_chat_log(hass, session) as chat_log,
3452+
):
3453+
pass
3454+
3455+
# Find the tool call in the chat log
3456+
tool_call_content: AssistantContent | None = None
3457+
tool_result_content: ToolResultContent | None = None
3458+
assistant_content: AssistantContent | None = None
3459+
3460+
for content in chat_log.content:
3461+
if content.role == "assistant" and content.tool_calls:
3462+
tool_call_content = content
3463+
if content.role == "tool_result":
3464+
tool_result_content = content
3465+
if content.role == "assistant" and not content.tool_calls:
3466+
assistant_content = content
3467+
3468+
# Verify tool call was stored
3469+
assert tool_call_content is not None and tool_call_content.tool_calls is not None
3470+
assert len(tool_call_content.tool_calls) == 1
3471+
assert tool_call_content.tool_calls[0].tool_name == "HassTurnOn"
3472+
assert tool_call_content.tool_calls[0].external is True
3473+
assert tool_call_content.tool_calls[0].tool_args.get("name") == "Test Light"
3474+
3475+
# Verify tool result was stored
3476+
assert tool_result_content is not None
3477+
assert tool_result_content.tool_name == "HassTurnOn"
3478+
assert tool_result_content.tool_result["response_type"] == "action_done"
3479+
3480+
# Verify final assistant content with speech
3481+
assert assistant_content is not None
3482+
assert assistant_content.content is not None
3483+
3484+
3485+
@pytest.mark.usefixtures("init_components")
3486+
async def test_trigger_tool_call_in_chat_log(hass: HomeAssistant) -> None:
3487+
"""Test that trigger tool calls are stored in the chat log."""
3488+
trigger_sentence = "test automation trigger"
3489+
trigger_response = "Trigger activated!"
3490+
3491+
manager = get_agent_manager(hass)
3492+
callback = AsyncMock(return_value=trigger_response)
3493+
manager.register_trigger(TriggerDetails([trigger_sentence], callback))
3494+
3495+
result = await conversation.async_converse(
3496+
hass, trigger_sentence, None, Context(), None
3497+
)
3498+
3499+
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
3500+
3501+
with (
3502+
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
3503+
async_get_chat_log(hass, session) as chat_log,
3504+
):
3505+
pass
3506+
3507+
# Find the tool call in the chat log
3508+
tool_call_content: AssistantContent | None = None
3509+
tool_result_content: ToolResultContent | None = None
3510+
3511+
for content in chat_log.content:
3512+
if content.role == "assistant" and content.tool_calls:
3513+
tool_call_content = content
3514+
if content.role == "tool_result":
3515+
tool_result_content = content
3516+
3517+
# Verify tool call was stored
3518+
assert tool_call_content is not None and tool_call_content.tool_calls is not None
3519+
assert len(tool_call_content.tool_calls) == 1
3520+
assert tool_call_content.tool_calls[0].tool_name == "trigger_sentence"
3521+
assert tool_call_content.tool_calls[0].external is True
3522+
assert tool_call_content.tool_calls[0].tool_args == {}
3523+
3524+
# Verify tool result was stored
3525+
assert tool_result_content is not None
3526+
assert tool_result_content.tool_name == "trigger_sentence"
3527+
assert tool_result_content.tool_result["response"] == trigger_response
3528+
3529+
3530+
@pytest.mark.usefixtures("init_components")
3531+
async def test_no_tool_call_on_no_intent_match(hass: HomeAssistant) -> None:
3532+
"""Test that no tool call is stored when no intent is matched."""
3533+
result = await conversation.async_converse(
3534+
hass, "this is a random sentence that should not match", None, Context(), None
3535+
)
3536+
3537+
assert result.response.response_type == intent.IntentResponseType.ERROR
3538+
3539+
with (
3540+
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
3541+
async_get_chat_log(hass, session) as chat_log,
3542+
):
3543+
pass
3544+
3545+
# Verify no tool call was stored
3546+
for content in chat_log.content:
3547+
if content.role == "assistant":
3548+
assert content.tool_calls is None or len(content.tool_calls) == 0
3549+
break
3550+
else:
3551+
pytest.fail("No assistant content found in chat log")
3552+
3553+
3554+
@pytest.mark.usefixtures("init_components")
3555+
async def test_intent_tool_call_with_error_response(hass: HomeAssistant) -> None:
3556+
"""Test that intent tool calls store error information correctly."""
3557+
# Request to turn on a non-existent device
3558+
result = await conversation.async_converse(
3559+
hass, "turn on the non existent device", None, Context(), None
3560+
)
3561+
3562+
assert result.response.response_type == intent.IntentResponseType.ERROR
3563+
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
3564+
3565+
with (
3566+
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
3567+
async_get_chat_log(hass, session) as chat_log,
3568+
):
3569+
pass
3570+
3571+
# Verify no tool call was stored for unmatched entities
3572+
tool_call_found = False
3573+
for content in chat_log.content:
3574+
if content.role == "assistant" and content.tool_calls:
3575+
tool_call_found = True
3576+
3577+
# No tool call should be stored since the entity could not be matched
3578+
assert not tool_call_found

0 commit comments

Comments
 (0)