python-agent-harness 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Edit tool: string replacement and diff/patch modes.
|
|
2
|
+
|
|
3
|
+
Edit mirrors `gptel-agent--edit-files` (inherited unchanged by the
|
|
4
|
+
harness): a string-replacement mode (exact unique `old_str` → `new_str`,
|
|
5
|
+
single files only) and a diff/patch mode that shells out to
|
|
6
|
+
`patch --forward` and works on both single files and whole directories
|
|
7
|
+
(multi-file unified diffs), with ```diff code-fence removal and
|
|
8
|
+
hunk-header line-count fixing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
|
|
18
|
+
from ..diffrender import unified_diff
|
|
19
|
+
from .base import Tool, ToolContext
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Edit(Tool):
|
|
23
|
+
name = "Edit"
|
|
24
|
+
description = (
|
|
25
|
+
"Replace text in one or more files.\n\n"
|
|
26
|
+
"To edit a single file, provide the file `path`.\n\n"
|
|
27
|
+
"For the replacement, there are two methods:\n"
|
|
28
|
+
"- Short replacements: Provide both `old_str` and `new_str`, in which "
|
|
29
|
+
"case `old_str` needs to exactly match one unique section of the "
|
|
30
|
+
"original file, including any whitespace. Make sure to include "
|
|
31
|
+
"enough context that the match is not ambiguous. The entire original "
|
|
32
|
+
"string will be replaced with `new_str`.\n"
|
|
33
|
+
"- Long or involved replacements: set the `diff` parameter to true and "
|
|
34
|
+
"provide a unified diff in `new_str`. `old_str` can be ignored.\n\n"
|
|
35
|
+
"To edit multiple files,\n"
|
|
36
|
+
"- provide the directory path,\n"
|
|
37
|
+
"- set the `diff` parameter to true\n"
|
|
38
|
+
"- and provide a unified diff in `new_str`.\n\n"
|
|
39
|
+
"Diff instructions:\n"
|
|
40
|
+
"- The diff must be in unified format (optionally within a ```diff "
|
|
41
|
+
"fenced code block).\n"
|
|
42
|
+
"- The file paths within the diff (e.g. '--- a/filename' "
|
|
43
|
+
"'+++ b/filename') must be appropriate for the `path`.\n\n"
|
|
44
|
+
'To simply insert text at some line, use the "Insert" tool instead.'
|
|
45
|
+
)
|
|
46
|
+
parameters = {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"path": {"type": "string", "description": "File path or directory to edit"},
|
|
50
|
+
"old_str": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Original string to replace. If providing a unified diff, this should be false",
|
|
53
|
+
},
|
|
54
|
+
"new_str": {"type": "string", "description": "Replacement string OR unified diff text"},
|
|
55
|
+
"diff": {
|
|
56
|
+
"type": "boolean",
|
|
57
|
+
"description": "Whether the replacement is a string or a diff. `true` for a diff, `false` otherwise.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
"required": ["path", "new_str"],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
64
|
+
raw = args["path"]
|
|
65
|
+
path = os.path.realpath(os.path.abspath(raw))
|
|
66
|
+
if not os.access(path, os.R_OK):
|
|
67
|
+
return f"Error: File or directory {path} is not readable"
|
|
68
|
+
new_str = args.get("new_str")
|
|
69
|
+
if new_str is None:
|
|
70
|
+
return "Error: Required argument `new_str' missing"
|
|
71
|
+
old = args.get("old_str")
|
|
72
|
+
diffp = args.get("diff")
|
|
73
|
+
# gptel: string mode when `diff` is false OR `old_str` is provided.
|
|
74
|
+
if diffp is False or old is not None:
|
|
75
|
+
return self._string_replace(path, old, new_str, ctx)
|
|
76
|
+
# Diff mode runs `patch` in Emacs `file-name-directory' of the path:
|
|
77
|
+
# a trailing-slash directory path -> that directory itself, so a
|
|
78
|
+
# multi-file diff applies to files within it; otherwise the parent.
|
|
79
|
+
if raw.endswith("/") or raw.endswith(os.sep):
|
|
80
|
+
cwd = path or "/"
|
|
81
|
+
else:
|
|
82
|
+
cwd = os.path.dirname(path) or "/"
|
|
83
|
+
return self._apply_patch(path, cwd, new_str, ctx)
|
|
84
|
+
|
|
85
|
+
def _string_replace(self, path: str, old: str | None, new_str: str, ctx: ToolContext) -> str:
|
|
86
|
+
if os.path.isdir(path):
|
|
87
|
+
return (
|
|
88
|
+
f"Error: String replacement is intended for single files, not directories ({path})"
|
|
89
|
+
)
|
|
90
|
+
if old is None:
|
|
91
|
+
return "Error: old_str is required for non-diff edits"
|
|
92
|
+
try:
|
|
93
|
+
with open(path, encoding="utf-8") as f:
|
|
94
|
+
content = f.read()
|
|
95
|
+
except OSError as e:
|
|
96
|
+
return f"Error: cannot read {path}: {e}"
|
|
97
|
+
count = content.count(old)
|
|
98
|
+
if count == 0:
|
|
99
|
+
return f'Error: Could not find old_str "{old[:20]}" in file {path}'
|
|
100
|
+
if count > 1:
|
|
101
|
+
return (
|
|
102
|
+
"Error: Match is not unique. Consider providing more context "
|
|
103
|
+
"for the replacement, or a unified diff"
|
|
104
|
+
)
|
|
105
|
+
new = content.replace(old, new_str, 1)
|
|
106
|
+
try:
|
|
107
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
108
|
+
f.write(new)
|
|
109
|
+
except OSError as e:
|
|
110
|
+
return f"Error: {e}"
|
|
111
|
+
diff_text = unified_diff(content, new, path)
|
|
112
|
+
if diff_text:
|
|
113
|
+
ctx.record_diff(diff_text)
|
|
114
|
+
return f"Successfully replaced {old[:20]} (truncated) with {new_str[:20]} (truncated)"
|
|
115
|
+
|
|
116
|
+
def _apply_patch(self, path: str, cwd: str, diff: str, ctx: ToolContext) -> str:
|
|
117
|
+
"""Diff/patch mode: shell out to `patch --forward` (files or dirs).
|
|
118
|
+
|
|
119
|
+
Mirrors the diff branch of `gptel-agent--edit-files`: ensure a
|
|
120
|
+
trailing newline, strip a ```diff code fence, fix hunk-header line
|
|
121
|
+
counts, then run `patch` in CWD (Emacs `file-name-directory' of the
|
|
122
|
+
path). For a single file the before/after contents are captured to
|
|
123
|
+
record a diff for the UI; directory (multi-file) patches skip that.
|
|
124
|
+
"""
|
|
125
|
+
if not shutil.which("patch"):
|
|
126
|
+
return (
|
|
127
|
+
'Error: Command "patch" not available, cannot apply diffs. '
|
|
128
|
+
"Use string replacement instead"
|
|
129
|
+
)
|
|
130
|
+
text = diff if diff.endswith("\n") else diff + "\n"
|
|
131
|
+
text = _strip_diff_fence(text)
|
|
132
|
+
text = _fix_patch_headers(text)
|
|
133
|
+
is_file = os.path.isfile(path)
|
|
134
|
+
old_content = None
|
|
135
|
+
if is_file:
|
|
136
|
+
try:
|
|
137
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
138
|
+
old_content = f.read()
|
|
139
|
+
except OSError:
|
|
140
|
+
old_content = None
|
|
141
|
+
options = ["--forward", "--verbose"]
|
|
142
|
+
try:
|
|
143
|
+
proc = subprocess.run(
|
|
144
|
+
["patch", *options],
|
|
145
|
+
input=text,
|
|
146
|
+
cwd=cwd,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
encoding="utf-8",
|
|
150
|
+
errors="replace",
|
|
151
|
+
timeout=60,
|
|
152
|
+
)
|
|
153
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
154
|
+
return f"Error: {e}"
|
|
155
|
+
out = (proc.stdout or "") + (proc.stderr or "")
|
|
156
|
+
if proc.returncode != 0:
|
|
157
|
+
return (
|
|
158
|
+
f"Error: Failed to apply diff to {path} (exit status "
|
|
159
|
+
f"{proc.returncode}).\nPatch command options: {options}\n"
|
|
160
|
+
f"Patch STDOUT:\n{out}"
|
|
161
|
+
)
|
|
162
|
+
if old_content is not None and os.path.isfile(path):
|
|
163
|
+
try:
|
|
164
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
165
|
+
new_content = f.read()
|
|
166
|
+
diff_text = unified_diff(old_content, new_content, path)
|
|
167
|
+
if diff_text:
|
|
168
|
+
ctx.record_diff(diff_text)
|
|
169
|
+
except OSError:
|
|
170
|
+
pass
|
|
171
|
+
return (
|
|
172
|
+
f"Diff successfully applied to {path}.\n"
|
|
173
|
+
f"Patch command options: {options}\n"
|
|
174
|
+
f"Patch STDOUT:\n{out}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_HUNK_HEADER_RE = re.compile(r"^@@ -(\d+),(\d+) +\+(\d+),(\d+) @@")
|
|
179
|
+
# A file section's header pair: the marker must be followed by whitespace
|
|
180
|
+
# (real headers name a path), which content lines rendered ---/+++ by the
|
|
181
|
+
# diff itself normally are not.
|
|
182
|
+
_FILE_HEADER_OLD_RE = re.compile(r"^---[ \t]")
|
|
183
|
+
_FILE_HEADER_NEW_RE = re.compile(r"^\+\+\+[ \t]")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _strip_diff_fence(text: str) -> str:
|
|
187
|
+
"""Remove a leading ```diff fence and its trailing ``` line.
|
|
188
|
+
|
|
189
|
+
Mirrors the fence handling in `gptel-agent--edit-files`: only a
|
|
190
|
+
```diff opening fence is stripped (a bare ``` or ```patch is left
|
|
191
|
+
for `patch` to reject), together with the closing ``` line.
|
|
192
|
+
"""
|
|
193
|
+
lines = text.splitlines(keepends=True)
|
|
194
|
+
if lines and re.match(r"^ *```diff", lines[0]):
|
|
195
|
+
lines = lines[1:]
|
|
196
|
+
if lines and re.match(r"^ *```", lines[-1]):
|
|
197
|
+
lines = lines[:-1]
|
|
198
|
+
return "".join(lines)
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _fix_patch_headers(diff_text: str) -> str:
|
|
203
|
+
"""Recompute the line counts in unified-diff hunk headers.
|
|
204
|
+
|
|
205
|
+
Mirrors `gptel-agent--fix-patch-headers`: for every ``@@ -a,b +c,d @@``
|
|
206
|
+
header, recount the body (context/removed/added lines) up to the next
|
|
207
|
+
header or EOF and rewrite ``b`` and ``d`` accordingly, so a model that
|
|
208
|
+
miscounts hunk lengths still produces a patch `patch` will accept.
|
|
209
|
+
Headers without explicit counts are passed through untouched.
|
|
210
|
+
"""
|
|
211
|
+
lines = diff_text.splitlines(keepends=True)
|
|
212
|
+
out: list[str] = []
|
|
213
|
+
i = 0
|
|
214
|
+
n = len(lines)
|
|
215
|
+
while i < n:
|
|
216
|
+
m = _HUNK_HEADER_RE.match(lines[i])
|
|
217
|
+
if not m:
|
|
218
|
+
out.append(lines[i])
|
|
219
|
+
i += 1
|
|
220
|
+
continue
|
|
221
|
+
orig_line, new_line = int(m.group(1)), int(m.group(3))
|
|
222
|
+
j = i + 1
|
|
223
|
+
orig_count = new_count = 0
|
|
224
|
+
body: list[str] = []
|
|
225
|
+
while j < n and not lines[j].startswith("@@"):
|
|
226
|
+
line = lines[j]
|
|
227
|
+
if line.startswith("---") and _starts_file_section(lines, j):
|
|
228
|
+
# A ---/+++ pair introducing the next file; not hunk body.
|
|
229
|
+
break
|
|
230
|
+
if line.startswith("-"):
|
|
231
|
+
orig_count += 1
|
|
232
|
+
elif line.startswith("+"):
|
|
233
|
+
new_count += 1
|
|
234
|
+
elif line.startswith(" "):
|
|
235
|
+
orig_count += 1
|
|
236
|
+
new_count += 1
|
|
237
|
+
body.append(line)
|
|
238
|
+
j += 1
|
|
239
|
+
out.append(f"@@ -{orig_line},{orig_count} +{new_line},{new_count} @@\n")
|
|
240
|
+
out.extend(body)
|
|
241
|
+
i = j
|
|
242
|
+
return "".join(out)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _starts_file_section(lines: list[str], idx: int) -> bool:
|
|
246
|
+
"""Whether lines[idx] begins the next file's ---/+++ header pair.
|
|
247
|
+
|
|
248
|
+
A new file section in a multi-file diff is ``--- path`` immediately
|
|
249
|
+
followed by ``+++ path`` and then a hunk header. Removed/added
|
|
250
|
+
content lines whose text merely starts with ``--``/``++`` (rendered
|
|
251
|
+
``---``/``+++``) are hunk body and must be counted, so all three
|
|
252
|
+
parts are required:
|
|
253
|
+
|
|
254
|
+
* the ordered pair — a lone ``---``-rendered content line as a
|
|
255
|
+
hunk's LAST body line is followed directly by the next hunk
|
|
256
|
+
header, which a peek for a trailing ``@@`` alone cannot tell from
|
|
257
|
+
a file header (it silently dropped the line from the count and
|
|
258
|
+
made `patch` reject the whole diff);
|
|
259
|
+
* the space/tab after the marker — real headers carry a path
|
|
260
|
+
(``--- a/f``), content lines usually do not (``---removed``);
|
|
261
|
+
* the trailing hunk header.
|
|
262
|
+
|
|
263
|
+
A hunk whose last two body lines happen to be a removed line
|
|
264
|
+
starting with ``-- `` AND an added line starting with ``++ `` is
|
|
265
|
+
still indistinguishable from a file header pair by shape alone; it
|
|
266
|
+
stays a known (and far rarer) miscount.
|
|
267
|
+
"""
|
|
268
|
+
if not _FILE_HEADER_OLD_RE.match(lines[idx]):
|
|
269
|
+
return False
|
|
270
|
+
if idx + 1 >= len(lines) or not _FILE_HEADER_NEW_RE.match(lines[idx + 1]):
|
|
271
|
+
return False
|
|
272
|
+
return idx + 2 < len(lines) and lines[idx + 2].startswith("@@")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Filesystem tool helpers and compatibility re-exports.
|
|
2
|
+
|
|
3
|
+
This module hosts the SHARED helper machinery for the filesystem
|
|
4
|
+
tools — spooling oversized tool results to temp files
|
|
5
|
+
(`gptel-agent--truncate-buffer` parity), git-root detection, and the
|
|
6
|
+
``natnump`` predicate — and re-exports the tool classes that live in
|
|
7
|
+
per-tool modules (`read.py`, `glob.py`, `grep.py`, `edit.py`,
|
|
8
|
+
`write.py`, `insert.py`, `mkdir.py`), so existing imports such as
|
|
9
|
+
``from .tools.filesystem import Read`` keep working.
|
|
10
|
+
|
|
11
|
+
The helpers must stay defined HERE (not in a separate ``_common``
|
|
12
|
+
module): tests monkey-patch ``filesystem._spool_dir`` and read
|
|
13
|
+
``filesystem.MAX_OUTPUT`` / ``SPOOL_LINES`` / ``READ_SIZE_LIMIT`` off
|
|
14
|
+
this module's namespace, and the helper functions resolve them through
|
|
15
|
+
their defining module's globals.
|
|
16
|
+
|
|
17
|
+
NOTE: these filesystem tools are intentionally SYNCHRONOUS. Only Bash
|
|
18
|
+
and Agent are `:async t` in gptel-agent; sync tools run one at a time in
|
|
19
|
+
the model-emitted order. Do NOT be tempted to port every tool to async
|
|
20
|
+
for parallelism: tools can depend on one another's side effects within a
|
|
21
|
+
single round (e.g. Write/Mkdir then Read/Edit the same path, or Edit then
|
|
22
|
+
Grep the just-changed file). Running them concurrently would introduce
|
|
23
|
+
read-after-write races and non-deterministic results. Keep filesystem
|
|
24
|
+
tools synchronous so ordering — and therefore correctness — is preserved.
|
|
25
|
+
|
|
26
|
+
Oversized Glob/Grep results are spilled to a temp file (mirroring
|
|
27
|
+
`gptel-agent--truncate-buffer` in gptel-agent-tools.el): the tool
|
|
28
|
+
result then carries a short preview plus the temp-file path, so the
|
|
29
|
+
full output remains readable via the Read tool.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import shutil # noqa: F401 (mock target for tests)
|
|
36
|
+
import subprocess # noqa: F401 (mock target for tests)
|
|
37
|
+
import tempfile
|
|
38
|
+
import time
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import TypeGuard
|
|
41
|
+
|
|
42
|
+
from ..config import MAX_OUTPUT_CHARS as MAX_OUTPUT
|
|
43
|
+
|
|
44
|
+
SPOOL_LINES = 50 # preview lines kept when results are spilled
|
|
45
|
+
READ_SIZE_LIMIT = 400 * 1024 # whole-file reads above this are refused
|
|
46
|
+
# (mirrors gptel-agent-read-file-size-threshold)
|
|
47
|
+
|
|
48
|
+
_spooled_files: list[str] = [] # temp files created by _spool, cleaned
|
|
49
|
+
# up by cleanup_spooled_files on session close
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _truncate(text: str, label: str = "output") -> str:
|
|
53
|
+
"""In-memory truncation fallback (used when spooling to disk fails)."""
|
|
54
|
+
if len(text) > MAX_OUTPUT:
|
|
55
|
+
return text[:MAX_OUTPUT] + f"\n... [truncated {label}]"
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _spool_dir() -> str:
|
|
60
|
+
"""Reliable temp dir for spilled results (first candidate set, else /tmp)."""
|
|
61
|
+
for d in (
|
|
62
|
+
os.environ.get("TMPDIR"),
|
|
63
|
+
os.environ.get("TMP"),
|
|
64
|
+
os.environ.get("TEMP"),
|
|
65
|
+
tempfile.gettempdir(),
|
|
66
|
+
):
|
|
67
|
+
if d:
|
|
68
|
+
return os.path.abspath(d)
|
|
69
|
+
return "/tmp"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _spool(text: str, label: str) -> str:
|
|
73
|
+
"""Spill oversized tool output to a temp file; return a preview.
|
|
74
|
+
|
|
75
|
+
Mirrors `gptel-agent--truncate-buffer': when TEXT exceeds
|
|
76
|
+
MAX_OUTPUT chars the full content is written to a temp file and the
|
|
77
|
+
returned string becomes a header (size + path), the first
|
|
78
|
+
SPOOL_LINES lines, and a footer telling the agent to Read the file.
|
|
79
|
+
Falls back to in-memory truncation if the temp file cannot be
|
|
80
|
+
written."""
|
|
81
|
+
if len(text) <= MAX_OUTPUT:
|
|
82
|
+
return text
|
|
83
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
84
|
+
try:
|
|
85
|
+
fd, temp_file = tempfile.mkstemp(
|
|
86
|
+
prefix=f"python-agent-harness-{label}-{stamp}-",
|
|
87
|
+
suffix=".txt",
|
|
88
|
+
dir=_spool_dir(),
|
|
89
|
+
)
|
|
90
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
91
|
+
f.write(text)
|
|
92
|
+
_spooled_files.append(temp_file)
|
|
93
|
+
except OSError:
|
|
94
|
+
return _truncate(text, label)
|
|
95
|
+
lines = text.splitlines()
|
|
96
|
+
preview = "\n".join(lines[:SPOOL_LINES])
|
|
97
|
+
return (
|
|
98
|
+
f"{label} results too large ({len(text)} chars, {len(lines)} lines) "
|
|
99
|
+
f"for context window.\n"
|
|
100
|
+
f"Stored in: {temp_file}\n\n"
|
|
101
|
+
f"First {SPOOL_LINES} lines:\n\n"
|
|
102
|
+
f"{preview}\n\n"
|
|
103
|
+
f'[Use Read tool with file_path="{temp_file}" to view full results]'
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cleanup_spooled_files() -> None:
|
|
108
|
+
"""Delete all tracked spooled temp files (best effort).
|
|
109
|
+
|
|
110
|
+
Mirrors ``PlanMode.cleanup_plan_file``: called from
|
|
111
|
+
``Session.close`` so oversized tool results do not accumulate
|
|
112
|
+
in the temp dir. Files already removed (e.g. by a restored
|
|
113
|
+
session) are skipped.
|
|
114
|
+
"""
|
|
115
|
+
paths = _spooled_files[:]
|
|
116
|
+
_spooled_files.clear()
|
|
117
|
+
for path in paths:
|
|
118
|
+
try:
|
|
119
|
+
if os.path.exists(path):
|
|
120
|
+
os.remove(path)
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _natnump(n: object) -> TypeGuard[int]:
|
|
126
|
+
"""True for a non-negative integer (Emacs `natnump' semantics)."""
|
|
127
|
+
return isinstance(n, int) and not isinstance(n, bool) and n >= 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _git_root(path: str) -> str | None:
|
|
131
|
+
d = Path(path).resolve()
|
|
132
|
+
for parent in [d, *d.parents]:
|
|
133
|
+
# .git is a directory in a normal clone and a file in worktrees
|
|
134
|
+
# / submodules; exists() covers both.
|
|
135
|
+
if (parent / ".git").exists():
|
|
136
|
+
return str(parent)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# tool classes live in per-tool modules; re-exported here so existing
|
|
142
|
+
# `from .tools.filesystem import ...` imports (tests included) keep working.
|
|
143
|
+
# The imports sit at the BOTTOM on purpose: the per-tool modules import the
|
|
144
|
+
# shared helpers from this module, so the helpers above must be defined
|
|
145
|
+
# before those modules are loaded (this is the fixed import order — do not
|
|
146
|
+
# move these imports to the top of the file).
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# isort: off
|
|
149
|
+
from .edit import Edit, _fix_patch_headers, _strip_diff_fence # noqa: E402
|
|
150
|
+
from .glob import GlobTool, _git_glob_results # noqa: E402
|
|
151
|
+
from .grep import Grep, _grep_out # noqa: E402
|
|
152
|
+
from .insert import Insert # noqa: E402
|
|
153
|
+
from .mkdir import Mkdir # noqa: E402
|
|
154
|
+
from .read import Read # noqa: E402
|
|
155
|
+
from .write import Write # noqa: E402
|
|
156
|
+
# isort: on
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"Edit",
|
|
160
|
+
"GlobTool",
|
|
161
|
+
"Grep",
|
|
162
|
+
"Insert",
|
|
163
|
+
"Mkdir",
|
|
164
|
+
"Read",
|
|
165
|
+
"Write",
|
|
166
|
+
"MAX_OUTPUT",
|
|
167
|
+
"READ_SIZE_LIMIT",
|
|
168
|
+
"SPOOL_LINES",
|
|
169
|
+
"cleanup_spooled_files",
|
|
170
|
+
"_fix_patch_headers",
|
|
171
|
+
"_git_glob_results",
|
|
172
|
+
"_git_root",
|
|
173
|
+
"_grep_out",
|
|
174
|
+
"_natnump",
|
|
175
|
+
"_spool",
|
|
176
|
+
"_spool_dir",
|
|
177
|
+
"_spooled_files",
|
|
178
|
+
"_strip_diff_fence",
|
|
179
|
+
"_truncate",
|
|
180
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Glob tool: `git ls-files` inside git repos, `tree` outside.
|
|
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
|
|
6
|
+
spilled to a temp file (see `filesystem._spool`), so no matches are
|
|
7
|
+
ever silently lost.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
from .base import Tool, ToolContext
|
|
17
|
+
from .filesystem import _git_root, _natnump, _spool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GlobTool(Tool):
|
|
21
|
+
name = "Glob"
|
|
22
|
+
description = (
|
|
23
|
+
"Recursively find files matching a provided glob pattern.\n\n"
|
|
24
|
+
'- Supports glob patterns like "*.md" or "*test*.py".\n'
|
|
25
|
+
"- Inside a git repository, matching respects .gitignore and covers "
|
|
26
|
+
"both tracked and untracked files.\n"
|
|
27
|
+
"- Returns matching file paths (absolute) at all depths. Limit the "
|
|
28
|
+
"depth of the search by providing the `depth` argument.\n"
|
|
29
|
+
"- When you are doing an open ended search that may require multiple "
|
|
30
|
+
'rounds of globbing and grepping, use the "Agent" tool instead.\n'
|
|
31
|
+
"- Oversized results are spilled to a temp file (see the 'Stored in:' "
|
|
32
|
+
"path); use Read to view the full output."
|
|
33
|
+
)
|
|
34
|
+
parameters = {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"pattern": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": (
|
|
40
|
+
'Glob pattern to match, for example "*.el". Must not be '
|
|
41
|
+
'empty.\nUse "*" to list all files in a directory.'
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
"path": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": (
|
|
47
|
+
'Directory to search in. Supports relative paths and defaults to "."'
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
"depth": {
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"description": (
|
|
53
|
+
"Limit directory depth of search, 1 or higher. Defaults to no limit."
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"required": ["pattern"],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def run(self, args: dict, ctx: ToolContext) -> str:
|
|
61
|
+
# Mirrors `gptel-agent-harness-tools--glob': `git ls-files' inside a
|
|
62
|
+
# git repository (fast, .gitignore-respecting), `tree' as a fallback
|
|
63
|
+
# outside git.
|
|
64
|
+
pattern = args.get("pattern") or ""
|
|
65
|
+
if not pattern:
|
|
66
|
+
return "Error: pattern must not be empty"
|
|
67
|
+
path = args.get("path")
|
|
68
|
+
if path:
|
|
69
|
+
if not (os.path.isdir(path) and os.access(path, os.R_OK)):
|
|
70
|
+
return f"Error: path {path} is not readable"
|
|
71
|
+
else:
|
|
72
|
+
path = ctx.cwd
|
|
73
|
+
base = os.path.abspath(path) # directory-file-name + expand-file-name
|
|
74
|
+
depth = args.get("depth")
|
|
75
|
+
|
|
76
|
+
git_root = _git_root(base)
|
|
77
|
+
if not git_root and not shutil.which("tree"):
|
|
78
|
+
return "Error: Executable `tree` not found. This tool cannot be used"
|
|
79
|
+
|
|
80
|
+
if git_root:
|
|
81
|
+
rel = os.path.relpath(base, git_root)
|
|
82
|
+
pathspec = pattern if rel == "." else f"{rel}/{pattern}".replace(os.sep, "/")
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
[
|
|
86
|
+
"git",
|
|
87
|
+
"ls-files",
|
|
88
|
+
"-z",
|
|
89
|
+
"--full-name",
|
|
90
|
+
"--cached",
|
|
91
|
+
"--others",
|
|
92
|
+
"--exclude-standard",
|
|
93
|
+
"--",
|
|
94
|
+
pathspec,
|
|
95
|
+
],
|
|
96
|
+
cwd=git_root,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
encoding="utf-8",
|
|
100
|
+
errors="replace",
|
|
101
|
+
timeout=60,
|
|
102
|
+
)
|
|
103
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
104
|
+
return f"Error: {e}"
|
|
105
|
+
if proc.returncode != 0:
|
|
106
|
+
# Failure banner is prepended to whatever git emitted.
|
|
107
|
+
banner = f"Glob failed with exit code {proc.returncode}\n.STDOUT:\n\n"
|
|
108
|
+
return _spool(banner + (proc.stdout or "") + (proc.stderr or ""), "glob")
|
|
109
|
+
return _git_glob_results(proc.stdout, git_root, base, depth)
|
|
110
|
+
|
|
111
|
+
# --- Tree strategy (fallback outside git) ---
|
|
112
|
+
cmd = [
|
|
113
|
+
"tree",
|
|
114
|
+
"-l",
|
|
115
|
+
"-f",
|
|
116
|
+
"-i",
|
|
117
|
+
"-I",
|
|
118
|
+
".git",
|
|
119
|
+
"--sort=mtime",
|
|
120
|
+
"--ignore-case",
|
|
121
|
+
"--prune",
|
|
122
|
+
"-P",
|
|
123
|
+
pattern,
|
|
124
|
+
base,
|
|
125
|
+
]
|
|
126
|
+
if _natnump(depth):
|
|
127
|
+
cmd += ["-L", str(depth)]
|
|
128
|
+
try:
|
|
129
|
+
proc = subprocess.run(
|
|
130
|
+
cmd,
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
encoding="utf-8",
|
|
134
|
+
errors="replace",
|
|
135
|
+
timeout=60,
|
|
136
|
+
)
|
|
137
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
138
|
+
return f"Error: {e}"
|
|
139
|
+
out = proc.stdout
|
|
140
|
+
if proc.returncode != 0:
|
|
141
|
+
out = f"Glob failed with exit code {proc.returncode}\n.STDOUT:\n\n" + out
|
|
142
|
+
return _spool(out, "glob")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _git_glob_results(raw: str, git_root: str, base: str, depth: object) -> str:
|
|
146
|
+
"""Format `git ls-files -z` output into absolute paths, depth-filtered.
|
|
147
|
+
|
|
148
|
+
Mirrors the git branch of `gptel-agent-harness-tools--glob': split on
|
|
149
|
+
NUL, drop entries whose slash-count reaches ``base_depth + depth``
|
|
150
|
+
(only when DEPTH is a non-negative integer — `natnump'), then prefix
|
|
151
|
+
each remaining entry with GIT-ROOT.
|
|
152
|
+
"""
|
|
153
|
+
lines = [line for line in raw.split("\0") if line]
|
|
154
|
+
if _natnump(depth):
|
|
155
|
+
rel_base = os.path.relpath(base, git_root)
|
|
156
|
+
base_depth = 0 if rel_base == "." else 1 + rel_base.count("/")
|
|
157
|
+
lines = [line for line in lines if line.count("/") < base_depth + depth]
|
|
158
|
+
out = "\n".join(os.path.join(git_root, line) for line in lines)
|
|
159
|
+
if not out:
|
|
160
|
+
return ""
|
|
161
|
+
return _spool(out + "\n", "glob")
|