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.

main.go · 195 lines · 7.6 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 4h ago1// Minimal coding agent: an agent loop with built-in tools — `bash`,
2// `read_skill`, and (switchable) `read_file`, `write_file`, `edit_file`.
3//
4// LLM engine: any provider of internal/engine — Docker Model Runner by default,
5// llama-server with `provider: llamacpp` — all through the OpenAI-compatible API.
6//
7// The code is split into packages (internal/ directory):
8// - config: every setting, with YAML overrides (see agent.yaml);
9// - detector: repeated-action detection (the logical loop);
10// - engine: connection to the LLM engine + generation (streaming + fallback),
11// with one Provider per kind of server;
12// - skills: the markdown procedures of ./skills, and their catalogue;
13// - mention: the @path notation of a question, shared by both front ends;
14// - fileedit: exact-replacement file editing, the rules of the `edit` CLI;
15// - tools: the built-in tools (`bash`, `read_skill`, the file tools);
16// - ui: where the human-facing output goes, and the event sink the
17// two front ends share;
18// - session: what a conversation starts from, and the /new command that
19// takes it back there — shared by the two front ends;
20// - agent: the terminal front end (the REPL);
21// - acp: the editor front end — the same loop behind the Agent Client
22// Protocol (`bob --acp`), for Zed, JetBrains, Neovim…
23//
24// main() only does the WIRING: load the settings, engine init, tool list, then
25// starting the loop.
26package main
27
28import (
29 "context"
30 "flag"
31 "fmt"
32 "os"
33 "path/filepath"
34 "strings"
35
36 "mm/internal/acp"
37 "mm/internal/agent"
38 "mm/internal/config"
39 "mm/internal/detector"
40 "mm/internal/engine"
41 "mm/internal/skills"
42 "mm/internal/spinner"
43 "mm/internal/tools"
44 "mm/internal/ui"
45
46 "github.com/firebase/genkit/go/ai"
47)
48
49func main() {
50 ctx := context.Background()
51
52 // Where the config file is. Same value, three ways to give it, from the most
53 // local to the most ambient:
54 //
55 // go run . -config ./fast.yaml (the flag)
56 // go run . ./fast.yaml (a lone argument, handy in a demo)
57 // AGENT_CONFIG=./fast.yaml go run .
58 //
59 // The flag package gives us "-h" for free; a second argument is a typo, and
60 // saying so is better than ignoring it.
61 configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
62 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
63 flag.Parse()
64 if *configPath == "" && flag.NArg() > 0 {
65 *configPath = flag.Arg(0)
66 }
67 if flag.NArg() > 1 {
68 fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]")
69 os.Exit(1)
70 }
71
72 // In ACP mode, stdout belongs to JSON-RPC — the spec forbids anything else
73 // on it. Everything below that prints (banner, warnings, errors) goes
74 // through ui.Out, so ONE move sends it all to stderr, where the editor's
75 // agent logs pick it up. The spinner is silenced outright: supported()
76 // cannot tell "a terminal" from "a terminal used as a protocol pipe".
77 if *acpMode {
78 ui.Out = os.Stderr
79 spinner.Disable()
80 }
81
82 // Settings: built-in defaults, overridden by the YAML file if there is one.
83 path, err := config.Load(*configPath)
84 if err != nil {
85 fmt.Fprintln(ui.Out, "[config error:", err, "]")
86 os.Exit(1)
87 }
88
89 // Where a relative skillsDir points depends on who started mm. From a
90 // terminal, it is the directory mm was started from — an installed mm
91 // (/usr/local/bin) run inside a project loads THAT project's skills. The
92 // editor launches mm with ITS working directory, so in ACP mode with a
93 // config file the anchor is that file — the one path the user actually
94 // named (env AGENT_CONFIG in the editor's settings). Either way the path
95 // is made absolute here, so the banner can say exactly where it looked.
96 config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)
97
98 e, err := engine.New(ctx)
99 if err != nil {
100 fmt.Fprintln(ui.Out, "[engine error:", err, "]")
101 os.Exit(1)
102 }
103
104 // Initialize the global loop detector.
105 loopDetector := detector.NewLoopDetector(10, 3)
106
107 agentTools := []ai.ToolRef{
108 tools.Bash(e.G, loopDetector),
109 }
110
111 // `read_skill` only exists when there is something to read. Its description
112 // carries the catalogue, so declaring it with an empty directory would
113 // advertise a tool that can do nothing.
114 skillCount := len(skills.List(config.Cfg.SkillsDir))
115 toolNames := []string{"bash"}
116 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil {
117 agentTools = append(agentTools, skillTool)
118 toolNames = append(toolNames, "read_skill")
119 }
120
121 // The file tools are a switch, not a given: the same binary must run as
122 // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so
123 // the two can be compared on the same prompts.
124 if config.Cfg.EditTools {
125 agentTools = append(agentTools,
126 tools.ReadFile(e.G, loopDetector),
127 tools.WriteFile(e.G, loopDetector),
128 tools.EditFile(e.G, loopDetector))
129 toolNames = append(toolNames, "read_file", "write_file", "edit_file")
130 }
131
132 if path == "" {
133 path = "built-in defaults"
134 }
135
136 // The probe runs before the banner so its warnings sit right under the
137 // prompt they explain. It never stops the agent: on a demo machine the
138 // server is often started AFTER the agent.
139 info := e.Probe(ctx)
140 ctxCol := "unknown"
141 if info.ContextWindow > 0 {
142 ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource)
143 }
144 // Dimmed on a terminal, plain in a pipe: the agent's output is meant to be
145 // consumed by another program as much as it is meant to be read.
146 dim, off := "", ""
147 if spinner.Styled() {
148 dim, off = "\033[2m", "\033[0m"
149 }
150 // The compression's own warning belongs with the provider's: it is the
151 // same kind of advice, and it depends on what the probe just learned.
152 if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 && c.MaxMessages == 0 {
153 info.Warnings = append(info.Warnings,
154 "context compression is on, but the context window is unknown and context.maxMessages is 0 — it will never trigger (set contextWindow, or maxMessages)")
155 }
156 // "skills: 0" alone sends people hunting; the resolved path says at once
157 // whether mm looked in the wrong place or found the right one empty.
158 if skillCount == 0 {
159 info.Warnings = append(info.Warnings,
160 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
161 }
162 for _, w := range info.Warnings {
163 fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off)
164 }
165 fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n",
166 dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off)
167
168 // Two front ends, one wiring: everything above — config, engine, tools,
169 // detector — is strictly identical whichever loop runs below.
170 if *acpMode {
171 acp.Run(ctx, e, config.Cfg.System, agentTools)
172 return
173 }
174 agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
175}
176
177// resolveSkillsDir makes the skills directory absolute. An absolute dir is
178// kept as-is. A relative one follows the config file in ACP mode when there
179// is one (the editor's working directory is not the project's), and the
180// current directory otherwise — for the terminal, that is where the user
181// started mm.
182func resolveSkillsDir(dir, configPath string, acpMode bool) string {
183 if filepath.IsAbs(dir) {
184 return dir
185 }
186 anchor := "."
187 if acpMode && configPath != "" {
188 anchor = filepath.Dir(configPath)
189 }
190 abs, err := filepath.Abs(filepath.Join(anchor, dir))
191 if err != nil {
192 return filepath.Join(anchor, dir)
193 }
194 return abs
195}