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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
|
// 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)
}
}
|