// Minimal coding agent: an agent loop with built-in tools — `bash`, // `read_skill`, and (switchable) `read_file`, `write_file`, `edit_file`. // // LLM engine: any provider of internal/engine — Docker Model Runner by default, // llama-server with `provider: llamacpp` — all through the OpenAI-compatible API. // // The code is split into packages (internal/ directory): // - config: every setting, with YAML overrides (see agent.yaml); // - detector: repeated-action detection (the logical loop); // - engine: connection to the LLM engine + generation (streaming + fallback), // with one Provider per kind of server; // - skills: the markdown procedures of ./skills, and their catalogue; // - mention: the @path notation of a question, shared by both front ends; // - fileedit: exact-replacement file editing, the rules of the `edit` CLI; // - tools: the built-in tools (`bash`, `read_skill`, the file tools); // - ui: where the human-facing output goes, and the event sink the // two front ends share; // - session: what a conversation starts from, and the /new command that // takes it back there — shared by the two front ends; // - agent: the terminal front end (the REPL); // - acp: the editor front end — the same loop behind the Agent Client // Protocol (`bob --acp`), for Zed, JetBrains, Neovim… // // main() only does the WIRING: load the settings, engine init, tool list, then // starting the loop. package main import ( "context" "flag" "fmt" "os" "path/filepath" "strings" "mm/internal/acp" "mm/internal/agent" "mm/internal/config" "mm/internal/detector" "mm/internal/engine" "mm/internal/skills" "mm/internal/spinner" "mm/internal/tools" "mm/internal/ui" "github.com/firebase/genkit/go/ai" ) func main() { ctx := context.Background() // Where the config file is. Same value, three ways to give it, from the most // local to the most ambient: // // go run . -config ./fast.yaml (the flag) // go run . ./fast.yaml (a lone argument, handy in a demo) // AGENT_CONFIG=./fast.yaml go run . // // The flag package gives us "-h" for free; a second argument is a typo, and // saying so is better than ignoring it. configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)") acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL") flag.Parse() if *configPath == "" && flag.NArg() > 0 { *configPath = flag.Arg(0) } if flag.NArg() > 1 { fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]") os.Exit(1) } // In ACP mode, stdout belongs to JSON-RPC — the spec forbids anything else // on it. Everything below that prints (banner, warnings, errors) goes // through ui.Out, so ONE move sends it all to stderr, where the editor's // agent logs pick it up. The spinner is silenced outright: supported() // cannot tell "a terminal" from "a terminal used as a protocol pipe". if *acpMode { ui.Out = os.Stderr spinner.Disable() } // Settings: built-in defaults, overridden by the YAML file if there is one. path, err := config.Load(*configPath) if err != nil { fmt.Fprintln(ui.Out, "[config error:", err, "]") os.Exit(1) } // Where a relative skillsDir points depends on who started mm. From a // terminal, it is the directory mm was started from — an installed mm // (/usr/local/bin) run inside a project loads THAT project's skills. The // editor launches mm with ITS working directory, so in ACP mode with a // config file the anchor is that file — the one path the user actually // named (env AGENT_CONFIG in the editor's settings). Either way the path // is made absolute here, so the banner can say exactly where it looked. config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode) e, err := engine.New(ctx) if err != nil { fmt.Fprintln(ui.Out, "[engine error:", err, "]") os.Exit(1) } // Initialize the global loop detector. loopDetector := detector.NewLoopDetector(10, 3) agentTools := []ai.ToolRef{ tools.Bash(e.G, loopDetector), } // `read_skill` only exists when there is something to read. Its description // carries the catalogue, so declaring it with an empty directory would // advertise a tool that can do nothing. skillCount := len(skills.List(config.Cfg.SkillsDir)) toolNames := []string{"bash"} if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil { agentTools = append(agentTools, skillTool) toolNames = append(toolNames, "read_skill") } // The file tools are a switch, not a given: the same binary must run as // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so // the two can be compared on the same prompts. if config.Cfg.EditTools { agentTools = append(agentTools, tools.ReadFile(e.G, loopDetector), tools.WriteFile(e.G, loopDetector), tools.EditFile(e.G, loopDetector)) toolNames = append(toolNames, "read_file", "write_file", "edit_file") } if path == "" { path = "built-in defaults" } // The probe runs before the banner so its warnings sit right under the // prompt they explain. It never stops the agent: on a demo machine the // server is often started AFTER the agent. info := e.Probe(ctx) ctxCol := "unknown" if info.ContextWindow > 0 { ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource) } // Dimmed on a terminal, plain in a pipe: the agent's output is meant to be // consumed by another program as much as it is meant to be read. dim, off := "", "" if spinner.Styled() { dim, off = "\033[2m", "\033[0m" } // The compression's own warning belongs with the provider's: it is the // same kind of advice, and it depends on what the probe just learned. if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 && c.MaxMessages == 0 { info.Warnings = append(info.Warnings, "context compression is on, but the context window is unknown and context.maxMessages is 0 — it will never trigger (set contextWindow, or maxMessages)") } // "skills: 0" alone sends people hunting; the resolved path says at once // whether mm looked in the wrong place or found the right one empty. if skillCount == 0 { info.Warnings = append(info.Warnings, fmt.Sprintf("no skills found in %s (expected .md or /SKILL.md there)", config.Cfg.SkillsDir)) } for _, w := range info.Warnings { fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off) } fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n", dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off) // Two front ends, one wiring: everything above — config, engine, tools, // detector — is strictly identical whichever loop runs below. if *acpMode { acp.Run(ctx, e, config.Cfg.System, agentTools) return } agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector) } // resolveSkillsDir makes the skills directory absolute. An absolute dir is // kept as-is. A relative one follows the config file in ACP mode when there // is one (the editor's working directory is not the project's), and the // current directory otherwise — for the terminal, that is where the user // started mm. func resolveSkillsDir(dir, configPath string, acpMode bool) string { if filepath.IsAbs(dir) { return dir } anchor := "." if acpMode && configPath != "" { anchor = filepath.Dir(configPath) } abs, err := filepath.Abs(filepath.Join(anchor, dir)) if err != nil { return filepath.Join(anchor, dir) } return abs }