Skip to content

Commit 469ebc4

Browse files
authored
Update grep.py
1 parent fbd339d commit 469ebc4

1 file changed

Lines changed: 120 additions & 123 deletions

File tree

python_agent_harness/tools/grep.py

Lines changed: 120 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
"""Glob tool: `git ls-files` inside git repos, `tree` outside.
1+
"""Grep tool: git grep, then rg, then plain grep.
22
3-
Glob mirrors `gptel-agent-harness-tools--glob`: inside a git
4-
repository it uses `git ls-files` (fast, .gitignore-respecting), and
5-
falls back to the `tree` command outside git. Oversized results are
3+
Grep mirrors `gptel-agent-harness-tools--grep`: git grep (passing the
4+
regex via `-e`), then rg, then plain grep. Oversized results are
65
spilled to a temp file (see `filesystem._spool`), so no matches are
76
ever silently lost.
87
"""
@@ -14,152 +13,150 @@
1413
import subprocess
1514

1615
from .base import Tool, ToolContext
17-
from .filesystem import _git_root, _natnump, _spool
16+
from .filesystem import _git_root, _spool
1817

1918

20-
class GlobTool(Tool):
21-
name = "Glob"
19+
class Grep(Tool):
20+
name = "Grep"
2221
is_readonly = True
2322
description = (
24-
"Recursively find files matching a provided glob pattern.\n\n"
25-
'- Supports glob patterns like "*.md" or "*test*.py".\n'
26-
"- Inside a git repository, matching respects .gitignore and covers "
27-
"both tracked and untracked files.\n"
28-
"- Returns matching file paths (absolute) at all depths. Limit the "
29-
"depth of the search by providing the `depth` argument.\n"
30-
"- When you are doing an open ended search that may require multiple "
31-
'rounds of globbing and grepping, use the "Agent" tool instead.\n'
32-
"- Oversized results are spilled to a temp file (see the 'Stored in:' "
23+
"Search file contents with a regular expression. "
24+
"Use this for content search; use Glob for filename search. "
25+
"Oversized results are spilled to a temp file (see the 'Stored in:' "
3326
"path); use Read to view the full output."
3427
)
3528
parameters = {
3629
"type": "object",
3730
"properties": {
38-
"pattern": {
39-
"type": "string",
40-
"description": (
41-
'Glob pattern to match, for example "*.el". Must not be '
42-
'empty.\nUse "*" to list all files in a directory.'
43-
),
44-
},
45-
"path": {
46-
"type": "string",
47-
"description": (
48-
'Directory to search in. Supports relative paths and defaults to "."'
49-
),
50-
},
51-
"depth": {
31+
"regex": {"type": "string", "description": "Regular expression to search for"},
32+
"path": {"type": "string", "description": "File or directory to search in"},
33+
"glob": {"type": "string", "description": "Optional file pattern filter (e.g. *.py)"},
34+
"context_lines": {
5235
"type": "integer",
53-
"description": (
54-
"Limit directory depth of search, 1 or higher. Defaults to no limit."
55-
),
36+
"description": "Lines of context (0-15)",
37+
"maximum": 15,
5638
},
5739
},
58-
"required": ["pattern"],
40+
"required": ["regex", "path"],
5941
}
6042

6143
def run(self, args: dict, ctx: ToolContext) -> str:
62-
# Mirrors `gptel-agent-harness-tools--glob': `git ls-files' inside a
63-
# git repository (fast, .gitignore-respecting), `tree' as a fallback
64-
# outside git.
65-
pattern = args.get("pattern") or ""
66-
if not pattern:
67-
return "Error: pattern must not be empty"
68-
path = args.get("path")
69-
if path:
70-
if not (os.path.isdir(path) and os.access(path, os.R_OK)):
71-
return f"Error: path {path} is not readable"
72-
else:
73-
path = ctx.cwd
74-
# realpath (not abspath): _git_root resolves symlinks (macOS /var ->
75-
# /private/var), so the base must be canonical or relpath produces a
76-
# pathspec git rejects as "outside repository".
77-
base = os.path.realpath(path)
78-
depth = args.get("depth")
79-
80-
git_root = _git_root(base)
81-
if not git_root and not shutil.which("tree"):
82-
return "Error: Executable `tree` not found. This tool cannot be used"
44+
regex = args["regex"]
45+
path = os.path.realpath(args["path"])
46+
if not os.path.isdir(path) and not os.path.isfile(path):
47+
return f"Error: path {args['path']} is not readable"
48+
glob = args.get("glob")
49+
context = args.get("context_lines")
50+
if context is not None:
51+
context = max(0, min(15, int(context)))
8352

53+
git_root = _git_root(path)
8454
if git_root:
85-
rel = os.path.relpath(base, git_root)
86-
pathspec = pattern if rel == "." else f"{rel}/{pattern}".replace(os.sep, "/")
55+
rel = os.path.relpath(path, git_root)
56+
pathspec = rel
57+
if glob and os.path.isdir(path):
58+
pathspec = os.path.join(rel, glob).replace(os.sep, "/")
59+
cmd = [
60+
"git",
61+
"grep",
62+
"--line-number",
63+
"--no-color",
64+
"--max-count=1000",
65+
"--untracked",
66+
"-P",
67+
"-e",
68+
regex,
69+
"--",
70+
pathspec,
71+
]
72+
if context:
73+
cmd = cmd[:3] + [f"-C{context}"] + cmd[3:]
8774
try:
8875
proc = subprocess.run(
89-
[
90-
"git",
91-
"ls-files",
92-
"-z",
93-
"--full-name",
94-
"--cached",
95-
"--others",
96-
"--exclude-standard",
97-
"--",
98-
pathspec,
99-
],
76+
cmd,
10077
cwd=git_root,
10178
capture_output=True,
10279
text=True,
10380
encoding="utf-8",
10481
errors="replace",
10582
timeout=60,
10683
)
107-
except (OSError, subprocess.TimeoutExpired) as e:
108-
return f"Error: {e}"
109-
if proc.returncode != 0:
110-
# Failure banner is prepended to whatever git emitted.
111-
banner = f"Glob failed with exit code {proc.returncode}\nSTDOUT:\n\n"
112-
return _spool(banner + (proc.stdout or "") + (proc.stderr or ""), "glob")
113-
return _git_glob_results(proc.stdout, git_root, base, depth)
84+
except (OSError, subprocess.TimeoutExpired):
85+
proc = None
86+
if proc is not None and proc.returncode in (0, 1):
87+
return _grep_out(proc, "git")
88+
return self._fallback_rg_grep(regex, path, glob, context)
11489

115-
# --- Tree strategy (fallback outside git) ---
116-
cmd = [
117-
"tree",
118-
"-l",
119-
"-f",
120-
"-i",
121-
"-I",
122-
".git",
123-
"--sort=mtime",
124-
"--ignore-case",
125-
"--prune",
126-
"-P",
127-
pattern,
128-
base,
129-
]
130-
if _natnump(depth):
131-
cmd += ["-L", str(depth)]
132-
try:
133-
proc = subprocess.run(
134-
cmd,
135-
capture_output=True,
136-
text=True,
137-
encoding="utf-8",
138-
errors="replace",
139-
timeout=60,
140-
)
141-
except (OSError, subprocess.TimeoutExpired) as e:
142-
return f"Error: {e}"
143-
out = proc.stdout
144-
if proc.returncode != 0:
145-
out = f"Glob failed with exit code {proc.returncode}\nSTDOUT:\n\n" + out
146-
return _spool(out, "glob")
90+
def _fallback_rg_grep(
91+
self, regex: str, path: str, glob: str | None, context: int | None
92+
) -> str:
93+
"""rg → grep fallback chain (no git grep).
14794
95+
Shared by :class:`Grep` (after git grep -P fails) and
96+
:class:`GrepMac` (after git grep -E fails). Extracted here so
97+
the Mac variant can skip the parent's ``git grep -P`` attempt
98+
without duplicating the rg/grep logic.
99+
"""
100+
if shutil.which("rg"):
101+
cmd = [
102+
"rg",
103+
"--sort=modified",
104+
"--max-count=1000",
105+
"--heading",
106+
"--line-number",
107+
"-e",
108+
regex,
109+
path,
110+
]
111+
if context:
112+
cmd = cmd[:1] + [f"--context={context}"] + cmd[1:]
113+
if glob:
114+
cmd = cmd[:1] + [f"--glob={glob}"] + cmd[1:]
115+
try:
116+
proc = subprocess.run(
117+
cmd,
118+
capture_output=True,
119+
text=True,
120+
encoding="utf-8",
121+
errors="replace",
122+
timeout=60,
123+
)
124+
except (OSError, subprocess.TimeoutExpired):
125+
proc = None
126+
if proc is not None and proc.returncode in (0, 1):
127+
return _grep_out(proc, "rg")
128+
if shutil.which("grep"):
129+
cmd = [
130+
"grep",
131+
"--recursive",
132+
"--max-count=1000",
133+
"--line-number",
134+
"--regexp",
135+
regex,
136+
path,
137+
]
138+
if context:
139+
cmd = cmd[:1] + [f"--context={context}"] + cmd[1:]
140+
if glob:
141+
cmd = cmd[:1] + [f"--include={glob}"] + cmd[1:]
142+
try:
143+
proc = subprocess.run(
144+
cmd,
145+
capture_output=True,
146+
text=True,
147+
encoding="utf-8",
148+
errors="replace",
149+
timeout=60,
150+
)
151+
except (OSError, subprocess.TimeoutExpired):
152+
proc = None
153+
if proc is not None:
154+
return _grep_out(proc, "grep")
155+
return "Error: ripgrep/grep/git-grep not available, this tool cannot be used"
148156

149-
def _git_glob_results(raw: str, git_root: str, base: str, depth: object) -> str:
150-
"""Format `git ls-files -z` output into absolute paths, depth-filtered.
151157

152-
Mirrors the git branch of `gptel-agent-harness-tools--glob': split on
153-
NUL, drop entries whose slash-count reaches ``base_depth + depth``
154-
(only when DEPTH is a non-negative integer — `natnump'), then prefix
155-
each remaining entry with GIT-ROOT.
156-
"""
157-
lines = [line for line in raw.split("\0") if line]
158-
if _natnump(depth):
159-
rel_base = os.path.relpath(base, git_root).replace(os.sep, "/")
160-
base_depth = 0 if rel_base == "." else 1 + rel_base.count("/")
161-
lines = [line for line in lines if line.count("/") < base_depth + depth]
162-
out = "\n".join(os.path.join(git_root, line).replace(os.sep, "/") for line in lines)
163-
if not out:
164-
return ""
165-
return _spool(out + "\n", "glob")
158+
def _grep_out(proc: subprocess.CompletedProcess, backend: str) -> str:
159+
text = proc.stdout
160+
if proc.returncode >= 2:
161+
text = f"Error: search failed with exit-code {proc.returncode}. Tool output:\n\n{text}"
162+
return _spool(text, "grep")

0 commit comments

Comments
 (0)