10. Coding Tools: read, edit, write, bash
What separates a coding agent from an ordinary tool-using agent shows up most in the write operations. Reading files and searching let the model observe the world; edit, write, and bash let it change the world and verify the results. This is also where the risk is highest: editing the wrong file, clobbering the user's changes, running dangerous commands, producing oversized output, and hung test processes all happen in real repositories.
Read before you edit
Both the system prompt and the tool runtime should reinforce one rule: read the target file before editing it. If the model calls edit on a file it has not read, the runtime can refuse and return an error tool result:
Cannot edit src/config.ts because it has not been read in this session. Read the file first, then retry with exact oldText.
This error is not conservatism. The reliability of edit depends on exact context. When the model has not read the file, it usually guesses at the shape of the code, which leads to failed replacements or wrong edits.
read itself has a few details that must be handled. Line offset and line count limits let the model read a large file in chunks; a default truncation can take the first 2000 lines or 50KB, whichever comes first, keeping the head. A BOM at the start of a file must be stripped, or the first line the model sees will not match the bytes on disk, and a later edit's exact match will fail for no obvious reason. If the model supports image input, read can also recognize image files and return image blocks (scaling down oversized images first); when it does not, return a clear explanation rather than binary garbage.
Edit is not "have the model emit a patch"
For a teaching project, start with an exact-replacement edit:
type EditInput = {
path: string;
oldText: string;
newText: string;
};
The runtime reads the current file, checks that oldText appears exactly once, and then replaces it with newText. If it does not appear, return a correctable error; if it appears multiple times, also return an error and ask the model to provide more context. This is far easier to validate than having the model output a patch directly, and it makes self-correction much easier for the model.
But pure exact matching has an annoying failure rate in production, and the reason is not that the model "misremembered the code" — it is that the text gets silently rewritten in transit: model output often turns straight quotes into curly quotes and plain hyphens into Unicode dashes; trailing whitespace gets dropped in rendering; the same character has multiple Unicode composition forms. The mature countermeasure is layered matching: try exact match first; on failure, do a normalized match — Unicode NFKC normalization, stripping trailing whitespace per line, restoring curly quotes to straight quotes, restoring the various Unicode dashes and spaces to ASCII — then look for a unique match. Only if that still fails do you report an error, attaching the closest fragment in the file to help the model locate it.
Failure feedback should be specific:
oldText was not found in src/parser.ts.
The file currently contains a similar line:
return parse(input, options);
Read the latest file contents and retry with exact oldText.
Given feedback like this, the model can usually re-read the file and issue a correct edit.
There is one more silent killer: line endings. For a CRLF file in a Windows repository, if the tool normalizes everything to LF internally and then writes it back as LF, a single edit will mark every line in the whole file as modified, and the diff becomes unrecognizable. The right approach is: detect the original newline style when reading, do the matching and replacement on normalized LF, and restore the original style before writing back. The BOM is the same: strip it so it participates in matching, and restore it as-is when writing back.
A production edit usually also supports submitting multiple replacements at once (an array of edits); the runtime verifies that all the oldText values are mutually non-overlapping and each unique, then applies them all atomically — either all succeed or all fail. A half-applied edit is the hardest state to debug.
The boundaries of write
write is for creating new files or replacing a file wholesale. It is more dangerous than edit because it does not require an oldText match. Recommended policy:
- Creating a new file can be allowed directly, creating parent directories recursively if they do not exist, but the path must still be inside the workspace.
- Before overwriting an existing file, require that the file has been read, or require user confirmation.
- For large file writes, show a diff in the UI.
- After writing, put a summary of the change into the tool result.
Do not use write for small fixes. Small changes go through edit, which lowers the risk of clobbering.
The boundaries of bash
bash is the most powerful and most dangerous tool. Even a minimal implementation needs:
- A confined cwd that stays consistent within the session.
- Timeouts.
- stdout/stderr truncation.
- Exit codes.
- AbortSignal cancellation.
- Confirmation or refusal for dangerous commands.
Timeouts and cancellation share one pitfall: killing a process means killing the whole process tree. npm test spawns a test process, and the test spawns a browser; if you only kill the direct child, the grandchildren keep holding ports and file locks. Correctly killing a process tree across platforms (the mechanisms on Windows and POSIX are completely different) is the dirtiest part of this tool, but there is no way around it. The exit code also has to honestly distinguish three outcomes: the code from a normal exit, the code from a non-zero failure, and the "no exit code" when the process was killed by a timeout or cancellation.
When feeding command output to the model, preserve the command, the exit code, a truncation note, and the key output:
Command: npm run check
Exit code: 1
Output was truncated to the last 200 lines.
src/index.ts:42:10 - error TS2322 ...
The truncation direction here is the opposite of read: keep the tail (for example, the last 2000 lines or 50KB), because compiler errors and test summaries are almost always at the end. When streaming output to the UI, throttle it (for example, at most one refresh every 100 milliseconds), or fast-scrolling logs will hammer the interface to death.
The file write queue
The model may call two write tools in parallel within a single turn. Even if your loop executes them serially, leave the boundary in place for future parallelism. Write operations on the same real path must go into the same queue:
edit src/a.ts -> waits for previous write to src/a.ts
write src/a.ts -> same queue
edit src/b.ts -> can run independently
The queue key should be the resolved real path. Symlinks, relative paths, and case differences can all point to the same file. Without a write queue, two tools will compute their changes against stale content, and whichever hits disk last overwrites the other.
Diffs for humans, summaries for the model
The model does not need a full unified diff to continue; the user interface does. The result of an edit can be split into two layers:
- For the model: the edit succeeded, the file, the line count, and a suggested next step.
- For the UI: a structured diff, the old content, the new content, whether the file was created, and the line number of the first change (so an editor integration can jump straight to it).
If you stuff every full diff into the context, the agent burns through tokens fast. What the model actually needs is "the change is done" and "which tests to run next."
Finally, to close the loop on the foreshadowing from Chapter 3: all five of these tools should reach the filesystem and the shell through an operations interface, rather than calling node:fs and child_process directly. Beyond the local implementation, the same tools can run over SSH to a remote, inside a container, or on an in-memory filesystem for tests — the write queue, newline handling, and truncation policy are all reused.
Exercise
Implement edit, write, and bash.
Acceptance criteria:
editrequiresoldTextto appear exactly once; curly-quote and trailing-whitespace differences are recovered by normalized matching.- A CRLF file is still CRLF after editing, and the diff contains only the genuinely changed lines.
- A failed
editreturns a correctable tool result (including the closest fragment) instead of throwing an uncaught exception. writehas an explicit policy for overwriting existing files.bashhas timeouts, tail truncation, exit codes, and cancellation; a timeout cleans up the entire process tree.- Writes to the same file never overwrite each other in parallel (queued by real path).
- UI events get the diff details, while the model context only gets a short summary.