// Package agent contains the interactive conversation loop (REPL). package agent import ( "bufio" "context" "errors" "fmt" "io" "os" "os/signal" "strings" "mm/internal/compact" "mm/internal/config" "mm/internal/detector" "mm/internal/engine" "mm/internal/mention" "mm/internal/session" "mm/internal/spinner" "github.com/firebase/genkit/go/ai" ) type genResult struct { resp *ai.ModelResponse // history is the FULL conversation returned by engine.Generate — tool turns // included. Genkit only hands back the last message; without this field the // agent forgets the commands it just ran and starts telling stories instead // of re-reading. history []*ai.Message err error } // Run starts the loop: read user input → generate (with automatic tool calls) → // print → start over, keeping the history. func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) { messages := session.Fresh(system) ctx, cancelAll := context.WithCancel(ctx) defer cancelAll() // @path mentions are relative to where mm was started — the same anchor // as a relative skillsDir and as the bash tool's own working directory. cwd, _ := os.Getwd() // sigCh captures Ctrl+C (SIGINT). sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt) // inputCh receives commands from the reader goroutine. inputCh := make(chan string) // startInputReader launches a goroutine that reads from Stdin. // It is designed to restart if an interrupt (Ctrl+C) occurs. startInputReader := func() { go func() { for { reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { select { case <-ctx.Done(): return default: if err == io.EOF { close(inputCh) return } // If it's an interrupt, just continue to restart the reader. continue } } select { case <-ctx.Done(): return case inputCh <- strings.TrimSpace(line): } } }() } startInputReader() fmt.Println(`Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.`) fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`) generating := false for { fmt.Print("\n> ") // 1. Wait for the next user input OR a quit signal. var input string var ok bool select { case <-sigCh: if !generating { fmt.Println("\nGoodbye.") return } // If generating, we let the generation loop handle the SIGINT. // We fall through to wait for the next input (which will be the prompt). case input, ok = <-inputCh: if !ok { return } } // 2. Basic Command Handling. if input == "/quit" { break } if input == "" { continue } if input == "/abort" { spinner.Stop() fmt.Println("⚠️ No generation in progress.") continue } // Manual compression. Only reachable here, between two generations: // Genkit holds its own copy of the conversation while it runs, and a // tool_call/tool_result pair in flight must not move under it. if input == "/compact" { messages = compactHistory(ctx, e, messages, true) continue } // New session. Same place and same reason as /compact: the history // only moves between two generations. Typed DURING a generation, the // command goes through the "new command received" branch below, which // cancels the turn and re-injects it here — so the reset always applies // to the finished history, never to one Genkit still holds. if session.IsNewCommand(input) { messages = startNewSession(e, d, system, messages) continue } // 3. Normal message: Add to history and start generation. // // The automatic compression runs BEFORE the question is appended and // BEFORE `before` is computed: that index is what the rollback // (`messages[:before-1]`) and the ⚙ line (`res.history[before:]`) // rely on, and a history that shrinks after it is taken would break // both. The server's own count of the last context is preferred to // the estimate when it is larger — it sees the tools' JSON and the // chat template, which the estimate cannot. The window is the one the // Engine knows: the yaml's contextWindow, or what the probe learned. // // That probe ran once, at start-up. A llama-server started AFTER bob // left `ctx: unknown` for the whole session — observed — and the // compression could then only trigger on maxMessages. So, while the // window is unknown and compression is on, ask the server again // before deciding; once known, never again. A probe that still // fails prints nothing: the start-up warning already said so. if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 { if w, src := e.EnsureContextWindow(ctx); w > 0 { dimln(fmt.Sprintf("ctx: %d (%s)", w, src)) } } if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact { messages = compactHistory(ctx, e, messages, false) } // "@path" in the question names a file for the model to look at. The // text stays as typed; each existing path is appended as an // [attached file: …] line — what an editor's @-mention becomes over // ACP — and echoed here so the user sees the notation was understood. input, attached := mention.Expand(input, cwd) for _, a := range attached { dimln("📎 " + a.Path) } messages = append(messages, ai.NewUserTextMessage(input)) before := len(messages) fmt.Println() // Create a cancellable context for this specific generation cycle. genCtx, cancelGen := context.WithCancel(ctx) generating = true // Launch generation in a goroutine. resultCh := make(chan genResult, 1) go func() { resp, history, err := e.Generate(genCtx, messages, tools) resultCh <- genResult{resp: resp, history: history, err: err} }() // 4. The "Waiting Room": Listen for completion, interruption, OR new command. generationRunning := true for generationRunning { select { case res := <-resultCh: generationRunning = false generating = false // We take the FULL history returned by engine.Generate, not just // resp.Message — and we take it EVEN when the generation failed // or was interrupted: on an abort (Ctrl+C, /abort), a maxTurns // overrun or a silence caught by the watchdog, all the work of // the turn is in `history`, and Genkit itself returns nothing. // (resp.History() will not do: empty on the streamed path.) switch { case len(res.history) > before: messages = res.history case res.resp != nil && res.resp.Message != nil: messages = append(messages, res.resp.Message) case res.err != nil: messages = messages[:before-1] // question sans réponse : on l'oublie } if res.err != nil { if genCtx.Err() == context.Canceled { fmt.Println("\n🛑 Generation aborted.") } else { // One line, in the provider's words: see engine.Explain. fmt.Println("\n[error: " + e.Explain(res.err) + "]") } } else { // --- Global Loop Detection --- // We check the tool calls in the model response. if res.resp != nil && res.resp.Message != nil { for _, part := range res.resp.Message.Content { if part.IsToolRequest() && part.ToolRequest != nil { d.Record(detector.Action{ ToolName: part.ToolRequest.Name, Input: fmt.Sprintf("%v", part.ToolRequest.Input), Output: "[tool_call]", }) } } } fmt.Println() } // The command count is the only visible proof that the model // worked rather than improvised. Zero is normal on a // conversational question; zero on "read this file" is not. turn := res.history[min(before, len(res.history)):] cmds := engine.CommandList(turn) ops := engine.FileOps(turn) line := fmt.Sprintf("⚙ %d command(s)", len(cmds)) // The file tools have their own column: the A/B of this part // is "edits through bash" versus "edits through tools", and // a single number would add up what it is meant to separate. if len(ops) > 0 { line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops)) } if k := engine.Skills(turn); k > 0 { line += fmt.Sprintf(" · 📖 %d skill(s)", k) } if spinner.Styled() { fmt.Printf("\033[2m%s\033[0m\n", line) } else { fmt.Println(line) } if config.Cfg.DisplayCommands { printCommands(cmds) printCommands(ops) } case <-sigCh: // Handle Ctrl+C during generation. // Stop() first: the spinner is running during the generation, // and without it this message lands on its line — the next // frame would erase it. Same rule as in dmr.emit. spinner.Stop() fmt.Println("\n🛑 Interrupted (Ctrl+C).") cancelGen() // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error. case nextInput := <-inputCh: spinner.Stop() // le spinner tourne : on lui reprend la ligne switch nextInput { case "/abort": cancelGen() fmt.Println("🛑 Aborting...") case "/quit": cancelGen() fmt.Println("Goodbye.") return default: // A new message arrived! Cancel current task and re-inject the message. fmt.Println("🔄 New command received. Cancelling current task...") cancelGen() // Wait for current goroutine to clean up before re-injecting. res := <-resultCh // Same principle as above: the commands already run stay in // the history, otherwise the next question starts again // without knowing what was done. switch { case len(res.history) > before: messages = res.history case res.err != nil: messages = messages[:before-1] if genCtx.Err() != context.Canceled { fmt.Println("[error: " + e.Explain(res.err) + "]") } } generationRunning = false generating = false // Re-inject the command into the main loop. go func(cmd string) { inputCh <- cmd }(nextInput) } } } // Final cleanup for this cycle. cancelGen() } } // printCommands recaps the commands of the turn, in green. // // The count says HOW MANY, the list says WHAT: it reads at a glance once the // answer is written, whereas the 🛠️ lines are scattered through the output of // the commands. Green separates it from both the grey of the tools and the // model's own text — and it only comes out on a terminal, like every other // colour here. func printCommands(cmds []string) { if len(cmds) == 0 { return } green, off := "", "" if spinner.Styled() { green, off = "\033[32m", "\033[0m" } var b strings.Builder for i, c := range cmds { b.WriteString(green + fmt.Sprintf(" %d. %s", i+1, oneLine(c)) + off + "\n") } fmt.Print(b.String()) } // maxCommandWidth is the width beyond which a command is cut. A hundred // columns: that fits a demo terminal without overflowing it. const maxCommandWidth = 100 // oneLine folds a command onto ONE line and truncates it when needed. // // A command can be long and span several lines — a heredoc, a trailing &&. The // recap is there to scan what the agent did, not to re-read its code: spread // over several lines it would grow longer than the output it summarises, and // the numbering would stop being readable. func oneLine(s string) string { // Fields splits on ALL whitespace, line breaks included: that is exactly // the folding we want, heredoc indentation and all. s = strings.Join(strings.Fields(s), " ") r := []rune(s) if len(r) <= maxCommandWidth { return s } return string(r[:maxCommandWidth-1]) + "…" } // compactHistory replaces the old turns of `msgs` by a model-written summary // and says so in one line. On ANY failure — server down, watchdog, empty or // malformed result — it returns `msgs` unchanged: the compression exists to // keep the next question possible, so it must never cost one. // // `forced` is the /compact command: it skips the threshold, not the "is there // anything older than the kept turns" check, and it says so when there is not. // The loop detector is deliberately not touched: it records what the agent // DOES, and forgetting a conversation does not make a repeated command new. func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, forced bool) []*ai.Message { cfg := config.Cfg.Context // The summary is the longest prefill of the session and prints nothing // while it runs: without a label the agent looks hung, exactly the case // the spinner exists for. spinner.Start("Compressing") res, err := compact.Compact(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) { return e.Summarize(ctx, request, cfg.SummaryMaxTokens) }) spinner.Stop() switch { case errors.Is(err, compact.ErrNothingToCompact): if forced { dimln(fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns)) } return msgs case err != nil: // One line, in the provider's words, like every other error here. fmt.Printf("[compact: failed, history kept: %s]\n", e.Explain(err)) return msgs } // The server's last measure described the history we just replaced. e.ForgetInputTokens() if cfg.ShowStats { dimln("🗜️ " + res.Report()) } return res.Messages } // startNewSession forgets the conversation and returns the history of a fresh // one: the system prompt alone. Three things make up "the session" here, and // all three are reset — the messages, the server's token count of the last // context (it described a history that no longer exists, same rule as after a // compression), and the loop detector (a new session is a new task: a command // repeated in the old one must not be flagged as a loop in this one — the // opposite of compactHistory's choice, where the task continues). // // It says in one line how much was forgotten, so a /new on an empty session // visibly did nothing rather than silently nothing. func startNewSession(e *engine.Engine, d *detector.LoopDetector, system string, old []*ai.Message) []*ai.Message { forgotten := session.Forgotten(old) e.ForgetInputTokens() d.Reset() dimln(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten)) return session.Fresh(system) } // dimln prints one line, dimmed on a terminal and plain in a pipe — the same // rule as the ⚙ line: `bob | jq` must not receive escape codes. func dimln(line string) { if spinner.Styled() { fmt.Printf("\033[2m%s\033[0m\n", line) } else { fmt.Println(line) } }