bots-garden/mini-mepublic Fork 0
main
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.

💾 Saved. d722711 · on main · k33g · 3h ago
tools.go · 316 lines · 11.1 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
// 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
}