text_editor: route file I/O through RFC in development

linuztx committed Mar 1, 2026 at 09:49 UTC ca65fb9a5238c398ebc29ebe6e4793fe7d99901d
3 files changed +314 -164
plugins/text_editor/helpers/file_ops.py
+59 -121
@@ -1,31 +1,46 @@
1 """
2 -Pure file operations for the text_editor plugin.
3 -
4 -No agent/tool dependencies — only stdlib + tokens helper.
2 +File operations for the text_editor plugin.
3 +
4 +Split into two layers:
5 + - _impl functions in python/helpers/rfc_text_editor.py: pure filesystem
6 + I/O with only stdlib deps. These run inside the Docker container
7 + when routed via RFC.
8 + - Public async functions here: orchestrate _impl calls through
9 + runtime.call_development_function and apply token budgeting
10 + (which requires tiktoken, a framework-only dependency).
11 """
12
7 -import os
8 -import shutil
9 -import tempfile
13 from dataclasses import dataclass
14
12 -from python.helpers import tokens
13 -
14 -_BINARY_PEEK = 8192
15 -
15 +from python.helpers import tokens, runtime
16 +from python.helpers.rfc_text_editor import (
17 + is_binary_impl as _is_binary_impl,
18 + read_file_raw_impl as _read_file_raw_impl,
19 + write_file_impl as _write_file_impl,
20 + apply_patch_impl as _apply_patch_impl,
21 + file_info_impl as _file_info_impl,
22 +)
23
24 # ------------------------------------------------------------------
25 # Binary detection
26 # ------------------------------------------------------------------
27
21 -def is_binary(path: str) -> bool:
28 +async def is_binary(path: str) -> bool:
29 """Detect binary file by checking for null bytes."""
23 - try:
24 - with open(path, "rb") as f:
25 - chunk = f.read(_BINARY_PEEK)
26 - return b"\x00" in chunk
27 - except OSError:
28 - return False
30 + return await runtime.call_development_function(_is_binary_impl, path)
31 +
32 +
33 +# ------------------------------------------------------------------
34 +# File metadata
35 +# ------------------------------------------------------------------
36 +
37 +async def file_info(path: str) -> dict:
38 + """
39 + Get file metadata from the container.
40 +
41 + Returns dict with: exists, is_file, realpath, expanded, mtime.
42 + """
43 + return await runtime.call_development_function(_file_info_impl, path)
44
45
46 # ------------------------------------------------------------------
@@ -40,7 +55,7 @@ class ReadResult:
55 error: str = ""
56
57
43 -def read_file(
58 +async def read_file(
59 path: str,
60 line_from: int = 1,
61 line_to: int | None = None,
@@ -55,21 +70,15 @@ def read_file(
70 line_from and line_to are both inclusive.
71 None line_to defaults to line_from + default_line_count - 1.
72 """
58 - path = os.path.expanduser(path)
59 -
60 - if not os.path.isfile(path):
61 - return ReadResult(error="file not found")
73 + # I/O happens in the container via RFC; token budgeting stays host-side.
74 + raw = await runtime.call_development_function(_read_file_raw_impl, path)
75
63 - if is_binary(path):
64 - return ReadResult(error="file appears binary, use terminal instead")
76 + if raw["error"]:
77 + return ReadResult(error=raw["error"])
78
66 - try:
67 - with open(path, "r", encoding="utf-8", errors="replace") as f:
68 - all_lines = f.readlines()
69 - except OSError as exc:
70 - return ReadResult(error=str(exc))
79 + all_lines = raw["lines"]
80 + total_lines = raw["total_lines"]
81
72 - total_lines = len(all_lines)
82 line_from = max(line_from, 1)
83 if line_to is None:
84 line_to = line_from + default_line_count - 1
@@ -139,22 +148,16 @@ class WriteResult:
148 error: str = ""
149
150
142 -def write_file(path: str, content: str | None) -> WriteResult:
151 +async def write_file(path: str, content: str | None) -> WriteResult:
152 """Create or overwrite a file."""
153 if content is None:
154 content = ""
146 - path = os.path.expanduser(path)
147 - try:
148 - os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
149 - with open(path, "w", encoding="utf-8") as f:
150 - f.write(content)
151 - except OSError as exc:
152 - return WriteResult(error=str(exc))
153 -
154 - total = content.count("\n") + (
155 - 1 if content and not content.endswith("\n") else 0
155 + result = await runtime.call_development_function(
156 + _write_file_impl, path, content
157 )
157 - return WriteResult(total_lines=total)
158 + if result["error"]:
159 + return WriteResult(error=result["error"])
160 + return WriteResult(total_lines=result["total_lines"])
161
162
163 # ------------------------------------------------------------------
@@ -221,7 +224,7 @@ def validate_edits(edits: list | None) -> tuple[list[dict], str]:
224 return parsed, ""
225
226
224 -def apply_patch(path: str, edits: list[dict]) -> int:
227 +async def apply_patch(path: str, edits: list[dict]) -> int:
228 """
229 Apply sorted, validated edits by streaming to a temp file.
230
@@ -229,75 +232,20 @@ def apply_patch(path: str, edits: list[dict]) -> int:
232 Inserts have 'insert': True.
233 Returns total line count after patching.
234 """
232 - # Ensure content always ends with newline to prevent line merging
233 - for e in edits:
234 - if e["content"] and not e["content"].endswith("\n"):
235 - e["content"] += "\n"
235 + result = await runtime.call_development_function(
236 + _apply_patch_impl, path, edits
237 + )
238 + if result["error"]:
239 + raise Exception(result["error"])
240 + return result["total_lines"]
241
237 - dir_name = os.path.dirname(path) or "."
238 - fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
239 - try:
240 - with (
241 - open(path, "r", encoding="utf-8", errors="replace") as src,
242 - os.fdopen(fd, "w", encoding="utf-8") as dst,
243 - ):
244 - edit_idx = 0
245 - line_no = 1 # 1-based
246 - total_written = 0
247 -
248 - for raw_line in src:
249 - # Process all inserts targeting this line first
250 - while (
251 - edit_idx < len(edits)
252 - and edits[edit_idx]["insert"]
253 - and edits[edit_idx]["from"] == line_no
254 - ):
255 - edit = edits[edit_idx]
256 - if edit["content"]:
257 - dst.write(edit["content"])
258 - total_written += _count_content_lines(edit["content"])
259 - edit_idx += 1
260 -
261 - # Check if current line falls in a replace/delete range
262 - if edit_idx < len(edits) and not edits[edit_idx]["insert"]:
263 - edit = edits[edit_idx]
264 - if edit["from"] <= line_no <= edit["to"]:
265 - # Write replacement content once at range start
266 - if line_no == edit["from"] and edit["content"]:
267 - dst.write(edit["content"])
268 - total_written += _count_content_lines(
269 - edit["content"]
270 - )
271 - # Skip original line; advance edit at range end
272 - if line_no == edit["to"]:
273 - edit_idx += 1
274 - line_no += 1
275 - continue
276 -
277 - dst.write(raw_line)
278 - total_written += 1
279 - line_no += 1
280 -
281 - # Remaining edits past end of file
282 - while edit_idx < len(edits):
283 - edit = edits[edit_idx]
284 - if edit["content"]:
285 - dst.write(edit["content"])
286 - total_written += _count_content_lines(edit["content"])
287 - edit_idx += 1
288 -
289 - shutil.move(tmp_path, path)
290 - return total_written
291 - except Exception:
292 - if os.path.exists(tmp_path):
293 - os.unlink(tmp_path)
294 - raise
295 -
296 -
297 -def patch_file(path: str, edits: list | None) -> PatchResult:
242 +
243 +async def patch_file(path: str, edits: list | None) -> PatchResult:
244 """Validate and apply edits to a file."""
299 - path = os.path.expanduser(path)
300 - if not os.path.isfile(path):
245 + info = await runtime.call_development_function(
246 + _file_info_impl, path
247 + )
248 + if not info["is_file"]:
249 return PatchResult(error="file not found")
250
251 parsed, err = validate_edits(edits)
@@ -305,18 +253,8 @@ def patch_file(path: str, edits: list | None) -> PatchResult:
253 return PatchResult(error=err)
254
255 try:
308 - total = apply_patch(path, parsed)
256 + total = await apply_patch(info["expanded"], parsed)
257 except Exception as exc:
258 return PatchResult(error=str(exc))
259
260 return PatchResult(total_lines=total, edit_count=len(parsed))
313 -
314 -
315 -# ------------------------------------------------------------------
316 -# Internal
317 -# ------------------------------------------------------------------
318 -
319 -def _count_content_lines(content: str) -> int:
320 - return content.count("\n") + (
321 - 1 if content and not content.endswith("\n") else 0
322 - )
plugins/text_editor/tools/text_editor.py
+45 -43
@@ -1,5 +1,3 @@
1 -import os
2 -
1 from python.helpers.tool import Tool, Response
2 from python.helpers.extension import call_extensions
3 from python.helpers import plugins
@@ -8,6 +6,7 @@ from plugins.text_editor.helpers.file_ops import (
6 write_file,
7 validate_edits,
8 apply_patch,
9 + file_info,
10 )
11
12 # Key used in agent.data to store file state for patch validation
@@ -42,7 +41,7 @@ class TextEditor(Tool):
41 raw_to = kwargs.get("line_to")
42 line_to = int(raw_to) if raw_to is not None else None
43
45 - result = read_file(
44 + result = await read_file(
45 path,
46 line_from=line_from,
47 line_to=line_to,
@@ -54,7 +53,8 @@ class TextEditor(Tool):
53 if result.error:
54 return self._error("read", path, result.error)
55
57 - _record_mtime(self.agent, os.path.expanduser(path), result.total_lines)
56 + info = await file_info(path)
57 + await _record_mtime(self.agent, info, result.total_lines)
58
59 # Extension point
60 ext_data = {"content": result.content, "warnings": result.warnings}
@@ -64,7 +64,7 @@ class TextEditor(Tool):
64
65 msg = self.agent.read_prompt(
66 "fw.text_editor.read_ok.md",
67 - path=os.path.expanduser(path),
67 + path=info["expanded"],
68 total_lines=str(result.total_lines),
69 warnings=ext_data["warnings"],
70 content=ext_data["content"],
@@ -84,7 +84,7 @@ class TextEditor(Tool):
84 "text_editor_write_before", agent=self.agent, data=ext_data
85 )
86
87 - result = write_file(ext_data["path"], ext_data["content"])
87 + result = await write_file(ext_data["path"], ext_data["content"])
88
89 if result.error:
90 return self._error("write", path, result.error)
@@ -95,12 +95,12 @@ class TextEditor(Tool):
95 data={"path": path, "total_lines": result.total_lines},
96 )
97
98 - expanded = os.path.expanduser(path)
99 - _record_mtime(self.agent, expanded, result.total_lines)
98 + info = await file_info(path)
99 + await _record_mtime(self.agent, info, result.total_lines)
100
101 cfg = _get_config(self.agent)
102 - read_result = read_file(
103 - expanded,
102 + read_result = await read_file(
103 + info["expanded"],
104 line_from=1,
105 line_to=result.total_lines,
106 max_line_tokens=cfg["max_line_tokens"],
@@ -109,7 +109,7 @@ class TextEditor(Tool):
109
110 msg = self.agent.read_prompt(
111 "fw.text_editor.write_ok.md",
112 - path=expanded,
112 + path=info["expanded"],
113 total_lines=str(result.total_lines),
114 content=read_result.content,
115 )
@@ -122,15 +122,16 @@ class TextEditor(Tool):
122 if not path:
123 return self._error("patch", path, "path is required")
124
125 - expanded = os.path.expanduser(path)
126 - if not os.path.isfile(expanded):
125 + info = await file_info(path)
126 + if not info["is_file"]:
127 return self._error("patch", path, "file not found")
128
129 - stale_err = _check_mtime(self.agent, expanded)
129 + expanded = info["expanded"]
130 +
131 + stale_err = _check_mtime(self.agent, info)
132 if stale_err:
133 return self._error("patch", path, stale_err)
134
133 -
135 parsed, err = validate_edits(edits)
136 if err:
137 return self._error("patch", path, err)
@@ -142,7 +143,7 @@ class TextEditor(Tool):
143 )
144
145 try:
145 - total_lines = apply_patch(ext_data["path"], ext_data["edits"])
146 + total_lines = await apply_patch(ext_data["path"], ext_data["edits"])
147 except Exception as exc:
148 return self._error("patch", path, str(exc))
149
@@ -152,9 +153,11 @@ class TextEditor(Tool):
153 data={"path": expanded, "total_lines": total_lines},
154 )
155
155 - _apply_patch_post(self.agent, expanded, total_lines, ext_data["edits"])
156 + # Refresh file info after patch for updated mtime
157 + post_info = await file_info(expanded)
158 + _apply_patch_post(self.agent, post_info, total_lines, ext_data["edits"])
159
157 - patch_content = _read_patch_region(
160 + patch_content = await _read_patch_region(
161 expanded, ext_data["edits"], total_lines, _get_config(self.agent)
162 )
163
@@ -181,7 +184,7 @@ class TextEditor(Tool):
184 # Standalone helpers
185 # ------------------------------------------------------------------
186
184 -def _read_patch_region(
187 +async def _read_patch_region(
188 path: str, edits: list[dict], total_lines: int, cfg: dict
189 ) -> str:
190 if not edits:
@@ -200,7 +203,7 @@ def _read_patch_region(
203 max_to = max(e["to"] for e in edits)
204 end_line = max_to + added - removed + 3
205
203 - result = read_file(
206 + result = await read_file(
207 path,
208 line_from=max(min_from - 1, 1),
209 line_to=min(end_line, total_lines),
@@ -210,21 +213,20 @@ def _read_patch_region(
213 return result.content
214
215
213 -def _record_mtime(agent, path: str, total_lines: int):
216 +async def _record_mtime(agent, info: dict, total_lines: int):
217 + """Record mtime using file_info dict from the container."""
218 mtimes = agent.data.setdefault(_MTIME_KEY, {})
215 - try:
216 - mtimes[os.path.realpath(path)] = {
217 - "mtime": os.path.getmtime(path),
219 + if info["mtime"] is not None:
220 + mtimes[info["realpath"]] = {
221 + "mtime": info["mtime"],
222 "total_lines": total_lines,
223 }
220 - except OSError:
221 - pass
224
225
224 -def _clear_mtime(agent, path: str):
226 +def _clear_mtime(agent, info: dict):
227 mtimes = agent.data.get(_MTIME_KEY)
228 if mtimes is not None:
227 - mtimes.pop(os.path.realpath(path), None)
229 + mtimes.pop(info["realpath"], None)
230
231
232 def _count_content_lines(content: str) -> int:
@@ -244,16 +246,16 @@ def _all_edits_in_place(edits: list[dict]) -> bool:
246 return True
247
248
247 -def _apply_patch_post(agent, path: str, new_total: int, edits: list[dict]):
248 -
249 +def _apply_patch_post(agent, info: dict, new_total: int, edits: list[dict]):
250 + """Update mtime cache after a patch, using file_info from the container."""
251 if not _all_edits_in_place(edits):
250 - _clear_mtime(agent, path)
252 + _clear_mtime(agent, info)
253 return
254
255 mtimes = agent.data.get(_MTIME_KEY)
256 if mtimes is None:
257 return
256 - real = os.path.realpath(path)
258 + real = info["realpath"]
259 stored = mtimes.get(real)
260 if not isinstance(stored, dict) or "total_lines" not in stored:
261 mtimes.pop(real, None)
@@ -261,36 +263,36 @@ def _apply_patch_post(agent, path: str, new_total: int, edits: list[dict]):
263 if new_total != stored["total_lines"]:
264 mtimes.pop(real, None)
265 return
264 - try:
266 + if info["mtime"] is not None:
267 mtimes[real] = {
266 - "mtime": os.path.getmtime(path),
268 + "mtime": info["mtime"],
269 "total_lines": new_total,
270 }
269 - except OSError:
271 + else:
272 mtimes.pop(real, None)
273
274
273 -def _check_mtime(agent, path: str) -> str:
275 +def _check_mtime(agent, info: dict) -> str:
276 + """Check if the file has been modified since last read, using file_info."""
277 mtimes = agent.data.get(_MTIME_KEY, {})
275 - real = os.path.realpath(path)
278 + real = info["realpath"]
279 if real not in mtimes:
280 return agent.read_prompt(
278 - "fw.text_editor.patch_need_read.md", path=path
281 + "fw.text_editor.patch_need_read.md", path=info["expanded"]
282 )
283 stored = mtimes[real]
284 mtime = stored.get("mtime") if isinstance(stored, dict) else stored
285 if mtime is None:
286 mtimes.pop(real, None)
287 return agent.read_prompt(
285 - "fw.text_editor.patch_need_read.md", path=path
288 + "fw.text_editor.patch_need_read.md", path=info["expanded"]
289 )
287 - try:
288 - current = os.path.getmtime(path)
289 - except OSError:
290 + current = info["mtime"]
291 + if current is None:
292 return ""
293 if current != mtime:
294 return agent.read_prompt(
293 - "fw.text_editor.patch_stale_read.md", path=path
295 + "fw.text_editor.patch_stale_read.md", path=info["expanded"]
296 )
297 return ""
298
python/helpers/rfc_text_editor.py new
+210
@@ -0,0 +1,210 @@
1 +"""
2 +RFC-routable filesystem operations for the text_editor plugin.
3 +
4 +These functions run inside the Docker container when invoked via
5 +runtime.call_development_function. They use only stdlib — no
6 +framework dependencies (no tiktoken, no agent context, etc.).
7 +
8 +All args and return values must be JSON-serializable.
9 +"""
10 +
11 +import os
12 +import shutil
13 +import tempfile
14 +
15 +_BINARY_PEEK = 8192
16 +
17 +
18 +# ------------------------------------------------------------------
19 +# Internal
20 +# ------------------------------------------------------------------
21 +
22 +def _count_content_lines(content: str) -> int:
23 + return content.count("\n") + (
24 + 1 if content and not content.endswith("\n") else 0
25 + )
26 +
27 +
28 +# ------------------------------------------------------------------
29 +# Binary detection
30 +# ------------------------------------------------------------------
31 +
32 +def is_binary_impl(path: str) -> bool:
33 + """Check for null bytes in the first 8 KiB."""
34 + try:
35 + with open(path, "rb") as f:
36 + chunk = f.read(_BINARY_PEEK)
37 + return b"\x00" in chunk
38 + except OSError:
39 + return False
40 +
41 +
42 +# ------------------------------------------------------------------
43 +# Read
44 +# ------------------------------------------------------------------
45 +
46 +def read_file_raw_impl(path: str) -> dict:
47 + """
48 + Read a text file and return raw lines plus metadata.
49 +
50 + Returns dict with:
51 + - lines: list[str] (raw lines including newlines)
52 + - total_lines: int
53 + - error: str (empty on success)
54 + """
55 + path = os.path.expanduser(path)
56 +
57 + if not os.path.isfile(path):
58 + return {"lines": [], "total_lines": 0, "error": "file not found"}
59 +
60 + # Binary check inline to avoid extra RFC round-trip
61 + try:
62 + with open(path, "rb") as f:
63 + chunk = f.read(_BINARY_PEEK)
64 + if b"\x00" in chunk:
65 + return {"lines": [], "total_lines": 0,
66 + "error": "file appears binary, use terminal instead"}
67 + except OSError:
68 + pass
69 +
70 + try:
71 + with open(path, "r", encoding="utf-8", errors="replace") as f:
72 + all_lines = f.readlines()
73 + except OSError as exc:
74 + return {"lines": [], "total_lines": 0, "error": str(exc)}
75 +
76 + return {"lines": all_lines, "total_lines": len(all_lines), "error": ""}
77 +
78 +
79 +# ------------------------------------------------------------------
80 +# Write
81 +# ------------------------------------------------------------------
82 +
83 +def write_file_impl(path: str, content: str) -> dict:
84 + """
85 + Write content to a file, creating parent dirs as needed.
86 +
87 + Returns dict with:
88 + - total_lines: int
89 + - error: str (empty on success)
90 + """
91 + path = os.path.expanduser(path)
92 + try:
93 + os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
94 + with open(path, "w", encoding="utf-8") as f:
95 + f.write(content)
96 + except OSError as exc:
97 + return {"total_lines": 0, "error": str(exc)}
98 +
99 + total = content.count("\n") + (
100 + 1 if content and not content.endswith("\n") else 0
101 + )
102 + return {"total_lines": total, "error": ""}
103 +
104 +
105 +# ------------------------------------------------------------------
106 +# Patch
107 +# ------------------------------------------------------------------
108 +
109 +def apply_patch_impl(path: str, edits: list) -> dict:
110 + """
111 + Apply sorted, validated edits to a file.
112 +
113 + Returns dict with:
114 + - total_lines: int
115 + - error: str (empty on success)
116 + """
117 + path = os.path.expanduser(path)
118 +
119 + # Ensure content always ends with newline to prevent line merging
120 + for e in edits:
121 + if e["content"] and not e["content"].endswith("\n"):
122 + e["content"] += "\n"
123 +
124 + dir_name = os.path.dirname(path) or "."
125 + fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
126 + try:
127 + with (
128 + open(path, "r", encoding="utf-8", errors="replace") as src,
129 + os.fdopen(fd, "w", encoding="utf-8") as dst,
130 + ):
131 + edit_idx = 0
132 + line_no = 1
133 + total_written = 0
134 +
135 + for raw_line in src:
136 + while (
137 + edit_idx < len(edits)
138 + and edits[edit_idx]["insert"]
139 + and edits[edit_idx]["from"] == line_no
140 + ):
141 + edit = edits[edit_idx]
142 + if edit["content"]:
143 + dst.write(edit["content"])
144 + total_written += _count_content_lines(edit["content"])
145 + edit_idx += 1
146 +
147 + if edit_idx < len(edits) and not edits[edit_idx]["insert"]:
148 + edit = edits[edit_idx]
149 + if edit["from"] <= line_no <= edit["to"]:
150 + if line_no == edit["from"] and edit["content"]:
151 + dst.write(edit["content"])
152 + total_written += _count_content_lines(
153 + edit["content"]
154 + )
155 + if line_no == edit["to"]:
156 + edit_idx += 1
157 + line_no += 1
158 + continue
159 +
160 + dst.write(raw_line)
161 + total_written += 1
162 + line_no += 1
163 +
164 + while edit_idx < len(edits):
165 + edit = edits[edit_idx]
166 + if edit["content"]:
167 + dst.write(edit["content"])
168 + total_written += _count_content_lines(edit["content"])
169 + edit_idx += 1
170 +
171 + shutil.move(tmp_path, path)
172 + return {"total_lines": total_written, "error": ""}
173 + except Exception as exc:
174 + if os.path.exists(tmp_path):
175 + os.unlink(tmp_path)
176 + return {"total_lines": 0, "error": str(exc)}
177 +
178 +
179 +# ------------------------------------------------------------------
180 +# File info
181 +# ------------------------------------------------------------------
182 +
183 +def file_info_impl(path: str) -> dict:
184 + """
185 + Return file metadata needed by the host for mtime tracking.
186 +
187 + Returns dict with:
188 + - exists: bool
189 + - is_file: bool
190 + - realpath: str
191 + - expanded: str (expanduser result)
192 + - mtime: float | None
193 + """
194 + path = os.path.expanduser(path)
195 + rp = os.path.realpath(path)
196 + exists = os.path.exists(path)
197 + is_file = os.path.isfile(path)
198 + mtime = None
199 + if exists:
200 + try:
201 + mtime = os.path.getmtime(path)
202 + except OSError:
203 + pass
204 + return {
205 + "exists": exists,
206 + "is_file": is_file,
207 + "realpath": rp,
208 + "expanded": path,
209 + "mtime": mtime,
210 + }