diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b58b52..e085a414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ Status of the `main` branch. Changes prior to the next official version change w resolved to the start of the following line. The line the match ends on is now determined correctly, which also keeps `context_lines_after` aligned. - `safe_delete`: Add heuristic to delete superfluous empty lines after a deletion + - `search_for_pattern`: on overflow, the shortening chain now emits each match's first line (full when + it fits, otherwise truncated with a trailing '...' and a note) before falling back to bare line + numbers, so agents can pick the right match without re-reading files. #1640 * JetBrains: - Allow external files from dependencies (specified via references like "") to be diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 65ecea7b..3cc55d82 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -611,15 +611,48 @@ class SearchForPatternTool(Tool): file_to_matches[match.source_file_path].append(match.to_display_string()) # capture lightweight match data for shortening before serialization - match_lines_by_file: dict[str, list[int]] = defaultdict(list) + match_lines_by_file: dict[str, list[dict[str, int | str]]] = defaultdict(list) for match in matches: assert match.source_file_path is not None - match_lines_by_file[match.source_file_path].append(match.matched_lines[0].line_number) + first = match.matched_lines[0] + match_lines_by_file[match.source_file_path].append({"line": first.line_number, "text": first.line_content.strip()}) # shortened result closures, from least to most aggressive shortening - def make_lines_only() -> str: - """Match locations without surrounding context""" - return f"Match lines per file:\n{self._to_json(match_lines_by_file)}" + _TEXT_TRUNCATE = 60 + + def render_first_lines(truncate: bool) -> str: + """Render each match's first line, either in full or truncated to a fixed length.""" + + def entry_text(text: str) -> str: + if truncate and len(text) > _TEXT_TRUNCATE: + return text[:_TEXT_TRUNCATE] + "..." + return text + + compact = { + path: [{"line": m["line"], "text": entry_text(str(m["text"]))} for m in lines] + for path, lines in match_lines_by_file.items() + } + if truncate: + header = ( + f"Matched lines (text over {_TEXT_TRUNCATE} chars is truncated, marked with a trailing '...'); " + "use read_file with the line numbers for full content:" + ) + else: + header = "Matched lines per file; use read_file with the line numbers for surrounding context:" + return f"{header}\n{self._to_json(compact)}" + + def make_first_lines_full() -> str: + """Match locations with each match's full first line.""" + return render_first_lines(truncate=False) + + def make_first_lines_truncated() -> str: + """Match locations with each match's first line truncated to a fixed length.""" + return render_first_lines(truncate=True) + + def make_line_numbers_only() -> str: + """Match locations as bare line numbers (no text).""" + numbers = {path: [m["line"] for m in lines] for path, lines in match_lines_by_file.items()} + return f"Match lines per file:\n{self._to_json(numbers)}" def make_per_file_counts() -> str: counts = {path: len(lines) for path, lines in match_lines_by_file.items()} @@ -630,5 +663,13 @@ class SearchForPatternTool(Tool): result = self._to_json(file_to_matches) return self._limit_length( - result, max_answer_chars, shortened_result_factories=[make_lines_only, make_per_file_counts, make_summary] + result, + max_answer_chars, + shortened_result_factories=[ + make_first_lines_full, + make_first_lines_truncated, + make_line_numbers_only, + make_per_file_counts, + make_summary, + ], ) diff --git a/test/serena/test_search_for_pattern.py b/test/serena/test_search_for_pattern.py new file mode 100644 index 00000000..c00107d3 --- /dev/null +++ b/test/serena/test_search_for_pattern.py @@ -0,0 +1,48 @@ +"""Tests for the ``SearchForPatternTool`` overflow shortening chain. + +The snippet stage and, in particular, its position in the shortening chain were +previously untested. Test contributed by @AmirF194 in review of PR #1667. +""" + +from unittest.mock import MagicMock + +from serena.config.serena_config import SerenaConfig +from serena.project import Project +from serena.tools.file_tools import SearchForPatternTool + + +def test_search_for_pattern_snippet_stage(tmp_path): + lines: list[str] = [] + for i in range(60): + lines += [ + "filler above", + "filler above", + f"MATCHME item number {i:04d} " + "payload " * 6, + "filler below", + "filler below", + ] + (tmp_path / "data.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + project = Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False)) + agent = MagicMock() + agent.get_active_project_or_raise.return_value = project + tool = SearchForPatternTool(agent) + + def run(cap: int) -> str: + return tool.apply( + substring_pattern="MATCHME", + context_lines_before=2, + context_lines_after=2, + restrict_search_to_code_files=False, + max_answer_chars=cap, + ) + + # wide but overflowing cap: the snippet stage (line + matched text) is returned + snippet = run(7000) + assert "The answer is too long" in snippet + assert '"text":' in snippet and "MATCHME item number 0000" in snippet + assert "Match lines per file" not in snippet # not the bare-line-numbers stage + + # tighter cap: the chain degrades past the snippet stage to bare line numbers + bare = run(1000) + assert "Match lines per file" in bare and '"text":' not in bare