fix(core): split concatenated tool arguments

This commit is contained in:
ggbond 2026-09-11 00:32:02 +08:00
parent fbed17d567
commit 7704317022
4 changed files with 269 additions and 21 deletions

View file

@ -2207,14 +2207,16 @@ def parse_tool_call_arguments(
arguments: str | None,
tool_name: str | None = None,
context: str | None = None,
allow_concatenated: bool = False,
) -> Any:
"""
Parse tool call arguments from a JSON string.
When the JSON is malformed (e.g. truncated by the model), this function
attempts a lightweight repair (closing unmatched brackets/braces) before
raising an error. A warning is logged whenever repair succeeds so that
callers are aware the arguments were not perfectly formed.
attempts a lightweight repair (closing unmatched brackets/braces). If
``allow_concatenated`` is true, it also attempts to split concatenated
JSON objects. A warning is logged whenever repair or splitting succeeds
so that callers are aware the arguments were not perfectly formed.
Args:
arguments: The JSON string containing tool arguments, or None.
@ -2223,8 +2225,10 @@ def parse_tool_call_arguments(
Returns:
Parsed arguments (usually a dict, but may be any JSON-deserializable
type such as list, str, int, float, or None). Returns empty dict if
arguments is None or empty.
type such as list, str, int, float, or None). When
``allow_concatenated`` is true, concatenated JSON objects are
returned as a list of dicts. Returns empty dict if arguments is
None or empty.
Raises:
ValueError: If the arguments string is not valid JSON and cannot be repaired.
@ -2249,6 +2253,21 @@ def parse_tool_call_arguments(
)
return repaired
if allow_concatenated:
split_arguments: Final = split_concatenated_json_objects(arguments)
if split_arguments:
verbose_logger.warning(
"Recovered %d concatenated tool call argument object(s) for tool '%s' "
"(%s). Original (%d chars): %.200s%s",
len(split_arguments),
tool_name or "<unknown>",
context or "unknown context",
len(arguments),
arguments,
"..." if len(arguments) > 200 else "",
)
return split_arguments
error_parts: Final = ["Failed to parse tool call arguments"]
if tool_name:
@ -2279,8 +2298,7 @@ def split_concatenated_json_objects(raw: str) -> list[dict[str, object]]:
The walk degrades gracefully: if the string is malformed or truncated
(e.g. a stream that ended mid-tool-call), whatever complete objects were
parsed before the bad tail are returned and the remainder is discarded
with a warning, rather than raising. The sole caller
(``_convert_to_bedrock_tool_call_invoke``) treats an empty result as
with a warning, rather than raising. Callers treat an empty result as
``input={}`` so the conversation can continue instead of hard-failing.
Returns

View file

@ -5350,10 +5350,12 @@ def get_attribute_or_key(tool_or_function, attribute, default=None):
class NormalizedToolCall(TypedDict):
id: str | None
name: str | None
arguments: dict[str, object]
arguments: Mapping[str, object]
def _parse_tool_call_arguments(raw: object, tool_name: str | None, context: str) -> dict[str, object]:
def _parse_tool_call_arguments(
raw: object, tool_name: str | None, context: str
) -> Mapping[str, object] | Sequence[Mapping[str, object]]:
# Anthropic's tool_use blocks already carry a parsed dict in "input";
# chat completions and the Responses API carry a JSON string that may be
# truncated by the model, so route those through the repair-aware parser.
@ -5367,11 +5369,37 @@ def _parse_tool_call_arguments(raw: object, tool_name: str | None, context: str)
)
try:
parsed: Final = parse_tool_call_arguments(normalized_raw, tool_name=tool_name, context=context)
parsed: Final = parse_tool_call_arguments(
normalized_raw,
tool_name=tool_name,
context=context,
allow_concatenated=True,
)
except ValueError as e:
verbose_logger.warning("Failed to parse tool call arguments: %s", e)
return {}
return parsed if isinstance(parsed, dict) else {}
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list) and parsed and all(isinstance(item, dict) for item in parsed):
return parsed
return {}
def _normalized_tool_calls(
call_id: str | None,
name: str | None,
parsed_arguments: Mapping[str, object] | Sequence[Mapping[str, object]],
) -> tuple[NormalizedToolCall, ...]:
if isinstance(parsed_arguments, Mapping):
return (NormalizedToolCall(id=call_id, name=name, arguments=parsed_arguments),)
return tuple(
NormalizedToolCall(
id=call_id if argument_index == 0 or call_id is None else f"{call_id}_{argument_index}",
name=name,
arguments=arguments,
)
for argument_index, arguments in enumerate(parsed_arguments)
)
def _tool_calls_from_chat_completion_response(
@ -5392,11 +5420,11 @@ def _tool_calls_from_chat_completion_response(
if fn is None:
continue
name = get_attribute_or_key(fn, "name")
result.append(
NormalizedToolCall(
id=get_attribute_or_key(tc, "id"),
name=name,
arguments=_parse_tool_call_arguments(
result.extend(
_normalized_tool_calls(
get_attribute_or_key(tc, "id"),
name,
_parse_tool_call_arguments(
get_attribute_or_key(fn, "arguments", "{}"),
tool_name=name,
context="chat completions",
@ -5415,11 +5443,11 @@ def _tool_calls_from_responses_api_response(response: object) -> list[Normalized
if get_attribute_or_key(item, "type") != "function_call":
continue
name = get_attribute_or_key(item, "name")
result.append(
NormalizedToolCall(
id=get_attribute_or_key(item, "call_id") or get_attribute_or_key(item, "id"),
name=name,
arguments=_parse_tool_call_arguments(
result.extend(
_normalized_tool_calls(
get_attribute_or_key(item, "call_id") or get_attribute_or_key(item, "id"),
name,
_parse_tool_call_arguments(
get_attribute_or_key(item, "arguments", "{}"),
tool_name=name,
context="responses API",

View file

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_any_messages_to_chat_completion_str_messages_conversion,
hoist_images_from_tool_messages,
is_encrypted_reasoning_block,
parse_tool_call_arguments,
responses_reasoning_items_from_thinking_blocks,
split_concatenated_json_objects,
strip_encrypted_reasoning_from_messages,
@ -191,6 +192,37 @@ def test_convert_prefix_message_to_non_prefix_messages():
# ── split_concatenated_json_objects tests ──
def test_parse_tool_call_arguments_concatenated_objects_opt_in():
"""Concatenated JSON objects are split only when explicitly enabled."""
raw = '{"city": "Paris"}{"units": "celsius"}'
result = parse_tool_call_arguments(
raw,
tool_name="weather",
context="chat completions",
allow_concatenated=True,
)
assert result == [{"city": "Paris"}, {"units": "celsius"}]
def test_parse_tool_call_arguments_concatenated_objects_disabled_by_default():
"""Existing parser callers keep the original strict behavior."""
raw = '{"city": "Paris"}{"units": "celsius"}'
with pytest.raises(ValueError, match="Extra data"):
parse_tool_call_arguments(raw, tool_name="weather", context="chat completions")
def test_parse_tool_call_arguments_concatenated_partial_tail():
"""Only complete objects before a truncated tail are recovered."""
raw = '{"city": "Paris"}{"units": "celsius"}{"forecast":'
result = parse_tool_call_arguments(
raw,
tool_name="weather",
context="chat completions",
allow_concatenated=True,
)
assert result == [{"city": "Paris"}, {"units": "celsius"}]
def test_split_concatenated_json_single_object():
"""A single valid JSON object is returned as a one-element list."""
result = split_concatenated_json_objects('{"location": "Boston"}')

View file

@ -3401,6 +3401,176 @@ def test_get_tool_calls_from_response_warns_for_malformed_arguments(caplog):
assert "Failed to parse tool call arguments" in caplog.text
def test_get_tool_calls_from_response_splits_concatenated_arguments():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "search",
"arguments": '{"query": "first"}{"query": "second"}',
},
}
]
}
}
]
}
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [
{"id": "call_1", "name": "search", "arguments": {"query": "first"}},
{"id": "call_1_1", "name": "search", "arguments": {"query": "second"}},
]
def test_get_tool_calls_from_response_splits_concatenated_responses_arguments():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": None,
"output": [
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "search",
"arguments": '{"query": "first"}{"query": "second"}',
}
],
}
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [
{"id": "call_1", "name": "search", "arguments": {"query": "first"}},
{"id": "call_1_1", "name": "search", "arguments": {"query": "second"}},
]
def test_get_tool_calls_from_response_non_object_arguments_returns_empty():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "search",
"arguments": "[1, 2, 3]",
},
}
]
}
}
]
}
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}]
def test_get_tool_calls_from_response_accepts_dict_arguments():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "search",
"arguments": {"query": "first"},
},
}
]
}
}
]
}
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {"query": "first"}}]
def test_get_tool_calls_from_response_ignores_non_string_arguments():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_1",
"function": {"name": "search", "arguments": 123},
}
]
}
}
]
}
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}]
def test_get_tool_calls_from_response_ignores_malformed_tool_call_entries():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": [
{
"message": {
"tool_calls": [{"id": "call_1"}],
}
}
],
"output": None,
}
assert get_tool_calls_from_response(response) == []
def test_get_tool_calls_from_response_ignores_non_function_output_items():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": None,
"output": [{"type": "message", "id": "msg_1"}],
}
assert get_tool_calls_from_response(response) == []
def test_group_tool_exchanges_pairs_assistant_with_its_tool_rows():
from litellm.litellm_core_utils.prompt_templates.factory import group_tool_exchanges