bots-garden/mini-mepublic Fork
⑂ dfcd2ed
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.

Download ZIP

🛟 Updated.

k33g committed 2026-10-25T22:38:12+01:00 Browse files
dfcd2ed parent: 79606ef
modified .tickets/issues/0153-add-colored-warning-about-the-status-of-the-agen.yaml +117 -5
@@ -1,16 +1,128 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 153
33 title: Add colored warning about the status of the agent (hallucinating, endless loop, ...)
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T08:40:29.067Z
9-updatedAt: 2026-10-25T08:41:17.587Z
9+updatedAt: 2026-10-25T19:23:53.287Z
1010 labels:
1111 - priority::urgent
1212 - kind::feature
1313 milestone: v1.0.1
14-body: ""
15-tasks: []
16-comments: []
14+body: |
15+ ## Need
16+
17+ From the start mm detects when a model struggles — the loop detector, the runaway guard on tool-call arguments, the watchdog, the note handed back after a broken tool call — but it told the **model**, or ended the turn with an error line lost in the conversation. The user had to read every line to see that the model was going in circles.
18+
19+ ## What was done
20+
21+ The same detections, and a few new ones, are now shown to the **user** as *struggle signals*, apart from the answer, in two levels: `⚠` warning (yellow, the turn goes on) and `✖` severe (red, the turn was stopped or failed because of it).
22+
23+ | Signal | Level | Source |
24+ |--------|-------|--------|
25+ | `loop: <tool> ×N` | ⚠ | loop detector flagged a repeat (`detector.WithReport`) |
26+ | `stuck: <tool> ×N` | ✖ | loop detector stopped the turn |
27+ | `broken tool call (cut off\|invalid JSON) n/m` | ⚠ | tool-call arguments did not parse, handed back to the model |
28+ | `stream cut — retried` | ⚠ | the server broke the stream, model call retried once |
29+ | `runaway tool call` | ✖ | runaway guard (`runawayError`) |
30+ | `stalled` | ✖ | watchdog (`errStalled`) |
31+ | `max turns reached` | ✖ | `ai.ErrMaxTurnsExceeded` |
32+ | `unknown tool: <name>` | ✖ | the model called a tool that does not exist (new — the "hallucinating" case) |
33+ | `empty answer` | ⚠ | turn ended with no text and no tool call (new) |
34+ | `context N% full` | ⚠ | last call read ≥ 80 % of the window (new) |
35+
36+ A turn cancelled by the user reports nothing.
37+
38+ Front ends (user's choices):
39+
40+ - **Window**: a badge on the right of the **status bar** (footer), in the version's place, in front of the counters: black on yellow / white on red. The worst signal of the turn, `(+N)` for the others; it stays after the turn and is cleared when the next question is sent. The key hints (`Ctrl-C quit`) never give way.
41+ - **Line mode / `-p`**: a yellow or red line when it happens (plain in a pipe).
42+ - **Editor (ACP)**: a paragraph `*⚠ mm: <signal>*` in the agent's message.
43+
44+ Code: `ui.Struggle` / `StruggleSink` / `ReportStruggle` (`internal/ui`), `detector.WithReport` + `main.reportLoop` (`struggle_wiring.go`), `internal/engine/struggle.go` (`reportTurn`, called at the end of `Generate`) + two inline reports in `engine.go`, `internal/tui/struggle.go` (badge), `internal/acp/sink.go` (`struggleLine`).
45+
46+ Rejected: the header (the user chose the footer); one transcript line per signal (would flood the conversation); "3 failed tool calls in a row" — left to ticket 160, because a call's failure is only known inside each tool (17 code paths, no sink in the line mode).
47+
48+ ## Verified
49+
50+ - `go vet ./...` and `go test ./...` green; tests in ui, detector, main (`loopStruggle`), engine (classification, 80 % threshold, empty answer reported, cancelled turn silent), tui (worst-wins, badge layout at 100 and 64 columns, cleared on next question), acp.
51+ - Live (llmman, Qwen3.5-4B, `maxTurns: 1`): `-p` prints `✖ max turns reached`; the window, driven through a pseudo-terminal, draws ` ✖ max turns reached ` white on red (`97;101`, bold) in the bottom bar.
52+ - Not verified live: Zed (ACP); the other signals (covered by unit tests).
53+ - Quality gate not run (qlty missing from the sandbox).
54+
55+ ## Docs
56+
57+ EN + FR: `reference/cli.md` (new "Struggle signals" section + rows in the ACP, line and window tables), `explanation/struggling-models.md` (new, linked from the index), `how-to/use-the-window-mode.md` (what to do when the bar turns yellow or red).
58+tasks:
59+ - id: 1
60+ title: Struggle event in ui + terminal rendering (yellow / red line)
61+ state: closed
62+ priority: none
63+ author:
64+ name: k33g
65+ email: ph.charriere@gmail.com
66+ createdAt: 2026-10-25T19:00:00.000Z
67+ - id: 2
68+ title: Loop detector reports its flags and its stop
69+ state: closed
70+ priority: none
71+ author:
72+ name: k33g
73+ email: ph.charriere@gmail.com
74+ createdAt: 2026-10-25T19:00:00.000Z
75+ - id: 3
76+ title: Engine reports retries, broken calls, runaway, stalled, max turns, unknown tool, empty answer, context ≥ 80 %
77+ state: closed
78+ priority: none
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-10-25T19:00:00.000Z
83+ - id: 4
84+ title: Window status-bar badge until the next question
85+ state: closed
86+ priority: none
87+ author:
88+ name: k33g
89+ email: ph.charriere@gmail.com
90+ createdAt: 2026-10-25T19:00:00.000Z
91+ - id: 5
92+ title: Editor (ACP) line
93+ state: closed
94+ priority: none
95+ author:
96+ name: k33g
97+ email: ph.charriere@gmail.com
98+ createdAt: 2026-10-25T19:00:00.000Z
99+ - id: 6
100+ title: Docs EN + FR
101+ state: closed
102+ priority: none
103+ author:
104+ name: k33g
105+ email: ph.charriere@gmail.com
106+ createdAt: 2026-10-25T19:00:00.000Z
107+ - id: 7
108+ title: Check in Zed (ACP)
109+ state: closed
110+ priority: none
111+ author:
112+ name: k33g
113+ email: ph.charriere@gmail.com
114+ createdAt: 2026-10-25T19:00:00.000Z
115+ updatedAt: 2026-10-25T19:23:53.279Z
116+ closedAt: 2026-10-25T19:23:53.279Z
117+comments:
118+ - id: 1
119+ author:
120+ name: k33g
121+ email: ph.charriere@gmail.com
122+ createdAt: 2026-10-25T19:45:00.000Z
123+ body: |
124+ Closed. All ten signals were seen live through a fake OpenAI-compatible server (one scenario per signal: loop, unknown tool, empty answer, context, broken call, runaway, silent, max turns) in `-p`, and in the window (yellow badge for `empty answer`, red for `max turns reached`).
125+
126+ That run found one defect, fixed: without a terminal the struggle line stuck to the streamed answer (`🤖 Done.⚠ context 90% full`); it now starts on its own line.
127+
128+ Still open: task 7 (Zed / ACP), only covered by a unit test. Follow-up: ticket 160 (several failed tool calls in a row).
@@ -1,16 +1,128 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 1532 id: 153
3 title: Add colored warning about the status of the agent (hallucinating, endless loop, ...)3 title: Add colored warning about the status of the agent (hallucinating, endless loop, ...)
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-25T08:40:29.067Z8 createdAt: 2026-10-25T08:40:29.067Z
9-updatedAt: 2026-10-25T08:41:17.587Z9+updatedAt: 2026-10-25T19:23:53.287Z
10 labels:10 labels:
11 - priority::urgent11 - priority::urgent
12 - kind::feature12 - kind::feature
13 milestone: v1.0.113 milestone: v1.0.1
14-body: ""14+body: |
15-tasks: []15+ ## Need
16-comments: []16+
17+ From the start mm detects when a model struggles — the loop detector, the runaway guard on tool-call arguments, the watchdog, the note handed back after a broken tool call — but it told the **model**, or ended the turn with an error line lost in the conversation. The user had to read every line to see that the model was going in circles.
18+
19+ ## What was done
20+
21+ The same detections, and a few new ones, are now shown to the **user** as *struggle signals*, apart from the answer, in two levels: `⚠` warning (yellow, the turn goes on) and `✖` severe (red, the turn was stopped or failed because of it).
22+
23+ | Signal | Level | Source |
24+ |--------|-------|--------|
25+ | `loop: <tool> ×N` | ⚠ | loop detector flagged a repeat (`detector.WithReport`) |
26+ | `stuck: <tool> ×N` | ✖ | loop detector stopped the turn |
27+ | `broken tool call (cut off\|invalid JSON) n/m` | ⚠ | tool-call arguments did not parse, handed back to the model |
28+ | `stream cut — retried` | ⚠ | the server broke the stream, model call retried once |
29+ | `runaway tool call` | ✖ | runaway guard (`runawayError`) |
30+ | `stalled` | ✖ | watchdog (`errStalled`) |
31+ | `max turns reached` | ✖ | `ai.ErrMaxTurnsExceeded` |
32+ | `unknown tool: <name>` | ✖ | the model called a tool that does not exist (new — the "hallucinating" case) |
33+ | `empty answer` | ⚠ | turn ended with no text and no tool call (new) |
34+ | `context N% full` | ⚠ | last call read ≥ 80 % of the window (new) |
35+
36+ A turn cancelled by the user reports nothing.
37+
38+ Front ends (user's choices):
39+
40+ - **Window**: a badge on the right of the **status bar** (footer), in the version's place, in front of the counters: black on yellow / white on red. The worst signal of the turn, `(+N)` for the others; it stays after the turn and is cleared when the next question is sent. The key hints (`Ctrl-C quit`) never give way.
41+ - **Line mode / `-p`**: a yellow or red line when it happens (plain in a pipe).
42+ - **Editor (ACP)**: a paragraph `*⚠ mm: <signal>*` in the agent's message.
43+
44+ Code: `ui.Struggle` / `StruggleSink` / `ReportStruggle` (`internal/ui`), `detector.WithReport` + `main.reportLoop` (`struggle_wiring.go`), `internal/engine/struggle.go` (`reportTurn`, called at the end of `Generate`) + two inline reports in `engine.go`, `internal/tui/struggle.go` (badge), `internal/acp/sink.go` (`struggleLine`).
45+
46+ Rejected: the header (the user chose the footer); one transcript line per signal (would flood the conversation); "3 failed tool calls in a row" — left to ticket 160, because a call's failure is only known inside each tool (17 code paths, no sink in the line mode).
47+
48+ ## Verified
49+
50+ - `go vet ./...` and `go test ./...` green; tests in ui, detector, main (`loopStruggle`), engine (classification, 80 % threshold, empty answer reported, cancelled turn silent), tui (worst-wins, badge layout at 100 and 64 columns, cleared on next question), acp.
51+ - Live (llmman, Qwen3.5-4B, `maxTurns: 1`): `-p` prints `✖ max turns reached`; the window, driven through a pseudo-terminal, draws ` ✖ max turns reached ` white on red (`97;101`, bold) in the bottom bar.
52+ - Not verified live: Zed (ACP); the other signals (covered by unit tests).
53+ - Quality gate not run (qlty missing from the sandbox).
54+
55+ ## Docs
56+
57+ EN + FR: `reference/cli.md` (new "Struggle signals" section + rows in the ACP, line and window tables), `explanation/struggling-models.md` (new, linked from the index), `how-to/use-the-window-mode.md` (what to do when the bar turns yellow or red).
58+tasks:
59+ - id: 1
60+ title: Struggle event in ui + terminal rendering (yellow / red line)
61+ state: closed
62+ priority: none
63+ author:
64+ name: k33g
65+ email: ph.charriere@gmail.com
66+ createdAt: 2026-10-25T19:00:00.000Z
67+ - id: 2
68+ title: Loop detector reports its flags and its stop
69+ state: closed
70+ priority: none
71+ author:
72+ name: k33g
73+ email: ph.charriere@gmail.com
74+ createdAt: 2026-10-25T19:00:00.000Z
75+ - id: 3
76+ title: Engine reports retries, broken calls, runaway, stalled, max turns, unknown tool, empty answer, context ≥ 80 %
77+ state: closed
78+ priority: none
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-10-25T19:00:00.000Z
83+ - id: 4
84+ title: Window status-bar badge until the next question
85+ state: closed
86+ priority: none
87+ author:
88+ name: k33g
89+ email: ph.charriere@gmail.com
90+ createdAt: 2026-10-25T19:00:00.000Z
91+ - id: 5
92+ title: Editor (ACP) line
93+ state: closed
94+ priority: none
95+ author:
96+ name: k33g
97+ email: ph.charriere@gmail.com
98+ createdAt: 2026-10-25T19:00:00.000Z
99+ - id: 6
100+ title: Docs EN + FR
101+ state: closed
102+ priority: none
103+ author:
104+ name: k33g
105+ email: ph.charriere@gmail.com
106+ createdAt: 2026-10-25T19:00:00.000Z
107+ - id: 7
108+ title: Check in Zed (ACP)
109+ state: closed
110+ priority: none
111+ author:
112+ name: k33g
113+ email: ph.charriere@gmail.com
114+ createdAt: 2026-10-25T19:00:00.000Z
115+ updatedAt: 2026-10-25T19:23:53.279Z
116+ closedAt: 2026-10-25T19:23:53.279Z
117+comments:
118+ - id: 1
119+ author:
120+ name: k33g
121+ email: ph.charriere@gmail.com
122+ createdAt: 2026-10-25T19:45:00.000Z
123+ body: |
124+ Closed. All ten signals were seen live through a fake OpenAI-compatible server (one scenario per signal: loop, unknown tool, empty answer, context, broken call, runaway, silent, max turns) in `-p`, and in the window (yellow badge for `empty answer`, red for `max turns reached`).
125+
126+ That run found one defect, fixed: without a terminal the struggle line stuck to the streamed answer (`🤖 Done.⚠ context 90% full`); it now starts on its own line.
127+
128+ Still open: task 7 (Zed / ACP), only covered by a unit test. Follow-up: ticket 160 (several failed tool calls in a row).
added .tickets/issues/0158-calculate-the-duration-of-a-completion.yaml +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 158
3+title: Calculate the duration of a completion
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-25T18:15:39.756Z
9+updatedAt: 2026-10-25T18:15:39.756Z
10+labels: []
11+milestone: v1.0.1
12+body: ""
13+tasks: []
14+comments: []
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 158
3+title: Calculate the duration of a completion
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-25T18:15:39.756Z
9+updatedAt: 2026-10-25T18:15:39.756Z
10+labels: []
11+milestone: v1.0.1
12+body: ""
13+tasks: []
14+comments: []
added .tickets/issues/0159-list-use-cases-for-mini-me.yaml +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 159
3+title: List use cases for Mini Me
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-25T18:27:48.558Z
9+updatedAt: 2026-10-25T18:27:48.558Z
10+labels: []
11+milestone: v1.0.1
12+body: ""
13+tasks: []
14+comments: []
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 159
3+title: List use cases for Mini Me
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-25T18:27:48.558Z
9+updatedAt: 2026-10-25T18:27:48.558Z
10+labels: []
11+milestone: v1.0.1
12+body: ""
13+tasks: []
14+comments: []
added .tickets/issues/0160-struggle-signal-consecutive-failed-tool-calls.yaml +83 -0
new file mode 100644
@@ -0,0 +1,83 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 160
3+title: "Struggle signal: several failed tool calls in a row"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-25T19:00:00.000Z
9+updatedAt: 2026-10-25T20:30:00.000Z
10+labels:
11+ - kind::feature
12+milestone: v1.0.1
13+related:
14+ - 153
15+body: |+
16+ ## Need
17+
18+ Follow-up of ticket 153. A model that keeps failing — three or more tool calls in a row that fail, with **different** inputs (so the loop detector, which needs identical calls, says nothing) — is struggling too, and should raise a `⚠ failing: N tool calls in a row` struggle signal.
19+
20+ ## Why it was left out of 153
21+
22+ Whether a call failed is only known inside each tool (`ui.ToolResult.Failed`, set in about seventeen code paths), and the line mode has no sink: its tools print directly. The loop detector's `record()` receives the output, not the failure. Doing it properly means passing the failure to one central place from every tool.
23+
24+ ## Proposal
25+
26+ - Give `record()` (internal/tools) the failure bit — or a `detector.Action.Failed` field — so the detector, which already sees every call of every tool, can count consecutive failures and report them through `WithReport`.
27+ - Report `⚠ failing: N tool calls in a row` from N = 3; reset on the first success; severe never (the turn goes on).
28+ - Tests in detector and tools; docs EN + FR (`reference/cli.md` struggle table).
29+tasks:
30+ - id: 1
31+ title: "detector: Action.Failed, WithFailures(n), Loop.Failing"
32+ state: closed
33+ priority: none
34+ author:
35+ name: k33g
36+ email: ph.charriere@gmail.com
37+ createdAt: 2026-10-25T20:30:00.000Z
38+ - id: 2
39+ title: "tools: record / recordFailure / recordOutcome at every call site; mcp record(…, failed)"
40+ state: closed
41+ priority: none
42+ author:
43+ name: k33g
44+ email: ph.charriere@gmail.com
45+ createdAt: 2026-10-25T20:30:00.000Z
46+ - id: 3
47+ title: "main: WithFailures(3); label ⚠ failing: N tool calls in a row"
48+ state: closed
49+ priority: none
50+ author:
51+ name: k33g
52+ email: ph.charriere@gmail.com
53+ createdAt: 2026-10-25T20:30:00.000Z
54+ - id: 4
55+ title: "Tests (detector, tools incl. the real bash path, main) + demo scenario FAKE=failing"
56+ state: closed
57+ priority: none
58+ author:
59+ name: k33g
60+ email: ph.charriere@gmail.com
61+ createdAt: 2026-10-25T20:30:00.000Z
62+ - id: 5
63+ title: "Docs EN + FR (reference/cli struggle table, explanation)"
64+ state: closed
65+ priority: none
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-10-25T20:30:00.000Z
70+comments:
71+ - id: 1
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-10-25T20:30:00.000Z
76+ body: |
77+ Done and closed.
78+
79+ - `detector.Action` has a `Failed` field; `WithFailures(3)` (set in `main.go`) reports, through the same `WithReport` as loops, every call that is the 3rd failure in a row or later, whatever the inputs; the first success resets the count. A flagged identical repeat is reported as a loop instead. Report only: the model is not told, the turn is never stopped.
80+ - Every tool now hands its outcome with the call: `record` (success), `recordFailure`, `recordOutcome(…, failed)` in internal/tools; `record(…, failed)` in internal/mcp. Failures = the cases that already set `ui.ToolResult.Failed`, plus the line-mode paths that used to discard the flag (`out, _ :=`).
81+ - Signal: `⚠ failing: N tool calls in a row`.
82+ - Verified: `go vet ./...` + `go test ./...` green (new tests in detector, tools — including the real bash path —, main); `demos/struggle-signals` has a `FAKE=failing` scenario: `./run-all.sh` shows `⚠ failing: 3 tool calls in a row` … `10`, then `✖ max turns reached`.
83+ - Note: it is reported again at every new failure (one terminal line each); in the window it is one badge.
new file mode 100644
@@ -0,0 +1,83 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 160
3+title: "Struggle signal: several failed tool calls in a row"
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-25T19:00:00.000Z
9+updatedAt: 2026-10-25T20:30:00.000Z
10+labels:
11+ - kind::feature
12+milestone: v1.0.1
13+related:
14+ - 153
15+body: |+
16+ ## Need
17+
18+ Follow-up of ticket 153. A model that keeps failing — three or more tool calls in a row that fail, with **different** inputs (so the loop detector, which needs identical calls, says nothing) — is struggling too, and should raise a `⚠ failing: N tool calls in a row` struggle signal.
19+
20+ ## Why it was left out of 153
21+
22+ Whether a call failed is only known inside each tool (`ui.ToolResult.Failed`, set in about seventeen code paths), and the line mode has no sink: its tools print directly. The loop detector's `record()` receives the output, not the failure. Doing it properly means passing the failure to one central place from every tool.
23+
24+ ## Proposal
25+
26+ - Give `record()` (internal/tools) the failure bit — or a `detector.Action.Failed` field — so the detector, which already sees every call of every tool, can count consecutive failures and report them through `WithReport`.
27+ - Report `⚠ failing: N tool calls in a row` from N = 3; reset on the first success; severe never (the turn goes on).
28+ - Tests in detector and tools; docs EN + FR (`reference/cli.md` struggle table).
29+tasks:
30+ - id: 1
31+ title: "detector: Action.Failed, WithFailures(n), Loop.Failing"
32+ state: closed
33+ priority: none
34+ author:
35+ name: k33g
36+ email: ph.charriere@gmail.com
37+ createdAt: 2026-10-25T20:30:00.000Z
38+ - id: 2
39+ title: "tools: record / recordFailure / recordOutcome at every call site; mcp record(…, failed)"
40+ state: closed
41+ priority: none
42+ author:
43+ name: k33g
44+ email: ph.charriere@gmail.com
45+ createdAt: 2026-10-25T20:30:00.000Z
46+ - id: 3
47+ title: "main: WithFailures(3); label ⚠ failing: N tool calls in a row"
48+ state: closed
49+ priority: none
50+ author:
51+ name: k33g
52+ email: ph.charriere@gmail.com
53+ createdAt: 2026-10-25T20:30:00.000Z
54+ - id: 4
55+ title: "Tests (detector, tools incl. the real bash path, main) + demo scenario FAKE=failing"
56+ state: closed
57+ priority: none
58+ author:
59+ name: k33g
60+ email: ph.charriere@gmail.com
61+ createdAt: 2026-10-25T20:30:00.000Z
62+ - id: 5
63+ title: "Docs EN + FR (reference/cli struggle table, explanation)"
64+ state: closed
65+ priority: none
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-10-25T20:30:00.000Z
70+comments:
71+ - id: 1
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-10-25T20:30:00.000Z
76+ body: |
77+ Done and closed.
78+
79+ - `detector.Action` has a `Failed` field; `WithFailures(3)` (set in `main.go`) reports, through the same `WithReport` as loops, every call that is the 3rd failure in a row or later, whatever the inputs; the first success resets the count. A flagged identical repeat is reported as a loop instead. Report only: the model is not told, the turn is never stopped.
80+ - Every tool now hands its outcome with the call: `record` (success), `recordFailure`, `recordOutcome(…, failed)` in internal/tools; `record(…, failed)` in internal/mcp. Failures = the cases that already set `ui.ToolResult.Failed`, plus the line-mode paths that used to discard the flag (`out, _ :=`).
81+ - Signal: `⚠ failing: N tool calls in a row`.
82+ - Verified: `go vet ./...` + `go test ./...` green (new tests in detector, tools — including the real bash path —, main); `demos/struggle-signals` has a `FAKE=failing` scenario: `./run-all.sh` shows `⚠ failing: 3 tool calls in a row` … `10`, then `✖ max turns reached`.
83+ - Note: it is reported again at every new failure (one terminal line each); in the window it is one badge.
modified demos/little-coder/agent.little-coder.yaml +1 -0
@@ -76,6 +76,7 @@ watchdogTimeout: 2m
7676
7777 # A sample app is ~6 files + build + test + run + check + fixes.
7878 maxTurns: 40
79+#maxTurns: 1
7980
8081 # go vet / go test failures are short; 8000 characters keep a whole report
8182 # without filling a 32k window with logs.
@@ -76,6 +76,7 @@ watchdogTimeout: 2m
76 76
77 # A sample app is ~6 files + build + test + run + check + fixes.77 # A sample app is ~6 files + build + test + run + check + fixes.
78 maxTurns: 4078 maxTurns: 40
79+#maxTurns: 1
79 80
80 # go vet / go test failures are short; 8000 characters keep a whole report81 # go vet / go test failures are short; 8000 characters keep a whole report
81 # without filling a 32k window with logs.82 # without filling a 32k window with logs.
added demos/struggle-signals/README.en.md +45 -0
new file mode 100644
@@ -0,0 +1,45 @@
1+# Demo: see every struggle signal without waiting for a model to fail
2+
3+*[Version française](README.fr.md)*
4+
5+mm shows the user when the model struggles — a loop, a broken or runaway tool call, a stall, too many turns, a tool that does not exist… ([struggle signals](../../docs/en/reference/cli.md#struggle-signals), ticket 153). A real model does not fail on demand, so this demo replaces it with `fake-llm.py`, a fake OpenAI-compatible server (Python, no dependency) that struggles on purpose, one scenario at a time.
6+
7+| File | Role |
8+|------|------|
9+| `fake-llm.py` | the fake model: `FAKE=<scenario>`, listens on `127.0.0.1:8765` (`PORT=` to change it); streams like a real server, and answers in one block when mm retries without streaming |
10+| `agent.yaml` | the agent: `provider: openai` on the fake server, `contextWindow: 1000`, `maxTurns: 10`, a 3 s watchdog |
11+| `run-all.sh` | runs every scenario through `mm -p` and prints the struggle lines |
12+
13+## Run it
14+
15+Every scenario at once (python3 and mm on the PATH, or `MM_BIN=/path/to/mm`):
16+
17+```bash
18+./run-all.sh
19+```
20+
21+One scenario, in the window — watch the right of the status bar:
22+
23+```bash
24+FAKE=loop python3 fake-llm.py &
25+mm tui agent.yaml # type anything, press Enter
26+kill %1
27+```
28+
29+## What you should see
30+
31+Measured on 2026-10-25 with `./run-all.sh`:
32+
33+| `FAKE=` | The fake model… | Signal |
34+|---------|-----------------|--------|
35+| `loop` | calls `bash cat /no/such/file` again and again | `⚠ failing: 3 tool calls in a row`, `⚠ loop: bash ×4`, `⚠ loop: bash ×5`, then `✖ stuck: bash ×6` |
36+| `failing` | calls `bash cat /no/such/file-N` with a new N every time | `⚠ failing: 3 tool calls in a row`, 4, 5… then `✖ max turns reached` |
37+| `unknown` | calls `run_python`, which does not exist | `✖ unknown tool: run_python` |
38+| `empty` | answers nothing | `⚠ empty answer` |
39+| `context` | answers, reporting 900 prompt tokens | `⚠ context 90% full` |
40+| `broken` | writes tool-call arguments that stop mid-JSON | `⚠ broken tool call (cut off) 1/2`, `2/2` |
41+| `runaway` | streams `abcdefgh` as arguments, forever | `✖ runaway tool call` |
42+| `silent` | accepts the request and never answers | `✖ stalled` (after 3 s) |
43+| `turns` | calls `bash echo step N` with a new N every time | `✖ max turns reached` (after 10 calls) |
44+
45+In the window, a `⚠` badge is black on yellow and a `✖` badge white on red; it stays until the next question. In a terminal the lines are yellow or red; piped (as in `run-all.sh`) they are plain.
new file mode 100644
@@ -0,0 +1,45 @@
1+# Demo: see every struggle signal without waiting for a model to fail
2+
3+*[Version française](README.fr.md)*
4+
5+mm shows the user when the model struggles — a loop, a broken or runaway tool call, a stall, too many turns, a tool that does not exist… ([struggle signals](../../docs/en/reference/cli.md#struggle-signals), ticket 153). A real model does not fail on demand, so this demo replaces it with `fake-llm.py`, a fake OpenAI-compatible server (Python, no dependency) that struggles on purpose, one scenario at a time.
6+
7+| File | Role |
8+|------|------|
9+| `fake-llm.py` | the fake model: `FAKE=<scenario>`, listens on `127.0.0.1:8765` (`PORT=` to change it); streams like a real server, and answers in one block when mm retries without streaming |
10+| `agent.yaml` | the agent: `provider: openai` on the fake server, `contextWindow: 1000`, `maxTurns: 10`, a 3 s watchdog |
11+| `run-all.sh` | runs every scenario through `mm -p` and prints the struggle lines |
12+
13+## Run it
14+
15+Every scenario at once (python3 and mm on the PATH, or `MM_BIN=/path/to/mm`):
16+
17+```bash
18+./run-all.sh
19+```
20+
21+One scenario, in the window — watch the right of the status bar:
22+
23+```bash
24+FAKE=loop python3 fake-llm.py &
25+mm tui agent.yaml # type anything, press Enter
26+kill %1
27+```
28+
29+## What you should see
30+
31+Measured on 2026-10-25 with `./run-all.sh`:
32+
33+| `FAKE=` | The fake model… | Signal |
34+|---------|-----------------|--------|
35+| `loop` | calls `bash cat /no/such/file` again and again | `⚠ failing: 3 tool calls in a row`, `⚠ loop: bash ×4`, `⚠ loop: bash ×5`, then `✖ stuck: bash ×6` |
36+| `failing` | calls `bash cat /no/such/file-N` with a new N every time | `⚠ failing: 3 tool calls in a row`, 4, 5… then `✖ max turns reached` |
37+| `unknown` | calls `run_python`, which does not exist | `✖ unknown tool: run_python` |
38+| `empty` | answers nothing | `⚠ empty answer` |
39+| `context` | answers, reporting 900 prompt tokens | `⚠ context 90% full` |
40+| `broken` | writes tool-call arguments that stop mid-JSON | `⚠ broken tool call (cut off) 1/2`, `2/2` |
41+| `runaway` | streams `abcdefgh` as arguments, forever | `✖ runaway tool call` |
42+| `silent` | accepts the request and never answers | `✖ stalled` (after 3 s) |
43+| `turns` | calls `bash echo step N` with a new N every time | `✖ max turns reached` (after 10 calls) |
44+
45+In the window, a `⚠` badge is black on yellow and a `✖` badge white on red; it stays until the next question. In a terminal the lines are yellow or red; piped (as in `run-all.sh`) they are plain.
added demos/struggle-signals/README.fr.md +45 -0
new file mode 100644
@@ -0,0 +1,45 @@
1+# Démo : voir chaque signal de difficulté sans attendre qu'un modèle échoue
2+
3+*[English version](README.en.md)*
4+
5+mm montre à l'utilisateur quand le modèle peine — une boucle, un appel d'outil cassé ou qui s'emballe, un blocage, trop de tours, un outil qui n'existe pas… ([signaux de difficulté](../../docs/fr/reference/cli.md#signaux-de-difficulté), ticket 153). Un vrai modèle n'échoue pas sur commande : cette démo le remplace par `fake-llm.py`, un faux serveur compatible OpenAI (Python, sans dépendance) qui peine exprès, un scénario à la fois.
6+
7+| Fichier | Rôle |
8+|---------|------|
9+| `fake-llm.py` | le faux modèle : `FAKE=<scénario>`, écoute sur `127.0.0.1:8765` (`PORT=` pour changer) ; il répond en flux comme un vrai serveur, et d'un bloc quand mm relance sans streaming |
10+| `agent.yaml` | l'agent : `provider: openai` sur le faux serveur, `contextWindow: 1000`, `maxTurns: 10`, un watchdog de 3 s |
11+| `run-all.sh` | passe chaque scénario dans `mm -p` et affiche les lignes de difficulté |
12+
13+## La lancer
14+
15+Tous les scénarios d'un coup (python3 et mm dans le PATH, ou `MM_BIN=/chemin/vers/mm`) :
16+
17+```bash
18+./run-all.sh
19+```
20+
21+Un scénario, dans la fenêtre — regardez la droite de la barre d'état :
22+
23+```bash
24+FAKE=loop python3 fake-llm.py &
25+mm tui agent.yaml # tapez n'importe quoi, puis Entrée
26+kill %1
27+```
28+
29+## Ce que vous devez voir
30+
31+Mesuré le 2026-10-25 avec `./run-all.sh` :
32+
33+| `FAKE=` | Le faux modèle… | Signal |
34+|---------|-----------------|--------|
35+| `loop` | appelle `bash cat /no/such/file` encore et encore | `⚠ failing: 3 tool calls in a row`, `⚠ loop: bash ×4`, `⚠ loop: bash ×5`, puis `✖ stuck: bash ×6` |
36+| `failing` | appelle `bash cat /no/such/file-N` avec un N différent à chaque fois | `⚠ failing: 3 tool calls in a row`, 4, 5… puis `✖ max turns reached` |
37+| `unknown` | appelle `run_python`, qui n'existe pas | `✖ unknown tool: run_python` |
38+| `empty` | ne répond rien | `⚠ empty answer` |
39+| `context` | répond en annonçant 900 tokens de prompt | `⚠ context 90% full` |
40+| `broken` | écrit des arguments d'appel d'outil coupés au milieu du JSON | `⚠ broken tool call (cut off) 1/2`, `2/2` |
41+| `runaway` | envoie `abcdefgh` comme arguments, sans fin | `✖ runaway tool call` |
42+| `silent` | accepte la requête et ne répond jamais | `✖ stalled` (au bout de 3 s) |
43+| `turns` | appelle `bash echo step N` avec un N différent à chaque fois | `✖ max turns reached` (après 10 appels) |
44+
45+Dans la fenêtre, un badge `⚠` est noir sur jaune et un badge `✖` blanc sur rouge ; il reste jusqu'à la question suivante. Dans un terminal les lignes sont jaunes ou rouges ; redirigées (comme dans `run-all.sh`), elles sont sans couleur.
new file mode 100644
@@ -0,0 +1,45 @@
1+# Démo : voir chaque signal de difficulté sans attendre qu'un modèle échoue
2+
3+*[English version](README.en.md)*
4+
5+mm montre à l'utilisateur quand le modèle peine — une boucle, un appel d'outil cassé ou qui s'emballe, un blocage, trop de tours, un outil qui n'existe pas… ([signaux de difficulté](../../docs/fr/reference/cli.md#signaux-de-difficulté), ticket 153). Un vrai modèle n'échoue pas sur commande : cette démo le remplace par `fake-llm.py`, un faux serveur compatible OpenAI (Python, sans dépendance) qui peine exprès, un scénario à la fois.
6+
7+| Fichier | Rôle |
8+|---------|------|
9+| `fake-llm.py` | le faux modèle : `FAKE=<scénario>`, écoute sur `127.0.0.1:8765` (`PORT=` pour changer) ; il répond en flux comme un vrai serveur, et d'un bloc quand mm relance sans streaming |
10+| `agent.yaml` | l'agent : `provider: openai` sur le faux serveur, `contextWindow: 1000`, `maxTurns: 10`, un watchdog de 3 s |
11+| `run-all.sh` | passe chaque scénario dans `mm -p` et affiche les lignes de difficulté |
12+
13+## La lancer
14+
15+Tous les scénarios d'un coup (python3 et mm dans le PATH, ou `MM_BIN=/chemin/vers/mm`) :
16+
17+```bash
18+./run-all.sh
19+```
20+
21+Un scénario, dans la fenêtre — regardez la droite de la barre d'état :
22+
23+```bash
24+FAKE=loop python3 fake-llm.py &
25+mm tui agent.yaml # tapez n'importe quoi, puis Entrée
26+kill %1
27+```
28+
29+## Ce que vous devez voir
30+
31+Mesuré le 2026-10-25 avec `./run-all.sh` :
32+
33+| `FAKE=` | Le faux modèle… | Signal |
34+|---------|-----------------|--------|
35+| `loop` | appelle `bash cat /no/such/file` encore et encore | `⚠ failing: 3 tool calls in a row`, `⚠ loop: bash ×4`, `⚠ loop: bash ×5`, puis `✖ stuck: bash ×6` |
36+| `failing` | appelle `bash cat /no/such/file-N` avec un N différent à chaque fois | `⚠ failing: 3 tool calls in a row`, 4, 5… puis `✖ max turns reached` |
37+| `unknown` | appelle `run_python`, qui n'existe pas | `✖ unknown tool: run_python` |
38+| `empty` | ne répond rien | `⚠ empty answer` |
39+| `context` | répond en annonçant 900 tokens de prompt | `⚠ context 90% full` |
40+| `broken` | écrit des arguments d'appel d'outil coupés au milieu du JSON | `⚠ broken tool call (cut off) 1/2`, `2/2` |
41+| `runaway` | envoie `abcdefgh` comme arguments, sans fin | `✖ runaway tool call` |
42+| `silent` | accepte la requête et ne répond jamais | `✖ stalled` (au bout de 3 s) |
43+| `turns` | appelle `bash echo step N` avec un N différent à chaque fois | `✖ max turns reached` (après 10 appels) |
44+
45+Dans la fenêtre, un badge `⚠` est noir sur jaune et un badge `✖` blanc sur rouge ; il reste jusqu'à la question suivante. Dans un terminal les lignes sont jaunes ou rouges ; redirigées (comme dans `run-all.sh`), elles sont sans couleur.
added demos/struggle-signals/agent.yaml +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+# The struggle-signals demo (ticket 153): an agent whose "model" is
2+# fake-llm.py, a fake OpenAI-compatible server that struggles on purpose.
3+#
4+# FAKE=loop python3 fake-llm.py & # pick a scenario (see fake-llm.py)
5+# mm -config agent.yaml -p "go" # the ⚠ / ✖ lines
6+# mm tui agent.yaml # the badge in the status bar
7+provider: openai
8+model: fake
9+baseUrl: http://127.0.0.1:8765/v1
10+fallback: ""
11+contextWindow: 1000 # the "context" scenario reports 900 tokens: 90 %
12+maxTurns: 10 # room for the loop detector to stop the turn first (6 calls)
13+watchdogTimeout: 3s # the "silent" scenario stalls quickly
14+firstTokenTimeout: 3s
15+skillsDir: ""
16+agentsMd:
17+ enabled: false
18+sessions:
19+ enabled: false
new file mode 100644
@@ -0,0 +1,19 @@
1+# The struggle-signals demo (ticket 153): an agent whose "model" is
2+# fake-llm.py, a fake OpenAI-compatible server that struggles on purpose.
3+#
4+# FAKE=loop python3 fake-llm.py & # pick a scenario (see fake-llm.py)
5+# mm -config agent.yaml -p "go" # the ⚠ / ✖ lines
6+# mm tui agent.yaml # the badge in the status bar
7+provider: openai
8+model: fake
9+baseUrl: http://127.0.0.1:8765/v1
10+fallback: ""
11+contextWindow: 1000 # the "context" scenario reports 900 tokens: 90 %
12+maxTurns: 10 # room for the loop detector to stop the turn first (6 calls)
13+watchdogTimeout: 3s # the "silent" scenario stalls quickly
14+firstTokenTimeout: 3s
15+skillsDir: ""
16+agentsMd:
17+ enabled: false
18+sessions:
19+ enabled: false
added demos/struggle-signals/fake-llm.py +129 -0
new file mode 100644
@@ -0,0 +1,129 @@
1+#!/usr/bin/env python3
2+"""A fake OpenAI-compatible server that makes a model struggle on purpose,
3+to see mm's struggle signals (ticket 153) without waiting for a real model
4+to misbehave.
5+
6+ FAKE=loop python3 fake-llm.py # listens on 127.0.0.1:8765
7+
8+Scenarios (FAKE=...):
9+ loop always calls bash with the same failing command → ⚠ loop … then ✖ stuck
10+ unknown calls a tool that does not exist → ✖ unknown tool: run_python
11+ empty answers nothing → ⚠ empty answer
12+ context answers, reporting 900 prompt tokens (window 1000)→ ⚠ context 90% full
13+ broken tool-call arguments that are not JSON → ⚠ broken tool call …
14+ runaway tool-call arguments that repeat forever → ✖ runaway tool call
15+ silent accepts the request and never answers → ✖ stalled
16+ failing calls bash with a new failing command every time → ⚠ failing: 3 tool calls in a row (ticket 160)
17+ turns calls bash with a new command every time → ✖ max turns reached (after maxTurns calls)
18+"""
19+import json
20+import os
21+import time
22+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23+
24+SCENARIO = os.environ.get("FAKE", "loop")
25+PORT = int(os.environ.get("PORT", "8765"))
26+calls = 0
27+
28+
29+def chunk(delta, finish=None, usage=None):
30+ body = {"id": "fake", "object": "chat.completion.chunk", "created": 0, "model": "fake",
31+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
32+ if usage:
33+ body["usage"] = usage
34+ return "data: " + json.dumps(body) + "\n\n"
35+
36+
37+def tool_call(name, arguments):
38+ return [chunk({"role": "assistant", "tool_calls": [{"index": 0, "id": "call_%d" % calls, "type": "function",
39+ "function": {"name": name, "arguments": ""}}]}),
40+ chunk({"tool_calls": [{"index": 0, "function": {"arguments": arguments}}]}),
41+ chunk({}, "tool_calls")]
42+
43+
44+def text(content, usage=None):
45+ return [chunk({"role": "assistant", "content": content}), chunk({}, "stop", usage)]
46+
47+
48+def reply():
49+ """The SSE events of one answer, for the scenario."""
50+ if SCENARIO == "loop":
51+ return tool_call("bash", json.dumps({"command": "cat /no/such/file"}))
52+ if SCENARIO == "unknown":
53+ return tool_call("run_python", json.dumps({"code": "print(1)"}))
54+ if SCENARIO == "empty":
55+ return text("")
56+ if SCENARIO == "context":
57+ return text("Done.", {"prompt_tokens": 900, "completion_tokens": 2, "total_tokens": 902})
58+ if SCENARIO == "broken":
59+ return tool_call("bash", '{"command": "ls')
60+ if SCENARIO == "failing":
61+ return tool_call("bash", json.dumps({"command": "cat /no/such/file-%d" % calls}))
62+ if SCENARIO == "turns":
63+ return tool_call("bash", json.dumps({"command": "echo step %d" % calls}))
64+ raise ValueError(SCENARIO)
65+
66+
67+class Handler(BaseHTTPRequestHandler):
68+ def log_message(self, *args):
69+ pass
70+
71+ def do_GET(self): # /v1/models
72+ self.send_response(200)
73+ self.send_header("Content-Type", "application/json")
74+ self.end_headers()
75+ self.wfile.write(json.dumps({"data": [{"id": "fake", "object": "model"}]}).encode())
76+
77+ def do_POST(self):
78+ global calls
79+ calls += 1
80+ request = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
81+ if SCENARIO == "silent":
82+ time.sleep(3600)
83+ return
84+ if not request.get("stream"): # mm's retry without streaming
85+ self.answer_once()
86+ return
87+ self.send_response(200)
88+ self.send_header("Content-Type", "text/event-stream")
89+ self.end_headers()
90+ if SCENARIO == "runaway":
91+ for event in tool_call("bash", "")[:1]:
92+ self.wfile.write(event.encode())
93+ for _ in range(200): # "abcd" forever, until mm stops reading
94+ try:
95+ self.wfile.write(chunk({"tool_calls": [{"index": 0, "function": {"arguments": "abcdefgh"}}]}).encode())
96+ self.wfile.flush()
97+ except BrokenPipeError:
98+ return
99+ time.sleep(0.01)
100+ return
101+ for event in reply():
102+ self.wfile.write(event.encode())
103+ self.wfile.write(b"data: [DONE]\n\n")
104+
105+
106+ def answer_once(self):
107+ """The same scenario as one JSON completion (stream: false)."""
108+ message, finish = {"role": "assistant", "content": ""}, "stop"
109+ for event in reply():
110+ delta = json.loads(event[len("data: "):])["choices"][0]["delta"]
111+ message["content"] += delta.get("content") or ""
112+ for call in delta.get("tool_calls") or []:
113+ if "id" in call:
114+ message["tool_calls"] = [call]
115+ finish = "tool_calls"
116+ else:
117+ message["tool_calls"][0]["function"]["arguments"] += call["function"]["arguments"]
118+ body = {"id": "fake", "object": "chat.completion", "created": 0, "model": "fake",
119+ "choices": [{"index": 0, "message": message, "finish_reason": finish}]}
120+ data = json.dumps(body).encode()
121+ self.send_response(200)
122+ self.send_header("Content-Type", "application/json")
123+ self.send_header("Content-Length", str(len(data)))
124+ self.end_headers()
125+ self.wfile.write(data)
126+
127+
128+print("fake LLM (%s) on http://127.0.0.1:%d/v1" % (SCENARIO, PORT), flush=True)
129+ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
new file mode 100644
@@ -0,0 +1,129 @@
1+#!/usr/bin/env python3
2+"""A fake OpenAI-compatible server that makes a model struggle on purpose,
3+to see mm's struggle signals (ticket 153) without waiting for a real model
4+to misbehave.
5+
6+ FAKE=loop python3 fake-llm.py # listens on 127.0.0.1:8765
7+
8+Scenarios (FAKE=...):
9+ loop always calls bash with the same failing command → ⚠ loop … then ✖ stuck
10+ unknown calls a tool that does not exist → ✖ unknown tool: run_python
11+ empty answers nothing → ⚠ empty answer
12+ context answers, reporting 900 prompt tokens (window 1000)→ ⚠ context 90% full
13+ broken tool-call arguments that are not JSON → ⚠ broken tool call …
14+ runaway tool-call arguments that repeat forever → ✖ runaway tool call
15+ silent accepts the request and never answers → ✖ stalled
16+ failing calls bash with a new failing command every time → ⚠ failing: 3 tool calls in a row (ticket 160)
17+ turns calls bash with a new command every time → ✖ max turns reached (after maxTurns calls)
18+"""
19+import json
20+import os
21+import time
22+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
23+
24+SCENARIO = os.environ.get("FAKE", "loop")
25+PORT = int(os.environ.get("PORT", "8765"))
26+calls = 0
27+
28+
29+def chunk(delta, finish=None, usage=None):
30+ body = {"id": "fake", "object": "chat.completion.chunk", "created": 0, "model": "fake",
31+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}
32+ if usage:
33+ body["usage"] = usage
34+ return "data: " + json.dumps(body) + "\n\n"
35+
36+
37+def tool_call(name, arguments):
38+ return [chunk({"role": "assistant", "tool_calls": [{"index": 0, "id": "call_%d" % calls, "type": "function",
39+ "function": {"name": name, "arguments": ""}}]}),
40+ chunk({"tool_calls": [{"index": 0, "function": {"arguments": arguments}}]}),
41+ chunk({}, "tool_calls")]
42+
43+
44+def text(content, usage=None):
45+ return [chunk({"role": "assistant", "content": content}), chunk({}, "stop", usage)]
46+
47+
48+def reply():
49+ """The SSE events of one answer, for the scenario."""
50+ if SCENARIO == "loop":
51+ return tool_call("bash", json.dumps({"command": "cat /no/such/file"}))
52+ if SCENARIO == "unknown":
53+ return tool_call("run_python", json.dumps({"code": "print(1)"}))
54+ if SCENARIO == "empty":
55+ return text("")
56+ if SCENARIO == "context":
57+ return text("Done.", {"prompt_tokens": 900, "completion_tokens": 2, "total_tokens": 902})
58+ if SCENARIO == "broken":
59+ return tool_call("bash", '{"command": "ls')
60+ if SCENARIO == "failing":
61+ return tool_call("bash", json.dumps({"command": "cat /no/such/file-%d" % calls}))
62+ if SCENARIO == "turns":
63+ return tool_call("bash", json.dumps({"command": "echo step %d" % calls}))
64+ raise ValueError(SCENARIO)
65+
66+
67+class Handler(BaseHTTPRequestHandler):
68+ def log_message(self, *args):
69+ pass
70+
71+ def do_GET(self): # /v1/models
72+ self.send_response(200)
73+ self.send_header("Content-Type", "application/json")
74+ self.end_headers()
75+ self.wfile.write(json.dumps({"data": [{"id": "fake", "object": "model"}]}).encode())
76+
77+ def do_POST(self):
78+ global calls
79+ calls += 1
80+ request = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
81+ if SCENARIO == "silent":
82+ time.sleep(3600)
83+ return
84+ if not request.get("stream"): # mm's retry without streaming
85+ self.answer_once()
86+ return
87+ self.send_response(200)
88+ self.send_header("Content-Type", "text/event-stream")
89+ self.end_headers()
90+ if SCENARIO == "runaway":
91+ for event in tool_call("bash", "")[:1]:
92+ self.wfile.write(event.encode())
93+ for _ in range(200): # "abcd" forever, until mm stops reading
94+ try:
95+ self.wfile.write(chunk({"tool_calls": [{"index": 0, "function": {"arguments": "abcdefgh"}}]}).encode())
96+ self.wfile.flush()
97+ except BrokenPipeError:
98+ return
99+ time.sleep(0.01)
100+ return
101+ for event in reply():
102+ self.wfile.write(event.encode())
103+ self.wfile.write(b"data: [DONE]\n\n")
104+
105+
106+ def answer_once(self):
107+ """The same scenario as one JSON completion (stream: false)."""
108+ message, finish = {"role": "assistant", "content": ""}, "stop"
109+ for event in reply():
110+ delta = json.loads(event[len("data: "):])["choices"][0]["delta"]
111+ message["content"] += delta.get("content") or ""
112+ for call in delta.get("tool_calls") or []:
113+ if "id" in call:
114+ message["tool_calls"] = [call]
115+ finish = "tool_calls"
116+ else:
117+ message["tool_calls"][0]["function"]["arguments"] += call["function"]["arguments"]
118+ body = {"id": "fake", "object": "chat.completion", "created": 0, "model": "fake",
119+ "choices": [{"index": 0, "message": message, "finish_reason": finish}]}
120+ data = json.dumps(body).encode()
121+ self.send_response(200)
122+ self.send_header("Content-Type", "application/json")
123+ self.send_header("Content-Length", str(len(data)))
124+ self.end_headers()
125+ self.wfile.write(data)
126+
127+
128+print("fake LLM (%s) on http://127.0.0.1:%d/v1" % (SCENARIO, PORT), flush=True)
129+ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
added demos/struggle-signals/run-all.sh +15 -0
new file mode 100755
@@ -0,0 +1,15 @@
1+#!/usr/bin/env bash
2+# Runs every scenario of fake-llm.py through `mm -p` and prints the struggle
3+# lines it produced. Needs python3 and mm on the PATH (or MM_BIN).
4+set -eu
5+cd "$(dirname "$0")"
6+mm="${MM_BIN:-mm}"
7+for scenario in loop failing unknown empty context broken runaway silent turns; do
8+ FAKE="$scenario" python3 fake-llm.py >/dev/null 2>&1 &
9+ server=$!
10+ sleep 0.5
11+ echo "== $scenario"
12+ "$mm" -config agent.yaml -p "go" 2>&1 | grep -E '^(⚠|✖) ' || echo "(no struggle line)"
13+ kill "$server"
14+ wait "$server" 2>/dev/null || true
15+done
new file mode 100755
@@ -0,0 +1,15 @@
1+#!/usr/bin/env bash
2+# Runs every scenario of fake-llm.py through `mm -p` and prints the struggle
3+# lines it produced. Needs python3 and mm on the PATH (or MM_BIN).
4+set -eu
5+cd "$(dirname "$0")"
6+mm="${MM_BIN:-mm}"
7+for scenario in loop failing unknown empty context broken runaway silent turns; do
8+ FAKE="$scenario" python3 fake-llm.py >/dev/null 2>&1 &
9+ server=$!
10+ sleep 0.5
11+ echo "== $scenario"
12+ "$mm" -config agent.yaml -p "go" 2>&1 | grep -E '^(⚠|✖) ' || echo "(no struggle line)"
13+ kill "$server"
14+ wait "$server" 2>/dev/null || true
15+done
modified docs/en/README.md +1 -0
@@ -86,6 +86,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
8686 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
8787 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits
8888 - [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text
89+- [Noticing when a model struggles](explanation/struggling-models.md) — the struggle signals: two levels, reported where they are detected, a status-bar badge until the next question, why a cancelled turn reports nothing
8990 - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive
9091 - [Yolo mode and permissions](explanation/yolo-and-permissions.md) — why tools run unasked by default, existing versus asking, four answers in one package, why the asking agent decides (and a per-agent `yolo` in `mmConfigs`)
9192 - [Project instructions (`AGENTS.md`)](explanation/project-instructions.md) — why appended to the system prompt, why per conversation, why a warning rather than a cut, who does not read it
@@ -86,6 +86,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
86 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission86 - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission
87 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits87 - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits
88 - [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text88 - [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text
89+- [Noticing when a model struggles](explanation/struggling-models.md) — the struggle signals: two levels, reported where they are detected, a status-bar badge until the next question, why a cancelled turn reports nothing
89 - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive90 - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive
90 - [Yolo mode and permissions](explanation/yolo-and-permissions.md) — why tools run unasked by default, existing versus asking, four answers in one package, why the asking agent decides (and a per-agent `yolo` in `mmConfigs`)91 - [Yolo mode and permissions](explanation/yolo-and-permissions.md) — why tools run unasked by default, existing versus asking, four answers in one package, why the asking agent decides (and a per-agent `yolo` in `mmConfigs`)
91 - [Project instructions (`AGENTS.md`)](explanation/project-instructions.md) — why appended to the system prompt, why per conversation, why a warning rather than a cut, who does not read it92 - [Project instructions (`AGENTS.md`)](explanation/project-instructions.md) — why appended to the system prompt, why per conversation, why a warning rather than a cut, who does not read it
added docs/en/explanation/struggling-models.md +30 -0
new file mode 100644
@@ -0,0 +1,30 @@
1+# Noticing when a model struggles — explanation
2+
3+## What is this about?
4+
5+`mm` was written for small local models, and small models struggle in recognisable ways: they repeat the same failing call, they write tool-call arguments that do not parse or never end, they go silent, they call tools that do not exist, they run out of turns, they answer nothing. From the start `mm` noticed most of these — the loop detector, the runaway guard, the watchdog, the note handed back after a broken call — but it told the **model** (an instruction in a tool's output) or ended the turn with an error line among the rest of the conversation. The person watching had to read every line to know that the model was going in circles.
6+
7+Struggle signals (ticket 153) make the same detections visible to the **user**, apart from the answer, in a colour that says how bad it is. The list of signals and how each front end shows them is in the [CLI reference](../reference/cli.md#struggle-signals).
8+
9+## Why it is designed this way
10+
11+**Two levels, not a score.** A warning (`⚠`, yellow) means `mm` noticed something and the turn goes on — the model was told it is looping, a broken call was handed back, the context is nearly full. A severe signal (`✖`, red) means the turn was stopped or failed because of it. That is the distinction a person acts on: wait and watch, or rephrase and try again (or change model).
12+
13+**Reported where they are detected.** Each detection already lived in one place — the loop detector's `Record`, the model-call wrapper for retries and broken calls, the end of `Generate` for how a turn ended. Each reports a `ui.Struggle` there; nothing re-reads the conversation afterwards to guess. The loop detector learned to call a function (`WithReport`) rather than to know about the screen, so it stays a package with no front-end knowledge.
14+
15+**One event, three renderings**, as for everything else in `mm` ([architecture](architecture.md)): the window keeps the worst struggle of the turn as a badge in the status bar; the line mode prints a coloured line when it happens; an editor gets a short italic paragraph signed `mm` in the agent's message, because it has no status bar of ours.
16+
17+**In the window, the status bar, until the next question.** The badge sits on the right of the bottom bar, in the version's place: the version is the least useful thing there while something is wrong, and the key hints on the left — `Ctrl-C quit` above all, the way out of a turn that will not stop — keep their room. It outlives the turn on purpose: once a turn ended badly, the badge says why, and it goes when the next question starts a new turn. A severe signal beats a warning; between two of the same level the latest wins, so `loop: bash ×4` replaces `×3`; `(+N)` counts the others.
18+
19+**Failures in a row, not only repeats** (ticket 160). The loop detector needs the same call with the same result; a model that tries `cat config.yml`, then `cat conf/config.yml`, then `cat config.yaml` and fails each time is struggling just as much. Whether a call failed is only known inside each tool, so every tool now hands its outcome to the detector with the call (`record`, `recordFailure`, `recordOutcome`); the detector, which already sees every call, counts the failures in a row and reports `failing: N tool calls in a row` from the third. It is a warning only: the model reads each failure in the tool's output already, and a hard stop would end turns that a fourth attempt would have saved.
20+
21+**A cancelled turn is not a struggle.** Esc, Ctrl-C or `/abort` end a turn on the user's request; reporting `stalled` or `max turns` then would blame the model for the user's choice.
22+
23+## Rejected alternatives
24+
25+- **The header.** Considered; the user chose the status bar, which keeps the header for who is answering and how full the context is.
26+- **A transcript line per signal.** The conversation is where the answer is; a loop flagged at every repeat would fill it with lines that repeat the tool's own output. The badge summarises; the line mode, which has no status bar, prints the lines.
27+
28+## How it relates to the rest
29+
30+The detections themselves are unchanged: the loop detector still tells the model to change approach and still stops the turn after six identical calls; the watchdog, the runaway guard and the broken-call note behave as before. What the turn returns and what the history keeps do not change either — a struggle is shown, never stored. See also [context compression](context-compression.md), which a `context N% full` warning usually precedes, and [thinking models](thinking-models.md) for why the watchdog stays fed while a model reasons.
new file mode 100644
@@ -0,0 +1,30 @@
1+# Noticing when a model struggles — explanation
2+
3+## What is this about?
4+
5+`mm` was written for small local models, and small models struggle in recognisable ways: they repeat the same failing call, they write tool-call arguments that do not parse or never end, they go silent, they call tools that do not exist, they run out of turns, they answer nothing. From the start `mm` noticed most of these — the loop detector, the runaway guard, the watchdog, the note handed back after a broken call — but it told the **model** (an instruction in a tool's output) or ended the turn with an error line among the rest of the conversation. The person watching had to read every line to know that the model was going in circles.
6+
7+Struggle signals (ticket 153) make the same detections visible to the **user**, apart from the answer, in a colour that says how bad it is. The list of signals and how each front end shows them is in the [CLI reference](../reference/cli.md#struggle-signals).
8+
9+## Why it is designed this way
10+
11+**Two levels, not a score.** A warning (`⚠`, yellow) means `mm` noticed something and the turn goes on — the model was told it is looping, a broken call was handed back, the context is nearly full. A severe signal (`✖`, red) means the turn was stopped or failed because of it. That is the distinction a person acts on: wait and watch, or rephrase and try again (or change model).
12+
13+**Reported where they are detected.** Each detection already lived in one place — the loop detector's `Record`, the model-call wrapper for retries and broken calls, the end of `Generate` for how a turn ended. Each reports a `ui.Struggle` there; nothing re-reads the conversation afterwards to guess. The loop detector learned to call a function (`WithReport`) rather than to know about the screen, so it stays a package with no front-end knowledge.
14+
15+**One event, three renderings**, as for everything else in `mm` ([architecture](architecture.md)): the window keeps the worst struggle of the turn as a badge in the status bar; the line mode prints a coloured line when it happens; an editor gets a short italic paragraph signed `mm` in the agent's message, because it has no status bar of ours.
16+
17+**In the window, the status bar, until the next question.** The badge sits on the right of the bottom bar, in the version's place: the version is the least useful thing there while something is wrong, and the key hints on the left — `Ctrl-C quit` above all, the way out of a turn that will not stop — keep their room. It outlives the turn on purpose: once a turn ended badly, the badge says why, and it goes when the next question starts a new turn. A severe signal beats a warning; between two of the same level the latest wins, so `loop: bash ×4` replaces `×3`; `(+N)` counts the others.
18+
19+**Failures in a row, not only repeats** (ticket 160). The loop detector needs the same call with the same result; a model that tries `cat config.yml`, then `cat conf/config.yml`, then `cat config.yaml` and fails each time is struggling just as much. Whether a call failed is only known inside each tool, so every tool now hands its outcome to the detector with the call (`record`, `recordFailure`, `recordOutcome`); the detector, which already sees every call, counts the failures in a row and reports `failing: N tool calls in a row` from the third. It is a warning only: the model reads each failure in the tool's output already, and a hard stop would end turns that a fourth attempt would have saved.
20+
21+**A cancelled turn is not a struggle.** Esc, Ctrl-C or `/abort` end a turn on the user's request; reporting `stalled` or `max turns` then would blame the model for the user's choice.
22+
23+## Rejected alternatives
24+
25+- **The header.** Considered; the user chose the status bar, which keeps the header for who is answering and how full the context is.
26+- **A transcript line per signal.** The conversation is where the answer is; a loop flagged at every repeat would fill it with lines that repeat the tool's own output. The badge summarises; the line mode, which has no status bar, prints the lines.
27+
28+## How it relates to the rest
29+
30+The detections themselves are unchanged: the loop detector still tells the model to change approach and still stops the turn after six identical calls; the watchdog, the runaway guard and the broken-call note behave as before. What the turn returns and what the history keeps do not change either — a struggle is shown, never stored. See also [context compression](context-compression.md), which a `context N% full` warning usually precedes, and [thinking models](thinking-models.md) for why the watchdog stays fed while a model reasons.
modified docs/en/how-to/use-the-window-mode.md +2 -0
@@ -33,6 +33,8 @@ This guide shows how to run mini-me as a single full-screen window — the conve
3333 - **Scroll the conversation.** PgUp/PgDn page; **Tab** moves the focus to the conversation so ↑/↓, Home and End scroll it; End resumes following the end. **Ctrl-L** clears the displayed conversation without touching the history. The mouse wheel scrolls only after **F2** (see below).
3434 - **Not a terminal.** `mm -tui | tee log.txt`, or any run where stdin or stdout is not a terminal, prints `[tui: stdin or stdout is not a terminal — running the line mode]` and runs the line mode. Nothing else changes.
3535
36+- **The status bar turns yellow or red.** The model is struggling: a yellow `⚠` badge (a loop flagged, a broken tool call handed back, the context nearly full…) means the turn goes on — watch it; a red `✖` badge (stuck in a loop, stalled, max turns, an unknown tool…) means the turn was stopped — rephrase, split the request, or try another model. The badge stays until your next question. See [Struggle signals](../reference/cli.md#struggle-signals).
37+
3638 ## Copy text out of the window
3739
3840 The window leaves the mouse to the terminal: click and drag selects text exactly as in any terminal, and your terminal's copy shortcut copies it. The status bar says `F2 mouse: off`. Press **F2** to hand the mouse to the window instead — the wheel then scrolls the conversation and the status bar says `mouse: on (Shift+drag selects)`; in most terminals, holding Shift while dragging still selects. F2 again gives the mouse back.
@@ -33,6 +33,8 @@ This guide shows how to run mini-me as a single full-screen window — the conve
33 - **Scroll the conversation.** PgUp/PgDn page; **Tab** moves the focus to the conversation so ↑/↓, Home and End scroll it; End resumes following the end. **Ctrl-L** clears the displayed conversation without touching the history. The mouse wheel scrolls only after **F2** (see below).33 - **Scroll the conversation.** PgUp/PgDn page; **Tab** moves the focus to the conversation so ↑/↓, Home and End scroll it; End resumes following the end. **Ctrl-L** clears the displayed conversation without touching the history. The mouse wheel scrolls only after **F2** (see below).
34 - **Not a terminal.** `mm -tui | tee log.txt`, or any run where stdin or stdout is not a terminal, prints `[tui: stdin or stdout is not a terminal — running the line mode]` and runs the line mode. Nothing else changes.34 - **Not a terminal.** `mm -tui | tee log.txt`, or any run where stdin or stdout is not a terminal, prints `[tui: stdin or stdout is not a terminal — running the line mode]` and runs the line mode. Nothing else changes.
35 35
36+- **The status bar turns yellow or red.** The model is struggling: a yellow `⚠` badge (a loop flagged, a broken tool call handed back, the context nearly full…) means the turn goes on — watch it; a red `✖` badge (stuck in a loop, stalled, max turns, an unknown tool…) means the turn was stopped — rephrase, split the request, or try another model. The badge stays until your next question. See [Struggle signals](../reference/cli.md#struggle-signals).
37+
36 ## Copy text out of the window38 ## Copy text out of the window
37 39
38 The window leaves the mouse to the terminal: click and drag selects text exactly as in any terminal, and your terminal's copy shortcut copies it. The status bar says `F2 mouse: off`. Press **F2** to hand the mouse to the window instead — the wheel then scrolls the conversation and the status bar says `mouse: on (Shift+drag selects)`; in most terminals, holding Shift while dragging still selects. F2 again gives the mouse back.40 The window leaves the mouse to the terminal: click and drag selects text exactly as in any terminal, and your terminal's copy shortcut copies it. The status bar says `F2 mouse: off`. Press **F2** to hand the mouse to the window instead — the wheel then scrolls the conversation and the status bar says `mouse: on (Shift+drag selects)`; in most terminals, holding Shift while dragging still selects. F2 again gives the mouse back.
modified docs/en/reference/cli.md +30 -0
@@ -97,6 +97,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
9797 | stdout | JSON-RPC 2.0 messages only, one per line. |
9898 | stderr | Banner, warnings, `[acp] …` trail, SDK logs. |
9999 | Spinner | Disabled. |
100+| Struggles | A `*⚠ mm: …*` paragraph in the agent's message — see [Struggle signals](#struggle-signals). |
100101 | Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |
101102 | Permissions | `yolo: true`: no `session/request_permission` is sent for the built-in tools. `yolo: false`: one per call, with four options — `allow` (allow once), `allow_always` (this tool for the session), `reject` (reject once), `never` (`reject_always`: this tool, for the session). MCP tools marked `ask` always send one. |
102103
@@ -104,6 +105,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
104105
105106 | Aspect | Behaviour |
106107 |--------|-----------|
108+| Struggles | A yellow `⚠` or red `✖` line when it happens — see [Struggle signals](#struggle-signals). |
107109 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), after the tool's `🛠️` line: `🔐 <tool title>`, the legend `[a] allow once · [s] allow <tool> for this session · [r] reject · [n] never <tool>` and ` ? `. The next typed line answers (`a`, `s`, `r`, `n` or the words `once`, `session`, `reject`, `never`); another answer prints the legend again; a line starting with `/` is a command (`/abort`, `/quit`), not an answer. `Ctrl+C` or `/abort` rejects and stops the turn. The same prompt answers the calls of an agent asked with `/ask`. |
108110
109111 ## Window mode specifics
@@ -123,6 +125,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
123125 | Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
124126 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject [n] never <tool>` until answered (`Esc` = `r`); a turn cancelled while asking rejects. |
125127 | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |
128+| Struggles | A badge on the right of the status bar until the next question — see [Struggle signals](#struggle-signals). |
126129 | Mouse | Not captured at start-up, so the terminal's click-and-drag selection works. `F2` captures it (wheel scrolling; `Shift`+drag selects) and releases it again. |
127130
128131 ## Prompt mode specifics
@@ -154,6 +157,33 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
154157 | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. |
155158 | Session | None: neither this process nor the agent writes session files. |
156159
160+## Struggle signals
161+
162+Signs that the model is struggling, shown to the user apart from the answer (ticket 153). `⚠` is a warning: the turn goes on. `✖` is severe: the turn was stopped or failed because of it.
163+
164+| Signal | Level | When |
165+|--------|-------|------|
166+| `loop: <tool> ×N` | ⚠ | the loop detector flagged the same call with the same result N times in a row; the model is told to change approach |
167+| `stuck: <tool> ×N` | ✖ | the model ignored that advice: the loop detector stopped the turn |
168+| `failing: N tool calls in a row` | ⚠ | the last N tool calls (N ≥ 3) all failed, with different inputs — a non-zero exit, a refused edit, a missing file, a rejected permission…; reported again at each new failure, reset by the first success; the model is not told |
169+| `broken tool call (cut off\|invalid JSON) n/m` | ⚠ | a tool call's arguments did not parse; the call is handed back to the model (attempt n of m) |
170+| `stream cut — retried` | ⚠ | the server broke the stream; this model call is retried once |
171+| `runaway tool call` | ✖ | a tool call's arguments repeated a pattern or grew past 256 KB; generation stopped |
172+| `stalled` | ✖ | the watchdog cut a silent generation |
173+| `max turns reached` | ✖ | `maxTurns` model ↔ tools round trips without a final answer |
174+| `unknown tool: <name>` | ✖ | the model called a tool that does not exist |
175+| `empty answer` | ⚠ | the turn ended without text and without a tool call |
176+| `context N% full` | ⚠ | the last model call read 80 % of the context window or more (window known only) |
177+
178+A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers every signal with a fake model.
179+
180+| Front end | Shown as |
181+|-----------|----------|
182+| Window | A badge on the right of the status bar, in front of the counters (the version gives way): black on yellow for `⚠`, white on red for `✖`. The worst of the turn, with `(+N)` for the others; a severe signal beats a warning, the latest of the same level wins. It stays after the turn and goes when the next question is sent. |
183+| Line mode, `-p` | One line `⚠ <signal>` / `✖ <signal>` on stdout when it happens: yellow or red on a terminal, plain otherwise. |
184+| ACP | A paragraph `*⚠ mm: <signal>*` in the agent's message when it happens; not kept in the history. |
185+| `-p` with `structuredOutput` | The plain line, on stderr. |
186+
157187 ## `mm init` — the agent.yaml wizard
158188
159189 `mm init` and `mm --init` (also `-init`) write a new, commented agent.yaml. They are answered before any configuration is read. Each question below has a flag; a question whose flag is given is not asked. See [How to create an agent with `mm init`](../how-to/create-an-agent.md).
@@ -97,6 +97,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
97 | stdout | JSON-RPC 2.0 messages only, one per line. |97 | stdout | JSON-RPC 2.0 messages only, one per line. |
98 | stderr | Banner, warnings, `[acp] …` trail, SDK logs. |98 | stderr | Banner, warnings, `[acp] …` trail, SDK logs. |
99 | Spinner | Disabled. |99 | Spinner | Disabled. |
100+| Struggles | A `*⚠ mm: …*` paragraph in the agent's message — see [Struggle signals](#struggle-signals). |
100 | Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |101 | Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |
101 | Permissions | `yolo: true`: no `session/request_permission` is sent for the built-in tools. `yolo: false`: one per call, with four options — `allow` (allow once), `allow_always` (this tool for the session), `reject` (reject once), `never` (`reject_always`: this tool, for the session). MCP tools marked `ask` always send one. |102 | Permissions | `yolo: true`: no `session/request_permission` is sent for the built-in tools. `yolo: false`: one per call, with four options — `allow` (allow once), `allow_always` (this tool for the session), `reject` (reject once), `never` (`reject_always`: this tool, for the session). MCP tools marked `ask` always send one. |
102 103
@@ -104,6 +105,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
104 105
105 | Aspect | Behaviour |106 | Aspect | Behaviour |
106 |--------|-----------|107 |--------|-----------|
108+| Struggles | A yellow `⚠` or red `✖` line when it happens — see [Struggle signals](#struggle-signals). |
107 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), after the tool's `🛠️` line: `🔐 <tool title>`, the legend `[a] allow once · [s] allow <tool> for this session · [r] reject · [n] never <tool>` and ` ? `. The next typed line answers (`a`, `s`, `r`, `n` or the words `once`, `session`, `reject`, `never`); another answer prints the legend again; a line starting with `/` is a command (`/abort`, `/quit`), not an answer. `Ctrl+C` or `/abort` rejects and stops the turn. The same prompt answers the calls of an agent asked with `/ask`. |109 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), after the tool's `🛠️` line: `🔐 <tool title>`, the legend `[a] allow once · [s] allow <tool> for this session · [r] reject · [n] never <tool>` and ` ? `. The next typed line answers (`a`, `s`, `r`, `n` or the words `once`, `session`, `reject`, `never`); another answer prints the legend again; a line starting with `/` is a command (`/abort`, `/quit`), not an answer. `Ctrl+C` or `/abort` rejects and stops the turn. The same prompt answers the calls of an agent asked with `/ask`. |
108 110
109 ## Window mode specifics111 ## Window mode specifics
@@ -123,6 +125,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
123 | Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |125 | Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
124 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject [n] never <tool>` until answered (`Esc` = `r`); a turn cancelled while asking rejects. |126 | Permissions | With `yolo: false` (and for MCP tools marked `ask`), the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject [n] never <tool>` until answered (`Esc` = `r`); a turn cancelled while asking rejects. |
125 | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |127 | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |
128+| Struggles | A badge on the right of the status bar until the next question — see [Struggle signals](#struggle-signals). |
126 | Mouse | Not captured at start-up, so the terminal's click-and-drag selection works. `F2` captures it (wheel scrolling; `Shift`+drag selects) and releases it again. |129 | Mouse | Not captured at start-up, so the terminal's click-and-drag selection works. `F2` captures it (wheel scrolling; `Shift`+drag selects) and releases it again. |
127 130
128 ## Prompt mode specifics131 ## Prompt mode specifics
@@ -154,6 +157,33 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
154 | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. |157 | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. |
155 | Session | None: neither this process nor the agent writes session files. |158 | Session | None: neither this process nor the agent writes session files. |
156 159
160+## Struggle signals
161+
162+Signs that the model is struggling, shown to the user apart from the answer (ticket 153). `⚠` is a warning: the turn goes on. `✖` is severe: the turn was stopped or failed because of it.
163+
164+| Signal | Level | When |
165+|--------|-------|------|
166+| `loop: <tool> ×N` | ⚠ | the loop detector flagged the same call with the same result N times in a row; the model is told to change approach |
167+| `stuck: <tool> ×N` | ✖ | the model ignored that advice: the loop detector stopped the turn |
168+| `failing: N tool calls in a row` | ⚠ | the last N tool calls (N ≥ 3) all failed, with different inputs — a non-zero exit, a refused edit, a missing file, a rejected permission…; reported again at each new failure, reset by the first success; the model is not told |
169+| `broken tool call (cut off\|invalid JSON) n/m` | ⚠ | a tool call's arguments did not parse; the call is handed back to the model (attempt n of m) |
170+| `stream cut — retried` | ⚠ | the server broke the stream; this model call is retried once |
171+| `runaway tool call` | ✖ | a tool call's arguments repeated a pattern or grew past 256 KB; generation stopped |
172+| `stalled` | ✖ | the watchdog cut a silent generation |
173+| `max turns reached` | ✖ | `maxTurns` model ↔ tools round trips without a final answer |
174+| `unknown tool: <name>` | ✖ | the model called a tool that does not exist |
175+| `empty answer` | ⚠ | the turn ended without text and without a tool call |
176+| `context N% full` | ⚠ | the last model call read 80 % of the context window or more (window known only) |
177+
178+A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers every signal with a fake model.
179+
180+| Front end | Shown as |
181+|-----------|----------|
182+| Window | A badge on the right of the status bar, in front of the counters (the version gives way): black on yellow for `⚠`, white on red for `✖`. The worst of the turn, with `(+N)` for the others; a severe signal beats a warning, the latest of the same level wins. It stays after the turn and goes when the next question is sent. |
183+| Line mode, `-p` | One line `⚠ <signal>` / `✖ <signal>` on stdout when it happens: yellow or red on a terminal, plain otherwise. |
184+| ACP | A paragraph `*⚠ mm: <signal>*` in the agent's message when it happens; not kept in the history. |
185+| `-p` with `structuredOutput` | The plain line, on stderr. |
186+
157 ## `mm init` — the agent.yaml wizard187 ## `mm init` — the agent.yaml wizard
158 188
159 `mm init` and `mm --init` (also `-init`) write a new, commented agent.yaml. They are answered before any configuration is read. Each question below has a flag; a question whose flag is given is not asked. See [How to create an agent with `mm init`](../how-to/create-an-agent.md).189 `mm init` and `mm --init` (also `-init`) write a new, commented agent.yaml. They are answered before any configuration is read. Each question below has a flag; a question whose flag is given is not asked. See [How to create an agent with `mm init`](../how-to/create-an-agent.md).
modified docs/fr/README.md +1 -0
@@ -86,6 +86,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
8686 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
8787 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues
8888 - [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte
89+- [Repérer qu'un modèle peine](explanation/struggling-models.md) — les signaux de difficulté : deux niveaux, signalés là où ils sont détectés, un badge dans la barre d'état jusqu'à la question suivante, pourquoi un tour annulé ne signale rien
8990 - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie
9091 - [Le mode yolo et les permissions](explanation/yolo-and-permissions.md) — pourquoi les outils s'exécutent sans demander par défaut, exister ou demander, quatre réponses dans un seul paquet, pourquoi l'agent qui interroge décide (et un `yolo` par agent dans `mmConfigs`)
9192 - [Consignes du projet (`AGENTS.md`)](explanation/project-instructions.md) — pourquoi ajoutées au prompt système, pourquoi par conversation, pourquoi un avertissement plutôt qu'une coupe, qui ne les lit pas
@@ -86,6 +86,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
86 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission86 - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission
87 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues87 - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues
88 - [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte88 - [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte
89+- [Repérer qu'un modèle peine](explanation/struggling-models.md) — les signaux de difficulté : deux niveaux, signalés là où ils sont détectés, un badge dans la barre d'état jusqu'à la question suivante, pourquoi un tour annulé ne signale rien
89 - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie90 - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie
90 - [Le mode yolo et les permissions](explanation/yolo-and-permissions.md) — pourquoi les outils s'exécutent sans demander par défaut, exister ou demander, quatre réponses dans un seul paquet, pourquoi l'agent qui interroge décide (et un `yolo` par agent dans `mmConfigs`)91 - [Le mode yolo et les permissions](explanation/yolo-and-permissions.md) — pourquoi les outils s'exécutent sans demander par défaut, exister ou demander, quatre réponses dans un seul paquet, pourquoi l'agent qui interroge décide (et un `yolo` par agent dans `mmConfigs`)
91 - [Consignes du projet (`AGENTS.md`)](explanation/project-instructions.md) — pourquoi ajoutées au prompt système, pourquoi par conversation, pourquoi un avertissement plutôt qu'une coupe, qui ne les lit pas92 - [Consignes du projet (`AGENTS.md`)](explanation/project-instructions.md) — pourquoi ajoutées au prompt système, pourquoi par conversation, pourquoi un avertissement plutôt qu'une coupe, qui ne les lit pas
added docs/fr/explanation/struggling-models.md +30 -0
new file mode 100644
@@ -0,0 +1,30 @@
1+# Repérer qu'un modèle peine — explication
2+
3+## De quoi s'agit-il ?
4+
5+`mm` a été écrit pour de petits modèles locaux, et les petits modèles peinent de façons reconnaissables : ils répètent le même appel qui échoue, ils écrivent des arguments d'appel d'outil illisibles ou sans fin, ils se taisent, ils appellent des outils qui n'existent pas, ils épuisent leurs tours, ils ne répondent rien. Dès le départ, `mm` repérait la plupart de ces cas — le détecteur de boucles, le garde-fou contre l'emballement, le watchdog, la note rendue après un appel cassé — mais il le disait au **modèle** (une consigne dans la sortie d'un outil) ou terminait le tour par une ligne d'erreur au milieu du reste de la conversation. La personne qui regardait devait tout lire pour savoir que le modèle tournait en rond.
6+
7+Les signaux de difficulté (ticket 153) rendent ces mêmes détections visibles à l'**utilisateur**, à part de la réponse, dans une couleur qui dit la gravité. La liste des signaux et la façon dont chaque front end les affiche sont dans la [référence du CLI](../reference/cli.md#signaux-de-difficulté).
8+
9+## Pourquoi c'est conçu ainsi
10+
11+**Deux niveaux, pas un score.** Un avertissement (`⚠`, jaune) veut dire que `mm` a remarqué quelque chose et que le tour continue — le modèle a été prévenu qu'il boucle, un appel cassé lui a été rendu, le contexte est presque plein. Un signal grave (`✖`, rouge) veut dire que le tour a été arrêté ou a échoué à cause de lui. C'est la distinction sur laquelle on agit : attendre et regarder, ou reformuler et recommencer (ou changer de modèle).
12+
13+**Signalés là où ils sont détectés.** Chaque détection vivait déjà à un seul endroit — le `Record` du détecteur de boucles, l'enveloppe des appels au modèle pour les relances et les appels cassés, la fin de `Generate` pour la façon dont un tour s'est terminé. Chacune y signale un `ui.Struggle` ; rien ne relit la conversation après coup pour deviner. Le détecteur de boucles appelle une fonction (`WithReport`) plutôt que de connaître l'écran : il reste un package sans connaissance des front ends.
14+
15+**Un événement, trois rendus**, comme tout le reste de `mm` ([architecture](architecture.md)) : la fenêtre garde la difficulté la plus grave du tour en badge dans la barre d'état ; le mode ligne imprime une ligne colorée au moment où elle arrive ; un éditeur reçoit un court paragraphe en italique signé `mm` dans le message de l'agent, faute de barre d'état à nous.
16+
17+**Dans la fenêtre, la barre d'état, jusqu'à la question suivante.** Le badge se place à droite de la barre du bas, à la place de la version : c'est ce qui sert le moins quand quelque chose ne va pas, et les raccourcis à gauche — `Ctrl-C quit` avant tout, la sortie d'un tour qui ne s'arrête pas — gardent leur place. Il survit au tour exprès : quand un tour finit mal, le badge dit pourquoi, et il disparaît quand la question suivante ouvre un nouveau tour. Un signal grave l'emporte sur un avertissement ; entre deux de même niveau le plus récent gagne, si bien que `loop: bash ×4` remplace `×3` ; `(+N)` compte les autres.
18+
19+**Des échecs d'affilée, pas seulement des répétitions** (ticket 160). Le détecteur de boucles veut le même appel avec le même résultat ; un modèle qui essaie `cat config.yml`, puis `cat conf/config.yml`, puis `cat config.yaml` et échoue à chaque fois peine tout autant. L'échec d'un appel n'est connu qu'à l'intérieur de chaque outil : chaque outil transmet donc maintenant son résultat au détecteur avec l'appel (`record`, `recordFailure`, `recordOutcome`) ; le détecteur, qui voit déjà chaque appel, compte les échecs d'affilée et signale `failing: N tool calls in a row` à partir du troisième. C'est seulement un avertissement : le modèle lit déjà chaque échec dans la sortie de l'outil, et un arrêt brutal couperait des tours qu'un quatrième essai aurait sauvés.
20+
21+**Un tour annulé n'est pas une difficulté.** Échap, Ctrl-C ou `/abort` terminent un tour à la demande de l'utilisateur ; signaler alors `stalled` ou `max turns` reprocherait au modèle le choix de l'utilisateur.
22+
23+## Alternatives écartées
24+
25+- **L'en-tête.** Envisagé ; l'utilisateur a choisi la barre d'état, ce qui garde l'en-tête pour qui répond et le remplissage du contexte.
26+- **Une ligne de conversation par signal.** La conversation est l'endroit de la réponse ; une boucle signalée à chaque répétition la remplirait de lignes qui répètent la sortie de l'outil. Le badge résume ; le mode ligne, qui n'a pas de barre d'état, imprime les lignes.
27+
28+## Liens avec le reste
29+
30+Les détections elles-mêmes ne changent pas : le détecteur de boucles dit toujours au modèle de changer d'approche et arrête toujours le tour après six appels identiques ; le watchdog, le garde-fou contre l'emballement et la note après un appel cassé se comportent comme avant. Ce que renvoie le tour et ce que garde l'historique ne changent pas non plus — une difficulté est montrée, jamais enregistrée. Voir aussi la [compression du contexte](context-compression.md), qu'un avertissement `context N% full` précède souvent, et les [modèles à raisonnement](thinking-models.md) pour savoir pourquoi le watchdog reste nourri pendant qu'un modèle réfléchit.
new file mode 100644
@@ -0,0 +1,30 @@
1+# Repérer qu'un modèle peine — explication
2+
3+## De quoi s'agit-il ?
4+
5+`mm` a été écrit pour de petits modèles locaux, et les petits modèles peinent de façons reconnaissables : ils répètent le même appel qui échoue, ils écrivent des arguments d'appel d'outil illisibles ou sans fin, ils se taisent, ils appellent des outils qui n'existent pas, ils épuisent leurs tours, ils ne répondent rien. Dès le départ, `mm` repérait la plupart de ces cas — le détecteur de boucles, le garde-fou contre l'emballement, le watchdog, la note rendue après un appel cassé — mais il le disait au **modèle** (une consigne dans la sortie d'un outil) ou terminait le tour par une ligne d'erreur au milieu du reste de la conversation. La personne qui regardait devait tout lire pour savoir que le modèle tournait en rond.
6+
7+Les signaux de difficulté (ticket 153) rendent ces mêmes détections visibles à l'**utilisateur**, à part de la réponse, dans une couleur qui dit la gravité. La liste des signaux et la façon dont chaque front end les affiche sont dans la [référence du CLI](../reference/cli.md#signaux-de-difficulté).
8+
9+## Pourquoi c'est conçu ainsi
10+
11+**Deux niveaux, pas un score.** Un avertissement (`⚠`, jaune) veut dire que `mm` a remarqué quelque chose et que le tour continue — le modèle a été prévenu qu'il boucle, un appel cassé lui a été rendu, le contexte est presque plein. Un signal grave (`✖`, rouge) veut dire que le tour a été arrêté ou a échoué à cause de lui. C'est la distinction sur laquelle on agit : attendre et regarder, ou reformuler et recommencer (ou changer de modèle).
12+
13+**Signalés là où ils sont détectés.** Chaque détection vivait déjà à un seul endroit — le `Record` du détecteur de boucles, l'enveloppe des appels au modèle pour les relances et les appels cassés, la fin de `Generate` pour la façon dont un tour s'est terminé. Chacune y signale un `ui.Struggle` ; rien ne relit la conversation après coup pour deviner. Le détecteur de boucles appelle une fonction (`WithReport`) plutôt que de connaître l'écran : il reste un package sans connaissance des front ends.
14+
15+**Un événement, trois rendus**, comme tout le reste de `mm` ([architecture](architecture.md)) : la fenêtre garde la difficulté la plus grave du tour en badge dans la barre d'état ; le mode ligne imprime une ligne colorée au moment où elle arrive ; un éditeur reçoit un court paragraphe en italique signé `mm` dans le message de l'agent, faute de barre d'état à nous.
16+
17+**Dans la fenêtre, la barre d'état, jusqu'à la question suivante.** Le badge se place à droite de la barre du bas, à la place de la version : c'est ce qui sert le moins quand quelque chose ne va pas, et les raccourcis à gauche — `Ctrl-C quit` avant tout, la sortie d'un tour qui ne s'arrête pas — gardent leur place. Il survit au tour exprès : quand un tour finit mal, le badge dit pourquoi, et il disparaît quand la question suivante ouvre un nouveau tour. Un signal grave l'emporte sur un avertissement ; entre deux de même niveau le plus récent gagne, si bien que `loop: bash ×4` remplace `×3` ; `(+N)` compte les autres.
18+
19+**Des échecs d'affilée, pas seulement des répétitions** (ticket 160). Le détecteur de boucles veut le même appel avec le même résultat ; un modèle qui essaie `cat config.yml`, puis `cat conf/config.yml`, puis `cat config.yaml` et échoue à chaque fois peine tout autant. L'échec d'un appel n'est connu qu'à l'intérieur de chaque outil : chaque outil transmet donc maintenant son résultat au détecteur avec l'appel (`record`, `recordFailure`, `recordOutcome`) ; le détecteur, qui voit déjà chaque appel, compte les échecs d'affilée et signale `failing: N tool calls in a row` à partir du troisième. C'est seulement un avertissement : le modèle lit déjà chaque échec dans la sortie de l'outil, et un arrêt brutal couperait des tours qu'un quatrième essai aurait sauvés.
20+
21+**Un tour annulé n'est pas une difficulté.** Échap, Ctrl-C ou `/abort` terminent un tour à la demande de l'utilisateur ; signaler alors `stalled` ou `max turns` reprocherait au modèle le choix de l'utilisateur.
22+
23+## Alternatives écartées
24+
25+- **L'en-tête.** Envisagé ; l'utilisateur a choisi la barre d'état, ce qui garde l'en-tête pour qui répond et le remplissage du contexte.
26+- **Une ligne de conversation par signal.** La conversation est l'endroit de la réponse ; une boucle signalée à chaque répétition la remplirait de lignes qui répètent la sortie de l'outil. Le badge résume ; le mode ligne, qui n'a pas de barre d'état, imprime les lignes.
27+
28+## Liens avec le reste
29+
30+Les détections elles-mêmes ne changent pas : le détecteur de boucles dit toujours au modèle de changer d'approche et arrête toujours le tour après six appels identiques ; le watchdog, le garde-fou contre l'emballement et la note après un appel cassé se comportent comme avant. Ce que renvoie le tour et ce que garde l'historique ne changent pas non plus — une difficulté est montrée, jamais enregistrée. Voir aussi la [compression du contexte](context-compression.md), qu'un avertissement `context N% full` précède souvent, et les [modèles à raisonnement](thinking-models.md) pour savoir pourquoi le watchdog reste nourri pendant qu'un modèle réfléchit.
modified docs/fr/how-to/use-the-window-mode.md +2 -0
@@ -33,6 +33,8 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran
3333 - **Faire défiler la conversation.** PgUp/PgDn paginent ; **Tab** donne le focus à la conversation pour que ↑/↓, Début et Fin la fassent défiler ; Fin reprend le suivi de la fin. **Ctrl-L** efface la conversation affichée sans toucher à l'historique. La molette ne fait défiler qu'après **F2** (voir plus bas).
3434 - **Pas de terminal.** `mm -tui | tee log.txt`, ou tout lancement où stdin ou stdout n'est pas un terminal, affiche `[tui: stdin or stdout is not a terminal — running the line mode]` et lance le mode ligne. Rien d'autre ne change.
3535
36+- **La barre d'état passe au jaune ou au rouge.** Le modèle peine : un badge `⚠` jaune (une boucle repérée, un appel d'outil cassé rendu au modèle, le contexte presque plein…) veut dire que le tour continue — surveillez-le ; un badge `✖` rouge (bloqué dans une boucle, figé, tours épuisés, outil inconnu…) veut dire que le tour a été arrêté — reformulez, découpez la demande, ou essayez un autre modèle. Le badge reste jusqu'à votre prochaine question. Voir [Signaux de difficulté](../reference/cli.md#signaux-de-difficulté).
37+
3638 ## Copier du texte depuis la fenêtre
3739
3840 La fenêtre laisse la souris au terminal : cliquer-glisser sélectionne du texte exactement comme dans n'importe quel terminal, et le raccourci de copie de votre terminal le copie. La barre d'état indique `F2 mouse: off`. Appuyez sur **F2** pour donner la souris à la fenêtre : la molette fait alors défiler la conversation et la barre d'état indique `mouse: on (Shift+drag selects)` ; dans la plupart des terminaux, glisser en maintenant Maj sélectionne encore. F2 à nouveau rend la souris.
@@ -33,6 +33,8 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran
33 - **Faire défiler la conversation.** PgUp/PgDn paginent ; **Tab** donne le focus à la conversation pour que ↑/↓, Début et Fin la fassent défiler ; Fin reprend le suivi de la fin. **Ctrl-L** efface la conversation affichée sans toucher à l'historique. La molette ne fait défiler qu'après **F2** (voir plus bas).33 - **Faire défiler la conversation.** PgUp/PgDn paginent ; **Tab** donne le focus à la conversation pour que ↑/↓, Début et Fin la fassent défiler ; Fin reprend le suivi de la fin. **Ctrl-L** efface la conversation affichée sans toucher à l'historique. La molette ne fait défiler qu'après **F2** (voir plus bas).
34 - **Pas de terminal.** `mm -tui | tee log.txt`, ou tout lancement où stdin ou stdout n'est pas un terminal, affiche `[tui: stdin or stdout is not a terminal — running the line mode]` et lance le mode ligne. Rien d'autre ne change.34 - **Pas de terminal.** `mm -tui | tee log.txt`, ou tout lancement où stdin ou stdout n'est pas un terminal, affiche `[tui: stdin or stdout is not a terminal — running the line mode]` et lance le mode ligne. Rien d'autre ne change.
35 35
36+- **La barre d'état passe au jaune ou au rouge.** Le modèle peine : un badge `⚠` jaune (une boucle repérée, un appel d'outil cassé rendu au modèle, le contexte presque plein…) veut dire que le tour continue — surveillez-le ; un badge `✖` rouge (bloqué dans une boucle, figé, tours épuisés, outil inconnu…) veut dire que le tour a été arrêté — reformulez, découpez la demande, ou essayez un autre modèle. Le badge reste jusqu'à votre prochaine question. Voir [Signaux de difficulté](../reference/cli.md#signaux-de-difficulté).
37+
36 ## Copier du texte depuis la fenêtre38 ## Copier du texte depuis la fenêtre
37 39
38 La fenêtre laisse la souris au terminal : cliquer-glisser sélectionne du texte exactement comme dans n'importe quel terminal, et le raccourci de copie de votre terminal le copie. La barre d'état indique `F2 mouse: off`. Appuyez sur **F2** pour donner la souris à la fenêtre : la molette fait alors défiler la conversation et la barre d'état indique `mouse: on (Shift+drag selects)` ; dans la plupart des terminaux, glisser en maintenant Maj sélectionne encore. F2 à nouveau rend la souris.40 La fenêtre laisse la souris au terminal : cliquer-glisser sélectionne du texte exactement comme dans n'importe quel terminal, et le raccourci de copie de votre terminal le copie. La barre d'état indique `F2 mouse: off`. Appuyez sur **F2** pour donner la souris à la fenêtre : la molette fait alors défiler la conversation et la barre d'état indique `mouse: on (Shift+drag selects)` ; dans la plupart des terminaux, glisser en maintenant Maj sélectionne encore. F2 à nouveau rend la souris.
modified docs/fr/reference/cli.md +30 -0
@@ -97,6 +97,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
9797 | stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. |
9898 | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |
9999 | Spinner | Désactivé. |
100+| Difficultés | Un paragraphe `*⚠ mm: …*` dans le message de l'agent — voir [Signaux de difficulté](#signaux-de-difficulté). |
100101 | `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |
101102 | Permissions | `yolo: true` : aucune `session/request_permission` n'est envoyée pour les outils intégrés. `yolo: false` : une par appel, avec quatre options — `allow` (autoriser une fois), `allow_always` (cet outil pour la session), `reject` (refuser une fois), `never` (`reject_always` : cet outil, pour la session). Les outils MCP marqués `ask` en envoient toujours une. |
102103
@@ -104,6 +105,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
104105
105106 | Aspect | Comportement |
106107 |--------|--------------|
108+| Difficultés | Une ligne `⚠` jaune ou `✖` rouge au moment où elle arrive — voir [Signaux de difficulté](#signaux-de-difficulté). |
107109 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), après la ligne `🛠️` de l'outil : `🔐 <titre de l'outil>`, la légende `[a] allow once · [s] allow <outil> for this session · [r] reject · [n] never <outil>` et ` ? `. La ligne tapée ensuite répond (`a`, `s`, `r`, `n` ou les mots `once`, `session`, `reject`, `never`) ; une autre réponse réaffiche la légende ; une ligne commençant par `/` est une commande (`/abort`, `/quit`), pas une réponse. `Ctrl+C` ou `/abort` refuse et arrête le tour. La même invite répond aux appels d'un agent interrogé avec `/ask`. |
108110
109111 ## Spécificités du mode fenêtre
@@ -123,6 +125,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
123125 | Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
124126 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject [n] never <outil>` jusqu'à la réponse (`Échap` = `r`) ; un tour annulé pendant la question refuse. |
125127 | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |
128+| Difficultés | Un badge à droite de la barre d'état jusqu'à la question suivante — voir [Signaux de difficulté](#signaux-de-difficulté). |
126129 | Souris | Non capturée au démarrage, donc le cliquer-glisser du terminal sélectionne du texte. `F2` la capture (défilement à la molette ; `Maj`+glisser sélectionne) puis la rend. |
127130
128131 ## Spécificités du mode invite
@@ -154,6 +157,33 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
154157 | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. |
155158 | Session | Aucune : ni ce processus ni l'agent n'écrivent de fichier de session. |
156159
160+## Signaux de difficulté
161+
162+Les signes que le modèle peine, montrés à l'utilisateur à part de la réponse (ticket 153). `⚠` est un avertissement : le tour continue. `✖` est grave : le tour a été arrêté ou a échoué à cause de lui.
163+
164+| Signal | Niveau | Quand |
165+|--------|--------|-------|
166+| `loop: <outil> ×N` | ⚠ | le détecteur de boucles a repéré le même appel avec le même résultat N fois de suite ; le modèle est invité à changer d'approche |
167+| `stuck: <outil> ×N` | ✖ | le modèle a ignoré ce conseil : le détecteur de boucles a arrêté le tour |
168+| `failing: N tool calls in a row` | ⚠ | les N derniers appels d'outil (N ≥ 3) ont tous échoué, avec des entrées différentes — code de sortie non nul, modification refusée, fichier absent, permission rejetée… ; signalé de nouveau à chaque nouvel échec, remis à zéro au premier succès ; le modèle n'en est pas informé |
169+| `broken tool call (cut off\|invalid JSON) n/m` | ⚠ | les arguments d'un appel d'outil ne se lisent pas ; l'appel est rendu au modèle (essai n sur m) |
170+| `stream cut — retried` | ⚠ | le serveur a coupé le flux ; cet appel au modèle est relancé une fois |
171+| `runaway tool call` | ✖ | les arguments d'un appel d'outil répétaient un motif ou dépassaient 256 Ko ; génération arrêtée |
172+| `stalled` | ✖ | le watchdog a coupé une génération silencieuse |
173+| `max turns reached` | ✖ | `maxTurns` allers-retours modèle ↔ outils sans réponse finale |
174+| `unknown tool: <nom>` | ✖ | le modèle a appelé un outil qui n'existe pas |
175+| `empty answer` | ⚠ | le tour s'est terminé sans texte ni appel d'outil |
176+| `context N% full` | ⚠ | le dernier appel au modèle a lu 80 % de la fenêtre de contexte ou plus (fenêtre connue uniquement) |
177+
178+Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` déclenche chaque signal avec un faux modèle.
179+
180+| Front end | Affiché comme |
181+|-----------|---------------|
182+| Fenêtre | Un badge à droite de la barre d'état, devant les compteurs (la version s'efface) : noir sur jaune pour `⚠`, blanc sur rouge pour `✖`. Le plus grave du tour, avec `(+N)` pour les autres ; un signal grave l'emporte sur un avertissement, le plus récent d'un même niveau gagne. Il reste après le tour et disparaît quand la question suivante est envoyée. |
183+| Mode ligne, `-p` | Une ligne `⚠ <signal>` / `✖ <signal>` sur stdout au moment où il arrive : jaune ou rouge dans un terminal, sans couleur sinon. |
184+| ACP | Un paragraphe `*⚠ mm: <signal>*` dans le message de l'agent au moment où il arrive ; pas gardé dans l'historique. |
185+| `-p` avec `structuredOutput` | La ligne sans couleur, sur stderr. |
186+
157187 ## `mm init` — l'assistant agent.yaml
158188
159189 `mm init` et `mm --init` (aussi `-init`) écrivent un nouvel agent.yaml commenté. Ils sont traités avant toute lecture de configuration. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Créer un agent avec `mm init`](../how-to/create-an-agent.md).
@@ -97,6 +97,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
97 | stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. |97 | stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. |
98 | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |98 | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. |
99 | Spinner | Désactivé. |99 | Spinner | Désactivé. |
100+| Difficultés | Un paragraphe `*⚠ mm: …*` dans le message de l'agent — voir [Signaux de difficulté](#signaux-de-difficulté). |
100 | `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |101 | `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |
101 | Permissions | `yolo: true` : aucune `session/request_permission` n'est envoyée pour les outils intégrés. `yolo: false` : une par appel, avec quatre options — `allow` (autoriser une fois), `allow_always` (cet outil pour la session), `reject` (refuser une fois), `never` (`reject_always` : cet outil, pour la session). Les outils MCP marqués `ask` en envoient toujours une. |102 | Permissions | `yolo: true` : aucune `session/request_permission` n'est envoyée pour les outils intégrés. `yolo: false` : une par appel, avec quatre options — `allow` (autoriser une fois), `allow_always` (cet outil pour la session), `reject` (refuser une fois), `never` (`reject_always` : cet outil, pour la session). Les outils MCP marqués `ask` en envoient toujours une. |
102 103
@@ -104,6 +105,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
104 105
105 | Aspect | Comportement |106 | Aspect | Comportement |
106 |--------|--------------|107 |--------|--------------|
108+| Difficultés | Une ligne `⚠` jaune ou `✖` rouge au moment où elle arrive — voir [Signaux de difficulté](#signaux-de-difficulté). |
107 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), après la ligne `🛠️` de l'outil : `🔐 <titre de l'outil>`, la légende `[a] allow once · [s] allow <outil> for this session · [r] reject · [n] never <outil>` et ` ? `. La ligne tapée ensuite répond (`a`, `s`, `r`, `n` ou les mots `once`, `session`, `reject`, `never`) ; une autre réponse réaffiche la légende ; une ligne commençant par `/` est une commande (`/abort`, `/quit`), pas une réponse. `Ctrl+C` ou `/abort` refuse et arrête le tour. La même invite répond aux appels d'un agent interrogé avec `/ask`. |109 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), après la ligne `🛠️` de l'outil : `🔐 <titre de l'outil>`, la légende `[a] allow once · [s] allow <outil> for this session · [r] reject · [n] never <outil>` et ` ? `. La ligne tapée ensuite répond (`a`, `s`, `r`, `n` ou les mots `once`, `session`, `reject`, `never`) ; une autre réponse réaffiche la légende ; une ligne commençant par `/` est une commande (`/abort`, `/quit`), pas une réponse. `Ctrl+C` ou `/abort` refuse et arrête le tour. La même invite répond aux appels d'un agent interrogé avec `/ask`. |
108 110
109 ## Spécificités du mode fenêtre111 ## Spécificités du mode fenêtre
@@ -123,6 +125,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
123 | Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |125 | Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
124 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject [n] never <outil>` jusqu'à la réponse (`Échap` = `r`) ; un tour annulé pendant la question refuse. |126 | Permissions | Avec `yolo: false` (et pour les outils MCP marqués `ask`), la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject [n] never <outil>` jusqu'à la réponse (`Échap` = `r`) ; un tour annulé pendant la question refuse. |
125 | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |127 | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |
128+| Difficultés | Un badge à droite de la barre d'état jusqu'à la question suivante — voir [Signaux de difficulté](#signaux-de-difficulté). |
126 | Souris | Non capturée au démarrage, donc le cliquer-glisser du terminal sélectionne du texte. `F2` la capture (défilement à la molette ; `Maj`+glisser sélectionne) puis la rend. |129 | Souris | Non capturée au démarrage, donc le cliquer-glisser du terminal sélectionne du texte. `F2` la capture (défilement à la molette ; `Maj`+glisser sélectionne) puis la rend. |
127 130
128 ## Spécificités du mode invite131 ## Spécificités du mode invite
@@ -154,6 +157,33 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
154 | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. |157 | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. |
155 | Session | Aucune : ni ce processus ni l'agent n'écrivent de fichier de session. |158 | Session | Aucune : ni ce processus ni l'agent n'écrivent de fichier de session. |
156 159
160+## Signaux de difficulté
161+
162+Les signes que le modèle peine, montrés à l'utilisateur à part de la réponse (ticket 153). `⚠` est un avertissement : le tour continue. `✖` est grave : le tour a été arrêté ou a échoué à cause de lui.
163+
164+| Signal | Niveau | Quand |
165+|--------|--------|-------|
166+| `loop: <outil> ×N` | ⚠ | le détecteur de boucles a repéré le même appel avec le même résultat N fois de suite ; le modèle est invité à changer d'approche |
167+| `stuck: <outil> ×N` | ✖ | le modèle a ignoré ce conseil : le détecteur de boucles a arrêté le tour |
168+| `failing: N tool calls in a row` | ⚠ | les N derniers appels d'outil (N ≥ 3) ont tous échoué, avec des entrées différentes — code de sortie non nul, modification refusée, fichier absent, permission rejetée… ; signalé de nouveau à chaque nouvel échec, remis à zéro au premier succès ; le modèle n'en est pas informé |
169+| `broken tool call (cut off\|invalid JSON) n/m` | ⚠ | les arguments d'un appel d'outil ne se lisent pas ; l'appel est rendu au modèle (essai n sur m) |
170+| `stream cut — retried` | ⚠ | le serveur a coupé le flux ; cet appel au modèle est relancé une fois |
171+| `runaway tool call` | ✖ | les arguments d'un appel d'outil répétaient un motif ou dépassaient 256 Ko ; génération arrêtée |
172+| `stalled` | ✖ | le watchdog a coupé une génération silencieuse |
173+| `max turns reached` | ✖ | `maxTurns` allers-retours modèle ↔ outils sans réponse finale |
174+| `unknown tool: <nom>` | ✖ | le modèle a appelé un outil qui n'existe pas |
175+| `empty answer` | ⚠ | le tour s'est terminé sans texte ni appel d'outil |
176+| `context N% full` | ⚠ | le dernier appel au modèle a lu 80 % de la fenêtre de contexte ou plus (fenêtre connue uniquement) |
177+
178+Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` déclenche chaque signal avec un faux modèle.
179+
180+| Front end | Affiché comme |
181+|-----------|---------------|
182+| Fenêtre | Un badge à droite de la barre d'état, devant les compteurs (la version s'efface) : noir sur jaune pour `⚠`, blanc sur rouge pour `✖`. Le plus grave du tour, avec `(+N)` pour les autres ; un signal grave l'emporte sur un avertissement, le plus récent d'un même niveau gagne. Il reste après le tour et disparaît quand la question suivante est envoyée. |
183+| Mode ligne, `-p` | Une ligne `⚠ <signal>` / `✖ <signal>` sur stdout au moment où il arrive : jaune ou rouge dans un terminal, sans couleur sinon. |
184+| ACP | Un paragraphe `*⚠ mm: <signal>*` dans le message de l'agent au moment où il arrive ; pas gardé dans l'historique. |
185+| `-p` avec `structuredOutput` | La ligne sans couleur, sur stderr. |
186+
157 ## `mm init` — l'assistant agent.yaml187 ## `mm init` — l'assistant agent.yaml
158 188
159 `mm init` et `mm --init` (aussi `-init`) écrivent un nouvel agent.yaml commenté. Ils sont traités avant toute lecture de configuration. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Créer un agent avec `mm init`](../how-to/create-an-agent.md).189 `mm init` et `mm --init` (aussi `-init`) écrivent un nouvel agent.yaml commenté. Ils sont traités avant toute lecture de configuration. Chaque question ci-dessous a une option ; une question dont l'option est donnée n'est pas posée. Voir [Créer un agent avec `mm init`](../how-to/create-an-agent.md).
modified internal/acp/display_test.go +15 -1
@@ -1,6 +1,10 @@
11 package acp
22
3-import "testing"
3+import (
4+ "testing"
5+
6+ "mm/internal/ui"
7+)
48
59 // A hook's display reaches the editor as a fenced block before the answer:
610 // tagged json when it parses, plain otherwise.
@@ -15,3 +19,13 @@ func TestDisplayBlockFencesTheText(t *testing.T) {
1519 }
1620 }
1721 }
22+
23+// A struggle reaches the editor as its own italic paragraph, signed by mm.
24+func TestStruggleLineIsAParagraphSignedByMM(t *testing.T) {
25+ if got := struggleLine(ui.Struggle{Label: "loop: bash ×3"}); got != "\n\n*⚠ mm: loop: bash ×3*\n\n" {
26+ t.Errorf("warning = %q", got)
27+ }
28+ if got := struggleLine(ui.Struggle{Severe: true, Label: "stalled"}); got != "\n\n*✖ mm: stalled*\n\n" {
29+ t.Errorf("severe = %q", got)
30+ }
31+}
@@ -1,6 +1,10 @@
1 package acp1 package acp
2 2
3-import "testing"3+import (
4+ "testing"
5+
6+ "mm/internal/ui"
7+)
4 8
5 // A hook's display reaches the editor as a fenced block before the answer:9 // A hook's display reaches the editor as a fenced block before the answer:
6 // tagged json when it parses, plain otherwise.10 // tagged json when it parses, plain otherwise.
@@ -15,3 +19,13 @@ func TestDisplayBlockFencesTheText(t *testing.T) {
15 }19 }
16 }20 }
17 }21 }
22+
23+// A struggle reaches the editor as its own italic paragraph, signed by mm.
24+func TestStruggleLineIsAParagraphSignedByMM(t *testing.T) {
25+ if got := struggleLine(ui.Struggle{Label: "loop: bash ×3"}); got != "\n\n*⚠ mm: loop: bash ×3*\n\n" {
26+ t.Errorf("warning = %q", got)
27+ }
28+ if got := struggleLine(ui.Struggle{Severe: true, Label: "stalled"}); got != "\n\n*✖ mm: stalled*\n\n" {
29+ t.Errorf("severe = %q", got)
30+ }
31+}
modified internal/acp/sink.go +15 -0
@@ -39,6 +39,21 @@ func (k *sink) Text(text string) {
3939 k.send(sdk.UpdateAgentMessageText(text))
4040 }
4141
42+// Struggle shows a sign that the model is struggling (ticket 153). An
43+// editor has no status bar of ours, so it is a short line of the agent's
44+// message, at the moment it happens — never in the history.
45+func (k *sink) Struggle(s ui.Struggle) {
46+ k.send(sdk.UpdateAgentMessageText(struggleLine(s)))
47+}
48+
49+// struggleLine sets a struggle apart from the answer around it: its own
50+// paragraph, in italics, signed by mm rather than the model.
51+//
52+// struggleLine(ui.Struggle{Label: "loop: bash ×3"}) // "\n\n*⚠ mm: loop: bash ×3*\n\n"
53+func struggleLine(s ui.Struggle) string {
54+ return "\n\n*" + s.Mark() + " mm: " + s.Label + "*\n\n"
55+}
56+
4257 // Display shows what a hook asked to show the user (a decider's answer)
4358 // before the answer streams. An editor has no colour of ours to give it, so
4459 // it travels as a fenced block of the agent's message — never in the
@@ -39,6 +39,21 @@ func (k *sink) Text(text string) {
39 k.send(sdk.UpdateAgentMessageText(text))39 k.send(sdk.UpdateAgentMessageText(text))
40 }40 }
41 41
42+// Struggle shows a sign that the model is struggling (ticket 153). An
43+// editor has no status bar of ours, so it is a short line of the agent's
44+// message, at the moment it happens — never in the history.
45+func (k *sink) Struggle(s ui.Struggle) {
46+ k.send(sdk.UpdateAgentMessageText(struggleLine(s)))
47+}
48+
49+// struggleLine sets a struggle apart from the answer around it: its own
50+// paragraph, in italics, signed by mm rather than the model.
51+//
52+// struggleLine(ui.Struggle{Label: "loop: bash ×3"}) // "\n\n*⚠ mm: loop: bash ×3*\n\n"
53+func struggleLine(s ui.Struggle) string {
54+ return "\n\n*" + s.Mark() + " mm: " + s.Label + "*\n\n"
55+}
56+
42 // Display shows what a hook asked to show the user (a decider's answer)57 // Display shows what a hook asked to show the user (a decider's answer)
43 // before the answer streams. An editor has no colour of ours to give it, so58 // before the answer streams. An editor has no colour of ours to give it, so
44 // it travels as a fenced block of the agent's message — never in the59 // it travels as a fenced block of the agent's message — never in the
modified internal/detector/detector.go +73 -2
@@ -27,6 +27,10 @@ type Action struct {
2727 ToolName string
2828 Input string
2929 Output string
30+ // Failed says the call failed — a non-zero exit, a refused edit, a
31+ // rejected permission… — whatever its input: the detector counts
32+ // consecutive failures apart from identical repeats (WithFailures).
33+ Failed bool
3034 }
3135
3236 // LoopDetector tracks the history of actions to detect repetitive cycles.
@@ -39,6 +43,24 @@ type LoopDetector struct {
3943 stopAfter int
4044 // flagged counts the consecutive Record calls that returned true.
4145 flagged int
46+ // report, when set, is told about every flagged repeat (WithReport).
47+ report func(Loop)
48+ // failAfter is how many failed calls in a row are reported as Failing;
49+ // 0 never reports them (WithFailures).
50+ failAfter int
51+ // failures counts the failed calls in a row, whatever their input.
52+ failures int
53+}
54+
55+// Loop is what WithReport hands over: a flagged repeat — the tool, how many
56+// identical calls in a row so far, and whether the detector now stops the
57+// turn — or, with Failing, a run of failed calls with different inputs:
58+// Repeats is then how many failed in a row, Tool the last one.
59+type Loop struct {
60+ Tool string
61+ Repeats int
62+ Stuck bool
63+ Failing bool
4264 }
4365
4466 // Stuck is the error a tool returns when the detector says the turn must
@@ -71,11 +93,55 @@ func (d *LoopDetector) WithStop(n int) *LoopDetector {
7193 return d
7294 }
7395
96+// WithReport makes the detector tell report about every flagged repeat —
97+// how mm shows the user, not only the model, that it is going in circles
98+// (ticket 153). report runs outside the detector's lock.
99+//
100+// d := detector.NewLoopDetector(10, 3).WithStop(3).WithReport(func(l detector.Loop) {
101+// fmt.Printf("loop: %s ×%d (stuck: %v)\n", l.Tool, l.Repeats, l.Stuck)
102+// })
103+func (d *LoopDetector) WithReport(report func(Loop)) *LoopDetector {
104+ d.historyMu.Lock()
105+ defer d.historyMu.Unlock()
106+ d.report = report
107+ return d
108+}
109+
110+// WithFailures makes the detector report, through WithReport, every call
111+// that is the n-th failed call in a row or later — a model trying one thing
112+// after another and failing each time, which the identical-repeat check does
113+// not see (ticket 160). It only reports: the model is not told, the turn is
114+// never stopped. 0 (the default) reports nothing.
115+//
116+// d := detector.NewLoopDetector(10, 3).WithFailures(3).WithReport(show)
117+func (d *LoopDetector) WithFailures(n int) *LoopDetector {
118+ d.historyMu.Lock()
119+ defer d.historyMu.Unlock()
120+ d.failAfter = n
121+ return d
122+}
123+
74124 // Record adds an action to the history and returns true if a loop is detected.
75125 func (d *LoopDetector) Record(action Action) bool {
126+ flagged, loop, report := d.record(action)
127+ if loop != nil && report != nil {
128+ report(*loop)
129+ }
130+ return flagged
131+}
132+
133+// record is Record under the lock; it also returns what to report, if
134+// anything: a flagged repeat wins over a run of failures — it says more.
135+func (d *LoopDetector) record(action Action) (bool, *Loop, func(Loop)) {
76136 d.historyMu.Lock()
77137 defer d.historyMu.Unlock()
78138
139+ if action.Failed {
140+ d.failures++
141+ } else {
142+ d.failures = 0
143+ }
144+
79145 // Check if the current action (tool + input + output) has occurred consecutively.
80146 count := 0
81147 for i := len(d.history) - 1; i >= 0; i-- {
@@ -85,7 +151,8 @@ func (d *LoopDetector) Record(action Action) bool {
85151 count++
86152 if count >= d.threshold {
87153 d.flagged++
88- return true
154+ loop := Loop{Tool: action.ToolName, Repeats: d.threshold + d.flagged, Stuck: d.stopAfter > 0 && d.flagged >= d.stopAfter}
155+ return true, &loop, d.report
89156 }
90157 } else {
91158 break
@@ -99,7 +166,10 @@ func (d *LoopDetector) Record(action Action) bool {
99166 d.history = d.history[1:]
100167 }
101168
102- return false
169+ if d.failAfter > 0 && d.failures >= d.failAfter {
170+ return false, &Loop{Tool: action.ToolName, Repeats: d.failures, Failing: true}, d.report
171+ }
172+ return false, nil, nil
103173 }
104174
105175 // Stuck reports whether the flagged repeats have reached the stop count:
@@ -130,6 +200,7 @@ func (d *LoopDetector) Reset() {
130200 defer d.historyMu.Unlock()
131201 d.history = d.history[:0]
132202 d.flagged = 0
203+ d.failures = 0
133204 }
134205
135206 // LoopError returns a standardized error message when a loop is detected.
@@ -27,6 +27,10 @@ type Action struct {
27 ToolName string27 ToolName string
28 Input string28 Input string
29 Output string29 Output string
30+ // Failed says the call failed — a non-zero exit, a refused edit, a
31+ // rejected permission… — whatever its input: the detector counts
32+ // consecutive failures apart from identical repeats (WithFailures).
33+ Failed bool
30 }34 }
31 35
32 // LoopDetector tracks the history of actions to detect repetitive cycles.36 // LoopDetector tracks the history of actions to detect repetitive cycles.
@@ -39,6 +43,24 @@ type LoopDetector struct {
39 stopAfter int43 stopAfter int
40 // flagged counts the consecutive Record calls that returned true.44 // flagged counts the consecutive Record calls that returned true.
41 flagged int45 flagged int
46+ // report, when set, is told about every flagged repeat (WithReport).
47+ report func(Loop)
48+ // failAfter is how many failed calls in a row are reported as Failing;
49+ // 0 never reports them (WithFailures).
50+ failAfter int
51+ // failures counts the failed calls in a row, whatever their input.
52+ failures int
53+}
54+
55+// Loop is what WithReport hands over: a flagged repeat — the tool, how many
56+// identical calls in a row so far, and whether the detector now stops the
57+// turn — or, with Failing, a run of failed calls with different inputs:
58+// Repeats is then how many failed in a row, Tool the last one.
59+type Loop struct {
60+ Tool string
61+ Repeats int
62+ Stuck bool
63+ Failing bool
42 }64 }
43 65
44 // Stuck is the error a tool returns when the detector says the turn must66 // Stuck is the error a tool returns when the detector says the turn must
@@ -71,11 +93,55 @@ func (d *LoopDetector) WithStop(n int) *LoopDetector {
71 return d93 return d
72 }94 }
73 95
96+// WithReport makes the detector tell report about every flagged repeat —
97+// how mm shows the user, not only the model, that it is going in circles
98+// (ticket 153). report runs outside the detector's lock.
99+//
100+// d := detector.NewLoopDetector(10, 3).WithStop(3).WithReport(func(l detector.Loop) {
101+// fmt.Printf("loop: %s ×%d (stuck: %v)\n", l.Tool, l.Repeats, l.Stuck)
102+// })
103+func (d *LoopDetector) WithReport(report func(Loop)) *LoopDetector {
104+ d.historyMu.Lock()
105+ defer d.historyMu.Unlock()
106+ d.report = report
107+ return d
108+}
109+
110+// WithFailures makes the detector report, through WithReport, every call
111+// that is the n-th failed call in a row or later — a model trying one thing
112+// after another and failing each time, which the identical-repeat check does
113+// not see (ticket 160). It only reports: the model is not told, the turn is
114+// never stopped. 0 (the default) reports nothing.
115+//
116+// d := detector.NewLoopDetector(10, 3).WithFailures(3).WithReport(show)
117+func (d *LoopDetector) WithFailures(n int) *LoopDetector {
118+ d.historyMu.Lock()
119+ defer d.historyMu.Unlock()
120+ d.failAfter = n
121+ return d
122+}
123+
74 // Record adds an action to the history and returns true if a loop is detected.124 // Record adds an action to the history and returns true if a loop is detected.
75 func (d *LoopDetector) Record(action Action) bool {125 func (d *LoopDetector) Record(action Action) bool {
126+ flagged, loop, report := d.record(action)
127+ if loop != nil && report != nil {
128+ report(*loop)
129+ }
130+ return flagged
131+}
132+
133+// record is Record under the lock; it also returns what to report, if
134+// anything: a flagged repeat wins over a run of failures — it says more.
135+func (d *LoopDetector) record(action Action) (bool, *Loop, func(Loop)) {
76 d.historyMu.Lock()136 d.historyMu.Lock()
77 defer d.historyMu.Unlock()137 defer d.historyMu.Unlock()
78 138
139+ if action.Failed {
140+ d.failures++
141+ } else {
142+ d.failures = 0
143+ }
144+
79 // Check if the current action (tool + input + output) has occurred consecutively.145 // Check if the current action (tool + input + output) has occurred consecutively.
80 count := 0146 count := 0
81 for i := len(d.history) - 1; i >= 0; i-- {147 for i := len(d.history) - 1; i >= 0; i-- {
@@ -85,7 +151,8 @@ func (d *LoopDetector) Record(action Action) bool {
85 count++151 count++
86 if count >= d.threshold {152 if count >= d.threshold {
87 d.flagged++153 d.flagged++
88- return true154+ loop := Loop{Tool: action.ToolName, Repeats: d.threshold + d.flagged, Stuck: d.stopAfter > 0 && d.flagged >= d.stopAfter}
155+ return true, &loop, d.report
89 }156 }
90 } else {157 } else {
91 break158 break
@@ -99,7 +166,10 @@ func (d *LoopDetector) Record(action Action) bool {
99 d.history = d.history[1:]166 d.history = d.history[1:]
100 }167 }
101 168
102- return false169+ if d.failAfter > 0 && d.failures >= d.failAfter {
170+ return false, &Loop{Tool: action.ToolName, Repeats: d.failures, Failing: true}, d.report
171+ }
172+ return false, nil, nil
103 }173 }
104 174
105 // Stuck reports whether the flagged repeats have reached the stop count:175 // Stuck reports whether the flagged repeats have reached the stop count:
@@ -130,6 +200,7 @@ func (d *LoopDetector) Reset() {
130 defer d.historyMu.Unlock()200 defer d.historyMu.Unlock()
131 d.history = d.history[:0]201 d.history = d.history[:0]
132 d.flagged = 0202 d.flagged = 0
203+ d.failures = 0
133 }204 }
134 205
135 // LoopError returns a standardized error message when a loop is detected.206 // LoopError returns a standardized error message when a loop is detected.
modified internal/detector/detector_test.go +68 -0
@@ -100,3 +100,71 @@ func TestStopAfterFlaggedRepeats(t *testing.T) {
100100 t.Error("without WithStop the detector never declares the turn stuck")
101101 }
102102 }
103+
104+// WithReport hears every flagged repeat — the count grows, and the last one
105+// before the stop says so — and nothing for calls that are not repeats.
106+func TestWithReportHearsEveryFlaggedRepeat(t *testing.T) {
107+ var heard []Loop
108+ d := NewLoopDetector(10, 3).WithStop(2).WithReport(func(l Loop) { heard = append(heard, l) })
109+ same := Action{ToolName: "bash", Input: "ls", Output: "x"}
110+ d.Record(Action{ToolName: "bash", Input: "pwd", Output: "/"})
111+ for i := 0; i < 5; i++ {
112+ d.Record(same)
113+ }
114+ want := []Loop{{Tool: "bash", Repeats: 4}, {Tool: "bash", Repeats: 5, Stuck: true}}
115+ if len(heard) != len(want) {
116+ t.Fatalf("heard %+v, want %+v", heard, want)
117+ }
118+ for i := range want {
119+ if heard[i] != want[i] {
120+ t.Errorf("heard[%d] = %+v, want %+v", i, heard[i], want[i])
121+ }
122+ }
123+}
124+
125+// Failed calls with different inputs are reported from the n-th in a row;
126+// a success resets the count; nothing is reported without WithFailures.
127+func TestWithFailuresReportsARunOfFailedCalls(t *testing.T) {
128+ var heard []Loop
129+ d := NewLoopDetector(10, 3).WithFailures(3).WithReport(func(l Loop) { heard = append(heard, l) })
130+ fail := func(cmd string) bool {
131+ return d.Record(Action{ToolName: "bash", Input: cmd, Output: "No such file", Failed: true})
132+ }
133+ fail("cat a")
134+ fail("cat b")
135+ if len(heard) != 0 {
136+ t.Fatalf("two failures reported: %+v", heard)
137+ }
138+ if fail("cat c") {
139+ t.Error("a run of different failures is not a loop for the model")
140+ }
141+ d.Record(Action{ToolName: "read_file", Input: "d", Output: "no", Failed: true})
142+ want := []Loop{{Tool: "bash", Repeats: 3, Failing: true}, {Tool: "read_file", Repeats: 4, Failing: true}}
143+ if len(heard) != 2 || heard[0] != want[0] || heard[1] != want[1] {
144+ t.Fatalf("heard %+v, want %+v", heard, want)
145+ }
146+ d.Record(Action{ToolName: "bash", Input: "ls", Output: "a"})
147+ fail("cat e")
148+ if len(heard) != 2 {
149+ t.Errorf("a success did not reset the count: %+v", heard)
150+ }
151+
152+ silent := NewLoopDetector(10, 3).WithReport(func(l Loop) { t.Errorf("reported %+v without WithFailures", l) })
153+ for _, cmd := range []string{"a", "b", "c", "d"} {
154+ silent.Record(Action{ToolName: "bash", Input: cmd, Failed: true})
155+ }
156+}
157+
158+// An identical failing repeat is reported as a loop, not as failures.
159+func TestALoopWinsOverARunOfFailures(t *testing.T) {
160+ var heard []Loop
161+ d := NewLoopDetector(10, 3).WithFailures(3).WithReport(func(l Loop) { heard = append(heard, l) })
162+ same := Action{ToolName: "bash", Input: "cat x", Output: "No such file", Failed: true}
163+ for i := 0; i < 4; i++ {
164+ d.Record(same)
165+ }
166+ // 3rd: failing ×3; 4th: loop ×4 (flagged), not failing ×4.
167+ if len(heard) != 2 || !heard[0].Failing || heard[1].Failing || heard[1].Repeats != 4 {
168+ t.Errorf("heard %+v", heard)
169+ }
170+}
@@ -100,3 +100,71 @@ func TestStopAfterFlaggedRepeats(t *testing.T) {
100 t.Error("without WithStop the detector never declares the turn stuck")100 t.Error("without WithStop the detector never declares the turn stuck")
101 }101 }
102 }102 }
103+
104+// WithReport hears every flagged repeat — the count grows, and the last one
105+// before the stop says so — and nothing for calls that are not repeats.
106+func TestWithReportHearsEveryFlaggedRepeat(t *testing.T) {
107+ var heard []Loop
108+ d := NewLoopDetector(10, 3).WithStop(2).WithReport(func(l Loop) { heard = append(heard, l) })
109+ same := Action{ToolName: "bash", Input: "ls", Output: "x"}
110+ d.Record(Action{ToolName: "bash", Input: "pwd", Output: "/"})
111+ for i := 0; i < 5; i++ {
112+ d.Record(same)
113+ }
114+ want := []Loop{{Tool: "bash", Repeats: 4}, {Tool: "bash", Repeats: 5, Stuck: true}}
115+ if len(heard) != len(want) {
116+ t.Fatalf("heard %+v, want %+v", heard, want)
117+ }
118+ for i := range want {
119+ if heard[i] != want[i] {
120+ t.Errorf("heard[%d] = %+v, want %+v", i, heard[i], want[i])
121+ }
122+ }
123+}
124+
125+// Failed calls with different inputs are reported from the n-th in a row;
126+// a success resets the count; nothing is reported without WithFailures.
127+func TestWithFailuresReportsARunOfFailedCalls(t *testing.T) {
128+ var heard []Loop
129+ d := NewLoopDetector(10, 3).WithFailures(3).WithReport(func(l Loop) { heard = append(heard, l) })
130+ fail := func(cmd string) bool {
131+ return d.Record(Action{ToolName: "bash", Input: cmd, Output: "No such file", Failed: true})
132+ }
133+ fail("cat a")
134+ fail("cat b")
135+ if len(heard) != 0 {
136+ t.Fatalf("two failures reported: %+v", heard)
137+ }
138+ if fail("cat c") {
139+ t.Error("a run of different failures is not a loop for the model")
140+ }
141+ d.Record(Action{ToolName: "read_file", Input: "d", Output: "no", Failed: true})
142+ want := []Loop{{Tool: "bash", Repeats: 3, Failing: true}, {Tool: "read_file", Repeats: 4, Failing: true}}
143+ if len(heard) != 2 || heard[0] != want[0] || heard[1] != want[1] {
144+ t.Fatalf("heard %+v, want %+v", heard, want)
145+ }
146+ d.Record(Action{ToolName: "bash", Input: "ls", Output: "a"})
147+ fail("cat e")
148+ if len(heard) != 2 {
149+ t.Errorf("a success did not reset the count: %+v", heard)
150+ }
151+
152+ silent := NewLoopDetector(10, 3).WithReport(func(l Loop) { t.Errorf("reported %+v without WithFailures", l) })
153+ for _, cmd := range []string{"a", "b", "c", "d"} {
154+ silent.Record(Action{ToolName: "bash", Input: cmd, Failed: true})
155+ }
156+}
157+
158+// An identical failing repeat is reported as a loop, not as failures.
159+func TestALoopWinsOverARunOfFailures(t *testing.T) {
160+ var heard []Loop
161+ d := NewLoopDetector(10, 3).WithFailures(3).WithReport(func(l Loop) { heard = append(heard, l) })
162+ same := Action{ToolName: "bash", Input: "cat x", Output: "No such file", Failed: true}
163+ for i := 0; i < 4; i++ {
164+ d.Record(same)
165+ }
166+ // 3rd: failing ×3; 4th: loop ×4 (flagged), not failing ×4.
167+ if len(heard) != 2 || !heard[0].Failing || heard[1].Failing || heard[1].Repeats != 4 {
168+ t.Errorf("heard %+v", heard)
169+ }
170+}
modified internal/engine/engine.go +15 -5
@@ -12,7 +12,6 @@ package engine
1212 import (
1313 "context"
1414 "encoding/json"
15- "errors"
1615 "fmt"
1716 "maps"
1817 "slices"
@@ -254,10 +253,19 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
254253 if e.decider != nil {
255254 return e.generateDecision(ctx, messages)
256255 }
256+ var (
257+ resp *ai.ModelResponse
258+ full []*ai.Message
259+ err error
260+ )
257261 if e.hooks.Enabled() {
258- return e.generateWithHooks(ctx, messages, tools)
262+ resp, full, err = e.generateWithHooks(ctx, messages, tools)
263+ } else {
264+ resp, full, err = e.generate(ctx, messages, tools)
259265 }
260- return e.generate(ctx, messages, tools)
266+ // How the turn went, for the user's eyes (struggle.go, ticket 153).
267+ e.reportTurn(ctx, resp, err)
268+ return resp, full, err
261269 }
262270
263271 // generate is Generate without the generation hooks (hooks.go): the turn
@@ -312,6 +320,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
312320 if streamBroken(err) && ctx.Err() == nil {
313321 spinner.Stop()
314322 _, _ = fmt.Fprintf(ui.Out, "\n[%s — retrying this model call once]\n", streamNotice(err))
323+ ui.ReportStruggle(ui.Struggle{Label: "stream cut — retried"})
315324 pl.expect()
316325 resp, err = next(ctx, params)
317326 err = withServerMessage(ctx, err)
@@ -337,6 +346,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
337346 spinner.Stop()
338347 _, _ = fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n",
339348 what, lastRunes(err.Error(), 60), argRetries, maxArgRetries)
349+ ui.ReportStruggle(ui.Struggle{Label: fmt.Sprintf("broken tool call (%s) %d/%d", what, argRetries, maxArgRetries)})
340350 params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated))
341351 full = slices.Clone(params.Request.Messages)
342352 pl.expect()
@@ -507,7 +517,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
507517 }),
508518 )...)
509519 if guard.reason != "" {
510- return nil, guard.keep(full, resp), errors.New(guard.reason)
520+ return nil, guard.keep(full, resp), runawayError(guard.reason)
511521 }
512522
513523 // `full` grows as soon as a tool turn has happened: it is the witness that
@@ -527,7 +537,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
527537 if err != nil && stalled {
528538 // We return `full` despite the failure: if commands ran before the
529539 // silence, their work must not be lost.
530- return nil, full, fmt.Errorf("generation timed out (stalled)")
540+ return nil, full, errStalled
531541 }
532542
533543 // The retry is only worth it for an SSE stream cut before the first byte.
@@ -12,7 +12,6 @@ package engine
12 import (12 import (
13 "context"13 "context"
14 "encoding/json"14 "encoding/json"
15- "errors"
16 "fmt"15 "fmt"
17 "maps"16 "maps"
18 "slices"17 "slices"
@@ -254,10 +253,19 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
254 if e.decider != nil {253 if e.decider != nil {
255 return e.generateDecision(ctx, messages)254 return e.generateDecision(ctx, messages)
256 }255 }
256+ var (
257+ resp *ai.ModelResponse
258+ full []*ai.Message
259+ err error
260+ )
257 if e.hooks.Enabled() {261 if e.hooks.Enabled() {
258- return e.generateWithHooks(ctx, messages, tools)262+ resp, full, err = e.generateWithHooks(ctx, messages, tools)
263+ } else {
264+ resp, full, err = e.generate(ctx, messages, tools)
259 }265 }
260- return e.generate(ctx, messages, tools)266+ // How the turn went, for the user's eyes (struggle.go, ticket 153).
267+ e.reportTurn(ctx, resp, err)
268+ return resp, full, err
261 }269 }
262 270
263 // generate is Generate without the generation hooks (hooks.go): the turn271 // generate is Generate without the generation hooks (hooks.go): the turn
@@ -312,6 +320,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
312 if streamBroken(err) && ctx.Err() == nil {320 if streamBroken(err) && ctx.Err() == nil {
313 spinner.Stop()321 spinner.Stop()
314 _, _ = fmt.Fprintf(ui.Out, "\n[%s — retrying this model call once]\n", streamNotice(err))322 _, _ = fmt.Fprintf(ui.Out, "\n[%s — retrying this model call once]\n", streamNotice(err))
323+ ui.ReportStruggle(ui.Struggle{Label: "stream cut — retried"})
315 pl.expect()324 pl.expect()
316 resp, err = next(ctx, params)325 resp, err = next(ctx, params)
317 err = withServerMessage(ctx, err)326 err = withServerMessage(ctx, err)
@@ -337,6 +346,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
337 spinner.Stop()346 spinner.Stop()
338 _, _ = fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n",347 _, _ = fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n",
339 what, lastRunes(err.Error(), 60), argRetries, maxArgRetries)348 what, lastRunes(err.Error(), 60), argRetries, maxArgRetries)
349+ ui.ReportStruggle(ui.Struggle{Label: fmt.Sprintf("broken tool call (%s) %d/%d", what, argRetries, maxArgRetries)})
340 params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated))350 params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated))
341 full = slices.Clone(params.Request.Messages)351 full = slices.Clone(params.Request.Messages)
342 pl.expect()352 pl.expect()
@@ -507,7 +517,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
507 }),517 }),
508 )...)518 )...)
509 if guard.reason != "" {519 if guard.reason != "" {
510- return nil, guard.keep(full, resp), errors.New(guard.reason)520+ return nil, guard.keep(full, resp), runawayError(guard.reason)
511 }521 }
512 522
513 // `full` grows as soon as a tool turn has happened: it is the witness that523 // `full` grows as soon as a tool turn has happened: it is the witness that
@@ -527,7 +537,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a
527 if err != nil && stalled {537 if err != nil && stalled {
528 // We return `full` despite the failure: if commands ran before the538 // We return `full` despite the failure: if commands ran before the
529 // silence, their work must not be lost.539 // silence, their work must not be lost.
530- return nil, full, fmt.Errorf("generation timed out (stalled)")540+ return nil, full, errStalled
531 }541 }
532 542
533 // The retry is only worth it for an SSE stream cut before the first byte.543 // The retry is only worth it for an SSE stream cut before the first byte.
modified internal/engine/hooks_test.go +6 -0
@@ -47,6 +47,7 @@ type recordingSink struct {
4747 results []ui.ToolResult
4848 notices []string
4949 shown []string
50+ strugs []ui.Struggle
5051 order []string // "display" and "text", in the order they arrived
5152 }
5253
@@ -57,6 +58,11 @@ func (s *recordingSink) Text(t string) {
5758 s.order = append(s.order, "text")
5859 s.mu.Unlock()
5960 }
61+func (s *recordingSink) Struggle(st ui.Struggle) {
62+ s.mu.Lock()
63+ s.strugs = append(s.strugs, st)
64+ s.mu.Unlock()
65+}
6066 func (s *recordingSink) Display(text string) {
6167 s.mu.Lock()
6268 s.shown = append(s.shown, text)
@@ -47,6 +47,7 @@ type recordingSink struct {
47 results []ui.ToolResult47 results []ui.ToolResult
48 notices []string48 notices []string
49 shown []string49 shown []string
50+ strugs []ui.Struggle
50 order []string // "display" and "text", in the order they arrived51 order []string // "display" and "text", in the order they arrived
51 }52 }
52 53
@@ -57,6 +58,11 @@ func (s *recordingSink) Text(t string) {
57 s.order = append(s.order, "text")58 s.order = append(s.order, "text")
58 s.mu.Unlock()59 s.mu.Unlock()
59 }60 }
61+func (s *recordingSink) Struggle(st ui.Struggle) {
62+ s.mu.Lock()
63+ s.strugs = append(s.strugs, st)
64+ s.mu.Unlock()
65+}
60 func (s *recordingSink) Display(text string) {66 func (s *recordingSink) Display(text string) {
61 s.mu.Lock()67 s.mu.Lock()
62 s.shown = append(s.shown, text)68 s.shown = append(s.shown, text)
added internal/engine/struggle.go +94 -0
new file mode 100644
@@ -0,0 +1,94 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "regexp"
8+ "strings"
9+
10+ "mm/internal/ui"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+// Ticket 153: mm was built to notice when a model struggles — the loop
16+// detector, the runaway guard, the watchdog, the broken-call note — but it
17+// told the MODEL, or ended the turn with an error line. This file turns how a
18+// turn ended into a ui.Struggle the user sees apart from the conversation
19+// (the window's status bar, a coloured line, a line in the editor). The
20+// struggles noticed DURING the turn are reported where they happen: the loop
21+// detector (main.reportLoop), a stream retried and a broken tool call
22+// (engine.go).
23+
24+// errStalled ends a turn the watchdog cut: the server went silent.
25+var errStalled = errors.New("generation timed out (stalled)")
26+
27+// runawayError ends a turn whose tool-call arguments ran away (runaway.go);
28+// its text is the guard's reason.
29+type runawayError string
30+
31+func (r runawayError) Error() string { return string(r) }
32+
33+// contextFullShare is the share of the context window, in percent, from
34+// which a turn reports that the context is nearly full: past it a small
35+// model starts losing the beginning of the conversation.
36+const contextFullShare = 80
37+
38+// unknownTool finds the tool name in Genkit's "tool %q not found".
39+var unknownTool = regexp.MustCompile(`tool "([^"]*)" not found`)
40+
41+// reportTurn reports how a finished turn went: what stopped it, an empty
42+// answer, a context nearly full. A turn the user cancelled reports nothing —
43+// stopping is not struggling.
44+func (e *Engine) reportTurn(ctx context.Context, resp *ai.ModelResponse, err error) {
45+ if ctx.Err() != nil {
46+ return
47+ }
48+ if s, ok := turnStruggle(resp, err); ok {
49+ ui.ReportStruggle(s)
50+ }
51+ if s, ok := contextStruggle(e.LastInputTokens(), e.ContextWindow); ok {
52+ ui.ReportStruggle(s)
53+ }
54+}
55+
56+// turnStruggle classifies how a turn ended. The loop detector's stop is not
57+// one of them: the detector reported it already (main.reportLoop).
58+//
59+// turnStruggle(nil, errStalled) // ✖ stalled, true
60+func turnStruggle(resp *ai.ModelResponse, err error) (ui.Struggle, bool) {
61+ if err == nil {
62+ if resp != nil && strings.TrimSpace(resp.Text()) == "" && len(resp.ToolRequests()) == 0 {
63+ return ui.Struggle{Label: "empty answer"}, true
64+ }
65+ return ui.Struggle{}, false
66+ }
67+ var runaway runawayError
68+ switch {
69+ case errors.Is(err, errStalled):
70+ return ui.Struggle{Severe: true, Label: "stalled"}, true
71+ case errors.As(err, &runaway):
72+ return ui.Struggle{Severe: true, Label: "runaway tool call"}, true
73+ case errors.Is(err, ai.ErrMaxTurnsExceeded) || strings.Contains(err.Error(), "exceeded maximum tool call iterations"):
74+ return ui.Struggle{Severe: true, Label: "max turns reached"}, true
75+ }
76+ if m := unknownTool.FindStringSubmatch(err.Error()); m != nil {
77+ return ui.Struggle{Severe: true, Label: "unknown tool: " + m[1]}, true
78+ }
79+ if errors.Is(err, ai.ErrToolNotFound) {
80+ return ui.Struggle{Severe: true, Label: "unknown tool"}, true
81+ }
82+ return ui.Struggle{}, false
83+}
84+
85+// contextStruggle warns when the last model call read contextFullShare
86+// percent of the window or more. An unknown window says nothing.
87+//
88+// contextStruggle(26000, 32000) // ⚠ context 81% full, true
89+func contextStruggle(used, window int) (ui.Struggle, bool) {
90+ if window <= 0 || used*100 < window*contextFullShare {
91+ return ui.Struggle{}, false
92+ }
93+ return ui.Struggle{Label: fmt.Sprintf("context %d%% full", used*100/window)}, true
94+}
new file mode 100644
@@ -0,0 +1,94 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "regexp"
8+ "strings"
9+
10+ "mm/internal/ui"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+// Ticket 153: mm was built to notice when a model struggles — the loop
16+// detector, the runaway guard, the watchdog, the broken-call note — but it
17+// told the MODEL, or ended the turn with an error line. This file turns how a
18+// turn ended into a ui.Struggle the user sees apart from the conversation
19+// (the window's status bar, a coloured line, a line in the editor). The
20+// struggles noticed DURING the turn are reported where they happen: the loop
21+// detector (main.reportLoop), a stream retried and a broken tool call
22+// (engine.go).
23+
24+// errStalled ends a turn the watchdog cut: the server went silent.
25+var errStalled = errors.New("generation timed out (stalled)")
26+
27+// runawayError ends a turn whose tool-call arguments ran away (runaway.go);
28+// its text is the guard's reason.
29+type runawayError string
30+
31+func (r runawayError) Error() string { return string(r) }
32+
33+// contextFullShare is the share of the context window, in percent, from
34+// which a turn reports that the context is nearly full: past it a small
35+// model starts losing the beginning of the conversation.
36+const contextFullShare = 80
37+
38+// unknownTool finds the tool name in Genkit's "tool %q not found".
39+var unknownTool = regexp.MustCompile(`tool "([^"]*)" not found`)
40+
41+// reportTurn reports how a finished turn went: what stopped it, an empty
42+// answer, a context nearly full. A turn the user cancelled reports nothing —
43+// stopping is not struggling.
44+func (e *Engine) reportTurn(ctx context.Context, resp *ai.ModelResponse, err error) {
45+ if ctx.Err() != nil {
46+ return
47+ }
48+ if s, ok := turnStruggle(resp, err); ok {
49+ ui.ReportStruggle(s)
50+ }
51+ if s, ok := contextStruggle(e.LastInputTokens(), e.ContextWindow); ok {
52+ ui.ReportStruggle(s)
53+ }
54+}
55+
56+// turnStruggle classifies how a turn ended. The loop detector's stop is not
57+// one of them: the detector reported it already (main.reportLoop).
58+//
59+// turnStruggle(nil, errStalled) // ✖ stalled, true
60+func turnStruggle(resp *ai.ModelResponse, err error) (ui.Struggle, bool) {
61+ if err == nil {
62+ if resp != nil && strings.TrimSpace(resp.Text()) == "" && len(resp.ToolRequests()) == 0 {
63+ return ui.Struggle{Label: "empty answer"}, true
64+ }
65+ return ui.Struggle{}, false
66+ }
67+ var runaway runawayError
68+ switch {
69+ case errors.Is(err, errStalled):
70+ return ui.Struggle{Severe: true, Label: "stalled"}, true
71+ case errors.As(err, &runaway):
72+ return ui.Struggle{Severe: true, Label: "runaway tool call"}, true
73+ case errors.Is(err, ai.ErrMaxTurnsExceeded) || strings.Contains(err.Error(), "exceeded maximum tool call iterations"):
74+ return ui.Struggle{Severe: true, Label: "max turns reached"}, true
75+ }
76+ if m := unknownTool.FindStringSubmatch(err.Error()); m != nil {
77+ return ui.Struggle{Severe: true, Label: "unknown tool: " + m[1]}, true
78+ }
79+ if errors.Is(err, ai.ErrToolNotFound) {
80+ return ui.Struggle{Severe: true, Label: "unknown tool"}, true
81+ }
82+ return ui.Struggle{}, false
83+}
84+
85+// contextStruggle warns when the last model call read contextFullShare
86+// percent of the window or more. An unknown window says nothing.
87+//
88+// contextStruggle(26000, 32000) // ⚠ context 81% full, true
89+func contextStruggle(used, window int) (ui.Struggle, bool) {
90+ if window <= 0 || used*100 < window*contextFullShare {
91+ return ui.Struggle{}, false
92+ }
93+ return ui.Struggle{Label: fmt.Sprintf("context %d%% full", used*100/window)}, true
94+}
added internal/engine/struggle_test.go +75 -0
new file mode 100644
@@ -0,0 +1,75 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "testing"
8+
9+ "mm/internal/config"
10+ "mm/internal/detector"
11+ "mm/internal/ui"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+func TestTurnStruggleClassifiesHowATurnEnded(t *testing.T) {
17+ text := &ai.ModelResponse{Message: ai.NewModelTextMessage("done")}
18+ empty := &ai.ModelResponse{Message: ai.NewModelTextMessage(" ")}
19+ cases := []struct {
20+ name string
21+ resp *ai.ModelResponse
22+ err error
23+ want ui.Struggle
24+ ok bool
25+ }{
26+ {"an answer", text, nil, ui.Struggle{}, false},
27+ {"an empty answer", empty, nil, ui.Struggle{Label: "empty answer"}, true},
28+ {"stalled, wrapped", nil, fmt.Errorf("turn: %w", errStalled), ui.Struggle{Severe: true, Label: "stalled"}, true},
29+ {"runaway", nil, runawayError("the model's tool call ran away: …"), ui.Struggle{Severe: true, Label: "runaway tool call"}, true},
30+ {"max turns", nil, errors.New("ai.GenerateWithRequest: exceeded maximum tool call iterations (8)"), ui.Struggle{Severe: true, Label: "max turns reached"}, true},
31+ {"unknown tool", nil, errors.New(`tool "run_python" not found`), ui.Struggle{Severe: true, Label: "unknown tool: run_python"}, true},
32+ {"stuck: the detector reported it", nil, &detector.Stuck{Tool: "bash", Repeats: 6}, ui.Struggle{}, false},
33+ {"any other failure", nil, errors.New("HTTP 500"), ui.Struggle{}, false},
34+ }
35+ for _, c := range cases {
36+ got, ok := turnStruggle(c.resp, c.err)
37+ if got != c.want || ok != c.ok {
38+ t.Errorf("%s: turnStruggle = %+v, %v; want %+v, %v", c.name, got, ok, c.want, c.ok)
39+ }
40+ }
41+}
42+
43+func TestContextStruggleWarnsFromEightyPercent(t *testing.T) {
44+ if _, ok := contextStruggle(25599, 32000); ok {
45+ t.Error("79.9 % reported")
46+ }
47+ if s, ok := contextStruggle(25600, 32000); !ok || s != (ui.Struggle{Label: "context 80% full"}) {
48+ t.Errorf("80 %% = %+v, %v", s, ok)
49+ }
50+ if _, ok := contextStruggle(90000, 0); ok {
51+ t.Error("an unknown window reported")
52+ }
53+}
54+
55+// Generate reports how the turn went to the front end; a cancelled turn
56+// reports nothing.
57+func TestGenerateReportsAnEmptyAnswerButNotACancelledTurn(t *testing.T) {
58+ g, _ := scriptedGenkit(t, modelTurn(ai.NewTextPart("")))
59+ e := testEngine(g)
60+ sink := hookedEngine(t, e, config.HooksConfig{})
61+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil {
62+ t.Fatal(err)
63+ }
64+ if len(sink.strugs) != 1 || sink.strugs[0].Label != "empty answer" {
65+ t.Fatalf("struggles = %+v", sink.strugs)
66+ }
67+
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+ sink.strugs = nil
71+ e.reportTurn(ctx, nil, errStalled)
72+ if len(sink.strugs) != 0 {
73+ t.Errorf("a cancelled turn reported %+v", sink.strugs)
74+ }
75+}
new file mode 100644
@@ -0,0 +1,75 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "testing"
8+
9+ "mm/internal/config"
10+ "mm/internal/detector"
11+ "mm/internal/ui"
12+
13+ "github.com/firebase/genkit/go/ai"
14+)
15+
16+func TestTurnStruggleClassifiesHowATurnEnded(t *testing.T) {
17+ text := &ai.ModelResponse{Message: ai.NewModelTextMessage("done")}
18+ empty := &ai.ModelResponse{Message: ai.NewModelTextMessage(" ")}
19+ cases := []struct {
20+ name string
21+ resp *ai.ModelResponse
22+ err error
23+ want ui.Struggle
24+ ok bool
25+ }{
26+ {"an answer", text, nil, ui.Struggle{}, false},
27+ {"an empty answer", empty, nil, ui.Struggle{Label: "empty answer"}, true},
28+ {"stalled, wrapped", nil, fmt.Errorf("turn: %w", errStalled), ui.Struggle{Severe: true, Label: "stalled"}, true},
29+ {"runaway", nil, runawayError("the model's tool call ran away: …"), ui.Struggle{Severe: true, Label: "runaway tool call"}, true},
30+ {"max turns", nil, errors.New("ai.GenerateWithRequest: exceeded maximum tool call iterations (8)"), ui.Struggle{Severe: true, Label: "max turns reached"}, true},
31+ {"unknown tool", nil, errors.New(`tool "run_python" not found`), ui.Struggle{Severe: true, Label: "unknown tool: run_python"}, true},
32+ {"stuck: the detector reported it", nil, &detector.Stuck{Tool: "bash", Repeats: 6}, ui.Struggle{}, false},
33+ {"any other failure", nil, errors.New("HTTP 500"), ui.Struggle{}, false},
34+ }
35+ for _, c := range cases {
36+ got, ok := turnStruggle(c.resp, c.err)
37+ if got != c.want || ok != c.ok {
38+ t.Errorf("%s: turnStruggle = %+v, %v; want %+v, %v", c.name, got, ok, c.want, c.ok)
39+ }
40+ }
41+}
42+
43+func TestContextStruggleWarnsFromEightyPercent(t *testing.T) {
44+ if _, ok := contextStruggle(25599, 32000); ok {
45+ t.Error("79.9 % reported")
46+ }
47+ if s, ok := contextStruggle(25600, 32000); !ok || s != (ui.Struggle{Label: "context 80% full"}) {
48+ t.Errorf("80 %% = %+v, %v", s, ok)
49+ }
50+ if _, ok := contextStruggle(90000, 0); ok {
51+ t.Error("an unknown window reported")
52+ }
53+}
54+
55+// Generate reports how the turn went to the front end; a cancelled turn
56+// reports nothing.
57+func TestGenerateReportsAnEmptyAnswerButNotACancelledTurn(t *testing.T) {
58+ g, _ := scriptedGenkit(t, modelTurn(ai.NewTextPart("")))
59+ e := testEngine(g)
60+ sink := hookedEngine(t, e, config.HooksConfig{})
61+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil {
62+ t.Fatal(err)
63+ }
64+ if len(sink.strugs) != 1 || sink.strugs[0].Label != "empty answer" {
65+ t.Fatalf("struggles = %+v", sink.strugs)
66+ }
67+
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+ sink.strugs = nil
71+ e.reportTurn(ctx, nil, errStalled)
72+ if len(sink.strugs) != 0 {
73+ t.Errorf("a cancelled turn reported %+v", sink.strugs)
74+ }
75+}
modified internal/mcp/tool_wrapper.go +8 -7
@@ -314,7 +314,7 @@ func toolEvented(
314314 if mustAsk(permissions) && !s.Allow(ctx, ev) {
315315 out := fmt.Sprintf("[%s rejected by user]", fullName)
316316 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
317- return record(d, fullName, mcpTool.Name, out)
317+ return record(d, fullName, mcpTool.Name, out, true)
318318 }
319319 s.ToolRunning(ev.ID)
320320
@@ -330,7 +330,7 @@ func toolEvented(
330330 truncated := truncate(out)
331331 s.ToolEnd(ev.ID, ui.ToolResult{Output: truncated, Failed: failed})
332332
333- return record(d, fullName, mcpTool.Name, truncated)
333+ return record(d, fullName, mcpTool.Name, truncated, failed)
334334 }
335335
336336 // toolTerminal handles the tool call for terminal mode.
@@ -347,7 +347,7 @@ func toolTerminal(
347347 // and an asking call is refused (consent.Prompt).
348348 ev := ui.ToolEvent{Tool: fullName, Title: fmt.Sprintf("%s: %s", sc.Server.Name, mcpTool.Name), Input: in}
349349 if mustAsk(permissions) && !consent.Prompt(ctx, ev) {
350- return record(d, fullName, mcpTool.Name, fmt.Sprintf("[%s rejected by user]", fullName))
350+ return record(d, fullName, mcpTool.Name, fmt.Sprintf("[%s rejected by user]", fullName), true)
351351 }
352352
353353 // Call the MCP tool
@@ -358,7 +358,7 @@ func toolTerminal(
358358 }
359359
360360 // Truncate and return
361- return record(d, fullName, mcpTool.Name, truncate(out))
361+ return record(d, fullName, mcpTool.Name, truncate(out), err != nil)
362362 }
363363
364364 // callMCPTool calls an MCP tool and returns the result as a string.
@@ -439,11 +439,12 @@ func truncate(s string) string {
439439
440440 // record records the tool call in the loop detector and checks for loops.
441441 // Returns the output (possibly with loop warning appended) or an error if stuck.
442-// This mirrors the behavior of the record function in internal/tools/files.go
443-func record(d *detector.LoopDetector, toolName, originalToolName, output string) (string, error) {
442+// This mirrors the behavior of the record function in internal/tools/files.go;
443+// failed says the call failed, for the detector's count of failures in a row.
444+func record(d *detector.LoopDetector, toolName, originalToolName, output string, failed bool) (string, error) {
444445 // Record the action for loop detection
445446 input := fmt.Sprintf("%s (from mcp:%s)", originalToolName, toolName)
446- if !d.Record(detector.Action{ToolName: toolName, Input: input, Output: output}) {
447+ if !d.Record(detector.Action{ToolName: toolName, Input: input, Output: output, Failed: failed}) {
447448 return output, nil
448449 }
449450
@@ -314,7 +314,7 @@ func toolEvented(
314 if mustAsk(permissions) && !s.Allow(ctx, ev) {314 if mustAsk(permissions) && !s.Allow(ctx, ev) {
315 out := fmt.Sprintf("[%s rejected by user]", fullName)315 out := fmt.Sprintf("[%s rejected by user]", fullName)
316 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})316 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
317- return record(d, fullName, mcpTool.Name, out)317+ return record(d, fullName, mcpTool.Name, out, true)
318 }318 }
319 s.ToolRunning(ev.ID)319 s.ToolRunning(ev.ID)
320 320
@@ -330,7 +330,7 @@ func toolEvented(
330 truncated := truncate(out)330 truncated := truncate(out)
331 s.ToolEnd(ev.ID, ui.ToolResult{Output: truncated, Failed: failed})331 s.ToolEnd(ev.ID, ui.ToolResult{Output: truncated, Failed: failed})
332 332
333- return record(d, fullName, mcpTool.Name, truncated)333+ return record(d, fullName, mcpTool.Name, truncated, failed)
334 }334 }
335 335
336 // toolTerminal handles the tool call for terminal mode.336 // toolTerminal handles the tool call for terminal mode.
@@ -347,7 +347,7 @@ func toolTerminal(
347 // and an asking call is refused (consent.Prompt).347 // and an asking call is refused (consent.Prompt).
348 ev := ui.ToolEvent{Tool: fullName, Title: fmt.Sprintf("%s: %s", sc.Server.Name, mcpTool.Name), Input: in}348 ev := ui.ToolEvent{Tool: fullName, Title: fmt.Sprintf("%s: %s", sc.Server.Name, mcpTool.Name), Input: in}
349 if mustAsk(permissions) && !consent.Prompt(ctx, ev) {349 if mustAsk(permissions) && !consent.Prompt(ctx, ev) {
350- return record(d, fullName, mcpTool.Name, fmt.Sprintf("[%s rejected by user]", fullName))350+ return record(d, fullName, mcpTool.Name, fmt.Sprintf("[%s rejected by user]", fullName), true)
351 }351 }
352 352
353 // Call the MCP tool353 // Call the MCP tool
@@ -358,7 +358,7 @@ func toolTerminal(
358 }358 }
359 359
360 // Truncate and return360 // Truncate and return
361- return record(d, fullName, mcpTool.Name, truncate(out))361+ return record(d, fullName, mcpTool.Name, truncate(out), err != nil)
362 }362 }
363 363
364 // callMCPTool calls an MCP tool and returns the result as a string.364 // callMCPTool calls an MCP tool and returns the result as a string.
@@ -439,11 +439,12 @@ func truncate(s string) string {
439 439
440 // record records the tool call in the loop detector and checks for loops.440 // record records the tool call in the loop detector and checks for loops.
441 // Returns the output (possibly with loop warning appended) or an error if stuck.441 // Returns the output (possibly with loop warning appended) or an error if stuck.
442-// This mirrors the behavior of the record function in internal/tools/files.go442+// This mirrors the behavior of the record function in internal/tools/files.go;
443-func record(d *detector.LoopDetector, toolName, originalToolName, output string) (string, error) {443+// failed says the call failed, for the detector's count of failures in a row.
444+func record(d *detector.LoopDetector, toolName, originalToolName, output string, failed bool) (string, error) {
444 // Record the action for loop detection445 // Record the action for loop detection
445 input := fmt.Sprintf("%s (from mcp:%s)", originalToolName, toolName)446 input := fmt.Sprintf("%s (from mcp:%s)", originalToolName, toolName)
446- if !d.Record(detector.Action{ToolName: toolName, Input: input, Output: output}) {447+ if !d.Record(detector.Action{ToolName: toolName, Input: input, Output: output, Failed: failed}) {
447 return output, nil448 return output, nil
448 }449 }
449 450
modified internal/tools/askagent.go +5 -5
@@ -93,10 +93,10 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
9393 key := in.Agent + ": " + in.Prompt
9494 ref, ok := config.Cfg.MMConfigs[in.Agent]
9595 if !ok {
96- return record(call.detector, "ask_agent", key, fmt.Sprintf("[ask_agent: unknown agent %q — agents: %s]", in.Agent, strings.Join(config.AgentNames(), ", ")))
96+ return recordFailure(call.detector, "ask_agent", key, fmt.Sprintf("[ask_agent: unknown agent %q — agents: %s]", in.Agent, strings.Join(config.AgentNames(), ", ")))
9797 }
9898 if strings.TrimSpace(in.Prompt) == "" {
99- return record(call.detector, "ask_agent", key, "[ask_agent: the prompt is empty — say what "+in.Agent+" must do]")
99+ return recordFailure(call.detector, "ask_agent", key, "[ask_agent: the prompt is empty — say what "+in.Agent+" must do]")
100100 }
101101 transcript := ""
102102 if in.WithContext {
@@ -122,7 +122,7 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
122122 }
123123 fmt.Println()
124124 spinner.Start("Thinking")
125- return record(call.detector, "ask_agent", key, out)
125+ return recordOutcome(call.detector, "ask_agent", key, out, err != nil)
126126 }
127127
128128 // evented is ask_agent for an event-consuming front end (the window, an
@@ -142,7 +142,7 @@ func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInpu
142142 if !consent.Gate(ctx, s, ev) {
143143 out := "[ask_agent rejected by the user]"
144144 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
145- return record(call.detector, "ask_agent", key, out)
145+ return recordFailure(call.detector, "ask_agent", key, out)
146146 }
147147 s.ToolRunning(ev.ID)
148148 if sp, ok := s.(ui.Speaker); ok {
@@ -163,7 +163,7 @@ func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInpu
163163 summary = fmt.Sprintf("%s answered; a hook replaced the answer (%d characters)", in.Agent, len([]rune(res.Answer)))
164164 }
165165 s.ToolEnd(ev.ID, ui.ToolResult{Output: summary, Failed: err != nil})
166- return record(call.detector, "ask_agent", key, out)
166+ return recordOutcome(call.detector, "ask_agent", key, out, err != nil)
167167 }
168168
169169 // beatingSink passes every event on, and beats this generation's watchdog on
@@ -93,10 +93,10 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
93 key := in.Agent + ": " + in.Prompt93 key := in.Agent + ": " + in.Prompt
94 ref, ok := config.Cfg.MMConfigs[in.Agent]94 ref, ok := config.Cfg.MMConfigs[in.Agent]
95 if !ok {95 if !ok {
96- return record(call.detector, "ask_agent", key, fmt.Sprintf("[ask_agent: unknown agent %q — agents: %s]", in.Agent, strings.Join(config.AgentNames(), ", ")))96+ return recordFailure(call.detector, "ask_agent", key, fmt.Sprintf("[ask_agent: unknown agent %q — agents: %s]", in.Agent, strings.Join(config.AgentNames(), ", ")))
97 }97 }
98 if strings.TrimSpace(in.Prompt) == "" {98 if strings.TrimSpace(in.Prompt) == "" {
99- return record(call.detector, "ask_agent", key, "[ask_agent: the prompt is empty — say what "+in.Agent+" must do]")99+ return recordFailure(call.detector, "ask_agent", key, "[ask_agent: the prompt is empty — say what "+in.Agent+" must do]")
100 }100 }
101 transcript := ""101 transcript := ""
102 if in.WithContext {102 if in.WithContext {
@@ -122,7 +122,7 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
122 }122 }
123 fmt.Println()123 fmt.Println()
124 spinner.Start("Thinking")124 spinner.Start("Thinking")
125- return record(call.detector, "ask_agent", key, out)125+ return recordOutcome(call.detector, "ask_agent", key, out, err != nil)
126 }126 }
127 127
128 // evented is ask_agent for an event-consuming front end (the window, an128 // evented is ask_agent for an event-consuming front end (the window, an
@@ -142,7 +142,7 @@ func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInpu
142 if !consent.Gate(ctx, s, ev) {142 if !consent.Gate(ctx, s, ev) {
143 out := "[ask_agent rejected by the user]"143 out := "[ask_agent rejected by the user]"
144 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})144 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
145- return record(call.detector, "ask_agent", key, out)145+ return recordFailure(call.detector, "ask_agent", key, out)
146 }146 }
147 s.ToolRunning(ev.ID)147 s.ToolRunning(ev.ID)
148 if sp, ok := s.(ui.Speaker); ok {148 if sp, ok := s.(ui.Speaker); ok {
@@ -163,7 +163,7 @@ func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInpu
163 summary = fmt.Sprintf("%s answered; a hook replaced the answer (%d characters)", in.Agent, len([]rune(res.Answer)))163 summary = fmt.Sprintf("%s answered; a hook replaced the answer (%d characters)", in.Agent, len([]rune(res.Answer)))
164 }164 }
165 s.ToolEnd(ev.ID, ui.ToolResult{Output: summary, Failed: err != nil})165 s.ToolEnd(ev.ID, ui.ToolResult{Output: summary, Failed: err != nil})
166- return record(call.detector, "ask_agent", key, out)166+ return recordOutcome(call.detector, "ask_agent", key, out, err != nil)
167 }167 }
168 168
169 // beatingSink passes every event on, and beats this generation's watchdog on169 // beatingSink passes every event on, and beats this generation's watchdog on
modified internal/tools/askuser.go +3 -3
@@ -82,7 +82,7 @@ func askUser(ctx context.Context, d *detector.LoopDetector, input map[string]any
8282 key := askUserKey(input)
8383 form, problem := elicit.Normalize(input)
8484 if problem != "" {
85- return record(d, "ask_user", key, problem)
85+ return recordFailure(d, "ask_user", key, problem)
8686 }
8787 if s := ui.ActiveSink(); s != nil {
8888 return askUserEvented(ctx, d, s, input, form, key)
@@ -90,7 +90,7 @@ func askUser(ctx context.Context, d *detector.LoopDetector, input map[string]any
9090 // The questions themselves are printed by the prompter, just below.
9191 announce(fmt.Sprintf("❓ ask_user: %d question(s)", len(form.Questions)))
9292 res := whileWaiting(engine.Heartbeat(ctx), func() elicit.Result { return elicit.Prompt(ctx, form) })
93- return record(d, "ask_user", key, res.Text(form))
93+ return recordOutcome(d, "ask_user", key, res.Text(form), res.Status != elicit.Answered)
9494 }
9595
9696 // askUserEvented is ask_user for an event-consuming front end: one tool call,
@@ -111,7 +111,7 @@ func askUserEvented(ctx context.Context, d *detector.LoopDetector, s ui.Sink, in
111111 }
112112 out := res.Text(form)
113113 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: res.Status != elicit.Answered})
114- return record(d, "ask_user", key, out)
114+ return recordOutcome(d, "ask_user", key, out, res.Status != elicit.Answered)
115115 }
116116
117117 // whileWaiting runs ask and beats a watchdog (beat) every second until it
@@ -82,7 +82,7 @@ func askUser(ctx context.Context, d *detector.LoopDetector, input map[string]any
82 key := askUserKey(input)82 key := askUserKey(input)
83 form, problem := elicit.Normalize(input)83 form, problem := elicit.Normalize(input)
84 if problem != "" {84 if problem != "" {
85- return record(d, "ask_user", key, problem)85+ return recordFailure(d, "ask_user", key, problem)
86 }86 }
87 if s := ui.ActiveSink(); s != nil {87 if s := ui.ActiveSink(); s != nil {
88 return askUserEvented(ctx, d, s, input, form, key)88 return askUserEvented(ctx, d, s, input, form, key)
@@ -90,7 +90,7 @@ func askUser(ctx context.Context, d *detector.LoopDetector, input map[string]any
90 // The questions themselves are printed by the prompter, just below.90 // The questions themselves are printed by the prompter, just below.
91 announce(fmt.Sprintf("❓ ask_user: %d question(s)", len(form.Questions)))91 announce(fmt.Sprintf("❓ ask_user: %d question(s)", len(form.Questions)))
92 res := whileWaiting(engine.Heartbeat(ctx), func() elicit.Result { return elicit.Prompt(ctx, form) })92 res := whileWaiting(engine.Heartbeat(ctx), func() elicit.Result { return elicit.Prompt(ctx, form) })
93- return record(d, "ask_user", key, res.Text(form))93+ return recordOutcome(d, "ask_user", key, res.Text(form), res.Status != elicit.Answered)
94 }94 }
95 95
96 // askUserEvented is ask_user for an event-consuming front end: one tool call,96 // askUserEvented is ask_user for an event-consuming front end: one tool call,
@@ -111,7 +111,7 @@ func askUserEvented(ctx context.Context, d *detector.LoopDetector, s ui.Sink, in
111 }111 }
112 out := res.Text(form)112 out := res.Text(form)
113 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: res.Status != elicit.Answered})113 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: res.Status != elicit.Answered})
114- return record(d, "ask_user", key, out)114+ return recordOutcome(d, "ask_user", key, out, res.Status != elicit.Answered)
115 }115 }
116 116
117 // whileWaiting runs ask and beats a watchdog (beat) every second until it117 // whileWaiting runs ask and beats a watchdog (beat) every second until it
modified internal/tools/declared.go +5 -5
@@ -186,10 +186,10 @@ func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string,
186186 return refuse(c.detector, c.tool.Name, j.shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name))
187187 }
188188 spinner.Start("Running")
189- out, _ := j.run(ctx, "")
189+ out, failed := j.run(ctx, "")
190190 spinner.Suspend(func() { echoOutput(out) })
191191 spinner.Start("Thinking")
192- return record(c.detector, c.tool.Name, j.shown, out)
192+ return recordOutcome(c.detector, c.tool.Name, j.shown, out, failed)
193193 }
194194
195195 // evented is the tool for an event-consuming front end (ACP, the window): a
@@ -210,18 +210,18 @@ func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any)
210210 s.ToolStart(ev)
211211 if problem != "" {
212212 s.ToolEnd(ev.ID, ui.ToolResult{Output: problem, Failed: true})
213- return record(c.detector, c.tool.Name, shown, problem)
213+ return recordFailure(c.detector, c.tool.Name, shown, problem)
214214 }
215215 if !consent.Gate(ctx, s, ev) {
216216 out := fmt.Sprintf("[%s rejected by the user]", c.tool.Name)
217217 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
218- return record(c.detector, c.tool.Name, shown, out)
218+ return recordFailure(c.detector, c.tool.Name, shown, out)
219219 }
220220 s.ToolRunning(ev.ID)
221221 // The session's directory, as for bash: in an editor, the project.
222222 out, failed := j.run(ctx, s.WorkDir())
223223 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
224- return record(c.detector, c.tool.Name, shown, out)
224+ return recordOutcome(c.detector, c.tool.Name, shown, out, failed)
225225 }
226226
227227 // timeout is the tool's own budget, or bash's.
@@ -186,10 +186,10 @@ func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string,
186 return refuse(c.detector, c.tool.Name, j.shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name))186 return refuse(c.detector, c.tool.Name, j.shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name))
187 }187 }
188 spinner.Start("Running")188 spinner.Start("Running")
189- out, _ := j.run(ctx, "")189+ out, failed := j.run(ctx, "")
190 spinner.Suspend(func() { echoOutput(out) })190 spinner.Suspend(func() { echoOutput(out) })
191 spinner.Start("Thinking")191 spinner.Start("Thinking")
192- return record(c.detector, c.tool.Name, j.shown, out)192+ return recordOutcome(c.detector, c.tool.Name, j.shown, out, failed)
193 }193 }
194 194
195 // evented is the tool for an event-consuming front end (ACP, the window): a195 // evented is the tool for an event-consuming front end (ACP, the window): a
@@ -210,18 +210,18 @@ func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any)
210 s.ToolStart(ev)210 s.ToolStart(ev)
211 if problem != "" {211 if problem != "" {
212 s.ToolEnd(ev.ID, ui.ToolResult{Output: problem, Failed: true})212 s.ToolEnd(ev.ID, ui.ToolResult{Output: problem, Failed: true})
213- return record(c.detector, c.tool.Name, shown, problem)213+ return recordFailure(c.detector, c.tool.Name, shown, problem)
214 }214 }
215 if !consent.Gate(ctx, s, ev) {215 if !consent.Gate(ctx, s, ev) {
216 out := fmt.Sprintf("[%s rejected by the user]", c.tool.Name)216 out := fmt.Sprintf("[%s rejected by the user]", c.tool.Name)
217 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})217 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
218- return record(c.detector, c.tool.Name, shown, out)218+ return recordFailure(c.detector, c.tool.Name, shown, out)
219 }219 }
220 s.ToolRunning(ev.ID)220 s.ToolRunning(ev.ID)
221 // The session's directory, as for bash: in an editor, the project.221 // The session's directory, as for bash: in an editor, the project.
222 out, failed := j.run(ctx, s.WorkDir())222 out, failed := j.run(ctx, s.WorkDir())
223 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})223 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
224- return record(c.detector, c.tool.Name, shown, out)224+ return recordOutcome(c.detector, c.tool.Name, shown, out, failed)
225 }225 }
226 226
227 // timeout is the tool's own budget, or bash's.227 // timeout is the tool's own budget, or bash's.
modified internal/tools/fetchcontent.go +4 -4
@@ -75,13 +75,13 @@ Failures come back as "[fetch_content: error N (…) — reason]": 1 = the page
7575 // A page can take seconds to come: same reasoning as bash's
7676 // "Running" label, a slow site must not look like a hung agent.
7777 spinner.Start("Fetching")
78- out, _ := fetchContent(tctx, f, in, ui.Resolve)
78+ out, failed := fetchContent(tctx, f, in, ui.Resolve)
7979 out = truncate(out)
8080 // With `output`, out is the confirmation line: the page itself is
8181 // not echoed, which is what "save it, don't show it" asks for.
8282 spinner.Suspend(func() { echoOutput(out) })
8383 spinner.Start("Thinking")
84- return record(d, "fetch_content", where, out)
84+ return recordOutcome(d, "fetch_content", where, out, failed)
8585 })
8686 }
8787
@@ -110,14 +110,14 @@ func (call fetchContentCall) evented(ctx context.Context, s ui.Sink, in fetchCon
110110 if in.Output != "" && !consent.Gate(ctx, s, ev) {
111111 out := "[fetch_content rejected by the user]"
112112 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
113- return record(d, "fetch_content", where, out)
113+ return recordFailure(d, "fetch_content", where, out)
114114 }
115115 s.ToolRunning(ev.ID)
116116
117117 out, failed := fetchContent(ctx, call.fetcher, in, ui.Resolve)
118118 out = truncate(out)
119119 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
120- return record(d, "fetch_content", where, out)
120+ return recordOutcome(d, "fetch_content", where, out, failed)
121121 }
122122
123123 // fetchContent does the work of the tool: fetch, convert, and return the
@@ -75,13 +75,13 @@ Failures come back as "[fetch_content: error N (…) — reason]": 1 = the page
75 // A page can take seconds to come: same reasoning as bash's75 // A page can take seconds to come: same reasoning as bash's
76 // "Running" label, a slow site must not look like a hung agent.76 // "Running" label, a slow site must not look like a hung agent.
77 spinner.Start("Fetching")77 spinner.Start("Fetching")
78- out, _ := fetchContent(tctx, f, in, ui.Resolve)78+ out, failed := fetchContent(tctx, f, in, ui.Resolve)
79 out = truncate(out)79 out = truncate(out)
80 // With `output`, out is the confirmation line: the page itself is80 // With `output`, out is the confirmation line: the page itself is
81 // not echoed, which is what "save it, don't show it" asks for.81 // not echoed, which is what "save it, don't show it" asks for.
82 spinner.Suspend(func() { echoOutput(out) })82 spinner.Suspend(func() { echoOutput(out) })
83 spinner.Start("Thinking")83 spinner.Start("Thinking")
84- return record(d, "fetch_content", where, out)84+ return recordOutcome(d, "fetch_content", where, out, failed)
85 })85 })
86 }86 }
87 87
@@ -110,14 +110,14 @@ func (call fetchContentCall) evented(ctx context.Context, s ui.Sink, in fetchCon
110 if in.Output != "" && !consent.Gate(ctx, s, ev) {110 if in.Output != "" && !consent.Gate(ctx, s, ev) {
111 out := "[fetch_content rejected by the user]"111 out := "[fetch_content rejected by the user]"
112 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})112 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
113- return record(d, "fetch_content", where, out)113+ return recordFailure(d, "fetch_content", where, out)
114 }114 }
115 s.ToolRunning(ev.ID)115 s.ToolRunning(ev.ID)
116 116
117 out, failed := fetchContent(ctx, call.fetcher, in, ui.Resolve)117 out, failed := fetchContent(ctx, call.fetcher, in, ui.Resolve)
118 out = truncate(out)118 out = truncate(out)
119 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})119 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
120- return record(d, "fetch_content", where, out)120+ return recordOutcome(d, "fetch_content", where, out, failed)
121 }121 }
122 122
123 // fetchContent does the work of the tool: fetch, convert, and return the123 // fetchContent does the work of the tool: fetch, convert, and return the
modified internal/tools/files.go +24 -9
@@ -58,8 +58,23 @@ func announce(line string) {
5858 // model going in circles, exactly like a failing command. When the model has
5959 // ignored that instruction as many times as the detector tolerates, the turn
6060 // ends: the ONE Go error a tool returns on purpose (see internal/detector).
61+//
62+// record is for a call that succeeded, recordFailure for one that failed and
63+// recordOutcome when the tool computed which: the detector also counts failed
64+// calls in a row, whatever their input (ticket 160).
6165 func record(d *detector.LoopDetector, tool, input, out string) (string, error) {
62- if !d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {
66+ return recordOutcome(d, tool, input, out, false)
67+}
68+
69+// recordFailure is record for a call that failed: a non-zero exit, a refused
70+// edit, a rejected permission, a missing file…
71+func recordFailure(d *detector.LoopDetector, tool, input, out string) (string, error) {
72+ return recordOutcome(d, tool, input, out, true)
73+}
74+
75+// recordOutcome is record with the call's outcome.
76+func recordOutcome(d *detector.LoopDetector, tool, input, out string, failed bool) (string, error) {
77+ if !d.Record(detector.Action{ToolName: tool, Input: input, Output: out, Failed: failed}) {
6378 return out, nil
6479 }
6580 if d.Stuck() {
@@ -97,7 +112,7 @@ Use start/end for a slice of a long file, and numbered=true to get line numbers.
97112 // Information, not a crash: the message says how to fix the call.
98113 out := err.Error()
99114 spinner.Suspend(func() { echoOutput(out) })
100- return record(d, "read_file", where, out)
115+ return recordFailure(d, "read_file", where, out)
101116 }
102117 out := truncate(content)
103118 // Shown to the user like a command's output: "show me that file"
@@ -133,7 +148,7 @@ Returns a one-line summary and the diff against the previous content. Writing id
133148 if err != nil {
134149 out := err.Error()
135150 spinner.Suspend(func() { echoOutput(out) })
136- return record(d, "write_file", in.Path, out)
151+ return recordFailure(d, "write_file", in.Path, out)
137152 }
138153 return report(d, "write_file", in.Path, r)
139154 })
@@ -175,7 +190,7 @@ Nothing is written when any edit fails. Returns a one-line summary and the diff
175190 if err != nil {
176191 out := err.Error()
177192 spinner.Suspend(func() { echoOutput(out) })
178- return record(d, "edit_file", editKey(in), out)
193+ return recordFailure(d, "edit_file", editKey(in), out)
179194 }
180195 return report(d, "edit_file", editKey(in), r)
181196 })
@@ -230,7 +245,7 @@ func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, wher
230245 if err != nil {
231246 out := err.Error()
232247 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
233- return record(d, "read_file", where, out)
248+ return recordFailure(d, "read_file", where, out)
234249 }
235250 out := truncate(content)
236251 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
@@ -251,7 +266,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
251266 if !consent.Gate(ctx, s, ev) {
252267 out := "[write_file rejected by the user]"
253268 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
254- return record(d, "write_file", in.Path, out)
269+ return recordFailure(d, "write_file", in.Path, out)
255270 }
256271 s.ToolRunning(ev.ID)
257272
@@ -263,7 +278,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
263278 if err != nil {
264279 out := err.Error()
265280 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
266- return record(d, "write_file", in.Path, out)
281+ return recordFailure(d, "write_file", in.Path, out)
267282 }
268283 out := resultText(r)
269284 res := ui.ToolResult{Output: out}
@@ -288,7 +303,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
288303 if !consent.Gate(ctx, s, ev) {
289304 out := "[edit_file rejected by the user]"
290305 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291- return record(d, "edit_file", editKey(in), out)
306+ return recordFailure(d, "edit_file", editKey(in), out)
292307 }
293308 s.ToolRunning(ev.ID)
294309
@@ -301,7 +316,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
301316 // model — which reads the rule and fixes its `old`.
302317 out := err.Error()
303318 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
304- return record(d, "edit_file", editKey(in), out)
319+ return recordFailure(d, "edit_file", editKey(in), out)
305320 }
306321 out := resultText(r)
307322 res := ui.ToolResult{Output: out}
@@ -58,8 +58,23 @@ func announce(line string) {
58 // model going in circles, exactly like a failing command. When the model has58 // model going in circles, exactly like a failing command. When the model has
59 // ignored that instruction as many times as the detector tolerates, the turn59 // ignored that instruction as many times as the detector tolerates, the turn
60 // ends: the ONE Go error a tool returns on purpose (see internal/detector).60 // ends: the ONE Go error a tool returns on purpose (see internal/detector).
61+//
62+// record is for a call that succeeded, recordFailure for one that failed and
63+// recordOutcome when the tool computed which: the detector also counts failed
64+// calls in a row, whatever their input (ticket 160).
61 func record(d *detector.LoopDetector, tool, input, out string) (string, error) {65 func record(d *detector.LoopDetector, tool, input, out string) (string, error) {
62- if !d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) {66+ return recordOutcome(d, tool, input, out, false)
67+}
68+
69+// recordFailure is record for a call that failed: a non-zero exit, a refused
70+// edit, a rejected permission, a missing file…
71+func recordFailure(d *detector.LoopDetector, tool, input, out string) (string, error) {
72+ return recordOutcome(d, tool, input, out, true)
73+}
74+
75+// recordOutcome is record with the call's outcome.
76+func recordOutcome(d *detector.LoopDetector, tool, input, out string, failed bool) (string, error) {
77+ if !d.Record(detector.Action{ToolName: tool, Input: input, Output: out, Failed: failed}) {
63 return out, nil78 return out, nil
64 }79 }
65 if d.Stuck() {80 if d.Stuck() {
@@ -97,7 +112,7 @@ Use start/end for a slice of a long file, and numbered=true to get line numbers.
97 // Information, not a crash: the message says how to fix the call.112 // Information, not a crash: the message says how to fix the call.
98 out := err.Error()113 out := err.Error()
99 spinner.Suspend(func() { echoOutput(out) })114 spinner.Suspend(func() { echoOutput(out) })
100- return record(d, "read_file", where, out)115+ return recordFailure(d, "read_file", where, out)
101 }116 }
102 out := truncate(content)117 out := truncate(content)
103 // Shown to the user like a command's output: "show me that file"118 // Shown to the user like a command's output: "show me that file"
@@ -133,7 +148,7 @@ Returns a one-line summary and the diff against the previous content. Writing id
133 if err != nil {148 if err != nil {
134 out := err.Error()149 out := err.Error()
135 spinner.Suspend(func() { echoOutput(out) })150 spinner.Suspend(func() { echoOutput(out) })
136- return record(d, "write_file", in.Path, out)151+ return recordFailure(d, "write_file", in.Path, out)
137 }152 }
138 return report(d, "write_file", in.Path, r)153 return report(d, "write_file", in.Path, r)
139 })154 })
@@ -175,7 +190,7 @@ Nothing is written when any edit fails. Returns a one-line summary and the diff
175 if err != nil {190 if err != nil {
176 out := err.Error()191 out := err.Error()
177 spinner.Suspend(func() { echoOutput(out) })192 spinner.Suspend(func() { echoOutput(out) })
178- return record(d, "edit_file", editKey(in), out)193+ return recordFailure(d, "edit_file", editKey(in), out)
179 }194 }
180 return report(d, "edit_file", editKey(in), r)195 return report(d, "edit_file", editKey(in), r)
181 })196 })
@@ -230,7 +245,7 @@ func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, wher
230 if err != nil {245 if err != nil {
231 out := err.Error()246 out := err.Error()
232 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})247 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
233- return record(d, "read_file", where, out)248+ return recordFailure(d, "read_file", where, out)
234 }249 }
235 out := truncate(content)250 out := truncate(content)
236 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})251 s.ToolEnd(ev.ID, ui.ToolResult{Output: out})
@@ -251,7 +266,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
251 if !consent.Gate(ctx, s, ev) {266 if !consent.Gate(ctx, s, ev) {
252 out := "[write_file rejected by the user]"267 out := "[write_file rejected by the user]"
253 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})268 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
254- return record(d, "write_file", in.Path, out)269+ return recordFailure(d, "write_file", in.Path, out)
255 }270 }
256 s.ToolRunning(ev.ID)271 s.ToolRunning(ev.ID)
257 272
@@ -263,7 +278,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
263 if err != nil {278 if err != nil {
264 out := err.Error()279 out := err.Error()
265 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})280 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
266- return record(d, "write_file", in.Path, out)281+ return recordFailure(d, "write_file", in.Path, out)
267 }282 }
268 out := resultText(r)283 out := resultText(r)
269 res := ui.ToolResult{Output: out}284 res := ui.ToolResult{Output: out}
@@ -288,7 +303,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
288 if !consent.Gate(ctx, s, ev) {303 if !consent.Gate(ctx, s, ev) {
289 out := "[edit_file rejected by the user]"304 out := "[edit_file rejected by the user]"
290 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})305 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291- return record(d, "edit_file", editKey(in), out)306+ return recordFailure(d, "edit_file", editKey(in), out)
292 }307 }
293 s.ToolRunning(ev.ID)308 s.ToolRunning(ev.ID)
294 309
@@ -301,7 +316,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i
301 // model — which reads the rule and fixes its `old`.316 // model — which reads the rule and fixes its `old`.
302 out := err.Error()317 out := err.Error()
303 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})318 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
304- return record(d, "edit_file", editKey(in), out)319+ return recordFailure(d, "edit_file", editKey(in), out)
305 }320 }
306 out := resultText(r)321 out := resultText(r)
307 res := ui.ToolResult{Output: out}322 res := ui.ToolResult{Output: out}
modified internal/tools/lsp.go +4 -4
@@ -84,10 +84,10 @@ Use it before editing a symbol used in several files (references), to find where
8484 return refuse(d, "lsp", what, "[rename rejected by the user]")
8585 }
8686 spinner.Start("Asking the language server")
87- out, _ := runLSP(tctx, client, in)
87+ out, failed := runLSP(tctx, client, in)
8888 spinner.Suspend(func() { echoOutput(out) })
8989 spinner.Start("Thinking")
90- return record(d, "lsp", what, out)
90+ return recordOutcome(d, "lsp", what, out, failed)
9191 })
9292 }
9393
@@ -288,10 +288,10 @@ func lspEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, client
288288 if in.Action == lspRename && !consent.Gate(ctx, s, ev) {
289289 out := "[rename rejected by the user]"
290290 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291- return record(d, "lsp", what, out)
291+ return recordFailure(d, "lsp", what, out)
292292 }
293293 s.ToolRunning(ev.ID)
294294 out, failed := runLSP(ctx, client, in)
295295 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
296- return record(d, "lsp", what, out)
296+ return recordOutcome(d, "lsp", what, out, failed)
297297 }
@@ -84,10 +84,10 @@ Use it before editing a symbol used in several files (references), to find where
84 return refuse(d, "lsp", what, "[rename rejected by the user]")84 return refuse(d, "lsp", what, "[rename rejected by the user]")
85 }85 }
86 spinner.Start("Asking the language server")86 spinner.Start("Asking the language server")
87- out, _ := runLSP(tctx, client, in)87+ out, failed := runLSP(tctx, client, in)
88 spinner.Suspend(func() { echoOutput(out) })88 spinner.Suspend(func() { echoOutput(out) })
89 spinner.Start("Thinking")89 spinner.Start("Thinking")
90- return record(d, "lsp", what, out)90+ return recordOutcome(d, "lsp", what, out, failed)
91 })91 })
92 }92 }
93 93
@@ -288,10 +288,10 @@ func lspEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, client
288 if in.Action == lspRename && !consent.Gate(ctx, s, ev) {288 if in.Action == lspRename && !consent.Gate(ctx, s, ev) {
289 out := "[rename rejected by the user]"289 out := "[rename rejected by the user]"
290 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})290 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
291- return record(d, "lsp", what, out)291+ return recordFailure(d, "lsp", what, out)
292 }292 }
293 s.ToolRunning(ev.ID)293 s.ToolRunning(ev.ID)
294 out, failed := runLSP(ctx, client, in)294 out, failed := runLSP(ctx, client, in)
295 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})295 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
296- return record(d, "lsp", what, out)296+ return recordOutcome(d, "lsp", what, out, failed)
297 }297 }
modified internal/tools/plan.go +8 -5
@@ -494,7 +494,7 @@ Always show the user the current plan status using the status action.`
494494
495495 // Record in loop detector
496496 inputJSON, _ := json.Marshal(in)
497- return record(d, "plan", string(inputJSON), result)
497+ return recordOutcome(d, "plan", string(inputJSON), result, planFailed(result))
498498 })
499499 }
500500
@@ -547,7 +547,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
547547 out := "[plan: command rejected by the user]"
548548 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
549549 inputJSON, _ := json.Marshal(in)
550- return record(d, "plan", string(inputJSON), out)
550+ return recordFailure(d, "plan", string(inputJSON), out)
551551 }
552552 }
553553
@@ -555,8 +555,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
555555
556556 result := runPlanAction(in, false)
557557
558- // Check if failed
559- failed := strings.HasPrefix(result, "[error:")
558+ failed := planFailed(result)
560559
561560 s.ToolEnd(ev.ID, ui.ToolResult{Output: result, Failed: failed})
562561 // The window shows the progress as a notice; an editor has none, and
@@ -569,9 +568,13 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
569568
570569 // Record in loop detector
571570 inputJSON, _ := json.Marshal(in)
572- return record(d, "plan", string(inputJSON), result)
571+ return recordOutcome(d, "plan", string(inputJSON), result, failed)
573572 }
574573
574+// planFailed says whether a plan action's result is a failure: the plan
575+// tool answers its errors as "[error: …]".
576+func planFailed(result string) bool { return strings.HasPrefix(result, "[error:") }
577+
575578 // ClearPlans forgets the plans of a conversation: "" for the line mode and
576579 // the window, the session id over ACP (ui.ConversationID). Plans are not
577580 // saved in the session file, so every front end calls it when the
@@ -494,7 +494,7 @@ Always show the user the current plan status using the status action.`
494 494
495 // Record in loop detector495 // Record in loop detector
496 inputJSON, _ := json.Marshal(in)496 inputJSON, _ := json.Marshal(in)
497- return record(d, "plan", string(inputJSON), result)497+ return recordOutcome(d, "plan", string(inputJSON), result, planFailed(result))
498 })498 })
499 }499 }
500 500
@@ -547,7 +547,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
547 out := "[plan: command rejected by the user]"547 out := "[plan: command rejected by the user]"
548 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})548 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
549 inputJSON, _ := json.Marshal(in)549 inputJSON, _ := json.Marshal(in)
550- return record(d, "plan", string(inputJSON), out)550+ return recordFailure(d, "plan", string(inputJSON), out)
551 }551 }
552 }552 }
553 553
@@ -555,8 +555,7 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
555 555
556 result := runPlanAction(in, false)556 result := runPlanAction(in, false)
557 557
558- // Check if failed558+ failed := planFailed(result)
559- failed := strings.HasPrefix(result, "[error:")
560 559
561 s.ToolEnd(ev.ID, ui.ToolResult{Output: result, Failed: failed})560 s.ToolEnd(ev.ID, ui.ToolResult{Output: result, Failed: failed})
562 // The window shows the progress as a notice; an editor has none, and561 // The window shows the progress as a notice; an editor has none, and
@@ -569,9 +568,13 @@ func planEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in pl
569 568
570 // Record in loop detector569 // Record in loop detector
571 inputJSON, _ := json.Marshal(in)570 inputJSON, _ := json.Marshal(in)
572- return record(d, "plan", string(inputJSON), result)571+ return recordOutcome(d, "plan", string(inputJSON), result, failed)
573 }572 }
574 573
574+// planFailed says whether a plan action's result is a failure: the plan
575+// tool answers its errors as "[error: …]".
576+func planFailed(result string) bool { return strings.HasPrefix(result, "[error:") }
577+
575 // ClearPlans forgets the plans of a conversation: "" for the line mode and578 // ClearPlans forgets the plans of a conversation: "" for the line mode and
576 // the window, the session id over ACP (ui.ConversationID). Plans are not579 // the window, the session id over ACP (ui.ConversationID). Plans are not
577 // saved in the session file, so every front end calls it when the580 // saved in the session file, so every front end calls it when the
added internal/tools/record_test.go +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+package tools
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "mm/internal/detector"
8+)
9+
10+// The tools tell the detector which calls failed (ticket 160): failures in a
11+// row are counted whatever the input; a success resets the count.
12+func TestRecordHandsTheOutcomeToTheDetector(t *testing.T) {
13+ var heard []detector.Loop
14+ d := detector.NewLoopDetector(10, 3).WithFailures(2).WithReport(func(l detector.Loop) { heard = append(heard, l) })
15+
16+ _, _ = recordFailure(d, "read_file", "a.go", "no such file")
17+ _, _ = record(d, "read_file", "b.go", "package b")
18+ _, _ = recordFailure(d, "read_file", "c.go", "no such file")
19+ _, _ = recordOutcome(d, "bash", "cat c.go", "No such file", true)
20+ if len(heard) != 1 || heard[0] != (detector.Loop{Tool: "bash", Repeats: 2, Failing: true}) {
21+ t.Errorf("heard %+v", heard)
22+ }
23+}
24+
25+// A failing command counts as a failure, a working one does not — through
26+// the real bash tool path.
27+func TestBashOutcomeReachesTheDetector(t *testing.T) {
28+ var heard []detector.Loop
29+ d := detector.NewLoopDetector(10, 3).WithFailures(2).WithReport(func(l detector.Loop) { heard = append(heard, l) })
30+ s := &recordingSink{allow: true, dir: t.TempDir()}
31+ for _, cmd := range []string{"exit 3", "false"} {
32+ if _, err := bashEvented(context.Background(), s, d, bashInput{Command: cmd}); err != nil {
33+ t.Fatal(err)
34+ }
35+ }
36+ if len(heard) != 1 || !heard[0].Failing {
37+ t.Fatalf("two failing commands: heard %+v", heard)
38+ }
39+ if _, err := bashEvented(context.Background(), s, d, bashInput{Command: "true"}); err != nil {
40+ t.Fatal(err)
41+ }
42+ if _, err := bashEvented(context.Background(), s, d, bashInput{Command: "false"}); err != nil {
43+ t.Fatal(err)
44+ }
45+ if len(heard) != 1 {
46+ t.Errorf("a success did not reset the count: %+v", heard)
47+ }
48+}
new file mode 100644
@@ -0,0 +1,48 @@
1+package tools
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "mm/internal/detector"
8+)
9+
10+// The tools tell the detector which calls failed (ticket 160): failures in a
11+// row are counted whatever the input; a success resets the count.
12+func TestRecordHandsTheOutcomeToTheDetector(t *testing.T) {
13+ var heard []detector.Loop
14+ d := detector.NewLoopDetector(10, 3).WithFailures(2).WithReport(func(l detector.Loop) { heard = append(heard, l) })
15+
16+ _, _ = recordFailure(d, "read_file", "a.go", "no such file")
17+ _, _ = record(d, "read_file", "b.go", "package b")
18+ _, _ = recordFailure(d, "read_file", "c.go", "no such file")
19+ _, _ = recordOutcome(d, "bash", "cat c.go", "No such file", true)
20+ if len(heard) != 1 || heard[0] != (detector.Loop{Tool: "bash", Repeats: 2, Failing: true}) {
21+ t.Errorf("heard %+v", heard)
22+ }
23+}
24+
25+// A failing command counts as a failure, a working one does not — through
26+// the real bash tool path.
27+func TestBashOutcomeReachesTheDetector(t *testing.T) {
28+ var heard []detector.Loop
29+ d := detector.NewLoopDetector(10, 3).WithFailures(2).WithReport(func(l detector.Loop) { heard = append(heard, l) })
30+ s := &recordingSink{allow: true, dir: t.TempDir()}
31+ for _, cmd := range []string{"exit 3", "false"} {
32+ if _, err := bashEvented(context.Background(), s, d, bashInput{Command: cmd}); err != nil {
33+ t.Fatal(err)
34+ }
35+ }
36+ if len(heard) != 1 || !heard[0].Failing {
37+ t.Fatalf("two failing commands: heard %+v", heard)
38+ }
39+ if _, err := bashEvented(context.Background(), s, d, bashInput{Command: "true"}); err != nil {
40+ t.Fatal(err)
41+ }
42+ if _, err := bashEvented(context.Background(), s, d, bashInput{Command: "false"}); err != nil {
43+ t.Fatal(err)
44+ }
45+ if len(heard) != 1 {
46+ t.Errorf("a success did not reset the count: %+v", heard)
47+ }
48+}
modified internal/tools/saveanswer.go +4 -4
@@ -53,9 +53,9 @@ Use it only when the user asks to save, write or export an answer to a file. It
5353 if text != "" && !confirmed(tctx, "save_answer", title) {
5454 return refuse(d, "save_answer", in.Path, "[save_answer rejected by the user]")
5555 }
56- out, _ := saveAnswer(ui.Resolve(in.Path), in.Path, text, which)
56+ out, failed := saveAnswer(ui.Resolve(in.Path), in.Path, text, which)
5757 spinner.Suspend(func() { echoOutput(out) })
58- return record(d, "save_answer", in.Path, out)
58+ return recordOutcome(d, "save_answer", in.Path, out, failed)
5959 })
6060 }
6161
@@ -75,12 +75,12 @@ func saveAnswerEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
7575 if text != "" && !consent.Gate(ctx, s, ev) {
7676 out := "[save_answer rejected by the user]"
7777 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
78- return record(d, "save_answer", in.Path, out)
78+ return recordFailure(d, "save_answer", in.Path, out)
7979 }
8080 s.ToolRunning(ev.ID)
8181 out, failed := saveAnswer(path, in.Path, text, which)
8282 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
83- return record(d, "save_answer", in.Path, out)
83+ return recordOutcome(d, "save_answer", in.Path, out, failed)
8484 }
8585
8686 // answerToSave picks the text: the reply carrying the call when it has text,
@@ -53,9 +53,9 @@ Use it only when the user asks to save, write or export an answer to a file. It
53 if text != "" && !confirmed(tctx, "save_answer", title) {53 if text != "" && !confirmed(tctx, "save_answer", title) {
54 return refuse(d, "save_answer", in.Path, "[save_answer rejected by the user]")54 return refuse(d, "save_answer", in.Path, "[save_answer rejected by the user]")
55 }55 }
56- out, _ := saveAnswer(ui.Resolve(in.Path), in.Path, text, which)56+ out, failed := saveAnswer(ui.Resolve(in.Path), in.Path, text, which)
57 spinner.Suspend(func() { echoOutput(out) })57 spinner.Suspend(func() { echoOutput(out) })
58- return record(d, "save_answer", in.Path, out)58+ return recordOutcome(d, "save_answer", in.Path, out, failed)
59 })59 })
60 }60 }
61 61
@@ -75,12 +75,12 @@ func saveAnswerEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector,
75 if text != "" && !consent.Gate(ctx, s, ev) {75 if text != "" && !consent.Gate(ctx, s, ev) {
76 out := "[save_answer rejected by the user]"76 out := "[save_answer rejected by the user]"
77 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})77 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
78- return record(d, "save_answer", in.Path, out)78+ return recordFailure(d, "save_answer", in.Path, out)
79 }79 }
80 s.ToolRunning(ev.ID)80 s.ToolRunning(ev.ID)
81 out, failed := saveAnswer(path, in.Path, text, which)81 out, failed := saveAnswer(path, in.Path, text, which)
82 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})82 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
83- return record(d, "save_answer", in.Path, out)83+ return recordOutcome(d, "save_answer", in.Path, out, failed)
84 }84 }
85 85
86 // answerToSave picks the text: the reply carrying the call when it has text,86 // answerToSave picks the text: the reply carrying the call when it has text,
modified internal/tools/tools.go +6 -6
@@ -240,7 +240,7 @@ Read before you write: if you do not know a path or the content of a file, run a
240240 // like a hung agent. Retitle for the duration, then hand the line
241241 // back to the "Thinking" wait Suspend restored.
242242 spinner.Start("Running")
243- out, _ := runBash(tctx, "", in.Command)
243+ out, failed := runBash(tctx, "", in.Command)
244244
245245 // The output is shown to the user BEFORE going back to the model:
246246 // what the command answered no longer depends on what the model
@@ -255,7 +255,7 @@ Read before you write: if you do not know a path or the content of a file, run a
255255 // twice in a row, and it hands the model back an instruction to
256256 // change its approach; ignored for too long, it ends the turn
257257 // (see internal/detector and record).
258- return record(d, "bash", in.Command, out)
258+ return recordOutcome(d, "bash", in.Command, out, failed)
259259 })
260260 }
261261
@@ -284,7 +284,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
284284 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
285285 // Recorded like any other outcome: a model that keeps retrying a
286286 // command the user keeps rejecting is looping.
287- return record(d, "bash", in.Command, out)
287+ return recordFailure(d, "bash", in.Command, out)
288288 }
289289
290290 s.ToolRunning(ev.ID)
@@ -293,7 +293,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
293293 out, failed := runBash(ctx, s.WorkDir(), in.Command)
294294 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
295295
296- return record(d, "bash", in.Command, out)
296+ return recordOutcome(d, "bash", in.Command, out, failed)
297297 }
298298
299299 // --- read_skill --------------------------------------------------------------
@@ -344,7 +344,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
344344 // asking four times for the same missing skill is looping,
345345 // exactly as it would on a failing command.
346346 out := skillMiss(in.Name, list)
347- return record(d, "read_skill", in.Name, out)
347+ return recordFailure(d, "read_skill", in.Name, out)
348348 }
349349
350350 // The content is NOT displayed: a skill runs to dozens of lines, it
@@ -387,7 +387,7 @@ func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []sk
387387 if err != nil {
388388 out := skillMiss(in.Name, list)
389389 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
390- return record(d, "read_skill", in.Name, out)
390+ return recordFailure(d, "read_skill", in.Name, out)
391391 }
392392
393393 out := truncate(content)
@@ -240,7 +240,7 @@ Read before you write: if you do not know a path or the content of a file, run a
240 // like a hung agent. Retitle for the duration, then hand the line240 // like a hung agent. Retitle for the duration, then hand the line
241 // back to the "Thinking" wait Suspend restored.241 // back to the "Thinking" wait Suspend restored.
242 spinner.Start("Running")242 spinner.Start("Running")
243- out, _ := runBash(tctx, "", in.Command)243+ out, failed := runBash(tctx, "", in.Command)
244 244
245 // The output is shown to the user BEFORE going back to the model:245 // The output is shown to the user BEFORE going back to the model:
246 // what the command answered no longer depends on what the model246 // what the command answered no longer depends on what the model
@@ -255,7 +255,7 @@ Read before you write: if you do not know a path or the content of a file, run a
255 // twice in a row, and it hands the model back an instruction to255 // twice in a row, and it hands the model back an instruction to
256 // change its approach; ignored for too long, it ends the turn256 // change its approach; ignored for too long, it ends the turn
257 // (see internal/detector and record).257 // (see internal/detector and record).
258- return record(d, "bash", in.Command, out)258+ return recordOutcome(d, "bash", in.Command, out, failed)
259 })259 })
260 }260 }
261 261
@@ -284,7 +284,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
284 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})284 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
285 // Recorded like any other outcome: a model that keeps retrying a285 // Recorded like any other outcome: a model that keeps retrying a
286 // command the user keeps rejecting is looping.286 // command the user keeps rejecting is looping.
287- return record(d, "bash", in.Command, out)287+ return recordFailure(d, "bash", in.Command, out)
288 }288 }
289 289
290 s.ToolRunning(ev.ID)290 s.ToolRunning(ev.ID)
@@ -293,7 +293,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
293 out, failed := runBash(ctx, s.WorkDir(), in.Command)293 out, failed := runBash(ctx, s.WorkDir(), in.Command)
294 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})294 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
295 295
296- return record(d, "bash", in.Command, out)296+ return recordOutcome(d, "bash", in.Command, out, failed)
297 }297 }
298 298
299 // --- read_skill --------------------------------------------------------------299 // --- read_skill --------------------------------------------------------------
@@ -344,7 +344,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe
344 // asking four times for the same missing skill is looping,344 // asking four times for the same missing skill is looping,
345 // exactly as it would on a failing command.345 // exactly as it would on a failing command.
346 out := skillMiss(in.Name, list)346 out := skillMiss(in.Name, list)
347- return record(d, "read_skill", in.Name, out)347+ return recordFailure(d, "read_skill", in.Name, out)
348 }348 }
349 349
350 // The content is NOT displayed: a skill runs to dozens of lines, it350 // The content is NOT displayed: a skill runs to dozens of lines, it
@@ -387,7 +387,7 @@ func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []sk
387 if err != nil {387 if err != nil {
388 out := skillMiss(in.Name, list)388 out := skillMiss(in.Name, list)
389 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})389 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true})
390- return record(d, "read_skill", in.Name, out)390+ return recordFailure(d, "read_skill", in.Name, out)
391 }391 }
392 392
393 out := truncate(content)393 out := truncate(content)
modified internal/tools/unload.go +2 -2
@@ -48,12 +48,12 @@ Returns one line: unloaded, not loaded (nothing to do), or why it could not be d
4848 s.ToolRunning(ev.ID)
4949 out := unload(tctx, in.Model)
5050 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: unloadFailed(out)})
51- return record(d, "unload_model", in.Model, out)
51+ return recordOutcome(d, "unload_model", in.Model, out, unloadFailed(out))
5252 }
5353 announce("🧹 " + title)
5454 out := unload(tctx, in.Model)
5555 spinner.Suspend(func() { echoOutput(out) })
56- return record(d, "unload_model", in.Model, out)
56+ return recordOutcome(d, "unload_model", in.Model, out, unloadFailed(out))
5757 })
5858 }
5959
@@ -48,12 +48,12 @@ Returns one line: unloaded, not loaded (nothing to do), or why it could not be d
48 s.ToolRunning(ev.ID)48 s.ToolRunning(ev.ID)
49 out := unload(tctx, in.Model)49 out := unload(tctx, in.Model)
50 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: unloadFailed(out)})50 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: unloadFailed(out)})
51- return record(d, "unload_model", in.Model, out)51+ return recordOutcome(d, "unload_model", in.Model, out, unloadFailed(out))
52 }52 }
53 announce("🧹 " + title)53 announce("🧹 " + title)
54 out := unload(tctx, in.Model)54 out := unload(tctx, in.Model)
55 spinner.Suspend(func() { echoOutput(out) })55 spinner.Suspend(func() { echoOutput(out) })
56- return record(d, "unload_model", in.Model, out)56+ return recordOutcome(d, "unload_model", in.Model, out, unloadFailed(out))
57 })57 })
58 }58 }
59 59
modified internal/tui/draw.go +7 -1
@@ -171,10 +171,16 @@ func (v *View) drawPermission(p painter) {
171171 p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject [n] never "+v.ask.tool, stBody)
172172 }
173173
174-// drawStatus is the bottom bar: key hints left, version and counters right.
174+// drawStatus is the bottom bar: key hints left, version and counters right
175+// — or, while the model struggles, the struggle badge in its colours in
176+// front of the counters.
175177 func (v *View) drawStatus(p painter) {
176178 p.fill(' ', stBar)
177179 left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"
180+ if v.struggle.text != "" {
181+ v.drawStruggle(p, left)
182+ return
183+ }
178184 right := v.rightBar(len([]rune(left)), p.width())
179185 p.textLimited(0, 0, max(p.width()-len([]rune(right))-2, 0), left, stBar)
180186 if right != "" {
@@ -171,10 +171,16 @@ func (v *View) drawPermission(p painter) {
171 p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject [n] never "+v.ask.tool, stBody)171 p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject [n] never "+v.ask.tool, stBody)
172 }172 }
173 173
174-// drawStatus is the bottom bar: key hints left, version and counters right.174+// drawStatus is the bottom bar: key hints left, version and counters right
175+// — or, while the model struggles, the struggle badge in its colours in
176+// front of the counters.
175 func (v *View) drawStatus(p painter) {177 func (v *View) drawStatus(p painter) {
176 p.fill(' ', stBar)178 p.fill(' ', stBar)
177 left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"179 left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"
180+ if v.struggle.text != "" {
181+ v.drawStruggle(p, left)
182+ return
183+ }
178 right := v.rightBar(len([]rune(left)), p.width())184 right := v.rightBar(len([]rune(left)), p.width())
179 p.textLimited(0, 0, max(p.width()-len([]rune(right))-2, 0), left, stBar)185 p.textLimited(0, 0, max(p.width()-len([]rune(right))-2, 0), left, stBar)
180 if right != "" {186 if right != "" {
modified internal/tui/sink.go +7 -0
@@ -92,6 +92,13 @@ func (k *sink) ToolEnd(id string, res ui.ToolResult) {
9292 k.r.wake()
9393 }
9494
95+// Struggle keeps a sign that the model is struggling for the status bar's
96+// badge (ticket 153), where it stays until the next question.
97+func (k *sink) Struggle(s ui.Struggle) {
98+ k.r.struggles.add(s)
99+ k.r.wake()
100+}
101+
95102 // Display shows what a hook asked to show the user (a decider's answer) as
96103 // its own entry, in its own colour, before the answer streams.
97104 func (k *sink) Display(text string) {
@@ -92,6 +92,13 @@ func (k *sink) ToolEnd(id string, res ui.ToolResult) {
92 k.r.wake()92 k.r.wake()
93 }93 }
94 94
95+// Struggle keeps a sign that the model is struggling for the status bar's
96+// badge (ticket 153), where it stays until the next question.
97+func (k *sink) Struggle(s ui.Struggle) {
98+ k.r.struggles.add(s)
99+ k.r.wake()
100+}
101+
95 // Display shows what a hook asked to show the user (a decider's answer) as102 // Display shows what a hook asked to show the user (a decider's answer) as
96 // its own entry, in its own colour, before the answer streams.103 // its own entry, in its own colour, before the answer streams.
97 func (k *sink) Display(text string) {104 func (k *sink) Display(text string) {
added internal/tui/struggle.go +96 -0
new file mode 100644
@@ -0,0 +1,96 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "sync"
6+
7+ "github.com/gdamore/tcell/v2"
8+
9+ "mm/internal/ui"
10+)
11+
12+// Ticket 153: the signs that the model is struggling (ui.Struggle) are kept
13+// for the turn and shown as a badge on the right of the status bar, in the
14+// place of the version: black on yellow for a warning, white on red when the
15+// turn was stopped. The badge outlives the turn — it says why the turn ended
16+// badly — and goes when the next question is sent.
17+
18+// struggles is what a turn reported, written from the engine's goroutines
19+// and read by the event loop.
20+type struggles struct {
21+ mu sync.Mutex
22+ worst ui.Struggle
23+ count int
24+}
25+
26+// add keeps the worst struggle so far: a severe one beats a warning, and
27+// between two of the same weight the latest wins (it is what is happening
28+// now: "loop: bash ×4" after "×3").
29+func (s *struggles) add(st ui.Struggle) {
30+ s.mu.Lock()
31+ defer s.mu.Unlock()
32+ if s.count == 0 || st.Severe || !s.worst.Severe {
33+ s.worst = st
34+ }
35+ s.count++
36+}
37+
38+// clear forgets the turn's struggles.
39+func (s *struggles) clear() {
40+ s.mu.Lock()
41+ defer s.mu.Unlock()
42+ s.worst, s.count = ui.Struggle{}, 0
43+}
44+
45+// badge is what the status bar draws: the worst struggle, and how many
46+// others the turn had ("⚠ loop: bash ×3 (+1)"); the zero badge when none.
47+func (s *struggles) badge() badge {
48+ s.mu.Lock()
49+ defer s.mu.Unlock()
50+ if s.count == 0 {
51+ return badge{}
52+ }
53+ text := " " + s.worst.String() + " "
54+ if s.count > 1 {
55+ text = fmt.Sprintf(" %s (+%d) ", s.worst.String(), s.count-1)
56+ }
57+ return badge{text: text, severe: s.worst.Severe}
58+}
59+
60+// badge is the struggle as the status bar draws it.
61+type badge struct {
62+ text string // padded with a space on each side; "" = none
63+ severe bool
64+}
65+
66+// style is the badge's colours.
67+func (b badge) style() tcell.Style {
68+ if b.severe {
69+ return stStruggleSevere
70+ }
71+ return stStruggleWarn
72+}
73+
74+// drawStruggle draws the right of the status bar while the model struggles:
75+// the badge, then the counters when they fit too. The key hints keep the
76+// left; the version gives way first, then the counters, then the end of the
77+// badge — never "Ctrl-C quit".
78+func (v *View) drawStruggle(p painter, left string) {
79+ width := p.width()
80+ leftWidth := len([]rune(left))
81+ badgeText := v.struggle.text
82+ counters := ""
83+ if v.status != "" && leftWidth+len([]rune(badgeText))+len([]rune(v.status))+3 <= width {
84+ counters = " " + v.status
85+ }
86+ room := max(width-leftWidth-2, 0)
87+ right := []rune(badgeText + counters)
88+ if len(right) > room {
89+ right = right[:room]
90+ }
91+ x := width - len(right) - 1
92+ p.textLimited(0, 0, max(x-1, 0), left, stBar)
93+ n := min(len([]rune(badgeText)), len(right))
94+ p.text(x, 0, string(right[:n]), v.struggle.style())
95+ p.text(x+n, 0, string(right[n:]), stBar)
96+}
new file mode 100644
@@ -0,0 +1,96 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "sync"
6+
7+ "github.com/gdamore/tcell/v2"
8+
9+ "mm/internal/ui"
10+)
11+
12+// Ticket 153: the signs that the model is struggling (ui.Struggle) are kept
13+// for the turn and shown as a badge on the right of the status bar, in the
14+// place of the version: black on yellow for a warning, white on red when the
15+// turn was stopped. The badge outlives the turn — it says why the turn ended
16+// badly — and goes when the next question is sent.
17+
18+// struggles is what a turn reported, written from the engine's goroutines
19+// and read by the event loop.
20+type struggles struct {
21+ mu sync.Mutex
22+ worst ui.Struggle
23+ count int
24+}
25+
26+// add keeps the worst struggle so far: a severe one beats a warning, and
27+// between two of the same weight the latest wins (it is what is happening
28+// now: "loop: bash ×4" after "×3").
29+func (s *struggles) add(st ui.Struggle) {
30+ s.mu.Lock()
31+ defer s.mu.Unlock()
32+ if s.count == 0 || st.Severe || !s.worst.Severe {
33+ s.worst = st
34+ }
35+ s.count++
36+}
37+
38+// clear forgets the turn's struggles.
39+func (s *struggles) clear() {
40+ s.mu.Lock()
41+ defer s.mu.Unlock()
42+ s.worst, s.count = ui.Struggle{}, 0
43+}
44+
45+// badge is what the status bar draws: the worst struggle, and how many
46+// others the turn had ("⚠ loop: bash ×3 (+1)"); the zero badge when none.
47+func (s *struggles) badge() badge {
48+ s.mu.Lock()
49+ defer s.mu.Unlock()
50+ if s.count == 0 {
51+ return badge{}
52+ }
53+ text := " " + s.worst.String() + " "
54+ if s.count > 1 {
55+ text = fmt.Sprintf(" %s (+%d) ", s.worst.String(), s.count-1)
56+ }
57+ return badge{text: text, severe: s.worst.Severe}
58+}
59+
60+// badge is the struggle as the status bar draws it.
61+type badge struct {
62+ text string // padded with a space on each side; "" = none
63+ severe bool
64+}
65+
66+// style is the badge's colours.
67+func (b badge) style() tcell.Style {
68+ if b.severe {
69+ return stStruggleSevere
70+ }
71+ return stStruggleWarn
72+}
73+
74+// drawStruggle draws the right of the status bar while the model struggles:
75+// the badge, then the counters when they fit too. The key hints keep the
76+// left; the version gives way first, then the counters, then the end of the
77+// badge — never "Ctrl-C quit".
78+func (v *View) drawStruggle(p painter, left string) {
79+ width := p.width()
80+ leftWidth := len([]rune(left))
81+ badgeText := v.struggle.text
82+ counters := ""
83+ if v.status != "" && leftWidth+len([]rune(badgeText))+len([]rune(v.status))+3 <= width {
84+ counters = " " + v.status
85+ }
86+ room := max(width-leftWidth-2, 0)
87+ right := []rune(badgeText + counters)
88+ if len(right) > room {
89+ right = right[:room]
90+ }
91+ x := width - len(right) - 1
92+ p.textLimited(0, 0, max(x-1, 0), left, stBar)
93+ n := min(len([]rune(badgeText)), len(right))
94+ p.text(x, 0, string(right[:n]), v.struggle.style())
95+ p.text(x+n, 0, string(right[n:]), stBar)
96+}
added internal/tui/struggle_test.go +64 -0
new file mode 100644
@@ -0,0 +1,64 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/ui"
8+)
9+
10+// The worst struggle of the turn wins the badge; the others are counted.
11+func TestStrugglesKeepTheWorstAndCountTheRest(t *testing.T) {
12+ var s struggles
13+ if b := s.badge(); b.text != "" {
14+ t.Fatalf("no struggle: badge %+v", b)
15+ }
16+ s.add(ui.Struggle{Label: "loop: bash ×4"})
17+ s.add(ui.Struggle{Severe: true, Label: "stuck: bash ×6"})
18+ s.add(ui.Struggle{Label: "context 85% full"})
19+ if b := s.badge(); b.text != " ✖ stuck: bash ×6 (+2) " || !b.severe {
20+ t.Errorf("badge = %+v", b)
21+ }
22+ s.clear()
23+ s.add(ui.Struggle{Label: "loop: bash ×4"})
24+ s.add(ui.Struggle{Label: "loop: bash ×5"})
25+ if b := s.badge(); b.text != " ⚠ loop: bash ×5 (+1) " || b.severe {
26+ t.Errorf("latest warning: badge = %+v", b)
27+ }
28+}
29+
30+// The badge takes the version's place, in front of the counters, and never
31+// pushes the key hints out.
32+func TestStatusBarShowsTheStruggleBadge(t *testing.T) {
33+ hs := newHarness(t, 100, 14)
34+ hs.v.status, hs.v.version = "2 msgs · 1 tools", "v1.0.1"
35+ hs.v.struggle = badge{text: " ⚠ loop: bash ×3 "}
36+ bar := hs.rows()[13]
37+ if !strings.HasSuffix(bar, "⚠ loop: bash ×3 2 msgs · 1 tools") || strings.Contains(bar, "v1.0.1") {
38+ t.Errorf("bar %q", bar)
39+ }
40+ if !strings.Contains(bar, "Ctrl-C quit") {
41+ t.Errorf("bar lost the way out: %q", bar)
42+ }
43+
44+ narrow := newHarness(t, 64, 14)
45+ narrow.v.status = "2 msgs · 1 tools"
46+ narrow.v.struggle = badge{text: " ✖ stalled ", severe: true}
47+ if bar := narrow.rows()[13]; !strings.HasSuffix(bar, "✖ stalled") || !strings.Contains(bar, "Ctrl-C quit") || strings.Contains(bar, "msgs") {
48+ t.Errorf("narrow bar %q", bar)
49+ }
50+}
51+
52+// The window's sink keeps the struggle for the bar; a new question clears it.
53+func TestSinkStruggleReachesTheBarUntilTheNextQuestion(t *testing.T) {
54+ r := &runner{t: &Transcript{}, wake: func() {}}
55+ k := &sink{r: r}
56+ k.Struggle(ui.Struggle{Severe: true, Label: "max turns reached"})
57+ if b := r.struggles.badge(); b.text != " ✖ max turns reached " {
58+ t.Fatalf("badge = %+v", b)
59+ }
60+ r.struggles.clear()
61+ if b := r.struggles.badge(); b.text != "" {
62+ t.Errorf("after the next question: %+v", b)
63+ }
64+}
new file mode 100644
@@ -0,0 +1,64 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "mm/internal/ui"
8+)
9+
10+// The worst struggle of the turn wins the badge; the others are counted.
11+func TestStrugglesKeepTheWorstAndCountTheRest(t *testing.T) {
12+ var s struggles
13+ if b := s.badge(); b.text != "" {
14+ t.Fatalf("no struggle: badge %+v", b)
15+ }
16+ s.add(ui.Struggle{Label: "loop: bash ×4"})
17+ s.add(ui.Struggle{Severe: true, Label: "stuck: bash ×6"})
18+ s.add(ui.Struggle{Label: "context 85% full"})
19+ if b := s.badge(); b.text != " ✖ stuck: bash ×6 (+2) " || !b.severe {
20+ t.Errorf("badge = %+v", b)
21+ }
22+ s.clear()
23+ s.add(ui.Struggle{Label: "loop: bash ×4"})
24+ s.add(ui.Struggle{Label: "loop: bash ×5"})
25+ if b := s.badge(); b.text != " ⚠ loop: bash ×5 (+1) " || b.severe {
26+ t.Errorf("latest warning: badge = %+v", b)
27+ }
28+}
29+
30+// The badge takes the version's place, in front of the counters, and never
31+// pushes the key hints out.
32+func TestStatusBarShowsTheStruggleBadge(t *testing.T) {
33+ hs := newHarness(t, 100, 14)
34+ hs.v.status, hs.v.version = "2 msgs · 1 tools", "v1.0.1"
35+ hs.v.struggle = badge{text: " ⚠ loop: bash ×3 "}
36+ bar := hs.rows()[13]
37+ if !strings.HasSuffix(bar, "⚠ loop: bash ×3 2 msgs · 1 tools") || strings.Contains(bar, "v1.0.1") {
38+ t.Errorf("bar %q", bar)
39+ }
40+ if !strings.Contains(bar, "Ctrl-C quit") {
41+ t.Errorf("bar lost the way out: %q", bar)
42+ }
43+
44+ narrow := newHarness(t, 64, 14)
45+ narrow.v.status = "2 msgs · 1 tools"
46+ narrow.v.struggle = badge{text: " ✖ stalled ", severe: true}
47+ if bar := narrow.rows()[13]; !strings.HasSuffix(bar, "✖ stalled") || !strings.Contains(bar, "Ctrl-C quit") || strings.Contains(bar, "msgs") {
48+ t.Errorf("narrow bar %q", bar)
49+ }
50+}
51+
52+// The window's sink keeps the struggle for the bar; a new question clears it.
53+func TestSinkStruggleReachesTheBarUntilTheNextQuestion(t *testing.T) {
54+ r := &runner{t: &Transcript{}, wake: func() {}}
55+ k := &sink{r: r}
56+ k.Struggle(ui.Struggle{Severe: true, Label: "max turns reached"})
57+ if b := r.struggles.badge(); b.text != " ✖ max turns reached " {
58+ t.Fatalf("badge = %+v", b)
59+ }
60+ r.struggles.clear()
61+ if b := r.struggles.badge(); b.text != "" {
62+ t.Errorf("after the next question: %+v", b)
63+ }
64+}
modified internal/tui/style.go +7 -2
@@ -25,8 +25,13 @@ var (
2525 stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal)
2626 stThinking = tcell.StyleDefault.Dim(true).Italic(true).Foreground(tcell.ColorPurple)
2727 stDisplay = tcell.StyleDefault.Foreground(tcell.ColorBlue)
28- stScroll = tcell.StyleDefault.Dim(true)
29- stThumb = tcell.StyleDefault.Reverse(true)
28+ // The struggle badge sits in the reversed status bar, so it carries its
29+ // own background: black on yellow for a warning, white on red when the
30+ // turn was stopped.
31+ stStruggleWarn = tcell.StyleDefault.Background(tcell.ColorYellow).Foreground(tcell.ColorBlack).Bold(true)
32+ stStruggleSevere = tcell.StyleDefault.Background(tcell.ColorRed).Foreground(tcell.ColorWhite).Bold(true)
33+ stScroll = tcell.StyleDefault.Dim(true)
34+ stThumb = tcell.StyleDefault.Reverse(true)
3035 )
3136
3237 // styles maps a laid-out line's kind onto a screen style; styleBody is the
@@ -25,8 +25,13 @@ var (
25 stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal)25 stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal)
26 stThinking = tcell.StyleDefault.Dim(true).Italic(true).Foreground(tcell.ColorPurple)26 stThinking = tcell.StyleDefault.Dim(true).Italic(true).Foreground(tcell.ColorPurple)
27 stDisplay = tcell.StyleDefault.Foreground(tcell.ColorBlue)27 stDisplay = tcell.StyleDefault.Foreground(tcell.ColorBlue)
28- stScroll = tcell.StyleDefault.Dim(true)28+ // The struggle badge sits in the reversed status bar, so it carries its
29- stThumb = tcell.StyleDefault.Reverse(true)29+ // own background: black on yellow for a warning, white on red when the
30+ // turn was stopped.
31+ stStruggleWarn = tcell.StyleDefault.Background(tcell.ColorYellow).Foreground(tcell.ColorBlack).Bold(true)
32+ stStruggleSevere = tcell.StyleDefault.Background(tcell.ColorRed).Foreground(tcell.ColorWhite).Bold(true)
33+ stScroll = tcell.StyleDefault.Dim(true)
34+ stThumb = tcell.StyleDefault.Reverse(true)
30 )35 )
31 36
32 // styles maps a laid-out line's kind onto a screen style; styleBody is the37 // styles maps a laid-out line's kind onto a screen style; styleBody is the
modified internal/tui/tui.go +1 -0
@@ -135,6 +135,7 @@ func (a *app) frame() {
135135 a.v.header, a.v.status, a.v.busy, a.v.ask = a.r.header(), a.r.status(), a.r.busyLabel(), a.r.currentAsk()
136136 a.v.syncForm(a.r.currentForm())
137137 a.v.version = a.r.opts.Version
138+ a.v.struggle = a.r.struggles.badge()
138139 a.v.draw()
139140 }
140141
@@ -135,6 +135,7 @@ func (a *app) frame() {
135 a.v.header, a.v.status, a.v.busy, a.v.ask = a.r.header(), a.r.status(), a.r.busyLabel(), a.r.currentAsk()135 a.v.header, a.v.status, a.v.busy, a.v.ask = a.r.header(), a.r.status(), a.r.busyLabel(), a.r.currentAsk()
136 a.v.syncForm(a.r.currentForm())136 a.v.syncForm(a.r.currentForm())
137 a.v.version = a.r.opts.Version137 a.v.version = a.r.opts.Version
138+ a.v.struggle = a.r.struggles.badge()
138 a.v.draw()139 a.v.draw()
139 }140 }
140 141
modified internal/tui/turn.go +6 -0
@@ -141,6 +141,10 @@ type runner struct {
141141 // that did not stream (a test model, or a server answering in one block
142142 // without the retry path) is shown from the response instead.
143143 streamed atomic.Bool
144+
145+ // struggles is what the turn reported about the model struggling
146+ // (ticket 153), for the status bar; cleared when a question is sent.
147+ struggles struggles
144148 }
145149
146150 func newRunner(opts Options, t *Transcript, wake func()) *runner {
@@ -174,6 +178,8 @@ func (r *runner) isBusy() bool { return r.busyLabel() != "" }
174178 // to attach whatever paths it happens to name.
175179 func (r *runner) startTurn(p Prompt) {
176180 r.t.AddUserFull(p.Display(), p.Text())
181+ // A new question, a new turn: the last one's struggles are history.
182+ r.struggles.clear()
177183 expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)
178184 // `$skill` is resolved on the same text and for the same reason as
179185 // `@path`: a pasted block is data the user brought, not a request to run
@@ -141,6 +141,10 @@ type runner struct {
141 // that did not stream (a test model, or a server answering in one block141 // that did not stream (a test model, or a server answering in one block
142 // without the retry path) is shown from the response instead.142 // without the retry path) is shown from the response instead.
143 streamed atomic.Bool143 streamed atomic.Bool
144+
145+ // struggles is what the turn reported about the model struggling
146+ // (ticket 153), for the status bar; cleared when a question is sent.
147+ struggles struggles
144 }148 }
145 149
146 func newRunner(opts Options, t *Transcript, wake func()) *runner {150 func newRunner(opts Options, t *Transcript, wake func()) *runner {
@@ -174,6 +178,8 @@ func (r *runner) isBusy() bool { return r.busyLabel() != "" }
174 // to attach whatever paths it happens to name.178 // to attach whatever paths it happens to name.
175 func (r *runner) startTurn(p Prompt) {179 func (r *runner) startTurn(p Prompt) {
176 r.t.AddUserFull(p.Display(), p.Text())180 r.t.AddUserFull(p.Display(), p.Text())
181+ // A new question, a new turn: the last one's struggles are history.
182+ r.struggles.clear()
177 expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)183 expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)
178 // `$skill` is resolved on the same text and for the same reason as184 // `$skill` is resolved on the same text and for the same reason as
179 // `@path`: a pasted block is data the user brought, not a request to run185 // `@path`: a pasted block is data the user brought, not a request to run
modified internal/tui/view.go +6 -3
@@ -87,9 +87,12 @@ type View struct {
8787 header string // the header line
8888 status string // the counters on the right of the status bar
8989 version string // shown before them while the bar is wide enough
90- busy string // "", "thinking", "compressing", "asking <agent>"
91- ask *permission
92- form *formView // the ask_user form being answered, nil when none
90+ // struggle is the badge in front of the counters (ticket 153): the
91+ // worst struggle of the turn, zero when the model is doing fine.
92+ struggle badge
93+ busy string // "", "thinking", "compressing", "asking <agent>"
94+ ask *permission
95+ form *formView // the ask_user form being answered, nil when none
9396
9497 // input is what has been typed but not sent, as lines. A long paste is
9598 // one placeholder rune in there; pastes holds the text it stands for.
@@ -87,9 +87,12 @@ type View struct {
87 header string // the header line87 header string // the header line
88 status string // the counters on the right of the status bar88 status string // the counters on the right of the status bar
89 version string // shown before them while the bar is wide enough89 version string // shown before them while the bar is wide enough
90- busy string // "", "thinking", "compressing", "asking <agent>"90+ // struggle is the badge in front of the counters (ticket 153): the
91- ask *permission91+ // worst struggle of the turn, zero when the model is doing fine.
92- form *formView // the ask_user form being answered, nil when none92+ struggle badge
93+ busy string // "", "thinking", "compressing", "asking <agent>"
94+ ask *permission
95+ form *formView // the ask_user form being answered, nil when none
93 96
94 // input is what has been typed but not sent, as lines. A long paste is97 // input is what has been typed but not sent, as lines. A long paste is
95 // one placeholder rune in there; pastes holds the text it stands for.98 // one placeholder rune in there; pastes holds the text it stands for.
modified internal/ui/ui.go +62 -0
@@ -184,6 +184,68 @@ func Display(text string) {
184184 _, _ = fmt.Fprintln(Out, text)
185185 }
186186
187+// Struggle is one sign that the model is struggling (ticket 153): a loop,
188+// a broken or runaway tool call, a stalled generation, a tool that does not
189+// exist… Severe means the turn was stopped or failed because of it; the
190+// others are warnings — the turn goes on. Label is short enough for a status
191+// bar: "loop: bash ×3", "stalled".
192+type Struggle struct {
193+ Severe bool
194+ Label string
195+}
196+
197+// Mark is the struggle's sign: ✖ when severe, ⚠ otherwise.
198+func (s Struggle) Mark() string {
199+ if s.Severe {
200+ return "✖"
201+ }
202+ return "⚠"
203+}
204+
205+// String is the struggle as one line: "⚠ loop: bash ×3".
206+func (s Struggle) String() string { return s.Mark() + " " + s.Label }
207+
208+// StruggleSink is the optional side of a Sink that shows struggles itself —
209+// the window keeps the worst of the turn in its status bar, an editor gets a
210+// short line in the agent's message.
211+type StruggleSink interface {
212+ Struggle(s Struggle)
213+}
214+
215+// The terminal's colours for a struggle: yellow for a warning, red when the
216+// turn was stopped.
217+const (
218+ struggleWarnColour = "\033[33m"
219+ struggleSevereColour = "\033[31m"
220+)
221+
222+// ReportStruggle shows a sign that the model is struggling, where the front
223+// end shows such things: the active sink's StruggleSink, or a line on Out —
224+// yellow or red on a terminal, plain in a pipe.
225+//
226+// ui.ReportStruggle(ui.Struggle{Label: "loop: bash ×3"}) // ⚠, yellow
227+// ui.ReportStruggle(ui.Struggle{Severe: true, Label: "stalled"}) // ✖, red
228+func ReportStruggle(s Struggle) {
229+ if k, ok := ActiveSink().(StruggleSink); ok {
230+ k.Struggle(s)
231+ return
232+ }
233+ // On its own line, even right after a streamed answer that did not end
234+ // with one ("🤖 Done.⚠ …" otherwise).
235+ if !spinner.Styled() {
236+ _, _ = fmt.Fprintln(Out, "\n"+s.String())
237+ return
238+ }
239+ colour := struggleWarnColour
240+ if s.Severe {
241+ colour = struggleSevereColour
242+ }
243+ // Never on the spinner's line, either, which the next frame would erase.
244+ spinner.Suspend(func() {
245+ _, _ = fmt.Fprintln(Out, "\n"+colour+s.String()+"\033[0m")
246+ })
247+}
248+
187249 // Speaker is the optional side of a Sink that shows WHICH agent is answering:
188250 // between Speaking and Spoken, what streams through the sink (text, tool
189251 // calls) comes from another agent — the one the ask_agent tool asked — and
@@ -184,6 +184,68 @@ func Display(text string) {
184 _, _ = fmt.Fprintln(Out, text)184 _, _ = fmt.Fprintln(Out, text)
185 }185 }
186 186
187+// Struggle is one sign that the model is struggling (ticket 153): a loop,
188+// a broken or runaway tool call, a stalled generation, a tool that does not
189+// exist… Severe means the turn was stopped or failed because of it; the
190+// others are warnings — the turn goes on. Label is short enough for a status
191+// bar: "loop: bash ×3", "stalled".
192+type Struggle struct {
193+ Severe bool
194+ Label string
195+}
196+
197+// Mark is the struggle's sign: ✖ when severe, ⚠ otherwise.
198+func (s Struggle) Mark() string {
199+ if s.Severe {
200+ return "✖"
201+ }
202+ return "⚠"
203+}
204+
205+// String is the struggle as one line: "⚠ loop: bash ×3".
206+func (s Struggle) String() string { return s.Mark() + " " + s.Label }
207+
208+// StruggleSink is the optional side of a Sink that shows struggles itself —
209+// the window keeps the worst of the turn in its status bar, an editor gets a
210+// short line in the agent's message.
211+type StruggleSink interface {
212+ Struggle(s Struggle)
213+}
214+
215+// The terminal's colours for a struggle: yellow for a warning, red when the
216+// turn was stopped.
217+const (
218+ struggleWarnColour = "\033[33m"
219+ struggleSevereColour = "\033[31m"
220+)
221+
222+// ReportStruggle shows a sign that the model is struggling, where the front
223+// end shows such things: the active sink's StruggleSink, or a line on Out —
224+// yellow or red on a terminal, plain in a pipe.
225+//
226+// ui.ReportStruggle(ui.Struggle{Label: "loop: bash ×3"}) // ⚠, yellow
227+// ui.ReportStruggle(ui.Struggle{Severe: true, Label: "stalled"}) // ✖, red
228+func ReportStruggle(s Struggle) {
229+ if k, ok := ActiveSink().(StruggleSink); ok {
230+ k.Struggle(s)
231+ return
232+ }
233+ // On its own line, even right after a streamed answer that did not end
234+ // with one ("🤖 Done.⚠ …" otherwise).
235+ if !spinner.Styled() {
236+ _, _ = fmt.Fprintln(Out, "\n"+s.String())
237+ return
238+ }
239+ colour := struggleWarnColour
240+ if s.Severe {
241+ colour = struggleSevereColour
242+ }
243+ // Never on the spinner's line, either, which the next frame would erase.
244+ spinner.Suspend(func() {
245+ _, _ = fmt.Fprintln(Out, "\n"+colour+s.String()+"\033[0m")
246+ })
247+}
248+
187 // Speaker is the optional side of a Sink that shows WHICH agent is answering:249 // Speaker is the optional side of a Sink that shows WHICH agent is answering:
188 // between Speaking and Spoken, what streams through the sink (text, tool250 // between Speaking and Spoken, what streams through the sink (text, tool
189 // calls) comes from another agent — the one the ask_agent tool asked — and251 // calls) comes from another agent — the one the ask_agent tool asked — and
modified internal/ui/ui_test.go +32 -0
@@ -116,3 +116,35 @@ func TestDisplayGoesToTheSinkOrToOut(t *testing.T) {
116116 t.Errorf("window saw %v", window.seen)
117117 }
118118 }
119+
120+// strugglingSink is a front end that shows struggles itself.
121+type strugglingSink struct {
122+ stubSink
123+ seen []Struggle
124+}
125+
126+func (s *strugglingSink) Struggle(st Struggle) { s.seen = append(s.seen, st) }
127+
128+// A struggle goes to the sink that can show it, else to Out as one marked
129+// line (plain: the tests' Out is not a terminal).
130+func TestReportStruggleGoesToTheSinkOrToOut(t *testing.T) {
131+ var out strings.Builder
132+ old := Out
133+ Out = &out
134+ defer func() { Out = old }()
135+
136+ SetActive(nil)
137+ ReportStruggle(Struggle{Label: "loop: bash ×3"})
138+ ReportStruggle(Struggle{Severe: true, Label: "stalled"})
139+ window := &strugglingSink{}
140+ SetActive(window)
141+ ReportStruggle(Struggle{Label: "empty answer"})
142+ SetActive(nil)
143+
144+ if got := out.String(); got != "\n⚠ loop: bash ×3\n\n✖ stalled\n" {
145+ t.Errorf("Out = %q", got)
146+ }
147+ if len(window.seen) != 1 || window.seen[0].Label != "empty answer" || window.seen[0].Severe {
148+ t.Errorf("window saw %v", window.seen)
149+ }
150+}
@@ -116,3 +116,35 @@ func TestDisplayGoesToTheSinkOrToOut(t *testing.T) {
116 t.Errorf("window saw %v", window.seen)116 t.Errorf("window saw %v", window.seen)
117 }117 }
118 }118 }
119+
120+// strugglingSink is a front end that shows struggles itself.
121+type strugglingSink struct {
122+ stubSink
123+ seen []Struggle
124+}
125+
126+func (s *strugglingSink) Struggle(st Struggle) { s.seen = append(s.seen, st) }
127+
128+// A struggle goes to the sink that can show it, else to Out as one marked
129+// line (plain: the tests' Out is not a terminal).
130+func TestReportStruggleGoesToTheSinkOrToOut(t *testing.T) {
131+ var out strings.Builder
132+ old := Out
133+ Out = &out
134+ defer func() { Out = old }()
135+
136+ SetActive(nil)
137+ ReportStruggle(Struggle{Label: "loop: bash ×3"})
138+ ReportStruggle(Struggle{Severe: true, Label: "stalled"})
139+ window := &strugglingSink{}
140+ SetActive(window)
141+ ReportStruggle(Struggle{Label: "empty answer"})
142+ SetActive(nil)
143+
144+ if got := out.String(); got != "\n⚠ loop: bash ×3\n\n✖ stalled\n" {
145+ t.Errorf("Out = %q", got)
146+ }
147+ if len(window.seen) != 1 || window.seen[0].Label != "empty answer" || window.seen[0].Severe {
148+ t.Errorf("window saw %v", window.seen)
149+ }
150+}
modified main.go +3 -1
@@ -243,7 +243,9 @@ func main() {
243243 // model that ignored the advice three times will not read it a fourth
244244 // time (seen on 2026-09-18: 30 identical refused edits before the user
245245 // pressed Escape).
246- loopDetector := detector.NewLoopDetector(10, 3).WithStop(3)
246+ // Every flagged repeat is also shown to the user (ticket 153), and so is
247+ // a run of three failed calls or more with different inputs (ticket 160).
248+ loopDetector := detector.NewLoopDetector(10, 3).WithStop(3).WithFailures(3).WithReport(reportLoop)
247249
248250 // A decider answers with its decision model: no tool is ever called.
249251 // Checked before the tools are assembled, so no MCP or language server
@@ -243,7 +243,9 @@ func main() {
243 // model that ignored the advice three times will not read it a fourth243 // model that ignored the advice three times will not read it a fourth
244 // time (seen on 2026-09-18: 30 identical refused edits before the user244 // time (seen on 2026-09-18: 30 identical refused edits before the user
245 // pressed Escape).245 // pressed Escape).
246- loopDetector := detector.NewLoopDetector(10, 3).WithStop(3)246+ // Every flagged repeat is also shown to the user (ticket 153), and so is
247+ // a run of three failed calls or more with different inputs (ticket 160).
248+ loopDetector := detector.NewLoopDetector(10, 3).WithStop(3).WithFailures(3).WithReport(reportLoop)
247 249
248 // A decider answers with its decision model: no tool is ever called.250 // A decider answers with its decision model: no tool is ever called.
249 // Checked before the tools are assembled, so no MCP or language server251 // Checked before the tools are assembled, so no MCP or language server
added struggle_wiring.go +32 -0
new file mode 100644
@@ -0,0 +1,32 @@
1+package main
2+
3+import (
4+ "fmt"
5+
6+ "mm/internal/detector"
7+ "mm/internal/ui"
8+)
9+
10+// reportLoop shows the user what the loop detector already tells the model
11+// (ticket 153): a warning while the model repeats itself, a severe struggle
12+// once the detector stops the turn — and a warning when calls keep failing
13+// with different inputs (ticket 160), which the model is not told about.
14+//
15+// reportLoop(detector.Loop{Tool: "bash", Repeats: 4}) // ⚠ loop: bash ×4
16+// reportLoop(detector.Loop{Tool: "bash", Repeats: 6, Stuck: true}) // ✖ stuck: bash ×6
17+// reportLoop(detector.Loop{Tool: "bash", Repeats: 3, Failing: true}) // ⚠ failing: 3 tool calls in a row
18+func reportLoop(l detector.Loop) {
19+ ui.ReportStruggle(loopStruggle(l))
20+}
21+
22+// loopStruggle is reportLoop's struggle, kept apart so it can be tested
23+// without a front end.
24+func loopStruggle(l detector.Loop) ui.Struggle {
25+ if l.Failing {
26+ return ui.Struggle{Label: fmt.Sprintf("failing: %d tool calls in a row", l.Repeats)}
27+ }
28+ if l.Stuck {
29+ return ui.Struggle{Severe: true, Label: fmt.Sprintf("stuck: %s ×%d", l.Tool, l.Repeats)}
30+ }
31+ return ui.Struggle{Label: fmt.Sprintf("loop: %s ×%d", l.Tool, l.Repeats)}
32+}
new file mode 100644
@@ -0,0 +1,32 @@
1+package main
2+
3+import (
4+ "fmt"
5+
6+ "mm/internal/detector"
7+ "mm/internal/ui"
8+)
9+
10+// reportLoop shows the user what the loop detector already tells the model
11+// (ticket 153): a warning while the model repeats itself, a severe struggle
12+// once the detector stops the turn — and a warning when calls keep failing
13+// with different inputs (ticket 160), which the model is not told about.
14+//
15+// reportLoop(detector.Loop{Tool: "bash", Repeats: 4}) // ⚠ loop: bash ×4
16+// reportLoop(detector.Loop{Tool: "bash", Repeats: 6, Stuck: true}) // ✖ stuck: bash ×6
17+// reportLoop(detector.Loop{Tool: "bash", Repeats: 3, Failing: true}) // ⚠ failing: 3 tool calls in a row
18+func reportLoop(l detector.Loop) {
19+ ui.ReportStruggle(loopStruggle(l))
20+}
21+
22+// loopStruggle is reportLoop's struggle, kept apart so it can be tested
23+// without a front end.
24+func loopStruggle(l detector.Loop) ui.Struggle {
25+ if l.Failing {
26+ return ui.Struggle{Label: fmt.Sprintf("failing: %d tool calls in a row", l.Repeats)}
27+ }
28+ if l.Stuck {
29+ return ui.Struggle{Severe: true, Label: fmt.Sprintf("stuck: %s ×%d", l.Tool, l.Repeats)}
30+ }
31+ return ui.Struggle{Label: fmt.Sprintf("loop: %s ×%d", l.Tool, l.Repeats)}
32+}
added struggle_wiring_test.go +21 -0
new file mode 100644
@@ -0,0 +1,21 @@
1+package main
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/detector"
7+ "mm/internal/ui"
8+)
9+
10+func TestLoopStruggleWarnsThenStops(t *testing.T) {
11+ cases := map[detector.Loop]ui.Struggle{
12+ {Tool: "bash", Repeats: 4}: {Label: "loop: bash ×4"},
13+ {Tool: "edit_file", Repeats: 6, Stuck: true}: {Severe: true, Label: "stuck: edit_file ×6"},
14+ {Tool: "bash", Repeats: 3, Failing: true}: {Label: "failing: 3 tool calls in a row"},
15+ }
16+ for loop, want := range cases {
17+ if got := loopStruggle(loop); got != want {
18+ t.Errorf("loopStruggle(%+v) = %+v, want %+v", loop, got, want)
19+ }
20+ }
21+}
new file mode 100644
@@ -0,0 +1,21 @@
1+package main
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/detector"
7+ "mm/internal/ui"
8+)
9+
10+func TestLoopStruggleWarnsThenStops(t *testing.T) {
11+ cases := map[detector.Loop]ui.Struggle{
12+ {Tool: "bash", Repeats: 4}: {Label: "loop: bash ×4"},
13+ {Tool: "edit_file", Repeats: 6, Stuck: true}: {Severe: true, Label: "stuck: edit_file ×6"},
14+ {Tool: "bash", Repeats: 3, Failing: true}: {Label: "failing: 3 tool calls in a row"},
15+ }
16+ for loop, want := range cases {
17+ if got := loopStruggle(loop); got != want {
18+ t.Errorf("loopStruggle(%+v) = %+v, want %+v", loop, got, want)
19+ }
20+ }
21+}