🛟 Updated.
be613a4 parent: d7d82e6 added
.tickets/issues/0046-add-builtin-tools-as-plugins-with-wasm.yaml +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 46 | |
| 3 | +title: Add builtin tools as plugins with wasm | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-27T02:30:34.975Z | |
| 9 | +updatedAt: 2026-09-27T03:45:08.358Z | |
| 10 | +labels: [] | |
| 11 | +body: | | |
| 12 | + example: safe / unsafe with wasm -> LlamaGuard | |
| 13 | + | |
| 14 | + location: like the skills in a tools directory | |
| 15 | +tasks: [] | |
| 16 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 46 | ||
| 3 | +title: Add builtin tools as plugins with wasm | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-27T02:30:34.975Z | ||
| 9 | +updatedAt: 2026-09-27T03:45:08.358Z | ||
| 10 | +labels: [] | ||
| 11 | +body: | | ||
| 12 | + example: safe / unsafe with wasm -> LlamaGuard | ||
| 13 | + | ||
| 14 | + location: like the skills in a tools directory | ||
| 15 | +tasks: [] | ||
| 16 | +comments: [] | ||
added
.tickets/issues/0047-builtin-web-search-tool.yaml +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 47 | |
| 3 | +title: Builtin web search tool | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-27T03:25:00.207Z | |
| 9 | +updatedAt: 2026-09-27T03:44:35.159Z | |
| 10 | +labels: [] | |
| 11 | +body: | | |
| 12 | + Or create a CLI for this (search if the CLI exists) | |
| 13 | + | |
| 14 | + Create a wasm plugin to search with duck duck go | |
| 15 | +tasks: [] | |
| 16 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 47 | ||
| 3 | +title: Builtin web search tool | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-27T03:25:00.207Z | ||
| 9 | +updatedAt: 2026-09-27T03:44:35.159Z | ||
| 10 | +labels: [] | ||
| 11 | +body: | | ||
| 12 | + Or create a CLI for this (search if the CLI exists) | ||
| 13 | + | ||
| 14 | + Create a wasm plugin to search with duck duck go | ||
| 15 | +tasks: [] | ||
| 16 | +comments: [] | ||
added
.tickets/issues/0048-add-a-builtin-tool-to-search-into-the-mm-session.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 48 | |
| 3 | +title: Add a builtin tool to search into the mm sessions | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-27T03:45:48.121Z | |
| 9 | +updatedAt: 2026-09-27T03:45:48.121Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 48 | ||
| 3 | +title: Add a builtin tool to search into the mm sessions | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-27T03:45:48.121Z | ||
| 9 | +updatedAt: 2026-09-27T03:45:48.121Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
modified
agent.dmr.yaml +2 -1 | @@ -163,7 +163,8 @@ context: | ||
| 163 | 163 | # The model works on the last few turns, so summarising them would make it redo what it just did. |
| 164 | 164 | keepLastTurns: 3 |
| 165 | 165 | |
| 166 | - # max_tokens of the summary request. | |
| 166 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider | |
| 167 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | |
| 167 | 168 | summaryMaxTokens: 1200 |
| 168 | 169 | |
| 169 | 170 | # Replaces the built-in summary prompt when set. |
| @@ -163,7 +163,8 @@ context: | |||
| 163 | # The model works on the last few turns, so summarising them would make it redo what it just did. | 163 | # The model works on the last few turns, so summarising them would make it redo what it just did. |
| 164 | keepLastTurns: 3 | 164 | keepLastTurns: 3 |
| 165 | 165 | ||
| 166 | - # max_tokens of the summary request. | 166 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider |
| 167 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | ||
| 167 | summaryMaxTokens: 1200 | 168 | summaryMaxTokens: 1200 |
| 168 | 169 | ||
| 169 | # Replaces the built-in summary prompt when set. | 170 | # Replaces the built-in summary prompt when set. |
modified
agent.llamacpp.yaml +2 -1 | @@ -184,7 +184,8 @@ context: | ||
| 184 | 184 | # The model works on the last few turns, so summarising them would make it redo what it just did. |
| 185 | 185 | keepLastTurns: 3 |
| 186 | 186 | |
| 187 | - # max_tokens of the summary request. | |
| 187 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider | |
| 188 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | |
| 188 | 189 | summaryMaxTokens: 1200 |
| 189 | 190 | |
| 190 | 191 | showStats: true |
| @@ -184,7 +184,8 @@ context: | |||
| 184 | # The model works on the last few turns, so summarising them would make it redo what it just did. | 184 | # The model works on the last few turns, so summarising them would make it redo what it just did. |
| 185 | keepLastTurns: 3 | 185 | keepLastTurns: 3 |
| 186 | 186 | ||
| 187 | - # max_tokens of the summary request. | 187 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider |
| 188 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | ||
| 188 | summaryMaxTokens: 1200 | 189 | summaryMaxTokens: 1200 |
| 189 | 190 | ||
| 190 | showStats: true | 191 | showStats: true |
modified
docs/en/explanation/persistent-sessions.md +1 -1 | @@ -30,7 +30,7 @@ In the terminal `/new` simply leaves the old file alone and lets the next questi | ||
| 30 | 30 | |
| 31 | 31 | ## Why errors are recorded next to the history, not in it |
| 32 | 32 | |
| 33 | -A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an `errors` array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: `generation` (a failed answer), `compaction` (a failed summary, automatic or `/compact`) and `resume` (a `/resume` that could not load its session). A cancelled turn is not an error and is not recorded. | |
| 33 | +A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an `errors` array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: `generation` (a failed answer), `compaction` (a failed summary, automatic or `/compact`) and `resume` (a `/resume` that could not load its session). A cancelled turn is not an error and is not recorded. Since 2026-09-27 each record also names the `model` that failed, and the file the `provider` and `model` it was last written with: the first thing an investigation needs, and the one it could not recover — a summary that comes back empty means nothing until you know the model reasons before it writes. | |
| 34 | 34 | |
| 35 | 35 | The array sits beside `messages`, not inside it, on purpose. The model never reads it: an error is about the run, not about the project, and feeding it back would make the model explain its infrastructure instead of doing its task. `/resume` and `session/load` never replay it: both read `messages` alone, so an old session loads exactly as before and a new one with errors loads as if the field were not there. And compression leaves it alone: `/compact` rewrites `messages`, the trail stays whole. The record follows its conversation: over ACP, `/new` moves it to the archived file with the history it belongs to. |
| 36 | 36 | |
| @@ -30,7 +30,7 @@ In the terminal `/new` simply leaves the old file alone and lets the next questi | |||
| 30 | 30 | ||
| 31 | ## Why errors are recorded next to the history, not in it | 31 | ## Why errors are recorded next to the history, not in it |
| 32 | 32 | ||
| 33 | -A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an `errors` array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: `generation` (a failed answer), `compaction` (a failed summary, automatic or `/compact`) and `resume` (a `/resume` that could not load its session). A cancelled turn is not an error and is not recorded. | 33 | +A turn can fail — the server is down, the model is not pulled, the watchdog gave up, the summary of a compaction came back empty — and the front end says so in one red line. That line was the only trace: gone with the scrollback, absent from the file, because the file holds what the model sees and a failed turn leaves nothing for the model to see (the unanswered question is dropped). Since 2026-09-18 the session file also carries an `errors` array: one record per line shown, with the time, the turn count, the first line of the question being answered, the line as shown, and the raw error when it says more. Three kinds are recorded: `generation` (a failed answer), `compaction` (a failed summary, automatic or `/compact`) and `resume` (a `/resume` that could not load its session). A cancelled turn is not an error and is not recorded. Since 2026-09-27 each record also names the `model` that failed, and the file the `provider` and `model` it was last written with: the first thing an investigation needs, and the one it could not recover — a summary that comes back empty means nothing until you know the model reasons before it writes. |
| 34 | 34 | ||
| 35 | The array sits beside `messages`, not inside it, on purpose. The model never reads it: an error is about the run, not about the project, and feeding it back would make the model explain its infrastructure instead of doing its task. `/resume` and `session/load` never replay it: both read `messages` alone, so an old session loads exactly as before and a new one with errors loads as if the field were not there. And compression leaves it alone: `/compact` rewrites `messages`, the trail stays whole. The record follows its conversation: over ACP, `/new` moves it to the archived file with the history it belongs to. | 35 | The array sits beside `messages`, not inside it, on purpose. The model never reads it: an error is about the run, not about the project, and feeding it back would make the model explain its infrastructure instead of doing its task. `/resume` and `session/load` never replay it: both read `messages` alone, so an old session loads exactly as before and a new one with errors loads as if the field were not there. And compression leaves it alone: `/compact` rewrites `messages`, the trail stays whole. The record follows its conversation: over ACP, `/new` moves it to the archived file with the history it belongs to. |
| 36 | 36 | ||
modified
docs/en/how-to/keep-sessions.md +3 -2 | @@ -54,7 +54,7 @@ This guide shows how to make `mm` save every conversation of a project and get o | ||
| 54 | 54 | 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s) |
| 55 | 55 | ``` |
| 56 | 56 | |
| 57 | -2. Read the `errors` array of its file. Each record says when, at which turn, on which question, what the screen showed (`message`) and, when the raw error says more, what it was (`detail`); `where` is `generation`, `compaction` or `resume`: | |
| 57 | +2. Read the `errors` array of its file. Each record says when, at which turn, on which question, what the screen showed (`message`), when the raw error says more, what it was (`detail`), and which `model` failed; `where` is `generation`, `compaction` or `resume`: | |
| 58 | 58 | |
| 59 | 59 | ```bash |
| 60 | 60 | jq .errors .mm/sessions/20260918-141225-02790c82.json |
| @@ -68,7 +68,8 @@ This guide shows how to make `mm` save every conversation of a project and get o | ||
| 68 | 68 | "where": "generation", |
| 69 | 69 | "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none", |
| 70 | 70 | "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found", |
| 71 | - "question": "hello" | |
| 71 | + "question": "hello", | |
| 72 | + "model": "no/such-model:none" | |
| 72 | 73 | } |
| 73 | 74 | ] |
| 74 | 75 | ``` |
| @@ -54,7 +54,7 @@ This guide shows how to make `mm` save every conversation of a project and get o | |||
| 54 | 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s) | 54 | 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s) |
| 55 | ``` | 55 | ``` |
| 56 | 56 | ||
| 57 | -2. Read the `errors` array of its file. Each record says when, at which turn, on which question, what the screen showed (`message`) and, when the raw error says more, what it was (`detail`); `where` is `generation`, `compaction` or `resume`: | 57 | +2. Read the `errors` array of its file. Each record says when, at which turn, on which question, what the screen showed (`message`), when the raw error says more, what it was (`detail`), and which `model` failed; `where` is `generation`, `compaction` or `resume`: |
| 58 | 58 | ||
| 59 | ```bash | 59 | ```bash |
| 60 | jq .errors .mm/sessions/20260918-141225-02790c82.json | 60 | jq .errors .mm/sessions/20260918-141225-02790c82.json |
| @@ -68,7 +68,8 @@ This guide shows how to make `mm` save every conversation of a project and get o | |||
| 68 | "where": "generation", | 68 | "where": "generation", |
| 69 | "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none", | 69 | "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none", |
| 70 | "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found", | 70 | "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found", |
| 71 | - "question": "hello" | 71 | + "question": "hello", |
| 72 | + "model": "no/such-model:none" | ||
| 72 | } | 73 | } |
| 73 | ] | 74 | ] |
| 74 | ``` | 75 | ``` |
modified
docs/en/reference/configuration.md +1 -1 | @@ -39,7 +39,7 @@ | ||
| 39 | 39 | | `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. | |
| 40 | 40 | | `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. | |
| 41 | 41 | | `keepLastTurns` | int | `3` | Question turns kept raw at the end of the history. Must be ≥ 1. | |
| 42 | -| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. | | |
| 42 | +| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. A thinking model's reasoning counts against it: on `dmr` and `llamacpp` the summary request turns reasoning off (`chat_template_kwargs.enable_thinking: false`); elsewhere, if the error says the model "reasoned until the limit", raise it. | | |
| 43 | 43 | | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. | |
| 44 | 44 | | `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. | |
| 45 | 45 | |
| @@ -39,7 +39,7 @@ | |||
| 39 | | `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. | | 39 | | `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. | |
| 40 | | `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. | | 40 | | `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. | |
| 41 | | `keepLastTurns` | int | `3` | Question turns kept raw at the end of the history. Must be ≥ 1. | | 41 | | `keepLastTurns` | int | `3` | Question turns kept raw at the end of the history. Must be ≥ 1. | |
| 42 | -| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. | | 42 | +| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. A thinking model's reasoning counts against it: on `dmr` and `llamacpp` the summary request turns reasoning off (`chat_template_kwargs.enable_thinking: false`); elsewhere, if the error says the model "reasoned until the limit", raise it. | |
| 43 | | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. | | 43 | | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. | |
| 44 | | `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. | | 44 | | `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. | |
| 45 | 45 | ||
modified
docs/fr/explanation/persistent-sessions.md +1 -1 | @@ -30,7 +30,7 @@ Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la quest | ||
| 30 | 30 | |
| 31 | 31 | ## Pourquoi les erreurs sont enregistrées à côté de l'historique, pas dedans |
| 32 | 32 | |
| 33 | -Un tour peut échouer — serveur arrêté, modèle non téléchargé, chien de garde qui renonce, résumé de compression revenu vide — et la façade le dit en une ligne rouge. Cette ligne était la seule trace : partie avec le défilement, absente du fichier, parce que le fichier contient ce que voit le modèle et qu'un tour raté ne lui laisse rien à voir (la question sans réponse est retirée). Depuis le 18 septembre 2026, le fichier de session porte aussi un tableau `errors` : un enregistrement par ligne affichée, avec l'heure, le nombre de tours, la première ligne de la question en cours, la ligne telle qu'affichée, et l'erreur brute quand elle en dit plus. Trois sortes sont enregistrées : `generation` (une réponse échouée), `compaction` (un résumé échoué, automatique ou `/compact`) et `resume` (un `/resume` qui n'a pas pu charger sa session). Un tour annulé n'est pas une erreur et n'est pas enregistré. | |
| 33 | +Un tour peut échouer — serveur arrêté, modèle non téléchargé, chien de garde qui renonce, résumé de compression revenu vide — et la façade le dit en une ligne rouge. Cette ligne était la seule trace : partie avec le défilement, absente du fichier, parce que le fichier contient ce que voit le modèle et qu'un tour raté ne lui laisse rien à voir (la question sans réponse est retirée). Depuis le 18 septembre 2026, le fichier de session porte aussi un tableau `errors` : un enregistrement par ligne affichée, avec l'heure, le nombre de tours, la première ligne de la question en cours, la ligne telle qu'affichée, et l'erreur brute quand elle en dit plus. Trois sortes sont enregistrées : `generation` (une réponse échouée), `compaction` (un résumé échoué, automatique ou `/compact`) et `resume` (un `/resume` qui n'a pas pu charger sa session). Un tour annulé n'est pas une erreur et n'est pas enregistré. Depuis le 2026-09-27, chaque enregistrement nomme aussi le `model` qui a échoué, et le fichier le `provider` et le `model` avec lesquels il a été écrit en dernier : la première chose dont une enquête a besoin, et la seule qu'elle ne pouvait pas retrouver — un résumé revenu vide ne dit rien tant qu'on ignore que le modèle réfléchit avant d'écrire. | |
| 34 | 34 | |
| 35 | 35 | Le tableau est à côté de `messages`, pas dedans, à dessein. Le modèle ne le lit jamais : une erreur concerne l'exécution, pas le projet, et la lui renvoyer lui ferait expliquer son infrastructure au lieu de faire sa tâche. `/resume` et `session/load` ne le rejouent jamais : tous deux ne lisent que `messages`, donc une vieille session se charge exactement comme avant et une nouvelle avec des erreurs se charge comme si le champ n'existait pas. Et la compression n'y touche pas : `/compact` réécrit `messages`, la trace reste entière. L'enregistrement suit sa conversation : en ACP, `/new` le déplace dans le fichier archivé avec l'historique auquel il appartient. |
| 36 | 36 | |
| @@ -30,7 +30,7 @@ Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la quest | |||
| 30 | 30 | ||
| 31 | ## Pourquoi les erreurs sont enregistrées à côté de l'historique, pas dedans | 31 | ## Pourquoi les erreurs sont enregistrées à côté de l'historique, pas dedans |
| 32 | 32 | ||
| 33 | -Un tour peut échouer — serveur arrêté, modèle non téléchargé, chien de garde qui renonce, résumé de compression revenu vide — et la façade le dit en une ligne rouge. Cette ligne était la seule trace : partie avec le défilement, absente du fichier, parce que le fichier contient ce que voit le modèle et qu'un tour raté ne lui laisse rien à voir (la question sans réponse est retirée). Depuis le 18 septembre 2026, le fichier de session porte aussi un tableau `errors` : un enregistrement par ligne affichée, avec l'heure, le nombre de tours, la première ligne de la question en cours, la ligne telle qu'affichée, et l'erreur brute quand elle en dit plus. Trois sortes sont enregistrées : `generation` (une réponse échouée), `compaction` (un résumé échoué, automatique ou `/compact`) et `resume` (un `/resume` qui n'a pas pu charger sa session). Un tour annulé n'est pas une erreur et n'est pas enregistré. | 33 | +Un tour peut échouer — serveur arrêté, modèle non téléchargé, chien de garde qui renonce, résumé de compression revenu vide — et la façade le dit en une ligne rouge. Cette ligne était la seule trace : partie avec le défilement, absente du fichier, parce que le fichier contient ce que voit le modèle et qu'un tour raté ne lui laisse rien à voir (la question sans réponse est retirée). Depuis le 18 septembre 2026, le fichier de session porte aussi un tableau `errors` : un enregistrement par ligne affichée, avec l'heure, le nombre de tours, la première ligne de la question en cours, la ligne telle qu'affichée, et l'erreur brute quand elle en dit plus. Trois sortes sont enregistrées : `generation` (une réponse échouée), `compaction` (un résumé échoué, automatique ou `/compact`) et `resume` (un `/resume` qui n'a pas pu charger sa session). Un tour annulé n'est pas une erreur et n'est pas enregistré. Depuis le 2026-09-27, chaque enregistrement nomme aussi le `model` qui a échoué, et le fichier le `provider` et le `model` avec lesquels il a été écrit en dernier : la première chose dont une enquête a besoin, et la seule qu'elle ne pouvait pas retrouver — un résumé revenu vide ne dit rien tant qu'on ignore que le modèle réfléchit avant d'écrire. |
| 34 | 34 | ||
| 35 | Le tableau est à côté de `messages`, pas dedans, à dessein. Le modèle ne le lit jamais : une erreur concerne l'exécution, pas le projet, et la lui renvoyer lui ferait expliquer son infrastructure au lieu de faire sa tâche. `/resume` et `session/load` ne le rejouent jamais : tous deux ne lisent que `messages`, donc une vieille session se charge exactement comme avant et une nouvelle avec des erreurs se charge comme si le champ n'existait pas. Et la compression n'y touche pas : `/compact` réécrit `messages`, la trace reste entière. L'enregistrement suit sa conversation : en ACP, `/new` le déplace dans le fichier archivé avec l'historique auquel il appartient. | 35 | Le tableau est à côté de `messages`, pas dedans, à dessein. Le modèle ne le lit jamais : une erreur concerne l'exécution, pas le projet, et la lui renvoyer lui ferait expliquer son infrastructure au lieu de faire sa tâche. `/resume` et `session/load` ne le rejouent jamais : tous deux ne lisent que `messages`, donc une vieille session se charge exactement comme avant et une nouvelle avec des erreurs se charge comme si le champ n'existait pas. Et la compression n'y touche pas : `/compact` réécrit `messages`, la trace reste entière. L'enregistrement suit sa conversation : en ACP, `/new` le déplace dans le fichier archivé avec l'historique auquel il appartient. |
| 36 | 36 | ||
modified
docs/fr/explanation/petits-llms.adoc +1 -0 | @@ -214,6 +214,7 @@ Le greffon OpenAI de Genkit ignore `reasoning_content`. `mm` le lit lui-même, u | ||
| 214 | 214 | |
| 215 | 215 | * un résumé précédent est *fusionné*, pas résumé à nouveau (le détail décroîtrait exponentiellement) ; |
| 216 | 216 | * la requête de résumé est envoyée *sans outils* (un petit modèle à qui l'on offre des outils les appelle au lieu d'écrire) et avec un watchdog deux fois plus patient ; |
| 217 | +* elle est envoyée *sans raisonnement* sur `dmr` et `llamacpp` (`chat_template_kwargs.enable_thinking: false`, depuis le 27/09/2026) : le raisonnement est décompté de `summaryMaxTokens` et n'est pas dans le texte rendu. Constaté avec Qwen3.8-27B sur 18 000 tokens d'historique : trois compressions de suite ont échoué sur « empty summary », le budget entier parti en réflexion ; reproduit avec qwen3.5-2b et `summaryMaxTokens: 300`, corrigé par ce réglage ; | |
| 217 | 218 | * le déclencheur prend le maximum entre l'estimation locale et le compte du serveur, et se replie sur un nombre de messages quand la fenêtre est inconnue ; |
| 218 | 219 | * si la fenêtre est inconnue au démarrage (serveur lancé après l'agent), elle est redemandée paresseusement (`EnsureContextWindow`). |
| 219 | 220 | |
| @@ -214,6 +214,7 @@ Le greffon OpenAI de Genkit ignore `reasoning_content`. `mm` le lit lui-même, u | |||
| 214 | 214 | ||
| 215 | * un résumé précédent est *fusionné*, pas résumé à nouveau (le détail décroîtrait exponentiellement) ; | 215 | * un résumé précédent est *fusionné*, pas résumé à nouveau (le détail décroîtrait exponentiellement) ; |
| 216 | * la requête de résumé est envoyée *sans outils* (un petit modèle à qui l'on offre des outils les appelle au lieu d'écrire) et avec un watchdog deux fois plus patient ; | 216 | * la requête de résumé est envoyée *sans outils* (un petit modèle à qui l'on offre des outils les appelle au lieu d'écrire) et avec un watchdog deux fois plus patient ; |
| 217 | +* elle est envoyée *sans raisonnement* sur `dmr` et `llamacpp` (`chat_template_kwargs.enable_thinking: false`, depuis le 27/09/2026) : le raisonnement est décompté de `summaryMaxTokens` et n'est pas dans le texte rendu. Constaté avec Qwen3.8-27B sur 18 000 tokens d'historique : trois compressions de suite ont échoué sur « empty summary », le budget entier parti en réflexion ; reproduit avec qwen3.5-2b et `summaryMaxTokens: 300`, corrigé par ce réglage ; | ||
| 217 | * le déclencheur prend le maximum entre l'estimation locale et le compte du serveur, et se replie sur un nombre de messages quand la fenêtre est inconnue ; | 218 | * le déclencheur prend le maximum entre l'estimation locale et le compte du serveur, et se replie sur un nombre de messages quand la fenêtre est inconnue ; |
| 218 | * si la fenêtre est inconnue au démarrage (serveur lancé après l'agent), elle est redemandée paresseusement (`EnsureContextWindow`). | 219 | * si la fenêtre est inconnue au démarrage (serveur lancé après l'agent), elle est redemandée paresseusement (`EnsureContextWindow`). |
| 219 | 220 | ||
modified
docs/fr/how-to/keep-sessions.md +3 -2 | @@ -54,7 +54,7 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj | ||
| 54 | 54 | 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s) |
| 55 | 55 | ``` |
| 56 | 56 | |
| 57 | -2. Lisez le tableau `errors` de son fichier. Chaque enregistrement dit quand, à quel tour, sur quelle question, ce que l'écran a montré (`message`) et, quand l'erreur brute en dit plus, ce qu'elle était (`detail`) ; `where` vaut `generation`, `compaction` ou `resume` : | |
| 57 | +2. Lisez le tableau `errors` de son fichier. Chaque enregistrement dit quand, à quel tour, sur quelle question, ce que l'écran a montré (`message`), quand l'erreur brute en dit plus, ce qu'elle était (`detail`), et quel `model` a échoué ; `where` vaut `generation`, `compaction` ou `resume` : | |
| 58 | 58 | |
| 59 | 59 | ```bash |
| 60 | 60 | jq .errors .mm/sessions/20260918-141225-02790c82.json |
| @@ -68,7 +68,8 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj | ||
| 68 | 68 | "where": "generation", |
| 69 | 69 | "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none", |
| 70 | 70 | "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found", |
| 71 | - "question": "hello" | |
| 71 | + "question": "hello", | |
| 72 | + "model": "no/such-model:none" | |
| 72 | 73 | } |
| 73 | 74 | ] |
| 74 | 75 | ``` |
| @@ -54,7 +54,7 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj | |||
| 54 | 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s) | 54 | 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s) |
| 55 | ``` | 55 | ``` |
| 56 | 56 | ||
| 57 | -2. Lisez le tableau `errors` de son fichier. Chaque enregistrement dit quand, à quel tour, sur quelle question, ce que l'écran a montré (`message`) et, quand l'erreur brute en dit plus, ce qu'elle était (`detail`) ; `where` vaut `generation`, `compaction` ou `resume` : | 57 | +2. Lisez le tableau `errors` de son fichier. Chaque enregistrement dit quand, à quel tour, sur quelle question, ce que l'écran a montré (`message`), quand l'erreur brute en dit plus, ce qu'elle était (`detail`), et quel `model` a échoué ; `where` vaut `generation`, `compaction` ou `resume` : |
| 58 | 58 | ||
| 59 | ```bash | 59 | ```bash |
| 60 | jq .errors .mm/sessions/20260918-141225-02790c82.json | 60 | jq .errors .mm/sessions/20260918-141225-02790c82.json |
| @@ -68,7 +68,8 @@ Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un proj | |||
| 68 | "where": "generation", | 68 | "where": "generation", |
| 69 | "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none", | 69 | "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none", |
| 70 | "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found", | 70 | "detail": "failed to create completion: POST \"http://localhost:12434/engines/v1/chat/completions\": 404 Not Found", |
| 71 | - "question": "hello" | 71 | + "question": "hello", |
| 72 | + "model": "no/such-model:none" | ||
| 72 | } | 73 | } |
| 73 | ] | 74 | ] |
| 74 | ``` | 75 | ``` |
modified
docs/fr/reference/configuration.md +1 -1 | @@ -39,7 +39,7 @@ | ||
| 39 | 39 | | `threshold` | entier | `75` | Pourcentage de la fenêtre de contexte au-delà duquel l'historique est compressé avant la question suivante. Entre 1 et 100. | |
| 40 | 40 | | `maxMessages` | entier | `80` | Nombre de messages qui déclenche une compression quand la fenêtre est inconnue ou l'estimation trop basse. `0` désactive. Doit être ≥ 0. | |
| 41 | 41 | | `keepLastTurns` | entier | `3` | Tours de question conservés bruts en fin d'historique. Doit être ≥ 1. | |
| 42 | -| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. | | |
| 42 | +| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. Le raisonnement d'un modèle qui réfléchit est décompté de ce budget : sur `dmr` et `llamacpp`, la requête de résumé désactive le raisonnement (`chat_template_kwargs.enable_thinking: false`) ; ailleurs, si l'erreur dit que le modèle « reasoned until the limit », augmentez-le. | | |
| 43 | 43 | | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. | |
| 44 | 44 | | `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. | |
| 45 | 45 | |
| @@ -39,7 +39,7 @@ | |||
| 39 | | `threshold` | entier | `75` | Pourcentage de la fenêtre de contexte au-delà duquel l'historique est compressé avant la question suivante. Entre 1 et 100. | | 39 | | `threshold` | entier | `75` | Pourcentage de la fenêtre de contexte au-delà duquel l'historique est compressé avant la question suivante. Entre 1 et 100. | |
| 40 | | `maxMessages` | entier | `80` | Nombre de messages qui déclenche une compression quand la fenêtre est inconnue ou l'estimation trop basse. `0` désactive. Doit être ≥ 0. | | 40 | | `maxMessages` | entier | `80` | Nombre de messages qui déclenche une compression quand la fenêtre est inconnue ou l'estimation trop basse. `0` désactive. Doit être ≥ 0. | |
| 41 | | `keepLastTurns` | entier | `3` | Tours de question conservés bruts en fin d'historique. Doit être ≥ 1. | | 41 | | `keepLastTurns` | entier | `3` | Tours de question conservés bruts en fin d'historique. Doit être ≥ 1. | |
| 42 | -| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. | | 42 | +| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. Le raisonnement d'un modèle qui réfléchit est décompté de ce budget : sur `dmr` et `llamacpp`, la requête de résumé désactive le raisonnement (`chat_template_kwargs.enable_thinking: false`) ; ailleurs, si l'erreur dit que le modèle « reasoned until the limit », augmentez-le. | |
| 43 | | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. | | 43 | | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. | |
| 44 | | `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. | | 44 | | `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. | |
| 45 | 45 | ||
modified
internal/engine/engine.go +28 -3 | @@ -513,6 +513,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 513 | 513 | // prefill of the session (the whole old history at once) and nothing streams |
| 514 | 514 | // until it is done. Streaming stays on so that the summary's own tokens beat |
| 515 | 515 | // the watchdog while it is being written. |
| 516 | +// | |
| 517 | +// A thinking model is asked not to reason (withoutThinking, llama.cpp | |
| 518 | +// providers): its reasoning counts against maxTokens and is not in | |
| 519 | +// resp.Text(), so it can spend the whole budget and leave no summary. On the | |
| 520 | +// other providers that can still happen; emptySummary then says so. | |
| 516 | 521 | func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) { |
| 517 | 522 | // The agent's sampling, minus what does not apply here: `parallel_tool_calls` |
| 518 | 523 | // without tools is a 400 on the OpenAI API, and `max_tokens` is the summary's |
| @@ -530,8 +535,11 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken | ||
| 530 | 535 | beat := pl.beat |
| 531 | 536 | // The same heartbeat, and deliberately no display: a summary is written |
| 532 | 537 | // behind the user's back, and a model that thinks about it must not spill |
| 533 | - // that onto the screen in the middle of their conversation. | |
| 534 | - genCtx = WithReasoning(genCtx, func(string) { beat() }) | |
| 538 | + // that onto the screen in the middle of their conversation. The length is | |
| 539 | + // kept for emptySummary. | |
| 540 | + var reasoned atomic.Int64 | |
| 541 | + genCtx = WithReasoning(genCtx, func(s string) { reasoned.Add(int64(len(s))); beat() }) | |
| 542 | + genCtx = withoutThinking(genCtx) | |
| 535 | 543 | genCtx, srvErrs := withServerErrors(genCtx) |
| 536 | 544 | genCtx = withPromptProgress(genCtx, func(promptProgress) { pl.alive() }) |
| 537 | 545 | |
| @@ -556,11 +564,28 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken | ||
| 556 | 564 | } |
| 557 | 565 | text := strings.TrimSpace(resp.Text()) |
| 558 | 566 | if text == "" { |
| 559 | - return "", fmt.Errorf("the model returned an empty summary") | |
| 567 | + return "", emptySummary(resp, maxTokens, int(reasoned.Load())) | |
| 560 | 568 | } |
| 561 | 569 | return text, nil |
| 562 | 570 | } |
| 563 | 571 | |
| 572 | +// emptySummary is the error of a summary request that came back without | |
| 573 | +// text, with what the response says about why: the line lands in the session | |
| 574 | +// file's `errors`, and "empty summary" alone sent an investigation to replay | |
| 575 | +// an 18k-token request. The usual cause is a thinking model that reasoned | |
| 576 | +// until max_tokens; the line then names the setting to raise. | |
| 577 | +func emptySummary(resp *ai.ModelResponse, maxTokens, reasoned int) error { | |
| 578 | + out := 0 | |
| 579 | + if resp.Usage != nil { | |
| 580 | + out = resp.Usage.OutputTokens | |
| 581 | + } | |
| 582 | + why := fmt.Sprintf("finish: %s, %d output token(s), %d char(s) of reasoning", resp.FinishReason, out, reasoned) | |
| 583 | + if resp.FinishReason == ai.FinishReasonLength && reasoned > 0 { | |
| 584 | + return fmt.Errorf("the model returned an empty summary: it reasoned until the %d-token limit (%s) — raise context.summaryMaxTokens", maxTokens, why) | |
| 585 | + } | |
| 586 | + return fmt.Errorf("the model returned an empty summary (%s)", why) | |
| 587 | +} | |
| 588 | + | |
| 564 | 589 | // Names of the tools whose responses we count. They live here rather than |
| 565 | 590 | // scattered through the display code: the counter is a measurement, and a |
| 566 | 591 | // measurement needs a single definition. |
| @@ -513,6 +513,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | |||
| 513 | // prefill of the session (the whole old history at once) and nothing streams | 513 | // prefill of the session (the whole old history at once) and nothing streams |
| 514 | // until it is done. Streaming stays on so that the summary's own tokens beat | 514 | // until it is done. Streaming stays on so that the summary's own tokens beat |
| 515 | // the watchdog while it is being written. | 515 | // the watchdog while it is being written. |
| 516 | +// | ||
| 517 | +// A thinking model is asked not to reason (withoutThinking, llama.cpp | ||
| 518 | +// providers): its reasoning counts against maxTokens and is not in | ||
| 519 | +// resp.Text(), so it can spend the whole budget and leave no summary. On the | ||
| 520 | +// other providers that can still happen; emptySummary then says so. | ||
| 516 | func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) { | 521 | func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) { |
| 517 | // The agent's sampling, minus what does not apply here: `parallel_tool_calls` | 522 | // The agent's sampling, minus what does not apply here: `parallel_tool_calls` |
| 518 | // without tools is a 400 on the OpenAI API, and `max_tokens` is the summary's | 523 | // without tools is a 400 on the OpenAI API, and `max_tokens` is the summary's |
| @@ -530,8 +535,11 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken | |||
| 530 | beat := pl.beat | 535 | beat := pl.beat |
| 531 | // The same heartbeat, and deliberately no display: a summary is written | 536 | // The same heartbeat, and deliberately no display: a summary is written |
| 532 | // behind the user's back, and a model that thinks about it must not spill | 537 | // behind the user's back, and a model that thinks about it must not spill |
| 533 | - // that onto the screen in the middle of their conversation. | 538 | + // that onto the screen in the middle of their conversation. The length is |
| 534 | - genCtx = WithReasoning(genCtx, func(string) { beat() }) | 539 | + // kept for emptySummary. |
| 540 | + var reasoned atomic.Int64 | ||
| 541 | + genCtx = WithReasoning(genCtx, func(s string) { reasoned.Add(int64(len(s))); beat() }) | ||
| 542 | + genCtx = withoutThinking(genCtx) | ||
| 535 | genCtx, srvErrs := withServerErrors(genCtx) | 543 | genCtx, srvErrs := withServerErrors(genCtx) |
| 536 | genCtx = withPromptProgress(genCtx, func(promptProgress) { pl.alive() }) | 544 | genCtx = withPromptProgress(genCtx, func(promptProgress) { pl.alive() }) |
| 537 | 545 | ||
| @@ -556,11 +564,28 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken | |||
| 556 | } | 564 | } |
| 557 | text := strings.TrimSpace(resp.Text()) | 565 | text := strings.TrimSpace(resp.Text()) |
| 558 | if text == "" { | 566 | if text == "" { |
| 559 | - return "", fmt.Errorf("the model returned an empty summary") | 567 | + return "", emptySummary(resp, maxTokens, int(reasoned.Load())) |
| 560 | } | 568 | } |
| 561 | return text, nil | 569 | return text, nil |
| 562 | } | 570 | } |
| 563 | 571 | ||
| 572 | +// emptySummary is the error of a summary request that came back without | ||
| 573 | +// text, with what the response says about why: the line lands in the session | ||
| 574 | +// file's `errors`, and "empty summary" alone sent an investigation to replay | ||
| 575 | +// an 18k-token request. The usual cause is a thinking model that reasoned | ||
| 576 | +// until max_tokens; the line then names the setting to raise. | ||
| 577 | +func emptySummary(resp *ai.ModelResponse, maxTokens, reasoned int) error { | ||
| 578 | + out := 0 | ||
| 579 | + if resp.Usage != nil { | ||
| 580 | + out = resp.Usage.OutputTokens | ||
| 581 | + } | ||
| 582 | + why := fmt.Sprintf("finish: %s, %d output token(s), %d char(s) of reasoning", resp.FinishReason, out, reasoned) | ||
| 583 | + if resp.FinishReason == ai.FinishReasonLength && reasoned > 0 { | ||
| 584 | + return fmt.Errorf("the model returned an empty summary: it reasoned until the %d-token limit (%s) — raise context.summaryMaxTokens", maxTokens, why) | ||
| 585 | + } | ||
| 586 | + return fmt.Errorf("the model returned an empty summary (%s)", why) | ||
| 587 | +} | ||
| 588 | + | ||
| 564 | // Names of the tools whose responses we count. They live here rather than | 589 | // Names of the tools whose responses we count. They live here rather than |
| 565 | // scattered through the display code: the counter is a measurement, and a | 590 | // scattered through the display code: the counter is a measurement, and a |
| 566 | // measurement needs a single definition. | 591 | // measurement needs a single definition. |
modified
internal/engine/openai_compat.go +2 -1 | @@ -48,7 +48,8 @@ type openaiCompat struct { | ||
| 48 | 48 | probe func(ctx context.Context, b Backend) Info |
| 49 | 49 | |
| 50 | 50 | // progress: the server is llama.cpp and understands return_progress |
| 51 | - // (usage.go, progress.go). DMR's default backend is llama.cpp. | |
| 51 | + // (usage.go, progress.go) and chat_template_kwargs, which turns the | |
| 52 | + // summary's reasoning off (usage.go). DMR's default backend is llama.cpp. | |
| 52 | 53 | progress bool |
| 53 | 54 | } |
| 54 | 55 | |
| @@ -48,7 +48,8 @@ type openaiCompat struct { | |||
| 48 | probe func(ctx context.Context, b Backend) Info | 48 | probe func(ctx context.Context, b Backend) Info |
| 49 | 49 | ||
| 50 | // progress: the server is llama.cpp and understands return_progress | 50 | // progress: the server is llama.cpp and understands return_progress |
| 51 | - // (usage.go, progress.go). DMR's default backend is llama.cpp. | 51 | + // (usage.go, progress.go) and chat_template_kwargs, which turns the |
| 52 | + // summary's reasoning off (usage.go). DMR's default backend is llama.cpp. | ||
| 52 | progress bool | 53 | progress bool |
| 53 | } | 54 | } |
| 54 | 55 | ||
added
internal/engine/summary_test.go +137 -0 | new file mode 100644 | ||
| @@ -0,0 +1,137 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "net/http" | |
| 9 | + "net/http/httptest" | |
| 10 | + "strings" | |
| 11 | + "sync" | |
| 12 | + "testing" | |
| 13 | + | |
| 14 | + "github.com/firebase/genkit/go/ai" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// recordingLlama is fakeLlama that also keeps the body of every request. | |
| 18 | +func recordingLlama(t *testing.T, answer func(w http.ResponseWriter)) (*httptest.Server, func() []map[string]any) { | |
| 19 | + t.Helper() | |
| 20 | + var mu sync.Mutex | |
| 21 | + var bodies []map[string]any | |
| 22 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 23 | + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { | |
| 24 | + http.NotFound(w, r) | |
| 25 | + return | |
| 26 | + } | |
| 27 | + var m map[string]any | |
| 28 | + b, _ := io.ReadAll(r.Body) | |
| 29 | + _ = json.Unmarshal(b, &m) | |
| 30 | + mu.Lock() | |
| 31 | + bodies = append(bodies, m) | |
| 32 | + mu.Unlock() | |
| 33 | + w.Header().Set("Content-Type", "text/event-stream") | |
| 34 | + answer(w) | |
| 35 | + })) | |
| 36 | + t.Cleanup(srv.Close) | |
| 37 | + return srv, func() []map[string]any { | |
| 38 | + mu.Lock() | |
| 39 | + defer mu.Unlock() | |
| 40 | + return append([]map[string]any(nil), bodies...) | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +func summaryRequest() []*ai.Message { | |
| 45 | + return []*ai.Message{ai.NewSystemTextMessage("note-taker"), ai.NewUserTextMessage("summarise")} | |
| 46 | +} | |
| 47 | + | |
| 48 | +// The summary request asks a llama.cpp server not to reason; a turn does not. | |
| 49 | +func TestSummaryTurnsReasoningOff(t *testing.T) { | |
| 50 | + srv, bodies := recordingLlama(t, goodAnswer) | |
| 51 | + e := engineOn(t, srv.URL+"/v1") | |
| 52 | + | |
| 53 | + if _, err := e.Summarize(context.Background(), summaryRequest(), 100); err != nil { | |
| 54 | + t.Fatalf("Summarize: %v", err) | |
| 55 | + } | |
| 56 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil { | |
| 57 | + t.Fatalf("Generate: %v", err) | |
| 58 | + } | |
| 59 | + | |
| 60 | + got := bodies() | |
| 61 | + if len(got) != 2 { | |
| 62 | + t.Fatalf("%d request(s), want 2", len(got)) | |
| 63 | + } | |
| 64 | + kw, _ := got[0]["chat_template_kwargs"].(map[string]any) | |
| 65 | + if v, set := kw["enable_thinking"]; !set || v != false { | |
| 66 | + t.Errorf("summary request: chat_template_kwargs = %v, want enable_thinking false", got[0]["chat_template_kwargs"]) | |
| 67 | + } | |
| 68 | + if _, set := got[1]["chat_template_kwargs"]; set { | |
| 69 | + t.Errorf("a turn's request turned the reasoning off: %v", got[1]["chat_template_kwargs"]) | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +// A model that reasons until max_tokens and writes nothing: the error says so | |
| 74 | +// and names the setting — the session file only ever said "empty summary". | |
| 75 | +func TestEmptySummarySaysWhy(t *testing.T) { | |
| 76 | + srv, _ := recordingLlama(t, func(w http.ResponseWriter) { | |
| 77 | + fmt.Fprint(w, sseChunkLine(`{"role":"assistant","reasoning_content":"Let me think about the session..."}`, "")) | |
| 78 | + fmt.Fprint(w, sseChunkLine(`{}`, "length")) | |
| 79 | + fmt.Fprint(w, "data: [DONE]\n\n") | |
| 80 | + }) | |
| 81 | + e := engineOn(t, srv.URL+"/v1") | |
| 82 | + | |
| 83 | + _, err := e.Summarize(context.Background(), summaryRequest(), 100) | |
| 84 | + if err == nil { | |
| 85 | + t.Fatal("an answer with no text must be an error") | |
| 86 | + } | |
| 87 | + got := e.Explain(err) | |
| 88 | + for _, want := range []string{"empty summary", "100-token limit", "finish: length", "context.summaryMaxTokens"} { | |
| 89 | + if !strings.Contains(got, want) { | |
| 90 | + t.Errorf("Explain = %q, want it to contain %q", got, want) | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +// An empty answer that is not a reasoning overrun gets the facts, not the | |
| 96 | +// advice. | |
| 97 | +func TestEmptySummaryWithoutReasoning(t *testing.T) { | |
| 98 | + resp := &ai.ModelResponse{FinishReason: ai.FinishReasonStop, Usage: &ai.GenerationUsage{OutputTokens: 1}} | |
| 99 | + got := emptySummary(resp, 1200, 0).Error() | |
| 100 | + if !strings.Contains(got, "finish: stop, 1 output token(s), 0 char(s) of reasoning") || strings.Contains(got, "summaryMaxTokens") { | |
| 101 | + t.Errorf("emptySummary = %q", got) | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +// The middleware turns the reasoning off only for a llama.cpp provider, only | |
| 106 | +// under a marked context, and never over a request that chose for itself. | |
| 107 | +func TestStreamOptionsWithoutThinking(t *testing.T) { | |
| 108 | + send := func(ctx context.Context, progress bool, body string) string { | |
| 109 | + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(body)) | |
| 110 | + var seen string | |
| 111 | + _, _ = streamOptions(progress)(req, func(r *http.Request) (*http.Response, error) { | |
| 112 | + b, _ := io.ReadAll(r.Body) | |
| 113 | + seen = string(b) | |
| 114 | + return &http.Response{StatusCode: 200, Body: http.NoBody}, nil | |
| 115 | + }) | |
| 116 | + return seen | |
| 117 | + } | |
| 118 | + marked := withoutThinking(context.Background()) | |
| 119 | + const off = `"chat_template_kwargs":{"enable_thinking":false}` | |
| 120 | + | |
| 121 | + if got := send(marked, true, `{"stream":true}`); !strings.Contains(got, off) { | |
| 122 | + t.Errorf("llama.cpp, marked: %s", got) | |
| 123 | + } | |
| 124 | + if got := send(marked, true, `{"stream":false}`); !strings.Contains(got, off) { | |
| 125 | + t.Errorf("llama.cpp, marked, not streamed: %s", got) | |
| 126 | + } | |
| 127 | + if got := send(context.Background(), true, `{"stream":true}`); strings.Contains(got, "chat_template_kwargs") { | |
| 128 | + t.Errorf("llama.cpp, not marked: %s", got) | |
| 129 | + } | |
| 130 | + if got := send(marked, false, `{"stream":true}`); strings.Contains(got, "chat_template_kwargs") { | |
| 131 | + t.Errorf("another provider, marked: %s", got) | |
| 132 | + } | |
| 133 | + chose := `{"stream":false,"chat_template_kwargs":{"enable_thinking":true}}` | |
| 134 | + if got := send(marked, true, chose); got != chose { | |
| 135 | + t.Errorf("a request that chose was changed: %s", got) | |
| 136 | + } | |
| 137 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,137 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "io" | ||
| 8 | + "net/http" | ||
| 9 | + "net/http/httptest" | ||
| 10 | + "strings" | ||
| 11 | + "sync" | ||
| 12 | + "testing" | ||
| 13 | + | ||
| 14 | + "github.com/firebase/genkit/go/ai" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// recordingLlama is fakeLlama that also keeps the body of every request. | ||
| 18 | +func recordingLlama(t *testing.T, answer func(w http.ResponseWriter)) (*httptest.Server, func() []map[string]any) { | ||
| 19 | + t.Helper() | ||
| 20 | + var mu sync.Mutex | ||
| 21 | + var bodies []map[string]any | ||
| 22 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 23 | + if !strings.HasSuffix(r.URL.Path, "/chat/completions") { | ||
| 24 | + http.NotFound(w, r) | ||
| 25 | + return | ||
| 26 | + } | ||
| 27 | + var m map[string]any | ||
| 28 | + b, _ := io.ReadAll(r.Body) | ||
| 29 | + _ = json.Unmarshal(b, &m) | ||
| 30 | + mu.Lock() | ||
| 31 | + bodies = append(bodies, m) | ||
| 32 | + mu.Unlock() | ||
| 33 | + w.Header().Set("Content-Type", "text/event-stream") | ||
| 34 | + answer(w) | ||
| 35 | + })) | ||
| 36 | + t.Cleanup(srv.Close) | ||
| 37 | + return srv, func() []map[string]any { | ||
| 38 | + mu.Lock() | ||
| 39 | + defer mu.Unlock() | ||
| 40 | + return append([]map[string]any(nil), bodies...) | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +func summaryRequest() []*ai.Message { | ||
| 45 | + return []*ai.Message{ai.NewSystemTextMessage("note-taker"), ai.NewUserTextMessage("summarise")} | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +// The summary request asks a llama.cpp server not to reason; a turn does not. | ||
| 49 | +func TestSummaryTurnsReasoningOff(t *testing.T) { | ||
| 50 | + srv, bodies := recordingLlama(t, goodAnswer) | ||
| 51 | + e := engineOn(t, srv.URL+"/v1") | ||
| 52 | + | ||
| 53 | + if _, err := e.Summarize(context.Background(), summaryRequest(), 100); err != nil { | ||
| 54 | + t.Fatalf("Summarize: %v", err) | ||
| 55 | + } | ||
| 56 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil { | ||
| 57 | + t.Fatalf("Generate: %v", err) | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + got := bodies() | ||
| 61 | + if len(got) != 2 { | ||
| 62 | + t.Fatalf("%d request(s), want 2", len(got)) | ||
| 63 | + } | ||
| 64 | + kw, _ := got[0]["chat_template_kwargs"].(map[string]any) | ||
| 65 | + if v, set := kw["enable_thinking"]; !set || v != false { | ||
| 66 | + t.Errorf("summary request: chat_template_kwargs = %v, want enable_thinking false", got[0]["chat_template_kwargs"]) | ||
| 67 | + } | ||
| 68 | + if _, set := got[1]["chat_template_kwargs"]; set { | ||
| 69 | + t.Errorf("a turn's request turned the reasoning off: %v", got[1]["chat_template_kwargs"]) | ||
| 70 | + } | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +// A model that reasons until max_tokens and writes nothing: the error says so | ||
| 74 | +// and names the setting — the session file only ever said "empty summary". | ||
| 75 | +func TestEmptySummarySaysWhy(t *testing.T) { | ||
| 76 | + srv, _ := recordingLlama(t, func(w http.ResponseWriter) { | ||
| 77 | + fmt.Fprint(w, sseChunkLine(`{"role":"assistant","reasoning_content":"Let me think about the session..."}`, "")) | ||
| 78 | + fmt.Fprint(w, sseChunkLine(`{}`, "length")) | ||
| 79 | + fmt.Fprint(w, "data: [DONE]\n\n") | ||
| 80 | + }) | ||
| 81 | + e := engineOn(t, srv.URL+"/v1") | ||
| 82 | + | ||
| 83 | + _, err := e.Summarize(context.Background(), summaryRequest(), 100) | ||
| 84 | + if err == nil { | ||
| 85 | + t.Fatal("an answer with no text must be an error") | ||
| 86 | + } | ||
| 87 | + got := e.Explain(err) | ||
| 88 | + for _, want := range []string{"empty summary", "100-token limit", "finish: length", "context.summaryMaxTokens"} { | ||
| 89 | + if !strings.Contains(got, want) { | ||
| 90 | + t.Errorf("Explain = %q, want it to contain %q", got, want) | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +// An empty answer that is not a reasoning overrun gets the facts, not the | ||
| 96 | +// advice. | ||
| 97 | +func TestEmptySummaryWithoutReasoning(t *testing.T) { | ||
| 98 | + resp := &ai.ModelResponse{FinishReason: ai.FinishReasonStop, Usage: &ai.GenerationUsage{OutputTokens: 1}} | ||
| 99 | + got := emptySummary(resp, 1200, 0).Error() | ||
| 100 | + if !strings.Contains(got, "finish: stop, 1 output token(s), 0 char(s) of reasoning") || strings.Contains(got, "summaryMaxTokens") { | ||
| 101 | + t.Errorf("emptySummary = %q", got) | ||
| 102 | + } | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +// The middleware turns the reasoning off only for a llama.cpp provider, only | ||
| 106 | +// under a marked context, and never over a request that chose for itself. | ||
| 107 | +func TestStreamOptionsWithoutThinking(t *testing.T) { | ||
| 108 | + send := func(ctx context.Context, progress bool, body string) string { | ||
| 109 | + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(body)) | ||
| 110 | + var seen string | ||
| 111 | + _, _ = streamOptions(progress)(req, func(r *http.Request) (*http.Response, error) { | ||
| 112 | + b, _ := io.ReadAll(r.Body) | ||
| 113 | + seen = string(b) | ||
| 114 | + return &http.Response{StatusCode: 200, Body: http.NoBody}, nil | ||
| 115 | + }) | ||
| 116 | + return seen | ||
| 117 | + } | ||
| 118 | + marked := withoutThinking(context.Background()) | ||
| 119 | + const off = `"chat_template_kwargs":{"enable_thinking":false}` | ||
| 120 | + | ||
| 121 | + if got := send(marked, true, `{"stream":true}`); !strings.Contains(got, off) { | ||
| 122 | + t.Errorf("llama.cpp, marked: %s", got) | ||
| 123 | + } | ||
| 124 | + if got := send(marked, true, `{"stream":false}`); !strings.Contains(got, off) { | ||
| 125 | + t.Errorf("llama.cpp, marked, not streamed: %s", got) | ||
| 126 | + } | ||
| 127 | + if got := send(context.Background(), true, `{"stream":true}`); strings.Contains(got, "chat_template_kwargs") { | ||
| 128 | + t.Errorf("llama.cpp, not marked: %s", got) | ||
| 129 | + } | ||
| 130 | + if got := send(marked, false, `{"stream":true}`); strings.Contains(got, "chat_template_kwargs") { | ||
| 131 | + t.Errorf("another provider, marked: %s", got) | ||
| 132 | + } | ||
| 133 | + chose := `{"stream":false,"chat_template_kwargs":{"enable_thinking":true}}` | ||
| 134 | + if got := send(marked, true, chose); got != chose { | ||
| 135 | + t.Errorf("a request that chose was changed: %s", got) | ||
| 136 | + } | ||
| 137 | +} | ||
modified
internal/engine/usage.go +50 -3 | @@ -2,6 +2,7 @@ package engine | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | + "context" | |
| 5 | 6 | "encoding/json" |
| 6 | 7 | "io" |
| 7 | 8 | "net/http" |
| @@ -35,15 +36,41 @@ import ( | ||
| 35 | 36 | // what tells "the server is reading" from "the server is stuck" (progress.go). |
| 36 | 37 | // Only for llama.cpp providers: an unknown field is a 400 on some servers. |
| 37 | 38 | |
| 39 | +// And it turns a thinking model's reasoning off for the requests whose | |
| 40 | +// context says so (withoutThinking) — the summary of a compaction. Measured | |
| 41 | +// 2026-09-27 on DMR, qwen3.5-2b, the summary request over four short turns: | |
| 42 | +// with max_tokens 300 the model reasoned for all 300 and wrote nothing | |
| 43 | +// (finish "length", empty content); with `chat_template_kwargs: | |
| 44 | +// {"enable_thinking": false}` the same 300 tokens were the summary. On a real | |
| 45 | +// session (18k tokens of history, Qwen3.8-27B) the reasoning ate the whole | |
| 46 | +// summaryMaxTokens and every compaction failed with "empty summary". Notes | |
| 47 | +// need no reasoning. It cannot go in the sampling config either: compat_oai | |
| 48 | +// maps the config onto openai.ChatCompletionNewParams, which has no such | |
| 49 | +// field. llama.cpp providers only, like return_progress; a template without | |
| 50 | +// the variable ignores it. | |
| 51 | + | |
| 52 | +// noThinkKey is the context key marking a request whose reasoning is off. | |
| 53 | +type noThinkKey struct{} | |
| 54 | + | |
| 55 | +// withoutThinking marks ctx: the requests made under it ask a llama.cpp | |
| 56 | +// server not to reason (see above). | |
| 57 | +func withoutThinking(ctx context.Context) context.Context { | |
| 58 | + return context.WithValue(ctx, noThinkKey{}, true) | |
| 59 | +} | |
| 60 | + | |
| 38 | 61 | // streamOptions returns the openai-go middleware that adds those options to a |
| 39 | -// request that streams and has not chosen for itself; `progress` adds | |
| 40 | -// return_progress. Anything else — not JSON, not streamed, the options already | |
| 41 | -// set — goes through untouched. | |
| 62 | +// request that streams and has not chosen for itself; `progress` — the server | |
| 63 | +// is llama.cpp — adds return_progress, and turns the reasoning off when the | |
| 64 | +// request's context asks. Anything else — not JSON, not streamed, the options | |
| 65 | +// already set — goes through untouched. | |
| 42 | 66 | func streamOptions(progress bool) option.Middleware { |
| 43 | 67 | return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { |
| 44 | 68 | if req.Method == http.MethodPost && req.Body != nil { |
| 45 | 69 | if body, err := io.ReadAll(req.Body); err == nil { |
| 46 | 70 | _ = req.Body.Close() |
| 71 | + if noThink, _ := req.Context().Value(noThinkKey{}).(bool); progress && noThink { | |
| 72 | + body = withoutReasoning(body) | |
| 73 | + } | |
| 47 | 74 | body = withStreamOptions(body, progress) |
| 48 | 75 | req.Body = io.NopCloser(bytes.NewReader(body)) |
| 49 | 76 | req.ContentLength = int64(len(body)) |
| @@ -85,3 +112,23 @@ func withStreamOptions(body []byte, progress bool) []byte { | ||
| 85 | 112 | } |
| 86 | 113 | return out |
| 87 | 114 | } |
| 115 | + | |
| 116 | +// withoutReasoning returns body with chat_template_kwargs.enable_thinking set | |
| 117 | +// to false, streamed or not. A request that already carries | |
| 118 | +// chat_template_kwargs has chosen for itself and is left as is, like a body | |
| 119 | +// that is not JSON. | |
| 120 | +func withoutReasoning(body []byte) []byte { | |
| 121 | + var m map[string]json.RawMessage | |
| 122 | + if json.Unmarshal(body, &m) != nil { | |
| 123 | + return body | |
| 124 | + } | |
| 125 | + if _, set := m["chat_template_kwargs"]; set { | |
| 126 | + return body | |
| 127 | + } | |
| 128 | + m["chat_template_kwargs"] = json.RawMessage(`{"enable_thinking":false}`) | |
| 129 | + out, err := json.Marshal(m) | |
| 130 | + if err != nil { | |
| 131 | + return body | |
| 132 | + } | |
| 133 | + return out | |
| 134 | +} | |
| @@ -2,6 +2,7 @@ package engine | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | 4 | "bytes" |
| 5 | + "context" | ||
| 5 | "encoding/json" | 6 | "encoding/json" |
| 6 | "io" | 7 | "io" |
| 7 | "net/http" | 8 | "net/http" |
| @@ -35,15 +36,41 @@ import ( | |||
| 35 | // what tells "the server is reading" from "the server is stuck" (progress.go). | 36 | // what tells "the server is reading" from "the server is stuck" (progress.go). |
| 36 | // Only for llama.cpp providers: an unknown field is a 400 on some servers. | 37 | // Only for llama.cpp providers: an unknown field is a 400 on some servers. |
| 37 | 38 | ||
| 39 | +// And it turns a thinking model's reasoning off for the requests whose | ||
| 40 | +// context says so (withoutThinking) — the summary of a compaction. Measured | ||
| 41 | +// 2026-09-27 on DMR, qwen3.5-2b, the summary request over four short turns: | ||
| 42 | +// with max_tokens 300 the model reasoned for all 300 and wrote nothing | ||
| 43 | +// (finish "length", empty content); with `chat_template_kwargs: | ||
| 44 | +// {"enable_thinking": false}` the same 300 tokens were the summary. On a real | ||
| 45 | +// session (18k tokens of history, Qwen3.8-27B) the reasoning ate the whole | ||
| 46 | +// summaryMaxTokens and every compaction failed with "empty summary". Notes | ||
| 47 | +// need no reasoning. It cannot go in the sampling config either: compat_oai | ||
| 48 | +// maps the config onto openai.ChatCompletionNewParams, which has no such | ||
| 49 | +// field. llama.cpp providers only, like return_progress; a template without | ||
| 50 | +// the variable ignores it. | ||
| 51 | + | ||
| 52 | +// noThinkKey is the context key marking a request whose reasoning is off. | ||
| 53 | +type noThinkKey struct{} | ||
| 54 | + | ||
| 55 | +// withoutThinking marks ctx: the requests made under it ask a llama.cpp | ||
| 56 | +// server not to reason (see above). | ||
| 57 | +func withoutThinking(ctx context.Context) context.Context { | ||
| 58 | + return context.WithValue(ctx, noThinkKey{}, true) | ||
| 59 | +} | ||
| 60 | + | ||
| 38 | // streamOptions returns the openai-go middleware that adds those options to a | 61 | // streamOptions returns the openai-go middleware that adds those options to a |
| 39 | -// request that streams and has not chosen for itself; `progress` adds | 62 | +// request that streams and has not chosen for itself; `progress` — the server |
| 40 | -// return_progress. Anything else — not JSON, not streamed, the options already | 63 | +// is llama.cpp — adds return_progress, and turns the reasoning off when the |
| 41 | -// set — goes through untouched. | 64 | +// request's context asks. Anything else — not JSON, not streamed, the options |
| 65 | +// already set — goes through untouched. | ||
| 42 | func streamOptions(progress bool) option.Middleware { | 66 | func streamOptions(progress bool) option.Middleware { |
| 43 | return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { | 67 | return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { |
| 44 | if req.Method == http.MethodPost && req.Body != nil { | 68 | if req.Method == http.MethodPost && req.Body != nil { |
| 45 | if body, err := io.ReadAll(req.Body); err == nil { | 69 | if body, err := io.ReadAll(req.Body); err == nil { |
| 46 | _ = req.Body.Close() | 70 | _ = req.Body.Close() |
| 71 | + if noThink, _ := req.Context().Value(noThinkKey{}).(bool); progress && noThink { | ||
| 72 | + body = withoutReasoning(body) | ||
| 73 | + } | ||
| 47 | body = withStreamOptions(body, progress) | 74 | body = withStreamOptions(body, progress) |
| 48 | req.Body = io.NopCloser(bytes.NewReader(body)) | 75 | req.Body = io.NopCloser(bytes.NewReader(body)) |
| 49 | req.ContentLength = int64(len(body)) | 76 | req.ContentLength = int64(len(body)) |
| @@ -85,3 +112,23 @@ func withStreamOptions(body []byte, progress bool) []byte { | |||
| 85 | } | 112 | } |
| 86 | return out | 113 | return out |
| 87 | } | 114 | } |
| 115 | + | ||
| 116 | +// withoutReasoning returns body with chat_template_kwargs.enable_thinking set | ||
| 117 | +// to false, streamed or not. A request that already carries | ||
| 118 | +// chat_template_kwargs has chosen for itself and is left as is, like a body | ||
| 119 | +// that is not JSON. | ||
| 120 | +func withoutReasoning(body []byte) []byte { | ||
| 121 | + var m map[string]json.RawMessage | ||
| 122 | + if json.Unmarshal(body, &m) != nil { | ||
| 123 | + return body | ||
| 124 | + } | ||
| 125 | + if _, set := m["chat_template_kwargs"]; set { | ||
| 126 | + return body | ||
| 127 | + } | ||
| 128 | + m["chat_template_kwargs"] = json.RawMessage(`{"enable_thinking":false}`) | ||
| 129 | + out, err := json.Marshal(m) | ||
| 130 | + if err != nil { | ||
| 131 | + return body | ||
| 132 | + } | ||
| 133 | + return out | ||
| 134 | +} | ||
modified
internal/store/store.go +15 -4 | @@ -29,6 +29,8 @@ import ( | ||
| 29 | 29 | "strings" |
| 30 | 30 | "time" |
| 31 | 31 | |
| 32 | + "mm/internal/config" | |
| 33 | + | |
| 32 | 34 | "github.com/firebase/genkit/go/ai" |
| 33 | 35 | ) |
| 34 | 36 | |
| @@ -41,8 +43,13 @@ import ( | ||
| 41 | 43 | // investigates a session afterwards (`jq .errors <file>`, or a coding agent |
| 42 | 44 | // asked why the run went wrong and what to change in the code or the YAML). |
| 43 | 45 | type Session struct { |
| 44 | - ID string `json:"id"` | |
| 45 | - Cwd string `json:"cwd"` | |
| 46 | + ID string `json:"id"` | |
| 47 | + Cwd string `json:"cwd"` | |
| 48 | + // Provider and Model are the ones the agent ran with when the file was | |
| 49 | + // last written (Save stamps them): the first thing to know when reading | |
| 50 | + // its errors, and nowhere else in the file. | |
| 51 | + Provider string `json:"provider,omitempty"` | |
| 52 | + Model string `json:"model,omitempty"` | |
| 46 | 53 | CreatedAt time.Time `json:"createdAt"` |
| 47 | 54 | UpdatedAt time.Time `json:"updatedAt"` |
| 48 | 55 | Messages []*ai.Message `json:"messages"` |
| @@ -61,6 +68,9 @@ type Failure struct { | ||
| 61 | 68 | Message string `json:"message"` |
| 62 | 69 | Detail string `json:"detail,omitempty"` |
| 63 | 70 | Question string `json:"question,omitempty"` |
| 71 | + // Model is the model that failed — the session's Model may be another | |
| 72 | + // one by now, after a /resume under a different configuration. | |
| 73 | + Model string `json:"model,omitempty"` | |
| 64 | 74 | } |
| 65 | 75 | |
| 66 | 76 | // The places a Failure.Where can name. |
| @@ -84,7 +94,7 @@ const maxErrors = 200 | ||
| 84 | 94 | // |
| 85 | 95 | // f := store.NewFailure(asked, store.FailGeneration, e.Explain(err), err) |
| 86 | 96 | func NewFailure(msgs []*ai.Message, where, shown string, err error) Failure { |
| 87 | - f := Failure{At: time.Now().UTC(), Turn: turns(msgs), Where: where, Message: shown, Question: lastQuestion(msgs)} | |
| 97 | + f := Failure{At: time.Now().UTC(), Turn: turns(msgs), Where: where, Message: shown, Question: lastQuestion(msgs), Model: config.Cfg.Model} | |
| 88 | 98 | if err != nil && !strings.Contains(shown, err.Error()) { |
| 89 | 99 | f.Detail = err.Error() |
| 90 | 100 | } |
| @@ -148,11 +158,12 @@ func (s *Store) New(cwd string, messages []*ai.Message) (*Session, error) { | ||
| 148 | 158 | |
| 149 | 159 | // Save writes the session's file, whole, atomically (temp file + rename): a |
| 150 | 160 | // crash mid-write leaves the previous version, never a truncated one. |
| 151 | -// UpdatedAt is set to now. | |
| 161 | +// UpdatedAt is set to now, Provider and Model to the configuration's. | |
| 152 | 162 | func (s *Store) Save(sess *Session) error { |
| 153 | 163 | if err := validID(sess.ID); err != nil { |
| 154 | 164 | return err |
| 155 | 165 | } |
| 166 | + sess.Provider, sess.Model = config.Cfg.Provider, config.Cfg.Model | |
| 156 | 167 | if err := os.MkdirAll(s.dir, 0o755); err != nil { |
| 157 | 168 | return fmt.Errorf("sessions dir: %w", err) |
| 158 | 169 | } |
| @@ -29,6 +29,8 @@ import ( | |||
| 29 | "strings" | 29 | "strings" |
| 30 | "time" | 30 | "time" |
| 31 | 31 | ||
| 32 | + "mm/internal/config" | ||
| 33 | + | ||
| 32 | "github.com/firebase/genkit/go/ai" | 34 | "github.com/firebase/genkit/go/ai" |
| 33 | ) | 35 | ) |
| 34 | 36 | ||
| @@ -41,8 +43,13 @@ import ( | |||
| 41 | // investigates a session afterwards (`jq .errors <file>`, or a coding agent | 43 | // investigates a session afterwards (`jq .errors <file>`, or a coding agent |
| 42 | // asked why the run went wrong and what to change in the code or the YAML). | 44 | // asked why the run went wrong and what to change in the code or the YAML). |
| 43 | type Session struct { | 45 | type Session struct { |
| 44 | - ID string `json:"id"` | 46 | + ID string `json:"id"` |
| 45 | - Cwd string `json:"cwd"` | 47 | + Cwd string `json:"cwd"` |
| 48 | + // Provider and Model are the ones the agent ran with when the file was | ||
| 49 | + // last written (Save stamps them): the first thing to know when reading | ||
| 50 | + // its errors, and nowhere else in the file. | ||
| 51 | + Provider string `json:"provider,omitempty"` | ||
| 52 | + Model string `json:"model,omitempty"` | ||
| 46 | CreatedAt time.Time `json:"createdAt"` | 53 | CreatedAt time.Time `json:"createdAt"` |
| 47 | UpdatedAt time.Time `json:"updatedAt"` | 54 | UpdatedAt time.Time `json:"updatedAt"` |
| 48 | Messages []*ai.Message `json:"messages"` | 55 | Messages []*ai.Message `json:"messages"` |
| @@ -61,6 +68,9 @@ type Failure struct { | |||
| 61 | Message string `json:"message"` | 68 | Message string `json:"message"` |
| 62 | Detail string `json:"detail,omitempty"` | 69 | Detail string `json:"detail,omitempty"` |
| 63 | Question string `json:"question,omitempty"` | 70 | Question string `json:"question,omitempty"` |
| 71 | + // Model is the model that failed — the session's Model may be another | ||
| 72 | + // one by now, after a /resume under a different configuration. | ||
| 73 | + Model string `json:"model,omitempty"` | ||
| 64 | } | 74 | } |
| 65 | 75 | ||
| 66 | // The places a Failure.Where can name. | 76 | // The places a Failure.Where can name. |
| @@ -84,7 +94,7 @@ const maxErrors = 200 | |||
| 84 | // | 94 | // |
| 85 | // f := store.NewFailure(asked, store.FailGeneration, e.Explain(err), err) | 95 | // f := store.NewFailure(asked, store.FailGeneration, e.Explain(err), err) |
| 86 | func NewFailure(msgs []*ai.Message, where, shown string, err error) Failure { | 96 | func NewFailure(msgs []*ai.Message, where, shown string, err error) Failure { |
| 87 | - f := Failure{At: time.Now().UTC(), Turn: turns(msgs), Where: where, Message: shown, Question: lastQuestion(msgs)} | 97 | + f := Failure{At: time.Now().UTC(), Turn: turns(msgs), Where: where, Message: shown, Question: lastQuestion(msgs), Model: config.Cfg.Model} |
| 88 | if err != nil && !strings.Contains(shown, err.Error()) { | 98 | if err != nil && !strings.Contains(shown, err.Error()) { |
| 89 | f.Detail = err.Error() | 99 | f.Detail = err.Error() |
| 90 | } | 100 | } |
| @@ -148,11 +158,12 @@ func (s *Store) New(cwd string, messages []*ai.Message) (*Session, error) { | |||
| 148 | 158 | ||
| 149 | // Save writes the session's file, whole, atomically (temp file + rename): a | 159 | // Save writes the session's file, whole, atomically (temp file + rename): a |
| 150 | // crash mid-write leaves the previous version, never a truncated one. | 160 | // crash mid-write leaves the previous version, never a truncated one. |
| 151 | -// UpdatedAt is set to now. | 161 | +// UpdatedAt is set to now, Provider and Model to the configuration's. |
| 152 | func (s *Store) Save(sess *Session) error { | 162 | func (s *Store) Save(sess *Session) error { |
| 153 | if err := validID(sess.ID); err != nil { | 163 | if err := validID(sess.ID); err != nil { |
| 154 | return err | 164 | return err |
| 155 | } | 165 | } |
| 166 | + sess.Provider, sess.Model = config.Cfg.Provider, config.Cfg.Model | ||
| 156 | if err := os.MkdirAll(s.dir, 0o755); err != nil { | 167 | if err := os.MkdirAll(s.dir, 0o755); err != nil { |
| 157 | return fmt.Errorf("sessions dir: %w", err) | 168 | return fmt.Errorf("sessions dir: %w", err) |
| 158 | } | 169 | } |
modified
internal/store/store_test.go +35 -0 | @@ -8,6 +8,8 @@ import ( | ||
| 8 | 8 | "testing" |
| 9 | 9 | "time" |
| 10 | 10 | |
| 11 | + "mm/internal/config" | |
| 12 | + | |
| 11 | 13 | "github.com/firebase/genkit/go/ai" |
| 12 | 14 | ) |
| 13 | 15 | |
| @@ -210,3 +212,36 @@ func TestOldFileWithoutErrorsLoads(t *testing.T) { | ||
| 210 | 212 | t.Errorf("loaded %+v", got) |
| 211 | 213 | } |
| 212 | 214 | } |
| 215 | + | |
| 216 | +// The file says which provider and model it was written with, and each | |
| 217 | +// error which model failed: without them a session file cannot tell a | |
| 218 | +// thinking model's overrun from a server fault, and the model is the one | |
| 219 | +// fact an investigation cannot recover afterwards. | |
| 220 | +func TestSaveAndFailureNameTheModel(t *testing.T) { | |
| 221 | + old := config.Cfg | |
| 222 | + t.Cleanup(func() { config.Cfg = old }) | |
| 223 | + config.Cfg.Provider, config.Cfg.Model = "dmr", "huggingface.co/unsloth/qwen3.8-27b-gguf:Q4_K_M" | |
| 224 | + | |
| 225 | + st := Open(t.TempDir()) | |
| 226 | + msgs := history("a") | |
| 227 | + sess, err := st.New("/work", msgs) | |
| 228 | + if err != nil { | |
| 229 | + t.Fatal(err) | |
| 230 | + } | |
| 231 | + sess.Fail(NewFailure(msgs, FailCompaction, "empty summary", nil)) | |
| 232 | + | |
| 233 | + config.Cfg.Model = "another-model" // a /resume under another configuration | |
| 234 | + if err := st.Save(sess); err != nil { | |
| 235 | + t.Fatal(err) | |
| 236 | + } | |
| 237 | + got, err := st.Load(sess.ID) | |
| 238 | + if err != nil { | |
| 239 | + t.Fatal(err) | |
| 240 | + } | |
| 241 | + if got.Provider != "dmr" || got.Model != "another-model" { | |
| 242 | + t.Errorf("session provider/model = %q/%q, want dmr/another-model (the last save's)", got.Provider, got.Model) | |
| 243 | + } | |
| 244 | + if len(got.Errors) != 1 || got.Errors[0].Model != "huggingface.co/unsloth/qwen3.8-27b-gguf:Q4_K_M" { | |
| 245 | + t.Errorf("errors = %+v, want the model that failed", got.Errors) | |
| 246 | + } | |
| 247 | +} | |
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | "testing" | 8 | "testing" |
| 9 | "time" | 9 | "time" |
| 10 | 10 | ||
| 11 | + "mm/internal/config" | ||
| 12 | + | ||
| 11 | "github.com/firebase/genkit/go/ai" | 13 | "github.com/firebase/genkit/go/ai" |
| 12 | ) | 14 | ) |
| 13 | 15 | ||
| @@ -210,3 +212,36 @@ func TestOldFileWithoutErrorsLoads(t *testing.T) { | |||
| 210 | t.Errorf("loaded %+v", got) | 212 | t.Errorf("loaded %+v", got) |
| 211 | } | 213 | } |
| 212 | } | 214 | } |
| 215 | + | ||
| 216 | +// The file says which provider and model it was written with, and each | ||
| 217 | +// error which model failed: without them a session file cannot tell a | ||
| 218 | +// thinking model's overrun from a server fault, and the model is the one | ||
| 219 | +// fact an investigation cannot recover afterwards. | ||
| 220 | +func TestSaveAndFailureNameTheModel(t *testing.T) { | ||
| 221 | + old := config.Cfg | ||
| 222 | + t.Cleanup(func() { config.Cfg = old }) | ||
| 223 | + config.Cfg.Provider, config.Cfg.Model = "dmr", "huggingface.co/unsloth/qwen3.8-27b-gguf:Q4_K_M" | ||
| 224 | + | ||
| 225 | + st := Open(t.TempDir()) | ||
| 226 | + msgs := history("a") | ||
| 227 | + sess, err := st.New("/work", msgs) | ||
| 228 | + if err != nil { | ||
| 229 | + t.Fatal(err) | ||
| 230 | + } | ||
| 231 | + sess.Fail(NewFailure(msgs, FailCompaction, "empty summary", nil)) | ||
| 232 | + | ||
| 233 | + config.Cfg.Model = "another-model" // a /resume under another configuration | ||
| 234 | + if err := st.Save(sess); err != nil { | ||
| 235 | + t.Fatal(err) | ||
| 236 | + } | ||
| 237 | + got, err := st.Load(sess.ID) | ||
| 238 | + if err != nil { | ||
| 239 | + t.Fatal(err) | ||
| 240 | + } | ||
| 241 | + if got.Provider != "dmr" || got.Model != "another-model" { | ||
| 242 | + t.Errorf("session provider/model = %q/%q, want dmr/another-model (the last save's)", got.Provider, got.Model) | ||
| 243 | + } | ||
| 244 | + if len(got.Errors) != 1 || got.Errors[0].Model != "huggingface.co/unsloth/qwen3.8-27b-gguf:Q4_K_M" { | ||
| 245 | + t.Errorf("errors = %+v, want the model that failed", got.Errors) | ||
| 246 | + } | ||
| 247 | +} | ||