Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1286,17 +1286,21 @@ async def _handle_tool_output_content_event(self, event_data: dict) -> None:
tool_name = event_data["event"]["toolUse"]["toolName"]
args = event_data["event"]["toolUse"]["content"]

# Nova Sonic sometimes double-encodes tool arguments as a JSON string
# containing another JSON string (e.g. "\"{\\\"order_id\\\":\\\"1234\\\"}\"").
# Detect and unwrap so the framework receives a proper JSON object string.
# Nova Sonic sometimes double-encodes tool arguments: the outer JSON parse
# yields a string whose contents are themselves a JSON object string
# (e.g. "\"{\\\"order_id\\\":\\\"1234\\\"}\"").
# Only peel one layer when the inner string is a JSON object so that
# legitimate string-valued schemas (e.g. content="hello") are preserved.
if isinstance(args, str):
try:
import json

parsed = json.loads(args)
if isinstance(parsed, str):
# Double-encoded: the outer parse gave us a string, try again
args = parsed
try:
inner = json.loads(parsed)
if isinstance(inner, dict):
args = parsed
except (json.JSONDecodeError, TypeError):
pass # inner string is a plain value, leave args untouched
except (json.JSONDecodeError, TypeError):
pass

Expand Down
Empty file.
166 changes: 166 additions & 0 deletions livekit-plugins/livekit-plugins-aws/tests/test_nova_sonic_tool_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""
Regression tests for Nova Sonic tool-call argument parsing.

Nova Sonic may deliver toolUse.content as a doubly-encoded JSON string — a
JSON string whose value is itself a JSON object string. When this reaches
prepare_function_arguments, pydantic_core.from_json() returns a Python str
instead of a dict, causing:

TypeError: string indices must be integers, not 'str' (utils.py:404)

The fix peels off one encoding layer *only* when the inner string is itself a
valid JSON object. Legitimate string-valued schemas (e.g. content="hello")
must be left untouched so that raw tool schemas with primitive top-level types
continue to work correctly.
"""

import json
import sys
from unittest.mock import MagicMock

# ---------------------------------------------------------------------------
# Stub out the optional AWS Smithy/Bedrock SDK not installed in the base venv.
# ---------------------------------------------------------------------------
_AWS_STUBS = [
"aws_sdk_bedrock_runtime",
"aws_sdk_bedrock_runtime.client",
"aws_sdk_bedrock_runtime.models",
"aws_sdk_bedrock_runtime.config",
"smithy_aws_core",
"smithy_aws_core.identity",
"smithy_aws_event_stream",
"smithy_aws_event_stream.exceptions",
"smithy_core",
"smithy_core.aio",
"smithy_core.aio.interfaces",
"smithy_core.aio.interfaces.identity",
]
Comment thread
rililinx marked this conversation as resolved.
for _mod in _AWS_STUBS:
if _mod not in sys.modules:
sys.modules[_mod] = MagicMock()


def _make_tool_event(content) -> dict:
return {
"event": {
"toolUse": {
"toolUseId": "test-id-123",
"toolName": "check_availability",
"content": content,
}
}
}


def _make_fake_session(captured: list) -> MagicMock:
ch = MagicMock()
ch.send_nowait = lambda call: captured.append(call)

generation = MagicMock()
generation.function_ch = ch

session = MagicMock()
session._current_generation = generation
session._pending_tools = set()
session._close_current_generation = MagicMock()
return session


class TestHandleToolOutputContentEvent:
"""Unit tests for _handle_tool_output_content_event."""

async def test_doubly_encoded_string_is_unwrapped(self):
"""Bug case: content is a JSON string wrapping another JSON string.

Nova Sonic sends: '"{\\"input\\":{\\"date\\":\\"2026-04-10\\"}}"'
After fix: '{"input":{"date":"2026-04-10"}}' (one layer removed)
"""
from livekit.plugins.aws.experimental.realtime.realtime_model import (
RealtimeSession,
)

captured = []
session = _make_fake_session(captured)

inner_json = json.dumps({"input": {"date": "2026-04-10"}})
doubly_encoded = json.dumps(inner_json) # wrap in another JSON string
event = _make_tool_event(doubly_encoded)

await RealtimeSession._handle_tool_output_content_event(session, event)

assert len(captured) == 1
# arguments must be the inner JSON string (one layer removed), not the
# doubly-encoded original
assert captured[0].arguments == inner_json

async def test_single_encoded_string_passed_through(self):
"""Normal case: content is already a proper JSON object string."""
from livekit.plugins.aws.experimental.realtime.realtime_model import (
RealtimeSession,
)

captured = []
session = _make_fake_session(captured)

json_str = json.dumps({"input": {"date": "2026-04-10"}})
event = _make_tool_event(json_str)

await RealtimeSession._handle_tool_output_content_event(session, event)

assert len(captured) == 1
assert captured[0].arguments == json_str

async def test_invalid_json_string_does_not_crash(self):
"""Invalid JSON string → plugin leaves it as-is rather than raising."""
from livekit.plugins.aws.experimental.realtime.realtime_model import (
RealtimeSession,
)

captured = []
session = _make_fake_session(captured)

event = _make_tool_event("not-valid-json")

await RealtimeSession._handle_tool_output_content_event(session, event)

assert len(captured) == 1
assert captured[0].arguments == "not-valid-json"

async def test_string_primitive_schema_not_unwrapped(self):
"""Regression: content is a JSON string literal (valid primitive schema).

Bedrock raw tool schemas may legitimately pass a string value such as
'"hello"'. This must NOT be unwrapped to 'hello' (which would be invalid
JSON and cause from_json() to fail downstream).
"""
from livekit.plugins.aws.experimental.realtime.realtime_model import (
RealtimeSession,
)

captured = []
session = _make_fake_session(captured)

string_arg = json.dumps("hello") # produces '"hello"'
event = _make_tool_event(string_arg)

await RealtimeSession._handle_tool_output_content_event(session, event)

assert len(captured) == 1
# Must be the original '"hello"', not the bare string 'hello'
assert captured[0].arguments == string_arg

async def test_tool_name_and_id_forwarded_correctly(self):
"""call_id and name are passed through regardless of args format."""
from livekit.plugins.aws.experimental.realtime.realtime_model import (
RealtimeSession,
)

captured = []
session = _make_fake_session(captured)

event = _make_tool_event(json.dumps({"date": "2026-04-10"}))

await RealtimeSession._handle_tool_output_content_event(session, event)

assert captured[0].call_id == "test-id-123"
assert captured[0].name == "check_availability"
Loading