Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

edit_file

Writes a text file: the whole file, an addition at the end, or one exact replacement. The counterpart of read_file: read a page, then replace exactly what was shown. Decided in ADR 0024.

Arguments

NameTypeRequiredMeaning
pathstringyesPath of the file. Its directory must exist; edit_file never creates directories.
modestringyeswrite replaces the whole file, creating it if absent. append adds to the end, creating it if absent. replace changes one line, by number or by exact text.
contentstringyesThe text to write or append; for replace with line, the whole new line (see “Line edits” for the one case its indentation is supplied); for replace with find alone, the replacement for find only.
lineintegerfor replaceThe 1-based line to rewrite, as read_file numbered it. Past the end, nothing changes. Preferred: the model copies a number it just read instead of retyping the text.
findstringfor replaceWithout line: the exact existing text to replace, which must occur exactly once. With line: text that line must contain (an empty find checks nothing); when it does not, the one line of the file that does is edited instead, and when none or several do, nothing changes.

Read the file first, then replace by line:

Use read_file to read /repo/Package.swift. Then use edit_file with mode replace on /repo/Package.swift,
with line set to the number read_file showed for `let version = "0.1.0"` and content `let version = "0.2.0"`.
Report the tool results verbatim.

Line edits

replace with line writes content as given, with two exceptions for the slips small models make most (measured with Qwen3-1.7B on 2026-10-06, ADR 0024, refined that day). Neither guesses: each acts only on an exact condition, and the result says what it did.

RuleWhenWhat is written
The line keeps its indentationcontent starts with neither a space nor a tab, is not empty, and the old line starts with spaces or tabs, and the two differ by more than whitespace at their endsThe old line’s indentation, then content (asked for return 10, the model sent return 10: return 10 is written)
A stale number with findThe numbered line does not contain find, and exactly one line of the file does (once or more on that line)That line, rewritten; the indentation rule applies to it too

Every other line edit is written exactly, so a deliberate whitespace change works: indenting a line (return 1 to return 1), dedenting one ( y = 2 to y = 2, the stripped texts being equal), tabs to spaces, trailing spaces stripped, or an indented line emptied. When find is on no line or on several, or the number is past the end, nothing changes, with the errors below.

The blind spot: a text change and a dedent to column zero in one edit ( return 1 to print(1) at the margin) keeps the indentation. The result says so, and the model can make the edit in two steps, or by find.

Line endings

replace numbers lines exactly as read_file does: a line ends at \n, \r\n, or a lone \r (a classic Mac OS file). Lines are split on the file’s bytes, never on Swift’s Character, which takes \r\n as one grapheme; until 2026-10-12 a CRLF file was one line to edit_file, so a line past 1 was refused and an edit of line 1 replaced the whole file (ADR 0024, refined that day).

CaseWhat is written
A line editThe line’s text; its own ending is kept, so a CRLF file stays CRLF and a mixed file keeps each line’s ending. One trailing ending of any kind on content is dropped; any other line break in it is refused
A line break in findMatches any ending in the file (a\nb finds a\r\nb), and a match never splits \r from its \n
A line break in the replacementWritten with the file’s dominant ending: the one most of its lines end with, ties going to \n and then \r\n; \n for a file with no line break at all. A CRLF file gets CRLF lines however the model wrote them
No final line breakStays without one, unless the edit itself adds one
Everything outside the editWritten back byte for byte, invalid UTF-8 included

write and append write content exactly as given, line endings included, and never read the file.

A guard makes a wider change impossible: before anything is written, the edited file is split again and compared with the original, and an edit whose result changes a line it does not name, has more or fewer line breaks than the edit removes and adds, or changes the ending of the line it touches is refused with nothing written (error: edit at line 1 refused: the file would have 0 line breaks, not 3, more than the edit names; nothing changed). Every edit is built not to reach it; it exists so that a fault in that construction refuses rather than loses data.

Result

One line, and for a replacement the edited line as it now reads, quoted with tabs, quotes, backslashes, line feeds, and carriage returns escaped and cut at 200 characters (a replacement by find that spans lines shows the first three and says how many more):

created /repo/notes.md; now 42 bytes
appended to /repo/notes.md; now 60 bytes
replaced at line 3 of /repo/notes.md; now 58 bytes; line 3 now: "opening paragraph"
replaced at line 2 of /repo/b.py, keeping the line's indentation (4 spaces); now 42 bytes; line 2 now: "    return 10"
line 2 did not contain "return 1"; replaced line 3 of /repo/b.py, the one line that does; now 42 bytes; line 3 now: "    return 10"

Errors come back as text so the model can react: error: text to replace not found: …, error: text to replace occurs 2 times, include more surrounding text: …, error: cannot write …: outside the writable directories (…), error: edit not approved: …, and the guard’s error: edit at line N refused: …; nothing changed.

Confinement

Writes land only under the directories the sandbox lets run_command write under: the directory wisp was launched in, the temporary directory, /private/tmp, the user cache directory, and sandbox.writablePaths from config.json. The check canonicalises the path (symlinks resolved, so /tmp is /private/tmp) and refuses anything else before touching the file system. With the sandbox off (sandbox.enabled: false or --unsafe) there is no confinement, as for commands, with one exception: wisp’s own home (~/.wisp, or WISP_HOME) is never written, sandbox or not and inside the writable set or not (error: cannot write …: it is in wisp's own home, which edits never change), as the sandbox denies it to commands.

Approval

Every edit is judged as the command edit_file <mode> <path> by the full classifier, rules and, when configured, the model. The rules rate every edit at least moderate (“edits a file”) and credential paths (.ssh/, .aws/credentials, .netrc, keys) dangerous, so under the default threshold the first edit in a conversation asks and the answer’s scope applies to later ones: “this session” covers every edit_file call, project and always scopes are keyed to the pattern edit_file * as for a program. Where nobody can answer the edit is refused and the file is untouched.

Limits

LimitValue
replace file size1 MiB; larger files are refused (FileWriter(maxBytes:), fixed in code)
Line editsOne line per call: content is that line, one trailing line ending (\n, \r\n, or \r) is dropped, and any other line break (a pasted neighbour or the [end of file] marker) is refused with nothing changed; a line that is not there is an error, and so is one that does not contain find unless exactly one other line does
Shown linesThe edited line, or the first three lines a find replacement covers, each cut at 200 characters
Binary filesRefused for replace (a NUL byte); write and append do not read the file
Directories, missing parent directory, unknown mode, replace without findErrors
AtomicityEvery write goes to a temporary file beside the target (same directory, the existing mode copied) and is renamed over it, so a reader never sees a partial file and a failure leaves the original untouched

Audit and receipts

Each edit that happens is recorded as file.write with path, mode, created, bytesBefore, and bytesAfter (logging.md); the MCP receipt lists it under files. The content itself is in the tool.call event’s arguments.

Measured with the model

The eval (ToolEvalTests, scripts/check eval) runs the 86 replace cases of edit_file.json, each once: files in 35 languages and formats of 2 to 81 lines, one line named by its text, two neighbouring lines, a line emptied or a fragment cut, an empty line filled, near-duplicate lines, non-ASCII text, CRLF files edited by line number and by find, and stale line numbers reconciled by find. A pass is the file ending up exactly as intended. The release runs a fixed subset of 36 spread over those shapes, against a floor set from the configured model’s history (47 of 86 for the whole set, 17 of 36 for the subset); WISP_EVAL_FULL=1 runs all of them (measurements.md, “The case sets” and “The floors”); the recorded result is also on wisp tools --markdown. Until 0.23.0 it was ten small files, three attempts each. The line argument exists because the first version, replace by find only, measured 3 of 5 on 2026-09-20: the model copied find correctly and then put the neighbouring line into content too. A write outside the writable set is refused with the directories named and no file created. A second measurement, edit_file.whitespace (24 cases, a subset of 8 in the release, floors 18 and 5), rewrites only a line’s whitespace: indent, dedent, tabs to spaces and back, trailing spaces or a tab stripped; it is kept apart so edit_file.replace stays comparable across runs. The line-edit rules above were measured before and after on 2026-10-06 (measurements.md, “edit_file’s line rules”).

Implementation

FileWriter in harness/Sources/WispCore/Tools/FileWriter.swift (confinement from CommandPolicy.writableRoots, the list the Seatbelt profile is built from), tested in FileWriterTests, with lines split by TextLines (TextLines.swift), tested with read_file’s numbering in LineEndingTests; EditFileTool is the model-facing wrapper, tested in ToolWrapperTests.