| 💾 Saved. d722711 k33g 7h ago | 1 | // Package tools contains the built-in tools the model can call. |
| 2 | // Each `XxxTool(g)` constructor declares the tool in Genkit and returns it. |
| 3 | // |
| 4 | // Shared principle: we NEVER return a Go `error` for a "normal" failure |
| 5 | // (a command that fails, etc.). The error goes back AS TEXT, so that the model |
| 6 | // reads it and reacts. |
| 7 | package tools |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "fmt" |
| 12 | "os/exec" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "time" |
| 16 | |
| 17 | "mm/internal/config" |
| 18 | "mm/internal/detector" |
| 19 | "mm/internal/skills" |
| 20 | "mm/internal/spinner" |
| 21 | "mm/internal/ui" |
| 22 | |
| 23 | "github.com/firebase/genkit/go/ai" |
| 24 | "github.com/firebase/genkit/go/genkit" |
| 25 | ) |
| 26 | |
| 27 | // --- Tool Implementation ---------------------------------------------------- |
| 28 | |
| 29 | // truncate caps a large output, keeping the beginning AND the end. |
| 30 | func truncate(s string) string { |
| 31 | max := config.Cfg.MaxOutput |
| 32 | r := []rune(s) |
| 33 | if len(r) <= max { |
| 34 | return s |
| 35 | } |
| 36 | // 2000 characters kept at the end — but never more than half the budget, |
| 37 | // since maxOutput comes from the config file and may be small. |
| 38 | tailLen := min(2000, max/2) |
| 39 | headLen := max - tailLen |
| 40 | |
| 41 | head := string(r[:headLen]) |
| 42 | tail := string(r[len(r)-tailLen:]) |
| 43 | omitted := len(r) - headLen - tailLen |
| 44 | return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", head, omitted, tail) |
| 45 | } |
| 46 | |
| 47 | // printMu serialises what the tools write to the screen. Genkit runs the calls |
| 48 | // of a single turn IN PARALLEL (one goroutine each), so two commands may want to |
| 49 | // print at the same time; without this lock their outputs interleave line by |
| 50 | // line. |
| 51 | var printMu sync.Mutex |
| 52 | |
| 53 | // echoOutput shows on screen what the command answered. |
| 54 | // |
| 55 | // Without it the agent swallows the output: it goes to the model, which |
| 56 | // summarises it — or does not. Seen on "display the content of agent.yaml": the |
| 57 | // model runs `cat`, considers the request fulfilled, and the user sees nothing. |
| 58 | // But "show me" means show. |
| 59 | // |
| 60 | // Only the first PreviewLines lines are printed: an output can run to thousands |
| 61 | // of lines, and drowning the terminal would be as useless as showing none of |
| 62 | // it. |
| 63 | func echoOutput(out string) { |
| 64 | if config.Cfg.PreviewLines <= 0 { |
| 65 | return |
| 66 | } |
| 67 | out = strings.TrimRight(out, "\n") |
| 68 | if out == "" { |
| 69 | printMu.Lock() |
| 70 | if spinner.Styled() { |
| 71 | fmt.Print("\033[2m (no output)\033[0m\n") |
| 72 | } else { |
| 73 | fmt.Print(" (no output)\n") |
| 74 | } |
| 75 | printMu.Unlock() |
| 76 | return |
| 77 | } |
| 78 | |
| 79 | lines := strings.Split(out, "\n") |
| 80 | shown := lines |
| 81 | if len(lines) > config.Cfg.PreviewLines { |
| 82 | shown = lines[:config.Cfg.PreviewLines] |
| 83 | } |
| 84 | |
| 85 | // Indented and dimmed: one glance must separate what the machine answered |
| 86 | // from what the model says about it. The grey only comes out on a terminal. |
| 87 | dim, off := "", "" |
| 88 | if spinner.Styled() { |
| 89 | dim, off = "\033[2m", "\033[0m" |
| 90 | } |
| 91 | |
| 92 | var b strings.Builder |
| 93 | for _, l := range shown { |
| 94 | b.WriteString(dim + " │ " + l + off + "\n") |
| 95 | } |
| 96 | if len(lines) > len(shown) { |
| 97 | fmt.Fprintf(&b, "%s └ … %d more line(s)%s\n", dim, len(lines)-len(shown), off) |
| 98 | } |
| 99 | |
| 100 | printMu.Lock() |
| 101 | fmt.Print(b.String()) |
| 102 | printMu.Unlock() |
| 103 | } |
| 104 | |
| 105 | // runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` — |
| 106 | // "" meaning the process's own directory, which is the terminal behaviour. |
| 107 | // `failed` reports a non-zero exit or a timeout; the text already says so, but |
| 108 | // a front end that renders statuses (ACP) needs it as a value, not a substring. |
| 109 | func runBash(dir, command string) (output string, failed bool) { |
| 110 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| 111 | defer cancel() |
| 112 | |
| 113 | cmd := exec.CommandContext(ctx, "bash", "-c", command) |
| 114 | cmd.Dir = dir |
| 115 | out, err := cmd.CombinedOutput() |
| 116 | output = truncate(string(out)) |
| 117 | |
| 118 | if ctx.Err() == context.DeadlineExceeded { |
| 119 | return output + "\n[error: 30s timeout exceeded]", true |
| 120 | } |
| 121 | if err != nil { |
| 122 | return output + fmt.Sprintf("\n[exit code: %v]", err), true |
| 123 | } |
| 124 | return output, false |
| 125 | } |
| 126 | |
| 127 | // Bash declares the `bash` tool. |
| 128 | func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { |
| 129 | |
| 130 | description := `Run a shell command and return its output, plus the exit status when it is not zero. |
| 131 | This is a real bash shell: pipes, redirections, && and any installed command are available. |
| 132 | Each call starts a NEW shell in the same directory, so a "cd" does not carry over to the next call — chain with && instead. |
| 133 | Nothing is interactive: there is no stdin, so never use a command that asks a question. |
| 134 | Long output is truncated, and the text says so when it happens. |
| 135 | Read before you write: if you do not know a path or the content of a file, run a command to find out rather than guessing.` |
| 136 | |
| 137 | return genkit.DefineTool(g, "bash", |
| 138 | description, |
| 139 | func(tctx *ai.ToolContext, in bashInput) (string, error) { |
| 140 | // A front end that consumes events (the ACP façade) gets them |
| 141 | // instead of the prints below; the terminal path is unchanged. |
| 142 | if s := ui.ActiveSink(); s != nil { |
| 143 | return bashEvented(tctx, s, d, in) |
| 144 | } |
| 145 | |
| 146 | // Suspend rather than Stop: the model was "Thinking" when it decided |
| 147 | // to call us, and it goes back to thinking as soon as we return the |
| 148 | // output — so the label is restored for the wait that follows. |
| 149 | spinner.Suspend(func() { |
| 150 | printMu.Lock() |
| 151 | fmt.Printf("\n🛠️ bash: %s\n", in.Command) |
| 152 | printMu.Unlock() |
| 153 | }) |
| 154 | |
| 155 | // The command has its own 30s budget, and a slow one looks exactly |
| 156 | // like a hung agent. Retitle for the duration, then hand the line |
| 157 | // back to the "Thinking" wait Suspend restored. |
| 158 | spinner.Start("Running") |
| 159 | out, _ := runBash("", in.Command) |
| 160 | |
| 161 | // The output is shown to the user BEFORE going back to the model: |
| 162 | // what the command answered no longer depends on what the model |
| 163 | // chooses to say about it. |
| 164 | spinner.Suspend(func() { |
| 165 | echoOutput(out) |
| 166 | }) |
| 167 | |
| 168 | spinner.Start("Thinking") |
| 169 | |
| 170 | // The loop detector sees the command AND its output: the same pair |
| 171 | // twice in a row, and it hands the model back an instruction to |
| 172 | // change its approach (see internal/detector). |
| 173 | if d.Record(detector.Action{ |
| 174 | ToolName: "bash", |
| 175 | Input: in.Command, |
| 176 | Output: out, |
| 177 | }) { |
| 178 | return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil |
| 179 | } |
| 180 | |
| 181 | return out, nil |
| 182 | }) |
| 183 | } |
| 184 | |
| 185 | type bashInput struct { |
| 186 | Command string `json:"command" jsonschema_description:"The shell command to run"` |
| 187 | } |
| 188 | |
| 189 | // bashEvented is the `bash` tool when a front end consumes events (ACP): the |
| 190 | // 🛠️ line becomes ToolStart, the grey echo becomes ToolEnd's output — and one |
| 191 | // thing exists here that the terminal never had: the command does not run until |
| 192 | // the client's user allowed it. The refusal goes back to the model AS TEXT, |
| 193 | // like every normal failure in this package, so it can propose something else |
| 194 | // instead of crashing the turn. |
| 195 | func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in bashInput) (string, error) { |
| 196 | ev := ui.ToolEvent{ |
| 197 | ID: ui.NextCallID(), |
| 198 | Tool: "bash", |
| 199 | Title: "bash: " + in.Command, |
| 200 | Kind: "execute", |
| 201 | Input: map[string]any{"command": in.Command}, |
| 202 | } |
| 203 | s.ToolStart(ev) |
| 204 | |
| 205 | if !s.Allow(ctx, ev) { |
| 206 | out := "[command rejected by the user]" |
| 207 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 208 | // Recorded like any other outcome: a model that keeps retrying a |
| 209 | // command the user keeps rejecting is looping. |
| 210 | return record(d, "bash", in.Command, out), nil |
| 211 | } |
| 212 | |
| 213 | s.ToolRunning(ev.ID) |
| 214 | // The session's working directory, not the process's: in an editor, the |
| 215 | // project the user opened is where the commands belong. |
| 216 | out, failed := runBash(s.WorkDir(), in.Command) |
| 217 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) |
| 218 | |
| 219 | return record(d, "bash", in.Command, out), nil |
| 220 | } |
| 221 | |
| 222 | // --- read_skill -------------------------------------------------------------- |
| 223 | |
| 224 | type readSkillInput struct { |
| 225 | Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description (for example \"go-rename\")"` |
| 226 | } |
| 227 | |
| 228 | // ReadSkill declares the `read_skill` tool: it loads one markdown procedure |
| 229 | // from the skills directory. |
| 230 | // |
| 231 | // The whole point is in the DESCRIPTION: it is built from the front matter of |
| 232 | // the files on disk, so the catalogue the model sees is always the directory's |
| 233 | // real content. Nothing to keep in sync by hand — and, unlike a paragraph in |
| 234 | // the system prompt, loading a skill becomes a tool the model can SEE. |
| 235 | // |
| 236 | // Returns nil when the directory holds no skill: an agent with an empty |
| 237 | // catalogue should not advertise the tool at all. |
| 238 | func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRef { |
| 239 | list := skills.List(dir) |
| 240 | if len(list) == 0 { |
| 241 | return nil |
| 242 | } |
| 243 | |
| 244 | return genkit.DefineTool(g, "read_skill", |
| 245 | skills.Catalogue(list), |
| 246 | func(_ *ai.ToolContext, in readSkillInput) (string, error) { |
| 247 | // Event-consuming front end (ACP): same information, as a tool_call. |
| 248 | if s := ui.ActiveSink(); s != nil { |
| 249 | return readSkillEvented(s, d, dir, list, in) |
| 250 | } |
| 251 | |
| 252 | // Same reasoning as `bash`: announce the call without losing the |
| 253 | // "Thinking" wait that resumes the moment the skill is returned. |
| 254 | // Reading a file is fast, so there is no second label here. |
| 255 | spinner.Suspend(func() { |
| 256 | printMu.Lock() |
| 257 | fmt.Printf("\n📖 read_skill: %s\n", in.Name) |
| 258 | printMu.Unlock() |
| 259 | }) |
| 260 | |
| 261 | content, err := skills.Read(dir, in.Name) |
| 262 | if err != nil { |
| 263 | // Unknown skill: information, not a crash. The list of valid |
| 264 | // names goes back AS TEXT so the model can correct itself. |
| 265 | // |
| 266 | // Recorded in the detector like everything else: a model |
| 267 | // asking four times for the same missing skill is looping, |
| 268 | // exactly as it would on a failing command. |
| 269 | out := fmt.Sprintf("No skill named %q. Available skills: %s", |
| 270 | in.Name, strings.Join(skills.Names(list), ", ")) |
| 271 | if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { |
| 272 | return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil |
| 273 | } |
| 274 | return out, nil |
| 275 | } |
| 276 | |
| 277 | // The content is NOT displayed: a skill runs to dozens of lines, it |
| 278 | // is written for the model, and the user already has the useful |
| 279 | // information — which one was loaded. What they want to see are the |
| 280 | // commands that follow from it, and those will show up. |
| 281 | out := truncate(content) |
| 282 | if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { |
| 283 | return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil |
| 284 | } |
| 285 | return out, nil |
| 286 | }) |
| 287 | } |
| 288 | |
| 289 | // readSkillEvented is `read_skill` for an event-consuming front end. No |
| 290 | // permission dialog: it reads a procedure the agent ships, nothing more — and a |
| 291 | // dialog for every skill would teach the user to click "allow" without reading, |
| 292 | // which is worse than no dialog at all. The client still sees the call, kind |
| 293 | // "read", with the loaded content as its output. |
| 294 | func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []skills.Skill, in readSkillInput) (string, error) { |
| 295 | ev := ui.ToolEvent{ |
| 296 | ID: ui.NextCallID(), |
| 297 | Tool: "read_skill", |
| 298 | Title: "read_skill: " + in.Name, |
| 299 | Kind: "read", |
| 300 | Input: map[string]any{"name": in.Name}, |
| 301 | } |
| 302 | s.ToolStart(ev) |
| 303 | s.ToolRunning(ev.ID) |
| 304 | |
| 305 | content, err := skills.Read(dir, in.Name) |
| 306 | if err != nil { |
| 307 | out := fmt.Sprintf("No skill named %q. Available skills: %s", |
| 308 | in.Name, strings.Join(skills.Names(list), ", ")) |
| 309 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 310 | return record(d, "read_skill", in.Name, out), nil |
| 311 | } |
| 312 | |
| 313 | out := truncate(content) |
| 314 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) |
| 315 | return record(d, "read_skill", in.Name, out), nil |
| 316 | } |