improve: text_editor allow patch-after-patch only for in-place edits (N→N)
linuztx committed
Feb 28, 2026 at 15:05 UTC
d67b0f516dc149bf6104ce0201e214994199abe3
2 files changed
+62
-8
plugins/text_editor/prompts/agent.system.tool.text_editor.md
+1
-1
@@ -50,7 +50,7 @@ use original line numbers from read. dont adjust for shifts
50
no overlapping edits
51
ensure valid syntax in content (all braces brackets tags closed)
52
only replace exact lines needed dont include surrounding unchanged lines
53
-re-read after patch before next patch
53
+re-read when insert delete or N≠M replace else patch again ok
54
large changes write over multiple patches
55
usage:
56
~~~json
plugins/text_editor/tools/text_editor.py
+61
-7
@@ -10,7 +10,8 @@ from plugins.text_editor.helpers.file_ops import (
10
apply_patch,
11
)
12
13
-# Key used in agent.data to store file mtimes
13
+# Key used in agent.data to store file state for patch validation
14
+# Value: {path: {"mtime": float, "total_lines": int}}
15
_MTIME_KEY = "_text_editor_mtimes"
16
17
@@ -53,7 +54,7 @@ class TextEditor(Tool):
54
if result.error:
55
return self._error("read", path, result.error)
56
56
- _record_mtime(self.agent, os.path.expanduser(path))
57
+ _record_mtime(self.agent, os.path.expanduser(path), result.total_lines)
58
59
# Extension point
60
ext_data = {"content": result.content, "warnings": result.warnings}
@@ -95,7 +96,7 @@ class TextEditor(Tool):
96
)
97
98
expanded = os.path.expanduser(path)
98
- _record_mtime(self.agent, expanded)
99
+ _record_mtime(self.agent, expanded, result.total_lines)
100
101
cfg = _get_config(self.agent)
102
read_result = read_file(
@@ -151,7 +152,7 @@ class TextEditor(Tool):
152
data={"path": expanded, "total_lines": total_lines},
153
)
154
154
- _clear_mtime(self.agent, expanded)
155
+ _apply_patch_post(self.agent, expanded, total_lines, ext_data["edits"])
156
157
patch_content = _read_patch_region(
158
expanded, ext_data["edits"], total_lines, _get_config(self.agent)
@@ -209,10 +210,13 @@ def _read_patch_region(
210
return result.content
211
212
212
-def _record_mtime(agent, path: str):
213
+def _record_mtime(agent, path: str, total_lines: int):
214
mtimes = agent.data.setdefault(_MTIME_KEY, {})
215
try:
215
- mtimes[os.path.realpath(path)] = os.path.getmtime(path)
216
+ mtimes[os.path.realpath(path)] = {
217
+ "mtime": os.path.getmtime(path),
218
+ "total_lines": total_lines,
219
+ }
220
except OSError:
221
pass
222
@@ -223,6 +227,49 @@ def _clear_mtime(agent, path: str):
227
mtimes.pop(os.path.realpath(path), None)
228
229
230
+def _count_content_lines(content: str) -> int:
231
+ return content.count("\n") + (
232
+ 1 if content and not content.endswith("\n") else 0
233
+ )
234
+
235
+
236
+def _all_edits_in_place(edits: list[dict]) -> bool:
237
+ for e in edits:
238
+ if e.get("insert"):
239
+ return False
240
+ removed = max(e["to"] - e["from"] + 1, 0)
241
+ added = _count_content_lines(e.get("content", "") or "")
242
+ if removed != added:
243
+ return False
244
+ return True
245
+
246
+
247
+def _apply_patch_post(agent, path: str, new_total: int, edits: list[dict]):
248
+
249
+ if not _all_edits_in_place(edits):
250
+ _clear_mtime(agent, path)
251
+ return
252
+
253
+ mtimes = agent.data.get(_MTIME_KEY)
254
+ if mtimes is None:
255
+ return
256
+ real = os.path.realpath(path)
257
+ stored = mtimes.get(real)
258
+ if not isinstance(stored, dict) or "total_lines" not in stored:
259
+ mtimes.pop(real, None)
260
+ return
261
+ if new_total != stored["total_lines"]:
262
+ mtimes.pop(real, None)
263
+ return
264
+ try:
265
+ mtimes[real] = {
266
+ "mtime": os.path.getmtime(path),
267
+ "total_lines": new_total,
268
+ }
269
+ except OSError:
270
+ mtimes.pop(real, None)
271
+
272
+
273
def _check_mtime(agent, path: str) -> str:
274
mtimes = agent.data.get(_MTIME_KEY, {})
275
real = os.path.realpath(path)
@@ -230,11 +277,18 @@ def _check_mtime(agent, path: str) -> str:
277
return agent.read_prompt(
278
"fw.text_editor.patch_need_read.md", path=path
279
)
280
+ stored = mtimes[real]
281
+ mtime = stored.get("mtime") if isinstance(stored, dict) else stored
282
+ if mtime is None:
283
+ mtimes.pop(real, None)
284
+ return agent.read_prompt(
285
+ "fw.text_editor.patch_need_read.md", path=path
286
+ )
287
try:
288
current = os.path.getmtime(path)
289
except OSError:
290
return ""
237
- if current != mtimes[real]:
291
+ if current != mtime:
292
return agent.read_prompt(
293
"fw.text_editor.patch_stale_read.md", path=path
294
)