loom-code 0.1.1__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.
Files changed (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
loom_code/edit_tool.py ADDED
@@ -0,0 +1,635 @@
1
+ """Wrapped ``edit`` tool — surfaces post-edit file state so the
2
+ agent can self-correct when its replacement was malformed.
3
+
4
+ Why this exists: loomflow's stock ``edit_tool`` returns just
5
+ ``"edited X bytes (Y → Z)"``. The agent has no visibility into
6
+ what the file ACTUALLY looks like after the edit, so when the
7
+ model writes a malformed ``new_string`` (e.g. accidentally
8
+ preserving the old code AND inserting new code, observed in a
9
+ real REPL session on ``silently_swallow`` → got two ``except``
10
+ blocks coexisting), the agent assumes success and moves on.
11
+
12
+ This wrapper forwards every call to the underlying edit_tool and
13
+ then appends an EDIT PREVIEW window — the edited region plus
14
+ ±10 lines of surrounding context — to the tool result. Next-turn
15
+ the agent sees what the file looks like and can issue a
16
+ correcting edit instead of declaring victory.
17
+
18
+ Also runs an ``ast.parse`` check for ``.py`` files and surfaces
19
+ syntax errors as a clear warning (loomflow's edit_tool doesn't
20
+ validate; you can edit a Python file into a syntactically broken
21
+ state silently).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ast
27
+ import difflib
28
+ import json
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from loomflow import tool
33
+ from loomflow.tools import edit_tool as _loomflow_edit_tool
34
+ from loomflow.tools.registry import Tool
35
+
36
+ from .grep_tool import _as_bool
37
+ from .paths import is_within, resolve_path
38
+
39
+ # How many lines of context to show around the edited region in
40
+ # the EDIT PREVIEW. Chosen so the model can usually see the whole
41
+ # function being edited; bigger windows just bloat the tool result.
42
+ _CONTEXT_LINES = 10
43
+
44
+
45
+ def _find_edit_region(
46
+ old_text: str, new_text: str
47
+ ) -> tuple[int, int]:
48
+ """Return ``(start_line, end_line)`` (1-indexed, inclusive)
49
+ of the region in ``new_text`` that differs from ``old_text``.
50
+
51
+ Crude line-diff: walk both line lists in parallel, find the
52
+ first divergence, then walk backward from the ends to find
53
+ the last divergence. Good enough for the typical edit
54
+ (single contiguous change). For multi-region edits this just
55
+ picks the bounding window, which is the right thing to show
56
+ anyway.
57
+ """
58
+ old_lines = old_text.splitlines()
59
+ new_lines = new_text.splitlines()
60
+
61
+ # Find first divergence.
62
+ start = 0
63
+ while (
64
+ start < len(old_lines)
65
+ and start < len(new_lines)
66
+ and old_lines[start] == new_lines[start]
67
+ ):
68
+ start += 1
69
+
70
+ # Find last divergence — walk from the end inward.
71
+ o_end = len(old_lines) - 1
72
+ n_end = len(new_lines) - 1
73
+ while (
74
+ o_end >= start
75
+ and n_end >= start
76
+ and old_lines[o_end] == new_lines[n_end]
77
+ ):
78
+ o_end -= 1
79
+ n_end -= 1
80
+ return (start + 1, n_end + 1)
81
+
82
+
83
+ def _render_preview(
84
+ file_path: Path, new_text: str, start_line: int, end_line: int
85
+ ) -> str:
86
+ """Render the edited region + ±N lines of context with line
87
+ numbers. A ▸ marker on each line in the edit window so the
88
+ agent can quickly see what changed."""
89
+ lines = new_text.splitlines()
90
+ if not lines:
91
+ return ""
92
+ show_start = max(1, start_line - _CONTEXT_LINES)
93
+ show_end = min(len(lines), end_line + _CONTEXT_LINES)
94
+ out: list[str] = [
95
+ f"--- EDIT PREVIEW: {file_path.name} "
96
+ f"(lines {show_start}-{show_end} of {len(lines)}) ---"
97
+ ]
98
+ for i in range(show_start, show_end + 1):
99
+ line = lines[i - 1].rstrip()
100
+ marker = "▸ " if start_line <= i <= end_line else " "
101
+ out.append(f"{marker}{i:4d} │ {line}")
102
+ out.append("--- END EDIT PREVIEW ---")
103
+ return "\n".join(out)
104
+
105
+
106
+ def _syntax_check_python(path: Path, text: str) -> str | None:
107
+ """Return a warning string if ``text`` is invalid Python, else
108
+ None. Only fires for ``.py`` files — silent for everything
109
+ else.
110
+
111
+ We don't BLOCK the edit on syntax failure because the agent
112
+ sometimes intentionally edits a file into a transient broken
113
+ state (e.g. mid-refactor), but we surface the error so it
114
+ notices on this turn instead of debugging in 5 turns."""
115
+ if path.suffix != ".py":
116
+ return None
117
+ try:
118
+ ast.parse(text)
119
+ return None
120
+ except SyntaxError as exc:
121
+ return (
122
+ f"⚠ WARNING: file is no longer syntactically valid "
123
+ f"Python after this edit. {type(exc).__name__}: "
124
+ f"{exc.msg} (line {exc.lineno or '?'}). The edit was "
125
+ "applied; consider correcting it on the next turn."
126
+ )
127
+
128
+
129
+ def verifying_edit_tool(workdir: Path | str) -> Tool:
130
+ """Build the loom-code edit tool.
131
+
132
+ Same signature as loomflow's edit_tool:
133
+ ``edit(path, old_string, new_string, replace_all=False)``
134
+
135
+ Differences in the tool result:
136
+ - On success: appends an ``EDIT PREVIEW`` block showing the
137
+ edited region + ±10 lines of context with line numbers.
138
+ - On Python syntax-break: appends a ``⚠ WARNING`` line so
139
+ the agent immediately knows the file is broken.
140
+ - On failure (file not found, no match, etc.): returns the
141
+ underlying error verbatim. No preview, no extra noise.
142
+ """
143
+ root = Path(workdir).resolve()
144
+ anchor = root.anchor or "/"
145
+ # Two delegates: the project-rooted one keeps in-project results
146
+ # clean (``edited sample.py``, not the absolute path); the
147
+ # anchor-rooted one handles genuinely-outside files the user
148
+ # referenced (an absolute path never escapes the "/" root, so its
149
+ # own workdir check passes — the outside decision is made HERE via
150
+ # consent). Which one + which path to pass is chosen per call.
151
+ inner = _loomflow_edit_tool(workdir=root)
152
+ inner_fs = _loomflow_edit_tool(workdir=Path(anchor))
153
+
154
+ def _delegate_for(target: Path) -> tuple[Any, str]:
155
+ """Pick (inner_tool, path_to_pass) for a resolved target: the
156
+ short project-relative form when inside, else the anchor-
157
+ stripped absolute form via the "/"-rooted delegate."""
158
+ if is_within(target, root):
159
+ return inner, str(target.relative_to(root))
160
+ rel = str(target)
161
+ return inner_fs, (
162
+ rel[len(anchor):] if rel.startswith(anchor) else rel
163
+ )
164
+
165
+ async def edit(
166
+ path: str,
167
+ old_string: str,
168
+ new_string: str,
169
+ replace_all: bool = False,
170
+ ) -> str:
171
+ """Find-and-replace inside an existing file; returns the
172
+ edit summary PLUS a preview of the file after the edit
173
+ so the agent can self-correct malformed replacements.
174
+ See module docstring for the full contract."""
175
+ # Coerce ``replace_all`` — the tool-call layer may send the
176
+ # STRING "true"/"false" instead of a bool, and a non-empty
177
+ # "false" string is truthy, which would silently replace
178
+ # ALL occurrences when the model meant just one.
179
+ replace_all = _as_bool(replace_all, default=False)
180
+ # Resolve ~ / relative / absolute the same way read does.
181
+ target = resolve_path(path, root)
182
+ if not is_within(target, root):
183
+ # Outside the project — allowed ONLY when the user
184
+ # explicitly referenced this file in the session ("the
185
+ # user naming a path IS the permission"); the approval
186
+ # gate still previews the diff either way.
187
+ from .consent import is_granted
188
+
189
+ if not is_granted(target):
190
+ return (
191
+ f"edit: refusing to edit {path} — it is outside "
192
+ "the project and the user has not referenced it. "
193
+ "Only files the user pasted or @-mentioned may be "
194
+ "edited outside the project."
195
+ )
196
+ delegate, inner_path = _delegate_for(target)
197
+ if not target.is_file():
198
+ # Defer to loomflow's error message for consistency.
199
+ return await delegate.fn(
200
+ path=inner_path,
201
+ old_string=old_string,
202
+ new_string=new_string,
203
+ replace_all=replace_all,
204
+ )
205
+ old_text = target.read_text(
206
+ encoding="utf-8", errors="replace"
207
+ )
208
+
209
+ # Delegate the actual replace + write.
210
+ result = await delegate.fn(
211
+ path=inner_path,
212
+ old_string=old_string,
213
+ new_string=new_string,
214
+ replace_all=replace_all,
215
+ )
216
+ # loomflow's edit_tool signals errors via leading "ERROR:".
217
+ # On error, return verbatim — no preview when the edit
218
+ # didn't change anything.
219
+ if str(result).startswith("ERROR"):
220
+ return result
221
+
222
+ # Read post-edit content + build preview.
223
+ new_text = target.read_text(
224
+ encoding="utf-8", errors="replace"
225
+ )
226
+ start_line, end_line = _find_edit_region(old_text, new_text)
227
+ preview = _render_preview(
228
+ target, new_text, start_line, end_line
229
+ )
230
+ warn = _syntax_check_python(target, new_text)
231
+ parts: list[str] = [result, "", preview]
232
+ if warn:
233
+ parts.append("")
234
+ parts.append(warn)
235
+ return "\n".join(parts)
236
+
237
+ return tool(
238
+ name="edit",
239
+ description=(
240
+ "Find-and-replace inside an existing file. Same as "
241
+ "loomflow's edit but the tool result includes an "
242
+ "EDIT PREVIEW window showing the edited region + ±10 "
243
+ "lines of context AFTER the edit, so you can verify "
244
+ "the replacement looks right and self-correct on the "
245
+ "next turn if it doesn't. Also warns when an edit "
246
+ "leaves a .py file syntactically invalid. Args: path "
247
+ "(relative), old_string (must match exactly), "
248
+ "new_string, replace_all=False."
249
+ ),
250
+ # ``destructive=True`` matches loomflow's edit_tool default
251
+ # so the ``ApprovalGate`` continues to fire before the
252
+ # write. Without this, every edit auto-approves even when
253
+ # the user opted into the gate — silently weakens the
254
+ # destructive-action safety contract (caught by
255
+ # tests/test_approval_integration.py).
256
+ destructive=True,
257
+ )(edit)
258
+
259
+
260
+ def _loads_lenient(text: str) -> Any:
261
+ """Parse a model-serialised string into a Python object, tolerating
262
+ BOTH JSON (double quotes) and Python-repr (single quotes) — weak
263
+ models emit either, and ``json.loads`` rejects the single-quote
264
+ form. Raises ``ValueError`` when neither parses."""
265
+ try:
266
+ return json.loads(text)
267
+ except json.JSONDecodeError:
268
+ pass
269
+ try:
270
+ return ast.literal_eval(text)
271
+ except (ValueError, SyntaxError) as exc:
272
+ raise ValueError(str(exc)) from exc
273
+
274
+
275
+ def _coerce_edits(value: Any) -> list[dict[str, str]] | str:
276
+ """Coerce the model's serialisation of the ``edits`` list into
277
+ a native list of ``{old_string, new_string}`` dicts. Returns
278
+ the list, or an error string the tool returns verbatim.
279
+
280
+ Weak models serialise list-of-objects args inconsistently — a
281
+ JSON string, a list of JSON strings, a Python-repr (single-quote)
282
+ string, a dict with an ``edits`` key — so we salvage every shape,
283
+ same lenient approach ``plan_write`` uses for its ``steps`` arg.
284
+ """
285
+ if isinstance(value, str):
286
+ try:
287
+ value = _loads_lenient(value)
288
+ except ValueError:
289
+ return (
290
+ "ERROR: `edits` must be a list of "
291
+ "{old_string, new_string} objects (or a JSON "
292
+ "string of one). Couldn't parse the string given."
293
+ )
294
+ if isinstance(value, dict):
295
+ # ``{"edits": [...]}`` wrapper, or a single edit dict.
296
+ value = value.get("edits", [value])
297
+ if not isinstance(value, list):
298
+ return (
299
+ "ERROR: `edits` must be a list of "
300
+ f"{{old_string, new_string}} objects. Got "
301
+ f"{type(value).__name__}."
302
+ )
303
+ out: list[dict[str, str]] = []
304
+ for i, item in enumerate(value):
305
+ if isinstance(item, str):
306
+ try:
307
+ item = _loads_lenient(item)
308
+ except ValueError:
309
+ return (
310
+ f"ERROR: edit #{i + 1} is a string that isn't "
311
+ "valid JSON. Each edit must be an object with "
312
+ "`old_string` and `new_string`."
313
+ )
314
+ if not isinstance(item, dict):
315
+ return (
316
+ f"ERROR: edit #{i + 1} must be an object with "
317
+ f"`old_string` + `new_string`, got "
318
+ f"{type(item).__name__}."
319
+ )
320
+ if "old_string" not in item or "new_string" not in item:
321
+ return (
322
+ f"ERROR: edit #{i + 1} is missing `old_string` "
323
+ "and/or `new_string`."
324
+ )
325
+ coerced_edit: dict[str, str] = {
326
+ "old_string": str(item["old_string"]),
327
+ "new_string": str(item["new_string"]),
328
+ }
329
+ # Preserve the optional per-edit ``replace_all`` flag (the
330
+ # multi_edit applier coerces it to bool). Without carrying
331
+ # it through, every edit defaults to single-replace.
332
+ if "replace_all" in item:
333
+ coerced_edit["replace_all"] = str(item["replace_all"])
334
+ out.append(coerced_edit)
335
+ if not out:
336
+ return "ERROR: `edits` was empty — nothing to do."
337
+ return out
338
+
339
+
340
+ def _leading_ws(s: str) -> str:
341
+ """Leading whitespace of the first non-blank line of ``s``."""
342
+ for line in s.split("\n"):
343
+ if line.strip():
344
+ return line[: len(line) - len(line.lstrip())]
345
+ return ""
346
+
347
+
348
+ def _flexible_apply(working: str, old: str, new: str) -> str | None:
349
+ """Whitespace-tolerant fallback for when ``old`` doesn't match
350
+ ``working`` byte-for-byte.
351
+
352
+ The #1 cause of "old_string not found" is a model that copied the
353
+ code with slightly wrong indentation / trailing whitespace. So:
354
+ locate the UNIQUE contiguous block whose lines equal ``old``'s
355
+ lines *ignoring per-line leading/trailing whitespace*. If exactly
356
+ one such block exists, re-indent ``new`` from ``old``'s base
357
+ indent to the file block's actual indent and splice it in.
358
+
359
+ Returns the edited text, or ``None`` when there's no match or it's
360
+ ambiguous (>1) — the caller then errors. Only ever used after an
361
+ exact match fails, and only when the match is unique, so it can't
362
+ silently edit the wrong place.
363
+ """
364
+ if old == "":
365
+ return None
366
+ h_lines = working.split("\n")
367
+ n_lines = old.split("\n")
368
+ target = [ln.strip() for ln in n_lines]
369
+ hits: list[int] = []
370
+ span = len(n_lines)
371
+ for i in range(0, len(h_lines) - span + 1):
372
+ if [ln.strip() for ln in h_lines[i : i + span]] == target:
373
+ hits.append(i)
374
+ if len(hits) != 1:
375
+ return None
376
+ i = hits[0]
377
+ matched = "\n".join(h_lines[i : i + span])
378
+ # Re-indent ``new`` from old's base indent to the file block's,
379
+ # so a model that under/over-indented its old_string doesn't
380
+ # leave the replacement mis-indented (would break .py).
381
+ old_base = _leading_ws(old)
382
+ file_base = _leading_ws(matched)
383
+ new_lines = new.split("\n")
384
+ if old_base != file_base:
385
+ new_lines = [
386
+ (file_base + ln[len(old_base):])
387
+ if ln.startswith(old_base)
388
+ else ln
389
+ for ln in new_lines
390
+ ]
391
+ edited = h_lines[:i] + new_lines + h_lines[i + span:]
392
+ return "\n".join(edited)
393
+
394
+
395
+ # Fuzzy-apply only when a block is at least this similar to ``old`` AND no
396
+ # other block comes within _FUZZY_MARGIN of it. The bar is high on purpose:
397
+ # this runs ONLY after exact + whitespace-flexible both miss, and it must
398
+ # never land an edit in a vaguely-similar block. Refuse-on-ambiguity (the
399
+ # margin) is the safety net — we'd rather error (model re-reads) than risk
400
+ # editing the wrong place.
401
+ _FUZZY_THRESHOLD = 0.90
402
+ _FUZZY_MARGIN = 0.05
403
+
404
+
405
+ def _best_fuzzy_window(
406
+ working: str, old: str
407
+ ) -> tuple[int, float, float]:
408
+ """Find the line-window in ``working`` most similar to ``old``.
409
+
410
+ Returns ``(start_line_index, best_ratio, runner_up_ratio)``. Scans
411
+ every contiguous window of ``len(old)`` lines and scores it against
412
+ ``old`` with :class:`difflib.SequenceMatcher` on the whitespace-
413
+ stripped forms (indentation drift is already handled by
414
+ ``_flexible_apply``; this catches CONTENT drift — a renamed var, a
415
+ reflowed comment — on top). The runner-up lets the caller refuse a
416
+ near-tie. Returns ``(-1, 0, 0)`` when ``old`` is empty."""
417
+ if old == "":
418
+ return -1, 0.0, 0.0
419
+ h_lines = working.split("\n")
420
+ n = len(old.split("\n"))
421
+ old_norm = "\n".join(ln.strip() for ln in old.split("\n"))
422
+ best_i, best_r, second_r = -1, 0.0, 0.0
423
+ sm = difflib.SequenceMatcher()
424
+ sm.set_seq2(old_norm)
425
+ for i in range(0, len(h_lines) - n + 1):
426
+ window = "\n".join(ln.strip() for ln in h_lines[i : i + n])
427
+ sm.set_seq1(window)
428
+ r = sm.ratio()
429
+ if r > best_r:
430
+ best_i, second_r, best_r = i, best_r, r
431
+ elif r > second_r:
432
+ second_r = r
433
+ return best_i, best_r, second_r
434
+
435
+
436
+ def _fuzzy_apply(working: str, old: str, new: str) -> str | None:
437
+ """Content-drift fallback: apply ``new`` to the single block in
438
+ ``working`` that is >=_FUZZY_THRESHOLD similar to ``old`` — but only
439
+ when that block clears the runner-up by _FUZZY_MARGIN (else it's
440
+ ambiguous and we refuse). Returns the edited text or ``None``.
441
+
442
+ Runs ONLY after exact + ``_flexible_apply`` both miss, so it can't
443
+ override a clean match. Re-indents ``new`` to the matched block's
444
+ base indent (same as ``_flexible_apply``) so the splice stays valid."""
445
+ i, best_r, second_r = _best_fuzzy_window(working, old)
446
+ if i < 0 or best_r < _FUZZY_THRESHOLD:
447
+ return None
448
+ if best_r - second_r < _FUZZY_MARGIN:
449
+ return None # ambiguous — two blocks both plausible; refuse
450
+ h_lines = working.split("\n")
451
+ span = len(old.split("\n"))
452
+ matched = "\n".join(h_lines[i : i + span])
453
+ old_base = _leading_ws(old)
454
+ file_base = _leading_ws(matched)
455
+ new_lines = new.split("\n")
456
+ if old_base != file_base:
457
+ new_lines = [
458
+ (file_base + ln[len(old_base):])
459
+ if ln.startswith(old_base)
460
+ else ln
461
+ for ln in new_lines
462
+ ]
463
+ edited = h_lines[:i] + new_lines + h_lines[i + span:]
464
+ return "\n".join(edited)
465
+
466
+
467
+ def _closest_block_hint(working: str, old: str) -> str:
468
+ """A unified diff of ``old`` vs the nearest block in ``working`` —
469
+ appended to the not-found error so the model sees WHAT is actually on
470
+ disk and re-sends exact text, instead of retrying blind."""
471
+ i, ratio, _ = _best_fuzzy_window(working, old)
472
+ if i < 0:
473
+ return ""
474
+ h_lines = working.split("\n")
475
+ span = len(old.split("\n"))
476
+ nearest = h_lines[i : i + span]
477
+ diff = "\n".join(
478
+ difflib.unified_diff(
479
+ old.split("\n"),
480
+ nearest,
481
+ fromfile="what you sent",
482
+ tofile=f"nearest text in file (line {i + 1}, "
483
+ f"{ratio:.0%} similar)",
484
+ lineterm="",
485
+ )
486
+ )
487
+ if not diff:
488
+ return ""
489
+ capped = diff if len(diff) <= 1200 else diff[:1200] + "\n… (truncated)"
490
+ return "Closest block in the file:\n" + capped
491
+
492
+
493
+ def multi_edit_tool(workdir: Path | str) -> Tool:
494
+ """Build the loom-code ``multi_edit`` tool — apply MANY edits to
495
+ ONE file in a single ATOMIC call.
496
+
497
+ Why it exists: making N separate ``edit`` calls to fix N things
498
+ in one file is N round-trips, and a model that produces a
499
+ slightly-off ``old_string`` retries the same edit repeatedly
500
+ (observed: ~8 retries for one fix, burning a whole session's
501
+ tokens). ``multi_edit`` collapses N changes into one call AND
502
+ is region-targeted — it never reproduces unchanged code, so it
503
+ scales to arbitrarily large files without the token blow-up (or
504
+ the "# ... rest unchanged ..." laziness) of a whole-file
505
+ rewrite.
506
+
507
+ **Atomic**: every edit's ``old_string`` must match (exactly
508
+ once, unless that edit sets ``replace_all``). If ANY edit fails
509
+ to match, NOTHING is written and the tool reports which edit
510
+ failed — so the file is never left half-changed / corrupted.
511
+ The model fixes the offending edit and resubmits the batch.
512
+
513
+ Model-facing signature:
514
+ ``multi_edit(path, edits=[{old_string, new_string,
515
+ replace_all?}, ...])``
516
+
517
+ On success the result carries the same EDIT PREVIEW + Python
518
+ syntax-break warning as ``edit``, so the model can verify the
519
+ whole batch landed correctly in one look.
520
+ """
521
+ root = Path(workdir).resolve()
522
+
523
+ async def multi_edit(path: str, edits: Any) -> str:
524
+ """Apply a batch of find-and-replace edits to one file
525
+ atomically. See the module/tool docstring for the full
526
+ contract."""
527
+ coerced = _coerce_edits(edits)
528
+ if isinstance(coerced, str):
529
+ return coerced # error message, verbatim
530
+
531
+ target = resolve_path(path, root)
532
+ if not is_within(target, root):
533
+ # Same consent rule as ``edit`` — user-named files only.
534
+ from .consent import is_granted
535
+
536
+ if not is_granted(target):
537
+ return (
538
+ f"multi_edit: refusing to edit {path} — it is "
539
+ "outside the project and the user has not "
540
+ "referenced it. Only files the user pasted or "
541
+ "@-mentioned may be edited outside the project."
542
+ )
543
+ if not target.is_file():
544
+ return f"multi_edit: file not found: {path}"
545
+
546
+ original = target.read_text(encoding="utf-8", errors="replace")
547
+ working = original
548
+ # Apply each edit to the in-memory working copy. Validate
549
+ # match BEFORE mutating so a mid-batch failure leaves
550
+ # ``working`` partially applied but we NEVER write it.
551
+ for i, e in enumerate(coerced):
552
+ old = e["old_string"]
553
+ new = e["new_string"]
554
+ replace_all = _as_bool(
555
+ e.get("replace_all"), default=False
556
+ )
557
+ count = working.count(old)
558
+ if count == 0:
559
+ # Exact match failed — try whitespace-flexible match
560
+ # (rescues an old_string that's right except for
561
+ # indentation / trailing space, the common failure).
562
+ flexed = _flexible_apply(working, old, new)
563
+ if flexed is not None:
564
+ working = flexed
565
+ continue
566
+ # Whitespace-flex also missed → CONTENT drift (renamed
567
+ # var, reflowed comment). Last resort: high-bar
568
+ # similarity match (single unambiguous block only).
569
+ fuzzed = _fuzzy_apply(working, old, new)
570
+ if fuzzed is not None:
571
+ working = fuzzed
572
+ continue
573
+ # No safe match — fail with the closest block as a diff
574
+ # so the retry is informed, not blind.
575
+ hint = _closest_block_hint(working, old)
576
+ msg = (
577
+ f"ERROR: edit #{i + 1} old_string not found in "
578
+ f"{path} (after applying edits 1..{i}) — not even "
579
+ "with whitespace-flexible or similarity matching, so "
580
+ "it's either absent or appears in multiple near-"
581
+ "identical spots. Re-`read` the exact lines and copy "
582
+ "them verbatim (with more surrounding context if it's "
583
+ "ambiguous). NOTHING was written."
584
+ )
585
+ if hint:
586
+ msg += "\n\n" + hint
587
+ return msg
588
+ if count > 1 and not replace_all:
589
+ return (
590
+ f"ERROR: edit #{i + 1} old_string appears "
591
+ f"{count} times in {path}; add more surrounding "
592
+ "context to make it unique, or set "
593
+ "replace_all=true on that edit. NOTHING was "
594
+ "written."
595
+ )
596
+ working = (
597
+ working.replace(old, new)
598
+ if replace_all
599
+ else working.replace(old, new, 1)
600
+ )
601
+
602
+ # All edits matched — write once.
603
+ target.write_text(working, encoding="utf-8")
604
+
605
+ start_line, end_line = _find_edit_region(original, working)
606
+ preview = _render_preview(
607
+ target, working, start_line, end_line
608
+ )
609
+ warn = _syntax_check_python(target, working)
610
+ header = (
611
+ f"multi_edit: ✓ applied {len(coerced)} edit"
612
+ f"{'s' if len(coerced) != 1 else ''} to {path} "
613
+ f"({len(original)} → {len(working)} bytes)"
614
+ )
615
+ parts = [header, "", preview]
616
+ if warn:
617
+ parts.append("")
618
+ parts.append(warn)
619
+ return "\n".join(parts)
620
+
621
+ return tool(
622
+ name="multi_edit",
623
+ description=(
624
+ "Apply MULTIPLE find-and-replace edits to ONE file in a "
625
+ "single ATOMIC call. Prefer this over repeated `edit` "
626
+ "calls when changing several things in the same file — "
627
+ "one round-trip, and it scales to large files (only "
628
+ "touches the changed regions, never rewrites the whole "
629
+ "file). All edits must match or NONE apply (no half-"
630
+ "edited file). Args: path, edits=[{old_string, "
631
+ "new_string, replace_all?}, ...]. Result includes an "
632
+ "EDIT PREVIEW + a syntax-break warning for .py files."
633
+ ),
634
+ destructive=True,
635
+ )(multi_edit)