minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
"""Workspace-bound file operations: read, write, append, and structured edit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import importlib.util
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..errors import ToolError
|
|
20
|
+
from .search import _MAX_READ_LINE_CHARS, drain_physical_line
|
|
21
|
+
|
|
22
|
+
# ``os.umask`` is process-global; serialize the read-and-restore window so a
|
|
23
|
+
# concurrently created directory cannot inherit a zero umask.
|
|
24
|
+
_UMASK_LOCK = threading.Lock()
|
|
25
|
+
|
|
26
|
+
# Failure feedback has to stay useful without turning a retry into another
|
|
27
|
+
# large prompt: a closest-match diff is capped by lines, per-line width, and
|
|
28
|
+
# total characters.
|
|
29
|
+
_MAX_DIAGNOSTIC_DIFF_LINES = 40
|
|
30
|
+
_MAX_DIAGNOSTIC_DIFF_CHARS = 4000
|
|
31
|
+
_MAX_DIAGNOSTIC_LINE_CHARS = 300
|
|
32
|
+
# Scoring every possible window is quadratic on a large file. Exact line hits
|
|
33
|
+
# are always scored; the remaining evenly spaced windows keep a typo-only match
|
|
34
|
+
# bounded on multi-megabyte inputs.
|
|
35
|
+
_MAX_SIMILARITY_WINDOWS = 4000
|
|
36
|
+
|
|
37
|
+
# Reported only when the requested range really is not valid UTF-8, so a plain
|
|
38
|
+
# read stays silent about encoding.
|
|
39
|
+
_NOT_UTF8_WARNING = (
|
|
40
|
+
"this range is not valid UTF-8; undecodable bytes were replaced with U+FFFD "
|
|
41
|
+
"and edit refuses this file"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# ``str.splitlines`` also breaks on VT, FF, NEL, and U+2028/U+2029, while
|
|
45
|
+
# ``read`` counts only CR/LF/CRLF. Line-mode edit must use the same definition
|
|
46
|
+
# or it replaces a different line than the caller read.
|
|
47
|
+
_PHYSICAL_LINE_RE = re.compile(r"[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _physical_lines(text: str) -> list[str]:
|
|
51
|
+
"""Split ``text`` into CR/LF/CRLF lines, each keeping its terminator."""
|
|
52
|
+
return _PHYSICAL_LINE_RE.findall(text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class _TextScan:
|
|
57
|
+
"""One pass over the requested line range of a text file."""
|
|
58
|
+
|
|
59
|
+
chunks: list[str]
|
|
60
|
+
start: int | None
|
|
61
|
+
end: int
|
|
62
|
+
last_line: int
|
|
63
|
+
truncated: bool
|
|
64
|
+
newline: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class _Diagnostic:
|
|
69
|
+
"""One validation finding, keyed without its line so edits can move it."""
|
|
70
|
+
|
|
71
|
+
validator: str
|
|
72
|
+
line: int | None
|
|
73
|
+
code: str | None
|
|
74
|
+
message: str
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def key(self) -> tuple[str, str | None, str]:
|
|
78
|
+
return (self.validator, self.code, self.message)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_PYTHON_SUFFIXES = frozenset({".py", ".pyi"})
|
|
82
|
+
_MAX_REPORTED_DIAGNOSTICS = 8
|
|
83
|
+
_VALIDATION_TIMEOUT_SECONDS = 30
|
|
84
|
+
# Codes that mean "the validator itself did not run" (missing tool, broken
|
|
85
|
+
# config, timeout) rather than "the new content is wrong". Counting them as
|
|
86
|
+
# content diagnostics rolled a fresh write back and deleted the new file.
|
|
87
|
+
_VALIDATOR_FAILURES = frozenset({"exit", "unavailable", "timeout"})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _source_preview(text: str, line: int | None) -> str:
|
|
91
|
+
if line is None:
|
|
92
|
+
return ""
|
|
93
|
+
# Same physical-line definition as `read`, so the previewed source is the
|
|
94
|
+
# line the validator actually reported.
|
|
95
|
+
lines = _physical_lines(text)
|
|
96
|
+
if line < 1 or line > len(lines):
|
|
97
|
+
return ""
|
|
98
|
+
source = lines[line - 1].rstrip()
|
|
99
|
+
if len(source) > 200:
|
|
100
|
+
source = source[:197] + "..."
|
|
101
|
+
return f"\n {line:>5} | {source}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _dominant_newline(counts: dict[str, int]) -> str | None:
|
|
105
|
+
"""Return the most frequent line ending, or None when there is none."""
|
|
106
|
+
best = max(counts, key=counts.__getitem__)
|
|
107
|
+
return best if counts[best] else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _count_text_newlines(text: str) -> dict[str, int]:
|
|
111
|
+
crlf = text.count("\r\n")
|
|
112
|
+
return {
|
|
113
|
+
"\r\n": crlf,
|
|
114
|
+
"\n": text.count("\n") - crlf,
|
|
115
|
+
"\r": text.count("\r") - crlf,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _detect_newline_optional(text: str) -> str | None:
|
|
120
|
+
return _dominant_newline(_count_text_newlines(text))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _detect_newline(text: str) -> str:
|
|
124
|
+
"""Return the file's dominant physical line ending."""
|
|
125
|
+
return _detect_newline_optional(text) or "\n"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _line_ending_name(newline: str | None) -> str:
|
|
129
|
+
return {"\r\n": "CRLF", "\r": "CR", "\n": "LF"}.get(newline, "none")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _detect_newline_bytes(data: bytes) -> str | None:
|
|
133
|
+
"""Detect a physical line ending without decoding the whole file."""
|
|
134
|
+
crlf = data.count(b"\r\n")
|
|
135
|
+
return _dominant_newline(
|
|
136
|
+
{
|
|
137
|
+
"\r\n": crlf,
|
|
138
|
+
"\n": data.count(b"\n") - crlf,
|
|
139
|
+
"\r": data.count(b"\r") - crlf,
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _normalize_newlines(text: str, newline: str) -> str:
|
|
145
|
+
"""Rewrite every physical line ending to ``newline``."""
|
|
146
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
147
|
+
return normalized.replace("\n", newline)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _line_number_at(text: str, offset: int) -> int:
|
|
151
|
+
return len(re.findall(r"\r\n|\r|\n", text[:offset])) + 1
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _clip_diagnostic(lines: list[str]) -> str:
|
|
155
|
+
"""Join a diff without letting a wrong-file match flood the next request."""
|
|
156
|
+
clipped = [
|
|
157
|
+
line
|
|
158
|
+
if len(line) <= _MAX_DIAGNOSTIC_LINE_CHARS
|
|
159
|
+
else line[: _MAX_DIAGNOSTIC_LINE_CHARS - 3] + "..."
|
|
160
|
+
for line in lines
|
|
161
|
+
]
|
|
162
|
+
if len(clipped) > _MAX_DIAGNOSTIC_DIFF_LINES:
|
|
163
|
+
head = _MAX_DIAGNOSTIC_DIFF_LINES - 4
|
|
164
|
+
tail = 3
|
|
165
|
+
omitted = len(clipped) - head - tail
|
|
166
|
+
clipped = [
|
|
167
|
+
*clipped[:head],
|
|
168
|
+
f"... ({omitted} diff lines omitted) ...",
|
|
169
|
+
*clipped[-tail:],
|
|
170
|
+
]
|
|
171
|
+
text = "\n".join(clipped)
|
|
172
|
+
if len(text) > _MAX_DIAGNOSTIC_DIFF_CHARS:
|
|
173
|
+
text = text[: _MAX_DIAGNOSTIC_DIFF_CHARS - 20] + "\n... (diff truncated)"
|
|
174
|
+
return text
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _closest_fragment(original: str, needle: str) -> tuple[int, int, float, str] | None:
|
|
178
|
+
"""Locate the region most similar to ``needle`` and render its diff.
|
|
179
|
+
|
|
180
|
+
Returns ``(start_line, end_line, similarity, diff)`` with 1-based inclusive
|
|
181
|
+
line numbers. ``difflib`` is only used after an exact match already failed,
|
|
182
|
+
and the candidate count is bounded so this stays a repair aid rather than a
|
|
183
|
+
second full-file scan.
|
|
184
|
+
"""
|
|
185
|
+
# Physical lines, not `splitlines`: the latter also breaks on FF/VT/NEL and
|
|
186
|
+
# U+2028, which would report line numbers `read` never showed.
|
|
187
|
+
needle_lines = [
|
|
188
|
+
line.rstrip("\n") for line in _physical_lines(_normalize_newlines(needle, "\n"))
|
|
189
|
+
]
|
|
190
|
+
if not needle_lines:
|
|
191
|
+
return None
|
|
192
|
+
file_lines = [
|
|
193
|
+
line.rstrip("\n")
|
|
194
|
+
for line in _physical_lines(_normalize_newlines(original, "\n"))
|
|
195
|
+
]
|
|
196
|
+
if not file_lines:
|
|
197
|
+
return None
|
|
198
|
+
count = len(needle_lines)
|
|
199
|
+
last_start = max(0, len(file_lines) - count)
|
|
200
|
+
starts: set[int] = set()
|
|
201
|
+
positions: dict[str, list[int]] = {}
|
|
202
|
+
for index, line in enumerate(file_lines):
|
|
203
|
+
positions.setdefault(line, []).append(index)
|
|
204
|
+
for offset, line in enumerate(needle_lines):
|
|
205
|
+
for index in positions.get(line, ())[:50]:
|
|
206
|
+
starts.add(min(max(0, index - offset), last_start))
|
|
207
|
+
step = max(1, len(file_lines) // _MAX_SIMILARITY_WINDOWS)
|
|
208
|
+
for index in range(0, last_start + 1, step):
|
|
209
|
+
starts.add(index)
|
|
210
|
+
best_start = 0
|
|
211
|
+
best_ratio = -1.0
|
|
212
|
+
matcher = difflib.SequenceMatcher(autojunk=False)
|
|
213
|
+
for start in sorted(starts):
|
|
214
|
+
window = file_lines[start : start + count]
|
|
215
|
+
if not window:
|
|
216
|
+
continue
|
|
217
|
+
matcher.set_seqs(needle_lines, window)
|
|
218
|
+
ratio = matcher.ratio()
|
|
219
|
+
if ratio > best_ratio:
|
|
220
|
+
best_ratio = ratio
|
|
221
|
+
best_start = start
|
|
222
|
+
context_start = max(0, best_start - 1)
|
|
223
|
+
context_end = min(len(file_lines), best_start + count + 1)
|
|
224
|
+
actual = file_lines[context_start:context_end]
|
|
225
|
+
diff = list(
|
|
226
|
+
difflib.unified_diff(
|
|
227
|
+
needle_lines,
|
|
228
|
+
actual,
|
|
229
|
+
fromfile="old_text",
|
|
230
|
+
tofile=f"file lines {context_start + 1}-{context_end}",
|
|
231
|
+
n=0,
|
|
232
|
+
lineterm="",
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
if not diff:
|
|
236
|
+
# Identical line content can still reach here when the caller included
|
|
237
|
+
# a trailing empty line that splitlines() dropped. Show real context so
|
|
238
|
+
# the retry is not blind.
|
|
239
|
+
diff = [f"{context_start + 1}: {line}" for line in actual]
|
|
240
|
+
return (
|
|
241
|
+
best_start + 1,
|
|
242
|
+
min(len(file_lines), best_start + count),
|
|
243
|
+
max(0.0, best_ratio),
|
|
244
|
+
_clip_diagnostic(diff),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _not_found_message(target: Path, original: str, old_text: str) -> str:
|
|
249
|
+
message = f"old_text was not found in {target}."
|
|
250
|
+
fragment = _closest_fragment(original, old_text)
|
|
251
|
+
if fragment is None:
|
|
252
|
+
return message + " The file has no text to compare against."
|
|
253
|
+
start, end, ratio, diff = fragment
|
|
254
|
+
return (
|
|
255
|
+
f"{message}\nMost similar region: lines {start}-{end} "
|
|
256
|
+
f"({ratio:.0%} similar):\n{diff}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _range_hint(lines: list[str], start: int, end: int) -> str:
|
|
261
|
+
"""Describe the valid line range nearest to an out-of-bounds request."""
|
|
262
|
+
total = len(lines)
|
|
263
|
+
if total == 0:
|
|
264
|
+
return "the file is empty; write or append content instead"
|
|
265
|
+
nearest_start = min(max(1, start), total)
|
|
266
|
+
nearest_end = min(max(nearest_start, end), total)
|
|
267
|
+
# The preview is a hint, not a dump: `end_line=1000000` on a 200k-line file
|
|
268
|
+
# used to paste the whole file into the tool error and the next request.
|
|
269
|
+
shown_end = min(nearest_end, nearest_start + _MAX_DIAGNOSTIC_DIFF_LINES - 1)
|
|
270
|
+
|
|
271
|
+
def visible(line: str) -> str:
|
|
272
|
+
line = line.rstrip("\r\n")
|
|
273
|
+
if len(line) > _MAX_DIAGNOSTIC_LINE_CHARS:
|
|
274
|
+
return line[: _MAX_DIAGNOSTIC_LINE_CHARS - 3] + "..."
|
|
275
|
+
return line
|
|
276
|
+
|
|
277
|
+
preview = "".join(
|
|
278
|
+
f"{number:>6}\t{visible(lines[number - 1])}\n"
|
|
279
|
+
for number in range(nearest_start, shown_end + 1)
|
|
280
|
+
)
|
|
281
|
+
if preview and not preview.endswith(("\n", "\r")):
|
|
282
|
+
preview += "\n"
|
|
283
|
+
if shown_end < nearest_end:
|
|
284
|
+
preview += f"... (lines {shown_end + 1}-{nearest_end} omitted) ...\n"
|
|
285
|
+
return f"closest available range is lines {nearest_start}-{nearest_end}:\n{preview}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class FileToolsMixin:
|
|
289
|
+
"""File methods mixed into :class:`~minimalcode.tools.ToolRuntime`.
|
|
290
|
+
|
|
291
|
+
All paths are resolved by the runtime's sandbox-aware ``_path`` helper
|
|
292
|
+
before any read or write occurs.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
def read(
|
|
296
|
+
self,
|
|
297
|
+
path: str,
|
|
298
|
+
start_line: int = 1,
|
|
299
|
+
end_line: int | None = None,
|
|
300
|
+
) -> dict[str, Any]:
|
|
301
|
+
"""Read a UTF-8 text file by 1-based line range with line numbers."""
|
|
302
|
+
target = self._path(path, operation="read")
|
|
303
|
+
if not target.is_file():
|
|
304
|
+
raise ToolError(f"file not found: {target}")
|
|
305
|
+
if isinstance(start_line, bool) or not isinstance(start_line, int):
|
|
306
|
+
raise ToolError("start_line must be an integer")
|
|
307
|
+
if end_line is not None and (
|
|
308
|
+
isinstance(end_line, bool) or not isinstance(end_line, int)
|
|
309
|
+
):
|
|
310
|
+
raise ToolError("end_line must be an integer or null")
|
|
311
|
+
start = max(1, start_line)
|
|
312
|
+
stop = end_line
|
|
313
|
+
if stop is not None and stop < start:
|
|
314
|
+
raise ToolError("end_line must be greater than or equal to start_line")
|
|
315
|
+
|
|
316
|
+
# Reject binary input before decoding so a NUL-containing file is not
|
|
317
|
+
# rendered as a wall of replacement characters. The sample catches the
|
|
318
|
+
# common case early; the per-line check below catches a NUL that only
|
|
319
|
+
# appears after the sample.
|
|
320
|
+
with target.open("rb") as binary:
|
|
321
|
+
sample = binary.read(65536)
|
|
322
|
+
if b"\x00" in sample:
|
|
323
|
+
raise ToolError("binary files are not supported by read")
|
|
324
|
+
|
|
325
|
+
line_limit = min(self.max_file_chars + 1, _MAX_READ_LINE_CHARS)
|
|
326
|
+
warnings: list[str] = []
|
|
327
|
+
try:
|
|
328
|
+
scan = self._scan_lines(
|
|
329
|
+
target,
|
|
330
|
+
start=start,
|
|
331
|
+
stop=stop,
|
|
332
|
+
line_limit=line_limit,
|
|
333
|
+
errors="strict",
|
|
334
|
+
)
|
|
335
|
+
except UnicodeDecodeError:
|
|
336
|
+
# Decoding with ``replace`` used to hide this: the caller saw a
|
|
337
|
+
# clean read made of U+FFFD characters, and only ``edit`` later
|
|
338
|
+
# refused the same file. Read it again lossily so the content is
|
|
339
|
+
# still visible, and say that the bytes were not UTF-8 only when
|
|
340
|
+
# the returned range actually contains a replacement character.
|
|
341
|
+
scan = self._scan_lines(
|
|
342
|
+
target,
|
|
343
|
+
start=start,
|
|
344
|
+
stop=stop,
|
|
345
|
+
line_limit=line_limit,
|
|
346
|
+
errors="replace",
|
|
347
|
+
)
|
|
348
|
+
if "\ufffd" in "".join(scan.chunks):
|
|
349
|
+
warnings.append(_NOT_UTF8_WARNING)
|
|
350
|
+
|
|
351
|
+
line_ending = _detect_newline_bytes(sample) or scan.newline
|
|
352
|
+
if scan.start is None:
|
|
353
|
+
if start > max(1, scan.last_line):
|
|
354
|
+
raise ToolError(f"start_line {start} is beyond the end of the file")
|
|
355
|
+
result: dict[str, Any] = {
|
|
356
|
+
"status": "success",
|
|
357
|
+
"path": str(target),
|
|
358
|
+
"content": "",
|
|
359
|
+
"start_line": start,
|
|
360
|
+
"end_line": 0,
|
|
361
|
+
"line_ending": _line_ending_name(line_ending),
|
|
362
|
+
"truncated": scan.truncated,
|
|
363
|
+
"eof": True,
|
|
364
|
+
}
|
|
365
|
+
else:
|
|
366
|
+
result = {
|
|
367
|
+
"status": "success",
|
|
368
|
+
"path": str(target),
|
|
369
|
+
"start_line": scan.start,
|
|
370
|
+
"end_line": scan.end,
|
|
371
|
+
"content": "".join(scan.chunks),
|
|
372
|
+
"line_ending": _line_ending_name(line_ending),
|
|
373
|
+
"truncated": scan.truncated,
|
|
374
|
+
}
|
|
375
|
+
if warnings:
|
|
376
|
+
result["warnings"] = warnings
|
|
377
|
+
return result
|
|
378
|
+
|
|
379
|
+
def read_range(self, path: str, start_line: int, end_line: int) -> dict[str, Any]:
|
|
380
|
+
"""Read an explicit range; both bounds are required by the schema."""
|
|
381
|
+
result = self.read(path, start_line, end_line)
|
|
382
|
+
result["line_count"] = max(0, result["end_line"] - result["start_line"] + 1)
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
def _scan_lines(
|
|
386
|
+
self,
|
|
387
|
+
target: Path,
|
|
388
|
+
*,
|
|
389
|
+
start: int,
|
|
390
|
+
stop: int | None,
|
|
391
|
+
line_limit: int,
|
|
392
|
+
errors: str,
|
|
393
|
+
) -> _TextScan:
|
|
394
|
+
"""Collect one line range, decoding with the caller's error policy.
|
|
395
|
+
|
|
396
|
+
A strict pass is what makes a non-UTF-8 file visible: with
|
|
397
|
+
``errors="replace"`` the decode never fails, so the caller could not
|
|
398
|
+
tell a clean file from one whose bytes were silently substituted.
|
|
399
|
+
``utf-8-sig`` also drops a leading BOM instead of showing it as part of
|
|
400
|
+
line 1.
|
|
401
|
+
"""
|
|
402
|
+
chunks: list[str] = []
|
|
403
|
+
used = 0
|
|
404
|
+
actual_start: int | None = None
|
|
405
|
+
actual_end = 0
|
|
406
|
+
truncated = False
|
|
407
|
+
last_line = 0
|
|
408
|
+
detected_newline: str | None = None
|
|
409
|
+
pending = ""
|
|
410
|
+
# Two extra characters make an exactly-at-the-limit physical line,
|
|
411
|
+
# including its terminator, readable as a complete line instead of
|
|
412
|
+
# being flagged as a cut and dropping every line after it.
|
|
413
|
+
read_limit = line_limit + 2
|
|
414
|
+
with target.open(
|
|
415
|
+
"r", encoding="utf-8-sig", errors=errors, newline=""
|
|
416
|
+
) as handle:
|
|
417
|
+
number = 0
|
|
418
|
+
while True:
|
|
419
|
+
line = pending + handle.readline(read_limit)
|
|
420
|
+
pending = ""
|
|
421
|
+
if not line:
|
|
422
|
+
break
|
|
423
|
+
if line.endswith("\r") and len(line) >= read_limit:
|
|
424
|
+
# A limit that lands between CR and LF must not turn the
|
|
425
|
+
# LF into a separate empty line. Peek one character and
|
|
426
|
+
# keep it for the next iteration when it is real content.
|
|
427
|
+
following = handle.read(1)
|
|
428
|
+
if following == "\n":
|
|
429
|
+
line += following
|
|
430
|
+
else:
|
|
431
|
+
pending = following
|
|
432
|
+
number += 1
|
|
433
|
+
last_line = number
|
|
434
|
+
if detected_newline is None:
|
|
435
|
+
detected_newline = _detect_newline_optional(line)
|
|
436
|
+
if "\x00" in line:
|
|
437
|
+
raise ToolError("binary files are not supported by read")
|
|
438
|
+
line_cut = len(line) >= read_limit and not line.endswith(("\n", "\r"))
|
|
439
|
+
if line_cut:
|
|
440
|
+
# The limit may still be an exact fit for a complete line
|
|
441
|
+
# that had not delivered its terminator within the limit.
|
|
442
|
+
position = handle.tell()
|
|
443
|
+
following = handle.read(1)
|
|
444
|
+
if not following:
|
|
445
|
+
line_cut = False
|
|
446
|
+
elif following == "\n":
|
|
447
|
+
line += following
|
|
448
|
+
line_cut = False
|
|
449
|
+
elif following == "\r":
|
|
450
|
+
line += following
|
|
451
|
+
peek = handle.read(1)
|
|
452
|
+
if peek == "\n":
|
|
453
|
+
line += peek
|
|
454
|
+
elif peek:
|
|
455
|
+
handle.seek(position + 1)
|
|
456
|
+
line_cut = False
|
|
457
|
+
else:
|
|
458
|
+
handle.seek(position)
|
|
459
|
+
if number < start or (stop is not None and number > stop):
|
|
460
|
+
if line_cut:
|
|
461
|
+
# Skip the rest of an over-long physical line so the
|
|
462
|
+
# next readline starts a new line and the reported
|
|
463
|
+
# numbers keep matching the file.
|
|
464
|
+
drain_physical_line(handle, read_limit)
|
|
465
|
+
if stop is not None and number > stop:
|
|
466
|
+
break
|
|
467
|
+
continue
|
|
468
|
+
if line_cut:
|
|
469
|
+
truncated = True
|
|
470
|
+
rendered = f"{number:>6}\t{line.rstrip(chr(13) + chr(10))}\n"
|
|
471
|
+
remaining = self.max_file_chars - used
|
|
472
|
+
if len(rendered) > remaining:
|
|
473
|
+
truncated = True
|
|
474
|
+
if actual_start is None and remaining > 0:
|
|
475
|
+
# A first line larger than the budget must not look like
|
|
476
|
+
# an empty file; return its prefix so the caller sees
|
|
477
|
+
# content.
|
|
478
|
+
chunks.append(rendered[:remaining])
|
|
479
|
+
actual_start = number
|
|
480
|
+
actual_end = number
|
|
481
|
+
break
|
|
482
|
+
if actual_start is None:
|
|
483
|
+
actual_start = number
|
|
484
|
+
chunks.append(rendered)
|
|
485
|
+
used += len(rendered)
|
|
486
|
+
actual_end = number
|
|
487
|
+
if line_cut:
|
|
488
|
+
break
|
|
489
|
+
return _TextScan(
|
|
490
|
+
chunks=chunks,
|
|
491
|
+
start=actual_start,
|
|
492
|
+
end=actual_end,
|
|
493
|
+
last_line=last_line,
|
|
494
|
+
truncated=truncated,
|
|
495
|
+
newline=detected_newline,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
@staticmethod
|
|
499
|
+
def _write_atomic(
|
|
500
|
+
target: Path, content: str | bytes, *, mode: int | None = None
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Write a sibling temp file, fsync it, then replace the target.
|
|
503
|
+
|
|
504
|
+
The rename keeps readers from observing a half-written file, and the
|
|
505
|
+
original mode is restored for executable scripts. A new file uses the
|
|
506
|
+
process umask instead of mkstemp's 0600 so other users/groups can read
|
|
507
|
+
source files created by the agent.
|
|
508
|
+
"""
|
|
509
|
+
fd: int
|
|
510
|
+
temp_name: str
|
|
511
|
+
try:
|
|
512
|
+
fd, temp_name = tempfile.mkstemp(
|
|
513
|
+
# A temp name longer than the filesystem's limit fails with an
|
|
514
|
+
# opaque EINVAL; the random suffix is what matters, not the stem.
|
|
515
|
+
prefix=f".{target.name[:40]}.",
|
|
516
|
+
dir=target.parent,
|
|
517
|
+
)
|
|
518
|
+
except OSError as exc:
|
|
519
|
+
raise ToolError(f"cannot write {target}: {exc}") from exc
|
|
520
|
+
temp_path = Path(temp_name)
|
|
521
|
+
try:
|
|
522
|
+
if isinstance(content, bytes):
|
|
523
|
+
with os.fdopen(fd, "wb") as handle:
|
|
524
|
+
handle.write(content)
|
|
525
|
+
handle.flush()
|
|
526
|
+
os.fsync(handle.fileno())
|
|
527
|
+
else:
|
|
528
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
529
|
+
handle.write(content)
|
|
530
|
+
handle.flush()
|
|
531
|
+
os.fsync(handle.fileno())
|
|
532
|
+
if mode is None:
|
|
533
|
+
with _UMASK_LOCK:
|
|
534
|
+
current_umask = os.umask(0)
|
|
535
|
+
os.umask(current_umask)
|
|
536
|
+
mode = 0o666 & ~current_umask
|
|
537
|
+
try:
|
|
538
|
+
os.chmod(temp_path, mode)
|
|
539
|
+
except OSError:
|
|
540
|
+
pass
|
|
541
|
+
try:
|
|
542
|
+
os.replace(temp_path, target)
|
|
543
|
+
except OSError as exc:
|
|
544
|
+
# A read-only target (or a read-only inherited mode on the
|
|
545
|
+
# temporary file) fails here; report a tool error instead of a
|
|
546
|
+
# raw PermissionError that the agent loop cannot explain.
|
|
547
|
+
raise ToolError(f"cannot write {target}: {exc}") from exc
|
|
548
|
+
try:
|
|
549
|
+
directory_fd = os.open(target.parent, os.O_RDONLY)
|
|
550
|
+
except OSError:
|
|
551
|
+
directory_fd = -1
|
|
552
|
+
if directory_fd >= 0:
|
|
553
|
+
try:
|
|
554
|
+
os.fsync(directory_fd)
|
|
555
|
+
except OSError:
|
|
556
|
+
pass
|
|
557
|
+
finally:
|
|
558
|
+
os.close(directory_fd)
|
|
559
|
+
except OSError as exc:
|
|
560
|
+
raise ToolError(f"cannot write {target}: {exc}") from exc
|
|
561
|
+
finally:
|
|
562
|
+
# A failed write or replace must not leave a hidden temp file. A
|
|
563
|
+
# Windows read-only attribute blocks unlink, so make it writable
|
|
564
|
+
# once and retry instead of leaking the file.
|
|
565
|
+
try:
|
|
566
|
+
temp_path.unlink(missing_ok=True)
|
|
567
|
+
except OSError:
|
|
568
|
+
try:
|
|
569
|
+
os.chmod(temp_path, 0o600)
|
|
570
|
+
temp_path.unlink(missing_ok=True)
|
|
571
|
+
except OSError:
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
def _ruff_invocation(self) -> list[str] | None:
|
|
575
|
+
"""Return a ruff command once, or None when ruff is unavailable."""
|
|
576
|
+
if hasattr(self, "_ruff_command_cache"):
|
|
577
|
+
return self._ruff_command_cache
|
|
578
|
+
executable = shutil.which("ruff")
|
|
579
|
+
if executable:
|
|
580
|
+
command = [executable]
|
|
581
|
+
elif importlib.util.find_spec("ruff") is not None:
|
|
582
|
+
command = [sys.executable, "-m", "ruff"]
|
|
583
|
+
else:
|
|
584
|
+
command = None
|
|
585
|
+
self._ruff_command_cache = command
|
|
586
|
+
return command
|
|
587
|
+
|
|
588
|
+
@staticmethod
|
|
589
|
+
def _run_validator(
|
|
590
|
+
argv: list[str], target: Path
|
|
591
|
+
) -> subprocess.CompletedProcess[str] | _Diagnostic:
|
|
592
|
+
try:
|
|
593
|
+
return subprocess.run(
|
|
594
|
+
argv,
|
|
595
|
+
cwd=str(target.parent),
|
|
596
|
+
stdin=subprocess.DEVNULL,
|
|
597
|
+
capture_output=True,
|
|
598
|
+
text=True,
|
|
599
|
+
encoding="utf-8",
|
|
600
|
+
errors="replace",
|
|
601
|
+
timeout=_VALIDATION_TIMEOUT_SECONDS,
|
|
602
|
+
check=False,
|
|
603
|
+
)
|
|
604
|
+
except subprocess.TimeoutExpired:
|
|
605
|
+
return _Diagnostic(
|
|
606
|
+
"validation",
|
|
607
|
+
None,
|
|
608
|
+
"timeout",
|
|
609
|
+
f"did not finish within {_VALIDATION_TIMEOUT_SECONDS:g} seconds",
|
|
610
|
+
)
|
|
611
|
+
except (OSError, ValueError) as exc:
|
|
612
|
+
return _Diagnostic("validation", None, "unavailable", str(exc))
|
|
613
|
+
|
|
614
|
+
def _py_compile_diagnostics(self, target: Path) -> list[_Diagnostic]:
|
|
615
|
+
"""Compile ``target`` without leaving a __pycache__ in the workspace."""
|
|
616
|
+
with tempfile.TemporaryDirectory(prefix="minimalcode-pycache-") as cache:
|
|
617
|
+
env = os.environ.copy()
|
|
618
|
+
env["PYTHONPYCACHEPREFIX"] = cache
|
|
619
|
+
try:
|
|
620
|
+
completed = subprocess.run(
|
|
621
|
+
[sys.executable, "-m", "py_compile", str(target)],
|
|
622
|
+
cwd=str(target.parent),
|
|
623
|
+
stdin=subprocess.DEVNULL,
|
|
624
|
+
capture_output=True,
|
|
625
|
+
text=True,
|
|
626
|
+
encoding="utf-8",
|
|
627
|
+
errors="replace",
|
|
628
|
+
timeout=_VALIDATION_TIMEOUT_SECONDS,
|
|
629
|
+
env=env,
|
|
630
|
+
check=False,
|
|
631
|
+
)
|
|
632
|
+
except subprocess.TimeoutExpired:
|
|
633
|
+
return [
|
|
634
|
+
_Diagnostic(
|
|
635
|
+
"py_compile",
|
|
636
|
+
None,
|
|
637
|
+
"timeout",
|
|
638
|
+
f"did not finish within {_VALIDATION_TIMEOUT_SECONDS:g}s",
|
|
639
|
+
)
|
|
640
|
+
]
|
|
641
|
+
except (OSError, ValueError) as exc:
|
|
642
|
+
return [_Diagnostic("py_compile", None, "unavailable", str(exc))]
|
|
643
|
+
if completed.returncode == 0:
|
|
644
|
+
return []
|
|
645
|
+
detail = (completed.stderr or completed.stdout or "").strip()
|
|
646
|
+
line_match = re.search(r"\bline (\d+)\b", detail)
|
|
647
|
+
line = int(line_match.group(1)) if line_match else None
|
|
648
|
+
lines = [part.strip() for part in detail.splitlines() if part.strip()]
|
|
649
|
+
message = lines[-1] if lines else f"exit code {completed.returncode}"
|
|
650
|
+
code = message.split(":", 1)[0] if ":" in message else None
|
|
651
|
+
return [
|
|
652
|
+
_Diagnostic(
|
|
653
|
+
"py_compile",
|
|
654
|
+
line,
|
|
655
|
+
code,
|
|
656
|
+
message[:400],
|
|
657
|
+
)
|
|
658
|
+
]
|
|
659
|
+
|
|
660
|
+
def _ruff_diagnostics(self, target: Path) -> tuple[list[_Diagnostic], list[str]]:
|
|
661
|
+
command = self._ruff_invocation()
|
|
662
|
+
if command is None:
|
|
663
|
+
return [], ["ruff is not installed; ruff check was skipped"]
|
|
664
|
+
completed = self._run_validator(
|
|
665
|
+
[*command, "check", "--output-format=concise", "--no-cache", str(target)],
|
|
666
|
+
target,
|
|
667
|
+
)
|
|
668
|
+
if isinstance(completed, _Diagnostic):
|
|
669
|
+
return [completed], []
|
|
670
|
+
if completed.returncode == 0:
|
|
671
|
+
return [], []
|
|
672
|
+
output = (completed.stdout or "") + (completed.stderr or "")
|
|
673
|
+
diagnostics: list[_Diagnostic] = []
|
|
674
|
+
pattern = re.compile(r"^(.*):(\d+):(\d+): ([A-Z]+[0-9]+) (.*)$")
|
|
675
|
+
for raw in output.splitlines():
|
|
676
|
+
match = pattern.match(raw.strip())
|
|
677
|
+
if match is None:
|
|
678
|
+
continue
|
|
679
|
+
diagnostics.append(
|
|
680
|
+
_Diagnostic(
|
|
681
|
+
"ruff",
|
|
682
|
+
int(match.group(2)),
|
|
683
|
+
match.group(4),
|
|
684
|
+
match.group(5)[:400],
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
if diagnostics:
|
|
688
|
+
return diagnostics, []
|
|
689
|
+
detail = " ".join(output.split())[:400]
|
|
690
|
+
return (
|
|
691
|
+
[
|
|
692
|
+
_Diagnostic(
|
|
693
|
+
"ruff",
|
|
694
|
+
None,
|
|
695
|
+
"exit",
|
|
696
|
+
detail or f"exit code {completed.returncode}",
|
|
697
|
+
)
|
|
698
|
+
],
|
|
699
|
+
[],
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
def _python_diagnostics(self, target: Path) -> tuple[list[_Diagnostic], list[str]]:
|
|
703
|
+
if target.suffix.lower() not in _PYTHON_SUFFIXES:
|
|
704
|
+
return [], []
|
|
705
|
+
diagnostics = self._py_compile_diagnostics(target)
|
|
706
|
+
ruff, warnings = self._ruff_diagnostics(target)
|
|
707
|
+
return [*diagnostics, *ruff], warnings
|
|
708
|
+
|
|
709
|
+
def _validate_python_write(
|
|
710
|
+
self,
|
|
711
|
+
target: Path,
|
|
712
|
+
before: list[_Diagnostic],
|
|
713
|
+
attempted_text: str,
|
|
714
|
+
*,
|
|
715
|
+
original_bytes: bytes | None,
|
|
716
|
+
original_mode: int | None,
|
|
717
|
+
) -> list[str]:
|
|
718
|
+
"""Validate Python output, roll it back, and report new diagnostics."""
|
|
719
|
+
if target.suffix.lower() not in _PYTHON_SUFFIXES:
|
|
720
|
+
return []
|
|
721
|
+
after, warnings = self._python_diagnostics(target)
|
|
722
|
+
failures = [
|
|
723
|
+
diagnostic for diagnostic in after if diagnostic.code in _VALIDATOR_FAILURES
|
|
724
|
+
]
|
|
725
|
+
if failures:
|
|
726
|
+
warnings = [
|
|
727
|
+
*warnings,
|
|
728
|
+
*(f"{item.validator}: {item.message}" for item in failures),
|
|
729
|
+
]
|
|
730
|
+
checked = [
|
|
731
|
+
diagnostic
|
|
732
|
+
for diagnostic in after
|
|
733
|
+
if diagnostic.code not in _VALIDATOR_FAILURES
|
|
734
|
+
]
|
|
735
|
+
remaining = Counter(diagnostic.key for diagnostic in checked)
|
|
736
|
+
remaining.subtract(Counter(diagnostic.key for diagnostic in before))
|
|
737
|
+
new_keys = {key for key, count in remaining.items() if count > 0}
|
|
738
|
+
if not new_keys:
|
|
739
|
+
return warnings
|
|
740
|
+
if original_bytes is None:
|
|
741
|
+
target.unlink(missing_ok=True)
|
|
742
|
+
else:
|
|
743
|
+
self._write_atomic(target, original_bytes, mode=original_mode)
|
|
744
|
+
reported = [diagnostic for diagnostic in after if diagnostic.key in new_keys]
|
|
745
|
+
lines = [f"validation failed for {target}; the change was rolled back."]
|
|
746
|
+
for diagnostic in reported[:_MAX_REPORTED_DIAGNOSTICS]:
|
|
747
|
+
location = (
|
|
748
|
+
f"{target}:{diagnostic.line}"
|
|
749
|
+
if diagnostic.line is not None
|
|
750
|
+
else str(target)
|
|
751
|
+
)
|
|
752
|
+
code = f" [{diagnostic.code}]" if diagnostic.code else ""
|
|
753
|
+
lines.append(
|
|
754
|
+
f"{location}: {diagnostic.validator}{code}: {diagnostic.message}"
|
|
755
|
+
+ _source_preview(attempted_text, diagnostic.line)
|
|
756
|
+
)
|
|
757
|
+
omitted = len(reported) - _MAX_REPORTED_DIAGNOSTICS
|
|
758
|
+
if omitted > 0:
|
|
759
|
+
lines.append(f"... {omitted} more diagnostics omitted")
|
|
760
|
+
raise ToolError("\n".join(lines))
|
|
761
|
+
|
|
762
|
+
def write(self, path: str, content: str) -> dict[str, Any]:
|
|
763
|
+
"""Create or fully replace a UTF-8 text file."""
|
|
764
|
+
if not isinstance(content, str):
|
|
765
|
+
raise ToolError("content must be a string")
|
|
766
|
+
if "\x00" in content:
|
|
767
|
+
# `read` rejects NUL bytes as binary, so accepting them here would
|
|
768
|
+
# create a file the agent can write but never read back.
|
|
769
|
+
raise ToolError("binary files are not supported by write")
|
|
770
|
+
target = self._path(path, operation="write")
|
|
771
|
+
if target.exists() and target.is_dir():
|
|
772
|
+
raise ToolError(f"path is a directory: {target}")
|
|
773
|
+
try:
|
|
774
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
775
|
+
except OSError as exc:
|
|
776
|
+
raise ToolError(f"cannot create parent directory: {exc}") from exc
|
|
777
|
+
existed = target.exists()
|
|
778
|
+
if existed and target.stat().st_size > self.max_file_bytes:
|
|
779
|
+
# `edit` and `append_file` refuse an oversized file; reading it to
|
|
780
|
+
# mirror the old bytes for validation rollback turned a legitimate
|
|
781
|
+
# truncate into a MemoryError that killed the process.
|
|
782
|
+
raise ToolError("file is too large for write")
|
|
783
|
+
original_bytes = target.read_bytes() if existed else None
|
|
784
|
+
newline = (
|
|
785
|
+
_detect_newline_bytes(original_bytes)
|
|
786
|
+
if original_bytes is not None
|
|
787
|
+
else _detect_newline_optional(content)
|
|
788
|
+
)
|
|
789
|
+
if newline is not None:
|
|
790
|
+
# Keep the file's physical style instead of letting a model's LF
|
|
791
|
+
# payload churn every line of a CRLF checkout.
|
|
792
|
+
content = _normalize_newlines(content, newline)
|
|
793
|
+
if original_bytes is not None and original_bytes.startswith(b"\xef\xbb\xbf"):
|
|
794
|
+
# `read` decodes with utf-8-sig, so a rewrite must put the BOM back:
|
|
795
|
+
# dropping it silently changed the encoding of the whole file.
|
|
796
|
+
content = "\ufeff" + content
|
|
797
|
+
try:
|
|
798
|
+
encoded = content.encode("utf-8")
|
|
799
|
+
except UnicodeEncodeError as exc:
|
|
800
|
+
raise ToolError(f"content is not valid UTF-8 text: {exc}") from exc
|
|
801
|
+
if len(encoded) > self.max_file_bytes:
|
|
802
|
+
raise ToolError(
|
|
803
|
+
f"content is too large for write: {len(encoded)} bytes exceeds "
|
|
804
|
+
f"tools.max_file_bytes ({self.max_file_bytes})"
|
|
805
|
+
)
|
|
806
|
+
# Never carry setuid/setgid bits onto rewritten content: the kernel
|
|
807
|
+
# clears them on a normal write, and restoring them would hand the new
|
|
808
|
+
# bytes the old privilege.
|
|
809
|
+
mode = target.stat().st_mode & 0o777 if existed else None
|
|
810
|
+
before: list[_Diagnostic] = []
|
|
811
|
+
if existed:
|
|
812
|
+
before, _ = self._python_diagnostics(target)
|
|
813
|
+
self._write_atomic(target, content, mode=mode)
|
|
814
|
+
warnings = self._validate_python_write(
|
|
815
|
+
target,
|
|
816
|
+
before,
|
|
817
|
+
content,
|
|
818
|
+
original_bytes=original_bytes,
|
|
819
|
+
original_mode=mode,
|
|
820
|
+
)
|
|
821
|
+
result: dict[str, Any] = {
|
|
822
|
+
"status": "success",
|
|
823
|
+
"path": str(target),
|
|
824
|
+
"bytes": len(encoded),
|
|
825
|
+
"created": not existed,
|
|
826
|
+
"line_ending": _line_ending_name(newline),
|
|
827
|
+
}
|
|
828
|
+
if warnings:
|
|
829
|
+
result["warnings"] = warnings
|
|
830
|
+
return result
|
|
831
|
+
|
|
832
|
+
def append_file(self, path: str, content: str) -> dict[str, Any]:
|
|
833
|
+
"""Append UTF-8 text at EOF, preserving the existing line ending."""
|
|
834
|
+
if not isinstance(content, str):
|
|
835
|
+
raise ToolError("content must be a string")
|
|
836
|
+
try:
|
|
837
|
+
encoded = content.encode("utf-8")
|
|
838
|
+
except UnicodeEncodeError as exc:
|
|
839
|
+
raise ToolError(f"content is not valid UTF-8 text: {exc}") from exc
|
|
840
|
+
if b"\x00" in encoded:
|
|
841
|
+
raise ToolError("binary files are not supported by append_file")
|
|
842
|
+
target = self._path(path, operation="write")
|
|
843
|
+
if target.exists() and target.is_dir():
|
|
844
|
+
raise ToolError(f"path is a directory: {target}")
|
|
845
|
+
try:
|
|
846
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
847
|
+
except OSError as exc:
|
|
848
|
+
raise ToolError(f"cannot create parent directory: {exc}") from exc
|
|
849
|
+
existed = target.exists()
|
|
850
|
+
if existed and target.stat().st_size > self.max_file_bytes:
|
|
851
|
+
raise ToolError("file is too large for append_file")
|
|
852
|
+
original_bytes = target.read_bytes() if existed else None
|
|
853
|
+
if original_bytes is None:
|
|
854
|
+
original_text = ""
|
|
855
|
+
newline = _detect_newline(content)
|
|
856
|
+
else:
|
|
857
|
+
try:
|
|
858
|
+
original_text = original_bytes.decode("utf-8")
|
|
859
|
+
except UnicodeDecodeError as exc:
|
|
860
|
+
raise ToolError(
|
|
861
|
+
f"file is not valid UTF-8 text: {target}: {exc}"
|
|
862
|
+
) from exc
|
|
863
|
+
if "\x00" in original_text:
|
|
864
|
+
raise ToolError("binary files are not supported by append_file")
|
|
865
|
+
newline = (
|
|
866
|
+
_detect_newline(original_text)
|
|
867
|
+
if original_text
|
|
868
|
+
else _detect_newline(content)
|
|
869
|
+
)
|
|
870
|
+
separator = ""
|
|
871
|
+
if (
|
|
872
|
+
original_text
|
|
873
|
+
and content
|
|
874
|
+
and not original_text.endswith(("\r", "\n"))
|
|
875
|
+
and not content.startswith(("\r", "\n"))
|
|
876
|
+
):
|
|
877
|
+
# Appending to a file without a final terminator must not glue the
|
|
878
|
+
# first new line onto the old last line.
|
|
879
|
+
separator = newline
|
|
880
|
+
appended = separator + _normalize_newlines(content, newline)
|
|
881
|
+
updated = original_text + appended
|
|
882
|
+
try:
|
|
883
|
+
updated_bytes = updated.encode("utf-8")
|
|
884
|
+
except UnicodeEncodeError as exc:
|
|
885
|
+
raise ToolError(f"appended content is not valid UTF-8 text: {exc}") from exc
|
|
886
|
+
if len(updated_bytes) > self.max_file_bytes:
|
|
887
|
+
raise ToolError(
|
|
888
|
+
f"appended file is too large: {len(updated_bytes)} bytes exceeds "
|
|
889
|
+
f"tools.max_file_bytes ({self.max_file_bytes})"
|
|
890
|
+
)
|
|
891
|
+
mode = target.stat().st_mode & 0o777 if existed else None
|
|
892
|
+
before: list[_Diagnostic] = []
|
|
893
|
+
if existed:
|
|
894
|
+
before, _ = self._python_diagnostics(target)
|
|
895
|
+
self._write_atomic(target, updated, mode=mode)
|
|
896
|
+
warnings = self._validate_python_write(
|
|
897
|
+
target,
|
|
898
|
+
before,
|
|
899
|
+
updated,
|
|
900
|
+
original_bytes=original_bytes,
|
|
901
|
+
original_mode=mode,
|
|
902
|
+
)
|
|
903
|
+
result: dict[str, Any] = {
|
|
904
|
+
"status": "success",
|
|
905
|
+
"path": str(target),
|
|
906
|
+
"created": not existed,
|
|
907
|
+
"appended_bytes": len(appended.encode("utf-8")),
|
|
908
|
+
"bytes": len(updated_bytes),
|
|
909
|
+
"line_ending": _line_ending_name(newline),
|
|
910
|
+
}
|
|
911
|
+
if warnings:
|
|
912
|
+
result["warnings"] = warnings
|
|
913
|
+
return result
|
|
914
|
+
|
|
915
|
+
def _read_for_edit(self, target: Path) -> tuple[str, str]:
|
|
916
|
+
"""Read an editable UTF-8 file and its dominant line ending."""
|
|
917
|
+
if not target.is_file():
|
|
918
|
+
raise ToolError(f"file not found: {target}")
|
|
919
|
+
if target.stat().st_size > self.max_file_bytes:
|
|
920
|
+
raise ToolError("file is too large for edit")
|
|
921
|
+
with target.open("r", encoding="utf-8", errors="strict", newline="") as handle:
|
|
922
|
+
try:
|
|
923
|
+
original = handle.read()
|
|
924
|
+
except UnicodeDecodeError as exc:
|
|
925
|
+
raise ToolError(
|
|
926
|
+
f"file is not valid UTF-8 text: {target}: {exc}"
|
|
927
|
+
) from exc
|
|
928
|
+
if "\x00" in original:
|
|
929
|
+
raise ToolError("binary files are not supported by edit")
|
|
930
|
+
return original, _detect_newline(original)
|
|
931
|
+
|
|
932
|
+
def edit(
|
|
933
|
+
self,
|
|
934
|
+
path: str,
|
|
935
|
+
old_text: str | None = None,
|
|
936
|
+
new_text: str | None = None,
|
|
937
|
+
replace_all: bool = False,
|
|
938
|
+
start_line: int | None = None,
|
|
939
|
+
end_line: int | None = None,
|
|
940
|
+
) -> dict[str, Any]:
|
|
941
|
+
"""Replace a line range with a patch block, or fall back to exact text.
|
|
942
|
+
|
|
943
|
+
Line mode (``start_line``/``end_line`` plus ``new_text``) is the primary
|
|
944
|
+
interface: it cannot silently match a second occurrence elsewhere in
|
|
945
|
+
the file. Exact ``old_text`` remains available for files whose line
|
|
946
|
+
numbers are not meaningful, and its failure now carries the closest
|
|
947
|
+
region and a diff instead of a bare ``not found``.
|
|
948
|
+
"""
|
|
949
|
+
if not isinstance(new_text, str):
|
|
950
|
+
raise ToolError("new_text must be a string")
|
|
951
|
+
if "\x00" in new_text:
|
|
952
|
+
# `read` and `append_file` reject NUL bytes as binary, so accepting
|
|
953
|
+
# one here would produce a file the agent can never read back.
|
|
954
|
+
raise ToolError("binary files are not supported by edit")
|
|
955
|
+
if not isinstance(replace_all, bool):
|
|
956
|
+
raise ToolError("replace_all must be a boolean")
|
|
957
|
+
for name, value in (("start_line", start_line), ("end_line", end_line)):
|
|
958
|
+
if value is not None and (
|
|
959
|
+
isinstance(value, bool) or not isinstance(value, int)
|
|
960
|
+
):
|
|
961
|
+
raise ToolError(f"{name} must be an integer or null")
|
|
962
|
+
target = self._path(path, operation="write")
|
|
963
|
+
original, default_newline = self._read_for_edit(target)
|
|
964
|
+
bom = "\ufeff" if original.startswith("\ufeff") else ""
|
|
965
|
+
if bom:
|
|
966
|
+
# `read` strips the BOM, so line 1 must match what the model saw and
|
|
967
|
+
# the marker is written back verbatim instead of being dropped.
|
|
968
|
+
original = original[1:]
|
|
969
|
+
line_mode = start_line is not None or end_line is not None
|
|
970
|
+
if line_mode:
|
|
971
|
+
if old_text is not None:
|
|
972
|
+
raise ToolError(
|
|
973
|
+
"provide either start_line/end_line or old_text, not both"
|
|
974
|
+
)
|
|
975
|
+
if replace_all:
|
|
976
|
+
raise ToolError("replace_all only applies to exact-text edits")
|
|
977
|
+
if start_line is None:
|
|
978
|
+
raise ToolError("start_line is required when end_line is set")
|
|
979
|
+
if end_line is None:
|
|
980
|
+
raise ToolError("end_line is required when start_line is set")
|
|
981
|
+
lines = _physical_lines(original)
|
|
982
|
+
# ``end_line == start_line - 1`` is an empty range: insert before
|
|
983
|
+
# start_line. This gives the model a safe way to add a line without
|
|
984
|
+
# guessing adjacent text.
|
|
985
|
+
if end_line < start_line - 1:
|
|
986
|
+
raise ToolError(
|
|
987
|
+
f"end_line {end_line} must be at least start_line - 1 "
|
|
988
|
+
f"({start_line - 1})"
|
|
989
|
+
)
|
|
990
|
+
if start_line < 1:
|
|
991
|
+
raise ToolError("start_line must be at least 1")
|
|
992
|
+
if start_line > len(lines) + 1:
|
|
993
|
+
raise ToolError(
|
|
994
|
+
f"start_line {start_line} is beyond EOF: file has "
|
|
995
|
+
f"{len(lines)} lines; {_range_hint(lines, start_line, end_line)}"
|
|
996
|
+
)
|
|
997
|
+
if end_line > len(lines):
|
|
998
|
+
raise ToolError(
|
|
999
|
+
f"end_line {end_line} is beyond EOF: file has {len(lines)} "
|
|
1000
|
+
f"lines; {_range_hint(lines, start_line, end_line)}"
|
|
1001
|
+
)
|
|
1002
|
+
start_index = start_line - 1
|
|
1003
|
+
end_index = end_line
|
|
1004
|
+
prefix = "".join(lines[:start_index])
|
|
1005
|
+
span = "".join(lines[start_index:end_index])
|
|
1006
|
+
# An unterminated last line carries no ending of its own, so it
|
|
1007
|
+
# inherits the file's; `_detect_newline` would fall back to LF and
|
|
1008
|
+
# write LF lines into a CRLF file.
|
|
1009
|
+
span_newline = _detect_newline_optional(span) or default_newline
|
|
1010
|
+
replacement = _normalize_newlines(new_text, span_newline)
|
|
1011
|
+
if replacement:
|
|
1012
|
+
if (
|
|
1013
|
+
prefix
|
|
1014
|
+
and not prefix.endswith(("\r", "\n"))
|
|
1015
|
+
and not replacement.startswith(("\r", "\n"))
|
|
1016
|
+
):
|
|
1017
|
+
# The insertion point follows a final line that has no
|
|
1018
|
+
# terminator; without one the new text is glued onto it.
|
|
1019
|
+
replacement = span_newline + replacement
|
|
1020
|
+
region_has_terminator = span.endswith(("\r", "\n"))
|
|
1021
|
+
following_exists = end_index < len(lines)
|
|
1022
|
+
if (
|
|
1023
|
+
region_has_terminator or following_exists
|
|
1024
|
+
) and not replacement.endswith(("\r", "\n")):
|
|
1025
|
+
# A patch block is line-oriented. Without this, replacing
|
|
1026
|
+
# line 7 of a 20-line file would glue line 8 onto it.
|
|
1027
|
+
replacement += span_newline
|
|
1028
|
+
updated = prefix + replacement + "".join(lines[end_index:])
|
|
1029
|
+
lines_removed = end_index - start_index
|
|
1030
|
+
lines_added = len(new_text.splitlines())
|
|
1031
|
+
else:
|
|
1032
|
+
if not isinstance(old_text, str) or not old_text:
|
|
1033
|
+
raise ToolError(
|
|
1034
|
+
"old_text must be a non-empty string when start_line/end_line "
|
|
1035
|
+
"are omitted"
|
|
1036
|
+
)
|
|
1037
|
+
# Normalize the search text's newlines, then match any physical
|
|
1038
|
+
# ending in the file. This lets an LF snippet edit a CRLF file.
|
|
1039
|
+
needle = old_text.replace("\r\n", "\n").replace("\r", "\n")
|
|
1040
|
+
pattern = re.compile(
|
|
1041
|
+
r"(?:\r\n|\r|\n)".join(re.escape(part) for part in needle.split("\n"))
|
|
1042
|
+
)
|
|
1043
|
+
# Do not materialize every match: a one-character needle in a
|
|
1044
|
+
# multi-megabyte file would allocate millions of match objects even
|
|
1045
|
+
# when only the first occurrence is replaced.
|
|
1046
|
+
first = pattern.search(original)
|
|
1047
|
+
if first is None:
|
|
1048
|
+
raise ToolError(_not_found_message(target, original, old_text))
|
|
1049
|
+
second = pattern.search(original, first.end())
|
|
1050
|
+
if second is not None and not replace_all:
|
|
1051
|
+
locations = [
|
|
1052
|
+
_line_number_at(original, match.start())
|
|
1053
|
+
for match in (first, second, pattern.search(original, second.end()))
|
|
1054
|
+
if match is not None
|
|
1055
|
+
]
|
|
1056
|
+
raise ToolError(
|
|
1057
|
+
f"old_text occurs more than once (first at lines "
|
|
1058
|
+
f"{', '.join(str(line) for line in locations)}); make it "
|
|
1059
|
+
"unique, use start_line/end_line, or set replace_all"
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Replace only the matched spans so untouched lines keep their own
|
|
1063
|
+
# endings.
|
|
1064
|
+
normalized_new = _normalize_newlines(new_text, "\n")
|
|
1065
|
+
|
|
1066
|
+
def replacement_for(span: str) -> str:
|
|
1067
|
+
if "\r\n" in span:
|
|
1068
|
+
span_newline = "\r\n"
|
|
1069
|
+
elif "\r" in span:
|
|
1070
|
+
span_newline = "\r"
|
|
1071
|
+
elif "\n" in span:
|
|
1072
|
+
span_newline = "\n"
|
|
1073
|
+
else:
|
|
1074
|
+
span_newline = default_newline
|
|
1075
|
+
return normalized_new.replace("\n", span_newline)
|
|
1076
|
+
|
|
1077
|
+
if replace_all:
|
|
1078
|
+
pieces: list[str] = []
|
|
1079
|
+
last = 0
|
|
1080
|
+
count = 0
|
|
1081
|
+
lines_removed = 0
|
|
1082
|
+
lines_added = 0
|
|
1083
|
+
for count, match in enumerate(pattern.finditer(original), start=1):
|
|
1084
|
+
replacement = replacement_for(match.group(0))
|
|
1085
|
+
pieces.append(original[last : match.start()])
|
|
1086
|
+
pieces.append(replacement)
|
|
1087
|
+
lines_removed += len(match.group(0).splitlines())
|
|
1088
|
+
lines_added += len(replacement.splitlines())
|
|
1089
|
+
last = match.end()
|
|
1090
|
+
pieces.append(original[last:])
|
|
1091
|
+
updated = "".join(pieces)
|
|
1092
|
+
else:
|
|
1093
|
+
replacement = replacement_for(first.group(0))
|
|
1094
|
+
updated = (
|
|
1095
|
+
original[: first.start()] + replacement + original[first.end() :]
|
|
1096
|
+
)
|
|
1097
|
+
count = 1
|
|
1098
|
+
lines_removed = len(first.group(0).splitlines())
|
|
1099
|
+
lines_added = len(replacement.splitlines())
|
|
1100
|
+
if bom:
|
|
1101
|
+
updated = bom + updated
|
|
1102
|
+
try:
|
|
1103
|
+
encoded = updated.encode("utf-8")
|
|
1104
|
+
except UnicodeEncodeError as exc:
|
|
1105
|
+
raise ToolError(f"edited content is not valid UTF-8 text: {exc}") from exc
|
|
1106
|
+
if len(encoded) > self.max_file_bytes:
|
|
1107
|
+
raise ToolError(
|
|
1108
|
+
f"edited file is too large: {len(encoded)} bytes exceeds "
|
|
1109
|
+
f"tools.max_file_bytes ({self.max_file_bytes})"
|
|
1110
|
+
)
|
|
1111
|
+
mode = target.stat().st_mode & 0o777
|
|
1112
|
+
# The rollback image has to be the original bytes, BOM included.
|
|
1113
|
+
original_bytes = (bom + original).encode("utf-8")
|
|
1114
|
+
before, _ = self._python_diagnostics(target)
|
|
1115
|
+
self._write_atomic(target, updated, mode=mode)
|
|
1116
|
+
warnings = self._validate_python_write(
|
|
1117
|
+
target,
|
|
1118
|
+
before,
|
|
1119
|
+
updated,
|
|
1120
|
+
original_bytes=original_bytes,
|
|
1121
|
+
original_mode=mode,
|
|
1122
|
+
)
|
|
1123
|
+
result: dict[str, Any] = {
|
|
1124
|
+
"status": "success",
|
|
1125
|
+
"path": str(target),
|
|
1126
|
+
"mode": "lines" if line_mode else "exact",
|
|
1127
|
+
"lines_added": lines_added,
|
|
1128
|
+
"lines_removed": lines_removed,
|
|
1129
|
+
"bytes": len(encoded),
|
|
1130
|
+
"line_ending": _line_ending_name(default_newline),
|
|
1131
|
+
}
|
|
1132
|
+
if line_mode:
|
|
1133
|
+
result["start_line"] = start_line
|
|
1134
|
+
result["end_line"] = end_line
|
|
1135
|
+
else:
|
|
1136
|
+
result["replacements"] = count if replace_all else 1
|
|
1137
|
+
if warnings:
|
|
1138
|
+
result["warnings"] = warnings
|
|
1139
|
+
return result
|