Skip to content
Merged
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
80 changes: 70 additions & 10 deletions emrg/tools/grep_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,17 @@ async def execute(self, arguments: dict) -> ToolResult:

# Search
results: list[str] = []
#: Where each match block's header line landed in ``results``. The block count
#: is this list's length, and nothing downstream re-derives it from the
#: rendered text — see the summary below for what that cost.
block_starts: list[int] = []
files_searched = 0
stop = False
#: Set when the loop stopped at the result budget rather than at the end of the
#: tree. The count is then a **floor**, and the summary has to say so: a number
#: produced by a budget reads exactly like a number produced by counting, and
#: this is the reading an agent answers "how many places does this happen?" from.
search_cut = False

for filepath in files:
if stop:
Expand Down Expand Up @@ -160,13 +169,15 @@ async def execute(self, arguments: dict) -> ToolResult:
ctx_start = max(0, i - context_before)
ctx_end = min(len(lines), i + 1 + context_after)

block_starts.append(len(results))
results.append(f"{rel}:{i + 1}:")
for ctx_i in range(ctx_start, ctx_end):
marker = ">" if ctx_i == i else " "
results.append(f" {marker}{lines[ctx_i]}")

if len(results) > max_results * (2 + context_before + context_after):
stop = True
search_cut = True
break

if not results:
Expand All @@ -179,17 +190,66 @@ async def execute(self, arguments: dict) -> ToolResult:
),
)

# Build output
actual_matches = sum(1 for r in results if r.endswith(":"))
summary = (
f"Found {actual_matches} matches for '{pattern}' "
f"in {root} (searched {files_searched} files):\n\n"
)
# Build output. The count is the number of blocks appended above, not a
# re-reading of the rendered lines: a block header is ``f"{rel}:{i}:"`` and a
# context line is emitted with its own text intact (``" " + marker + text``),
# so the predicate "ends with a colon" cannot tell them apart — a YAML block
# key, a `public:` label or a Markdown `Term:` inside the window was counted as
# a match. Measured on master `6b417c4`, 2026-10-02: one matching line with
# `first:` / `second:` / `third:` around it printed **Found 4 matches** at
# `context_before=1, context_after=2` and **Found 1 matches** for the same
# search without context — asking for context created matches that do not
# exist, and the inflation grows with the amount of context requested.
matches_found = len(block_starts)
if search_cut:
# The second half of the same claim (measured on master `6b417c4`, 2026-10-02):
# the loop above stops once the rendered lines pass
# ``max_results * (2 + context_before + context_after)`` — i.e. at about
# ``max_results`` matches — and the summary printed that number as if it were
# the number in the tree. A file holding 4000 matching lines came back as
# "Found 11 matches ... (searched 1 files)" with **no** indication that the
# search had stopped, so the floor read as a total. It is the same claim this
# action's count makes, one cause upstream, and the same reader: an agent
# answering "how many places does this happen?" from a budget.
summary = (
f"Found {matches_found} matches for '{pattern}' in {root}, where the "
f"search stopped at its result budget (max_results={max_results}) after "
f"{files_searched} file(s) - so this count is a floor and the tree may "
f"hold more. Narrow the pattern or the path, or raise max_results, to "
f"count them all:\n\n"
)
else:
summary = (
f"Found {matches_found} matches for '{pattern}' "
f"in {root} (searched {files_searched} files):\n\n"
)

# Truncate if too many lines
if len(results) > max_results * 3:
results = results[: max_results * 3]
results.append(f"\n... [output truncated at ~{max_results} match blocks]")
# Truncate if too many lines — at a **block boundary**, and the note names the
# two numbers this actually measured. A cut at ``max_results * 3`` lines lands
# inside a block (a block is 1 header + the context window) and leaves context
# lines whose header is gone, i.e. output that reads as belonging to no match;
# and because a block is longer than one line, those same ``max_results * 3``
# lines are *fewer* than ``max_results`` blocks, which the note used to claim.
line_budget = max_results * 3
if len(results) > line_budget:
shown = 0
cut = 0
for index, start in enumerate(block_starts):
end = (
block_starts[index + 1]
if index + 1 < len(block_starts)
else len(results)
)
# The first block is always kept: a budget smaller than one block would
# otherwise print nothing but the notice.
if end > line_budget and shown:
break
shown += 1
cut = end
results = results[:cut]
results.append(
f"\n... [output truncated: {shown} of {matches_found} match blocks shown]"
)

return ToolResult(name="grep", content=summary + "\n".join(results))

Expand Down
224 changes: 224 additions & 0 deletions tests/test_grep_tool.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the grep tool."""

import asyncio
import re
import sys
import tempfile
from pathlib import Path
Expand Down Expand Up @@ -120,3 +121,226 @@ def test_grep_nonexistent_path():
}))
assert result.error
assert "not found" in result.content.lower()


# ── The reported count, and the truncation note ──────────────────────


def _payload(content: str) -> list[str]:
"""The printed match blocks: summary, blank lines and the notice removed."""
return [ln for ln in content.splitlines()[2:] if ln and not ln.startswith("...")]


def _headers(payload: list[str]) -> list[str]:
"""Block header lines — the ones a match produced, not a context line."""
return [ln for ln in payload if not ln.startswith(" ") and ln.endswith(":")]


def _reported(content: str) -> int:
return int(re.search(r"Found (\d+) matches", content).group(1))


class TestTheCountIsTheNumberOfMatches:
"""Issue #1805 — asking for context used to *create* matches.

The count was re-derived from the rendered lines with "ends with a colon", and a
context line is emitted with its own text intact — so every YAML block key, code
label or `Term:` inside the window was counted as a match. Measured on master
`6b417c4`, 2026-10-02: one matching line with `first:` / `second:` / `third:`
around it printed **Found 4 matches** at `context_before=1, context_after=2` and
**Found 1 matches** for the same search without context.
"""

def test_a_context_line_ending_in_a_colon_is_not_a_match(self, tmp_path):
f = tmp_path / "conf.yaml"
f.write_text("first:\nTARGET here\nsecond:\nthird:\n", encoding="utf-8")

result = _run(GrepTool().execute({
"pattern": "TARGET", "path": str(f),
"context_before": 1, "context_after": 2,
}))

assert not result.error
assert _reported(result.content) == 1, "a context line was counted as a match"
assert len(_headers(_payload(result.content))) == 1

def test_the_same_search_reports_the_same_count_with_and_without_context(self, tmp_path):
f = tmp_path / "conf.yaml"
f.write_text("first:\nTARGET here\nsecond:\nthird:\n", encoding="utf-8")

tool = GrepTool()
plain = _run(tool.execute({"pattern": "TARGET", "path": str(f)}))
with_ctx = _run(tool.execute({
"pattern": "TARGET", "path": str(f), "context_before": 1, "context_after": 2,
}))

assert _reported(plain.content) == _reported(with_ctx.content) == 1

def test_the_count_is_the_number_of_matching_lines(self, tmp_path):
(tmp_path / "a.yaml").write_text("k:\nTARGET one\nv:\n", encoding="utf-8")
(tmp_path / "b.yaml").write_text("w:\nTARGET two\nTARGET three\nx:\n", encoding="utf-8")

for context in (0, 1, 3):
result = _run(GrepTool().execute({
"pattern": "TARGET", "path": str(tmp_path),
"context_before": context, "context_after": context,
}))
assert _reported(result.content) == 3, f"context={context} changed the count"

def test_a_context_line_that_ends_in_a_colon_is_still_printed(self, tmp_path):
"""The fix is the number, not the output — the context is what was asked for."""
f = tmp_path / "conf.yaml"
f.write_text("first:\nTARGET here\nsecond:\n", encoding="utf-8")

result = _run(GrepTool().execute({
"pattern": "TARGET", "path": str(f),
"context_before": 1, "context_after": 1,
}))

assert " first:" in result.content
assert " second:" in result.content
assert " >TARGET here" in result.content


class TestTheTruncationNoteNamesWhatItMeasured:
"""The old note named ``max_results`` "match blocks" while cutting at
``max_results * 3`` **lines** — fewer blocks than it claimed, and the cut could
land inside a block, printing a header with nothing under it (measured on master
`6b417c4`: last payload line was a header; note said 10 blocks, 7 were printed).
"""

MATCHES = 80

def _big_file(self, tmp_path) -> Path:
body = []
for i in range(self.MATCHES):
body += [f"TARGET {i}:", " a: 1", " b: 2", " c: 3"]
f = tmp_path / "conf.yaml"
f.write_text("\n".join(body) + "\n", encoding="utf-8")
return f

def _run_truncated(self, path: Path, max_results: int = 10):
return _run(GrepTool().execute({
"pattern": "^TARGET ", "path": str(path),
"context_before": 1, "context_after": 2, "max_results": max_results,
}))

def test_the_output_ends_on_a_complete_block(self, tmp_path):
result = self._run_truncated(self._big_file(tmp_path))
payload = _payload(result.content)

assert payload, "the truncation printed nothing"
assert payload[0] in _headers(payload), "the payload no longer starts with a header"
assert payload[-1] not in _headers(payload), (
"the cut fell inside a block, so its header was printed with no lines under it"
)

def test_the_note_names_the_blocks_shown_and_the_blocks_found(self, tmp_path):
result = self._run_truncated(self._big_file(tmp_path))
payload = _payload(result.content)
match = re.search(r"\[output truncated: (\d+) of (\d+) match blocks shown\]", result.content)

assert match, result.content.splitlines()[-1]
shown, total = int(match.group(1)), int(match.group(2))
assert shown == len(_headers(payload)), "the note's 'shown' is not the number printed"
assert total == _reported(result.content), (
"the note's total is not the count the summary reported"
)
assert 0 < shown < total, "this fixture is meant to truncate"

def test_an_untruncated_search_carries_no_note(self, tmp_path):
f = tmp_path / "small.yaml"
f.write_text("k:\nTARGET one\n", encoding="utf-8")

result = _run(GrepTool().execute({
"pattern": "TARGET", "path": str(f), "context_before": 1, "context_after": 1,
}))

assert "output truncated" not in result.content
assert not _payload(result.content)[-1].startswith("...")

def test_a_budget_smaller_than_one_block_still_prints_the_first(self, tmp_path):
"""The cut keeps at least one block: a note with no output under it is worse
than exceeding a soft line budget."""
result = self._run_truncated(self._big_file(tmp_path), max_results=1)
payload = _payload(result.content)

assert len(_headers(payload)) == 1, (
"a budget smaller than one block printed the notice with no match under it"
)
assert payload[-1] not in _headers(payload)


class TestTheCountSaysWhenItIsAFloor:
"""Issue #1805, second cause — the search itself stops at a budget and said nothing.

``max_results`` bounds a **line** budget (``max_results * (2 + cb + ca)``, about
``max_results`` matches) and the summary printed the number it reached as if it were
the number in the tree. Measured on master `6b417c4`, 2026-10-02: a file holding
**4000** matching lines came back as ``Found 11 matches ... (searched 1 files)`` with
no indication that the search had stopped — a 364x undercount that reads as a total.
"""

LINES = 4000
HITS = "HIT"

def _big_file(self, tmp_path) -> Path:
f = tmp_path / "big.txt"
f.write_text(f"{self.HITS}\n" * self.LINES, encoding="utf-8")
return f

def _search(self, path: Path, max_results: int = 10, context: int = 0):
return _run(GrepTool().execute({
"pattern": self.HITS, "path": str(path), "max_results": max_results,
"context_before": context, "context_after": context,
"intent": "floor probe",
}))

def test_a_capped_search_says_the_count_is_a_floor(self, tmp_path):
result = self._search(self._big_file(tmp_path))

assert "floor" in result.content, (
f"{self.LINES} matches in the file, and the summary reported the budget "
f"as a total: {result.content.splitlines()[0]}"
)
assert "max_results=10" in result.content, "the note does not name the budget"

def test_the_note_and_the_count_name_the_same_number(self, tmp_path):
result = self._search(self._big_file(tmp_path))
reported = _reported(result.content)
named = int(re.search(r"max_results=(\d+)\)", result.content).group(1))

assert reported == len(_headers(_payload(result.content))), (
"the count is not the number of blocks printed"
)
assert named == 10, "the note names a budget the call did not use"
assert reported <= 10 + 1, "the cap is meant to stop the search near max_results"

def test_a_bigger_budget_finds_more_of_the_same_file(self, tmp_path):
"""The number was produced by the budget, so raising it must raise the count —
this is what a reader tells apart from a real total only if it is said."""
f = self._big_file(tmp_path)

assert _reported(self._search(f, max_results=10).content) < _reported(
self._search(f, max_results=50).content
)

def test_an_uncapped_search_keeps_the_plain_summary(self, tmp_path):
f = tmp_path / "small.txt"
f.write_text("one\nHIT here\ntwo\n", encoding="utf-8")

result = self._search(f)

assert "floor" not in result.content
assert "(searched 1 files)" in result.content
assert _reported(result.content) == 1

def test_the_floor_note_survives_the_print_truncation(self, tmp_path):
"""Both notes can be true at once: the search stopped, and the print was cut."""
result = self._search(self._big_file(tmp_path), max_results=3, context=2)

assert "floor" in result.content, "the floor note was lost"
assert "output truncated" in result.content, "the print note was lost"
assert result.content.index("floor") < result.content.index("output truncated"), (
"the summary has to carry its own caveat, not depend on the note at the end"
)
Loading