bots-garden/mini-mepublic Fork
⑂ be613a4
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

🛟 Updated.

k33g committed 2026-09-27T10:31:28+02:00 Browse files
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:
163163 # The model works on the last few turns, so summarising them would make it redo what it just did.
164164 keepLastTurns: 3
165165
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).
167168 summaryMaxTokens: 1200
168169
169170 # 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: 3164 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: 1200168 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:
184184 # The model works on the last few turns, so summarising them would make it redo what it just did.
185185 keepLastTurns: 3
186186
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).
188189 summaryMaxTokens: 1200
189190
190191 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: 3185 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: 1200189 summaryMaxTokens: 1200
189 190
190 showStats: true191 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
3030
3131 ## Why errors are recorded next to the history, not in it
3232
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.
3434
3535 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.
3636
@@ -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 it31 ## 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
5454 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s)
5555 ```
5656
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`:
5858
5959 ```bash
6060 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
6868 "where": "generation",
6969 "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none",
7070 "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"
7273 }
7374 ]
7475 ```
@@ -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 ```bash59 ```bash
60 jq .errors .mm/sessions/20260918-141225-02790c82.json60 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 @@
3939 | `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. |
4040 | `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. |
4141 | `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. |
4343 | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. |
4444 | `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. |
4545
@@ -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
3030
3131 ## Pourquoi les erreurs sont enregistrées à côté de l'historique, pas dedans
3232
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.
3434
3535 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.
3636
@@ -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 dedans31 ## 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
214214
215215 * un résumé précédent est *fusionné*, pas résumé à nouveau (le détail décroîtrait exponentiellement) ;
216216 * 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 ;
217218 * 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 ;
218219 * si la fenêtre est inconnue au démarrage (serveur lancé après l'agent), elle est redemandée paresseusement (`EnsureContextWindow`).
219220
@@ -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
5454 20260918-141225-02790c82 2026-09-18 16:12 0 turn(s) · ⚠️ 1 error(s)
5555 ```
5656
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` :
5858
5959 ```bash
6060 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
6868 "where": "generation",
6969 "message": "dmr: \"no/such-model:none\" not found at http://localhost:12434/engines/v1 — docker model pull no/such-model:none",
7070 "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"
7273 }
7374 ]
7475 ```
@@ -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 ```bash59 ```bash
60 jq .errors .mm/sessions/20260918-141225-02790c82.json60 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 @@
3939 | `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. |
4040 | `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. |
4141 | `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. |
4343 | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. |
4444 | `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. |
4545
@@ -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
513513 // prefill of the session (the whole old history at once) and nothing streams
514514 // until it is done. Streaming stays on so that the summary's own tokens beat
515515 // 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.
516521 func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) {
517522 // The agent's sampling, minus what does not apply here: `parallel_tool_calls`
518523 // 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
530535 beat := pl.beat
531536 // The same heartbeat, and deliberately no display: a summary is written
532537 // 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)
535543 genCtx, srvErrs := withServerErrors(genCtx)
536544 genCtx = withPromptProgress(genCtx, func(promptProgress) { pl.alive() })
537545
@@ -556,11 +564,28 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken
556564 }
557565 text := strings.TrimSpace(resp.Text())
558566 if text == "" {
559- return "", fmt.Errorf("the model returned an empty summary")
567+ return "", emptySummary(resp, maxTokens, int(reasoned.Load()))
560568 }
561569 return text, nil
562570 }
563571
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+
564589 // Names of the tools whose responses we count. They live here rather than
565590 // scattered through the display code: the counter is a measurement, and a
566591 // 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 streams513 // 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 beat514 // 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's523 // 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.beat535 beat := pl.beat
531 // The same heartbeat, and deliberately no display: a summary is written536 // 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 spill537 // 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, nil569 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 than589 // 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 a590 // 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 {
4848 probe func(ctx context.Context, b Backend) Info
4949
5050 // 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.
5253 progress bool
5354 }
5455
@@ -48,7 +48,8 @@ type openaiCompat struct {
48 probe func(ctx context.Context, b Backend) Info48 probe func(ctx context.Context, b Backend) Info
49 49
50 // progress: the server is llama.cpp and understands return_progress50 // 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 bool53 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
22
33 import (
44 "bytes"
5+ "context"
56 "encoding/json"
67 "io"
78 "net/http"
@@ -35,15 +36,41 @@ import (
3536 // what tells "the server is reading" from "the server is stuck" (progress.go).
3637 // Only for llama.cpp providers: an unknown field is a 400 on some servers.
3738
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+
3861 // 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.
4266 func streamOptions(progress bool) option.Middleware {
4367 return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
4468 if req.Method == http.MethodPost && req.Body != nil {
4569 if body, err := io.ReadAll(req.Body); err == nil {
4670 _ = req.Body.Close()
71+ if noThink, _ := req.Context().Value(noThinkKey{}).(bool); progress && noThink {
72+ body = withoutReasoning(body)
73+ }
4774 body = withStreamOptions(body, progress)
4875 req.Body = io.NopCloser(bytes.NewReader(body))
4976 req.ContentLength = int64(len(body))
@@ -85,3 +112,23 @@ func withStreamOptions(body []byte, progress bool) []byte {
85112 }
86113 return out
87114 }
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 a61 // streamOptions returns the openai-go middleware that adds those options to a
39-// request that streams and has not chosen for itself; `progress` adds62+// request that streams and has not chosen for itself; `progress` — the server
40-// return_progress. Anything else — not JSON, not streamed, the options already63+// 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 out113 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 (
2929 "strings"
3030 "time"
3131
32+ "mm/internal/config"
33+
3234 "github.com/firebase/genkit/go/ai"
3335 )
3436
@@ -41,8 +43,13 @@ import (
4143 // investigates a session afterwards (`jq .errors <file>`, or a coding agent
4244 // asked why the run went wrong and what to change in the code or the YAML).
4345 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"`
4653 CreatedAt time.Time `json:"createdAt"`
4754 UpdatedAt time.Time `json:"updatedAt"`
4855 Messages []*ai.Message `json:"messages"`
@@ -61,6 +68,9 @@ type Failure struct {
6168 Message string `json:"message"`
6269 Detail string `json:"detail,omitempty"`
6370 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"`
6474 }
6575
6676 // The places a Failure.Where can name.
@@ -84,7 +94,7 @@ const maxErrors = 200
8494 //
8595 // f := store.NewFailure(asked, store.FailGeneration, e.Explain(err), err)
8696 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}
8898 if err != nil && !strings.Contains(shown, err.Error()) {
8999 f.Detail = err.Error()
90100 }
@@ -148,11 +158,12 @@ func (s *Store) New(cwd string, messages []*ai.Message) (*Session, error) {
148158
149159 // Save writes the session's file, whole, atomically (temp file + rename): a
150160 // 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.
152162 func (s *Store) Save(sess *Session) error {
153163 if err := validID(sess.ID); err != nil {
154164 return err
155165 }
166+ sess.Provider, sess.Model = config.Cfg.Provider, config.Cfg.Model
156167 if err := os.MkdirAll(s.dir, 0o755); err != nil {
157168 return fmt.Errorf("sessions dir: %w", err)
158169 }
@@ -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 agent43 // 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): a159 // 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 err164 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 (
88 "testing"
99 "time"
1010
11+ "mm/internal/config"
12+
1113 "github.com/firebase/genkit/go/ai"
1214 )
1315
@@ -210,3 +212,36 @@ func TestOldFileWithoutErrorsLoads(t *testing.T) {
210212 t.Errorf("loaded %+v", got)
211213 }
212214 }
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+}