From 590d36039b1c7516979006f6705ff6638aaa7546 Mon Sep 17 00:00:00 2001 From: Jessica Mulein Date: Tue, 2 Jun 2026 15:51:42 -0700 Subject: [PATCH] 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 --- cecli/tools/grep.py | 37 ++++++++++++++++++++++++++---- tests/tools/test_tool_arguments.py | 24 ++++++++++++++++++- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index deb9db27d60..3c595f9f388 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -4,6 +4,7 @@ import oslex +from cecli.helpers import responses from cecli.helpers.hashline import strip_hashline from cecli.run_cmd import run_cmd_subprocess from cecli.tools.utils.base_tool import BaseTool @@ -83,6 +84,29 @@ def _find_search_tool(self): else: return None, None + @classmethod + def _coerce_searches(cls, searches) -> list[dict]: + """Normalize ``searches`` for UI display (local models often double-encode).""" + if isinstance(searches, str): + parsed = responses.try_parse_json_value(searches) + if isinstance(parsed, list): + searches = parsed + elif isinstance(parsed, dict): + searches = [parsed] + else: + return [] + if not isinstance(searches, list): + return [] + out: list[dict] = [] + for item in searches: + if isinstance(item, dict): + out.append(item) + elif isinstance(item, str): + parsed = responses.try_parse_json_value(item) + if isinstance(parsed, dict): + out.append(parsed) + return out + @classmethod def execute( cls, @@ -98,6 +122,10 @@ def execute( # Handle legacy single-search call if necessary, or just error return "Error: 'searches' parameter must be an array." + searches = cls._coerce_searches(searches) + if not searches: + return "Error: 'searches' parameter must be a non-empty array of search objects." + repo = coder.repo if not repo: coder.io.tool_error("Not in a git repository.") @@ -236,16 +264,15 @@ def format_output(cls, coder, mcp_server, tool_response): """Format output for Grep tool.""" color_start, color_end = color_markers(coder) - try: - params = json.loads(tool_response.function.arguments) - except json.JSONDecodeError: - coder.io.tool_error("Invalid Tool JSON") + params = responses.parse_tool_arguments(tool_response.function.arguments or "") + if "@error" in params: + coder.io.tool_error(f"Invalid Tool JSON: {params['@error']}") return tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) # Output each search operation with the requested format - searches = params.get("searches", []) + searches = cls._coerce_searches(params.get("searches", [])) if searches: coder.io.tool_output("") for i, search_op in enumerate(searches): diff --git a/tests/tools/test_tool_arguments.py b/tests/tools/test_tool_arguments.py index c06ff1d5d83..ece01cb9ff2 100644 --- a/tests/tools/test_tool_arguments.py +++ b/tests/tools/test_tool_arguments.py @@ -98,7 +98,29 @@ def test_grep_format_output_empty_searches_does_not_crash_tool_footer(): mcp_server=SimpleNamespace(name="Local"), tool_response=tool_response, ) - assert coder.io.tool_error.called + assert not coder.io.tool_error.called + + +def test_grep_format_output_malformed_string_searches_does_not_crash(): + """Local models sometimes double-encode searches as an unparseable JSON string.""" + coder = SimpleNamespace( + io=SimpleNamespace(tool_error=Mock(), tool_output=Mock(), tool_warning=Mock()), + verbose=False, + pretty=False, + tui=lambda: None, + ) + tool_response = SimpleNamespace( + function=SimpleNamespace( + name="Grep", + arguments='{"searches": "[{\\"file_pattern>\\nCOPYING"}', + ), + ) + GrepTool.format_output( + coder, + mcp_server=SimpleNamespace(name="Local"), + tool_response=tool_response, + ) + assert not coder.io.tool_error.called def test_try_join_char_split_json_array_reconstructs_array():