bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

files.go · 324 lines · 12.9 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 7h ago1package tools
2
3import (
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.
32const 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.
36func 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.
47func 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.
58func 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
65type 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.
73func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
74 description := `Read a text file, or a range of its lines.
75Read 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.
76Use 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
104type 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.
110func 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.
112On an existing file it overwrites everything, including what you did not intend to touch: to change part of a file, use edit_file instead.
113Returns 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
132type 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.
139func 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.
141Each edit has an old text and a new text. The rules, which are what makes an edit verifiable:
1421. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file.
1432. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique.
1443. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote.
1454. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit.
1465. An empty new deletes the matched text.
1476. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines.
148Nothing 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.
174func 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.
193func absolute(path string) string {
194 if abs, err := filepath.Abs(path); err == nil {
195 return abs
196 }
197 return path
198}
199
200func 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
227func 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
264func 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.
307func 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.
320func 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}