The Edit Tool
loom can read a file and use what it read in the conversation. But now it wants to change text on line 9. It has no editor and no cursor. The only thing it knows how to do is emit tokens. What technique can you use to edit a file if you are only able to mint tokens?
This question has a number of different answers. Aider documents the trade offs very well. Here is the summary of what it says:
| Format | Idea | Why it hurts |
|---|---|---|
| Whole file | Model re-emits the entire file and overwrites it | Token cost scales with file size, not edit size, and models get “lazy,” emitting // … rest unchanged and destroying the rest. |
| Line numbers | “Replace lines 7–9 with…” | Models miscount lines notoriously and every applied edit shifts all numbers below it, so the second edit of a batch lands wrong. |
| Unified diff | Model emits a patch style diff |
Strict appliers reject the model’s fudged context lines and offsets and lenient ones mis-apply them. (Aider made it work well for GPT-4 Turbo. Format effectiveness tracks the model’s training.) |
| Exact string replacement | “Replace this exact text (which appears once) with this text” | This is what Claude Code’s Edit tool, Pi, and other agents use, and what we’ll build. |
Exact string replacement is what loom will use. The model needs to provide old_string and new_string. The edit tool replaces old_string but does it only if it appears exactly once in the file. If there are zero matches or more than one, then this is an error. This uniqueness rule is what makes this technique work. It forces the model to quote enough surrounding context to pin the location, which then is replaced with new text.
This exact matching rule also serves as self-verification to the model. The edit can only succeed if the model’s picture of the file is accurate. Zero match error, for example, doesn’t just block a bad edit, it diagnoses a potential staleness of the model’s view of the file, that is, the file no longer looks like the last time it read it (maybe someone changed it). This gives the model a hint that maybe it should re-read the file and try again.
Step 1: Your turn - build edit_file
This is going to be the last core tool for loom. Try to build it yourself first. Here is the algorithm:
- Tool arguments:
path,old_string,new_string.pathandnew_stringare required.old_stringmay be empty. - Create file if
old_stringis empty and the file doesn’t exist. Create it with any parent dirs (os.MkdirAll) containingnew_string. This approach makes the edit tool double as the write file tool. - Otherwise, if
old_stringis non-empty, count occurrences ofold_string(strings.Count):- 0:
"error: old_string not found in <path> - re-read the file, it may have changed." - >=2:
"error: old_string appears N times in <path> - include more surrounding context to make it unique" - 1 - replace it
strings.Replace(..., 1)withnew_string.
- 0:
- Every code path returns a result string. For success, it is
"ok, edited <path>"or"ok, created <path>". And for errors (error messages are prompts to the model) they tell the model what to do. - Detailed
Descriptionis the key to this tool. It should say something along the lines of “old_string is the exact text to replace. It must match exactly once, including whitespace. Empty old_string creates a new file…”.
When you are ready to validate your approach or need help, here is the finished code:
...
var registry = []ToolDef{readFileDef, listFilesDef, editFileDef, bashDef}
...
var editFileDef = ToolDef{
Tool: Tool{
Type: "function",
Function: ToolFunction{
Name: "edit_file",
Description: "Edit a file by replacing old_string (which must occur exactly once) " +
"with new_string. If old_string is empty and the file does not exist, " +
"the file is created with new_string as its contents.",
Parameters: json.RawMessage(`{
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path to the file"},
"old_string": {"type": "string", "description": "The exact text to replace. Must match exactly once, including whitespace and indentation. Empty to create a new file."},
"new_string": {"type": "string", "description": "The replacement text"}
},
"required": ["path", "new_string"]
}`),
},
},
Run: editFile,
}
func editFile(args map[string]any) string {
path, ok := args["path"].(string)
if !ok {
return "error: edit_file requires a string 'path' argument"
}
oldStr, _ := args["old_string"].(string)
newStr, ok := args["new_string"].(string)
if !ok {
return "error: edit_file requires a string 'new_string' argument"
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) && oldStr == "" {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "error: " + err.Error()
}
if err := os.WriteFile(path, []byte(newStr), 0o644); err != nil {
return "error: " + err.Error()
}
return "ok, created " + path
}
return "error: " + err.Error()
}
content := string(data)
switch n := strings.Count(content, oldStr); {
case oldStr == "" || n == 0:
return "error: old_string not found in " + path +
" — re-read the file; it may have changed"
case n > 1:
return fmt.Sprintf("error: old_string appears %d times in %s — "+
"include more surrounding context to make it unique", n, path)
}
content = strings.Replace(content, oldStr, newStr, 1)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return "error: " + err.Error()
}
return "ok, edited " + path
}
...
Notice a subtle case, if old_string is empty but the file already exists, the code explicitly catches oldStr == "" in the switch statement. This forces a not found error instead of incorrectly mutating the file.
Step 2: Test it
loom now has a full set of tools: read_file, list_files, bash, edit_file. It’s time to watch all of them work. Create sample code with a bug in it:
mkdir -p playground && cat > playground/clamp.go <<'EOF'
package playground
// Clamp limits v to the range [lo, hi].
func Clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return lo
}
return v
}
EOF
cat > playground/clamp_test.go <<'EOF'
package playground
import "testing"
func TestClamp(t *testing.T) {
for _, tc := range []struct{ v, lo, hi, want int }{
{5, 0, 10, 5}, {-3, 0, 10, 0}, {15, 0, 10, 10},
} {
if got := Clamp(tc.v, tc.lo, tc.hi); got != tc.want {
t.Errorf("Clamp(%d,%d,%d) = %d, want %d", tc.v, tc.lo, tc.hi, got, tc.want)
}
}
}
EOF
Start loom and give it this instruction:
❯ go run ./cmd/loom
loom v0.4 — chatting with gemma4:e4b-mlx (ctrl-c to quit)
you: The tests in ./playground are failing. Find the bug, fix it, and prove the fix.
⚙ list_files(map[path:./playground])
⚙ bash(map[command:go test ./playground])
⚙ read_file(map[path:./playground/clamp.go])
⚙ edit_file(map[new_string: return hi old_string: return lo path:./playground/clamp.go])
⚙ edit_file(map[new_string: return hi old_string: return lo path:./playground/clamp.go])
⚙ edit_file(map[new_string: return hi old_string: return lo path:./playground/clamp.go])
⚙ edit_file(map[new_string: return hi old_string: return lo path:./playground/clamp.go])
⚙ edit_file(map[new_string: return hi old_string: return lo path:./playground/clamp.go])
⚙ edit_file(map[new_string: return hi old_string: return lo path:./playground/clamp.go])
⚙ edit_file(map[new_string:if v > hi {
return hi old_string:if v > hi {
return lo path:./playground/clamp.go])
⚙ bash(map[command:go test ./playground])
loom: The bug has been successfully fixed and proven.
1. **The Bug:** The `Clamp` function in `./playground/clamp.go` incorrectly returned `lo` when the input value `v` exceeded the upper bound `hi`.
2. **The Fix:** I updated the logic to return `hi` in this scenario.
3. **Proof:** Running the tests after the modification confirmed that all tests now pass.
The final test run shows:
ok example.com/loom/playground 0.263s
Look at the output from your code. What you want to see is a similar flow: bash(go test ./playground/) -> read the failure -> read_file(playground/clamp.go) -> edit_file to flip the second return lo to return hi -> bash(test ./playground/) again -> green -> a sentence telling you what was done.
Now let’s try creating a new file:
you: add a playground/abs.go with an Abs function and a test, then run the tests
⚙ edit_file(map[new_string:package playground
// Abs returns the absolute value of x.
func Abs(x int) int {
if x < 0 {
return -x
}
return x
}
old_string: path:./playground/abs.go])
⚙ edit_file(map[new_string:package playground
import "testing"
func TestAbs(t *testing.T) {
tests := []struct {
input int
want int
}{
{-5, 5},
{5, 5},
{0, 0},
{-10, 10},
}
for _, tt := range tests {
t.Run(string(rune(tt.input)), func(t *testing.T) {
got := Abs(tt.input)
if got != tt.want {
t.Errorf("Abs(%d) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
old_string: path:./playground/abs_test.go])
⚙ bash(map[command:go test ./playground])
loom: I have successfully created `playground/abs.go` and `playground/abs_test.go` and then ran the tests.
The test run was successful. The `Abs` function is now integrated and tested in the playground.
**Files created:**
* `playground/abs.go` (containing the `Abs` function)
* `playground/abs_test.go` (containing the test for `Abs`)
**Test results:**
The tests ran without failure (`ok`).
Everything works!
What’s Next
With the core set of tools loom is a fully functional coding agent. Every feature beyond this, that Claude Code or Codex provides, is a refinement of what we have built: better prompts, better model, safety, UX.
We are not stopping either, and we’re going to add a number of these refinements to loom in coming lessons. The next lesson will deal with the system prompt and the context window size.
Code
You can find full code on GitHub.