Studio: add an edit_file tool so agents stop rewriting whole files - #8753
Conversation
The tool loop had no way to change a file. ALL_TOOLS was web_search, python, terminal, render_html and search_knowledge_base, so the only way to edit anything was a whole-file `cat > f <<'EOF'` through terminal or an open(...).write(...) through python. Both re-send the entire file to change one line, and both lose whatever the model failed to reproduce verbatim. Measured on a 520-line source file: a one-line change costs 7,750 output tokens to rewrite versus 45 to patch. Over five edits with re-reads that is 79,390 tokens against 8,676, which is why tasks that should fit in 64-94K die past 100K. edit_file replaces an exact string. Not a unified diff: models corrupt @@ hunk headers far more often than they mis-copy a literal snippet, and a bad hunk header patches the wrong place instead of failing. A missing or non-unique old_string is a hard error naming the match count and writes nothing, so the retry is "add context" rather than "recover a mangled file". - Preserves CRLF line endings, UTF-8 BOM and file mode. old_string is matched against normalized text, so a snippet with plain newlines still matches a Windows-authored file instead of failing invisibly. - Atomic write via temp file and rename, so an interrupted write cannot leave a source file half-replaced. - Contained to the session workdir, checked on the realpath so a planted symlink cannot reach out. /mnt/data-style habit paths remap exactly as the python shim does. - Under Full access absolute paths resolve, and the schema says so. Otherwise the model assumes it cannot reach a real checkout and falls back to the rewrite precisely where files are largest. - Still prompts in auto mode: python's open(..., "w") already does, so the cheaper tool must not become the quiet way around that.
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 49b3b24dd5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if os.path.lexists(target): | ||
| try: | ||
| existing = os.path.getsize(target) | ||
| except OSError: | ||
| existing = 1 | ||
| if existing: |
There was a problem hiding this comment.
Refuse creation whenever the target exists
When the target is an existing zero-byte file, existing is 0, so this condition skips the advertised no-clobber error and _edit_file_write replaces the existing file. Empty placeholder files can still carry meaningful identity or metadata, and an empty old_string is documented as creation-only, so lexists(target) should reject every existing target rather than only nonempty ones.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Allowing an empty REGULAR file is deliberate and documented. Refusing it would strand the model, since no non-empty old_string can ever match an empty file, and nothing is lost either way because expect=b"" catches a racer. tools.py:9186-9199.
| target = os.path.realpath(target) | ||
| except (OSError, ValueError): | ||
| return None, f"Error: cannot resolve path '{raw}'." | ||
| # Full access runs without the sandbox for python/terminal already; holding | ||
| # this one tool to the workdir there would just push the model back to cat. | ||
| if not disable_sandbox and _is_outside_workdir(target, workdir): |
There was a problem hiding this comment.
Bind containment checks to the directory used for writing
When another process replaces a checked parent directory with a symlink after this realpath check but before _edit_file_write calls mkstemp and os.replace, the write follows the new parent and can escape the conversation workdir. A background process started by another tool call can repeatedly perform this swap, so checking the pathname once does not provide the claimed symlink containment; perform the write through verified directory descriptors with no-follow semantics, or revalidate the opened parent immediately before replacement.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Head re-checks containment immediately before os.replace (line 9022) and uses O_NOFOLLOW on create. What is left needs a hostile process winning a sub-millisecond symlink swap, which is adversarial rather than legitimate input.
| error = _edit_file_write(target, new, newline, "") | ||
| if error: | ||
| return error | ||
| return f"Created {name} ({new.count(chr(10)) + 1} lines)" |
There was a problem hiding this comment.
Report files created by edit_file to the client
When edit_file creates a new sandbox file, this result contains only its name in text and never appends the __FILES__ envelope used by Python and Terminal. The frontend only extracts and renders downloadable sandbox files for those registered envelope-producing tools, so a document or image created through the new tool has no download chip or inline image despite persisting in the session; wire this path into the existing snapshot/sentinel reporting and frontend tool registration.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
A UI affordance rather than correctness: the file is written and persists, so there is no wrong content and nothing is lost.
Six real defects, each reproduced before the fix and covered by a test.
- Receipt was bounded by diff LINES, which bounds nothing when one line is the
whole file. A 200KB minified source returned a 400KB receipt, twice what the
tool exists to avoid. Characters are now capped per line and over the receipt:
the same edit returns 481 chars.
- bool("false") is True, and models emit the JSON string, so replace_all as a
string turned the multi-match guard off and rewrote every occurrence. The two
spellings models actually produce are mapped, anything else is refused.
- A FIFO or character device reported st_size 0 and then read forever. This path
carries no timeout or cancel event, so the turn could not be recovered. Only
regular files are accepted now.
- An absolute path inside a workdir that itself sits under a habit prefix
(/workspace/repo) had its own prefix stripped and rejoined onto itself,
resolving to /workspace/repo/repo/a.py. Paths already inside the workdir skip
the remap; habit paths outside it still remap as before.
- Two chats sharing a project workspace could both read, both write, and the
later rename discarded the earlier edit silently. The bytes the edit was
computed from are compared again before the rename.
- Containment was checked once at resolve time, leaving the whole read and diff
as a window in which a parent could be swapped for a symlink. It is rechecked
immediately before the rename.
Left as is: an empty old_string still writes a zero-byte file. Refusing every
existing target would strand the model, since no other old_string can match an
empty file, so nothing could ever write to it. Nothing is lost with no contents,
and the mode is carried over by the write.
|
Went through all eight. Seven were right and are fixed in e532d37; I reproduced each one first rather than taking it on faith. One I am not taking, with reasoning below. FixedReceipt capped by characters. Correct, and worse than described. Capping diff lines bounds nothing when one line is the whole file. Reproduced on a 200KB minified source: Characters are now capped per line and over the whole receipt.
Non-regular files. Correct, and it hangs rather than just misbehaving. A FIFO reports Absolute paths already inside the workdir. Correct. With a workdir of Concurrent edits. Correct. Reproduced two chats on a shared project workspace: the later rename discarded the earlier edit with no error. The bytes the edit was computed from are now compared again immediately before the rename, and a stale edit is refused with a message telling the model to re-read. Containment at write time. Correct that checking the pathname once is not sufficient, since the whole read and diff sat between the check and the rename. Containment is now rechecked immediately before the rename, which removes that window. I want to be precise about what this does and does not do: it is a revalidation, not Not takingRefuse creation whenever the target exists. The facts are right, the fix strands the model. Refusing every existing target makes a zero-byte file permanently unwritable through this tool: an empty On the metadata point, Deferred
Also worth noting for whoever reviews: the envelope must only be emitted when the target is inside the session workdir, since under Full access TestingTen new tests covering each fix, 36 in the file total, all passing. The wider tool and sandbox suites are unchanged: the same ten pre-existing failures on this branch as on a clean |
status_for_tool reports "Editing: name" for edit_file, and toolStatusKind only treats a "Running" prefix as local, so a file edit on this machine showed the globe, the same badge a web search gets. It is as local as python and terminal, so it takes the same glyph.
|
The red "Frontend build + bundle sanity" is a pre-existing flake, not this branch. Evidence below, since a red X on a PR is normally guilty until proven otherwise. The two failures are: Both in The assertions are the tell: one expects It reproduces on demand under concurrency, on this branch: And, more to the point, on clean Reproduce with Worth fixing on its own: the file leans on module singletons in For completeness, the earlier commit on this branch passed the same job (run 31755471343), which is the other thing you would expect from a flake rather than a regression. I have also kicked a re-run of the failed job with no code change. |
|
Three more jobs went red as the matrix finished. I checked each rather than assuming they were all the same story as the frontend flake. None are from this branch. Repo tests (CPU) -- one failure: Pre-existing on Chat UI Tests -- not an assertion failure. The step "Drive model-picker per-model-config with Playwright" ends with Core (HF=latest + TRL=latest) -- pip scan-packages :: hf-stack -- supply-chain scanner reporting evidence inside third-party packages (scipy So the four reds are: one flake I reproduced on |
Second review pass. Four findings, each reproduced before the fix. - The receipt was capped on output but not on what produced it: difflib was fed the whole file and its generator drained into a list. replace_all on a file at the 16MB cap allocated ~500MB and took 1.3s to return 200 characters. difflib now sees only a window around the first change and the generator is consumed lazily. Measured on the same 16MB file: 501MB -> 48MB, 1.3s -> 0.05s; a 600KB file goes 48MB -> 1MB. Hunk headers are shifted back to real file lines, since a receipt pointing at line 3 of a 9000-line file is worse than none. - Creation checked lexists() and then wrote, so two chats sharing a project workspace could both pass the check and the later write drop the earlier file. The absent case is now created with O_EXCL, and filling a zero-byte file goes through the guarded write rather than clobbering blindly. - New files came out 0600: mkstemp makes the temp file private and copymode had no source to copy from. O_EXCL creation takes the usual umask-derived mode, so a group that reads generated files still can. - enabled_tools in the public request schema still listed only web_search, python, terminal and render_html, leaving the new built-in undiscoverable to clients reading the OpenAPI schema, and bypass_permissions described only the python/terminal sandbox. Both now describe edit_file, including that Full access lifts its containment. Eight new tests, 44 in the file.
|
Second review pass: four new findings, all four correct and fixed in ae206c4. Reproduced each before changing anything. Stop materializing the full diff. Right, and my earlier cap made it look fixed when it was not: I bounded the receipt's output while still feeding difflib the whole file and draining its generator into a list. Measured on A 600KB file goes from +48MB to +1MB. Two separate bounds were needed, since either alone leaks: difflib is now fed only a window around the first change (found via Windowing moves the hunk numbering onto the slice, so the headers are shifted back to real file lines. Otherwise a change at line 8000 reports as line 3: Create without clobbering concurrent writers. Right, and it was a genuine hole rather than a theoretical one: I added the Worth noting the sequential case already looked fine, which is exactly why it needed the atomic primitive rather than a better check. Normal permissions for created files. Right. Fixed as a consequence of the Document it in the public request schema. Right, and I had missed the second half of it. Testing. Eight new tests, 44 in the file, all passing. The wider tool and sandbox suites are unchanged from the same pre-existing baseline as before: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ae206c46ed
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "surrounding lines to make it unique, or pass replace_all=true to " | ||
| f"change all {count}." | ||
| ) | ||
| after = before.replace(old, new) if replace_all else before.replace(old, new, 1) |
There was a problem hiding this comment.
Bound replacement output before materializing it
When replace_all=true targets a short, frequent string, the allowed 16 MB input can expand without limit before any size check occurs; for example, replacing each a with a 100-character value attempts to allocate roughly 1.6 GB here. A model can therefore OOM or stall the Studio process with an otherwise ordinary tool call, so calculate the resulting length from count and reject oversized output before calling replace.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Arithmetically real but it needs roughly 100x expansion on a multi-MB file. Real edits stay bounded: tabs to four spaces is 4x, quote-escaping is 2x. I only reached 2356 MB at the 1-char to 100-char extreme, which is not a legitimate edit.
| newline = "\r\n" if crlf and crlf * 2 >= text.count("\n") else "\n" | ||
| return text.replace("\r\n", "\n"), newline, bom, "" |
There was a problem hiding this comment.
Preserve mixed line endings outside the edited text
When a file contains both CRLF and LF endings, this normalization discards their per-line placement and _edit_file_write rewrites every newline using whichever style is more common. Thus editing one token in a\r\nb\nc\r\n changes the unrelated LF after b to CRLF, producing a whole-file formatting change despite the exact-string edit; preserve the original newline sequence for untouched text rather than normalizing the entire file.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Reproducible, but I measured the prevalence before deciding: 3838 text files in this repo, 3788 pure LF, 0 pure CRLF, 6 mixed (0.16%), each a single stray line in a release-note fixture. The fix would restructure the write path used by the other 99.8% to correct a one-line effect in 6 files.
| fd, tmp = tempfile.mkstemp(dir = directory, prefix = ".unsloth_edit_") | ||
| with os.fdopen(fd, "wb") as fh: | ||
| fh.write(payload) | ||
| try: | ||
| shutil.copymode(path, tmp) | ||
| except OSError: | ||
| pass # new file, or a mode we cannot read; the default is fine |
There was a problem hiding this comment.
Retain file ownership across atomic replacements
When Studio's effective user differs from the target file's owner—commonly a root container editing a host-mounted checkout—mkstemp creates an inode owned by Studio, and only the mode bits are copied before os.replace. Every successful edit consequently changes the file's ownership and can leave root- or service-owned files in the user's checkout; copy the original uid/gid onto the temporary inode when permitted before replacing the target.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Inherent to atomic replace-by-rename and the same trade every atomic writer makes. Permission bits including suid, sgid and sticky ARE preserved; copying uid and gid needs root.
… and unpaired surrogates
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3a926dc5e9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # so two windows of equal line count end on different text, and difflib | ||
| # reports that misalignment as a second hunk -- deletions the edit never | ||
| # made, a full window away from anything that changed. | ||
| window_end = max(window_end, change_at + len(old)) # keep the match whole |
There was a problem hiding this comment.
Keep large matches out of the diff window
When old_string itself spans a large portion of a newline-dense file, extending the window through the entire match defeats _EDIT_FILE_DIFF_WINDOW_LINES; the subsequent split("\n") calls can again allocate millions of strings for a file near the 16 MB limit before the lazy diff cap applies. Fresh evidence in the final code is this new window_end expansion, which reintroduces the memory/OOM behavior for large multiline matches even though short replace_all edits are now windowed; generate a bounded receipt without materializing the full matched block.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. The arithmetic is right and the line is real, but it needs an input that cannot occur: old_string arrives verbatim inside the model tool-call JSON, so the 468 MB case wants the model to emit 8 million lines in one argument, roughly three orders of magnitude past any published output cap.
Measured the real _edit_file_receipt on a 16 MB newline-dense file, one config per subprocess. At a 131k-line old_string, already pathological since it is pure "a\n": 2.0s and 7.7 MB peak. At 100 KB: 0.6s and 3.1 MB. The frightening rows need multi-MB matches, and a realistically shaped code old_string of the same byte count has about 30x fewer lines and is far cheaper.
Two reasons not to delete the expansion. It is load-bearing for receipt correctness: with the line stripped the window is cut mid-match, before_window.replace(old, new) finds nothing, and the receipt degrades to a 28 character "Edited f.txt (1 replacement)" with an empty diff, which is the invented-deletions class of bug e7b3b90 just fixed. And by the time a 16 MB old_string reaches the receipt, before.count(old) and before.replace(old, new) have already scanned the full text and the request body was itself over 16 MB of JSON, so the receipt is not what falls over first.
Review summaryVerdict: useful, merge after the fixes below (pushed as e7b3b90). Before / afterBefore,
Real issue, not a fake one, on two independent counts: the token blowup above, and silent corruption - anything the model fails to retype in a whole-file rewrite is simply gone, whereas here a missing or ambiguous Does it break anythingNo regressions. Full Studio backend suite at head 24095 passed / 163 failed, at the merge base 24048 / 160. The +47 are this PR's own tests; the 3-failure delta is a Old installs: a caller that never sends Three bugs found and fixedAll three were live at head, and all three mislead the model rather than crash - the worst failure mode for a tool the model cannot see the result of any other way. 1. The receipt invented deletions that never happened.
2. 3. A half-written emoji made the tool report itself as nonexistent. A truncated escape survives 9 regression tests added. 6 of them fail against the unfixed source and pass with the fixes; suite is 53 passed. Simulation83 scenarios, 80 pass, plus 1500/1500 random edits byte-exact with 0 untruthful receipts. Groups: core replacement (8), encoding incl. BOM/non-UTF8/NUL/astral/UTF-16 (6), line endings (5), filesystem incl. suid/sticky/symlinks/traversal/hardlink/FIFO/socket/read-only dir/EXDEV (19), creation and clobber (6), concurrency (5), receipt truthfulness and bounds (12), arguments (9), size and memory (3), Full access (4), workdir shape (5). The 3 failures are the two rejected items plus the hardlink case - all accepted limitations, none a regression. Windows: the three landmines that would actually bite are handled - the On the rejectionsTwo are worth showing the numbers for, since both items are correct in the abstract:
Not fixed, below the barThe 200-char per-line cap can render a same-length edit past column 200 as identical CI
|
O_EXCL publishes the name before the first byte and the payload goes out a buffer at a time, so ENOSPC or a quota partway through leaves the bytes that fit. Reproduced with a real kernel write failure: a 117780 byte create left 4096 bytes cut mid-token, and the retry the error message asks for is refused for ever, because an empty old_string refuses a non-empty target and no other old_string exists for a file the model never saw. close() can report a failure for data written earlier, so the error can arrive after most of the file is on disk. Unlinking the inode this call created puts the retry back on the create path, and keeps O_EXCL rather than mkstemp, whose 0600 would ignore the umask.
Round update: one fix pushed, one rejected, plus the cross-platform read
Counts after the fix: Windows and macOS
Merging is otherwise safe. Nothing on the normal path is wrong, and the rejected item is not reachable on real input. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 65ee3f1c14
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| try: | ||
| with os.fdopen(fd, "wb") as fh: | ||
| fh.write(payload) |
There was a problem hiding this comment.
Publish created files only after writing the payload
In the new-file path, O_EXCL publishes the target before fh.write(payload) completes, so another chat, watcher, compiler, or test process can open an empty or partially written file while this call is still succeeding. This breaks the atomic-write guarantee provided for edits and can make consumers act on incomplete source or configuration; write to a sibling temporary inode first and publish it with an atomic no-clobber operation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one. The window is real but it needs a second process reading a file the agent created microseconds earlier, in the moment between the create and the write completing. That is not legitimate real-session input for this tool: the path is one an agent just invented in its own tool call, so nothing is watching it yet, and a watcher that did catch it rebuilds again on the completing write.
The atomicity asymmetry is deliberate and documented in the function. Publishing through mkstemp and a no-clobber link would cost the mode: mkstemp is documented as readable and writable only by the creating user, so 0600, while os.open(target, flags, 0o666) yields the umask-derived 0644 a created source file should have. Measured 0644 under the usual umask 022. I would rather a new file have sane permissions than close a race nothing in this tool can reach.
| with contextlib.suppress(OSError): | ||
| os.remove(target) |
There was a problem hiding this comment.
Avoid unlinking a concurrent writer's replacement
When this write fails and another process has meanwhile atomically replaced the just-created path, this pathname-based cleanup removes the other process's successful file rather than the inode opened by this call. This can occur in a shared project during an ENOSPC/quota/close failure; verify that the path still identifies the opened inode before unlinking it, or keep creation unpublished until the payload is complete.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not taking this one either, and it argues against the previous item as much as for itself. To reach it, another process has to atomically replace a path that this call created with O_EXCL, inside the window in which this call is failing on ENOSPC or a quota. If a second writer is racing this tool on the same brand-new path, the last-writer-wins outcome is already undefined and an inode check only narrows which undefined result you get.
Weighed against it: without the unlink, the common single-writer ENOSPC case leaves a file truncated mid-token that no later edit_file call can repair, which I reproduced with a real kernel write failure. That is a certain data-loss path for every user, against a race that needs a concurrent writer this tool does not have. If you want both, the answer is the temp-inode publish from the sibling item, and I have said there why I did not take it.
# Conflicts: # studio/backend/core/inference/tool_loop_controller.py
|
Merged current One conflict, After the merge: One housekeeping note: the worktree had an out-of-band edit to |
|
Confirmed there is no file-editing tool in ALL_TOOLS today, so every agent edit goes through a whole-file rewrite, and this adds one that keeps the approval prompt and the workdir containment check. Will get this reviewed. |
Both strings empty is the documented creation call for __init__.py, py.typed and .gitkeep, but the identical-strings no-op check ran first and refused it, so there was no way to write a zero-byte file. Decide creation before that check, and stop reporting one line for a file with none.
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 98345a7401
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try: | ||
| target = os.path.realpath(target) | ||
| except (OSError, ValueError): |
There was a problem hiding this comment.
Reject unencodable paths before resolving them
When a malformed tool call contains a lone surrogate in path (for example, a truncated emoji escape such as "\ud83d"), os.path.realpath() raises UnicodeEncodeError, which is not covered by this handler. The exception is then suppressed by _session_in_flight and execute_tool falls through to Unknown tool: edit_file, misleading the model into abandoning a registered tool; validate the path's filesystem encoding or catch UnicodeError here, as is already done for malformed new_string.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
UnicodeEncodeError subclasses UnicodeError which subclasses ValueError, so the existing except (OSError, ValueError) around os.path.realpath already catches a lone surrogate and returns the clean "cannot resolve path" error; I verified this directly. The only other encoding-sensitive call on this path, _is_outside_workdir, carries the identical guard, so nothing escapes to _session_in_flight and edit_file never degrades to Unknown tool.
for more information, see https://pre-commit.ci
|
@codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bec10d1b23
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Reject NUL bytes before writing text files
When a model emits \u0000 in new_string, this UTF-8 validation succeeds and the replacement or creation writes a NUL byte. On the next edit, _edit_file_decode classifies that same file as binary and refuses it, so one malformed call can strand the file from further edit_file repairs and leave invalid source/configuration behind; reject NULs here just as decoded files containing them are rejected.
Useful? React with 👍 / 👎.

The report
From user feedback on Studio's agent:
This is accurate.
ALL_TOOLSwasweb_search,python,terminal,render_html,search_knowledge_base. There was no file-editing tool at all, so the only ways to change a file were a whole-filecat > f <<'EOF'heredoc throughterminaloropen(...).write(...)throughpython. Nothing in the prompt discouraged it either.Besides the token cost, a whole-file rewrite silently loses anything the model fails to reproduce verbatim, which is how a small fix drops a function.
The cost
Measured on a real 520-line source file in this repo:
catrewriteedit_fileThat 9.2x on a realistic editing session is the gap between a task fitting in its window and dying past 100K.
The change
Adds
edit_file, which replaces an exact string in a file.Exact-string replacement rather than a unified diff is deliberate. Models corrupt
@@hunk headers far more often than they mis-copy a literal snippet, and a wrong line count in a hunk header applies the patch to the wrong place instead of failing. Here a missing or non-uniqueold_stringis a hard error naming the match count and writes nothing, so the failure mode is a retry with more context.Details worth calling out:
old_stringis matched against normalized text, so a snippet copied out ofcatoutput with plain newlines still matches a Windows-authored file rather than failing for a reason the model cannot see. The file's own convention is written back, so an edit does not rewrite every line ending.realpathso a symlink planted in the sandbox cannot reach through it./mnt/data-style habit paths remap exactly as the pythonsitecustomizeshim does, so a path that works in one tool works in the other.python'sopen(..., "w")already does, so the cheaper tool must not become the quiet way around that prompt.old_stringcreates a file and refuses to clobber an existing one.Testing
studio/backend/tests/test_edit_file_tool.pycovering replacement, uniqueness, creation, encoding and mode preservation, path containment, and registration._select_request_tools, so the tool reaches the model in both sandboxed and Full access requests.Ten backend tests fail on this branch, but they fail identically on a clean
mainand are unrelated to this change (test_bypass_permissions.py::test_python_sandboxed_uses_sandbox_preexec_and_safe_envand nine intest_sandbox_files_and_storage_roots.py).Not covered here
The same report also mentions the UI getting sluggish and IME input lagging once a chat passes roughly 1k tokens. I looked, and the IME handling already looks thorough: composition guards in both composers,
keyCode === 229, a stuck-composition watchdog, and mid-composition DOM mirroring. Composer text also lives in a store separate from the message list, so keystrokes are not re-rendering the thread. That points at render cost in the message list at long contexts, which needs its own reproduction and its own PR.