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
95 changes: 95 additions & 0 deletions tests/tools/test_search_zero_match_and_multipath.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Tests for search_files zero-match probes and multi-path recovery."""

import json
import os

import pytest

from tools.file_tools import search_tool


@pytest.fixture
def proj(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
d = tmp_path / "proj"
d.mkdir()
(d / "a.py").write_text("TOKEN_ALPHA = 'find_me_value'\nother = 1\n")
(d / "b.py").write_text("x = compute(TOKEN_ALPHA)\n")
e = tmp_path / "extra"
e.mkdir()
(e / "c.txt").write_text("TOKEN_ALPHA appears here too\n")
return tmp_path


class TestZeroMatchProbe:
def test_case_mismatch_gets_hint(self, proj):
r = json.loads(search_tool("token_alpha", path=str(proj / "proj"), task_id="t-zm"))
assert r["total_count"] == 0
assert "case-insensitive" in r.get("warning", "")

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", "")

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"))
assert r["total_count"] == 0
assert "warning" not in r

def test_hidden_only_match_gets_hint(self, proj):
d = proj / "proj"
(d / ".secretdir").mkdir()
(d / ".secretdir" / "conf.cfg").write_text("HIDDEN_ONLY_TOKEN = true\n")
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", "")

def test_matching_search_unaffected(self, proj):
r = json.loads(search_tool("TOKEN_ALPHA", path=str(proj / "proj"), task_id="t-zm"))
assert r["total_count"] >= 2
assert "warning" not in r


class TestMultiPathRecovery:
def test_two_existing_paths_merged(self, proj):
p = f"{proj / 'proj'} {proj / 'extra'}"
r = json.loads(search_tool("TOKEN_ALPHA", path=p, task_id="t-mp"))
assert "error" not in r
assert r["total_count"] >= 3
blob = json.dumps(r)
assert "a.py" in blob and "c.txt" in blob
assert "2 entries" in r.get("warning", "") or "searched 2" in r.get("warning", "")

def test_missing_path_skipped_with_note(self, proj):
p = f"{proj / 'proj'} {proj / 'nonexistent_dir'}"
r = json.loads(search_tool("TOKEN_ALPHA", path=p, task_id="t-mp"))
assert "error" not in r
assert r["total_count"] >= 2
assert "skipped missing" in r.get("warning", "")

def test_comma_separated_paths(self, proj):
p = f"{proj / 'proj'},{proj / 'extra'}"
r = json.loads(search_tool("TOKEN_ALPHA", path=p, task_id="t-mp"))
assert "error" not in r
assert r["total_count"] >= 3

def test_all_missing_still_errors(self, proj):
p = f"{proj / 'gone1'} {proj / 'gone2'}"
r = json.loads(search_tool("TOKEN_ALPHA", path=p, task_id="t-mp"))
assert "error" in r

def test_single_missing_path_keeps_similar_hint(self, proj):
# single-path miss must keep the existing "Similar paths" behavior
r = json.loads(search_tool("TOKEN_ALPHA", path=str(proj / "pro"), task_id="t-mp"))
assert "error" in r
assert "Path not found" in r["error"]

def test_files_target_multi_path(self, proj):
p = f"{proj / 'proj'} {proj / 'extra'}"
r = json.loads(search_tool("*.py", path=p, target="files", task_id="t-mp"))
assert "error" not in r
blob = json.dumps(r)
assert "a.py" in blob
144 changes: 144 additions & 0 deletions tools/file_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2111,6 +2111,15 @@ def search(self, pattern: str, path: str = ".", target: str = "content",
# Validate that the path exists before searching
check = self._exec(f"test -e {self._escape_shell_arg(path)} && echo exists || echo not_found")
if "not_found" in check.stdout:
# Multi-path recovery: models frequently pass several paths in
# one string ("dir1 dir2 dir3" or comma-separated). Instead of
# failing the whole call, split, search every path that exists,
# merge the results, and report the skipped parts.
multi = self._try_multi_path_search(
pattern, path, target, file_glob, limit, offset, output_mode, context
)
if multi is not None:
return multi
# Try to suggest nearby paths
parent = os.path.dirname(path) or "."
basename_query = os.path.basename(path)
Expand Down Expand Up @@ -2147,6 +2156,129 @@ def search(self, pattern: str, path: str = ".", target: str = "content",
return self._search_content(pattern, path, file_glob, limit, offset,
output_mode, context)

def _try_multi_path_search(self, pattern: str, path: str, target: str,
file_glob: Optional[str], limit: int, offset: int,
output_mode: str, context: int) -> Optional[SearchResult]:
"""Recover a not-found ``path`` that is really several paths in one string.

Production trajectories show models passing "dir1 dir2 dir3" (or
comma-separated lists) as ``path``. Split on whitespace/commas; when
at least one candidate exists and at least two candidates were given,
search every existing path, merge results, and note skipped parts.
Returns None when this doesn't look like a multi-path string.
"""
parts = [p for chunk in path.split(",") for p in chunk.split() if p.strip()]
if len(parts) < 2:
return None
existing, missing = [], []
for p in parts:
expanded = self._expand_path(p)
chk = self._exec(
f"test -e {self._escape_shell_arg(expanded)} && echo exists || echo not_found"
)
(existing if "exists" in chk.stdout else missing).append(expanded)
if not existing:
return None

merged = SearchResult()
for p in existing:
if target == "files":
sub = self._search_files(pattern, p, limit, offset)
else:
sub = self._search_content(pattern, p, file_glob, limit, offset,
output_mode, context)
if sub.error:
continue
merged.matches.extend(sub.matches)
merged.files.extend(sub.files)
merged.counts.update(sub.counts)
merged.total_count += sub.total_count
merged.truncated = merged.truncated or sub.truncated
# Respect the caller's limit across the merged set.
merged.matches = merged.matches[:limit]
merged.files = merged.files[:limit]
note = f"path contained {len(parts)} entries; searched {len(existing)} that exist"
if missing:
note += "; skipped missing: " + ", ".join(missing[:3])
if len(missing) > 3:
note += f" (+{len(missing) - 3} more)"
merged.warning = note
return merged

def _zero_match_probe(self, pattern: str, path: str,
file_glob: Optional[str]) -> Optional[str]:
"""Return a hint for a 0-match content search, or None.

13.9% of production content searches return zero matches and give
the model nothing to steer by. Run ONE cheap case-insensitive count
probe; if it hits, say so. If the pattern contains regex
metacharacters, also probe it as a fixed string. Bounded: two rg
invocations max, count-only output.
"""
if not self._has_command('rg'):
return None
glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else ""
probe = self._exec(
f"rg -i --count-matches{glob_expr} "
f"{self._escape_shell_arg(pattern)} {self._escape_shell_arg(path)} "
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
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."
)
# Hidden/ignored probe: rg skips dotdirs and .gitignore'd files by
# default. When the pattern exists only there, say so instead of
# returning a bare zero (bench case: match in .hidden/ silently
# missing from results).
hidden = self._exec(
f"rg --hidden --no-ignore --count-matches{glob_expr} "
f"{self._escape_shell_arg(pattern)} {self._escape_shell_arg(path)} "
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
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."
)
if re.search(r"[.\[\](){}?*+^$\\|]", pattern):
fixed = self._exec(
f"rg -F --count-matches{glob_expr} "
f"{self._escape_shell_arg(pattern)} {self._escape_shell_arg(path)} "
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()
)
if f_total > 0:
return (
f"0 regex matches, but {f_total} literal match(es) — the "
"pattern contains regex metacharacters that likely need "
"escaping (or pass a simpler substring)."
)
return None

def _search_files(self, pattern: str, path: str, limit: int, offset: int) -> SearchResult:
"""Search for files by name pattern (glob-like)."""
# Auto-prepend **/ for recursive search if not already present
Expand Down Expand Up @@ -2296,6 +2428,18 @@ def _search_content(self, pattern: str, path: str, file_glob: Optional[str],
"Install ripgrep: https://github.com/BurntSushi/ripgrep#installation"
)

# Zero-match steering: a 0-match result with no guidance is a dead
# turn. Probe cheaply for near-misses (wrong casing, unescaped regex
# metacharacters) and attach the finding as a warning.
if (not result.error and result.total_count == 0
and not result.matches and not result.files and not result.counts):
try:
hint = self._zero_match_probe(pattern, path, file_glob)
except Exception:
hint = None
if hint:
result.warning = hint if not result.warning else f"{result.warning} {hint}"

return _maybe_warn_line_oriented_newline_pattern(result, pattern)

def _search_with_rg(self, pattern: str, path: str, file_glob: Optional[str],
Expand Down
Loading