🛟 Updated.
dfcd2ed parent: 79606ef modified
.tickets/issues/0153-add-colored-warning-about-the-status-of-the-agen.yaml +117 -5 | @@ -1,16 +1,128 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 153 |
| 3 | 3 | title: Add colored warning about the status of the agent (hallucinating, endless loop, ...) |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-25T08:40:29.067Z |
| 9 | -updatedAt: 2026-10-25T08:41:17.587Z | |
| 9 | +updatedAt: 2026-10-25T19:23:53.287Z | |
| 10 | 10 | labels: |
| 11 | 11 | - priority::urgent |
| 12 | 12 | - kind::feature |
| 13 | 13 | 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: 153 | 2 | 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: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-25T08:40:29.067Z | 8 | createdAt: 2026-10-25T08:40:29.067Z |
| 9 | -updatedAt: 2026-10-25T08:41:17.587Z | 9 | +updatedAt: 2026-10-25T19:23:53.287Z |
| 10 | labels: | 10 | labels: |
| 11 | - priority::urgent | 11 | - priority::urgent |
| 12 | - kind::feature | 12 | - kind::feature |
| 13 | milestone: v1.0.1 | 13 | 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 | ||
| 76 | 76 | |
| 77 | 77 | # A sample app is ~6 files + build + test + run + check + fixes. |
| 78 | 78 | maxTurns: 40 |
| 79 | +#maxTurns: 1 | |
| 79 | 80 | |
| 80 | 81 | # go vet / go test failures are short; 8000 characters keep a whole report |
| 81 | 82 | # 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: 40 | 78 | maxTurns: 40 |
| 79 | +#maxTurns: 1 | ||
| 79 | 80 | ||
| 80 | # go vet / go test failures are short; 8000 characters keep a whole report | 81 | # 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) | ||
| 86 | 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 permission |
| 87 | 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 limits |
| 88 | 88 | - [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 | 90 | - [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 | 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 | 92 | - [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 permission | 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 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 limits | 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 limits |
| 88 | - [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text | 88 | - [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 alive | 90 | - [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 it | 92 | - [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 | ||
| 33 | 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 | 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 | 38 | ## Copy text out of the window |
| 37 | 39 | |
| 38 | 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. |
| @@ -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 window | 38 | ## 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/ | ||
| 97 | 97 | | stdout | JSON-RPC 2.0 messages only, one per line. | |
| 98 | 98 | | stderr | Banner, warnings, `[acp] …` trail, SDK logs. | |
| 99 | 99 | | Spinner | Disabled. | |
| 100 | +| Struggles | A `*⚠ mm: …*` paragraph in the agent's message — see [Struggle signals](#struggle-signals). | | |
| 100 | 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 | 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 | 106 | | Aspect | Behaviour | |
| 106 | 107 | |--------|-----------| |
| 108 | +| Struggles | A yellow `⚠` or red `✖` line when it happens — see [Struggle signals](#struggle-signals). | | |
| 107 | 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 | 111 | ## Window mode specifics |
| @@ -123,6 +125,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 123 | 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 | 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 | 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 | 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 | 131 | ## Prompt mode specifics |
| @@ -154,6 +157,33 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 154 | 157 | | Parent process | Watched every 500 ms: when it is gone, the ask is cancelled and the agent stopped. | |
| 155 | 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 | 187 | ## `mm init` — the agent.yaml wizard |
| 158 | 188 | |
| 159 | 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). |
| @@ -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 specifics | 111 | ## 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 specifics | 131 | ## 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 wizard | 187 | ## `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: | ||
| 86 | 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 à permission |
| 87 | 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 connues |
| 88 | 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 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 | 90 | - [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 | 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 | 92 | - [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 à permission | 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 à 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 connues | 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 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 texte | 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 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 vie | 90 | - [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 pas | 92 | - [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 | ||
| 33 | 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 | 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 | 38 | ## Copier du texte depuis la fenêtre |
| 37 | 39 | |
| 38 | 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. |
| @@ -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être | 38 | ## 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/ | ||
| 97 | 97 | | stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. | |
| 98 | 98 | | stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. | |
| 99 | 99 | | Spinner | Désactivé. | |
| 100 | +| Difficultés | Un paragraphe `*⚠ mm: …*` dans le message de l'agent — voir [Signaux de difficulté](#signaux-de-difficulté). | | |
| 100 | 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 | 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 | 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 | 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 | 111 | ## Spécificités du mode fenêtre |
| @@ -123,6 +125,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 123 | 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 | 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 | 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 | 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 | 131 | ## Spécificités du mode invite |
| @@ -154,6 +157,33 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 154 | 157 | | Processus parent | Surveillé toutes les 500 ms : s'il disparaît, la demande est annulée et l'agent arrêté. | |
| 155 | 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 | 187 | ## `mm init` — l'assistant agent.yaml |
| 158 | 188 | |
| 159 | 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). |
| @@ -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être | 111 | ## 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 invite | 131 | ## 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.yaml | 187 | ## `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 @@ | ||
| 1 | 1 | package acp |
| 2 | 2 | |
| 3 | -import "testing" | |
| 3 | +import ( | |
| 4 | + "testing" | |
| 5 | + | |
| 6 | + "mm/internal/ui" | |
| 7 | +) | |
| 4 | 8 | |
| 5 | 9 | // A hook's display reaches the editor as a fenced block before the answer: |
| 6 | 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 | +} | |
| @@ -1,6 +1,10 @@ | |||
| 1 | package acp | 1 | 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) { | ||
| 39 | 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 | 57 | // Display shows what a hook asked to show the user (a decider's answer) |
| 43 | 58 | // before the answer streams. An editor has no colour of ours to give it, so |
| 44 | 59 | // 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, so | 58 | // 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 the | 59 | // 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 { | ||
| 27 | 27 | ToolName string |
| 28 | 28 | Input string |
| 29 | 29 | 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 | 36 | // LoopDetector tracks the history of actions to detect repetitive cycles. |
| @@ -39,6 +43,24 @@ type LoopDetector struct { | ||
| 39 | 43 | stopAfter int |
| 40 | 44 | // flagged counts the consecutive Record calls that returned true. |
| 41 | 45 | 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 | 66 | // 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 | 93 | 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 | 124 | // Record adds an action to the history and returns true if a loop is detected. |
| 75 | 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 | 136 | d.historyMu.Lock() |
| 77 | 137 | defer d.historyMu.Unlock() |
| 78 | 138 | |
| 139 | + if action.Failed { | |
| 140 | + d.failures++ | |
| 141 | + } else { | |
| 142 | + d.failures = 0 | |
| 143 | + } | |
| 144 | + | |
| 79 | 145 | // Check if the current action (tool + input + output) has occurred consecutively. |
| 80 | 146 | count := 0 |
| 81 | 147 | for i := len(d.history) - 1; i >= 0; i-- { |
| @@ -85,7 +151,8 @@ func (d *LoopDetector) Record(action Action) bool { | ||
| 85 | 151 | count++ |
| 86 | 152 | if count >= d.threshold { |
| 87 | 153 | 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 | |
| 89 | 156 | } |
| 90 | 157 | } else { |
| 91 | 158 | break |
| @@ -99,7 +166,10 @@ func (d *LoopDetector) Record(action Action) bool { | ||
| 99 | 166 | d.history = d.history[1:] |
| 100 | 167 | } |
| 101 | 168 | |
| 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 | |
| 103 | 173 | } |
| 104 | 174 | |
| 105 | 175 | // Stuck reports whether the flagged repeats have reached the stop count: |
| @@ -130,6 +200,7 @@ func (d *LoopDetector) Reset() { | ||
| 130 | 200 | defer d.historyMu.Unlock() |
| 131 | 201 | d.history = d.history[:0] |
| 132 | 202 | d.flagged = 0 |
| 203 | + d.failures = 0 | |
| 133 | 204 | } |
| 134 | 205 | |
| 135 | 206 | // LoopError returns a standardized error message when a loop is detected. |
| @@ -27,6 +27,10 @@ type Action struct { | |||
| 27 | ToolName string | 27 | ToolName string |
| 28 | Input string | 28 | Input string |
| 29 | Output string | 29 | 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 int | 43 | stopAfter int |
| 40 | // flagged counts the consecutive Record calls that returned true. | 44 | // flagged counts the consecutive Record calls that returned true. |
| 41 | flagged int | 45 | 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 must | 66 | // 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 d | 93 | 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 := 0 | 146 | 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 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 | ||
| 89 | } | 156 | } |
| 90 | } else { | 157 | } else { |
| 91 | break | 158 | 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 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 | ||
| 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 = 0 | 202 | 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) { | ||
| 100 | 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 | +} | |
| @@ -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 | ||
| 12 | 12 | import ( |
| 13 | 13 | "context" |
| 14 | 14 | "encoding/json" |
| 15 | - "errors" | |
| 16 | 15 | "fmt" |
| 17 | 16 | "maps" |
| 18 | 17 | "slices" |
| @@ -254,10 +253,19 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 254 | 253 | if e.decider != nil { |
| 255 | 254 | return e.generateDecision(ctx, messages) |
| 256 | 255 | } |
| 256 | + var ( | |
| 257 | + resp *ai.ModelResponse | |
| 258 | + full []*ai.Message | |
| 259 | + err error | |
| 260 | + ) | |
| 257 | 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 | 271 | // 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 | 320 | if streamBroken(err) && ctx.Err() == nil { |
| 313 | 321 | spinner.Stop() |
| 314 | 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 | 324 | pl.expect() |
| 316 | 325 | resp, err = next(ctx, params) |
| 317 | 326 | err = withServerMessage(ctx, err) |
| @@ -337,6 +346,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 337 | 346 | spinner.Stop() |
| 338 | 347 | _, _ = fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n", |
| 339 | 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 | 350 | params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated)) |
| 341 | 351 | full = slices.Clone(params.Request.Messages) |
| 342 | 352 | pl.expect() |
| @@ -507,7 +517,7 @@ func (e *Engine) generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 507 | 517 | }), |
| 508 | 518 | )...) |
| 509 | 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 | 523 | // `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 | 537 | if err != nil && stalled { |
| 528 | 538 | // We return `full` despite the failure: if commands ran before the |
| 529 | 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 | 543 | // 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 turn | 271 | // 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 that | 523 | // `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 the | 538 | // 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 { | ||
| 47 | 47 | results []ui.ToolResult |
| 48 | 48 | notices []string |
| 49 | 49 | shown []string |
| 50 | + strugs []ui.Struggle | |
| 50 | 51 | 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 | 58 | s.order = append(s.order, "text") |
| 58 | 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 | 66 | func (s *recordingSink) Display(text string) { |
| 61 | 67 | s.mu.Lock() |
| 62 | 68 | s.shown = append(s.shown, text) |
| @@ -47,6 +47,7 @@ type recordingSink struct { | |||
| 47 | results []ui.ToolResult | 47 | results []ui.ToolResult |
| 48 | notices []string | 48 | notices []string |
| 49 | shown []string | 49 | shown []string |
| 50 | + strugs []ui.Struggle | ||
| 50 | order []string // "display" and "text", in the order they arrived | 51 | 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( | ||
| 314 | 314 | if mustAsk(permissions) && !s.Allow(ctx, ev) { |
| 315 | 315 | out := fmt.Sprintf("[%s rejected by user]", fullName) |
| 316 | 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 | 319 | s.ToolRunning(ev.ID) |
| 320 | 320 | |
| @@ -330,7 +330,7 @@ func toolEvented( | ||
| 330 | 330 | truncated := truncate(out) |
| 331 | 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 | 336 | // toolTerminal handles the tool call for terminal mode. |
| @@ -347,7 +347,7 @@ func toolTerminal( | ||
| 347 | 347 | // and an asking call is refused (consent.Prompt). |
| 348 | 348 | ev := ui.ToolEvent{Tool: fullName, Title: fmt.Sprintf("%s: %s", sc.Server.Name, mcpTool.Name), Input: in} |
| 349 | 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 | 353 | // Call the MCP tool |
| @@ -358,7 +358,7 @@ func toolTerminal( | ||
| 358 | 358 | } |
| 359 | 359 | |
| 360 | 360 | // 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 | 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 | 440 | // record records the tool call in the loop detector and checks for loops. |
| 441 | 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.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) { | |
| 444 | 445 | // Record the action for loop detection |
| 445 | 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 | 448 | return output, nil |
| 448 | 449 | } |
| 449 | 450 | |
| @@ -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 tool | 353 | // Call the MCP tool |
| @@ -358,7 +358,7 @@ func toolTerminal( | |||
| 358 | } | 358 | } |
| 359 | 359 | ||
| 360 | // Truncate and return | 360 | // 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.go | 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) { | 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 detection | 445 | // 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, nil | 448 | 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 | ||
| 93 | 93 | key := in.Agent + ": " + in.Prompt |
| 94 | 94 | ref, ok := config.Cfg.MMConfigs[in.Agent] |
| 95 | 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 | 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 | 101 | transcript := "" |
| 102 | 102 | if in.WithContext { |
| @@ -122,7 +122,7 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err | ||
| 122 | 122 | } |
| 123 | 123 | fmt.Println() |
| 124 | 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 | 128 | // 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 | 142 | if !consent.Gate(ctx, s, ev) { |
| 143 | 143 | out := "[ask_agent rejected by the user]" |
| 144 | 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 | 147 | s.ToolRunning(ev.ID) |
| 148 | 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 | 163 | summary = fmt.Sprintf("%s answered; a hook replaced the answer (%d characters)", in.Agent, len([]rune(res.Answer))) |
| 164 | 164 | } |
| 165 | 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 | 169 | // 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.Prompt | 93 | 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, an | 128 | // 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 on | 169 | // 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 | ||
| 82 | 82 | key := askUserKey(input) |
| 83 | 83 | form, problem := elicit.Normalize(input) |
| 84 | 84 | if problem != "" { |
| 85 | - return record(d, "ask_user", key, problem) | |
| 85 | + return recordFailure(d, "ask_user", key, problem) | |
| 86 | 86 | } |
| 87 | 87 | if s := ui.ActiveSink(); s != nil { |
| 88 | 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 | 90 | // The questions themselves are printed by the prompter, just below. |
| 91 | 91 | announce(fmt.Sprintf("❓ ask_user: %d question(s)", len(form.Questions))) |
| 92 | 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 | 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 | 112 | out := res.Text(form) |
| 113 | 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 | 117 | // 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 it | 117 | // whileWaiting runs ask and beats a watchdog (beat) every second until it |
modified
internal/tools/consent.go +1 -1 | @@ -21,5 +21,5 @@ func confirmed(ctx context.Context, tool, title string) bool { | ||
| 21 | 21 | // the user keeps refusing is looping. |
| 22 | 22 | func refuse(d *detector.LoopDetector, tool, key, out string) (string, error) { |
| 23 | 23 | spinner.Suspend(func() { echoOutput(out) }) |
| 24 | - return record(d, tool, key, out) | |
| 24 | + return recordFailure(d, tool, key, out) | |
| 25 | 25 | } |
| @@ -21,5 +21,5 @@ func confirmed(ctx context.Context, tool, title string) bool { | |||
| 21 | // the user keeps refusing is looping. | 21 | // the user keeps refusing is looping. |
| 22 | func refuse(d *detector.LoopDetector, tool, key, out string) (string, error) { | 22 | func refuse(d *detector.LoopDetector, tool, key, out string) (string, error) { |
| 23 | spinner.Suspend(func() { echoOutput(out) }) | 23 | spinner.Suspend(func() { echoOutput(out) }) |
| 24 | - return record(d, tool, key, out) | 24 | + return recordFailure(d, tool, key, out) |
| 25 | } | 25 | } |
modified
internal/tools/declared.go +5 -5 | @@ -186,10 +186,10 @@ func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string, | ||
| 186 | 186 | return refuse(c.detector, c.tool.Name, j.shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name)) |
| 187 | 187 | } |
| 188 | 188 | spinner.Start("Running") |
| 189 | - out, _ := j.run(ctx, "") | |
| 189 | + out, failed := j.run(ctx, "") | |
| 190 | 190 | spinner.Suspend(func() { echoOutput(out) }) |
| 191 | 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 | 195 | // 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 | 210 | s.ToolStart(ev) |
| 211 | 211 | if problem != "" { |
| 212 | 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 | 215 | if !consent.Gate(ctx, s, ev) { |
| 216 | 216 | out := fmt.Sprintf("[%s rejected by the user]", c.tool.Name) |
| 217 | 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 | 220 | s.ToolRunning(ev.ID) |
| 221 | 221 | // The session's directory, as for bash: in an editor, the project. |
| 222 | 222 | out, failed := j.run(ctx, s.WorkDir()) |
| 223 | 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 | 227 | // 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): a | 195 | // 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 | ||
| 75 | 75 | // A page can take seconds to come: same reasoning as bash's |
| 76 | 76 | // "Running" label, a slow site must not look like a hung agent. |
| 77 | 77 | spinner.Start("Fetching") |
| 78 | - out, _ := fetchContent(tctx, f, in, ui.Resolve) | |
| 78 | + out, failed := fetchContent(tctx, f, in, ui.Resolve) | |
| 79 | 79 | out = truncate(out) |
| 80 | 80 | // With `output`, out is the confirmation line: the page itself is |
| 81 | 81 | // not echoed, which is what "save it, don't show it" asks for. |
| 82 | 82 | spinner.Suspend(func() { echoOutput(out) }) |
| 83 | 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 | 110 | if in.Output != "" && !consent.Gate(ctx, s, ev) { |
| 111 | 111 | out := "[fetch_content rejected by the user]" |
| 112 | 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 | 115 | s.ToolRunning(ev.ID) |
| 116 | 116 | |
| 117 | 117 | out, failed := fetchContent(ctx, call.fetcher, in, ui.Resolve) |
| 118 | 118 | out = truncate(out) |
| 119 | 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 | 123 | // 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's | 75 | // 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 is | 80 | // 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 the | 123 | // 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) { | ||
| 58 | 58 | // model going in circles, exactly like a failing command. When the model has |
| 59 | 59 | // ignored that instruction as many times as the detector tolerates, the turn |
| 60 | 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 | 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 | 78 | return out, nil |
| 64 | 79 | } |
| 65 | 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 | 112 | // Information, not a crash: the message says how to fix the call. |
| 98 | 113 | out := err.Error() |
| 99 | 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 | 117 | out := truncate(content) |
| 103 | 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 | 148 | if err != nil { |
| 134 | 149 | out := err.Error() |
| 135 | 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 | 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 | 190 | if err != nil { |
| 176 | 191 | out := err.Error() |
| 177 | 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 | 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 | 245 | if err != nil { |
| 231 | 246 | out := err.Error() |
| 232 | 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 | 250 | out := truncate(content) |
| 236 | 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 | 266 | if !consent.Gate(ctx, s, ev) { |
| 252 | 267 | out := "[write_file rejected by the user]" |
| 253 | 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 | 271 | s.ToolRunning(ev.ID) |
| 257 | 272 | |
| @@ -263,7 +278,7 @@ func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, | ||
| 263 | 278 | if err != nil { |
| 264 | 279 | out := err.Error() |
| 265 | 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 | 283 | out := resultText(r) |
| 269 | 284 | res := ui.ToolResult{Output: out} |
| @@ -288,7 +303,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i | ||
| 288 | 303 | if !consent.Gate(ctx, s, ev) { |
| 289 | 304 | out := "[edit_file rejected by the user]" |
| 290 | 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 | 308 | s.ToolRunning(ev.ID) |
| 294 | 309 | |
| @@ -301,7 +316,7 @@ func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, i | ||
| 301 | 316 | // model — which reads the rule and fixes its `old`. |
| 302 | 317 | out := err.Error() |
| 303 | 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 | 321 | out := resultText(r) |
| 307 | 322 | 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 has | 58 | // 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 turn | 59 | // 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, nil | 78 | 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 | ||
| 84 | 84 | return refuse(d, "lsp", what, "[rename rejected by the user]") |
| 85 | 85 | } |
| 86 | 86 | spinner.Start("Asking the language server") |
| 87 | - out, _ := runLSP(tctx, client, in) | |
| 87 | + out, failed := runLSP(tctx, client, in) | |
| 88 | 88 | spinner.Suspend(func() { echoOutput(out) }) |
| 89 | 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 | 288 | if in.Action == lspRename && !consent.Gate(ctx, s, ev) { |
| 289 | 289 | out := "[rename rejected by the user]" |
| 290 | 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 | 293 | s.ToolRunning(ev.ID) |
| 294 | 294 | out, failed := runLSP(ctx, client, in) |
| 295 | 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 | } |
| @@ -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.` | ||
| 494 | 494 | |
| 495 | 495 | // Record in loop detector |
| 496 | 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 | 547 | out := "[plan: command rejected by the user]" |
| 548 | 548 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 549 | 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 | 556 | result := runPlanAction(in, false) |
| 557 | 557 | |
| 558 | - // Check if failed | |
| 559 | - failed := strings.HasPrefix(result, "[error:") | |
| 558 | + failed := planFailed(result) | |
| 560 | 559 | |
| 561 | 560 | s.ToolEnd(ev.ID, ui.ToolResult{Output: result, Failed: failed}) |
| 562 | 561 | // 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 | 569 | // Record in loop detector |
| 571 | 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 | 578 | // ClearPlans forgets the plans of a conversation: "" for the line mode and |
| 576 | 579 | // the window, the session id over ACP (ui.ConversationID). Plans are not |
| 577 | 580 | // 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 detector | 495 | // 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 failed | 558 | + 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, and | 561 | // 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 detector | 569 | // 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 and | 578 | // ClearPlans forgets the plans of a conversation: "" for the line mode and |
| 576 | // the window, the session id over ACP (ui.ConversationID). Plans are not | 579 | // 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 the | 580 | // 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 | ||
| 53 | 53 | if text != "" && !confirmed(tctx, "save_answer", title) { |
| 54 | 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 | 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 | 75 | if text != "" && !consent.Gate(ctx, s, ev) { |
| 76 | 76 | out := "[save_answer rejected by the user]" |
| 77 | 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 | 80 | s.ToolRunning(ev.ID) |
| 81 | 81 | out, failed := saveAnswer(path, in.Path, text, which) |
| 82 | 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 | 86 | // 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 | ||
| 240 | 240 | // like a hung agent. Retitle for the duration, then hand the line |
| 241 | 241 | // back to the "Thinking" wait Suspend restored. |
| 242 | 242 | spinner.Start("Running") |
| 243 | - out, _ := runBash(tctx, "", in.Command) | |
| 243 | + out, failed := runBash(tctx, "", in.Command) | |
| 244 | 244 | |
| 245 | 245 | // The output is shown to the user BEFORE going back to the model: |
| 246 | 246 | // 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 | 255 | // twice in a row, and it hands the model back an instruction to |
| 256 | 256 | // change its approach; ignored for too long, it ends the turn |
| 257 | 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 | 284 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) |
| 285 | 285 | // Recorded like any other outcome: a model that keeps retrying a |
| 286 | 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 | 290 | s.ToolRunning(ev.ID) |
| @@ -293,7 +293,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba | ||
| 293 | 293 | out, failed := runBash(ctx, s.WorkDir(), in.Command) |
| 294 | 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 | 299 | // --- read_skill -------------------------------------------------------------- |
| @@ -344,7 +344,7 @@ func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRe | ||
| 344 | 344 | // asking four times for the same missing skill is looping, |
| 345 | 345 | // exactly as it would on a failing command. |
| 346 | 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 | 350 | // 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 | 387 | if err != nil { |
| 388 | 388 | out := skillMiss(in.Name, list) |
| 389 | 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 | 393 | 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 line | 240 | // 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 model | 246 | // 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 to | 255 | // twice in a row, and it hands the model back an instruction to |
| 256 | // change its approach; ignored for too long, it ends the turn | 256 | // 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 a | 285 | // 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, it | 350 | // 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 | ||
| 48 | 48 | s.ToolRunning(ev.ID) |
| 49 | 49 | out := unload(tctx, in.Model) |
| 50 | 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 | 53 | announce("🧹 " + title) |
| 54 | 54 | out := unload(tctx, in.Model) |
| 55 | 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 | |
| @@ -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) { | ||
| 171 | 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 | 177 | func (v *View) drawStatus(p painter) { |
| 176 | 178 | p.fill(' ', stBar) |
| 177 | 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 | 184 | right := v.rightBar(len([]rune(left)), p.width()) |
| 179 | 185 | p.textLimited(0, 0, max(p.width()-len([]rune(right))-2, 0), left, stBar) |
| 180 | 186 | 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) { | ||
| 92 | 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 | 102 | // Display shows what a hook asked to show the user (a decider's answer) as |
| 96 | 103 | // its own entry, in its own colour, before the answer streams. |
| 97 | 104 | 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) as | 102 | // 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 ( | ||
| 25 | 25 | stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal) |
| 26 | 26 | stThinking = tcell.StyleDefault.Dim(true).Italic(true).Foreground(tcell.ColorPurple) |
| 27 | 27 | 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) | |
| 30 | 35 | ) |
| 31 | 36 | |
| 32 | 37 | // 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 the | 37 | // 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() { | ||
| 135 | 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 | 136 | a.v.syncForm(a.r.currentForm()) |
| 137 | 137 | a.v.version = a.r.opts.Version |
| 138 | + a.v.struggle = a.r.struggles.badge() | |
| 138 | 139 | a.v.draw() |
| 139 | 140 | } |
| 140 | 141 | |
| @@ -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.Version | 137 | 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 { | ||
| 141 | 141 | // that did not stream (a test model, or a server answering in one block |
| 142 | 142 | // without the retry path) is shown from the response instead. |
| 143 | 143 | 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 | 150 | func newRunner(opts Options, t *Transcript, wake func()) *runner { |
| @@ -174,6 +178,8 @@ func (r *runner) isBusy() bool { return r.busyLabel() != "" } | ||
| 174 | 178 | // to attach whatever paths it happens to name. |
| 175 | 179 | func (r *runner) startTurn(p Prompt) { |
| 176 | 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 | 183 | expanded, attached := mention.Expand(p.Typed, r.opts.Cwd) |
| 178 | 184 | // `$skill` is resolved on the same text and for the same reason as |
| 179 | 185 | // `@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 block | 141 | // 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.Bool | 143 | 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 as | 184 | // `$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 run | 185 | // `@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 { | ||
| 87 | 87 | header string // the header line |
| 88 | 88 | status string // the counters on the right of the status bar |
| 89 | 89 | 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 | |
| 93 | 96 | |
| 94 | 97 | // input is what has been typed but not sent, as lines. A long paste is |
| 95 | 98 | // one placeholder rune in there; pastes holds the text it stands for. |
| @@ -87,9 +87,12 @@ type View struct { | |||
| 87 | header string // the header line | 87 | header string // the header line |
| 88 | status string // the counters on the right of the status bar | 88 | status string // the counters on the right of the status bar |
| 89 | version string // shown before them while the bar is wide enough | 89 | 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 *permission | 91 | + // worst struggle of the turn, zero when the model is doing fine. |
| 92 | - form *formView // the ask_user form being answered, nil when none | 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 | ||
| 93 | 96 | ||
| 94 | // input is what has been typed but not sent, as lines. A long paste is | 97 | // 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) { | ||
| 184 | 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 | 249 | // Speaker is the optional side of a Sink that shows WHICH agent is answering: |
| 188 | 250 | // between Speaking and Spoken, what streams through the sink (text, tool |
| 189 | 251 | // 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, tool | 250 | // between Speaking and Spoken, what streams through the sink (text, tool |
| 189 | // calls) comes from another agent — the one the ask_agent tool asked — and | 251 | // 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) { | ||
| 116 | 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 | +} | |
| @@ -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() { | ||
| 243 | 243 | // model that ignored the advice three times will not read it a fourth |
| 244 | 244 | // time (seen on 2026-09-18: 30 identical refused edits before the user |
| 245 | 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 | 250 | // A decider answers with its decision model: no tool is ever called. |
| 249 | 251 | // 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 fourth | 243 | // 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 user | 244 | // 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 server | 251 | // 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 | +} | ||