// Package tools contains the built-in tools the model can call. // Each `XxxTool(g)` constructor declares the tool in Genkit and returns it. // // Shared principle: we NEVER return a Go `error` for a "normal" failure // (a command that fails, etc.). The error goes back AS TEXT, so that the model // reads it and reacts. package tools import ( "context" "fmt" "os/exec" "strings" "sync" "time" "mm/internal/config" "mm/internal/detector" "mm/internal/skills" "mm/internal/spinner" "mm/internal/ui" "github.com/firebase/genkit/go/ai" "github.com/firebase/genkit/go/genkit" ) // --- Tool Implementation ---------------------------------------------------- // truncate caps a large output, keeping the beginning AND the end. func truncate(s string) string { max := config.Cfg.MaxOutput r := []rune(s) if len(r) <= max { return s } // 2000 characters kept at the end — but never more than half the budget, // since maxOutput comes from the config file and may be small. tailLen := min(2000, max/2) headLen := max - tailLen head := string(r[:headLen]) tail := string(r[len(r)-tailLen:]) omitted := len(r) - headLen - tailLen return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", head, omitted, tail) } // printMu serialises what the tools write to the screen. Genkit runs the calls // of a single turn IN PARALLEL (one goroutine each), so two commands may want to // print at the same time; without this lock their outputs interleave line by // line. var printMu sync.Mutex // echoOutput shows on screen what the command answered. // // Without it the agent swallows the output: it goes to the model, which // summarises it — or does not. Seen on "display the content of agent.yaml": the // model runs `cat`, considers the request fulfilled, and the user sees nothing. // But "show me" means show. // // Only the first PreviewLines lines are printed: an output can run to thousands // of lines, and drowning the terminal would be as useless as showing none of // it. func echoOutput(out string) { if config.Cfg.PreviewLines <= 0 { return } out = strings.TrimRight(out, "\n") if out == "" { printMu.Lock() if spinner.Styled() { fmt.Print("\033[2m (no output)\033[0m\n") } else { fmt.Print(" (no output)\n") } printMu.Unlock() return } lines := strings.Split(out, "\n") shown := lines if len(lines) > config.Cfg.PreviewLines { shown = lines[:config.Cfg.PreviewLines] } // Indented and dimmed: one glance must separate what the machine answered // from what the model says about it. The grey only comes out on a terminal. dim, off := "", "" if spinner.Styled() { dim, off = "\033[2m", "\033[0m" } var b strings.Builder for _, l := range shown { b.WriteString(dim + " │ " + l + off + "\n") } if len(lines) > len(shown) { fmt.Fprintf(&b, "%s └ … %d more line(s)%s\n", dim, len(lines)-len(shown), off) } printMu.Lock() fmt.Print(b.String()) printMu.Unlock() } // runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` — // "" meaning the process's own directory, which is the terminal behaviour. // `failed` reports a non-zero exit or a timeout; the text already says so, but // a front end that renders statuses (ACP) needs it as a value, not a substring. func runBash(dir, command string) (output string, failed bool) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "bash", "-c", command) cmd.Dir = dir out, err := cmd.CombinedOutput() output = truncate(string(out)) if ctx.Err() == context.DeadlineExceeded { return output + "\n[error: 30s timeout exceeded]", true } if err != nil { return output + fmt.Sprintf("\n[exit code: %v]", err), true } return output, false } // Bash declares the `bash` tool. func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { description := `Run a shell command and return its output, plus the exit status when it is not zero. This is a real bash shell: pipes, redirections, && and any installed command are available. Each call starts a NEW shell in the same directory, so a "cd" does not carry over to the next call — chain with && instead. Nothing is interactive: there is no stdin, so never use a command that asks a question. Long output is truncated, and the text says so when it happens. 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.` return genkit.DefineTool(g, "bash", description, func(tctx *ai.ToolContext, in bashInput) (string, error) { // A front end that consumes events (the ACP façade) gets them // instead of the prints below; the terminal path is unchanged. if s := ui.ActiveSink(); s != nil { return bashEvented(tctx, s, d, in) } // Suspend rather than Stop: the model was "Thinking" when it decided // to call us, and it goes back to thinking as soon as we return the // output — so the label is restored for the wait that follows. spinner.Suspend(func() { printMu.Lock() fmt.Printf("\n🛠️ bash: %s\n", in.Command) printMu.Unlock() }) // The command has its own 30s budget, and a slow one looks exactly // like a hung agent. Retitle for the duration, then hand the line // back to the "Thinking" wait Suspend restored. spinner.Start("Running") out, _ := runBash("", in.Command) // The output is shown to the user BEFORE going back to the model: // what the command answered no longer depends on what the model // chooses to say about it. spinner.Suspend(func() { echoOutput(out) }) spinner.Start("Thinking") // The loop detector sees the command AND its output: the same pair // twice in a row, and it hands the model back an instruction to // change its approach (see internal/detector). if d.Record(detector.Action{ ToolName: "bash", Input: in.Command, Output: out, }) { return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil } return out, nil }) } type bashInput struct { Command string `json:"command" jsonschema_description:"The shell command to run"` } // bashEvented is the `bash` tool when a front end consumes events (ACP): the // 🛠️ line becomes ToolStart, the grey echo becomes ToolEnd's output — and one // thing exists here that the terminal never had: the command does not run until // the client's user allowed it. The refusal goes back to the model AS TEXT, // like every normal failure in this package, so it can propose something else // instead of crashing the turn. func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in bashInput) (string, error) { ev := ui.ToolEvent{ ID: ui.NextCallID(), Tool: "bash", Title: "bash: " + in.Command, Kind: "execute", Input: map[string]any{"command": in.Command}, } s.ToolStart(ev) if !s.Allow(ctx, ev) { out := "[command rejected by the user]" s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) // Recorded like any other outcome: a model that keeps retrying a // command the user keeps rejecting is looping. return record(d, "bash", in.Command, out), nil } s.ToolRunning(ev.ID) // The session's working directory, not the process's: in an editor, the // project the user opened is where the commands belong. out, failed := runBash(s.WorkDir(), in.Command) s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) return record(d, "bash", in.Command, out), nil } // --- read_skill -------------------------------------------------------------- type readSkillInput struct { Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description (for example \"go-rename\")"` } // ReadSkill declares the `read_skill` tool: it loads one markdown procedure // from the skills directory. // // The whole point is in the DESCRIPTION: it is built from the front matter of // the files on disk, so the catalogue the model sees is always the directory's // real content. Nothing to keep in sync by hand — and, unlike a paragraph in // the system prompt, loading a skill becomes a tool the model can SEE. // // Returns nil when the directory holds no skill: an agent with an empty // catalogue should not advertise the tool at all. func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRef { list := skills.List(dir) if len(list) == 0 { return nil } return genkit.DefineTool(g, "read_skill", skills.Catalogue(list), func(_ *ai.ToolContext, in readSkillInput) (string, error) { // Event-consuming front end (ACP): same information, as a tool_call. if s := ui.ActiveSink(); s != nil { return readSkillEvented(s, d, dir, list, in) } // Same reasoning as `bash`: announce the call without losing the // "Thinking" wait that resumes the moment the skill is returned. // Reading a file is fast, so there is no second label here. spinner.Suspend(func() { printMu.Lock() fmt.Printf("\n📖 read_skill: %s\n", in.Name) printMu.Unlock() }) content, err := skills.Read(dir, in.Name) if err != nil { // Unknown skill: information, not a crash. The list of valid // names goes back AS TEXT so the model can correct itself. // // Recorded in the detector like everything else: a model // asking four times for the same missing skill is looping, // exactly as it would on a failing command. out := fmt.Sprintf("No skill named %q. Available skills: %s", in.Name, strings.Join(skills.Names(list), ", ")) if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil } return out, nil } // The content is NOT displayed: a skill runs to dozens of lines, it // is written for the model, and the user already has the useful // information — which one was loaded. What they want to see are the // commands that follow from it, and those will show up. out := truncate(content) if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil } return out, nil }) } // readSkillEvented is `read_skill` for an event-consuming front end. No // permission dialog: it reads a procedure the agent ships, nothing more — and a // dialog for every skill would teach the user to click "allow" without reading, // which is worse than no dialog at all. The client still sees the call, kind // "read", with the loaded content as its output. func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []skills.Skill, in readSkillInput) (string, error) { ev := ui.ToolEvent{ ID: ui.NextCallID(), Tool: "read_skill", Title: "read_skill: " + in.Name, Kind: "read", Input: map[string]any{"name": in.Name}, } s.ToolStart(ev) s.ToolRunning(ev.ID) content, err := skills.Read(dir, in.Name) if err != nil { out := fmt.Sprintf("No skill named %q. Available skills: %s", in.Name, strings.Join(skills.Names(list), ", ")) s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) return record(d, "read_skill", in.Name, out), nil } out := truncate(content) s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) return record(d, "read_skill", in.Name, out), nil }