Studio: add an edit_file tool so agents stop rewriting whole files by shimmyshimmer · Pull Request #8753 · unslothai/unsloth · GitHub
Skip to content

Studio: add an edit_file tool so agents stop rewriting whole files - #8753

Merged
danielhanchen merged 14 commits into
mainfrom
studio-edit-file-tool
Aug 19, 2026
Merged

Studio: add an edit_file tool so agents stop rewriting whole files#8753
danielhanchen merged 14 commits into
mainfrom
studio-edit-file-tool

Conversation

@shimmyshimmer

Copy link
Copy Markdown
Member

The report

From user feedback on Studio's agent:

It relies on the cat command to create and update files, so handling a single large file results in a lot of unnecessary overhead. In my experience tasks that should be manageable within a 64K-94K context are failing to complete even when exceeding 100K. Because it doesn't use a standard diff format for rewrites, opting instead for cat or Python to apply changes, there is excessive redundancy.

This is accurate. ALL_TOOLS was web_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-file cat > f <<'EOF' heredoc through terminal or open(...).write(...) through python. 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:

tokens
One-line change, cat rewrite 7,750
One-line change, exact-string patch 45
Five edits with re-reads, today 79,390
Five edits with re-reads, with edit_file 8,676

That 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-unique old_string is a hard error naming the match count and writes nothing, so the failure mode is a retry with more context.

Details worth calling out:

  • CRLF, BOM and file mode survive an edit. old_string is matched against normalized text, so a snippet copied out of cat output 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.
  • 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 symlink planted in the sandbox cannot reach through it. /mnt/data-style habit paths remap exactly as the python sitecustomize shim does, so a path that works in one tool works in the other.
  • Under Full access absolute paths resolve, and the schema says so. Without that the model assumes it cannot reach a real checkout and falls straight back to the rewrite, precisely where files are largest.
  • It still prompts in auto mode. python's open(..., "w") already does, so the cheaper tool must not become the quiet way around that prompt.
  • Empty old_string creates a file and refuses to clobber an existing one.

Testing

  • 26 new tests in studio/backend/tests/test_edit_file_tool.py covering replacement, uniqueness, creation, encoding and mode preservation, path containment, and registration.
  • 196 related backend tests pass, frontend typecheck is clean, and all 2,278 frontend tests pass.
  • Verified end to end through _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 main and are unrelated to this change (test_bypass_permissions.py::test_python_sandboxed_uses_sandbox_preexec_and_safe_env and nine in test_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.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +9038 to +9043
if os.path.lexists(target):
try:
existing = os.path.getsize(target)
except OSError:
existing = 1
if existing:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +8936 to +8941
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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread studio/backend/core/inference/tools.py Outdated
Comment on lines +9049 to +9052
error = _edit_file_write(target, new, newline, "")
if error:
return error
return f"Created {name} ({new.count(chr(10)) + 1} lines)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

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.

Fixed

Receipt 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:

before: file 200,007 bytes -> receipt 400,059 chars
after:  file 200,007 bytes -> receipt 481 chars

Characters are now capped per line and over the whole receipt.

replace_all as a non-boolean. Correct and the most dangerous of the set, since it silently corrupts. bool("false") is True, so the string turned the multi-match guard off and rewrote every occurrence:

before: replace_all="false" on 3 matches -> "Edited (3 replacements)", file became b\nb\nb
after:  replace_all="false" on 3 matches -> Error: matches 3 places ..., file untouched

"true" and "false" are mapped since models really do emit them; anything else is refused rather than guessed at.

Non-regular files. Correct, and it hangs rather than just misbehaving. A FIFO reports st_size 0 and then read() blocks forever, and this path has no timeout or cancel event, so the turn cannot be recovered. Confirmed with a 12s watchdog before the fix; now returns Error: 'pipe' is not a regular file. immediately.

Absolute paths already inside the workdir. Correct. With a workdir of /workspace/repo, /workspace/repo/a.py resolved to /workspace/repo/repo/a.py. Paths already contained by the workdir now skip the habit-path remap. I added a test that habit paths outside the workdir still remap, so the narrowing does not switch off the behaviour it narrows.

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 O_NOFOLLOW through directory descriptors, so it narrows the race rather than closing it. Doing it properly with dirfd semantics is worth its own change, needs a Windows story, and would apply equally to the existing python and terminal write paths, which have the same exposure today.

Not taking

Refuse 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 old_string would be refused because the file exists, and no non-empty old_string can match an empty file, so both doors are shut.

empty file exists
  old_string=""  -> would be rejected under the suggested rule
  old_string="x" -> Error: 'old_string' was not found

On the metadata point, _edit_file_write already carries the mode over via copymode, and there are no contents to lose. I have made the behaviour explicit in a docstring and pinned it with a test so it reads as a decision rather than an oversight.

Deferred

__FILES__ envelope for created files. Real gap, but a feature rather than a defect: a file created through edit_file gets no download chip. It needs the sentinel wired on the backend and edit_file registered in the frontend's file-rendering set, and a half-wired sentinel leaks marker text to the model. It also only pays off in UI rendering that I would want to verify visually. Better as its own PR than bolted onto this one.

Also worth noting for whoever reviews: the envelope must only be emitted when the target is inside the session workdir, since under Full access edit_file can write anywhere and the UI can only serve sandbox files.

Testing

Ten 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 main (test_bypass_permissions.py::test_python_sandboxed_uses_sandbox_preexec_and_safe_env and nine in test_sandbox_files_and_storage_roots.py), and no new ones.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

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:

not ok - HF hydration does not overwrite an in-flight user edit
not ok - cross-tab refresh replays after an active HF hydration

Both in tests/credential-persistence.test.ts, around hf-token-store. Nothing on this branch touches that module or anything it imports; the frontend files here are tool-status.ts, code-tool-placement.ts, chat-adapter.ts and message-response-details-sheet.tsx.

The assertions are the tell: one expects false and gets true, the other expects 2 fetch calls and gets 4. A doubled count is shared module state across test files, not a logic error.

It reproduces on demand under concurrency, on this branch:

run 1: pass 2278  fail 0
run 2: pass 2276  fail 2
run 3: pass 2276  fail 2
run 4: pass 2278  fail 0
run 5: pass 2278  fail 0

And, more to the point, on clean main with none of this branch's code, same two names:

main run 1: HF hydration does not overwrite an in-flight user edit; cross-tab refresh replays after an active HF hydration
main run 2: none
main run 3: none
main run 4: HF hydration does not overwrite an in-flight user edit; cross-tab refresh replays after an active HF hydration
main run 5: none
main run 6: none

Reproduce with node --experimental-strip-types --test --test-concurrency=8 "tests/**/*.test.ts" from studio/frontend, a few times. CI's default concurrency on a Linux runner hits it more often than a local single run does, which is why main mostly looks green.

Worth fixing on its own: the file leans on module singletons in hf-token-store and does not reset them between tests, so whether it passes depends on which file the runner schedules alongside it. Happy to open that separately rather than fold an unrelated fix in here.

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.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

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:

FAILED tests/studio/test_studio_text_descender_clipping.py::test_model_selector_trigger_label_uses_leading_tight
E   AssertionError: could not find ModelSelectorTrigger model-name span

Pre-existing on main. It passes on this branch in isolation and fails on a clean origin/main worktree at ea34ac9 with none of this branch's code, which is what CI is really testing since it builds the merge commit:

$ git worktree add /tmp/main-probe origin/main
$ pytest tests/studio/test_studio_text_descender_clipping.py
1 failed, 2 passed

Chat UI Tests -- not an assertion failure. The step "Drive model-picker per-model-config with Playwright" ends with ##[error]The operation was canceled. after 30m19s, so it hit the job timeout. Same model-picker area as the failure above.

Core (HF=latest + TRL=latest) -- tests/test_loss_normalization_contract.py::test_guard_returns_a_count_exactly_when_stock_transformers_would, plus a ModuleNotFoundError. That lane tracks upstream transformers/TRL releases and is training-side. This branch touches no training code; the diff is studio/backend/core/inference/, studio/backend/routes/inference.py and four Studio frontend files.

pip scan-packages :: hf-stack -- supply-chain scanner reporting evidence inside third-party packages (scipy CubicSpline, tokenizers byte_decoder). This branch adds no dependencies and changes no lockfile.

So the four reds are: one flake I reproduced on main, one test already failing on main, one timeout, and two dependency/training lanes unrelated to the diff. Worth someone looking at the model-picker pair independently, since that is two separate jobs pointing at the same component.

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

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\n repeated to the 16MB cap with replace_all:

before: receipt 205 chars | RSS +501 MB | 1.3s
after:  receipt 201 chars | RSS  +48 MB | 0.05s

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 before.find(old_string), so it is exact rather than a scan), and the generator is consumed with islice.

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:

Edited mid.py (1 replacement)
@@ -7998,5 +7998,5 @@
 line7999
-line8000
+CHANGED

Create without clobbering concurrent writers. Right, and it was a genuine hole rather than a theoretical one: I added the expect guard for edits and left the creation path on expect=None, so the one path that writes without reading first had no guard at all. The absent case is now O_EXCL (plus O_NOFOLLOW), so the loser of a race gets an error instead of silently overwriting; filling a zero-byte file goes through the normal guarded write with expect=b"".

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. mkstemp makes the temp file 0600 and copymode had no existing file to copy from, so every file created through the tool landed 0600:

before: 0o600
after:  0o644   (0o666 & ~umask)

Fixed as a consequence of the O_EXCL change above, which takes the mode directly.

Document it in the public request schema. Right, and I had missed the second half of it. enabled_tools now lists edit_file, and bypass_permissions now says that Full access lifts its containment too, not just the python/terminal sandbox. Both are pinned by tests that read the field descriptions off ChatCompletionRequest, so a reword cannot silently drop them.

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: test_bypass_permissions.py::test_python_sandboxed_uses_sandbox_preexec_and_safe_env, nine in test_sandbox_files_and_storage_roots.py, and the test_studio_api.py ones that need a live server. No new failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +8982 to +8983
newline = "\r\n" if crlf and crlf * 2 >= text.count("\n") else "\n"
return text.replace("\r\n", "\n"), newline, bom, ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +9015 to +9021
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@danielhanchen

Copy link
Copy Markdown
Member

Review summary

Verdict: useful, merge after the fixes below (pushed as e7b3b90).

Before / after

Before, edit_file did not exist. Changing one line meant the model re-emitting the whole file through python or a shell heredoc. After, an exact-string replacement plus a bounded receipt. Measured on a 500-line file:

chars the model must emit
whole-file rewrite via python 6587
edit_file call 80
reduction 98.8%

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 old_string is a hard error that writes nothing.

Does it break anything

No 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 test_transformers_version.py / test_stt_ggml_sidecar.py ordering flake under -n 8 that gives an identical 5 failed, 344 passed on both head and base when run in isolation. _is_outside_workdir, the containment primitive, is byte-identical to base and was already used by the python/terminal path-healing, so no existing pathway changed.

Old installs: a caller that never sends edit_file is unaffected, and enabled_tools is a plain List[str] with no enum, so unknown names are ignored rather than rejected. New frontend against an old backend sends "edit_file" and the old backend ignores it - no 422. An old frontend rendering a new persisted chat falls through to the safe default glyph, never a throw. edit_file is an append to _FULL_ACCESS_TOOL_BY_NAME, not a substitution, and is_high_risk_tool_call("edit_file", ...) returns True, so Full access still prompts.

Three bugs found and fixed

All 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. _edit_file_receipt cut a window out of the before text and a second window of the same line count out of the after text. Any edit changing the line count shifts everything below it, so the two windows ended on different text and difflib reported the misalignment as a second hunk. Reproduced on a 400-line file:

@@ -317,4 +318,3 @@
 line317
 line318
-line319

line319 is still in the file, 119 lines from anything the edit touched. The receipt is the only thing the model learns about its edit, so a model that trusts it "restores" a line that never left. Fixed by cutting one window from the old text and replaying the replacement on it.

2. edit_file hung forever on a FIFO. The edit path had the S_ISREG guard but _edit_file_create still used getsize(): a FIFO stats as 0 bytes, fell into the zero-byte branch, and open() on a writerless pipe blocked the turn with no timeout and no cancel event. It also destroyed /dev/null under Full access.

3. A half-written emoji made the tool report itself as nonexistent. A truncated escape survives json.loads as a lone surrogate; _edit_file_write encodes on its first statement, outside every try, and the UnicodeEncodeError was swallowed upstream into Unknown tool: edit_file - the one answer that teaches the model this tool does not exist and sends it straight back to the whole-file rewrite this PR exists to eliminate. The swallow itself is pre-existing (python shows it too) and out of scope; the unguarded encode is PR code, so it is guarded there.

9 regression tests added. 6 of them fail against the unfixed source and pass with the fixes; suite is 53 passed.

Simulation

83 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 mkstemp fd is closed before os.replace (a sharing violation otherwise), an empty old_string can never reach str.replace, and the BOM is stripped out of the matchable text. getattr(os, "O_NOFOLLOW", 0) degrades safely because O_EXCL still carries the no-clobber guarantee. os.replace surfaces a clean error on a Windows read-only destination rather than succeeding as POSIX does - safe either way, and the original survives with no temp file left behind.

On the rejections

Two are worth showing the numbers for, since both items are correct in the abstract:

  • Mixed line endings: reproducible, but I measured 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%.
  • Concurrent edits: 0 lost updates in 300 trials at 1 ms spacing and 0 at 50 and 500 ms, versus 74 of 300 when truly simultaneous. Two edit_file calls in a real session are separated by a model turn.

Not fixed, below the bar

The 200-char per-line cap can render a same-length edit past column 200 as identical -/+ lines; Created ... (N lines) is one too high for a file ending in a newline; the receipt shows a phantom trailing context line from split("\n"). All cosmetic - the bytes on disk are correct in every case. Also pre-existing and shared with python/terminal: _is_outside_workdir compares realpaths case-sensitively, which can reject a legitimate edit on a case-insensitive macOS or Windows volume.

CI

Repo tests (CPU) is pre-existing and flaky, not PR-caused: Backend CI is red on 17 of the 25 most recent main runs with no PR involved. The PR run's logs have expired (HTTP 410), so I substituted the local head-vs-base full-suite comparison above: identical real failures, +47 new passes.

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.
@danielhanchen

Copy link
Copy Markdown
Member

Round update: one fix pushed, one rejected, plus the cross-platform read

65ee3f1c1 closes the only genuine defect this PR still shipped: on a full disk or over quota, creating a file left a truncated file that no later edit_file call could ever repair. Details are in the item threads; the short version is that it was reproduced with a real kernel write failure, not a mock, and the four new tests are discrimination-checked (3 of 4 fail against the reverted source, the fourth is the control).

Counts after the fix: tests/test_edit_file_tool.py 57 passed (53 existing + 4 new). A backend -k tool sweep gives 2594 passed / 5 failed / 10 errors against 2590 / 5 / 10 on the clean head, so exactly +4 and an identical failure set; every one of those failures is pre-existing and unrelated (needs a live server, a storage fixture, grandchild-process timing, or pymupdf). The PR's frontend tests are 16 passed, 0 failed.

Windows and macOS

  • O_NOFOLLOW is correctly guarded with getattr(os, "O_NOFOLLOW", 0). It is absent on Windows and present on macOS, so nothing crashes anywhere; Windows simply gets no final-component symlink protection. Worth noting plain O_NOFOLLOW guards only the last component even on Linux and macOS, but the realpath containment check in _edit_file_resolve already covers the parents.
  • os.replace is safe: the temp comes from tempfile.mkstemp(dir = directory), the destination's own directory, so it is never cross-filesystem. On Windows it maps to MoveFileExW(..., MOVEFILE_REPLACE_EXISTING) without MOVEFILE_COPY_ALLOWED, which is right for same-volume. The one behavioural difference: if the destination is held open by another process without FILE_SHARE_DELETE, the replace raises PermissionError, which is an OSError, so it is caught and returned as a clean Error: cannot write ... with the finally removing the temp. Degraded, not broken. Only POSIX rename documents atomicity; Windows does not.
  • umask behaves as the docstring claims: os.open(target, flags, 0o666) gives 0o666 & ~umask, so 0644 under the usual 022, where mkstemp would have given 0600. That is exactly why the create path uses O_EXCL, and the unlink-on-failure fix preserves it.
  • The new os.remove is Windows-safe: the with block has exited and CPython releases the descriptor even when the closing flush raises, so no open handle blocks the delete, and any residual failure is swallowed by contextlib.suppress(OSError).

Merging is otherwise safe. Nothing on the normal path is wrong, and the rejected item is not reachable on real input.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +9224 to +9226
try:
with os.fdopen(fd, "wb") as fh:
fh.write(payload)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +9237 to +9238
with contextlib.suppress(OSError):
os.remove(target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@danielhanchen

Copy link
Copy Markdown
Member

Merged current main in (8c4f6f618) rather than rebasing. This PR was CONFLICTING / DIRTY and 198 commits behind, so pre-commit.ci - pr was failing at the mergeable check rather than on any hook, and no other workflow could compute a merge ref.

One conflict, tool_loop_controller.py, additive on both sides: your edit_file branches in status_for_tool and awaiting_approval_status, and main's MCP display branches. Both kept, with the specific edit_file check ahead of the generic MCP lookup. Verified the merged behaviour directly rather than trusting the diff: status_for_tool("edit_file", {"path": "/a/b/notes.py"}) gives Editing: notes.py, awaiting_approval_status("edit_file") gives Waiting for approval: file edit, and the python and MCP paths still answer as before.

After the merge: tests/test_edit_file_tool.py 57 passed, tests/test_tool_loop_controller.py 23 passed. A wider -k sweep also surfaced 11 collection errors, all missing optional dependencies (fastmcp and friends) in unrelated modules that my filter pulled in whole; they reproduce without this branch.

One housekeeping note: the worktree had an out-of-band edit to studio/backend/utils/process_lifetime.py (a _signalable guard against a killpg(1) broadcast) whose own comment says it is superseded by the rebuild in #8797. It is unrelated to this PR's 11 files, so I kept it out of the merge rather than letting it ride along here.

@danielhanchen

Copy link
Copy Markdown
Member

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.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 19, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 07d8e1ff2c

ℹ️ 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".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +8945 to +8947
try:
target = os.path.realpath(target)
except (OSError, ValueError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: ed9aeef3d3

ℹ️ 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".

@danielhanchen
danielhanchen merged commit 3cb8ecc into main Aug 19, 2026
@danielhanchen
danielhanchen deleted the studio-edit-file-tool branch August 19, 2026 14:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants