search_for_pattern: add a snippet stage to the overflow shortening chain (#1667)

This adds an intermediate stage returning line numbers plus the matched text truncated to 60 characters.

Test contributed by @AmirF194 in review.
This commit is contained in:
bymebyu authored and GitHub committed 2026-07-17 17:45:09 +02:00
1 parent 369e3a05a0
commit 7c34662996
3 files changed
+98 -6

No files matched your search

+3
View File
@@ -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 "<ext:FileUtil.class|472e0a13>") to be
+47 -6
View File
@@ -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,
],
)
+48
View File
@@ -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