Skip to content

Commit 590d360

Browse files
fix(tools): harden Grep format_output for local-model searches JSON
Coerce double-encoded or malformed searches strings via parse_tool_arguments so format_output no longer crashes when qwen emits truncated inner JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e53c2cd commit 590d360

2 files changed

Lines changed: 55 additions & 6 deletions

File tree

cecli/tools/grep.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import oslex
66

7+
from cecli.helpers import responses
78
from cecli.helpers.hashline import strip_hashline
89
from cecli.run_cmd import run_cmd_subprocess
910
from cecli.tools.utils.base_tool import BaseTool
@@ -83,6 +84,29 @@ def _find_search_tool(self):
8384
else:
8485
return None, None
8586

87+
@classmethod
88+
def _coerce_searches(cls, searches) -> list[dict]:
89+
"""Normalize ``searches`` for UI display (local models often double-encode)."""
90+
if isinstance(searches, str):
91+
parsed = responses.try_parse_json_value(searches)
92+
if isinstance(parsed, list):
93+
searches = parsed
94+
elif isinstance(parsed, dict):
95+
searches = [parsed]
96+
else:
97+
return []
98+
if not isinstance(searches, list):
99+
return []
100+
out: list[dict] = []
101+
for item in searches:
102+
if isinstance(item, dict):
103+
out.append(item)
104+
elif isinstance(item, str):
105+
parsed = responses.try_parse_json_value(item)
106+
if isinstance(parsed, dict):
107+
out.append(parsed)
108+
return out
109+
86110
@classmethod
87111
def execute(
88112
cls,
@@ -98,6 +122,10 @@ def execute(
98122
# Handle legacy single-search call if necessary, or just error
99123
return "Error: 'searches' parameter must be an array."
100124

125+
searches = cls._coerce_searches(searches)
126+
if not searches:
127+
return "Error: 'searches' parameter must be a non-empty array of search objects."
128+
101129
repo = coder.repo
102130
if not repo:
103131
coder.io.tool_error("Not in a git repository.")
@@ -236,16 +264,15 @@ def format_output(cls, coder, mcp_server, tool_response):
236264
"""Format output for Grep tool."""
237265
color_start, color_end = color_markers(coder)
238266

239-
try:
240-
params = json.loads(tool_response.function.arguments)
241-
except json.JSONDecodeError:
242-
coder.io.tool_error("Invalid Tool JSON")
267+
params = responses.parse_tool_arguments(tool_response.function.arguments or "")
268+
if "@error" in params:
269+
coder.io.tool_error(f"Invalid Tool JSON: {params['@error']}")
243270
return
244271

245272
tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response)
246273

247274
# Output each search operation with the requested format
248-
searches = params.get("searches", [])
275+
searches = cls._coerce_searches(params.get("searches", []))
249276
if searches:
250277
coder.io.tool_output("")
251278
for i, search_op in enumerate(searches):

tests/tools/test_tool_arguments.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,29 @@ def test_grep_format_output_empty_searches_does_not_crash_tool_footer():
9898
mcp_server=SimpleNamespace(name="Local"),
9999
tool_response=tool_response,
100100
)
101-
assert coder.io.tool_error.called
101+
assert not coder.io.tool_error.called
102+
103+
104+
def test_grep_format_output_malformed_string_searches_does_not_crash():
105+
"""Local models sometimes double-encode searches as an unparseable JSON string."""
106+
coder = SimpleNamespace(
107+
io=SimpleNamespace(tool_error=Mock(), tool_output=Mock(), tool_warning=Mock()),
108+
verbose=False,
109+
pretty=False,
110+
tui=lambda: None,
111+
)
112+
tool_response = SimpleNamespace(
113+
function=SimpleNamespace(
114+
name="Grep",
115+
arguments='{"searches": "[{\\"file_pattern>\\nCOPYING"}',
116+
),
117+
)
118+
GrepTool.format_output(
119+
coder,
120+
mcp_server=SimpleNamespace(name="Local"),
121+
tool_response=tool_response,
122+
)
123+
assert not coder.io.tool_error.called
102124

103125

104126
def test_try_join_char_split_json_array_reconstructs_array():

0 commit comments

Comments
 (0)