Skip to content
Open
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 @@ -7,7 +7,11 @@

from livekit.agents import llm

from .utils import convert_mid_conversation_instructions, group_tool_calls
from .utils import (
convert_mid_conversation_instructions,
group_tool_calls,
parse_tool_call_arguments,
)


@dataclass
Expand Down Expand Up @@ -65,7 +69,7 @@ def to_chat_ctx(
"id": msg.call_id,
"type": "tool_use",
"name": msg.name,
"input": json.loads(msg.arguments or "{}"),
"input": parse_tool_call_arguments(msg),
}
)
elif msg.type == "function_call_output":
Expand Down
9 changes: 6 additions & 3 deletions livekit-agents/livekit/agents/llm/_provider_format/aws.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from __future__ import annotations

import itertools
import json
from dataclasses import dataclass
from typing import Any

from livekit.agents import llm

from .utils import convert_mid_conversation_instructions, group_tool_calls
from .utils import (
convert_mid_conversation_instructions,
group_tool_calls,
parse_tool_call_arguments,
)

_AWS_IMAGE_FORMATS = {
"image/jpeg": "jpeg",
Expand Down Expand Up @@ -66,7 +69,7 @@ def to_chat_ctx(
"toolUse": {
"toolUseId": msg.call_id,
"name": msg.name,
"input": json.loads(msg.arguments or "{}"),
"input": parse_tool_call_arguments(msg),
}
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
from livekit.agents import llm
from livekit.agents.log import logger

from .utils import convert_mid_conversation_instructions, group_tool_calls
from .utils import (
convert_mid_conversation_instructions,
group_tool_calls,
parse_tool_call_arguments,
)


@dataclass
Expand Down Expand Up @@ -65,7 +69,7 @@ def to_chat_ctx(
"function_call": {
"id": msg.call_id,
"name": msg.name,
"args": json.loads(msg.arguments or "{}"),
"args": parse_tool_call_arguments(msg),
}
}
# Inject thought_signature if available (Gemini 3 multi-turn function calling)
Expand Down
31 changes: 31 additions & 0 deletions livekit-agents/livekit/agents/llm/_provider_format/utils.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
from __future__ import annotations

import json
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any

from livekit.agents import llm
from livekit.agents.log import logger

_DEFAULT_INLINE_INSTRUCTIONS_TEMPLATE = "<instructions>\n{content}\n</instructions>"


def parse_tool_call_arguments(fnc_call: llm.FunctionCall) -> dict[str, Any]:
"""Parse a stored function call's arguments into a dict for JSON-object providers.

``FunctionCall.arguments`` is only canonicalized to valid JSON when the model's
output parses successfully; when it can't be parsed the raw string is kept
verbatim (an open-weight model's output that ``json_repair`` couldn't recover, or
history restored via ``ChatContext.from_dict`` / a custom ``llm_node``). The
Anthropic, Google, and AWS formatters send the arguments as a JSON object, so
formatting such history would otherwise raise ``json.JSONDecodeError`` on an
unrelated later turn. Fall back to an empty object rather than fabricating
arguments the model never sent, since the historical call already produced its
output.
"""
arguments = fnc_call.arguments
if not arguments:
return {}
try:
parsed = json.loads(arguments)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
return parsed
logger.warning(
"could not parse stored tool call arguments as a JSON object, using empty arguments",
extra={"call_id": fnc_call.call_id, "tool_name": fnc_call.name},
)
return {}
Comment thread
PranavMishra28 marked this conversation as resolved.


def convert_mid_conversation_instructions(
chat_ctx: llm.ChatContext,
*,
Expand Down
66 changes: 66 additions & 0 deletions tests/test_chat_ctx.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,3 +689,69 @@ def test_instructions_render():
empty_common = Instructions("", audio="audio only", text="text only")
assert empty_common.render(modality="audio") == "audio only"
assert empty_common.render(modality="text") == "text only"


# formats that send tool call arguments as a JSON object (vs. an opaque string like openai/mistral)
_JSON_OBJECT_FORMATS = ["anthropic", "google", "aws"]


def _tool_call_input(fmt: str, messages: list[dict[str, Any]]) -> Any:
"""Pull the single tool call's arguments out of a provider-formatted context."""
if fmt == "google":
for turn in messages:
for part in turn["parts"]:
if "function_call" in part:
return part["function_call"]["args"]
elif fmt == "anthropic":
for msg in messages:
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_use":
return block["input"]
elif fmt == "aws":
for msg in messages:
for block in msg["content"]:
if isinstance(block, dict) and "toolUse" in block:
return block["toolUse"]["input"]
raise AssertionError(f"no tool call found in {fmt} messages")


@pytest.mark.parametrize("fmt", _JSON_OBJECT_FORMATS)
def test_to_provider_format_tolerates_unparseable_tool_arguments(fmt: str):
"""A stored tool call whose arguments never parsed must not crash a later turn.

`FunctionCall.arguments` is kept verbatim when it can't be parsed (unrecoverable
open-weight output, or history restored via `ChatContext.from_dict`). The
Anthropic/Google/AWS formatters send arguments as a JSON object, so formatting
such history previously raised `json.JSONDecodeError`. It should degrade to an
empty object instead.
"""
ctx = ChatContext.empty()
ctx.insert(FunctionCall(call_id="c1", name="lookup", arguments="not-a-json-object"))
ctx.insert(FunctionCallOutput(call_id="c1", name="lookup", output="tool error", is_error=True))

messages, _ = ctx.to_provider_format(format=fmt)
assert _tool_call_input(fmt, messages) == {}


@pytest.mark.parametrize("fmt", _JSON_OBJECT_FORMATS)
def test_to_provider_format_preserves_valid_tool_arguments(fmt: str):
"""Valid JSON-object arguments are still passed through unchanged."""
ctx = ChatContext.empty()
ctx.insert(FunctionCall(call_id="c1", name="lookup", arguments='{"order": "123"}'))
ctx.insert(FunctionCallOutput(call_id="c1", name="lookup", output="ok", is_error=False))

messages, _ = ctx.to_provider_format(format=fmt)
assert _tool_call_input(fmt, messages) == {"order": "123"}


@pytest.mark.parametrize("fmt", _JSON_OBJECT_FORMATS)
@pytest.mark.parametrize("arguments", ["", "[1, 2, 3]", "42", "null"])
def test_to_provider_format_non_object_tool_arguments(fmt: str, arguments: str):
"""Empty or non-object arguments degrade to `{}`; a call's arguments are always a
named-parameter object, so an array/scalar/null is treated as no arguments."""
ctx = ChatContext.empty()
ctx.insert(FunctionCall(call_id="c1", name="lookup", arguments=arguments))
ctx.insert(FunctionCallOutput(call_id="c1", name="lookup", output="ok", is_error=False))

messages, _ = ctx.to_provider_format(format=fmt)
assert _tool_call_input(fmt, messages) == {}
Loading