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.

tools.go · 316 lines · 11.1 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 7h ago1// 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.
7package tools
8
9import (
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.
30func 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.
51var 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.
63func 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.
109func 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.
128func 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.
131This is a real bash shell: pipes, redirections, && and any installed command are available.
132Each call starts a NEW shell in the same directory, so a "cd" does not carry over to the next call — chain with && instead.
133Nothing is interactive: there is no stdin, so never use a command that asks a question.
134Long output is truncated, and the text says so when it happens.
135Read 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
185type 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.
195func 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
224type 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.
238func 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.
294func 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}