| 💾 Saved. d722711 k33g 7h ago | 1 | package tools |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | |
| 10 | "mm/internal/detector" |
| 11 | "mm/internal/fileedit" |
| 12 | "mm/internal/spinner" |
| 13 | "mm/internal/ui" |
| 14 | |
| 15 | "github.com/firebase/genkit/go/ai" |
| 16 | "github.com/firebase/genkit/go/genkit" |
| 17 | ) |
| 18 | |
| 19 | // --- read_file / write_file / edit_file --------------------------------------- |
| 20 | // |
| 21 | // Three built-in tools with the rules of the `edit` CLI (tools/edit). Part 05 |
| 22 | // gave the model that CLI through bash; this part gives it the same operations |
| 23 | // as tools, so the two can be COMPARED: does a 12B model edit more reliably |
| 24 | // when the operation is a tool it can see, or a command it has to spell? |
| 25 | // |
| 26 | // Same contract as `bash`: a failure is returned AS TEXT, never as a Go error, |
| 27 | // so the model reads why and retries; every call prints one scannable line |
| 28 | // before running and echoes its result to the user before the model sees it. |
| 29 | |
| 30 | // maxDisplayWidth is where a display line is cut — the same hundred columns as |
| 31 | // the command recap: it fits a demo terminal without wrapping. |
| 32 | const maxDisplayWidth = 100 |
| 33 | |
| 34 | // clip folds a display line onto ONE line and truncates it. A path can be |
| 35 | // long, an old text longer: the line is there to be scanned, not read. |
| 36 | func clip(s string) string { |
| 37 | s = strings.Join(strings.Fields(s), " ") |
| 38 | if r := []rune(s); len(r) > maxDisplayWidth { |
| 39 | return string(r[:maxDisplayWidth-1]) + "…" |
| 40 | } |
| 41 | return s |
| 42 | } |
| 43 | |
| 44 | // announce prints the display line of a file tool, the way `bash` prints its |
| 45 | // 🛠️ line: under the spinner's Suspend so the label comes back for the wait |
| 46 | // that follows, and under printMu so two tools of one turn do not interleave. |
| 47 | func announce(line string) { |
| 48 | spinner.Suspend(func() { |
| 49 | printMu.Lock() |
| 50 | fmt.Printf("\n%s\n", clip(line)) |
| 51 | printMu.Unlock() |
| 52 | }) |
| 53 | } |
| 54 | |
| 55 | // record feeds the loop detector like `bash` does, and appends its instruction |
| 56 | // to the output when a loop is detected: the same edit refused three times in |
| 57 | // a row is the model going in circles, exactly like a failing command. |
| 58 | func record(d *detector.LoopDetector, tool, input, out string) string { |
| 59 | if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) { |
| 60 | return fmt.Sprintf("%s\n\n%s", out, d.LoopError()) |
| 61 | } |
| 62 | return out |
| 63 | } |
| 64 | |
| 65 | type readFileInput struct { |
| 66 | Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"` |
| 67 | Start int `json:"start,omitempty" jsonschema_description:"First line to return (1-based); 0 or absent = from the start"` |
| 68 | End int `json:"end,omitempty" jsonschema_description:"Last line to return (1-based, inclusive); 0 or absent = to the end"` |
| 69 | Numbered bool `json:"numbered,omitempty" jsonschema_description:"Prefix each line with its number — useful to pick a range for a second read"` |
| 70 | } |
| 71 | |
| 72 | // ReadFile declares the `read_file` tool. |
| 73 | func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { |
| 74 | description := `Read a text file, or a range of its lines. |
| 75 | Read before you edit: edit_file needs the EXACT text of the file, and you cannot match text you have not seen. Do not type it from memory. |
| 76 | Use start/end for a slice of a long file, and numbered=true to get line numbers. Long content is truncated, and the text says so.` |
| 77 | |
| 78 | return genkit.DefineTool(g, "read_file", description, |
| 79 | func(_ *ai.ToolContext, in readFileInput) (string, error) { |
| 80 | where := in.Path |
| 81 | if in.Start > 0 || in.End > 0 { |
| 82 | where += fmt.Sprintf(" %d-%d", in.Start, in.End) |
| 83 | } |
| 84 | if s := ui.ActiveSink(); s != nil { |
| 85 | return readFileEvented(s, d, in, where) |
| 86 | } |
| 87 | announce("📄 read_file: " + where) |
| 88 | |
| 89 | content, err := fileedit.Read(in.Path, in.Start, in.End, in.Numbered) |
| 90 | if err != nil { |
| 91 | // Information, not a crash: the message says how to fix the call. |
| 92 | out := err.Error() |
| 93 | spinner.Suspend(func() { echoOutput(out) }) |
| 94 | return record(d, "read_file", where, out), nil |
| 95 | } |
| 96 | out := truncate(content) |
| 97 | // Shown to the user like a command's output: "show me that file" |
| 98 | // means show it, whatever tool read it. |
| 99 | spinner.Suspend(func() { echoOutput(out) }) |
| 100 | return record(d, "read_file", where, out), nil |
| 101 | }) |
| 102 | } |
| 103 | |
| 104 | type writeFileInput struct { |
| 105 | Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory; parent directories are created"` |
| 106 | Content string `json:"content" jsonschema_description:"The WHOLE content of the file"` |
| 107 | } |
| 108 | |
| 109 | // WriteFile declares the `write_file` tool. |
| 110 | func WriteFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { |
| 111 | description := `Write the WHOLE content of a file: create it, or rewrite it entirely and on purpose. |
| 112 | On an existing file it overwrites everything, including what you did not intend to touch: to change part of a file, use edit_file instead. |
| 113 | Returns a one-line summary and the diff against the previous content. Writing identical content changes nothing and says so.` |
| 114 | |
| 115 | return genkit.DefineTool(g, "write_file", description, |
| 116 | func(tctx *ai.ToolContext, in writeFileInput) (string, error) { |
| 117 | if s := ui.ActiveSink(); s != nil { |
| 118 | return writeFileEvented(tctx, s, d, in) |
| 119 | } |
| 120 | announce(fmt.Sprintf("💾 write_file: %s (%d bytes)", in.Path, len(in.Content))) |
| 121 | |
| 122 | r, err := fileedit.Write(in.Path, in.Content) |
| 123 | if err != nil { |
| 124 | out := err.Error() |
| 125 | spinner.Suspend(func() { echoOutput(out) }) |
| 126 | return record(d, "write_file", in.Path, out), nil |
| 127 | } |
| 128 | return report(d, "write_file", in.Path, r), nil |
| 129 | }) |
| 130 | } |
| 131 | |
| 132 | type editFileInput struct { |
| 133 | Path string `json:"path" jsonschema_description:"Path of an EXISTING file, relative to the working directory"` |
| 134 | Edits []fileedit.Edit `json:"edits" jsonschema_description:"The replacements, all resolved against the ORIGINAL file; two edits must not overlap"` |
| 135 | DryRun bool `json:"dry_run,omitempty" jsonschema_description:"Show what would change and write nothing"` |
| 136 | } |
| 137 | |
| 138 | // EditFile declares the `edit_file` tool. |
| 139 | func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { |
| 140 | description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it. |
| 141 | Each edit has an old text and a new text. The rules, which are what makes an edit verifiable: |
| 142 | 1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file. |
| 143 | 2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique. |
| 144 | 3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote. |
| 145 | 4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit. |
| 146 | 5. An empty new deletes the matched text. |
| 147 | 6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines. |
| 148 | Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.` |
| 149 | |
| 150 | return genkit.DefineTool(g, "edit_file", description, |
| 151 | func(tctx *ai.ToolContext, in editFileInput) (string, error) { |
| 152 | what := fmt.Sprintf("%s (%d edit(s))", in.Path, len(in.Edits)) |
| 153 | if in.DryRun { |
| 154 | what += " dry-run" |
| 155 | } |
| 156 | if s := ui.ActiveSink(); s != nil { |
| 157 | return editFileEvented(tctx, s, d, in, what) |
| 158 | } |
| 159 | announce("📝 edit_file: " + what) |
| 160 | |
| 161 | r, err := fileedit.Replace(in.Path, in.Edits, in.DryRun) |
| 162 | if err != nil { |
| 163 | out := err.Error() |
| 164 | spinner.Suspend(func() { echoOutput(out) }) |
| 165 | return record(d, "edit_file", editKey(in), out), nil |
| 166 | } |
| 167 | return report(d, "edit_file", editKey(in), r), nil |
| 168 | }) |
| 169 | } |
| 170 | |
| 171 | // editKey is what the loop detector compares for edit_file: the path AND the |
| 172 | // edits. Two calls with the same path but different old texts are the model |
| 173 | // correcting itself, not looping. |
| 174 | func editKey(in editFileInput) string { |
| 175 | var b strings.Builder |
| 176 | b.WriteString(in.Path) |
| 177 | for _, e := range in.Edits { |
| 178 | b.WriteString("\x00" + e.Old + "\x00" + e.New) |
| 179 | } |
| 180 | return b.String() |
| 181 | } |
| 182 | |
| 183 | // --- the same three tools, for an event-consuming front end (ACP) ------------- |
| 184 | // |
| 185 | // The information is the same as the terminal path prints; only the channel |
| 186 | // changes. Two things exist here that the terminal never had: write_file and |
| 187 | // edit_file ask the client's user for permission before touching a file, and a |
| 188 | // successful change travels as a structured diff the editor can render. Paths |
| 189 | // resolve against the SESSION's working directory (the project open in the |
| 190 | // editor), not the process's — that is ui.Resolve. |
| 191 | |
| 192 | // absolute is the location a client can jump to: ACP wants absolute paths. |
| 193 | func absolute(path string) string { |
| 194 | if abs, err := filepath.Abs(path); err == nil { |
| 195 | return abs |
| 196 | } |
| 197 | return path |
| 198 | } |
| 199 | |
| 200 | func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, where string) (string, error) { |
| 201 | path := ui.Resolve(in.Path) |
| 202 | ev := ui.ToolEvent{ |
| 203 | ID: ui.NextCallID(), |
| 204 | Tool: "read_file", |
| 205 | Title: "read_file: " + where, |
| 206 | Kind: "read", |
| 207 | Input: map[string]any{"path": in.Path}, |
| 208 | Path: absolute(path), |
| 209 | } |
| 210 | // Reading is not gated: the permission dialog exists to protect the user's |
| 211 | // files from changes and their shell from commands, and asking before every |
| 212 | // read teaches them to stop reading the dialogs. |
| 213 | s.ToolStart(ev) |
| 214 | s.ToolRunning(ev.ID) |
| 215 | |
| 216 | content, err := fileedit.Read(path, in.Start, in.End, in.Numbered) |
| 217 | if err != nil { |
| 218 | out := err.Error() |
| 219 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 220 | return record(d, "read_file", where, out), nil |
| 221 | } |
| 222 | out := truncate(content) |
| 223 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) |
| 224 | return record(d, "read_file", where, out), nil |
| 225 | } |
| 226 | |
| 227 | func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) { |
| 228 | path := ui.Resolve(in.Path) |
| 229 | ev := ui.ToolEvent{ |
| 230 | ID: ui.NextCallID(), |
| 231 | Tool: "write_file", |
| 232 | Title: fmt.Sprintf("write_file: %s (%d bytes)", in.Path, len(in.Content)), |
| 233 | Kind: "edit", |
| 234 | Input: map[string]any{"path": in.Path}, |
| 235 | Path: absolute(path), |
| 236 | } |
| 237 | s.ToolStart(ev) |
| 238 | if !s.Allow(ctx, ev) { |
| 239 | out := "[write_file rejected by the user]" |
| 240 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 241 | return record(d, "write_file", in.Path, out), nil |
| 242 | } |
| 243 | s.ToolRunning(ev.ID) |
| 244 | |
| 245 | // The old content is read BEFORE the write, for the diff the client |
| 246 | // renders. Best effort: an unreadable file simply means "created". |
| 247 | before, readErr := os.ReadFile(path) |
| 248 | |
| 249 | r, err := fileedit.Write(path, in.Content) |
| 250 | if err != nil { |
| 251 | out := err.Error() |
| 252 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 253 | return record(d, "write_file", in.Path, out), nil |
| 254 | } |
| 255 | out := resultText(r) |
| 256 | res := ui.ToolResult{Output: out} |
| 257 | if r.Changed { |
| 258 | res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil} |
| 259 | } |
| 260 | s.ToolEnd(ev.ID, res) |
| 261 | return record(d, "write_file", in.Path, truncate(out)), nil |
| 262 | } |
| 263 | |
| 264 | func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) { |
| 265 | path := ui.Resolve(in.Path) |
| 266 | ev := ui.ToolEvent{ |
| 267 | ID: ui.NextCallID(), |
| 268 | Tool: "edit_file", |
| 269 | Title: "edit_file: " + what, |
| 270 | Kind: "edit", |
| 271 | Input: map[string]any{"path": in.Path, "edits": len(in.Edits), "dry_run": in.DryRun}, |
| 272 | Path: absolute(path), |
| 273 | } |
| 274 | s.ToolStart(ev) |
| 275 | if !s.Allow(ctx, ev) { |
| 276 | out := "[edit_file rejected by the user]" |
| 277 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 278 | return record(d, "edit_file", editKey(in), out), nil |
| 279 | } |
| 280 | s.ToolRunning(ev.ID) |
| 281 | |
| 282 | before, _ := os.ReadFile(path) |
| 283 | |
| 284 | r, err := fileedit.Replace(path, in.Edits, in.DryRun) |
| 285 | if err != nil { |
| 286 | // A refused edit (text not found, ambiguous, overlapping) is the |
| 287 | // NORMAL path of this tool: failed status for the client, text for the |
| 288 | // model — which reads the rule and fixes its `old`. |
| 289 | out := err.Error() |
| 290 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 291 | return record(d, "edit_file", editKey(in), out), nil |
| 292 | } |
| 293 | out := resultText(r) |
| 294 | res := ui.ToolResult{Output: out} |
| 295 | if r.Changed && !in.DryRun { |
| 296 | after, readErr := os.ReadFile(path) |
| 297 | if readErr == nil { |
| 298 | res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: string(after)} |
| 299 | } |
| 300 | } |
| 301 | s.ToolEnd(ev.ID, res) |
| 302 | return record(d, "edit_file", editKey(in), truncate(out)), nil |
| 303 | } |
| 304 | |
| 305 | // resultText is what report() builds for the model, without the echo: the |
| 306 | // headline, then the numbered diff. |
| 307 | func resultText(r fileedit.Result) string { |
| 308 | out := r.Headline |
| 309 | if r.Diff != "" { |
| 310 | out += "\n" + strings.TrimRight(r.Diff, "\n") |
| 311 | } |
| 312 | return out |
| 313 | } |
| 314 | |
| 315 | // report echoes a write/edit result to the user — headline then diff, dimmed |
| 316 | // and capped by previewLines like a command's output — and returns the same |
| 317 | // text to the model, capped by maxOutput. The user sees the change BEFORE the |
| 318 | // model comments on it: what the file became no longer depends on what the |
| 319 | // model chooses to say about it. |
| 320 | func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string { |
| 321 | out := resultText(r) |
| 322 | spinner.Suspend(func() { echoOutput(out) }) |
| 323 | return record(d, tool, key, truncate(out)) |
| 324 | } |