package tools import ( "context" "fmt" "os" "path/filepath" "strings" "mm/internal/detector" "mm/internal/fileedit" "mm/internal/spinner" "mm/internal/ui" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" ) // --- read_file / write_file / edit_file --------------------------------------- // // Three built-in tools with the rules of the `edit` CLI (tools/edit). Part 05 // gave the model that CLI through bash; this part gives it the same operations // as tools, so the two can be COMPARED: does a 12B model edit more reliably // when the operation is a tool it can see, or a command it has to spell? // // Same contract as `bash`: a failure is returned AS TEXT, never as a Go error, // so the model reads why and retries; every call prints one scannable line // before running and echoes its result to the user before the model sees it. // maxDisplayWidth is where a display line is cut — the same hundred columns as // the command recap: it fits a demo terminal without wrapping. const maxDisplayWidth = 100 // clip folds a display line onto ONE line and truncates it. A path can be // long, an old text longer: the line is there to be scanned, not read. func clip(s string) string { s = strings.Join(strings.Fields(s), " ") if r := []rune(s); len(r) > maxDisplayWidth { return string(r[:maxDisplayWidth-1]) + "…" } return s } // announce prints the display line of a file tool, the way `bash` prints its // 🛠️ line: under the spinner's Suspend so the label comes back for the wait // that follows, and under printMu so two tools of one turn do not interleave. func announce(line string) { spinner.Suspend(func() { printMu.Lock() fmt.Printf("\n%s\n", clip(line)) printMu.Unlock() }) } // record feeds the loop detector like `bash` does, and appends its instruction // to the output when a loop is detected: the same edit refused three times in // a row is the model going in circles, exactly like a failing command. func record(d *detector.LoopDetector, tool, input, out string) string { if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) { return fmt.Sprintf("%s\n\n%s", out, d.LoopError()) } return out } type readFileInput struct { Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"` Start int `json:"start,omitempty" jsonschema_description:"First line to return (1-based); 0 or absent = from the start"` End int `json:"end,omitempty" jsonschema_description:"Last line to return (1-based, inclusive); 0 or absent = to the end"` Numbered bool `json:"numbered,omitempty" jsonschema_description:"Prefix each line with its number — useful to pick a range for a second read"` } // ReadFile declares the `read_file` tool. func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { description := `Read a text file, or a range of its lines. 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. 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.` return genkit.DefineTool(g, "read_file", description, func(_ *ai.ToolContext, in readFileInput) (string, error) { where := in.Path if in.Start > 0 || in.End > 0 { where += fmt.Sprintf(" %d-%d", in.Start, in.End) } if s := ui.ActiveSink(); s != nil { return readFileEvented(s, d, in, where) } announce("📄 read_file: " + where) content, err := fileedit.Read(in.Path, in.Start, in.End, in.Numbered) if err != nil { // Information, not a crash: the message says how to fix the call. out := err.Error() spinner.Suspend(func() { echoOutput(out) }) return record(d, "read_file", where, out), nil } out := truncate(content) // Shown to the user like a command's output: "show me that file" // means show it, whatever tool read it. spinner.Suspend(func() { echoOutput(out) }) return record(d, "read_file", where, out), nil }) } type writeFileInput struct { Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory; parent directories are created"` Content string `json:"content" jsonschema_description:"The WHOLE content of the file"` } // WriteFile declares the `write_file` tool. func WriteFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { description := `Write the WHOLE content of a file: create it, or rewrite it entirely and on purpose. 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. Returns a one-line summary and the diff against the previous content. Writing identical content changes nothing and says so.` return genkit.DefineTool(g, "write_file", description, func(tctx *ai.ToolContext, in writeFileInput) (string, error) { if s := ui.ActiveSink(); s != nil { return writeFileEvented(tctx, s, d, in) } announce(fmt.Sprintf("💾 write_file: %s (%d bytes)", in.Path, len(in.Content))) r, err := fileedit.Write(in.Path, in.Content) if err != nil { out := err.Error() spinner.Suspend(func() { echoOutput(out) }) return record(d, "write_file", in.Path, out), nil } return report(d, "write_file", in.Path, r), nil }) } type editFileInput struct { Path string `json:"path" jsonschema_description:"Path of an EXISTING file, relative to the working directory"` Edits []fileedit.Edit `json:"edits" jsonschema_description:"The replacements, all resolved against the ORIGINAL file; two edits must not overlap"` DryRun bool `json:"dry_run,omitempty" jsonschema_description:"Show what would change and write nothing"` } // EditFile declares the `edit_file` tool. func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it. Each edit has an old text and a new text. The rules, which are what makes an edit verifiable: 1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file. 2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique. 3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote. 4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit. 5. An empty new deletes the matched text. 6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines. 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.` return genkit.DefineTool(g, "edit_file", description, func(tctx *ai.ToolContext, in editFileInput) (string, error) { what := fmt.Sprintf("%s (%d edit(s))", in.Path, len(in.Edits)) if in.DryRun { what += " dry-run" } if s := ui.ActiveSink(); s != nil { return editFileEvented(tctx, s, d, in, what) } announce("📝 edit_file: " + what) r, err := fileedit.Replace(in.Path, in.Edits, in.DryRun) if err != nil { out := err.Error() spinner.Suspend(func() { echoOutput(out) }) return record(d, "edit_file", editKey(in), out), nil } return report(d, "edit_file", editKey(in), r), nil }) } // editKey is what the loop detector compares for edit_file: the path AND the // edits. Two calls with the same path but different old texts are the model // correcting itself, not looping. func editKey(in editFileInput) string { var b strings.Builder b.WriteString(in.Path) for _, e := range in.Edits { b.WriteString("\x00" + e.Old + "\x00" + e.New) } return b.String() } // --- the same three tools, for an event-consuming front end (ACP) ------------- // // The information is the same as the terminal path prints; only the channel // changes. Two things exist here that the terminal never had: write_file and // edit_file ask the client's user for permission before touching a file, and a // successful change travels as a structured diff the editor can render. Paths // resolve against the SESSION's working directory (the project open in the // editor), not the process's — that is ui.Resolve. // absolute is the location a client can jump to: ACP wants absolute paths. func absolute(path string) string { if abs, err := filepath.Abs(path); err == nil { return abs } return path } func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, where string) (string, error) { path := ui.Resolve(in.Path) ev := ui.ToolEvent{ ID: ui.NextCallID(), Tool: "read_file", Title: "read_file: " + where, Kind: "read", Input: map[string]any{"path": in.Path}, Path: absolute(path), } // Reading is not gated: the permission dialog exists to protect the user's // files from changes and their shell from commands, and asking before every // read teaches them to stop reading the dialogs. s.ToolStart(ev) s.ToolRunning(ev.ID) content, err := fileedit.Read(path, in.Start, in.End, in.Numbered) if err != nil { out := err.Error() s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) return record(d, "read_file", where, out), nil } out := truncate(content) s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) return record(d, "read_file", where, out), nil } func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) { path := ui.Resolve(in.Path) ev := ui.ToolEvent{ ID: ui.NextCallID(), Tool: "write_file", Title: fmt.Sprintf("write_file: %s (%d bytes)", in.Path, len(in.Content)), Kind: "edit", Input: map[string]any{"path": in.Path}, Path: absolute(path), } s.ToolStart(ev) if !s.Allow(ctx, ev) { out := "[write_file rejected by the user]" s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) return record(d, "write_file", in.Path, out), nil } s.ToolRunning(ev.ID) // The old content is read BEFORE the write, for the diff the client // renders. Best effort: an unreadable file simply means "created". before, readErr := os.ReadFile(path) r, err := fileedit.Write(path, in.Content) if err != nil { out := err.Error() s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) return record(d, "write_file", in.Path, out), nil } out := resultText(r) res := ui.ToolResult{Output: out} if r.Changed { res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil} } s.ToolEnd(ev.ID, res) return record(d, "write_file", in.Path, truncate(out)), nil } func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) { path := ui.Resolve(in.Path) ev := ui.ToolEvent{ ID: ui.NextCallID(), Tool: "edit_file", Title: "edit_file: " + what, Kind: "edit", Input: map[string]any{"path": in.Path, "edits": len(in.Edits), "dry_run": in.DryRun}, Path: absolute(path), } s.ToolStart(ev) if !s.Allow(ctx, ev) { out := "[edit_file rejected by the user]" s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) return record(d, "edit_file", editKey(in), out), nil } s.ToolRunning(ev.ID) before, _ := os.ReadFile(path) r, err := fileedit.Replace(path, in.Edits, in.DryRun) if err != nil { // A refused edit (text not found, ambiguous, overlapping) is the // NORMAL path of this tool: failed status for the client, text for the // model — which reads the rule and fixes its `old`. out := err.Error() s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) return record(d, "edit_file", editKey(in), out), nil } out := resultText(r) res := ui.ToolResult{Output: out} if r.Changed && !in.DryRun { after, readErr := os.ReadFile(path) if readErr == nil { res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: string(after)} } } s.ToolEnd(ev.ID, res) return record(d, "edit_file", editKey(in), truncate(out)), nil } // resultText is what report() builds for the model, without the echo: the // headline, then the numbered diff. func resultText(r fileedit.Result) string { out := r.Headline if r.Diff != "" { out += "\n" + strings.TrimRight(r.Diff, "\n") } return out } // report echoes a write/edit result to the user — headline then diff, dimmed // and capped by previewLines like a command's output — and returns the same // text to the model, capped by maxOutput. The user sees the change BEFORE the // model comments on it: what the file became no longer depends on what the // model chooses to say about it. func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string { out := resultText(r) spinner.Suspend(func() { echoOutput(out) }) return record(d, tool, key, truncate(out)) }