co-agent-coder 0.1.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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- coagent/ui/render.py +422 -0
coagent/tools/fs.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""Filesystem tools: read, write, list, grep, and SEARCH/REPLACE editing.
|
|
2
|
+
|
|
3
|
+
Design constraints honoured here:
|
|
4
|
+
|
|
5
|
+
* **Path jail on reads and writes** (DESIGN §6.2, FR-TOL-2) — every path is
|
|
6
|
+
resolved and checked against `Policy` before it is touched.
|
|
7
|
+
* **Read before write** (FR-AGT-4) — `edit_file`/`write_file` (rewrite mode)
|
|
8
|
+
refuse a path the session has not read. This kills the hallucinated-edit
|
|
9
|
+
class of failure for the price of a dict lookup.
|
|
10
|
+
* **Unique SEARCH match** (DESIGN §6.1) — zero matches returns the closest
|
|
11
|
+
region with line numbers; more than one match is rejected with the offsets.
|
|
12
|
+
* **Full rewrite only for new files or files under 200 lines** — large rewrites
|
|
13
|
+
are the biggest source of catastrophic diffs with small models.
|
|
14
|
+
* **Deterministic truncation** (FR-CTX-3) — head/tail with line numbers, never a
|
|
15
|
+
silent mid-file drop.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import difflib
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..safety.policy import Policy, Verdict
|
|
27
|
+
from .base import ArgSpec, BaseTool, SafetyClass, ToolError, ToolResult
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"EditFileTool",
|
|
31
|
+
"GrepTool",
|
|
32
|
+
"ListDirTool",
|
|
33
|
+
"ReadFileTool",
|
|
34
|
+
"WriteFileTool",
|
|
35
|
+
"FileSession",
|
|
36
|
+
"truncate_head_tail",
|
|
37
|
+
"apply_search_replace",
|
|
38
|
+
"MAX_REWRITE_LINES",
|
|
39
|
+
"MAX_READ_LINES",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
MAX_REWRITE_LINES = 200
|
|
43
|
+
MAX_READ_LINES = 2000
|
|
44
|
+
MAX_READ_BYTES = 512 * 1024
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FileSession:
|
|
49
|
+
"""Per-session bookkeeping shared by the filesystem tools."""
|
|
50
|
+
|
|
51
|
+
repo_root: Path
|
|
52
|
+
policy: Policy
|
|
53
|
+
read: set[Path] = field(default_factory=set)
|
|
54
|
+
written: set[Path] = field(default_factory=set)
|
|
55
|
+
#: Set by `build_registry`; the notebook exec tool needs the run envelope
|
|
56
|
+
#: (timeouts, scrubbed env, scratch dir) that `ExecConfig` carries.
|
|
57
|
+
exec_cfg: Any = None
|
|
58
|
+
#: Set by `build_registry`: the shared store that applies the §3.5 size policy.
|
|
59
|
+
artifact_store: Any = None
|
|
60
|
+
#: Optional `(kind, payload, mime) -> None` renderer, injected by the notebook
|
|
61
|
+
#: UI. `None` means headless: artifacts are referenced, never drawn.
|
|
62
|
+
render: Any = None
|
|
63
|
+
|
|
64
|
+
def __post_init__(self) -> None:
|
|
65
|
+
self.repo_root = Path(self.repo_root).resolve()
|
|
66
|
+
|
|
67
|
+
def resolve(self, path: str) -> Path:
|
|
68
|
+
candidate = Path(path)
|
|
69
|
+
if not candidate.is_absolute():
|
|
70
|
+
candidate = self.repo_root / candidate
|
|
71
|
+
try:
|
|
72
|
+
return candidate.resolve()
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
raise ToolError(f"cannot resolve path {path!r}: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
def rel(self, path: Path) -> str:
|
|
77
|
+
"""Repo-relative POSIX path. Colab is Linux; a stable separator keeps the
|
|
78
|
+
model's `path` arguments consistent across dev machines."""
|
|
79
|
+
try:
|
|
80
|
+
return path.relative_to(self.repo_root).as_posix()
|
|
81
|
+
except ValueError:
|
|
82
|
+
return path.as_posix()
|
|
83
|
+
|
|
84
|
+
def mark_read(self, path: Path) -> None:
|
|
85
|
+
self.read.add(path)
|
|
86
|
+
|
|
87
|
+
def mark_written(self, path: Path) -> None:
|
|
88
|
+
self.written.add(path)
|
|
89
|
+
self.read.add(path)
|
|
90
|
+
|
|
91
|
+
def was_read(self, path: Path) -> bool:
|
|
92
|
+
return path in self.read or path in self.written
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def truncate_head_tail(text: str, *, max_lines: int = MAX_READ_LINES, start_line: int = 1) -> tuple[str, bool]:
|
|
96
|
+
"""Head/tail truncation with line numbers; never a silent mid-file cut."""
|
|
97
|
+
lines = text.splitlines()
|
|
98
|
+
if len(lines) <= max_lines:
|
|
99
|
+
numbered = [f"{start_line + i:>6}\t{line}" for i, line in enumerate(lines)]
|
|
100
|
+
return "\n".join(numbered), False
|
|
101
|
+
head_n = max_lines * 6 // 10
|
|
102
|
+
tail_n = max_lines - head_n
|
|
103
|
+
head = lines[:head_n]
|
|
104
|
+
tail = lines[-tail_n:]
|
|
105
|
+
omitted = len(lines) - head_n - tail_n
|
|
106
|
+
out = [f"{start_line + i:>6}\t{line}" for i, line in enumerate(head)]
|
|
107
|
+
out.append(f" … [{omitted} lines omitted]")
|
|
108
|
+
out.extend(
|
|
109
|
+
f"{start_line + len(lines) - tail_n + i:>6}\t{line}" for i, line in enumerate(tail)
|
|
110
|
+
)
|
|
111
|
+
return "\n".join(out), True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _normalize_ws(text: str) -> str:
|
|
115
|
+
return "\n".join(line.strip() for line in text.strip().splitlines())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def apply_search_replace(source: str, search: str, replace: str, *, path: str = "") -> tuple[str, int]:
|
|
119
|
+
"""Apply one SEARCH/REPLACE block, returning new text and the 1-based line.
|
|
120
|
+
|
|
121
|
+
Exact match first; then a whitespace/indentation-insensitive fuzzy pass. Any
|
|
122
|
+
ambiguity is an error, never a guess.
|
|
123
|
+
"""
|
|
124
|
+
if not search:
|
|
125
|
+
raise ToolError("the `search` block is empty; provide the exact text to replace.")
|
|
126
|
+
|
|
127
|
+
occurrences = source.count(search)
|
|
128
|
+
if occurrences == 1:
|
|
129
|
+
idx = source.index(search)
|
|
130
|
+
return source[:idx] + replace + source[idx + len(search) :], source[:idx].count("\n") + 1
|
|
131
|
+
if occurrences > 1:
|
|
132
|
+
starts = [m.start() for m in re.finditer(re.escape(search), source)]
|
|
133
|
+
lines = [source[:s].count("\n") + 1 for s in starts]
|
|
134
|
+
raise ToolError(
|
|
135
|
+
f"the `search` block matches {occurrences} times in {path or 'the file'} "
|
|
136
|
+
f"(lines {', '.join(map(str, lines))}). Add surrounding context so it is unique."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Fuzzy pass: compare whitespace-normalized lines.
|
|
140
|
+
src_lines = source.splitlines(keepends=True)
|
|
141
|
+
search_norm = _normalize_ws(search)
|
|
142
|
+
search_len = len(search.strip().splitlines())
|
|
143
|
+
best: tuple[float, int, int] | None = None
|
|
144
|
+
for start in range(len(src_lines)):
|
|
145
|
+
window = "".join(src_lines[start : start + search_len])
|
|
146
|
+
if not window:
|
|
147
|
+
break
|
|
148
|
+
ratio = difflib.SequenceMatcher(None, search_norm, _normalize_ws(window)).ratio()
|
|
149
|
+
if best is None or ratio > best[0]:
|
|
150
|
+
best = (ratio, start, start + search_len)
|
|
151
|
+
|
|
152
|
+
if best is not None and best[0] >= 0.75:
|
|
153
|
+
ratio, start, end = best
|
|
154
|
+
region_lines = [
|
|
155
|
+
f"{i + 1:>6}\t{src_lines[i].rstrip()}" for i in range(start, min(end, len(src_lines)))
|
|
156
|
+
]
|
|
157
|
+
raise ToolError(
|
|
158
|
+
f"no exact match in {path or 'the file'}, but a {ratio:.0%} similar region exists at "
|
|
159
|
+
f"lines {start + 1}-{end}. Re-issue the edit with this exact text:\n"
|
|
160
|
+
+ "\n".join(region_lines)
|
|
161
|
+
)
|
|
162
|
+
raise ToolError(
|
|
163
|
+
f"the `search` block does not appear in {path or 'the file'}. "
|
|
164
|
+
"Read the file first and copy the text exactly, including indentation."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _PolicyTool(BaseTool):
|
|
169
|
+
"""Shared plumbing: a `FileSession`-backed resolver with jail checks."""
|
|
170
|
+
|
|
171
|
+
session: FileSession
|
|
172
|
+
|
|
173
|
+
def __init__(self, session: FileSession) -> None:
|
|
174
|
+
super().__init__(session)
|
|
175
|
+
self.session = session
|
|
176
|
+
|
|
177
|
+
def _read_path(self, path: str) -> Path:
|
|
178
|
+
resolved = self.session.resolve(path)
|
|
179
|
+
decision = self.session.policy.check_read(resolved)
|
|
180
|
+
if decision.verdict is Verdict.DENY:
|
|
181
|
+
raise ToolError(decision.reason)
|
|
182
|
+
return resolved
|
|
183
|
+
|
|
184
|
+
def _write_path(self, path: str) -> Path:
|
|
185
|
+
resolved = self.session.resolve(path)
|
|
186
|
+
decision = self.session.policy.check_write(resolved)
|
|
187
|
+
if decision.verdict is Verdict.DENY:
|
|
188
|
+
raise ToolError(decision.reason)
|
|
189
|
+
if decision.verdict is Verdict.ASK:
|
|
190
|
+
# Approvals are resolved by the loop before the tool runs; reaching
|
|
191
|
+
# here means the loop skipped the gate.
|
|
192
|
+
raise ToolError(decision.reason)
|
|
193
|
+
return resolved
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ReadFileTool(_PolicyTool):
|
|
197
|
+
name = "read_file"
|
|
198
|
+
description = (
|
|
199
|
+
"Read a text file from the repository. Returns line-numbered content. "
|
|
200
|
+
"Files over 2000 lines are head/tail truncated. Always read before editing."
|
|
201
|
+
)
|
|
202
|
+
safety = SafetyClass.READ
|
|
203
|
+
args = (
|
|
204
|
+
ArgSpec("path", "string", "Repo-relative path to the file."),
|
|
205
|
+
ArgSpec("start", "integer", "1-based first line to read.", required=False, default=1),
|
|
206
|
+
ArgSpec("end", "integer", "Last line to read (inclusive).", required=False, default=0),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def run(self, path: str, start: int = 1, end: int = 0) -> ToolResult:
|
|
210
|
+
resolved = self._read_path(path)
|
|
211
|
+
if not resolved.exists():
|
|
212
|
+
raise ToolError(f"{path} does not exist. Use list_dir to find the right path.")
|
|
213
|
+
if resolved.is_dir():
|
|
214
|
+
raise ToolError(f"{path} is a directory; use list_dir.")
|
|
215
|
+
if resolved.suffix.lower() == ".ipynb":
|
|
216
|
+
# JSON-notebook text is unreadable as line numbers and every byte of it
|
|
217
|
+
# is escaped source. Point at the tool that shows cells instead.
|
|
218
|
+
raise ToolError(
|
|
219
|
+
f"{path} is a notebook; use read_notebook to see its cells by id, "
|
|
220
|
+
"or edit_notebook to change one."
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
raw = resolved.read_bytes()
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
raise ToolError(f"cannot read {path}: {exc}") from exc
|
|
226
|
+
if b"\x00" in raw[:4096]:
|
|
227
|
+
raise ToolError(f"{path} looks binary; use a shell command or an artifact tool.")
|
|
228
|
+
if len(raw) > MAX_READ_BYTES:
|
|
229
|
+
raise ToolError(
|
|
230
|
+
f"{path} is {len(raw) / 1024:.0f} KB, over the {MAX_READ_BYTES // 1024} KB read cap. "
|
|
231
|
+
"Read a line range with start/end, or grep for what you need."
|
|
232
|
+
)
|
|
233
|
+
text = raw.decode("utf-8", errors="replace")
|
|
234
|
+
self.session.mark_read(resolved)
|
|
235
|
+
|
|
236
|
+
lines = text.splitlines()
|
|
237
|
+
offset = max(start, 1) - 1
|
|
238
|
+
if end and end >= start:
|
|
239
|
+
lines = lines[offset:end]
|
|
240
|
+
else:
|
|
241
|
+
text = "\n".join(lines[offset:])
|
|
242
|
+
body, truncated = truncate_head_tail(text, start_line=offset + 1)
|
|
243
|
+
return ToolResult.success(body, path=self.session.rel(resolved), truncated=truncated)
|
|
244
|
+
numbered = "\n".join(f"{offset + i + 1:>6}\t{ln}" for i, ln in enumerate(lines))
|
|
245
|
+
return ToolResult.success(numbered, path=self.session.rel(resolved))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class ListDirTool(_PolicyTool):
|
|
249
|
+
name = "list_dir"
|
|
250
|
+
description = "List a directory in the repository, one entry per line with sizes."
|
|
251
|
+
safety = SafetyClass.READ
|
|
252
|
+
args = (
|
|
253
|
+
ArgSpec("path", "string", "Repo-relative directory path.", required=False, default="."),
|
|
254
|
+
ArgSpec("depth", "integer", "Recursion depth (1-3).", required=False, default=1),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def run(self, path: str = ".", depth: int = 1) -> ToolResult:
|
|
258
|
+
resolved = self._read_path(path)
|
|
259
|
+
if not resolved.is_dir():
|
|
260
|
+
raise ToolError(f"{path} is not a directory.")
|
|
261
|
+
depth = max(1, min(depth, 3))
|
|
262
|
+
out: list[str] = []
|
|
263
|
+
self._walk(resolved, "", depth, out, cap=400)
|
|
264
|
+
self.session.mark_read(resolved)
|
|
265
|
+
if not out:
|
|
266
|
+
return ToolResult.success(f"{path} is empty.")
|
|
267
|
+
return ToolResult.success("\n".join(out), path=self.session.rel(resolved))
|
|
268
|
+
|
|
269
|
+
def _walk(self, root: Path, prefix: str, depth: int, out: list[str], cap: int) -> None:
|
|
270
|
+
if len(out) >= cap:
|
|
271
|
+
return
|
|
272
|
+
try:
|
|
273
|
+
entries = sorted(root.iterdir(), key=lambda p: (p.is_file(), p.name))
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
out.append(f"{prefix}[unreadable: {exc}]")
|
|
276
|
+
return
|
|
277
|
+
for entry in entries:
|
|
278
|
+
if len(out) >= cap:
|
|
279
|
+
out.append("… [listing truncated]")
|
|
280
|
+
return
|
|
281
|
+
if entry.name == ".git":
|
|
282
|
+
continue
|
|
283
|
+
if entry.is_dir():
|
|
284
|
+
out.append(f"{prefix}{entry.name}/")
|
|
285
|
+
if depth > 1:
|
|
286
|
+
self._walk(entry, prefix + " ", depth - 1, out, cap)
|
|
287
|
+
else:
|
|
288
|
+
try:
|
|
289
|
+
size = entry.stat().st_size
|
|
290
|
+
except OSError:
|
|
291
|
+
size = -1
|
|
292
|
+
out.append(f"{prefix}{entry.name} ({_human(size)})")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class GrepTool(_PolicyTool):
|
|
296
|
+
name = "grep"
|
|
297
|
+
description = (
|
|
298
|
+
"Search file contents with a Python regular expression. Returns matching "
|
|
299
|
+
"lines with line numbers. Use this instead of reading whole files."
|
|
300
|
+
)
|
|
301
|
+
safety = SafetyClass.READ
|
|
302
|
+
args = (
|
|
303
|
+
ArgSpec("pattern", "string", "Python regular expression."),
|
|
304
|
+
ArgSpec("path", "string", "File or directory to search.", required=False, default="."),
|
|
305
|
+
ArgSpec("glob", "string", "Only search files matching this glob, e.g. '*.py'.", required=False, default=""),
|
|
306
|
+
ArgSpec("max_matches", "integer", "Maximum matching lines to return.", required=False, default=80),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def run(self, pattern: str, path: str = ".", glob: str = "", max_matches: int = 80) -> ToolResult:
|
|
310
|
+
try:
|
|
311
|
+
regex = re.compile(pattern)
|
|
312
|
+
except re.error as exc:
|
|
313
|
+
raise ToolError(f"invalid regex {pattern!r}: {exc}") from exc
|
|
314
|
+
resolved = self._read_path(path)
|
|
315
|
+
max_matches = max(1, min(max_matches, 300))
|
|
316
|
+
files = [resolved] if resolved.is_file() else _iter_files(resolved, glob)
|
|
317
|
+
|
|
318
|
+
hits: list[str] = []
|
|
319
|
+
scanned = 0
|
|
320
|
+
for file in files:
|
|
321
|
+
if len(hits) >= max_matches:
|
|
322
|
+
break
|
|
323
|
+
decision = self.session.policy.check_read(file)
|
|
324
|
+
if decision.verdict is not Verdict.ALLOW:
|
|
325
|
+
continue
|
|
326
|
+
scanned += 1
|
|
327
|
+
try:
|
|
328
|
+
with file.open("r", encoding="utf-8", errors="replace") as fh:
|
|
329
|
+
for lineno, line in enumerate(fh, 1):
|
|
330
|
+
if regex.search(line):
|
|
331
|
+
rel = self.session.rel(file)
|
|
332
|
+
hits.append(f"{rel}:{lineno}: {line.rstrip()[:300]}")
|
|
333
|
+
if len(hits) >= max_matches:
|
|
334
|
+
break
|
|
335
|
+
except OSError:
|
|
336
|
+
continue
|
|
337
|
+
self.session.mark_read(resolved)
|
|
338
|
+
if not hits:
|
|
339
|
+
return ToolResult.success(
|
|
340
|
+
f"no matches for {pattern!r} in {path} ({scanned} files scanned)", matches=0
|
|
341
|
+
)
|
|
342
|
+
body = "\n".join(hits)
|
|
343
|
+
if len(hits) >= max_matches:
|
|
344
|
+
body += f"\n… [stopped at {max_matches} matches]"
|
|
345
|
+
return ToolResult.success(body, matches=len(hits))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class EditFileTool(_PolicyTool):
|
|
349
|
+
name = "edit_file"
|
|
350
|
+
description = (
|
|
351
|
+
"Replace one exact, unique block of text in an existing file (SEARCH/REPLACE). "
|
|
352
|
+
"The file must have been read this session. Prefer this over write_file."
|
|
353
|
+
)
|
|
354
|
+
safety = SafetyClass.WRITE
|
|
355
|
+
args = (
|
|
356
|
+
ArgSpec("path", "string", "Repo-relative path to edit."),
|
|
357
|
+
ArgSpec("search", "string", "Exact existing text to replace. Must match exactly once."),
|
|
358
|
+
ArgSpec("replace", "string", "Replacement text."),
|
|
359
|
+
ArgSpec("cite", "string", "Optional file:line hint you are anchoring on (advisory).", required=False, default=""),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def run(self, path: str, search: str, replace: str, cite: str = "") -> ToolResult:
|
|
363
|
+
resolved = self._write_path(path)
|
|
364
|
+
if not self.session.was_read(resolved):
|
|
365
|
+
raise ToolError(
|
|
366
|
+
f"{path} has not been read this session. Call read_file on it first, "
|
|
367
|
+
"then re-issue the edit with the exact text."
|
|
368
|
+
)
|
|
369
|
+
if not resolved.is_file():
|
|
370
|
+
raise ToolError(f"{path} does not exist; use write_file to create it.")
|
|
371
|
+
try:
|
|
372
|
+
original = resolved.read_text(encoding="utf-8")
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
raise ToolError(f"cannot read {path}: {exc}") from exc
|
|
375
|
+
|
|
376
|
+
updated, line = apply_search_replace(original, search, replace, path=path)
|
|
377
|
+
syntax_error = _syntax_check(resolved, updated)
|
|
378
|
+
if syntax_error:
|
|
379
|
+
raise ToolError(
|
|
380
|
+
f"the edit would leave {path} unparseable ({syntax_error}). "
|
|
381
|
+
"Re-check the indentation and syntax in your `replace` block."
|
|
382
|
+
)
|
|
383
|
+
try:
|
|
384
|
+
resolved.write_text(updated, encoding="utf-8")
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
raise ToolError(f"cannot write {path}: {exc}") from exc
|
|
387
|
+
self.session.mark_written(resolved)
|
|
388
|
+
|
|
389
|
+
diff = _unified_diff(original, updated, self.session.rel(resolved))
|
|
390
|
+
return ToolResult.success(
|
|
391
|
+
f"edited {path} at line {line}\n{diff}",
|
|
392
|
+
path=self.session.rel(resolved),
|
|
393
|
+
diff=diff,
|
|
394
|
+
line=line,
|
|
395
|
+
added=max(0, diff.count("\n+") - diff.count("\n+++")),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class WriteFileTool(_PolicyTool):
|
|
400
|
+
name = "write_file"
|
|
401
|
+
description = (
|
|
402
|
+
"Create a new file, or fully rewrite a file under 200 lines. "
|
|
403
|
+
"Large rewrites are refused; use edit_file for existing files."
|
|
404
|
+
)
|
|
405
|
+
safety = SafetyClass.WRITE
|
|
406
|
+
args = (
|
|
407
|
+
ArgSpec("path", "string", "Repo-relative path to write."),
|
|
408
|
+
ArgSpec("content", "string", "Full file content."),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def run(self, path: str, content: str) -> ToolResult:
|
|
412
|
+
resolved = self._write_path(path)
|
|
413
|
+
exists = resolved.exists()
|
|
414
|
+
if exists:
|
|
415
|
+
if not self.session.was_read(resolved):
|
|
416
|
+
raise ToolError(
|
|
417
|
+
f"{path} already exists and has not been read this session. "
|
|
418
|
+
"Read it and use edit_file, or read it first before rewriting."
|
|
419
|
+
)
|
|
420
|
+
try:
|
|
421
|
+
current_lines = len(resolved.read_text(encoding="utf-8").splitlines())
|
|
422
|
+
except OSError as exc:
|
|
423
|
+
raise ToolError(f"cannot read {path}: {exc}") from exc
|
|
424
|
+
if current_lines > MAX_REWRITE_LINES:
|
|
425
|
+
raise ToolError(
|
|
426
|
+
f"{path} is {current_lines} lines; full rewrites are only allowed under "
|
|
427
|
+
f"{MAX_REWRITE_LINES}. Use edit_file."
|
|
428
|
+
)
|
|
429
|
+
syntax_error = _syntax_check(resolved, content)
|
|
430
|
+
if syntax_error and not syntax_error.startswith("unknown"):
|
|
431
|
+
raise ToolError(f"{path} would not parse ({syntax_error}); fix the content first.")
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
resolved.write_text(content, encoding="utf-8")
|
|
436
|
+
except OSError as exc:
|
|
437
|
+
raise ToolError(f"cannot write {path}: {exc}") from exc
|
|
438
|
+
self.session.mark_written(resolved)
|
|
439
|
+
return ToolResult.success(
|
|
440
|
+
f"wrote {path} ({len(content.splitlines())} lines)",
|
|
441
|
+
path=self.session.rel(resolved),
|
|
442
|
+
created=not exists,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ----------------------------------------------------------------- helpers
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _iter_files(root: Path, glob: str) -> list[Path]:
|
|
450
|
+
pattern = glob or "*"
|
|
451
|
+
if root.is_file():
|
|
452
|
+
return [root]
|
|
453
|
+
return [p for p in sorted(root.rglob(pattern)) if p.is_file() and ".git" not in p.parts]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _syntax_check(path: Path, text: str) -> str:
|
|
457
|
+
"""Parse the file if we know how. Returns an error string, or '' if fine."""
|
|
458
|
+
suffix = path.suffix.lower()
|
|
459
|
+
if suffix == ".py":
|
|
460
|
+
import ast
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
ast.parse(text)
|
|
464
|
+
except SyntaxError as exc:
|
|
465
|
+
return f"{exc.msg} at line {exc.lineno}"
|
|
466
|
+
return ""
|
|
467
|
+
if suffix == ".json":
|
|
468
|
+
import json
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
json.loads(text)
|
|
472
|
+
except json.JSONDecodeError as exc:
|
|
473
|
+
return f"{exc.msg} at line {exc.lineno}"
|
|
474
|
+
return ""
|
|
475
|
+
if suffix in {".toml", ".tml"}:
|
|
476
|
+
try:
|
|
477
|
+
from ..config import tomllib
|
|
478
|
+
except ImportError: # pragma: no cover
|
|
479
|
+
return ""
|
|
480
|
+
try:
|
|
481
|
+
tomllib.loads(text)
|
|
482
|
+
except Exception as exc: # noqa: BLE001 - tomllib raises variants by version
|
|
483
|
+
return str(exc)
|
|
484
|
+
return ""
|
|
485
|
+
return "" # unknown language: the configured linter is the gate (FR-VER-4)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _unified_diff(before: str, after: str, name: str, context: int = 3) -> str:
|
|
489
|
+
diff = difflib.unified_diff(
|
|
490
|
+
before.splitlines(),
|
|
491
|
+
after.splitlines(),
|
|
492
|
+
fromfile=f"a/{name}",
|
|
493
|
+
tofile=f"b/{name}",
|
|
494
|
+
n=context,
|
|
495
|
+
lineterm="",
|
|
496
|
+
)
|
|
497
|
+
return "\n".join(diff)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _human(size: int) -> str:
|
|
501
|
+
if size < 0:
|
|
502
|
+
return "?"
|
|
503
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
504
|
+
if size < 1024 or unit == "GB":
|
|
505
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
506
|
+
size /= 1024.0
|
|
507
|
+
return f"{size:.1f} GB"
|