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 )