Skip to content

Commit 33e13eb

Browse files
fix(tools): merge glued tool JSON fragments from local models
DeepSeek and similar models emit {…}{}{…} tool args; merge into one object before dispatch instead of triplicate ls/Git/Grep calls. Fix Grep format_output tool_footer TypeError on parse errors. Tests in test_tool_arguments.py. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8190f0d commit 33e13eb

5 files changed

Lines changed: 162 additions & 25 deletions

File tree

cecli/coders/agent_coder.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -727,25 +727,9 @@ async def _execute_local_tools(self, tool_calls_list):
727727
continue
728728

729729
if args_string:
730-
json_chunks = utils.split_concatenated_json(args_string)
731-
for chunk in json_chunks:
732-
try:
733-
parsed_args_list.append(json.loads(chunk))
734-
except json.JSONDecodeError as e:
735-
self.model_kwargs = {}
736-
self.io.tool_warning(
737-
f"Malformed JSON arguments in tool {tool_name}: {chunk}"
738-
)
739-
tool_responses.append(
740-
{
741-
"role": "tool",
742-
"tool_call_id": tool_call.id,
743-
"content": (
744-
f"Malformed JSON arguments in tool {tool_name}: {str(e)}"
745-
),
746-
}
747-
)
748-
continue
730+
from cecli.tools.utils.helpers import parse_tool_arguments
731+
732+
parsed_args_list = [parse_tool_arguments(args_string)]
749733
if not parsed_args_list and not args_string:
750734
parsed_args_list.append({})
751735
all_results_content = []

cecli/coders/base_coder.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2645,7 +2645,16 @@ def _expand_concatenated_json(self, tool_calls):
26452645
expanded_tool_calls.append(tool_call)
26462646
continue
26472647

2648-
# We have concatenated JSON, so expand it into multiple tool calls.
2648+
from cecli.tools.utils.helpers import merge_glued_json_objects
2649+
2650+
merged = merge_glued_json_objects(json_chunks)
2651+
if merged is not None:
2652+
new_tool_call = copy_tool_call(tool_call)
2653+
new_tool_call.function.arguments = json.dumps(merged)
2654+
expanded_tool_calls.append(new_tool_call)
2655+
continue
2656+
2657+
# Non-object glued JSON: expand into multiple tool calls (legacy).
26492658
for i, chunk in enumerate(json_chunks):
26502659
if not chunk.strip():
26512660
continue

cecli/tools/grep.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from cecli.helpers.hashline import strip_hashline
88
from cecli.run_cmd import run_cmd_subprocess
99
from cecli.tools.utils.base_tool import BaseTool
10-
from cecli.tools.utils.helpers import ToolError, grep_error_hint, normalize_search_operations
10+
from cecli.tools.utils.helpers import (
11+
ToolError,
12+
grep_error_hint,
13+
normalize_search_operations,
14+
parse_tool_arguments,
15+
)
1116
from cecli.tools.utils.output import color_markers, tool_footer, tool_header
1217

1318

@@ -242,9 +247,8 @@ def format_output(cls, coder, mcp_server, tool_response):
242247
"""Format output for Grep tool."""
243248
color_start, color_end = color_markers(coder)
244249

245-
try:
246-
params = json.loads(tool_response.function.arguments)
247-
except json.JSONDecodeError:
250+
params = parse_tool_arguments(tool_response.function.arguments or "")
251+
if not params and (tool_response.function.arguments or "").strip():
248252
coder.io.tool_error("Invalid Tool JSON")
249253
return
250254

@@ -255,7 +259,7 @@ def format_output(cls, coder, mcp_server, tool_response):
255259
searches = normalize_search_operations(params.get("searches", []))
256260
except ToolError as err:
257261
coder.io.tool_error(str(err))
258-
tool_footer(coder=coder, mcp_server=mcp_server, tool_response=tool_response)
262+
tool_footer(coder=coder, tool_response=tool_response)
259263
return
260264

261265
if searches:

cecli/tools/utils/helpers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,74 @@ def _repair_local_model_json_text(text: str) -> str:
358358
return repaired
359359

360360

361+
def merge_glued_json_objects(chunks: list[str]) -> dict | None:
362+
"""
363+
Merge consecutive JSON object strings from glued local-model tool args.
364+
365+
Example: ``{"limit": 15}{}{"path": "."}`` → ``{"limit": 15, "path": "."}``.
366+
Returns ``None`` when chunks are not all mergeable objects (caller may split).
367+
"""
368+
merged: dict = {}
369+
saw_non_empty = False
370+
for chunk in chunks:
371+
text = chunk.strip()
372+
if not text:
373+
continue
374+
obj = _try_parse_json_value(text)
375+
if obj is None:
376+
try:
377+
obj = json.loads(text)
378+
except json.JSONDecodeError:
379+
return None
380+
if isinstance(obj, list):
381+
return None
382+
if not isinstance(obj, dict):
383+
return None
384+
if obj:
385+
merged.update(obj)
386+
saw_non_empty = True
387+
if saw_non_empty or merged == {}:
388+
return merged
389+
return None
390+
391+
392+
def parse_tool_arguments(args_string: str) -> dict:
393+
"""Parse tool-call arguments, merging glued ``{…}{} {…}`` object fragments."""
394+
text = (args_string or "").strip()
395+
if not text:
396+
return {}
397+
try:
398+
parsed = json.loads(text)
399+
if isinstance(parsed, dict):
400+
return parsed
401+
except json.JSONDecodeError:
402+
pass
403+
404+
parsed = _try_parse_json_value(text)
405+
if isinstance(parsed, dict):
406+
return parsed
407+
408+
from cecli import utils as cecli_utils
409+
410+
chunks = cecli_utils.split_concatenated_json(text)
411+
if len(chunks) <= 1:
412+
if not chunks:
413+
return {}
414+
lone = _try_parse_json_value(chunks[0])
415+
if isinstance(lone, dict):
416+
return lone
417+
try:
418+
single = json.loads(chunks[0])
419+
except json.JSONDecodeError:
420+
return {}
421+
return single if isinstance(single, dict) else {}
422+
423+
merged = merge_glued_json_objects(chunks)
424+
if merged is not None:
425+
return merged
426+
return {}
427+
428+
361429
def _try_parse_json_value(text: str):
362430
"""Parse JSON text, including repairs for common local-model tool-arg quirks."""
363431
text = text.strip()

tests/tools/test_tool_arguments.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Glued local-model tool JSON argument parsing."""
2+
3+
import json
4+
from types import SimpleNamespace
5+
from unittest.mock import Mock
6+
7+
import pytest
8+
9+
from cecli.coders.base_coder import Coder
10+
from cecli.tools.grep import Tool as GrepTool
11+
from cecli.tools.utils.helpers import merge_glued_json_objects, parse_tool_arguments
12+
13+
14+
def test_parse_tool_arguments_merges_glued_objects_with_empty_fragments():
15+
raw = '{"limit": 15}{}{"path": "."}'
16+
assert parse_tool_arguments(raw) == {"limit": 15, "path": "."}
17+
18+
19+
def test_parse_tool_arguments_merges_grep_style_glued_args():
20+
raw = (
21+
'{"limit": 15}{}{"searches": [{"file_pattern": "*.md", '
22+
'"pattern": "TODO|FIXME", "use_regex": true}]}'
23+
)
24+
out = parse_tool_arguments(raw)
25+
assert out["limit"] == 15
26+
assert out["searches"][0]["pattern"] == "TODO|FIXME"
27+
28+
29+
def test_merge_glued_returns_none_for_non_object_chunks():
30+
assert merge_glued_json_objects(['["a"]', '{"b": 1}']) is None
31+
32+
33+
def test_expand_concatenated_json_merges_instead_of_splitting(monkeypatch):
34+
"""Dogfood: DeepSeek ``{…}{}{…}`` must not become three tool calls."""
35+
36+
class MiniCoder(Coder):
37+
def __init__(self):
38+
pass
39+
40+
coder = MiniCoder.__new__(MiniCoder)
41+
tool_call = SimpleNamespace(
42+
id="call-1",
43+
function=SimpleNamespace(
44+
name="ls",
45+
arguments='{"limit": 15}{}{"path": "."}',
46+
),
47+
)
48+
expanded = coder._expand_concatenated_json([tool_call])
49+
assert len(expanded) == 1
50+
assert json.loads(expanded[0].function.arguments) == {"limit": 15, "path": "."}
51+
assert expanded[0].id == "call-1"
52+
53+
54+
def test_grep_format_output_empty_searches_does_not_crash_tool_footer():
55+
coder = SimpleNamespace(
56+
io=SimpleNamespace(tool_error=Mock(), tool_output=Mock(), tool_warning=Mock()),
57+
verbose=False,
58+
pretty=False,
59+
tui=lambda: None,
60+
)
61+
tool_response = SimpleNamespace(
62+
function=SimpleNamespace(
63+
name="Grep",
64+
arguments='{"limit": 15}{}{"searches": []}',
65+
),
66+
)
67+
GrepTool.format_output(
68+
coder,
69+
mcp_server=SimpleNamespace(name="Local"),
70+
tool_response=tool_response,
71+
)
72+
assert coder.io.tool_error.called

0 commit comments

Comments
 (0)