| 💾 Saved. d722711 k33g 2h ago | 1 | // 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. |
| 26 | package main |
| 27 | |
| 28 | import ( |
| 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 | |
| 49 | func 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. |
| 182 | func 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 | } |