From d273e0f2fa70bc6c277d8f8f002cb68c65324996 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:20:56 -0700 Subject: [PATCH] fix(search): zero-match probes return the file paths they found, not just counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The casing/hidden/literal probes already ran the widened search to produce their counts, then threw away the paths and returned a hint-only warning. Strong models pivot in one turn; weak models spiral — the A/B eval measured qwen3-coder-30b going 3.3 -> 9.3 turns on err_case_search, retrying casing variants the probe had already resolved. All three probes (case-insensitive, hidden/gitignored, literal-vs-regex) now include up to 5 matched paths (+N more) in the warning via a shared tally helper. Fixes the class, not the site. Closes #80522 --- .../test_search_zero_match_and_multipath.py | 20 ++++++++ tools/file_operations.py | 49 ++++++++++--------- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/tests/tools/test_search_zero_match_and_multipath.py b/tests/tools/test_search_zero_match_and_multipath.py index 9afccd982cbba..a001447051934 100644 --- a/tests/tools/test_search_zero_match_and_multipath.py +++ b/tests/tools/test_search_zero_match_and_multipath.py @@ -27,12 +27,21 @@ class TestZeroMatchProbe: assert r["total_count"] == 0 assert "case-insensitive" in r.get("warning", "") + def test_case_mismatch_hint_names_the_files(self, proj): + # The probe already ran the -i search; it must hand over the paths, + # not just a count (issue #80522: hint-only sent weak models into + # 5-search casing-variant spirals — +6 turns measured on the A/B eval). + r = json.loads(search_tool("token_alpha", path=str(proj / "proj"), task_id="t-zm")) + w = r.get("warning", "") + assert "a.py" in w and "b.py" in w + def test_regex_metachar_literal_hint(self, proj): d = proj / "proj" (d / "meta.py").write_text("result = lookup[key+1]\n") r = json.loads(search_tool("lookup[key+1]", path=str(d), task_id="t-zm")) assert r["total_count"] == 0 assert "literal match" in r.get("warning", "") + assert "meta.py" in r.get("warning", "") def test_true_zero_match_no_hint(self, proj): r = json.loads(search_tool("zzz_totally_absent_zzz", path=str(proj / "proj"), task_id="t-zm")) @@ -46,6 +55,17 @@ class TestZeroMatchProbe: r = json.loads(search_tool("HIDDEN_ONLY_TOKEN", path=str(d), task_id="t-zm")) assert r["total_count"] == 0 assert "hidden or gitignored" in r.get("warning", "") + # Same class as the casing probe: the path must be in the hint. + assert "conf.cfg" in r.get("warning", "") + + def test_probe_path_list_is_capped(self, proj): + d = proj / "proj" + for i in range(8): + (d / f"cap{i}.txt").write_text("capped_case_token = 1\n") + r = json.loads(search_tool("CAPPED_CASE_TOKEN", path=str(d), task_id="t-zm")) + w = r.get("warning", "") + assert "case-insensitive" in w + assert "+3 more" in w # 8 files, 5 shown def test_matching_search_unaffected(self, proj): r = json.loads(search_tool("TOKEN_ALPHA", path=str(proj / "proj"), task_id="t-zm")) diff --git a/tools/file_operations.py b/tools/file_operations.py index 2c1d1a62c9675..927e919218c70 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -2308,6 +2308,23 @@ class ShellFileOperations(FileOperations): """ if not self._has_command('rg'): return None + + def _tally(stdout: str): + """Parse ``path:count`` lines from rg --count-matches.""" + total = 0 + per_file = [] + for line in (stdout or "").strip().splitlines(): + p, _sep, n = line.rpartition(":") + if n.isdigit(): + total += int(n) + per_file.append(p) + return total, per_file + + def _paths_note(per_file, cap: int = 5) -> str: + shown = ", ".join(per_file[:cap]) + extra = len(per_file) - cap + return shown + (f" (+{extra} more)" if extra > 0 else "") + glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else "" probe = self._exec( f"rg -i --count-matches{glob_expr} " @@ -2315,17 +2332,12 @@ class ShellFileOperations(FileOperations): f"2>/dev/null | head -50", timeout=30, ) - ci_total = 0 - ci_files = 0 - for line in (probe.stdout or "").strip().splitlines(): - _p, _sep, n = line.rpartition(":") - if n.isdigit(): - ci_total += int(n) - ci_files += 1 + ci_total, ci_paths = _tally(probe.stdout) if ci_total > 0: return ( f"0 exact matches, but {ci_total} case-insensitive match(es) " - f"in {ci_files} file(s) — the pattern's casing may be wrong." + f"in {len(ci_paths)} file(s): {_paths_note(ci_paths)} — " + "the pattern's casing may be wrong." ) # Hidden/ignored probe: rg skips dotdirs and .gitignore'd files by # default. When the pattern exists only there, say so instead of @@ -2337,18 +2349,12 @@ class ShellFileOperations(FileOperations): f"2>/dev/null | head -50", timeout=30, ) - h_total = 0 - h_files = 0 - for line in (hidden.stdout or "").strip().splitlines(): - _p, _sep, n = line.rpartition(":") - if n.isdigit(): - h_total += int(n) - h_files += 1 + h_total, h_paths = _tally(hidden.stdout) if h_total > 0: return ( f"0 matches in visible files, but {h_total} match(es) in " - f"{h_files} hidden or gitignored file(s) — these are excluded " - "by default. Search the hidden path explicitly to include them." + f"{len(h_paths)} hidden or gitignored file(s): " + f"{_paths_note(h_paths)} — these are excluded by default." ) if re.search(r"[.\[\](){}?*+^$\\|]", pattern): fixed = self._exec( @@ -2357,14 +2363,11 @@ class ShellFileOperations(FileOperations): f"2>/dev/null | head -50", timeout=30, ) - f_total = sum( - int(line.rpartition(":")[2]) - for line in (fixed.stdout or "").strip().splitlines() - if line.rpartition(":")[2].isdigit() - ) + f_total, f_paths = _tally(fixed.stdout) if f_total > 0: return ( - f"0 regex matches, but {f_total} literal match(es) — the " + f"0 regex matches, but {f_total} literal match(es) in " + f"{len(f_paths)} file(s): {_paths_note(f_paths)} — the " "pattern contains regex metacharacters that likely need " "escaping (or pass a simpler substring)." )