bots-garden/mini-mepublic Fork
⑂ 82c52a1
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-26T10:21:03+02:00 Browse files
82c52a1 parent: 460cf23
modified docker-sbx/template/Dockerfile +10 -3
@@ -128,9 +128,16 @@ ARG GOPLS_VERSION=v0.23.0
128128 ARG X_TOOLS_VERSION=v0.49.0
129129 ARG STATICCHECK_VERSION=v0.7.0
130130
131-RUN go install golang.org/x/tools/gopls@${GOPLS_VERSION} \
132- && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \
133- && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}
131+# Retried: parallel multi-arch builds occasionally hit DNS timeouts on
132+# Docker Desktop's resolver. The module cache survives within this RUN,
133+# so a retry only fetches what's still missing.
134+RUN for i in 1 2 3 4 5; do \
135+ go install golang.org/x/tools/gopls@${GOPLS_VERSION} \
136+ && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \
137+ && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION} \
138+ && exit 0; \
139+ echo "go install failed (attempt $i), retrying..." >&2; sleep $((i * 5)); \
140+ done; exit 1
134141
135142 # Prove the binary runs on this platform before the image is tagged.
136143 RUN mm --version && mm-tui --version
@@ -128,9 +128,16 @@ ARG GOPLS_VERSION=v0.23.0
128 ARG X_TOOLS_VERSION=v0.49.0128 ARG X_TOOLS_VERSION=v0.49.0
129 ARG STATICCHECK_VERSION=v0.7.0129 ARG STATICCHECK_VERSION=v0.7.0
130 130
131-RUN go install golang.org/x/tools/gopls@${GOPLS_VERSION} \131+# Retried: parallel multi-arch builds occasionally hit DNS timeouts on
132- && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \132+# Docker Desktop's resolver. The module cache survives within this RUN,
133- && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}133+# so a retry only fetches what's still missing.
134+RUN for i in 1 2 3 4 5; do \
135+ go install golang.org/x/tools/gopls@${GOPLS_VERSION} \
136+ && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \
137+ && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION} \
138+ && exit 0; \
139+ echo "go install failed (attempt $i), retrying..." >&2; sleep $((i * 5)); \
140+ done; exit 1
134 141
135 # Prove the binary runs on this platform before the image is tagged.142 # Prove the binary runs on this platform before the image is tagged.
136 RUN mm --version && mm-tui --version143 RUN mm --version && mm-tui --version
modified docs/en/reference/acp.md +3 -2
@@ -83,8 +83,8 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
8383 | `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
8484 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
8585 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
86-| Success | `stopReason: end_turn`. |
87-| Cancelled | `stopReason: cancelled`. |
86+| Success | `stopReason: end_turn`, plus `usage` (the session's **totals**: `inputTokens`, `outputTokens`, `totalTokens`, and `thoughtTokens` / `cachedReadTokens` when the server reported them) once the server has reported any count; absent otherwise. Also on `/new` and `/compact`. |
87+| Cancelled | `stopReason: cancelled`, with `usage` as above. |
8888 | Failure | `internal error` with `error: <one-line explanation>`. |
8989 | Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |
9090
@@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
9797 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
9898 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
9999 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
100+| `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. |
100101
101102 ## Available commands
102103
@@ -83,8 +83,8 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
83 | `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |83 | `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
84 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |84 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
85 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |85 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
86-| Success | `stopReason: end_turn`. |86+| Success | `stopReason: end_turn`, plus `usage` (the session's **totals**: `inputTokens`, `outputTokens`, `totalTokens`, and `thoughtTokens` / `cachedReadTokens` when the server reported them) once the server has reported any count; absent otherwise. Also on `/new` and `/compact`. |
87-| Cancelled | `stopReason: cancelled`. |87+| Cancelled | `stopReason: cancelled`, with `usage` as above. |
88 | Failure | `internal error` with `error: <one-line explanation>`. |88 | Failure | `internal error` with `error: <one-line explanation>`. |
89 | Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |89 | Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |
90 90
@@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
97 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |97 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
98 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |98 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
99 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |99 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
100+| `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. |
100 101
101 ## Available commands102 ## Available commands
102 103
modified docs/en/reference/configuration.md +1 -1
@@ -21,7 +21,7 @@
2121 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
2222 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |
2323 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
24-| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |
24+| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
2525 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
2626 | `context` | map | see below | Automatic history compression. |
2727 | `sessions` | map | see below | Persistent, per-project sessions. |
@@ -21,7 +21,7 @@
21 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |21 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
22 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |22 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |
23 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |23 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
24-| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |24+| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
25 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |25 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
26 | `context` | map | see below | Automatic history compression. |26 | `context` | map | see below | Automatic history compression. |
27 | `sessions` | map | see below | Persistent, per-project sessions. |27 | `sessions` | map | see below | Persistent, per-project sessions. |
added docs/fr/explanation/petits-llms.adoc +447 -0
new file mode 100644
@@ -0,0 +1,447 @@
1+= Mini Me et les petits LLMs
2+:toc: left
3+:toclevels: 2
4+:toc-title: Sommaire
5+:sectnums:
6+:icons: font
7+:source-highlighter: rouge
8+:lang: fr
9+:revdate: 2026-09-26
10+
11+[abstract]
12+Mini Me (`mm`) a été conçu pour tourner avec des modèles *locaux* — typiquement 2 à 12 milliards de paramètres servis par Docker Model Runner ou llama-server.
13+Ce document relève, dans l'implémentation, ce qui rend l'agent utilisable avec ces modèles, puis propose des pistes d'amélioration chiffrées en *effort* et en *résultat attendu*.
14+
15+== Le point de départ : ce qu'un petit modèle fait mal
16+
17+Tout le code de `mm` se lit comme une réponse à une liste de défaillances observées (souvent datées et mesurées dans les commentaires) :
18+
19+[cols="1,2",options="header"]
20+|===
21+| Défaillance | Manifestation typique
22+
23+| Hallucination
24+| Le modèle « cite » un fichier ou une sortie de commande qu'il n'a jamais lus.
25+
26+| Imprécision sur les octets
27+| Il retape un bloc à éditer avec des espaces là où le fichier a des tabulations, ou ajoute une ligne vide.
28+
29+| Boucles
30+| Il répète le même appel refusé — trente fois, observé le 18/09/2026 avec un modèle 12B.
31+
32+| Déraillement en cours de génération
33+| Il compose des arguments d'outil qui se répètent pendant des minutes (`"Greet 2023-06-15 11:16:00 …"`).
34+
35+| Appel d'outil trop long
36+| Il écrit tout un document dans un seul `write_file`, qui dépasse `max_tokens` et arrive coupé (observé le 26/09/2026).
37+
38+| Raisonnement long
39+| Un modèle « thinking » réfléchit 38 s sans émettre un octet de contenu.
40+
41+| Petite fenêtre de contexte
42+| La fenêtre est fixée au chargement du modèle ; une session longue finit en « stalled » puis en erreur 400.
43+
44+| Routage faible
45+| Il ne relie pas « salue Bob » à la procédure nommée `greetings`.
46+|===
47+
48+L'idée directrice : *ne pas demander au modèle ce que le code peut garantir*, et quand le modèle se trompe, *lui rendre une information exploitable plutôt qu'un échec muet*.
49+
50+== Une surface d'outils petite, configurable et explicite
51+
52+=== Bash d'abord, peu d'outils
53+
54+L'agent est né autour d'un seul outil, `bash` (`internal/tools/tools.go`). Les autres sont optionnels et désactivables un par un dans le YAML : `bashTool`, `editTools` (`read_file`, `write_file`, `edit_file`), `planTool`. Deux outils ne sont déclarés *que s'ils servent* :
55+
56+* `read_skill` n'existe pas si `skillsDir` ne contient aucun `.md` ;
57+* `lsp` n'existe pas si aucun serveur de langage n'est configuré *et* présent dans le `PATH`.
58+
59+Chaque outil déclaré coûte des tokens à chaque requête et une option de plus parmi lesquelles un petit modèle doit choisir. Moins il y en a, mieux il choisit.
60+
61+=== Des descriptions écrites pour être suivies
62+
63+Les descriptions d'outils ne décrivent pas seulement l'API, elles anticipent les erreurs :
64+
65+* `bash` : « Each call starts a NEW shell … a "cd" does not carry over », « there is no stdin, so never use a command that asks a question », « Read before you write ».
66+* `read_file` : « edit_file needs the EXACT text of the file … Do not type it from memory. »
67+* `edit_file` : six règles *numérotées*, dont « do not switch to write_file to force the change through ».
68+* `write_file` : rappelle qu'il écrase tout et renvoie vers `edit_file`.
69+
70+Le prompt système des configurations livrées (`agent.dmr.yaml`, `agent.llamacpp.yaml`) répète ces règles en sections courtes en majuscules (`SKILLS`, `FILE EDITING`, `RULES`, `BACKGROUND JOBS`). La redondance est voulue : un petit modèle suit mieux une consigne qu'il lit deux fois, à deux endroits.
71+
72+=== Un échantillonnage conservateur
73+
74+Les valeurs par défaut (`internal/config/config.go`) et les YAML livrés fixent `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` et `parallel_tool_calls: false`. Un seul appel d'outil à la fois est plus simple à produire correctement pour un petit modèle, et plus lisible dans l'historique.
75+
76+== Le contrat des outils : un échec est du texte
77+
78+C'est le principe structurant, énoncé en tête de `internal/tools/tools.go` :
79+
80+[quote]
81+We NEVER return a Go `error` for a "normal" failure. The error goes back AS TEXT, so that the model reads it and reacts.
82+
83+Commande en échec (`[exit code: …]`), délai dépassé (`[error: 30s timeout exceeded]`), fichier absent (« Create it with write_file »), édition ambiguë : tout revient au modèle comme résultat d'outil. Une erreur Go terminerait le tour et le modèle n'aurait aucune chance de se corriger.
84+
85+Les messages sont rédigés comme des *consignes de réparation* :
86+
87+* `read_skill` avec un nom inconnu répond `[skill: no skill named "x" — available: a, b, c]` ;
88+* `edit_file` avec un texte en double donne les numéros de ligne des occurrences et explique comment rendre `old` unique (`internal/fileedit/edits.go`).
89+
90+La seule exception est volontaire : l'arrêt par le détecteur de boucle (voir <<boucles>>).
91+
92+Certaines erreurs échappent pourtant aux outils, parce qu'elles se produisent *avant* eux, dans Genkit. C'est le cas d'un appel dont les arguments ne sont pas du JSON valide : il est désormais rendu au modèle lui aussi (voir <<args>>). Les erreurs de validation de schéma et les noms d'outils inventés restent à traiter (pistes <<p1>> et <<p2>>).
93+
94+== Une édition de fichiers tolérante, mais sûre
95+
96+`internal/fileedit` est probablement la partie la plus « petits modèles » du code.
97+
98+=== Trois passes de correspondance, de la plus stricte à la plus souple
99+
100+`findAll` (`internal/fileedit/match.go`) cherche `old` :
101+
102+. *exactement* ;
103+. en ignorant les *bords* (ligne vide au début, indentation d'une ligne unique, ligne vide finale) ;
104+. en ignorant l'*indentation* ligne par ligne, pour un bloc de plusieurs lignes.
105+
106+La première passe qui trouve quelque chose l'emporte : un texte présent tel quel n'est jamais apparié autrement. La souplesse ne s'active que là où la règle stricte a déjà échoué — typiquement le « ticket 27 », des espaces envoyés pour un fichier Go indenté par tabulations.
107+
108+=== Ce qui n'est *pas* relâché
109+
110+* *L'unicité* : deux correspondances souples sont refusées exactement comme deux correspondances exactes.
111+* *L'indentation du fichier* : `reindent` (`internal/fileedit/indent.go`) réécrit `new` dans l'indentation du fichier (niveau de base *et* unité, quatre espaces devenant une tabulation), pour ne pas insérer un bloc d'espaces dans un fichier à tabulations.
112+* *La transparence* : le résumé signale « N matched ignoring whitespace », pour que le modèle recopie mieux la fois suivante.
113+
114+=== Un « not found » qui aide
115+
116+`notFound` + `nearest` montrent les lignes du fichier qui s'en approchent le plus (au moins la moitié des lignes doivent coïncider), avec leurs numéros : « copy these lines ». Un modèle à qui l'on dit seulement « introuvable » retape le même texte ; un modèle à qui l'on montre le bon texte le recopie.
117+
118+=== Des détails invisibles neutralisés
119+
120+`internal/fileedit/textfile.go` retire le BOM UTF-8 et convertit les fins de ligne CRLF avant l'édition, puis les restaure. Sans cela, *chaque* `old` multi-ligne écrit avec `\n` par un modèle échouerait sur un fichier Windows.
121+
122+S'y ajoutent `dry_run`, des modifications résolues toutes contre le fichier *original* (pas d'effets de bord entre modifications), le refus des chevauchements, et un diff numéroté en retour, qui sert de preuve au modèle.
123+
124+== Contre l'hallucination : montrer, puis seulement commenter
125+
126+* *La sortie est affichée avant que le modèle la voie.* `echoOutput` (`internal/tools/tools.go`) montre les `previewLines` premières lignes à l'utilisateur. Observé : sur « affiche agent.yaml », le modèle lançait `cat`, jugeait la demande satisfaite, et l'utilisateur ne voyait rien.
127+* *Le prompt interdit d'affirmer sans preuve* : « Never state the contents of a file … unless a command in THIS answer returned it. »
128+* *Les pièces jointes sont des chemins, pas du contenu* : `@chemin` (`internal/mention`) et le `resource_link` ACP deviennent `[attached file: /abs/path]`. Le modèle doit lire le fichier avec ses outils, et la lecture est visible.
129+* *L'historique complet est conservé même quand un tour échoue* (`session.AfterTurn`). Genkit ne renvoie que le dernier message ; `engine.Generate` capture la conversation entière via un middleware `WrapModel`. Sans cela, le modèle réinvente au tour suivant les commandes qu'il a déjà lancées.
130+
131+[[boucles]]
132+== Garde-fous contre les boucles et les blocages
133+
134+Quatre mécanismes, du plus doux au plus radical :
135+
136+[cols="1,2,1",options="header"]
137+|===
138+| Mécanisme | Déclencheur | Réaction
139+
140+| Détecteur de boucle, niveau 1 (`internal/detector`)
141+| Même triplet (outil, entrée, sortie) 3 fois de suite
142+| `[LOOP_DETECTED: … try a different approach]` ajouté à la sortie
143+
144+| Détecteur, niveau 2
145+| 3 répétitions supplémentaires malgré l'avertissement
146+| Erreur Go `Stuck` : fin du tour, pas de nouvelle tentative, enregistrée dans la session
147+
148+| Garde des arguments (`internal/engine/runaway.go`)
149+| Arguments d'outil en streaming périodiques (motif de 4 à 64 octets sur les 256 derniers) ou > 256 Kio
150+| Génération interrompue immédiatement, avec un message explicite
151+
152+| Watchdog (`engine.watchdog`)
153+| Aucun token depuis `watchdogTimeout`
154+| Annulation : « stalled »
155+|===
156+
157+Plusieurs choix fins méritent d'être notés :
158+
159+* Le détecteur n'est *pas* remis à zéro par `/compact` (oublier la conversation ne rend pas nouvelle une commande répétée), mais il l'est par `/new`.
160+* La relance sans streaming n'a lieu *que* si rien n'a été affiché et qu'aucun outil n'a tourné. Sinon, rejouer le tour rejouerait les commandes (mesuré : un `echo >>` écrit deux fois).
161+* Le spinner indique « Composing a tool call » : un utilisateur sait que le modèle travaille, et non qu'il est bloqué.
162+* `maxTurns` borne les allers-retours modèle ↔ outils (10 par défaut, 40 dans `agent.dmr.yaml`).
163+* `bash` a son propre budget de 30 s et écrit dans un fichier temporaire plutôt que dans un pipe, pour qu'un job lancé en arrière-plan sans redirection ne bloque pas le tour indéfiniment (ticket 26). Le prompt et le skill `bg-jobs` apprennent au modèle la bonne forme `nohup … > log 2>&1 &`.
164+
165+[[args]]
166+== Appels d'outils coupés ou mal formés
167+
168+=== Le cas qui l'a motivé
169+
170+Session du 26/09/2026, Qwen3 27B servi par llama-server, `max_tokens: 4096`. On demande au modèle d'écrire un document. Il explore le dossier (trois `bash`, deux `read_file`), puis compose un seul `write_file` avec tout le document : ≈ 12 500 caractères, soit ≈ 4 000 tokens. Le serveur coupe la génération à la limite (`finish_reason: "length"`), en pleine phrase.
171+
172+Le greffon OpenAI de Genkit décode les arguments en convertissant la réponse du serveur (`compat_oai/generate.go` : `could not parse tool args: … unexpected end of JSON input`). Cette erreur *termine la génération*. Le modèle ne la lit jamais, et l'utilisateur voyait comme message d'erreur le document tronqué en entier.
173+
174+Deux causes se cumulent, typiques des modèles locaux :
175+
176+* un petit modèle veut tout faire en un appel, même quand le résultat est long ;
177+* avec un modèle « thinking », le raisonnement consomme aussi le budget `max_tokens`.
178+
179+=== Ce que fait `mm`
180+
181+`internal/engine/toolargs.go`, branché dans le hook `WrapModel` de `engine.Generate` :
182+
183+. Deux cas sont reconnus : un JSON *coupé* (`unexpected end of JSON input`) et un JSON *mal formé*.
184+. L'appel est rendu au modèle, deux fois au plus par génération. `mm` ajoute une note à la conversation, puis relance l'appel au modèle. Dans le cas coupé, la note donne un budget chiffré : « écris seulement la première partie (≈ 6 000 caractères au plus pour 4 096 tokens), puis ajoute la suite avec `edit_file` ».
185+. La note est ajoutée au `ModelRequest` que Genkit réutilise pour les tours suivants. Elle reste donc dans la conversation, et le modèle la relit en écrivant la partie suivante.
186+. La relance sans streaming n'est pas tentée pour cette erreur : la même limite couperait le même appel.
187+. Une fois les deux tentatives épuisées, `Explain` donne une ligne exploitable au lieu du JSON : « the model hit max_tokens (4096) while writing a tool call … raise sampling.max_tokens … ».
188+
189+=== Ce que la mesure a montré
190+
191+Test réel sur Docker Model Runner avec Mellum 12B, `max_tokens` volontairement réduit à 300 puis 800 :
192+
193+* le mécanisme fonctionne : le modèle reprend la main deux fois, puis le message clair s'affiche ;
194+* *le modèle a ignoré la consigne* les deux fois et a retenté le fichier entier, même avec un budget chiffré en caractères.
195+
196+La relance rend l'échec lisible et laisse une chance aux modèles qui suivent les instructions. Elle ne remplace pas un `max_tokens` adapté : 16 384 est une valeur raisonnable pour un modèle « thinking », avec une marge sous la fenêtre de contexte. Voir aussi la piste <<p15>>.
197+
198+== Modèles à raisonnement
199+
200+Le greffon OpenAI de Genkit ignore `reasoning_content`. `mm` le lit lui-même, une couche plus bas, via un middleware HTTP qui parcourt le flux SSE sans le modifier (`internal/engine/reasoning.go`). Deux effets :
201+
202+* *Le watchdog reste alimenté* pendant que le modèle réfléchit ; sans cela, `qwen3.5-2b` était tué après 38 s de raisonnement légitime.
203+* Le raisonnement est *affiché* (bloc 💭, `agent_thought_chunk` en ACP) mais *n'entre jamais dans l'historique* : il ne consomme pas le contexte des tours suivants.
204+
205+== Le contexte : une ressource rare
206+
207+=== Des sorties bornées
208+
209+`truncate` limite chaque sortie d'outil à `maxOutput` caractères en conservant *le début et la fin* (la fin porte souvent l'erreur ou le résumé d'une commande). `bash` ne relit jamais plus d'1 Mio par extrémité depuis le disque.
210+
211+=== Une compression hybride
212+
213+`internal/compact` remplace les vieux tours par *un* résumé écrit par le modèle et garde les `keepLastTurns` derniers tours intacts (le modèle travaille sur eux). Points notables :
214+
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 ;
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+* si la fenêtre est inconnue au démarrage (serveur lancé après l'agent), elle est redemandée paresseusement (`EnsureContextWindow`).
219+
220+=== Le compte exact du serveur
221+
222+Depuis le 26/09/2026, un middleware ajoute `stream_options.include_usage` aux requêtes en streaming (`internal/engine/usage.go`). Sans lui, Docker Model Runner n'envoyait aucun `usage` en streaming, et le déclencheur de compression ne voyait jamais la mesure exacte. Ce compte alimente aussi `usage_update` en ACP.
223+
224+== Skills : aider le routage
225+
226+Un petit modèle choisit mal une procédure sur son seul nom. `internal/skills` construit le *catalogue* dans la description de l'outil `read_skill` :
227+
228+* chaque skill y figure avec sa description et au plus 8 *mots-clés* : le pont lexical entre « salue Bob » et `greetings` (via « saluer ») ; le plafond de 8 vient du fait que le catalogue est payé à chaque requête ;
229+* la notation `$skill paramètres` et la commande `/skill` (en ACP) sont réécrites en une ligne explicite : `[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob]`. La décision est prise *pour* le modèle ;
230+* les skills livrés sont écrits pour un petit modèle : « The command below is for YOU to run with your `bash` tool, in this same turn. Do not show it to the user. »
231+
232+`internal/eval` mesure le routage contre un vrai modèle (`MM_EVAL=1`), un seul tour par cas, avec des outils factices. Les chiffres se lisent en *comparaison* avant/après une modification du catalogue ou du prompt.
233+
234+== Robustesse côté serveur local
235+
236+* *Fournisseurs* : Docker Model Runner et llama-server sont deux paramétrages d'un même client OpenAI-compatible (`internal/engine/openai_compat.go`), avec une URL de repli (`host.docker.internal`) pour un agent en conteneur.
237+* *Erreurs expliquées en une ligne* : `Explain` traduit « connection refused » en « start it: llama-server … --jinja », signale l'absence de `--jinja` (nécessaire aux appels d'outils), et le 401 sur un serveur sans clé.
238+* *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine.
239+
240+== Une méthode : mesurer
241+
242+La plupart des décisions sont justifiées dans le code par une *mesure datée* (« measured on qwen3.5-2b », « the demo audit of 2026-09-18 », « ticket 27 »). Le reste de `mm` est testé contre un moteur factice (rapide, déterministe) ; seul le routage des skills, qui est une propriété du modèle, est mesuré contre un vrai modèle. Garder cette discipline est la meilleure garantie que les améliorations ci-dessous en soient vraiment.
243+
244+[[pistes]]
245+== Pistes d'amélioration
246+
247+Échelle d'effort : *faible* ≈ moins d'une journée, *moyen* ≈ quelques jours, *élevé* ≈ une semaine ou plus, mesures comprises.
248+Le résultat est celui attendu sur un petit modèle local ; « à mesurer » signale un gain plausible mais non démontré.
249+
250+=== Vue d'ensemble
251+
252+[cols="3,1,1,4",options="header"]
253+|===
254+| Piste | Effort | Résultat | En une phrase
255+
256+| <<p1>> | Faible | Élevé | Rendre au modèle, en texte, les erreurs de validation d'arguments qui terminent encore le tour.
257+| <<p2>> | Moyen | Élevé | Un nom d'outil inventé (`python`, `shell`…) ne doit pas tuer le tour.
258+| <<p3>> | Faible | Élevé | 16 000 caractères de sortie, c'est la moitié d'une fenêtre de 8k.
259+| <<p4>> | Moyen | Élevé | Masquer les vieilles sorties d'outils avant de recourir au résumé.
260+| <<p5>> | Faible | Moyen | Donner au modèle une dernière chance de répondre quand `maxTurns` est atteint.
261+| <<p6>> | Faible | Moyen | Sortir les balises `<think>` du texte de réponse et de l'historique.
262+| <<p7>> | Faible | Moyen | Réduire le coût fixe du prompt (≈ 3,4k tokens mesurés).
263+| <<p8>> | Faible à moyen | Moyen | Détecter aussi les cycles A→B→A et les quasi-doublons.
264+| <<p9>> | Faible | Moyen | Connaître la fenêtre de contexte avec Docker Model Runner.
265+| <<p10>> | Faible | Moyen | Exploiter `cached_tokens` et `timings` (cache KV, vitesse de prefill).
266+| <<p11>> | Moyen | Élevé (sur certains modèles) | Récupérer les appels d'outils écrits en texte.
267+| <<p12>> | Moyen à élevé | Élevé (pour décider) | Étendre l'évaluation aux tâches complètes, modèle par modèle.
268+| <<p13>> | Faible | Moyen | Profils par modèle (échantillonnage, variantes de prompt).
269+| <<p14>> | Moyen | À mesurer | Varier la température après un `LOOP_DETECTED`.
270+| <<p15>> | Faible | Moyen | Signaler une réponse coupée par `max_tokens`, et prévenir les appels trop longs.
271+|===
272+
273+Ordre conseillé : 1, 3, 5, 6 et 15 (effort faible, gain net), puis 2 et 4, puis 12 pour mesurer le reste.
274+
275+[[p1]]
276+=== Erreurs d'arguments rendues en texte
277+
278+*Constat.* Dans Genkit v1.10, une erreur d'exécution d'outil (`ai/generate.go`, `handleToolRequests`) devient `tool "x" failed: …` et *termine la génération*. Cela concerne notamment des arguments qui ne passent pas la validation du schéma (champ manquant, `"cmd"` au lieu de `"command"`). C'est une faute fréquente chez un petit modèle, et elle contourne le principe « un échec est du texte ».
279+
280+Le cas voisin des arguments qui ne sont pas du JSON valide (JSON coupé par `max_tokens`) est traité depuis le 26/09/2026 (voir <<args>>). Il est intercepté plus tôt, dans `WrapModel`, parce que l'erreur survient pendant la conversion de la réponse, avant tout outil.
281+
282+*Proposition.* Dans le hook `WrapTool` déjà installé par `engine.Generate`, convertir toute erreur qui n'est pas `detector.Stuck` en réponse texte : `[error: invalid arguments for edit_file: … — expected {path, edits:[{old,new}]}]`. Le rappel du schéma attendu vaut mieux que le message brut de validation.
283+
284+*Effort : faible* (un hook existant, un test avec un faux outil). *Résultat : élevé* — un tour perdu devient une correction au tour suivant. À vérifier d'abord : que la validation d'entrée a bien lieu à l'intérieur de `next()` dans `WrapTool`.
285+
286+[[p2]]
287+=== Outils inventés
288+
289+*Constat.* Un nom d'outil inconnu produit `tool "python" not found` *avant* tout middleware d'outil : le tour s'arrête.
290+
291+*Proposition.* Dans `WrapModel`, après la réponse du modèle, renommer les `ToolRequest` inconnues vers un outil interne (par exemple `_unknown_tool`) qui répond `[error: there is no tool named "python"; your tools are: bash, read_file, …]`. Un alias explicite pour les confusions fréquentes (`shell`, `run`, `execute` → `bash`) serait une variante plus agressive, à réserver aux cas mesurés.
292+
293+*Effort : moyen* (manipuler la réponse et la déclaration d'un outil caché). *Résultat : élevé* sur les modèles de 2 à 4B, qui inventent volontiers des outils.
294+
295+[[p3]]
296+=== `maxOutput` proportionnel à la fenêtre
297+
298+*Constat.* `maxOutput: 16000` caractères représente ≈ 4 à 5k tokens. Sur une fenêtre de 8k, *une seule* sortie de `cat` occupe la moitié du contexte, avec un coût fixe de ≈ 3,4k tokens déjà mesuré.
299+
300+*Proposition.* Quand la fenêtre est connue et que `maxOutput` n'est pas fixé explicitement, le calculer, par exemple à 15 % de la fenêtre × 3,5 caractères/token, borné entre 2 000 et 16 000. Le message de troncature invite déjà à relire une plage (`read_file start/end`, `grep`, `head`).
301+
302+*Effort : faible.* *Résultat : élevé* sur les fenêtres de 4 à 16k, nul au-delà.
303+
304+[[p4]]
305+=== Masquage des vieilles sorties d'outils
306+
307+*Constat.* La compression actuelle est le seul moyen de réduire l'historique, et elle coûte un appel au modèle, un long prefill et un risque de résumé de mauvaise qualité avec un petit modèle.
308+
309+*Proposition.* Avant chaque requête, remplacer le *contenu* des résultats d'outils plus anciens que les N derniers tours par un talon : `[output of "go test ./..." elided — 212 lines, exit 1; rerun it if needed]`. Les appels, les arguments et les réponses du modèle restent. Des travaux publiés en 2025 sur les agents de code (« observation masking ») rapportent des gains comparables à la synthèse par LLM pour un coût bien moindre ; à confirmer sur `mm` avec l'éval de la piste 12. La compression par résumé reste le second niveau.
310+
311+*Effort : moyen* (transformation dans `WrapModel` ou avant `Generate`, sans toucher à l'historique persistant). *Résultat : élevé* sur les longues sessions : moins de compressions, moins de « stalled ».
312+
313+[[p5]]
314+=== Dernière chance à `maxTurns`
315+
316+*Constat.* Quand `maxTurns` est atteint, le tour se termine sur une erreur ; l'utilisateur n'a pas de réponse alors que le travail est souvent fait.
317+
318+*Proposition.* Intercepter ce cas et faire *un* appel supplémentaire sans outils, avec la consigne « summarise what you did and what remains ». Il faut distinguer ce cas du watchdog et du détecteur de boucle, qui ne doivent pas en bénéficier.
319+
320+*Effort : faible.* *Résultat : moyen* — une fin de tour lisible plutôt qu'une erreur.
321+
322+[[p6]]
323+=== Balises `<think>` dans le contenu
324+
325+*Constat.* Documenté comme non géré (`thinking-models.md`) : certains modèles (Qwen3 sans analyseur côté serveur, DeepSeek-R1 distillés…) mettent leur raisonnement dans `content` entre `<think>…</think>`. Ce texte est affiché comme une réponse et, surtout, *entre dans l'historique*, où il consomme le contexte de tous les tours suivants.
326+
327+*Proposition.* Un petit filtre à états dans `emit` qui route le texte entre balises vers `Reasoning`, et le même nettoyage sur le message final avant `AfterTurn`.
328+
329+*Effort : faible.* *Résultat : moyen*, élevé pour les modèles concernés.
330+
331+[[p7]]
332+=== Coût fixe du prompt
333+
334+*Constat.* Mesuré sur `agent.dmr.yaml` : la première requête d'une session pèse ≈ 3,4k tokens avant toute question (prompt système ≈ 3,7k caractères, six descriptions d'outils et leurs schémas, catalogue de skills). La description de `plan` à elle seule fait ≈ 1,3k caractères, pour un outil utile seulement aux demandes en plusieurs étapes.
335+
336+*Proposition.*
337+
338+* Condenser la description de `plan` (l'exemple de workflow en sept étapes pourrait passer dans un skill), ou désactiver `planTool` par défaut dans les configurations pour petits modèles.
339+* Éviter les doublons entre prompt système et descriptions quand ils ne servent pas.
340+* Mesurer chaque retrait avec `internal/eval` : la redondance est parfois ce qui fait marcher un 4B.
341+
342+*Effort : faible.* *Résultat : moyen* — prefill plus rapide à chaque requête, et plus de place pour le travail sur les petites fenêtres.
343+
344+[[p8]]
345+=== Détecteur de boucle plus fin
346+
347+*Constat.* Le détecteur ne voit que des triplets *identiques et consécutifs*. Il manque :
348+
349+* les cycles A→B→A→B (lire, éditer refusé, lire, éditer refusé) ;
350+* les quasi-doublons (le même `edit_file` refusé, avec un `old` qui varie d'un espace).
351+
352+*Proposition.* Ajouter une détection de période 2 à 3 sur l'historique, et une clé normalisée pour les refus d'`edit_file` (chemin + message d'erreur, sans le texte exact).
353+
354+*Effort : faible à moyen.* *Résultat : moyen* — des boucles aujourd'hui arrêtées seulement par `maxTurns` ou par l'utilisateur.
355+
356+[[p9]]
357+=== Fenêtre de contexte avec Docker Model Runner
358+
359+*Constat.* Avec `agent.dmr.yaml` (`contextWindow: 0`), la bannière affiche `ctx: unknown` : la compression ne peut se déclencher que sur le nombre de messages, et `usage_update` part avec `size: 0`.
360+
361+*Proposition.* Chercher si DMR expose la taille configurée (configuration du modèle, ou passage vers le `/props` du llama.cpp sous-jacent). À défaut, afficher un avertissement explicite au démarrage quand `context.enabled` est vrai et que la fenêtre est inconnue, avec la commande pour la connaître.
362+
363+*Effort : faible.* *Résultat : moyen.*
364+
365+[[p10]]
366+=== Exploiter `cached_tokens` et `timings`
367+
368+*Constat.* DMR et llama-server renvoient `prompt_tokens_details.cached_tokens` et `timings` (vitesse de prefill et de génération), mais l'accumulateur d'openai-go n'additionne que les trois compteurs de base.
369+
370+*Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite :
371+
372+* afficher le taux de cache KV ;
373+* repérer ce qui casse la stabilité du préfixe (un prompt système qui change, un catalogue réordonné) ;
374+* alimenter `cachedReadTokens` en ACP.
375+
376+Sur une machine locale, un préfixe en cache fait la différence entre une réponse immédiate et plusieurs secondes de prefill.
377+
378+*Effort : faible.* *Résultat : moyen*, surtout comme outil de diagnostic.
379+
380+[[p11]]
381+=== Appels d'outils écrits en texte
382+
383+*Constat.* Quand le template de chat ou l'analyseur du serveur échoue, certains modèles écrivent leur appel dans le texte (`<tool_call>{"name":"bash",…}</tool_call>`, ou un JSON nu). Le tour se termine alors sur une « réponse » qui n'en est pas une.
384+
385+*Proposition.* Détecter ce cas en fin de tour, quand la réponse est un *seul* bloc qui ressemble à un appel d'outil valide pour un outil déclaré, et le convertir en vrai appel. Il faut rester strict pour éviter les faux positifs, par exemple quand l'utilisateur a demandé un exemple de JSON.
386+
387+*Effort : moyen.* *Résultat : élevé sur les modèles concernés*, nul sur les autres. À faire *après* avoir mesuré la fréquence du cas.
388+
389+[[p12]]
390+=== Évaluation de bout en bout
391+
392+*Constat.* `internal/eval` mesure le routage des skills sur un seul tour. Les questions qui comptent — ce modèle édite-t-il correctement ? combien de tours ? combien de boucles ? — restent sans réponse chiffrée.
393+
394+*Proposition.* Une petite suite de tâches reproductibles (« renomme cette fonction », « corrige ce test », « ajoute un champ ») exécutées dans un répertoire temporaire, en vérifiant le *résultat* (compilation, tests, diff attendu) et en relevant les tours, les `LOOP_DETECTED`, les correspondances souples et les tokens (désormais disponibles). Lancée à la demande, comme l'éval actuelle.
395+
396+*Effort : moyen à élevé.* *Résultat : élevé* — c'est ce qui permet de trancher toutes les autres pistes « à mesurer » et de comparer les modèles.
397+
398+[[p13]]
399+=== Profils par modèle
400+
401+*Constat.* Les réglages utiles varient d'une famille à l'autre : pénalité de répétition, `top_k`, `min_p`, gestion du rôle `system`, longueur de prompt supportée. `tests/` contient déjà des variantes par modèle (`agent.dmr-qwen.yaml`, `agent.dmr-gemma.yaml`).
402+
403+*Proposition.* Une clé `profile:` (ou un fichier de profils) qui applique des valeurs par défaut connues par famille de modèle, surchargées par le YAML.
404+
405+*Effort : faible.* *Résultat : moyen*, à mesurer avec la piste 12.
406+
407+[[p14]]
408+=== Température adaptative après une boucle
409+
410+*Constat.* À `temperature: 0`, un modèle qui s'est trompé a tendance à reproduire exactement la même erreur, ce que le détecteur constate ensuite.
411+
412+*Proposition.* Après un `LOOP_DETECTED`, faire le ou les appels suivants à une température légèrement plus haute (0,3 à 0,5), puis revenir à 0.
413+
414+*Effort : moyen* (changer l'échantillonnage en cours de tour sous Genkit). *Résultat : à mesurer* — plausible, mais peut aussi dégrader la précision des éditions.
415+
416+[[p15]]
417+=== Réponses coupées par `max_tokens`
418+
419+*Constat.* Le cas d'un *appel d'outil* coupé est traité (<<args>>). Une *réponse texte* coupée ne l'est pas : la génération s'arrête avec `finish_reason: "length"`, et `mm` l'affiche comme une réponse complète. L'utilisateur lit une phrase inachevée sans savoir pourquoi. La mesure montre aussi que la consigne « procède par morceaux » arrive trop tard, une fois l'appel déjà coupé.
420+
421+*Proposition.*
422+
423+* Lire `FinishReason` dans `WrapModel` (Genkit le transmet : `FinishReasonLength`) et, pour une réponse texte, ajouter `[answer cut off at max_tokens (N)]`, avec le même conseil que pour les appels d'outils.
424+* *Prévenir* plutôt que guérir : une phrase dans la description de `write_file` (« pour un fichier de plus de ≈ N caractères, écris la première partie puis complète avec edit_file »), avec N calculé à partir de `max_tokens`.
425+* Au démarrage, avertir quand `max_tokens` est bas pour un modèle à raisonnement (le raisonnement consomme le même budget).
426+
427+*Effort : faible.* *Résultat : moyen* — plus de réponses tronquées en silence ; l'effet de la consigne préventive reste à mesurer avec la piste <<p12>>.
428+
429+== Références dans le code
430+
431+[cols="1,2",options="header"]
432+|===
433+| Sujet | Fichiers
434+
435+| Contrat des outils, `bash`, troncature | `internal/tools/tools.go`
436+| Outils fichiers | `internal/tools/files.go`, `internal/fileedit/`
437+| Détecteur de boucle | `internal/detector/detector.go`
438+| Arguments qui s'emballent | `internal/engine/runaway.go`
439+| Watchdog, relance, historique complet | `internal/engine/engine.go`
440+| Raisonnement | `internal/engine/reasoning.go`, `internal/engine/thoughts.go`
441+| Usage en streaming | `internal/engine/usage.go`
442+| Appels d'outils coupés ou mal formés | `internal/engine/toolargs.go`
443+| Compression | `internal/compact/`
444+| Skills et catalogue | `internal/skills/`, `skills/`
445+| Évaluation du routage | `internal/eval/`
446+| Explications d'erreurs | `internal/engine/explain.go`, `internal/engine/openai_compat.go`
447+|===
new file mode 100644
@@ -0,0 +1,447 @@
1+= Mini Me et les petits LLMs
2+:toc: left
3+:toclevels: 2
4+:toc-title: Sommaire
5+:sectnums:
6+:icons: font
7+:source-highlighter: rouge
8+:lang: fr
9+:revdate: 2026-09-26
10+
11+[abstract]
12+Mini Me (`mm`) a été conçu pour tourner avec des modèles *locaux* — typiquement 2 à 12 milliards de paramètres servis par Docker Model Runner ou llama-server.
13+Ce document relève, dans l'implémentation, ce qui rend l'agent utilisable avec ces modèles, puis propose des pistes d'amélioration chiffrées en *effort* et en *résultat attendu*.
14+
15+== Le point de départ : ce qu'un petit modèle fait mal
16+
17+Tout le code de `mm` se lit comme une réponse à une liste de défaillances observées (souvent datées et mesurées dans les commentaires) :
18+
19+[cols="1,2",options="header"]
20+|===
21+| Défaillance | Manifestation typique
22+
23+| Hallucination
24+| Le modèle « cite » un fichier ou une sortie de commande qu'il n'a jamais lus.
25+
26+| Imprécision sur les octets
27+| Il retape un bloc à éditer avec des espaces là où le fichier a des tabulations, ou ajoute une ligne vide.
28+
29+| Boucles
30+| Il répète le même appel refusé — trente fois, observé le 18/09/2026 avec un modèle 12B.
31+
32+| Déraillement en cours de génération
33+| Il compose des arguments d'outil qui se répètent pendant des minutes (`"Greet 2023-06-15 11:16:00 …"`).
34+
35+| Appel d'outil trop long
36+| Il écrit tout un document dans un seul `write_file`, qui dépasse `max_tokens` et arrive coupé (observé le 26/09/2026).
37+
38+| Raisonnement long
39+| Un modèle « thinking » réfléchit 38 s sans émettre un octet de contenu.
40+
41+| Petite fenêtre de contexte
42+| La fenêtre est fixée au chargement du modèle ; une session longue finit en « stalled » puis en erreur 400.
43+
44+| Routage faible
45+| Il ne relie pas « salue Bob » à la procédure nommée `greetings`.
46+|===
47+
48+L'idée directrice : *ne pas demander au modèle ce que le code peut garantir*, et quand le modèle se trompe, *lui rendre une information exploitable plutôt qu'un échec muet*.
49+
50+== Une surface d'outils petite, configurable et explicite
51+
52+=== Bash d'abord, peu d'outils
53+
54+L'agent est né autour d'un seul outil, `bash` (`internal/tools/tools.go`). Les autres sont optionnels et désactivables un par un dans le YAML : `bashTool`, `editTools` (`read_file`, `write_file`, `edit_file`), `planTool`. Deux outils ne sont déclarés *que s'ils servent* :
55+
56+* `read_skill` n'existe pas si `skillsDir` ne contient aucun `.md` ;
57+* `lsp` n'existe pas si aucun serveur de langage n'est configuré *et* présent dans le `PATH`.
58+
59+Chaque outil déclaré coûte des tokens à chaque requête et une option de plus parmi lesquelles un petit modèle doit choisir. Moins il y en a, mieux il choisit.
60+
61+=== Des descriptions écrites pour être suivies
62+
63+Les descriptions d'outils ne décrivent pas seulement l'API, elles anticipent les erreurs :
64+
65+* `bash` : « Each call starts a NEW shell … a "cd" does not carry over », « there is no stdin, so never use a command that asks a question », « Read before you write ».
66+* `read_file` : « edit_file needs the EXACT text of the file … Do not type it from memory. »
67+* `edit_file` : six règles *numérotées*, dont « do not switch to write_file to force the change through ».
68+* `write_file` : rappelle qu'il écrase tout et renvoie vers `edit_file`.
69+
70+Le prompt système des configurations livrées (`agent.dmr.yaml`, `agent.llamacpp.yaml`) répète ces règles en sections courtes en majuscules (`SKILLS`, `FILE EDITING`, `RULES`, `BACKGROUND JOBS`). La redondance est voulue : un petit modèle suit mieux une consigne qu'il lit deux fois, à deux endroits.
71+
72+=== Un échantillonnage conservateur
73+
74+Les valeurs par défaut (`internal/config/config.go`) et les YAML livrés fixent `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` et `parallel_tool_calls: false`. Un seul appel d'outil à la fois est plus simple à produire correctement pour un petit modèle, et plus lisible dans l'historique.
75+
76+== Le contrat des outils : un échec est du texte
77+
78+C'est le principe structurant, énoncé en tête de `internal/tools/tools.go` :
79+
80+[quote]
81+We NEVER return a Go `error` for a "normal" failure. The error goes back AS TEXT, so that the model reads it and reacts.
82+
83+Commande en échec (`[exit code: …]`), délai dépassé (`[error: 30s timeout exceeded]`), fichier absent (« Create it with write_file »), édition ambiguë : tout revient au modèle comme résultat d'outil. Une erreur Go terminerait le tour et le modèle n'aurait aucune chance de se corriger.
84+
85+Les messages sont rédigés comme des *consignes de réparation* :
86+
87+* `read_skill` avec un nom inconnu répond `[skill: no skill named "x" — available: a, b, c]` ;
88+* `edit_file` avec un texte en double donne les numéros de ligne des occurrences et explique comment rendre `old` unique (`internal/fileedit/edits.go`).
89+
90+La seule exception est volontaire : l'arrêt par le détecteur de boucle (voir <<boucles>>).
91+
92+Certaines erreurs échappent pourtant aux outils, parce qu'elles se produisent *avant* eux, dans Genkit. C'est le cas d'un appel dont les arguments ne sont pas du JSON valide : il est désormais rendu au modèle lui aussi (voir <<args>>). Les erreurs de validation de schéma et les noms d'outils inventés restent à traiter (pistes <<p1>> et <<p2>>).
93+
94+== Une édition de fichiers tolérante, mais sûre
95+
96+`internal/fileedit` est probablement la partie la plus « petits modèles » du code.
97+
98+=== Trois passes de correspondance, de la plus stricte à la plus souple
99+
100+`findAll` (`internal/fileedit/match.go`) cherche `old` :
101+
102+. *exactement* ;
103+. en ignorant les *bords* (ligne vide au début, indentation d'une ligne unique, ligne vide finale) ;
104+. en ignorant l'*indentation* ligne par ligne, pour un bloc de plusieurs lignes.
105+
106+La première passe qui trouve quelque chose l'emporte : un texte présent tel quel n'est jamais apparié autrement. La souplesse ne s'active que là où la règle stricte a déjà échoué — typiquement le « ticket 27 », des espaces envoyés pour un fichier Go indenté par tabulations.
107+
108+=== Ce qui n'est *pas* relâché
109+
110+* *L'unicité* : deux correspondances souples sont refusées exactement comme deux correspondances exactes.
111+* *L'indentation du fichier* : `reindent` (`internal/fileedit/indent.go`) réécrit `new` dans l'indentation du fichier (niveau de base *et* unité, quatre espaces devenant une tabulation), pour ne pas insérer un bloc d'espaces dans un fichier à tabulations.
112+* *La transparence* : le résumé signale « N matched ignoring whitespace », pour que le modèle recopie mieux la fois suivante.
113+
114+=== Un « not found » qui aide
115+
116+`notFound` + `nearest` montrent les lignes du fichier qui s'en approchent le plus (au moins la moitié des lignes doivent coïncider), avec leurs numéros : « copy these lines ». Un modèle à qui l'on dit seulement « introuvable » retape le même texte ; un modèle à qui l'on montre le bon texte le recopie.
117+
118+=== Des détails invisibles neutralisés
119+
120+`internal/fileedit/textfile.go` retire le BOM UTF-8 et convertit les fins de ligne CRLF avant l'édition, puis les restaure. Sans cela, *chaque* `old` multi-ligne écrit avec `\n` par un modèle échouerait sur un fichier Windows.
121+
122+S'y ajoutent `dry_run`, des modifications résolues toutes contre le fichier *original* (pas d'effets de bord entre modifications), le refus des chevauchements, et un diff numéroté en retour, qui sert de preuve au modèle.
123+
124+== Contre l'hallucination : montrer, puis seulement commenter
125+
126+* *La sortie est affichée avant que le modèle la voie.* `echoOutput` (`internal/tools/tools.go`) montre les `previewLines` premières lignes à l'utilisateur. Observé : sur « affiche agent.yaml », le modèle lançait `cat`, jugeait la demande satisfaite, et l'utilisateur ne voyait rien.
127+* *Le prompt interdit d'affirmer sans preuve* : « Never state the contents of a file … unless a command in THIS answer returned it. »
128+* *Les pièces jointes sont des chemins, pas du contenu* : `@chemin` (`internal/mention`) et le `resource_link` ACP deviennent `[attached file: /abs/path]`. Le modèle doit lire le fichier avec ses outils, et la lecture est visible.
129+* *L'historique complet est conservé même quand un tour échoue* (`session.AfterTurn`). Genkit ne renvoie que le dernier message ; `engine.Generate` capture la conversation entière via un middleware `WrapModel`. Sans cela, le modèle réinvente au tour suivant les commandes qu'il a déjà lancées.
130+
131+[[boucles]]
132+== Garde-fous contre les boucles et les blocages
133+
134+Quatre mécanismes, du plus doux au plus radical :
135+
136+[cols="1,2,1",options="header"]
137+|===
138+| Mécanisme | Déclencheur | Réaction
139+
140+| Détecteur de boucle, niveau 1 (`internal/detector`)
141+| Même triplet (outil, entrée, sortie) 3 fois de suite
142+| `[LOOP_DETECTED: … try a different approach]` ajouté à la sortie
143+
144+| Détecteur, niveau 2
145+| 3 répétitions supplémentaires malgré l'avertissement
146+| Erreur Go `Stuck` : fin du tour, pas de nouvelle tentative, enregistrée dans la session
147+
148+| Garde des arguments (`internal/engine/runaway.go`)
149+| Arguments d'outil en streaming périodiques (motif de 4 à 64 octets sur les 256 derniers) ou > 256 Kio
150+| Génération interrompue immédiatement, avec un message explicite
151+
152+| Watchdog (`engine.watchdog`)
153+| Aucun token depuis `watchdogTimeout`
154+| Annulation : « stalled »
155+|===
156+
157+Plusieurs choix fins méritent d'être notés :
158+
159+* Le détecteur n'est *pas* remis à zéro par `/compact` (oublier la conversation ne rend pas nouvelle une commande répétée), mais il l'est par `/new`.
160+* La relance sans streaming n'a lieu *que* si rien n'a été affiché et qu'aucun outil n'a tourné. Sinon, rejouer le tour rejouerait les commandes (mesuré : un `echo >>` écrit deux fois).
161+* Le spinner indique « Composing a tool call » : un utilisateur sait que le modèle travaille, et non qu'il est bloqué.
162+* `maxTurns` borne les allers-retours modèle ↔ outils (10 par défaut, 40 dans `agent.dmr.yaml`).
163+* `bash` a son propre budget de 30 s et écrit dans un fichier temporaire plutôt que dans un pipe, pour qu'un job lancé en arrière-plan sans redirection ne bloque pas le tour indéfiniment (ticket 26). Le prompt et le skill `bg-jobs` apprennent au modèle la bonne forme `nohup … > log 2>&1 &`.
164+
165+[[args]]
166+== Appels d'outils coupés ou mal formés
167+
168+=== Le cas qui l'a motivé
169+
170+Session du 26/09/2026, Qwen3 27B servi par llama-server, `max_tokens: 4096`. On demande au modèle d'écrire un document. Il explore le dossier (trois `bash`, deux `read_file`), puis compose un seul `write_file` avec tout le document : ≈ 12 500 caractères, soit ≈ 4 000 tokens. Le serveur coupe la génération à la limite (`finish_reason: "length"`), en pleine phrase.
171+
172+Le greffon OpenAI de Genkit décode les arguments en convertissant la réponse du serveur (`compat_oai/generate.go` : `could not parse tool args: … unexpected end of JSON input`). Cette erreur *termine la génération*. Le modèle ne la lit jamais, et l'utilisateur voyait comme message d'erreur le document tronqué en entier.
173+
174+Deux causes se cumulent, typiques des modèles locaux :
175+
176+* un petit modèle veut tout faire en un appel, même quand le résultat est long ;
177+* avec un modèle « thinking », le raisonnement consomme aussi le budget `max_tokens`.
178+
179+=== Ce que fait `mm`
180+
181+`internal/engine/toolargs.go`, branché dans le hook `WrapModel` de `engine.Generate` :
182+
183+. Deux cas sont reconnus : un JSON *coupé* (`unexpected end of JSON input`) et un JSON *mal formé*.
184+. L'appel est rendu au modèle, deux fois au plus par génération. `mm` ajoute une note à la conversation, puis relance l'appel au modèle. Dans le cas coupé, la note donne un budget chiffré : « écris seulement la première partie (≈ 6 000 caractères au plus pour 4 096 tokens), puis ajoute la suite avec `edit_file` ».
185+. La note est ajoutée au `ModelRequest` que Genkit réutilise pour les tours suivants. Elle reste donc dans la conversation, et le modèle la relit en écrivant la partie suivante.
186+. La relance sans streaming n'est pas tentée pour cette erreur : la même limite couperait le même appel.
187+. Une fois les deux tentatives épuisées, `Explain` donne une ligne exploitable au lieu du JSON : « the model hit max_tokens (4096) while writing a tool call … raise sampling.max_tokens … ».
188+
189+=== Ce que la mesure a montré
190+
191+Test réel sur Docker Model Runner avec Mellum 12B, `max_tokens` volontairement réduit à 300 puis 800 :
192+
193+* le mécanisme fonctionne : le modèle reprend la main deux fois, puis le message clair s'affiche ;
194+* *le modèle a ignoré la consigne* les deux fois et a retenté le fichier entier, même avec un budget chiffré en caractères.
195+
196+La relance rend l'échec lisible et laisse une chance aux modèles qui suivent les instructions. Elle ne remplace pas un `max_tokens` adapté : 16 384 est une valeur raisonnable pour un modèle « thinking », avec une marge sous la fenêtre de contexte. Voir aussi la piste <<p15>>.
197+
198+== Modèles à raisonnement
199+
200+Le greffon OpenAI de Genkit ignore `reasoning_content`. `mm` le lit lui-même, une couche plus bas, via un middleware HTTP qui parcourt le flux SSE sans le modifier (`internal/engine/reasoning.go`). Deux effets :
201+
202+* *Le watchdog reste alimenté* pendant que le modèle réfléchit ; sans cela, `qwen3.5-2b` était tué après 38 s de raisonnement légitime.
203+* Le raisonnement est *affiché* (bloc 💭, `agent_thought_chunk` en ACP) mais *n'entre jamais dans l'historique* : il ne consomme pas le contexte des tours suivants.
204+
205+== Le contexte : une ressource rare
206+
207+=== Des sorties bornées
208+
209+`truncate` limite chaque sortie d'outil à `maxOutput` caractères en conservant *le début et la fin* (la fin porte souvent l'erreur ou le résumé d'une commande). `bash` ne relit jamais plus d'1 Mio par extrémité depuis le disque.
210+
211+=== Une compression hybride
212+
213+`internal/compact` remplace les vieux tours par *un* résumé écrit par le modèle et garde les `keepLastTurns` derniers tours intacts (le modèle travaille sur eux). Points notables :
214+
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 ;
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+* si la fenêtre est inconnue au démarrage (serveur lancé après l'agent), elle est redemandée paresseusement (`EnsureContextWindow`).
219+
220+=== Le compte exact du serveur
221+
222+Depuis le 26/09/2026, un middleware ajoute `stream_options.include_usage` aux requêtes en streaming (`internal/engine/usage.go`). Sans lui, Docker Model Runner n'envoyait aucun `usage` en streaming, et le déclencheur de compression ne voyait jamais la mesure exacte. Ce compte alimente aussi `usage_update` en ACP.
223+
224+== Skills : aider le routage
225+
226+Un petit modèle choisit mal une procédure sur son seul nom. `internal/skills` construit le *catalogue* dans la description de l'outil `read_skill` :
227+
228+* chaque skill y figure avec sa description et au plus 8 *mots-clés* : le pont lexical entre « salue Bob » et `greetings` (via « saluer ») ; le plafond de 8 vient du fait que le catalogue est payé à chaque requête ;
229+* la notation `$skill paramètres` et la commande `/skill` (en ACP) sont réécrites en une ligne explicite : `[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob]`. La décision est prise *pour* le modèle ;
230+* les skills livrés sont écrits pour un petit modèle : « The command below is for YOU to run with your `bash` tool, in this same turn. Do not show it to the user. »
231+
232+`internal/eval` mesure le routage contre un vrai modèle (`MM_EVAL=1`), un seul tour par cas, avec des outils factices. Les chiffres se lisent en *comparaison* avant/après une modification du catalogue ou du prompt.
233+
234+== Robustesse côté serveur local
235+
236+* *Fournisseurs* : Docker Model Runner et llama-server sont deux paramétrages d'un même client OpenAI-compatible (`internal/engine/openai_compat.go`), avec une URL de repli (`host.docker.internal`) pour un agent en conteneur.
237+* *Erreurs expliquées en une ligne* : `Explain` traduit « connection refused » en « start it: llama-server … --jinja », signale l'absence de `--jinja` (nécessaire aux appels d'outils), et le 401 sur un serveur sans clé.
238+* *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine.
239+
240+== Une méthode : mesurer
241+
242+La plupart des décisions sont justifiées dans le code par une *mesure datée* (« measured on qwen3.5-2b », « the demo audit of 2026-09-18 », « ticket 27 »). Le reste de `mm` est testé contre un moteur factice (rapide, déterministe) ; seul le routage des skills, qui est une propriété du modèle, est mesuré contre un vrai modèle. Garder cette discipline est la meilleure garantie que les améliorations ci-dessous en soient vraiment.
243+
244+[[pistes]]
245+== Pistes d'amélioration
246+
247+Échelle d'effort : *faible* ≈ moins d'une journée, *moyen* ≈ quelques jours, *élevé* ≈ une semaine ou plus, mesures comprises.
248+Le résultat est celui attendu sur un petit modèle local ; « à mesurer » signale un gain plausible mais non démontré.
249+
250+=== Vue d'ensemble
251+
252+[cols="3,1,1,4",options="header"]
253+|===
254+| Piste | Effort | Résultat | En une phrase
255+
256+| <<p1>> | Faible | Élevé | Rendre au modèle, en texte, les erreurs de validation d'arguments qui terminent encore le tour.
257+| <<p2>> | Moyen | Élevé | Un nom d'outil inventé (`python`, `shell`…) ne doit pas tuer le tour.
258+| <<p3>> | Faible | Élevé | 16 000 caractères de sortie, c'est la moitié d'une fenêtre de 8k.
259+| <<p4>> | Moyen | Élevé | Masquer les vieilles sorties d'outils avant de recourir au résumé.
260+| <<p5>> | Faible | Moyen | Donner au modèle une dernière chance de répondre quand `maxTurns` est atteint.
261+| <<p6>> | Faible | Moyen | Sortir les balises `<think>` du texte de réponse et de l'historique.
262+| <<p7>> | Faible | Moyen | Réduire le coût fixe du prompt (≈ 3,4k tokens mesurés).
263+| <<p8>> | Faible à moyen | Moyen | Détecter aussi les cycles A→B→A et les quasi-doublons.
264+| <<p9>> | Faible | Moyen | Connaître la fenêtre de contexte avec Docker Model Runner.
265+| <<p10>> | Faible | Moyen | Exploiter `cached_tokens` et `timings` (cache KV, vitesse de prefill).
266+| <<p11>> | Moyen | Élevé (sur certains modèles) | Récupérer les appels d'outils écrits en texte.
267+| <<p12>> | Moyen à élevé | Élevé (pour décider) | Étendre l'évaluation aux tâches complètes, modèle par modèle.
268+| <<p13>> | Faible | Moyen | Profils par modèle (échantillonnage, variantes de prompt).
269+| <<p14>> | Moyen | À mesurer | Varier la température après un `LOOP_DETECTED`.
270+| <<p15>> | Faible | Moyen | Signaler une réponse coupée par `max_tokens`, et prévenir les appels trop longs.
271+|===
272+
273+Ordre conseillé : 1, 3, 5, 6 et 15 (effort faible, gain net), puis 2 et 4, puis 12 pour mesurer le reste.
274+
275+[[p1]]
276+=== Erreurs d'arguments rendues en texte
277+
278+*Constat.* Dans Genkit v1.10, une erreur d'exécution d'outil (`ai/generate.go`, `handleToolRequests`) devient `tool "x" failed: …` et *termine la génération*. Cela concerne notamment des arguments qui ne passent pas la validation du schéma (champ manquant, `"cmd"` au lieu de `"command"`). C'est une faute fréquente chez un petit modèle, et elle contourne le principe « un échec est du texte ».
279+
280+Le cas voisin des arguments qui ne sont pas du JSON valide (JSON coupé par `max_tokens`) est traité depuis le 26/09/2026 (voir <<args>>). Il est intercepté plus tôt, dans `WrapModel`, parce que l'erreur survient pendant la conversion de la réponse, avant tout outil.
281+
282+*Proposition.* Dans le hook `WrapTool` déjà installé par `engine.Generate`, convertir toute erreur qui n'est pas `detector.Stuck` en réponse texte : `[error: invalid arguments for edit_file: … — expected {path, edits:[{old,new}]}]`. Le rappel du schéma attendu vaut mieux que le message brut de validation.
283+
284+*Effort : faible* (un hook existant, un test avec un faux outil). *Résultat : élevé* — un tour perdu devient une correction au tour suivant. À vérifier d'abord : que la validation d'entrée a bien lieu à l'intérieur de `next()` dans `WrapTool`.
285+
286+[[p2]]
287+=== Outils inventés
288+
289+*Constat.* Un nom d'outil inconnu produit `tool "python" not found` *avant* tout middleware d'outil : le tour s'arrête.
290+
291+*Proposition.* Dans `WrapModel`, après la réponse du modèle, renommer les `ToolRequest` inconnues vers un outil interne (par exemple `_unknown_tool`) qui répond `[error: there is no tool named "python"; your tools are: bash, read_file, …]`. Un alias explicite pour les confusions fréquentes (`shell`, `run`, `execute` → `bash`) serait une variante plus agressive, à réserver aux cas mesurés.
292+
293+*Effort : moyen* (manipuler la réponse et la déclaration d'un outil caché). *Résultat : élevé* sur les modèles de 2 à 4B, qui inventent volontiers des outils.
294+
295+[[p3]]
296+=== `maxOutput` proportionnel à la fenêtre
297+
298+*Constat.* `maxOutput: 16000` caractères représente ≈ 4 à 5k tokens. Sur une fenêtre de 8k, *une seule* sortie de `cat` occupe la moitié du contexte, avec un coût fixe de ≈ 3,4k tokens déjà mesuré.
299+
300+*Proposition.* Quand la fenêtre est connue et que `maxOutput` n'est pas fixé explicitement, le calculer, par exemple à 15 % de la fenêtre × 3,5 caractères/token, borné entre 2 000 et 16 000. Le message de troncature invite déjà à relire une plage (`read_file start/end`, `grep`, `head`).
301+
302+*Effort : faible.* *Résultat : élevé* sur les fenêtres de 4 à 16k, nul au-delà.
303+
304+[[p4]]
305+=== Masquage des vieilles sorties d'outils
306+
307+*Constat.* La compression actuelle est le seul moyen de réduire l'historique, et elle coûte un appel au modèle, un long prefill et un risque de résumé de mauvaise qualité avec un petit modèle.
308+
309+*Proposition.* Avant chaque requête, remplacer le *contenu* des résultats d'outils plus anciens que les N derniers tours par un talon : `[output of "go test ./..." elided — 212 lines, exit 1; rerun it if needed]`. Les appels, les arguments et les réponses du modèle restent. Des travaux publiés en 2025 sur les agents de code (« observation masking ») rapportent des gains comparables à la synthèse par LLM pour un coût bien moindre ; à confirmer sur `mm` avec l'éval de la piste 12. La compression par résumé reste le second niveau.
310+
311+*Effort : moyen* (transformation dans `WrapModel` ou avant `Generate`, sans toucher à l'historique persistant). *Résultat : élevé* sur les longues sessions : moins de compressions, moins de « stalled ».
312+
313+[[p5]]
314+=== Dernière chance à `maxTurns`
315+
316+*Constat.* Quand `maxTurns` est atteint, le tour se termine sur une erreur ; l'utilisateur n'a pas de réponse alors que le travail est souvent fait.
317+
318+*Proposition.* Intercepter ce cas et faire *un* appel supplémentaire sans outils, avec la consigne « summarise what you did and what remains ». Il faut distinguer ce cas du watchdog et du détecteur de boucle, qui ne doivent pas en bénéficier.
319+
320+*Effort : faible.* *Résultat : moyen* — une fin de tour lisible plutôt qu'une erreur.
321+
322+[[p6]]
323+=== Balises `<think>` dans le contenu
324+
325+*Constat.* Documenté comme non géré (`thinking-models.md`) : certains modèles (Qwen3 sans analyseur côté serveur, DeepSeek-R1 distillés…) mettent leur raisonnement dans `content` entre `<think>…</think>`. Ce texte est affiché comme une réponse et, surtout, *entre dans l'historique*, où il consomme le contexte de tous les tours suivants.
326+
327+*Proposition.* Un petit filtre à états dans `emit` qui route le texte entre balises vers `Reasoning`, et le même nettoyage sur le message final avant `AfterTurn`.
328+
329+*Effort : faible.* *Résultat : moyen*, élevé pour les modèles concernés.
330+
331+[[p7]]
332+=== Coût fixe du prompt
333+
334+*Constat.* Mesuré sur `agent.dmr.yaml` : la première requête d'une session pèse ≈ 3,4k tokens avant toute question (prompt système ≈ 3,7k caractères, six descriptions d'outils et leurs schémas, catalogue de skills). La description de `plan` à elle seule fait ≈ 1,3k caractères, pour un outil utile seulement aux demandes en plusieurs étapes.
335+
336+*Proposition.*
337+
338+* Condenser la description de `plan` (l'exemple de workflow en sept étapes pourrait passer dans un skill), ou désactiver `planTool` par défaut dans les configurations pour petits modèles.
339+* Éviter les doublons entre prompt système et descriptions quand ils ne servent pas.
340+* Mesurer chaque retrait avec `internal/eval` : la redondance est parfois ce qui fait marcher un 4B.
341+
342+*Effort : faible.* *Résultat : moyen* — prefill plus rapide à chaque requête, et plus de place pour le travail sur les petites fenêtres.
343+
344+[[p8]]
345+=== Détecteur de boucle plus fin
346+
347+*Constat.* Le détecteur ne voit que des triplets *identiques et consécutifs*. Il manque :
348+
349+* les cycles A→B→A→B (lire, éditer refusé, lire, éditer refusé) ;
350+* les quasi-doublons (le même `edit_file` refusé, avec un `old` qui varie d'un espace).
351+
352+*Proposition.* Ajouter une détection de période 2 à 3 sur l'historique, et une clé normalisée pour les refus d'`edit_file` (chemin + message d'erreur, sans le texte exact).
353+
354+*Effort : faible à moyen.* *Résultat : moyen* — des boucles aujourd'hui arrêtées seulement par `maxTurns` ou par l'utilisateur.
355+
356+[[p9]]
357+=== Fenêtre de contexte avec Docker Model Runner
358+
359+*Constat.* Avec `agent.dmr.yaml` (`contextWindow: 0`), la bannière affiche `ctx: unknown` : la compression ne peut se déclencher que sur le nombre de messages, et `usage_update` part avec `size: 0`.
360+
361+*Proposition.* Chercher si DMR expose la taille configurée (configuration du modèle, ou passage vers le `/props` du llama.cpp sous-jacent). À défaut, afficher un avertissement explicite au démarrage quand `context.enabled` est vrai et que la fenêtre est inconnue, avec la commande pour la connaître.
362+
363+*Effort : faible.* *Résultat : moyen.*
364+
365+[[p10]]
366+=== Exploiter `cached_tokens` et `timings`
367+
368+*Constat.* DMR et llama-server renvoient `prompt_tokens_details.cached_tokens` et `timings` (vitesse de prefill et de génération), mais l'accumulateur d'openai-go n'additionne que les trois compteurs de base.
369+
370+*Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite :
371+
372+* afficher le taux de cache KV ;
373+* repérer ce qui casse la stabilité du préfixe (un prompt système qui change, un catalogue réordonné) ;
374+* alimenter `cachedReadTokens` en ACP.
375+
376+Sur une machine locale, un préfixe en cache fait la différence entre une réponse immédiate et plusieurs secondes de prefill.
377+
378+*Effort : faible.* *Résultat : moyen*, surtout comme outil de diagnostic.
379+
380+[[p11]]
381+=== Appels d'outils écrits en texte
382+
383+*Constat.* Quand le template de chat ou l'analyseur du serveur échoue, certains modèles écrivent leur appel dans le texte (`<tool_call>{"name":"bash",…}</tool_call>`, ou un JSON nu). Le tour se termine alors sur une « réponse » qui n'en est pas une.
384+
385+*Proposition.* Détecter ce cas en fin de tour, quand la réponse est un *seul* bloc qui ressemble à un appel d'outil valide pour un outil déclaré, et le convertir en vrai appel. Il faut rester strict pour éviter les faux positifs, par exemple quand l'utilisateur a demandé un exemple de JSON.
386+
387+*Effort : moyen.* *Résultat : élevé sur les modèles concernés*, nul sur les autres. À faire *après* avoir mesuré la fréquence du cas.
388+
389+[[p12]]
390+=== Évaluation de bout en bout
391+
392+*Constat.* `internal/eval` mesure le routage des skills sur un seul tour. Les questions qui comptent — ce modèle édite-t-il correctement ? combien de tours ? combien de boucles ? — restent sans réponse chiffrée.
393+
394+*Proposition.* Une petite suite de tâches reproductibles (« renomme cette fonction », « corrige ce test », « ajoute un champ ») exécutées dans un répertoire temporaire, en vérifiant le *résultat* (compilation, tests, diff attendu) et en relevant les tours, les `LOOP_DETECTED`, les correspondances souples et les tokens (désormais disponibles). Lancée à la demande, comme l'éval actuelle.
395+
396+*Effort : moyen à élevé.* *Résultat : élevé* — c'est ce qui permet de trancher toutes les autres pistes « à mesurer » et de comparer les modèles.
397+
398+[[p13]]
399+=== Profils par modèle
400+
401+*Constat.* Les réglages utiles varient d'une famille à l'autre : pénalité de répétition, `top_k`, `min_p`, gestion du rôle `system`, longueur de prompt supportée. `tests/` contient déjà des variantes par modèle (`agent.dmr-qwen.yaml`, `agent.dmr-gemma.yaml`).
402+
403+*Proposition.* Une clé `profile:` (ou un fichier de profils) qui applique des valeurs par défaut connues par famille de modèle, surchargées par le YAML.
404+
405+*Effort : faible.* *Résultat : moyen*, à mesurer avec la piste 12.
406+
407+[[p14]]
408+=== Température adaptative après une boucle
409+
410+*Constat.* À `temperature: 0`, un modèle qui s'est trompé a tendance à reproduire exactement la même erreur, ce que le détecteur constate ensuite.
411+
412+*Proposition.* Après un `LOOP_DETECTED`, faire le ou les appels suivants à une température légèrement plus haute (0,3 à 0,5), puis revenir à 0.
413+
414+*Effort : moyen* (changer l'échantillonnage en cours de tour sous Genkit). *Résultat : à mesurer* — plausible, mais peut aussi dégrader la précision des éditions.
415+
416+[[p15]]
417+=== Réponses coupées par `max_tokens`
418+
419+*Constat.* Le cas d'un *appel d'outil* coupé est traité (<<args>>). Une *réponse texte* coupée ne l'est pas : la génération s'arrête avec `finish_reason: "length"`, et `mm` l'affiche comme une réponse complète. L'utilisateur lit une phrase inachevée sans savoir pourquoi. La mesure montre aussi que la consigne « procède par morceaux » arrive trop tard, une fois l'appel déjà coupé.
420+
421+*Proposition.*
422+
423+* Lire `FinishReason` dans `WrapModel` (Genkit le transmet : `FinishReasonLength`) et, pour une réponse texte, ajouter `[answer cut off at max_tokens (N)]`, avec le même conseil que pour les appels d'outils.
424+* *Prévenir* plutôt que guérir : une phrase dans la description de `write_file` (« pour un fichier de plus de ≈ N caractères, écris la première partie puis complète avec edit_file »), avec N calculé à partir de `max_tokens`.
425+* Au démarrage, avertir quand `max_tokens` est bas pour un modèle à raisonnement (le raisonnement consomme le même budget).
426+
427+*Effort : faible.* *Résultat : moyen* — plus de réponses tronquées en silence ; l'effet de la consigne préventive reste à mesurer avec la piste <<p12>>.
428+
429+== Références dans le code
430+
431+[cols="1,2",options="header"]
432+|===
433+| Sujet | Fichiers
434+
435+| Contrat des outils, `bash`, troncature | `internal/tools/tools.go`
436+| Outils fichiers | `internal/tools/files.go`, `internal/fileedit/`
437+| Détecteur de boucle | `internal/detector/detector.go`
438+| Arguments qui s'emballent | `internal/engine/runaway.go`
439+| Watchdog, relance, historique complet | `internal/engine/engine.go`
440+| Raisonnement | `internal/engine/reasoning.go`, `internal/engine/thoughts.go`
441+| Usage en streaming | `internal/engine/usage.go`
442+| Appels d'outils coupés ou mal formés | `internal/engine/toolargs.go`
443+| Compression | `internal/compact/`
444+| Skills et catalogue | `internal/skills/`, `skills/`
445+| Évaluation du routage | `internal/eval/`
446+| Explications d'erreurs | `internal/engine/explain.go`, `internal/engine/openai_compat.go`
447+|===
modified docs/fr/reference/acp.md +3 -2
@@ -83,8 +83,8 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
8383 | `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
8484 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
8585 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
86-| Succès | `stopReason: end_turn`. |
87-| Annulé | `stopReason: cancelled`. |
86+| Succès | `stopReason: end_turn`, plus `usage` (les **totaux** de la session : `inputTokens`, `outputTokens`, `totalTokens`, et `thoughtTokens` / `cachedReadTokens` quand le serveur les a donnés) dès que le serveur a rapporté un compte ; absent sinon. Aussi pour `/new` et `/compact`. |
87+| Annulé | `stopReason: cancelled`, avec `usage` comme ci-dessus. |
8888 | Échec | `internal error` avec `error: <explication en une ligne>`. |
8989 | Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |
9090
@@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
9797 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
9898 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
9999 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
100+| `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. |
100101
101102 ## Commandes disponibles
102103
@@ -83,8 +83,8 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
83 | `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |83 | `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
84 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |84 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
85 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |85 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
86-| Succès | `stopReason: end_turn`. |86+| Succès | `stopReason: end_turn`, plus `usage` (les **totaux** de la session : `inputTokens`, `outputTokens`, `totalTokens`, et `thoughtTokens` / `cachedReadTokens` quand le serveur les a donnés) dès que le serveur a rapporté un compte ; absent sinon. Aussi pour `/new` et `/compact`. |
87-| Annulé | `stopReason: cancelled`. |87+| Annulé | `stopReason: cancelled`, avec `usage` comme ci-dessus. |
88 | Échec | `internal error` avec `error: <explication en une ligne>`. |88 | Échec | `internal error` avec `error: <explication en une ligne>`. |
89 | Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |89 | Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |
90 90
@@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
97 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |97 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
98 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |98 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
99 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |99 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
100+| `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. |
100 101
101 ## Commandes disponibles102 ## Commandes disponibles
102 103
modified docs/fr/reference/configuration.md +1 -1
@@ -21,7 +21,7 @@
2121 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
2222 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |
2323 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
24-| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |
24+| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
2525 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
2626 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
2727 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
@@ -21,7 +21,7 @@
21 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |21 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
22 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |22 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |
23 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |23 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
24-| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |24+| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
25 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |25 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
26 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |26 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
27 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |27 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
modified internal/acp/acp.go +25 -4
@@ -59,6 +59,9 @@ type session struct {
5959 // allowAlways remembers the tools the user granted for the whole session
6060 // ("allow always" in the permission dialog).
6161 allowAlways map[string]bool
62+ // usage is the token accounting: totals for the session/prompt response,
63+ // the last measure for the context meter (usage.go).
64+ usage usage
6265 }
6366
6467 type front struct {
@@ -386,6 +389,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
386389 s.messages = append(s.messages, ai.NewUserTextMessage(input))
387390 before := len(s.messages)
388391
392+ // The meter's `size`. The start-up probe runs once, and a server started
393+ // after the agent left it unknown for good; asking again while it is
394+ // unknown is the REPL's rule (agent.go), and costs nothing once known.
395+ if f.e.ContextWindow == 0 {
396+ if w, src := f.e.EnsureContextWindow(turnCtx); w > 0 {
397+ fmt.Fprintf(os.Stderr, "[acp] ctx: %d (%s)\n", w, src)
398+ }
399+ }
400+
389401 resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)
390402
391403 // Same rule as the REPL, for the same reason: keep the FULL history even
@@ -402,6 +414,11 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
402414 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",
403415 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))
404416 f.persist(p.SessionId, s)
417+ // The meter once more, on the history as the turn left it: the last model
418+ // call's measure predates the answer, and a server that reports nothing
419+ // sent no update at all. The request's context, not the turn's: a
420+ // cancelled turn still changed the history.
421+ f.sendUsage(context.WithoutCancel(ctx), p.SessionId, contextUsed(s))
405422
406423 switch {
407424 case turnCtx.Err() != nil:
@@ -412,7 +429,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
412429 // "Internal error: stream error: context canceled" instead of honouring
413430 // the spec's contract). Either way the answer is the same: respond to
414431 // session/prompt anyway, with stopReason "cancelled".
415- return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
432+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil
416433 case err != nil:
417434 // One line, in the provider's words — the same text the REPL prints
418435 // between brackets, delivered as a JSON-RPC error — and recorded in
@@ -421,7 +438,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
421438 f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))
422439 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})
423440 }
424- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
441+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
425442 }
426443
427444 // slashCommand runs input when it is one of the commands this front end
@@ -474,13 +491,15 @@ func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *sessi
474491 f.archive(sid, s)
475492 forgotten := resetSession(s, f.system)
476493 f.e.ForgetInputTokens()
494+ s.usage.lastUsed = 0 // measured on the history just dropped; the totals stay, the tokens were spent
477495 f.persist(sid, s)
478496 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
479497 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
480498 SessionId: sid,
481499 Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),
482500 })
483- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
501+ f.sendUsage(ctx, sid, contextUsed(s))
502+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
484503 }
485504
486505 // compactSession is the ACP side of /compact: the REPL's compactHistory with
@@ -496,6 +515,7 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
496515 switch outcome {
497516 case compact.Compressed:
498517 f.e.ForgetInputTokens() // the server's measure described the history just replaced
518+ s.usage.lastUsed = 0
499519 f.persist(sid, s)
500520 case compact.Failed:
501521 f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))
@@ -505,7 +525,8 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
505525 SessionId: sid,
506526 Update: sdk.UpdateAgentMessageText(line),
507527 })
508- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
528+ f.sendUsage(ctx, sid, contextUsed(s))
529+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
509530 }
510531
511532 // compactHistory compresses the session's history in place when there is
@@ -59,6 +59,9 @@ type session struct {
59 // allowAlways remembers the tools the user granted for the whole session59 // allowAlways remembers the tools the user granted for the whole session
60 // ("allow always" in the permission dialog).60 // ("allow always" in the permission dialog).
61 allowAlways map[string]bool61 allowAlways map[string]bool
62+ // usage is the token accounting: totals for the session/prompt response,
63+ // the last measure for the context meter (usage.go).
64+ usage usage
62 }65 }
63 66
64 type front struct {67 type front struct {
@@ -386,6 +389,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
386 s.messages = append(s.messages, ai.NewUserTextMessage(input))389 s.messages = append(s.messages, ai.NewUserTextMessage(input))
387 before := len(s.messages)390 before := len(s.messages)
388 391
392+ // The meter's `size`. The start-up probe runs once, and a server started
393+ // after the agent left it unknown for good; asking again while it is
394+ // unknown is the REPL's rule (agent.go), and costs nothing once known.
395+ if f.e.ContextWindow == 0 {
396+ if w, src := f.e.EnsureContextWindow(turnCtx); w > 0 {
397+ fmt.Fprintf(os.Stderr, "[acp] ctx: %d (%s)\n", w, src)
398+ }
399+ }
400+
389 resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)401 resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)
390 402
391 // Same rule as the REPL, for the same reason: keep the FULL history even403 // Same rule as the REPL, for the same reason: keep the FULL history even
@@ -402,6 +414,11 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
402 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",414 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",
403 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))415 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))
404 f.persist(p.SessionId, s)416 f.persist(p.SessionId, s)
417+ // The meter once more, on the history as the turn left it: the last model
418+ // call's measure predates the answer, and a server that reports nothing
419+ // sent no update at all. The request's context, not the turn's: a
420+ // cancelled turn still changed the history.
421+ f.sendUsage(context.WithoutCancel(ctx), p.SessionId, contextUsed(s))
405 422
406 switch {423 switch {
407 case turnCtx.Err() != nil:424 case turnCtx.Err() != nil:
@@ -412,7 +429,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
412 // "Internal error: stream error: context canceled" instead of honouring429 // "Internal error: stream error: context canceled" instead of honouring
413 // the spec's contract). Either way the answer is the same: respond to430 // the spec's contract). Either way the answer is the same: respond to
414 // session/prompt anyway, with stopReason "cancelled".431 // session/prompt anyway, with stopReason "cancelled".
415- return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil432+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil
416 case err != nil:433 case err != nil:
417 // One line, in the provider's words — the same text the REPL prints434 // One line, in the provider's words — the same text the REPL prints
418 // between brackets, delivered as a JSON-RPC error — and recorded in435 // between brackets, delivered as a JSON-RPC error — and recorded in
@@ -421,7 +438,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
421 f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))438 f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))
422 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})439 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})
423 }440 }
424- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil441+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
425 }442 }
426 443
427 // slashCommand runs input when it is one of the commands this front end444 // slashCommand runs input when it is one of the commands this front end
@@ -474,13 +491,15 @@ func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *sessi
474 f.archive(sid, s)491 f.archive(sid, s)
475 forgotten := resetSession(s, f.system)492 forgotten := resetSession(s, f.system)
476 f.e.ForgetInputTokens()493 f.e.ForgetInputTokens()
494+ s.usage.lastUsed = 0 // measured on the history just dropped; the totals stay, the tokens were spent
477 f.persist(sid, s)495 f.persist(sid, s)
478 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)496 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
479 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{497 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
480 SessionId: sid,498 SessionId: sid,
481 Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),499 Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),
482 })500 })
483- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}501+ f.sendUsage(ctx, sid, contextUsed(s))
502+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
484 }503 }
485 504
486 // compactSession is the ACP side of /compact: the REPL's compactHistory with505 // compactSession is the ACP side of /compact: the REPL's compactHistory with
@@ -496,6 +515,7 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
496 switch outcome {515 switch outcome {
497 case compact.Compressed:516 case compact.Compressed:
498 f.e.ForgetInputTokens() // the server's measure described the history just replaced517 f.e.ForgetInputTokens() // the server's measure described the history just replaced
518+ s.usage.lastUsed = 0
499 f.persist(sid, s)519 f.persist(sid, s)
500 case compact.Failed:520 case compact.Failed:
501 f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))521 f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))
@@ -505,7 +525,8 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
505 SessionId: sid,525 SessionId: sid,
506 Update: sdk.UpdateAgentMessageText(line),526 Update: sdk.UpdateAgentMessageText(line),
507 })527 })
508- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}528+ f.sendUsage(ctx, sid, contextUsed(s))
529+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
509 }530 }
510 531
511 // compactHistory compresses the session's history in place when there is532 // compactHistory compresses the session's history in place when there is
added internal/acp/usage.go +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+package acp
2+
3+import (
4+ "context"
5+
6+ "mm/internal/compact"
7+ "mm/internal/ui"
8+
9+ sdk "github.com/coder/acp-go-sdk"
10+)
11+
12+// Token accounting, the two shapes ACP gives it (both UNSTABLE in the spec,
13+// and gated by no capability — an agent may send them unasked):
14+//
15+// - usage_update, a session/update: how full the context is now (`used`
16+// out of `size`). Sent after every model call, and once more at the end
17+// of a turn, a /new or a /compact, so the client's meter follows the
18+// history even when the server reports nothing.
19+// - `usage` in the session/prompt response: the session's TOTALS so far,
20+// the spec's definition — a client computes a turn's cost as the
21+// difference with the previous response.
22+//
23+// The counts come from the server (engine → ui.UsageSink, one report per
24+// model call). A server that reports nothing leaves the totals at zero, and
25+// the response then carries no `usage` at all rather than a row of zeros that
26+// would read as "this turn was free".
27+
28+// usage is one session's running totals. Guarded by turnMu like the rest of
29+// the session's turn state: only the turn in flight touches it.
30+type usage struct {
31+ input, output, thought, cachedRead int
32+ // lastUsed is the context the last model call filled — what it read plus
33+ // what it wrote, which the next call will read back. 0 = not measured
34+ // since the history last changed shape (new session, compression).
35+ lastUsed int
36+}
37+
38+func (u *usage) add(r ui.Usage) {
39+ u.input += r.InputTokens
40+ u.output += r.OutputTokens
41+ u.thought += r.ThoughtTokens
42+ u.cachedRead += r.CachedReadTokens
43+ if r.InputTokens > 0 {
44+ u.lastUsed = r.InputTokens + r.OutputTokens
45+ }
46+}
47+
48+// totals is the `usage` of a session/prompt response, nil while the server
49+// has reported nothing.
50+func (u *usage) totals() *sdk.Usage {
51+ if u.input == 0 && u.output == 0 {
52+ return nil
53+ }
54+ out := &sdk.Usage{
55+ InputTokens: u.input,
56+ OutputTokens: u.output,
57+ TotalTokens: u.input + u.output,
58+ }
59+ if u.thought > 0 {
60+ out.ThoughtTokens = sdk.Ptr(u.thought)
61+ }
62+ if u.cachedRead > 0 {
63+ out.CachedReadTokens = sdk.Ptr(u.cachedRead)
64+ }
65+ return out
66+}
67+
68+// Usage is the sink's side: one model call's counts. Accumulated, then the
69+// meter is refreshed with what that call measured.
70+func (k *sink) Usage(r ui.Usage) {
71+ k.s.usage.add(r)
72+ if r.InputTokens > 0 {
73+ k.f.sendUsage(k.ctx, k.sid, r.InputTokens+r.OutputTokens)
74+ }
75+}
76+
77+var _ ui.UsageSink = (*sink)(nil)
78+
79+// contextUsed is how full the session's context is: the server's measure of
80+// the last call when there is one, the character estimate of the history
81+// when it is larger — the same rule as the terminal UI's header, and for the
82+// same reason: the estimate cannot see the tools' JSON or the chat template,
83+// but it does see a history that changed since the last call.
84+func contextUsed(s *session) int {
85+ return max(s.usage.lastUsed, compact.Estimate(s.messages))
86+}
87+
88+// sendUsage pushes a usage_update. `size` is the window the engine knows —
89+// the yaml's contextWindow or what the probe learned — and 0 when neither
90+// says: the spec has no "unknown", and a client can tell 0 is not a window.
91+func (f *front) sendUsage(ctx context.Context, sid sdk.SessionId, used int) {
92+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
93+ SessionId: sid,
94+ Update: sdk.SessionUpdate{UsageUpdate: &sdk.SessionUsageUpdate{
95+ Used: used,
96+ Size: f.e.ContextWindow,
97+ }},
98+ })
99+}
new file mode 100644
@@ -0,0 +1,99 @@
1+package acp
2+
3+import (
4+ "context"
5+
6+ "mm/internal/compact"
7+ "mm/internal/ui"
8+
9+ sdk "github.com/coder/acp-go-sdk"
10+)
11+
12+// Token accounting, the two shapes ACP gives it (both UNSTABLE in the spec,
13+// and gated by no capability — an agent may send them unasked):
14+//
15+// - usage_update, a session/update: how full the context is now (`used`
16+// out of `size`). Sent after every model call, and once more at the end
17+// of a turn, a /new or a /compact, so the client's meter follows the
18+// history even when the server reports nothing.
19+// - `usage` in the session/prompt response: the session's TOTALS so far,
20+// the spec's definition — a client computes a turn's cost as the
21+// difference with the previous response.
22+//
23+// The counts come from the server (engine → ui.UsageSink, one report per
24+// model call). A server that reports nothing leaves the totals at zero, and
25+// the response then carries no `usage` at all rather than a row of zeros that
26+// would read as "this turn was free".
27+
28+// usage is one session's running totals. Guarded by turnMu like the rest of
29+// the session's turn state: only the turn in flight touches it.
30+type usage struct {
31+ input, output, thought, cachedRead int
32+ // lastUsed is the context the last model call filled — what it read plus
33+ // what it wrote, which the next call will read back. 0 = not measured
34+ // since the history last changed shape (new session, compression).
35+ lastUsed int
36+}
37+
38+func (u *usage) add(r ui.Usage) {
39+ u.input += r.InputTokens
40+ u.output += r.OutputTokens
41+ u.thought += r.ThoughtTokens
42+ u.cachedRead += r.CachedReadTokens
43+ if r.InputTokens > 0 {
44+ u.lastUsed = r.InputTokens + r.OutputTokens
45+ }
46+}
47+
48+// totals is the `usage` of a session/prompt response, nil while the server
49+// has reported nothing.
50+func (u *usage) totals() *sdk.Usage {
51+ if u.input == 0 && u.output == 0 {
52+ return nil
53+ }
54+ out := &sdk.Usage{
55+ InputTokens: u.input,
56+ OutputTokens: u.output,
57+ TotalTokens: u.input + u.output,
58+ }
59+ if u.thought > 0 {
60+ out.ThoughtTokens = sdk.Ptr(u.thought)
61+ }
62+ if u.cachedRead > 0 {
63+ out.CachedReadTokens = sdk.Ptr(u.cachedRead)
64+ }
65+ return out
66+}
67+
68+// Usage is the sink's side: one model call's counts. Accumulated, then the
69+// meter is refreshed with what that call measured.
70+func (k *sink) Usage(r ui.Usage) {
71+ k.s.usage.add(r)
72+ if r.InputTokens > 0 {
73+ k.f.sendUsage(k.ctx, k.sid, r.InputTokens+r.OutputTokens)
74+ }
75+}
76+
77+var _ ui.UsageSink = (*sink)(nil)
78+
79+// contextUsed is how full the session's context is: the server's measure of
80+// the last call when there is one, the character estimate of the history
81+// when it is larger — the same rule as the terminal UI's header, and for the
82+// same reason: the estimate cannot see the tools' JSON or the chat template,
83+// but it does see a history that changed since the last call.
84+func contextUsed(s *session) int {
85+ return max(s.usage.lastUsed, compact.Estimate(s.messages))
86+}
87+
88+// sendUsage pushes a usage_update. `size` is the window the engine knows —
89+// the yaml's contextWindow or what the probe learned — and 0 when neither
90+// says: the spec has no "unknown", and a client can tell 0 is not a window.
91+func (f *front) sendUsage(ctx context.Context, sid sdk.SessionId, used int) {
92+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
93+ SessionId: sid,
94+ Update: sdk.SessionUpdate{UsageUpdate: &sdk.SessionUsageUpdate{
95+ Used: used,
96+ Size: f.e.ContextWindow,
97+ }},
98+ })
99+}
added internal/acp/usage_test.go +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+package acp
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/ui"
7+)
8+
9+// The response carries session TOTALS (the spec's definition), the meter the
10+// context the last call filled; a server that reported nothing leaves no
11+// `usage` at all rather than zeros.
12+func TestUsageTotals(t *testing.T) {
13+ var u usage
14+ if u.totals() != nil {
15+ t.Fatal("no report yet, but totals are not nil")
16+ }
17+ u.add(ui.Usage{InputTokens: 1000, OutputTokens: 50})
18+ u.add(ui.Usage{InputTokens: 1200, OutputTokens: 80, ThoughtTokens: 30, CachedReadTokens: 900})
19+
20+ got := u.totals()
21+ if got.InputTokens != 2200 || got.OutputTokens != 130 || got.TotalTokens != 2330 {
22+ t.Errorf("totals = %+v, want 2200 in / 130 out / 2330 total", got)
23+ }
24+ if got.ThoughtTokens == nil || *got.ThoughtTokens != 30 {
25+ t.Errorf("thoughtTokens = %v, want 30", got.ThoughtTokens)
26+ }
27+ if got.CachedReadTokens == nil || *got.CachedReadTokens != 900 {
28+ t.Errorf("cachedReadTokens = %v, want 900", got.CachedReadTokens)
29+ }
30+ if u.lastUsed != 1280 {
31+ t.Errorf("lastUsed = %d, want 1280 (last call's input + output)", u.lastUsed)
32+ }
33+
34+ // A call that measured nothing does not erase the last measure.
35+ u.add(ui.Usage{})
36+ if u.lastUsed != 1280 {
37+ t.Errorf("lastUsed after an empty report = %d, want 1280", u.lastUsed)
38+ }
39+}
40+
41+// Optional fields stay absent when the server never gave them.
42+func TestUsageTotalsOmitsUnreported(t *testing.T) {
43+ u := usage{input: 10, output: 2}
44+ got := u.totals()
45+ if got.ThoughtTokens != nil || got.CachedReadTokens != nil {
46+ t.Errorf("totals = %+v, want no thought/cached fields", got)
47+ }
48+}
new file mode 100644
@@ -0,0 +1,48 @@
1+package acp
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/ui"
7+)
8+
9+// The response carries session TOTALS (the spec's definition), the meter the
10+// context the last call filled; a server that reported nothing leaves no
11+// `usage` at all rather than zeros.
12+func TestUsageTotals(t *testing.T) {
13+ var u usage
14+ if u.totals() != nil {
15+ t.Fatal("no report yet, but totals are not nil")
16+ }
17+ u.add(ui.Usage{InputTokens: 1000, OutputTokens: 50})
18+ u.add(ui.Usage{InputTokens: 1200, OutputTokens: 80, ThoughtTokens: 30, CachedReadTokens: 900})
19+
20+ got := u.totals()
21+ if got.InputTokens != 2200 || got.OutputTokens != 130 || got.TotalTokens != 2330 {
22+ t.Errorf("totals = %+v, want 2200 in / 130 out / 2330 total", got)
23+ }
24+ if got.ThoughtTokens == nil || *got.ThoughtTokens != 30 {
25+ t.Errorf("thoughtTokens = %v, want 30", got.ThoughtTokens)
26+ }
27+ if got.CachedReadTokens == nil || *got.CachedReadTokens != 900 {
28+ t.Errorf("cachedReadTokens = %v, want 900", got.CachedReadTokens)
29+ }
30+ if u.lastUsed != 1280 {
31+ t.Errorf("lastUsed = %d, want 1280 (last call's input + output)", u.lastUsed)
32+ }
33+
34+ // A call that measured nothing does not erase the last measure.
35+ u.add(ui.Usage{})
36+ if u.lastUsed != 1280 {
37+ t.Errorf("lastUsed after an empty report = %d, want 1280", u.lastUsed)
38+ }
39+}
40+
41+// Optional fields stay absent when the server never gave them.
42+func TestUsageTotalsOmitsUnreported(t *testing.T) {
43+ u := usage{input: 10, output: 2}
44+ got := u.totals()
45+ if got.ThoughtTokens != nil || got.CachedReadTokens != nil {
46+ t.Errorf("totals = %+v, want no thought/cached fields", got)
47+ }
48+}
modified internal/engine/engine.go +52 -1
@@ -131,6 +131,12 @@ func (e *Engine) Explain(err error) string {
131131 if sentence, ok := stuckTurn(err); ok {
132132 return sentence
133133 }
134+ // A tool call with broken arguments: the raw error quotes them whole —
135+ // a truncated document, on the day it was seen — so it gets a line of
136+ // its own (toolargs.go).
137+ if line, ok := badToolArgs(err); ok {
138+ return line
139+ }
134140 return e.Provider.Explain(err, e.Backend)
135141 }
136142
@@ -241,6 +247,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
241247 // `New` is called once per `Generate`, which keeps `full` captured at the
242248 // right level.
243249 var full []*ai.Message
250+ // argRetries counts the broken tool calls handed back to the model in
251+ // this generation (toolargs.go).
252+ argRetries := 0
244253 var gaps gap
245254 // think is the turn's thought block: see reasoning.go and thoughts below.
246255 // Declared here because emit, defined further down, closes over it.
@@ -250,12 +259,50 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
250259 WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {
251260 full = slices.Clone(params.Request.Messages)
252261 resp, err := next(ctx, params)
262+ // A tool call whose arguments did not parse — cut off by
263+ // max_tokens, most of the time — is given back to the model
264+ // with a note instead of ending the turn. The note goes into
265+ // params.Request itself: Genkit builds the next turn's request
266+ // from that same value, so it stays in the conversation — the
267+ // model needs it again when it writes the second piece — and
268+ // `full` carries it into the history.
269+ for argRetries < maxArgRetries && ctx.Err() == nil {
270+ truncated, malformed := argsError(err)
271+ if !truncated && !malformed {
272+ break
273+ }
274+ argRetries++
275+ what := "invalid JSON"
276+ if truncated {
277+ what = "cut off"
278+ }
279+ // ui.Out: stderr in ACP mode, never the JSON-RPC stream.
280+ spinner.Stop()
281+ fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n",
282+ what, lastRunes(err.Error(), 60), argRetries, maxArgRetries)
283+ params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated))
284+ full = slices.Clone(params.Request.Messages)
285+ resp, err = next(ctx, params)
286+ }
253287 // The server's own count of what it just read: the one exact
254288 // measure of the context size, kept for the compression
255289 // trigger. Zero means the server reported nothing.
256290 if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {
257291 e.lastInput.Store(int64(resp.Usage.InputTokens))
258292 }
293+ // An event-consuming front end (ACP) wants the counts of
294+ // every call, not just the last input: the client shows the
295+ // context meter and the tokens a turn cost.
296+ if resp != nil && resp.Usage != nil {
297+ if s, ok := ui.ActiveSink().(ui.UsageSink); ok {
298+ s.Usage(ui.Usage{
299+ InputTokens: resp.Usage.InputTokens,
300+ OutputTokens: resp.Usage.OutputTokens,
301+ ThoughtTokens: resp.Usage.ThoughtsTokens,
302+ CachedReadTokens: resp.Usage.CachedContentTokens,
303+ })
304+ }
305+ }
259306 return resp, err
260307 },
261308 // The tool is about to write to the screen: we forget the
@@ -393,7 +440,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
393440 // turn has happened, which is enough to tell the two apart.
394441 // The loop detector's stop is never retried either: replaying the turn
395442 // would run the repeated tool once more before stopping again.
396- if _, stuck := stuckTurn(err); err != nil && !printed && !toolsRan && !stuck {
443+ // Nor is a tool call whose arguments did not parse: the hook above has
444+ // already handed it back, and the same limit would cut the same call.
445+ _, stuck := stuckTurn(err)
446+ truncated, malformed := argsError(err)
447+ if err != nil && !printed && !toolsRan && !stuck && !truncated && !malformed {
397448 spinner.Start("Retrying without streaming")
398449 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
399450 emit(resp.Text())
@@ -131,6 +131,12 @@ func (e *Engine) Explain(err error) string {
131 if sentence, ok := stuckTurn(err); ok {131 if sentence, ok := stuckTurn(err); ok {
132 return sentence132 return sentence
133 }133 }
134+ // A tool call with broken arguments: the raw error quotes them whole —
135+ // a truncated document, on the day it was seen — so it gets a line of
136+ // its own (toolargs.go).
137+ if line, ok := badToolArgs(err); ok {
138+ return line
139+ }
134 return e.Provider.Explain(err, e.Backend)140 return e.Provider.Explain(err, e.Backend)
135 }141 }
136 142
@@ -241,6 +247,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
241 // `New` is called once per `Generate`, which keeps `full` captured at the247 // `New` is called once per `Generate`, which keeps `full` captured at the
242 // right level.248 // right level.
243 var full []*ai.Message249 var full []*ai.Message
250+ // argRetries counts the broken tool calls handed back to the model in
251+ // this generation (toolargs.go).
252+ argRetries := 0
244 var gaps gap253 var gaps gap
245 // think is the turn's thought block: see reasoning.go and thoughts below.254 // think is the turn's thought block: see reasoning.go and thoughts below.
246 // Declared here because emit, defined further down, closes over it.255 // Declared here because emit, defined further down, closes over it.
@@ -250,12 +259,50 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
250 WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {259 WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {
251 full = slices.Clone(params.Request.Messages)260 full = slices.Clone(params.Request.Messages)
252 resp, err := next(ctx, params)261 resp, err := next(ctx, params)
262+ // A tool call whose arguments did not parse — cut off by
263+ // max_tokens, most of the time — is given back to the model
264+ // with a note instead of ending the turn. The note goes into
265+ // params.Request itself: Genkit builds the next turn's request
266+ // from that same value, so it stays in the conversation — the
267+ // model needs it again when it writes the second piece — and
268+ // `full` carries it into the history.
269+ for argRetries < maxArgRetries && ctx.Err() == nil {
270+ truncated, malformed := argsError(err)
271+ if !truncated && !malformed {
272+ break
273+ }
274+ argRetries++
275+ what := "invalid JSON"
276+ if truncated {
277+ what = "cut off"
278+ }
279+ // ui.Out: stderr in ACP mode, never the JSON-RPC stream.
280+ spinner.Stop()
281+ fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n",
282+ what, lastRunes(err.Error(), 60), argRetries, maxArgRetries)
283+ params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated))
284+ full = slices.Clone(params.Request.Messages)
285+ resp, err = next(ctx, params)
286+ }
253 // The server's own count of what it just read: the one exact287 // The server's own count of what it just read: the one exact
254 // measure of the context size, kept for the compression288 // measure of the context size, kept for the compression
255 // trigger. Zero means the server reported nothing.289 // trigger. Zero means the server reported nothing.
256 if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {290 if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {
257 e.lastInput.Store(int64(resp.Usage.InputTokens))291 e.lastInput.Store(int64(resp.Usage.InputTokens))
258 }292 }
293+ // An event-consuming front end (ACP) wants the counts of
294+ // every call, not just the last input: the client shows the
295+ // context meter and the tokens a turn cost.
296+ if resp != nil && resp.Usage != nil {
297+ if s, ok := ui.ActiveSink().(ui.UsageSink); ok {
298+ s.Usage(ui.Usage{
299+ InputTokens: resp.Usage.InputTokens,
300+ OutputTokens: resp.Usage.OutputTokens,
301+ ThoughtTokens: resp.Usage.ThoughtsTokens,
302+ CachedReadTokens: resp.Usage.CachedContentTokens,
303+ })
304+ }
305+ }
259 return resp, err306 return resp, err
260 },307 },
261 // The tool is about to write to the screen: we forget the308 // The tool is about to write to the screen: we forget the
@@ -393,7 +440,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
393 // turn has happened, which is enough to tell the two apart.440 // turn has happened, which is enough to tell the two apart.
394 // The loop detector's stop is never retried either: replaying the turn441 // The loop detector's stop is never retried either: replaying the turn
395 // would run the repeated tool once more before stopping again.442 // would run the repeated tool once more before stopping again.
396- if _, stuck := stuckTurn(err); err != nil && !printed && !toolsRan && !stuck {443+ // Nor is a tool call whose arguments did not parse: the hook above has
444+ // already handed it back, and the same limit would cut the same call.
445+ _, stuck := stuckTurn(err)
446+ truncated, malformed := argsError(err)
447+ if err != nil && !printed && !toolsRan && !stuck && !truncated && !malformed {
397 spinner.Start("Retrying without streaming")448 spinner.Start("Retrying without streaming")
398 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {449 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
399 emit(resp.Text())450 emit(resp.Text())
modified internal/engine/openai_compat.go +4 -3
@@ -130,10 +130,11 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str
130130 Provider: p.name, // model prefix: "<provider>/<id>"
131131 BaseURL: b.BaseURL,
132132 APIKey: b.APIKey,
133- // The one hook the plugin leaves open, and all we need: a middleware
133+ // The one hook the plugin leaves open, and all we need: middlewares
134134 // on the HTTP client, so a thinking model's reasoning can be read off
135- // the wire the plugin drops it from (reasoning.go).
136- Opts: []option.RequestOption{option.WithMiddleware(reasoningTap)},
135+ // the wire the plugin drops it from (reasoning.go), and a streamed
136+ // request asks for the token counts it would otherwise lack (usage.go).
137+ Opts: []option.RequestOption{option.WithMiddleware(streamUsage, reasoningTap)},
137138 }
138139 g := genkit.Init(ctx, genkit.WithPlugins(client))
139140 client.DefineModel(p.name, b.Model, ai.ModelOptions{
@@ -130,10 +130,11 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str
130 Provider: p.name, // model prefix: "<provider>/<id>"130 Provider: p.name, // model prefix: "<provider>/<id>"
131 BaseURL: b.BaseURL,131 BaseURL: b.BaseURL,
132 APIKey: b.APIKey,132 APIKey: b.APIKey,
133- // The one hook the plugin leaves open, and all we need: a middleware133+ // The one hook the plugin leaves open, and all we need: middlewares
134 // on the HTTP client, so a thinking model's reasoning can be read off134 // on the HTTP client, so a thinking model's reasoning can be read off
135- // the wire the plugin drops it from (reasoning.go).135+ // the wire the plugin drops it from (reasoning.go), and a streamed
136- Opts: []option.RequestOption{option.WithMiddleware(reasoningTap)},136+ // request asks for the token counts it would otherwise lack (usage.go).
137+ Opts: []option.RequestOption{option.WithMiddleware(streamUsage, reasoningTap)},
137 }138 }
138 g := genkit.Init(ctx, genkit.WithPlugins(client))139 g := genkit.Init(ctx, genkit.WithPlugins(client))
139 client.DefineModel(p.name, b.Model, ai.ModelOptions{140 client.DefineModel(p.name, b.Model, ai.ModelOptions{
added internal/engine/toolargs.go +111 -0
new file mode 100644
@@ -0,0 +1,111 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/config"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// A tool call whose arguments are not valid JSON never reaches the tool: the
13+// compat_oai plugin decodes them while converting the server's response
14+// (`could not parse tool args: …`), and that error ends the whole generation.
15+// The model never reads it, so it cannot do what every other failure lets it
16+// do — read why and try again.
17+//
18+// The common cause is not a model that cannot write JSON: it is max_tokens.
19+// Seen on 2026-09-26 with a Qwen3 27B asked to write a document: one
20+// write_file of ~12,500 characters (~4k tokens) against `max_tokens: 4096`,
21+// cut mid-sentence, "unexpected end of JSON input", turn lost — and the error
22+// line the user saw was the whole truncated document.
23+//
24+// So the call is given back to the model once or twice, with a note saying
25+// what happened and how to get out of it (smaller pieces), before the error is
26+// let through — and then with a one-line explanation (badToolArgs) instead of
27+// the raw JSON.
28+
29+// maxArgRetries is how many times one generation hands a broken call back.
30+// A model that truncates twice in a row on the same file will do it a third
31+// time; past that the user has to change something (max_tokens, the request).
32+const maxArgRetries = 2
33+
34+// argsError classifies an error from the model call: truncated (the JSON stops
35+// before its end — max_tokens, almost always), malformed (it is complete but
36+// not JSON), or neither.
37+func argsError(err error) (truncated, malformed bool) {
38+ if err == nil {
39+ return false, false
40+ }
41+ msg := err.Error()
42+ if !strings.Contains(msg, "could not parse tool args") {
43+ return false, false
44+ }
45+ if strings.Contains(msg, "unexpected end of JSON input") || strings.Contains(msg, "unexpected EOF") {
46+ return true, false
47+ }
48+ return false, true
49+}
50+
51+// maxTokens is the configured max_tokens, "" when the sampling sets none.
52+func maxTokens() string {
53+ if v, ok := config.Cfg.Sampling["max_tokens"]; ok && v != nil {
54+ return fmt.Sprint(v)
55+ }
56+ return ""
57+}
58+
59+// pieceChars is a size one piece can safely have: half the answer limit, at
60+// roughly three characters per token. 0 when the limit is unknown.
61+func pieceChars() int {
62+ var n int
63+ if _, err := fmt.Sscan(maxTokens(), &n); err != nil || n <= 0 {
64+ return 0
65+ }
66+ return n * 3 / 2
67+}
68+
69+// argsRetryNote is what the model reads in place of the call it could not
70+// finish: a user message, since there is no tool response to attach it to —
71+// the call never became one.
72+func argsRetryNote(truncated bool) *ai.Message {
73+ var b strings.Builder
74+ b.WriteString("[mm: your last tool call was not run: ")
75+ if truncated {
76+ b.WriteString("it was cut off before its end")
77+ if mt := maxTokens(); mt != "" {
78+ b.WriteString(" (the answer limit is " + mt + " tokens)")
79+ }
80+ b.WriteString(", so its arguments were incomplete JSON. ")
81+ // A number the model can act on: "smaller pieces" alone was measured
82+ // to change nothing — the 12B retried the whole file, twice.
83+ b.WriteString("Do NOT retry the same call. Split the work: call write_file now with ONLY the first part")
84+ if n := pieceChars(); n > 0 {
85+ fmt.Fprintf(&b, " (at most about %d characters)", n)
86+ }
87+ b.WriteString(", then add each following part with its own edit_file call " +
88+ "(old = the last line already written, new = that line followed by the next part).]")
89+ } else {
90+ b.WriteString("its arguments were not valid JSON. " +
91+ "Call the tool again with a single JSON object matching its parameters; escape quotes, backslashes and newlines inside strings.]")
92+ }
93+ return ai.NewUserTextMessage(b.String())
94+}
95+
96+// badToolArgs is Explain's line for the same error once the retries are
97+// spent: what happened and which knob to turn, never the JSON itself.
98+func badToolArgs(err error) (string, bool) {
99+ truncated, malformed := argsError(err)
100+ switch {
101+ case truncated:
102+ limit := "the answer limit"
103+ if mt := maxTokens(); mt != "" {
104+ limit = "max_tokens (" + mt + ")"
105+ }
106+ return fmt.Sprintf("the model hit %s while writing a tool call, so its arguments were cut off — raise sampling.max_tokens in the config, or ask for a smaller change (a long file written in several parts)", limit), true
107+ case malformed:
108+ return "the model wrote a tool call whose arguments are not valid JSON — rephrase the request, or try another model", true
109+ }
110+ return "", false
111+}
new file mode 100644
@@ -0,0 +1,111 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/config"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// A tool call whose arguments are not valid JSON never reaches the tool: the
13+// compat_oai plugin decodes them while converting the server's response
14+// (`could not parse tool args: …`), and that error ends the whole generation.
15+// The model never reads it, so it cannot do what every other failure lets it
16+// do — read why and try again.
17+//
18+// The common cause is not a model that cannot write JSON: it is max_tokens.
19+// Seen on 2026-09-26 with a Qwen3 27B asked to write a document: one
20+// write_file of ~12,500 characters (~4k tokens) against `max_tokens: 4096`,
21+// cut mid-sentence, "unexpected end of JSON input", turn lost — and the error
22+// line the user saw was the whole truncated document.
23+//
24+// So the call is given back to the model once or twice, with a note saying
25+// what happened and how to get out of it (smaller pieces), before the error is
26+// let through — and then with a one-line explanation (badToolArgs) instead of
27+// the raw JSON.
28+
29+// maxArgRetries is how many times one generation hands a broken call back.
30+// A model that truncates twice in a row on the same file will do it a third
31+// time; past that the user has to change something (max_tokens, the request).
32+const maxArgRetries = 2
33+
34+// argsError classifies an error from the model call: truncated (the JSON stops
35+// before its end — max_tokens, almost always), malformed (it is complete but
36+// not JSON), or neither.
37+func argsError(err error) (truncated, malformed bool) {
38+ if err == nil {
39+ return false, false
40+ }
41+ msg := err.Error()
42+ if !strings.Contains(msg, "could not parse tool args") {
43+ return false, false
44+ }
45+ if strings.Contains(msg, "unexpected end of JSON input") || strings.Contains(msg, "unexpected EOF") {
46+ return true, false
47+ }
48+ return false, true
49+}
50+
51+// maxTokens is the configured max_tokens, "" when the sampling sets none.
52+func maxTokens() string {
53+ if v, ok := config.Cfg.Sampling["max_tokens"]; ok && v != nil {
54+ return fmt.Sprint(v)
55+ }
56+ return ""
57+}
58+
59+// pieceChars is a size one piece can safely have: half the answer limit, at
60+// roughly three characters per token. 0 when the limit is unknown.
61+func pieceChars() int {
62+ var n int
63+ if _, err := fmt.Sscan(maxTokens(), &n); err != nil || n <= 0 {
64+ return 0
65+ }
66+ return n * 3 / 2
67+}
68+
69+// argsRetryNote is what the model reads in place of the call it could not
70+// finish: a user message, since there is no tool response to attach it to —
71+// the call never became one.
72+func argsRetryNote(truncated bool) *ai.Message {
73+ var b strings.Builder
74+ b.WriteString("[mm: your last tool call was not run: ")
75+ if truncated {
76+ b.WriteString("it was cut off before its end")
77+ if mt := maxTokens(); mt != "" {
78+ b.WriteString(" (the answer limit is " + mt + " tokens)")
79+ }
80+ b.WriteString(", so its arguments were incomplete JSON. ")
81+ // A number the model can act on: "smaller pieces" alone was measured
82+ // to change nothing — the 12B retried the whole file, twice.
83+ b.WriteString("Do NOT retry the same call. Split the work: call write_file now with ONLY the first part")
84+ if n := pieceChars(); n > 0 {
85+ fmt.Fprintf(&b, " (at most about %d characters)", n)
86+ }
87+ b.WriteString(", then add each following part with its own edit_file call " +
88+ "(old = the last line already written, new = that line followed by the next part).]")
89+ } else {
90+ b.WriteString("its arguments were not valid JSON. " +
91+ "Call the tool again with a single JSON object matching its parameters; escape quotes, backslashes and newlines inside strings.]")
92+ }
93+ return ai.NewUserTextMessage(b.String())
94+}
95+
96+// badToolArgs is Explain's line for the same error once the retries are
97+// spent: what happened and which knob to turn, never the JSON itself.
98+func badToolArgs(err error) (string, bool) {
99+ truncated, malformed := argsError(err)
100+ switch {
101+ case truncated:
102+ limit := "the answer limit"
103+ if mt := maxTokens(); mt != "" {
104+ limit = "max_tokens (" + mt + ")"
105+ }
106+ return fmt.Sprintf("the model hit %s while writing a tool call, so its arguments were cut off — raise sampling.max_tokens in the config, or ask for a smaller change (a long file written in several parts)", limit), true
107+ case malformed:
108+ return "the model wrote a tool call whose arguments are not valid JSON — rephrase the request, or try another model", true
109+ }
110+ return "", false
111+}
added internal/engine/toolargs_test.go +132 -0
new file mode 100644
@@ -0,0 +1,132 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+
11+ "github.com/firebase/genkit/go/ai"
12+ "github.com/firebase/genkit/go/genkit"
13+)
14+
15+// The plugin's error for a tool call cut off by max_tokens, as it was seen on
16+// 2026-09-26 (shortened: the real one quoted the whole document).
17+var truncatedArgs = errors.New(`could not parse tool args: unmarshal failed to parse json string {"path":"docs/M.md","content":"# Mémoire: unexpected end of JSON input`)
18+
19+// scriptedGenkit registers a model that plays `steps` in order — a response
20+// or an error — and records every request it was sent, plus the fake `bash`.
21+func scriptedGenkit(t *testing.T, steps ...any) (*genkit.Genkit, *[]*ai.ModelRequest) {
22+ t.Helper()
23+ g := genkit.Init(context.Background())
24+ var seen []*ai.ModelRequest
25+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
26+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
27+ func(_ context.Context, req *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
28+ seen = append(seen, req)
29+ if len(seen) > len(steps) {
30+ t.Fatalf("the model was called %d times for %d planned steps", len(seen), len(steps))
31+ }
32+ switch s := steps[len(seen)-1].(type) {
33+ case error:
34+ return nil, s
35+ default:
36+ return s.(*ai.ModelResponse), nil
37+ }
38+ })
39+ genkit.DefineTool(g, "bash", "run a shell command",
40+ func(_ *ai.ToolContext, in struct {
41+ Command string `json:"command"`
42+ }) (string, error) {
43+ return "hello from " + in.Command, nil
44+ })
45+ return g, &seen
46+}
47+
48+// The shape of the session that motivated this: a command ran, then the
49+// model's next call was cut off. The call is handed back with a note, the
50+// model answers, the turn succeeds — and the note stays in the history the
51+// front end keeps, where the model will read it again for the next piece.
52+func TestTruncatedToolCallIsHandedBack(t *testing.T) {
53+ bash := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}, Ref: "1"}))
54+ g, seen := scriptedGenkit(t, bash, truncatedArgs, modelTurn(ai.NewTextPart("written in two parts")))
55+
56+ resp, full, err := testEngine(g).Generate(context.Background(),
57+ []*ai.Message{ai.NewUserTextMessage("write the document")},
58+ []ai.ToolRef{ai.ToolName("bash")})
59+ if err != nil {
60+ t.Fatalf("Generate: %v — the broken call should have been handed back", err)
61+ }
62+ if resp.Text() != "written in two parts" {
63+ t.Errorf("answer = %q", resp.Text())
64+ }
65+ if len(*seen) != 3 {
66+ t.Fatalf("model called %d times, want 3 (bash, cut off, retry)", len(*seen))
67+ }
68+ retry := (*seen)[2].Messages
69+ note := retry[len(retry)-1]
70+ if note.Role != ai.RoleUser || !strings.Contains(note.Text(), "cut off") || !strings.Contains(note.Text(), "Split the work") {
71+ t.Errorf("the retry does not end with the note: %s %q", note.Role, note.Text())
72+ }
73+ found := false
74+ for _, m := range full {
75+ if m.Role == ai.RoleUser && strings.Contains(m.Text(), "[mm: your last tool call was not run") {
76+ found = true
77+ }
78+ }
79+ if !found {
80+ t.Error("the note is not in the history handed back")
81+ }
82+ if Commands(full) != 1 {
83+ t.Errorf("Commands(full) = %d, want the ls that ran before", Commands(full))
84+ }
85+}
86+
87+// A model that keeps truncating is not retried forever: after maxArgRetries
88+// the error goes through, and Explain says what to change instead of quoting
89+// the JSON.
90+func TestTruncatedToolCallGivesUpAndExplains(t *testing.T) {
91+ old := config.Cfg.Sampling
92+ config.Cfg.Sampling = map[string]any{"max_tokens": 4096}
93+ defer func() { config.Cfg.Sampling = old }()
94+
95+ steps := []any{truncatedArgs}
96+ for range maxArgRetries {
97+ steps = append(steps, truncatedArgs)
98+ }
99+ g, seen := scriptedGenkit(t, steps...)
100+ e := testEngine(g)
101+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
102+
103+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("write it")}, nil)
104+ if err == nil {
105+ t.Fatal("Generate succeeded on a model that never finishes its call")
106+ }
107+ if len(*seen) != 1+maxArgRetries {
108+ t.Errorf("model called %d times, want %d", len(*seen), 1+maxArgRetries)
109+ }
110+ got := e.Explain(err)
111+ if !strings.Contains(got, "max_tokens (4096)") || strings.Contains(got, "Mémoire") {
112+ t.Errorf("Explain = %q, want the limit named and no JSON quoted", got)
113+ }
114+}
115+
116+func TestArgsError(t *testing.T) {
117+ cases := []struct {
118+ err error
119+ truncated, malformed bool
120+ }{
121+ {nil, false, false},
122+ {errors.New("connection refused"), false, false},
123+ {truncatedArgs, true, false},
124+ {errors.New(`could not parse tool args: unmarshal failed to parse json string {"a":1}}: invalid character '}'`), false, true},
125+ }
126+ for _, c := range cases {
127+ tr, ma := argsError(c.err)
128+ if tr != c.truncated || ma != c.malformed {
129+ t.Errorf("argsError(%v) = %v, %v; want %v, %v", c.err, tr, ma, c.truncated, c.malformed)
130+ }
131+ }
132+}
new file mode 100644
@@ -0,0 +1,132 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+
11+ "github.com/firebase/genkit/go/ai"
12+ "github.com/firebase/genkit/go/genkit"
13+)
14+
15+// The plugin's error for a tool call cut off by max_tokens, as it was seen on
16+// 2026-09-26 (shortened: the real one quoted the whole document).
17+var truncatedArgs = errors.New(`could not parse tool args: unmarshal failed to parse json string {"path":"docs/M.md","content":"# Mémoire: unexpected end of JSON input`)
18+
19+// scriptedGenkit registers a model that plays `steps` in order — a response
20+// or an error — and records every request it was sent, plus the fake `bash`.
21+func scriptedGenkit(t *testing.T, steps ...any) (*genkit.Genkit, *[]*ai.ModelRequest) {
22+ t.Helper()
23+ g := genkit.Init(context.Background())
24+ var seen []*ai.ModelRequest
25+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
26+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
27+ func(_ context.Context, req *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
28+ seen = append(seen, req)
29+ if len(seen) > len(steps) {
30+ t.Fatalf("the model was called %d times for %d planned steps", len(seen), len(steps))
31+ }
32+ switch s := steps[len(seen)-1].(type) {
33+ case error:
34+ return nil, s
35+ default:
36+ return s.(*ai.ModelResponse), nil
37+ }
38+ })
39+ genkit.DefineTool(g, "bash", "run a shell command",
40+ func(_ *ai.ToolContext, in struct {
41+ Command string `json:"command"`
42+ }) (string, error) {
43+ return "hello from " + in.Command, nil
44+ })
45+ return g, &seen
46+}
47+
48+// The shape of the session that motivated this: a command ran, then the
49+// model's next call was cut off. The call is handed back with a note, the
50+// model answers, the turn succeeds — and the note stays in the history the
51+// front end keeps, where the model will read it again for the next piece.
52+func TestTruncatedToolCallIsHandedBack(t *testing.T) {
53+ bash := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}, Ref: "1"}))
54+ g, seen := scriptedGenkit(t, bash, truncatedArgs, modelTurn(ai.NewTextPart("written in two parts")))
55+
56+ resp, full, err := testEngine(g).Generate(context.Background(),
57+ []*ai.Message{ai.NewUserTextMessage("write the document")},
58+ []ai.ToolRef{ai.ToolName("bash")})
59+ if err != nil {
60+ t.Fatalf("Generate: %v — the broken call should have been handed back", err)
61+ }
62+ if resp.Text() != "written in two parts" {
63+ t.Errorf("answer = %q", resp.Text())
64+ }
65+ if len(*seen) != 3 {
66+ t.Fatalf("model called %d times, want 3 (bash, cut off, retry)", len(*seen))
67+ }
68+ retry := (*seen)[2].Messages
69+ note := retry[len(retry)-1]
70+ if note.Role != ai.RoleUser || !strings.Contains(note.Text(), "cut off") || !strings.Contains(note.Text(), "Split the work") {
71+ t.Errorf("the retry does not end with the note: %s %q", note.Role, note.Text())
72+ }
73+ found := false
74+ for _, m := range full {
75+ if m.Role == ai.RoleUser && strings.Contains(m.Text(), "[mm: your last tool call was not run") {
76+ found = true
77+ }
78+ }
79+ if !found {
80+ t.Error("the note is not in the history handed back")
81+ }
82+ if Commands(full) != 1 {
83+ t.Errorf("Commands(full) = %d, want the ls that ran before", Commands(full))
84+ }
85+}
86+
87+// A model that keeps truncating is not retried forever: after maxArgRetries
88+// the error goes through, and Explain says what to change instead of quoting
89+// the JSON.
90+func TestTruncatedToolCallGivesUpAndExplains(t *testing.T) {
91+ old := config.Cfg.Sampling
92+ config.Cfg.Sampling = map[string]any{"max_tokens": 4096}
93+ defer func() { config.Cfg.Sampling = old }()
94+
95+ steps := []any{truncatedArgs}
96+ for range maxArgRetries {
97+ steps = append(steps, truncatedArgs)
98+ }
99+ g, seen := scriptedGenkit(t, steps...)
100+ e := testEngine(g)
101+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
102+
103+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("write it")}, nil)
104+ if err == nil {
105+ t.Fatal("Generate succeeded on a model that never finishes its call")
106+ }
107+ if len(*seen) != 1+maxArgRetries {
108+ t.Errorf("model called %d times, want %d", len(*seen), 1+maxArgRetries)
109+ }
110+ got := e.Explain(err)
111+ if !strings.Contains(got, "max_tokens (4096)") || strings.Contains(got, "Mémoire") {
112+ t.Errorf("Explain = %q, want the limit named and no JSON quoted", got)
113+ }
114+}
115+
116+func TestArgsError(t *testing.T) {
117+ cases := []struct {
118+ err error
119+ truncated, malformed bool
120+ }{
121+ {nil, false, false},
122+ {errors.New("connection refused"), false, false},
123+ {truncatedArgs, true, false},
124+ {errors.New(`could not parse tool args: unmarshal failed to parse json string {"a":1}}: invalid character '}'`), false, true},
125+ }
126+ for _, c := range cases {
127+ tr, ma := argsError(c.err)
128+ if tr != c.truncated || ma != c.malformed {
129+ t.Errorf("argsError(%v) = %v, %v; want %v, %v", c.err, tr, ma, c.truncated, c.malformed)
130+ }
131+ }
132+}
added internal/engine/usage.go +68 -0
new file mode 100644
@@ -0,0 +1,68 @@
1+package engine
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+
9+ "github.com/openai/openai-go/option"
10+)
11+
12+// A streamed chat completion carries no token counts unless the request asks
13+// for them with `stream_options: {"include_usage": true}` — then the server
14+// adds one last chunk, with empty choices, holding the `usage` object.
15+//
16+// Genkit's compat_oai plugin never asks. Measured on Docker Model Runner
17+// (llama.cpp backend), 2026-09-26: without the option the last chunk has
18+// `timings` and no `usage`; with it, `usage` arrives. So in streaming mode —
19+// every generation — resp.Usage stayed at zero, which left two things blind:
20+//
21+// - the compression trigger, which fell back to the character estimate
22+// instead of the server's exact count (lastInput);
23+// - the ACP front end, which had nothing to report as usage_update or in
24+// the session/prompt response.
25+//
26+// The plugin's accumulator already sums the `usage` of every chunk, empty
27+// choices included, so asking is all it takes. It cannot go in the sampling
28+// config: that is sent on the non-streamed calls too (the retry, the
29+// summariser), and OpenAI rejects stream_options when stream is false.
30+
31+// streamUsage is the openai-go middleware that adds the option to a request
32+// that streams and has not chosen for itself. Anything else — not JSON, not
33+// streamed, stream_options already set — goes through untouched.
34+func streamUsage(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
35+ if req.Method == http.MethodPost && req.Body != nil {
36+ if body, err := io.ReadAll(req.Body); err == nil {
37+ _ = req.Body.Close()
38+ body = withIncludeUsage(body)
39+ req.Body = io.NopCloser(bytes.NewReader(body))
40+ req.ContentLength = int64(len(body))
41+ req.GetBody = func() (io.ReadCloser, error) {
42+ return io.NopCloser(bytes.NewReader(body)), nil
43+ }
44+ }
45+ }
46+ return next(req)
47+}
48+
49+// withIncludeUsage returns body with stream_options.include_usage set when it
50+// is a streamed request that says nothing about it, body unchanged otherwise.
51+func withIncludeUsage(body []byte) []byte {
52+ var m map[string]json.RawMessage
53+ if json.Unmarshal(body, &m) != nil {
54+ return body
55+ }
56+ if string(m["stream"]) != "true" {
57+ return body
58+ }
59+ if _, set := m["stream_options"]; set {
60+ return body
61+ }
62+ m["stream_options"] = json.RawMessage(`{"include_usage":true}`)
63+ out, err := json.Marshal(m)
64+ if err != nil {
65+ return body
66+ }
67+ return out
68+}
new file mode 100644
@@ -0,0 +1,68 @@
1+package engine
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+
9+ "github.com/openai/openai-go/option"
10+)
11+
12+// A streamed chat completion carries no token counts unless the request asks
13+// for them with `stream_options: {"include_usage": true}` — then the server
14+// adds one last chunk, with empty choices, holding the `usage` object.
15+//
16+// Genkit's compat_oai plugin never asks. Measured on Docker Model Runner
17+// (llama.cpp backend), 2026-09-26: without the option the last chunk has
18+// `timings` and no `usage`; with it, `usage` arrives. So in streaming mode —
19+// every generation — resp.Usage stayed at zero, which left two things blind:
20+//
21+// - the compression trigger, which fell back to the character estimate
22+// instead of the server's exact count (lastInput);
23+// - the ACP front end, which had nothing to report as usage_update or in
24+// the session/prompt response.
25+//
26+// The plugin's accumulator already sums the `usage` of every chunk, empty
27+// choices included, so asking is all it takes. It cannot go in the sampling
28+// config: that is sent on the non-streamed calls too (the retry, the
29+// summariser), and OpenAI rejects stream_options when stream is false.
30+
31+// streamUsage is the openai-go middleware that adds the option to a request
32+// that streams and has not chosen for itself. Anything else — not JSON, not
33+// streamed, stream_options already set — goes through untouched.
34+func streamUsage(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
35+ if req.Method == http.MethodPost && req.Body != nil {
36+ if body, err := io.ReadAll(req.Body); err == nil {
37+ _ = req.Body.Close()
38+ body = withIncludeUsage(body)
39+ req.Body = io.NopCloser(bytes.NewReader(body))
40+ req.ContentLength = int64(len(body))
41+ req.GetBody = func() (io.ReadCloser, error) {
42+ return io.NopCloser(bytes.NewReader(body)), nil
43+ }
44+ }
45+ }
46+ return next(req)
47+}
48+
49+// withIncludeUsage returns body with stream_options.include_usage set when it
50+// is a streamed request that says nothing about it, body unchanged otherwise.
51+func withIncludeUsage(body []byte) []byte {
52+ var m map[string]json.RawMessage
53+ if json.Unmarshal(body, &m) != nil {
54+ return body
55+ }
56+ if string(m["stream"]) != "true" {
57+ return body
58+ }
59+ if _, set := m["stream_options"]; set {
60+ return body
61+ }
62+ m["stream_options"] = json.RawMessage(`{"include_usage":true}`)
63+ out, err := json.Marshal(m)
64+ if err != nil {
65+ return body
66+ }
67+ return out
68+}
added internal/engine/usage_test.go +60 -0
new file mode 100644
@@ -0,0 +1,60 @@
1+package engine
2+
3+import (
4+ "encoding/json"
5+ "io"
6+ "net/http"
7+ "strings"
8+ "testing"
9+)
10+
11+// A streamed request asks for the token counts; everything else — a request
12+// that does not stream (the retry, the summariser: OpenAI rejects the option
13+// there), one that already chose, a body that is not JSON — is left as is.
14+func TestWithIncludeUsage(t *testing.T) {
15+ var m map[string]any
16+ got := withIncludeUsage([]byte(`{"model":"m","stream":true,"messages":[]}`))
17+ if err := json.Unmarshal(got, &m); err != nil {
18+ t.Fatalf("not JSON any more: %s", got)
19+ }
20+ so, _ := m["stream_options"].(map[string]any)
21+ if so["include_usage"] != true {
22+ t.Errorf("streamed request: stream_options = %v, want include_usage true", m["stream_options"])
23+ }
24+ if m["model"] != "m" {
25+ t.Errorf("the rest of the body changed: %s", got)
26+ }
27+
28+ for _, body := range []string{
29+ `{"model":"m","messages":[]}`,
30+ `{"model":"m","stream":false}`,
31+ `{"stream":true,"stream_options":{"include_usage":false}}`,
32+ `not json`,
33+ } {
34+ if got := withIncludeUsage([]byte(body)); string(got) != body {
35+ t.Errorf("%s was changed to %s", body, got)
36+ }
37+ }
38+}
39+
40+// The middleware hands the rewritten body on with a matching length, and a
41+// GetBody that replays it — what net/http needs to retry or redirect.
42+func TestStreamUsageRewritesTheRequest(t *testing.T) {
43+ req, _ := http.NewRequest(http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(`{"stream":true}`))
44+ var seen string
45+ _, _ = streamUsage(req, func(r *http.Request) (*http.Response, error) {
46+ b, _ := io.ReadAll(r.Body)
47+ seen = string(b)
48+ if r.ContentLength != int64(len(b)) {
49+ t.Errorf("ContentLength %d, body %d bytes", r.ContentLength, len(b))
50+ }
51+ again, _ := r.GetBody()
52+ if b2, _ := io.ReadAll(again); string(b2) != seen {
53+ t.Errorf("GetBody replays %q, want %q", b2, seen)
54+ }
55+ return &http.Response{StatusCode: 200, Body: http.NoBody}, nil
56+ })
57+ if !strings.Contains(seen, `"include_usage":true`) {
58+ t.Errorf("body sent: %s", seen)
59+ }
60+}
new file mode 100644
@@ -0,0 +1,60 @@
1+package engine
2+
3+import (
4+ "encoding/json"
5+ "io"
6+ "net/http"
7+ "strings"
8+ "testing"
9+)
10+
11+// A streamed request asks for the token counts; everything else — a request
12+// that does not stream (the retry, the summariser: OpenAI rejects the option
13+// there), one that already chose, a body that is not JSON — is left as is.
14+func TestWithIncludeUsage(t *testing.T) {
15+ var m map[string]any
16+ got := withIncludeUsage([]byte(`{"model":"m","stream":true,"messages":[]}`))
17+ if err := json.Unmarshal(got, &m); err != nil {
18+ t.Fatalf("not JSON any more: %s", got)
19+ }
20+ so, _ := m["stream_options"].(map[string]any)
21+ if so["include_usage"] != true {
22+ t.Errorf("streamed request: stream_options = %v, want include_usage true", m["stream_options"])
23+ }
24+ if m["model"] != "m" {
25+ t.Errorf("the rest of the body changed: %s", got)
26+ }
27+
28+ for _, body := range []string{
29+ `{"model":"m","messages":[]}`,
30+ `{"model":"m","stream":false}`,
31+ `{"stream":true,"stream_options":{"include_usage":false}}`,
32+ `not json`,
33+ } {
34+ if got := withIncludeUsage([]byte(body)); string(got) != body {
35+ t.Errorf("%s was changed to %s", body, got)
36+ }
37+ }
38+}
39+
40+// The middleware hands the rewritten body on with a matching length, and a
41+// GetBody that replays it — what net/http needs to retry or redirect.
42+func TestStreamUsageRewritesTheRequest(t *testing.T) {
43+ req, _ := http.NewRequest(http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(`{"stream":true}`))
44+ var seen string
45+ _, _ = streamUsage(req, func(r *http.Request) (*http.Response, error) {
46+ b, _ := io.ReadAll(r.Body)
47+ seen = string(b)
48+ if r.ContentLength != int64(len(b)) {
49+ t.Errorf("ContentLength %d, body %d bytes", r.ContentLength, len(b))
50+ }
51+ again, _ := r.GetBody()
52+ if b2, _ := io.ReadAll(again); string(b2) != seen {
53+ t.Errorf("GetBody replays %q, want %q", b2, seen)
54+ }
55+ return &http.Response{StatusCode: 200, Body: http.NoBody}, nil
56+ })
57+ if !strings.Contains(seen, `"include_usage":true`) {
58+ t.Errorf("body sent: %s", seen)
59+ }
60+}
modified internal/ui/ui.go +16 -0
@@ -82,6 +82,22 @@ type Sink interface {
8282 WorkDir() string
8383 }
8484
85+// Usage is what the server reported for ONE model call — a turn makes one per
86+// model ↔ tools round trip. Zero fields mean the server did not say.
87+type Usage struct {
88+ InputTokens int // the prompt it read: the context size of that call
89+ OutputTokens int
90+ ThoughtTokens int
91+ CachedReadTokens int
92+}
93+
94+// UsageSink is the optional side of a Sink that wants the token counts. Kept
95+// out of Sink itself: only the ACP front end reports them, and a sink that
96+// does not care — the tests' fakes — should not have to say so.
97+type UsageSink interface {
98+ Usage(u Usage)
99+}
100+
85101 // The active sink is set once per turn by the ACP façade and read by the tools
86102 // from Genkit's goroutines — hence the lock, not a plain variable.
87103 var (
@@ -82,6 +82,22 @@ type Sink interface {
82 WorkDir() string82 WorkDir() string
83 }83 }
84 84
85+// Usage is what the server reported for ONE model call — a turn makes one per
86+// model ↔ tools round trip. Zero fields mean the server did not say.
87+type Usage struct {
88+ InputTokens int // the prompt it read: the context size of that call
89+ OutputTokens int
90+ ThoughtTokens int
91+ CachedReadTokens int
92+}
93+
94+// UsageSink is the optional side of a Sink that wants the token counts. Kept
95+// out of Sink itself: only the ACP front end reports them, and a sink that
96+// does not care — the tests' fakes — should not have to say so.
97+type UsageSink interface {
98+ Usage(u Usage)
99+}
100+
85 // The active sink is set once per turn by the ACP façade and read by the tools101 // The active sink is set once per turn by the ACP façade and read by the tools
86 // from Genkit's goroutines — hence the lock, not a plain variable.102 // from Genkit's goroutines — hence the lock, not a plain variable.
87 var (103 var (