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
+ }