feat: support new async-tool mechanism in AWS Nova Sonic
Add support to AWSNovaSonicLLMService for the new "async tool call" mechanism activated by `cancel_on_interruption=False`, which includes: - delivering results asynchronously - delivering result streams - cancelling running async tools Note that the introduction of the new mechanism had actually caused a regression in AWS Nova Sonic, which previously supported `cancel_on_interruption=False` with the old mechanism (simply avoiding discarding tool calls on interruptions). Support for the other major realtime services (`GeminiLiveLLMService`, `OpenAIRealtimeLLMService`) will follow in a separate PR — Gemini Live in particular needs more work before it can support long-running tool calls reliably.
This commit is contained in:
251
tests/test_async_tool_messages.py
Normal file
251
tests/test_async_tool_messages.py
Normal file
@@ -0,0 +1,251 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from pipecat.processors.aggregators import async_tool_messages
|
||||
|
||||
# The parser tests intentionally exercise the parser via the canonical
|
||||
# builders, so a drift between the two sides will surface as a parse failure
|
||||
# in CI rather than as a silent contract break in production.
|
||||
|
||||
|
||||
def _started_message(tool_call_id: str = "call_123") -> dict:
|
||||
return async_tool_messages.build_started_message(tool_call_id)
|
||||
|
||||
|
||||
def _intermediate_message(
|
||||
tool_call_id: str = "call_123",
|
||||
result: str = '"intermediate-1"',
|
||||
) -> dict:
|
||||
return async_tool_messages.build_intermediate_result_message(tool_call_id, result)
|
||||
|
||||
|
||||
def _final_message(
|
||||
tool_call_id: str = "call_123",
|
||||
result: str = '"final-result"',
|
||||
) -> dict:
|
||||
return async_tool_messages.build_final_result_message(tool_call_id, result)
|
||||
|
||||
|
||||
class TestParseMessage(unittest.TestCase):
|
||||
def test_parses_started(self):
|
||||
info = async_tool_messages.parse_message(_started_message("abc"))
|
||||
assert info is not None
|
||||
assert info.kind == "started"
|
||||
assert info.tool_call_id == "abc"
|
||||
assert info.status == "running"
|
||||
assert info.result is None
|
||||
assert "asynchronous task" in info.description
|
||||
|
||||
def test_parses_intermediate(self):
|
||||
info = async_tool_messages.parse_message(_intermediate_message("abc", '"hello"'))
|
||||
assert info is not None
|
||||
assert info.kind == "intermediate"
|
||||
assert info.tool_call_id == "abc"
|
||||
assert info.status == "running"
|
||||
assert info.result == '"hello"'
|
||||
|
||||
def test_parses_final(self):
|
||||
info = async_tool_messages.parse_message(_final_message("abc", '"done"'))
|
||||
assert info is not None
|
||||
assert info.kind == "final"
|
||||
assert info.tool_call_id == "abc"
|
||||
assert info.status == "finished"
|
||||
assert info.result == '"done"'
|
||||
|
||||
def test_raw_content_preserves_original_payload(self):
|
||||
# raw_content should round-trip the source message's `content` field so
|
||||
# services can forward the full payload to providers.
|
||||
msg = _final_message("abc", '"done"')
|
||||
info = async_tool_messages.parse_message(msg)
|
||||
assert info is not None
|
||||
assert info.raw_content == msg["content"]
|
||||
# Sanity: it should parse back to the original payload dict.
|
||||
payload = json.loads(info.raw_content)
|
||||
assert payload["type"] == "async_tool"
|
||||
assert payload["tool_call_id"] == "abc"
|
||||
assert payload["status"] == "finished"
|
||||
assert payload["result"] == '"done"'
|
||||
|
||||
def test_parses_completed_sentinel_result(self):
|
||||
# When a function returns no value, the aggregator sets the result to
|
||||
# the literal "COMPLETED" — same convention used for synchronous tool
|
||||
# calls. The parser doesn't treat it specially; it's just a string.
|
||||
info = async_tool_messages.parse_message(_final_message("abc", "COMPLETED"))
|
||||
assert info is not None
|
||||
assert info.kind == "final"
|
||||
assert info.result == "COMPLETED"
|
||||
|
||||
def test_returns_none_for_regular_user_message(self):
|
||||
assert async_tool_messages.parse_message({"role": "user", "content": "hello"}) is None
|
||||
|
||||
def test_returns_none_for_regular_assistant_message(self):
|
||||
assert async_tool_messages.parse_message({"role": "assistant", "content": "hi"}) is None
|
||||
|
||||
def test_returns_none_for_regular_tool_message(self):
|
||||
# IN_PROGRESS / regular tool result string content.
|
||||
assert (
|
||||
async_tool_messages.parse_message(
|
||||
{"role": "tool", "tool_call_id": "x", "content": "IN_PROGRESS"}
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
async_tool_messages.parse_message(
|
||||
{"role": "tool", "tool_call_id": "x", "content": "weather: sunny"}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_none_for_developer_message_without_payload(self):
|
||||
# role=developer is also used for non-async-tool things (potentially).
|
||||
assert (
|
||||
async_tool_messages.parse_message(
|
||||
{"role": "developer", "content": "some other developer note"}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_none_for_invalid_json_content(self):
|
||||
assert async_tool_messages.parse_message({"role": "tool", "content": "{not json"}) is None
|
||||
|
||||
def test_returns_none_for_non_dict_json(self):
|
||||
assert async_tool_messages.parse_message({"role": "tool", "content": "[1, 2, 3]"}) is None
|
||||
|
||||
def test_returns_none_for_wrong_payload_type(self):
|
||||
assert (
|
||||
async_tool_messages.parse_message(
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"type": "something_else", "tool_call_id": "x"}),
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_none_when_tool_call_id_missing(self):
|
||||
assert (
|
||||
async_tool_messages.parse_message(
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"type": "async_tool", "status": "running"}),
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_none_when_status_invalid(self):
|
||||
assert (
|
||||
async_tool_messages.parse_message(
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps(
|
||||
{"type": "async_tool", "tool_call_id": "x", "status": "weird"}
|
||||
),
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_none_for_non_string_content(self):
|
||||
# A multimodal message with content as a list would not be an async-tool message.
|
||||
assert (
|
||||
async_tool_messages.parse_message(
|
||||
{"role": "tool", "content": [{"type": "text", "text": "hi"}]}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_returns_none_for_missing_role(self):
|
||||
assert async_tool_messages.parse_message({"content": "{}"}) is None
|
||||
|
||||
|
||||
class TestBuilders(unittest.TestCase):
|
||||
"""Verify the builders produce the canonical payload shape and round-trip cleanly."""
|
||||
|
||||
def test_started_message_shape(self):
|
||||
msg = async_tool_messages.build_started_message("call_42")
|
||||
# Top-level: role=tool plus the tool_call_id (so the message can sit
|
||||
# alongside other regular tool messages in the context).
|
||||
assert msg["role"] == "tool"
|
||||
assert msg["tool_call_id"] == "call_42"
|
||||
payload = json.loads(msg["content"])
|
||||
assert payload["type"] == "async_tool"
|
||||
assert payload["status"] == "running"
|
||||
assert payload["tool_call_id"] == "call_42"
|
||||
assert "result" not in payload
|
||||
assert isinstance(payload["description"], str) and payload["description"]
|
||||
|
||||
def test_intermediate_message_shape(self):
|
||||
msg = async_tool_messages.build_intermediate_result_message("call_99", '"step-1"')
|
||||
# Intermediate/final use role=developer and don't carry tool_call_id at
|
||||
# the top level (that's only inside the payload).
|
||||
assert msg["role"] == "developer"
|
||||
assert "tool_call_id" not in msg
|
||||
payload = json.loads(msg["content"])
|
||||
assert payload["type"] == "async_tool"
|
||||
assert payload["status"] == "running"
|
||||
assert payload["tool_call_id"] == "call_99"
|
||||
assert payload["result"] == '"step-1"'
|
||||
assert isinstance(payload["description"], str) and payload["description"]
|
||||
|
||||
def test_final_message_shape(self):
|
||||
msg = async_tool_messages.build_final_result_message("call_7", '"all-done"')
|
||||
assert msg["role"] == "developer"
|
||||
assert "tool_call_id" not in msg
|
||||
payload = json.loads(msg["content"])
|
||||
assert payload["type"] == "async_tool"
|
||||
assert payload["status"] == "finished"
|
||||
assert payload["tool_call_id"] == "call_7"
|
||||
assert payload["result"] == '"all-done"'
|
||||
assert isinstance(payload["description"], str) and payload["description"]
|
||||
|
||||
def test_final_message_with_completed_sentinel(self):
|
||||
# The aggregator passes the literal "COMPLETED" string when the
|
||||
# function returned no value (same convention as for synchronous
|
||||
# tool calls). The builder doesn't treat it specially; it just
|
||||
# round-trips as the result.
|
||||
msg = async_tool_messages.build_final_result_message("call_1", "COMPLETED")
|
||||
payload = json.loads(msg["content"])
|
||||
assert payload["result"] == "COMPLETED"
|
||||
info = async_tool_messages.parse_message(msg)
|
||||
assert info is not None
|
||||
assert info.kind == "final"
|
||||
assert info.result == "COMPLETED"
|
||||
|
||||
def test_started_round_trip(self):
|
||||
msg = async_tool_messages.build_started_message("call_x")
|
||||
info = async_tool_messages.parse_message(msg)
|
||||
assert info is not None
|
||||
assert info.kind == "started"
|
||||
assert info.tool_call_id == "call_x"
|
||||
assert info.status == "running"
|
||||
assert info.result is None
|
||||
assert info.raw_content == msg["content"]
|
||||
|
||||
def test_intermediate_round_trip(self):
|
||||
msg = async_tool_messages.build_intermediate_result_message("call_x", '{"step": 1}')
|
||||
info = async_tool_messages.parse_message(msg)
|
||||
assert info is not None
|
||||
assert info.kind == "intermediate"
|
||||
assert info.tool_call_id == "call_x"
|
||||
assert info.status == "running"
|
||||
assert info.result == '{"step": 1}'
|
||||
|
||||
def test_final_round_trip(self):
|
||||
msg = async_tool_messages.build_final_result_message("call_x", '{"answer": 42}')
|
||||
info = async_tool_messages.parse_message(msg)
|
||||
assert info is not None
|
||||
assert info.kind == "final"
|
||||
assert info.tool_call_id == "call_x"
|
||||
assert info.status == "finished"
|
||||
assert info.result == '{"answer": 42}'
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user