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
37 changes: 32 additions & 5 deletions cecli/tools/grep.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import json

Check failure on line 1 in cecli/tools/grep.py

View workflow job for this annotation

GitHub Actions / pre-commit

F401 'json' imported but unused
import shutil
from pathlib import Path

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
Expand Down Expand Up @@ -83,6 +84,29 @@
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,
Expand All @@ -98,6 +122,10 @@
# 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.")
Expand Down Expand Up @@ -236,16 +264,15 @@
"""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):
Expand Down
24 changes: 23 additions & 1 deletion tests/tools/test_tool_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,29 @@
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."""

Check failure on line 105 in tests/tools/test_tool_arguments.py

View workflow job for this annotation

GitHub Actions / pre-commit

unparseable ==> unparsable
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():
Expand Down
Loading