Skip to content
Closed
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
56 changes: 56 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,42 @@
logger = logging.getLogger(__name__)



def _split_concatenated_tool_call_arguments(raw_args: str) -> list[str] | None:
"""Split ``{"..."}{"..."}`` tool-call payloads into separate JSON objects.

Some providers can concatenate multiple complete top-level argument
objects without delimiters when emitting parallel tool calls in a single
streaming chunk. Only return a split when the entire payload can be
losslessly decoded as 2+ complete dict objects; otherwise return None so
existing truncation handling stays in control.
"""
if not isinstance(raw_args, str):
return None

raw_stripped = raw_args.strip()
if not raw_stripped:
return None

decoder = json.JSONDecoder()
pos = 0
decoded: list[str] = []
while pos < len(raw_stripped):
while pos < len(raw_stripped) and raw_stripped[pos].isspace():
pos += 1
if pos >= len(raw_stripped):
break
try:
parsed, end = decoder.raw_decode(raw_stripped, pos)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
decoded.append(json.dumps(parsed, separators=(",", ":")))
pos = end

return decoded if len(decoded) > 1 else None

def _ra():
"""Lazy ``run_agent`` reference.

Expand Down Expand Up @@ -1907,6 +1943,26 @@ def _call_chat_completions():
tc = tool_calls_acc[idx]
arguments = tc["function"]["arguments"]
tool_name = tc["function"]["name"] or "?"
split_arguments = _split_concatenated_tool_call_arguments(arguments)
if split_arguments:
logger.warning(
"Split concatenated tool_call arguments for %s into %d calls",
tool_name,
len(split_arguments),
)
base_id = tc["id"] or f"call_{idx}"
for split_idx, split_arg in enumerate(split_arguments):
split_id = base_id if split_idx == 0 else f"{base_id}_split_{split_idx}"
mock_tool_calls.append(SimpleNamespace(
id=split_id,
type=tc["type"],
extra_content=tc.get("extra_content"),
function=SimpleNamespace(
name=tc["function"]["name"],
arguments=split_arg,
),
))
continue
if arguments and arguments.strip():
try:
json.loads(arguments)
Expand Down
32 changes: 32 additions & 0 deletions tests/test_model_tools_tool_call_payload_repair.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from __future__ import annotations

import json
from pathlib import Path

from agent import chat_completion_helpers as helper


def test_split_concatenated_tool_call_arguments_returns_complete_dict_json_objects():
result = helper._split_concatenated_tool_call_arguments(
'{"path":"README.md"}{"query":"tool calls"}'
)
assert result == ['{"path":"README.md"}', '{"query":"tool calls"}']
assert [json.loads(item) for item in result] == [
{"path": "README.md"},
{"query": "tool calls"},
]


def test_split_concatenated_tool_call_arguments_rejects_single_partial_or_non_dict_payloads():
assert helper._split_concatenated_tool_call_arguments('{"path":"README.md"}') is None
assert helper._split_concatenated_tool_call_arguments('{"path":"README.md"}{"query":') is None
assert helper._split_concatenated_tool_call_arguments('[1,2]{"path":"README.md"}') is None
assert helper._split_concatenated_tool_call_arguments('') is None


def test_streaming_reconstruction_wires_concatenated_argument_splitter():
source = Path(helper.__file__).read_text(encoding="utf-8")
assert "split_arguments = _split_concatenated_tool_call_arguments(arguments)" in source
assert "mock_tool_calls.append(SimpleNamespace(" in source
assert "split_idx" in source
assert "continue" in source