|
1 | | -"""Glob tool: `git ls-files` inside git repos, `tree` outside. |
| 1 | +"""Grep tool: git grep, then rg, then plain grep. |
2 | 2 |
|
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 |
6 | 5 | spilled to a temp file (see `filesystem._spool`), so no matches are |
7 | 6 | ever silently lost. |
8 | 7 | """ |
|
14 | 13 | import subprocess |
15 | 14 |
|
16 | 15 | from .base import Tool, ToolContext |
17 | | -from .filesystem import _git_root, _natnump, _spool |
| 16 | +from .filesystem import _git_root, _spool |
18 | 17 |
|
19 | 18 |
|
20 | | -class GlobTool(Tool): |
21 | | - name = "Glob" |
| 19 | +class Grep(Tool): |
| 20 | + name = "Grep" |
22 | 21 | is_readonly = True |
23 | 22 | 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:' " |
33 | 26 | "path); use Read to view the full output." |
34 | 27 | ) |
35 | 28 | parameters = { |
36 | 29 | "type": "object", |
37 | 30 | "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": { |
52 | 35 | "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, |
56 | 38 | }, |
57 | 39 | }, |
58 | | - "required": ["pattern"], |
| 40 | + "required": ["regex", "path"], |
59 | 41 | } |
60 | 42 |
|
61 | 43 | 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))) |
83 | 52 |
|
| 53 | + git_root = _git_root(path) |
84 | 54 | 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:] |
87 | 74 | try: |
88 | 75 | 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, |
100 | 77 | cwd=git_root, |
101 | 78 | capture_output=True, |
102 | 79 | text=True, |
103 | 80 | encoding="utf-8", |
104 | 81 | errors="replace", |
105 | 82 | timeout=60, |
106 | 83 | ) |
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) |
114 | 89 |
|
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). |
147 | 94 |
|
| 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" |
148 | 156 |
|
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. |
151 | 157 |
|
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