bots-garden/mini-mepublic Fork
⑂ feature/wasm-custom-tools
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. 015bd7a · on feature/wasm-custom-tools · k33g · Sep 30, 2026
petits-llms.adoc · 491 lines · 37.9 KBGDScript3 Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
= Mini Me et les petits LLMs
:toc: left
:toclevels: 2
:toc-title: Sommaire
:sectnums:
:icons: font
:source-highlighter: rouge
:lang: fr
:revdate: 2026-09-30

[abstract]
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.
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*.

== Le point de départ : ce qu'un petit modèle fait mal

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) :

[cols="1,2",options="header"]
|===
| Défaillance | Manifestation typique

| Hallucination
| Le modèle « cite » un fichier ou une sortie de commande qu'il n'a jamais lus.

| Imprécision sur les octets
| Il retape un bloc à éditer avec des espaces là où le fichier a des tabulations, ou ajoute une ligne vide.

| Boucles
| Il répète le même appel refusé — trente fois, observé le 18/09/2026 avec un modèle 12B.

| Déraillement en cours de génération
| Il compose des arguments d'outil qui se répètent pendant des minutes (`"Greet 2023-06-15 11:16:00 …"`).

| Appel d'outil trop long
| Il écrit tout un document dans un seul `write_file`, qui dépasse `max_tokens` et arrive coupé (observé le 26/09/2026).

| Raisonnement long
| Un modèle « thinking » réfléchit 38 s sans émettre un octet de contenu.

| Petite fenêtre de contexte
| La fenêtre est fixée au chargement du modèle ; une session longue finit en « stalled » puis en erreur 400.

| Routage faible
| Il ne relie pas « salue Bob » à la procédure nommée `greetings`.
|===

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*.

== Une surface d'outils petite, configurable et explicite

=== Bash d'abord, peu d'outils

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* :

* `read_skill` n'existe pas si `skillsDir` ne contient aucun `.md` ;
* `lsp` n'existe pas si aucun serveur de langage n'est configuré *et* présent dans le `PATH`.

Un dernier, `fetch_content` (voir <<fetch>>), est désactivé par défaut (`fetchContentTool: true` pour l'activer, comme dans `agent.dmr.yaml`) : c'est le seul outil intégré qui accède au réseau.

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.

=== Des descriptions écrites pour être suivies

Les descriptions d'outils ne décrivent pas seulement l'API, elles anticipent les erreurs :

* `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 ».
* `read_file` : « edit_file needs the EXACT text of the file … Do not type it from memory. »
* `edit_file` : six règles *numérotées*, dont « do not switch to write_file to force the change through ».
* `write_file` : rappelle qu'il écrase tout et renvoie vers `edit_file`.
* `fetch_content` : donne des exemples de demandes en plusieurs langues (« récupère le contenu de https://example.com »), avec la consigne « do not use curl or wget through bash for that ».

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.

=== Un échantillonnage conservateur

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.

== Le contrat des outils : un échec est du texte

C'est le principe structurant, énoncé en tête de `internal/tools/tools.go` :

[quote]
We NEVER return a Go `error` for a "normal" failure. The error goes back AS TEXT, so that the model reads it and reacts.

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.

Les messages sont rédigés comme des *consignes de réparation* :

* `read_skill` avec un nom inconnu répond `[skill: no skill named "x" — available: a, b, c]` ;
* `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`) ;
* `fetch_content` classe ses échecs en trois codes, `[fetch_content: error N (…) — raison]` : 1 = page non convertible, 2 = page inaccessible (hôte, statut HTTP, `robots.txt`), 3 = argument invalide. La description dit au modèle que le code 3 signifie « corrige ton appel », pour qu'il ne s'acharne pas sur un site en panne.

La seule exception est volontaire : l'arrêt par le détecteur de boucle (voir <<boucles>>).

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>>).

== Une édition de fichiers tolérante, mais sûre

`internal/fileedit` est probablement la partie la plus « petits modèles » du code.

=== Trois passes de correspondance, de la plus stricte à la plus souple

`findAll` (`internal/fileedit/match.go`) cherche `old` :

. *exactement* ;
. en ignorant les *bords* (ligne vide au début, indentation d'une ligne unique, ligne vide finale) ;
. en ignorant l'*indentation* ligne par ligne, pour un bloc de plusieurs lignes.

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.

=== Ce qui n'est *pas* relâché

* *L'unicité* : deux correspondances souples sont refusées exactement comme deux correspondances exactes.
* *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.
* *La transparence* : le résumé signale « N matched ignoring whitespace », pour que le modèle recopie mieux la fois suivante.

=== Un « not found » qui aide

`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.

=== Des détails invisibles neutralisés

`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.

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.

== Contre l'hallucination : montrer, puis seulement commenter

* *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.
* *Le prompt interdit d'affirmer sans preuve* : « Never state the contents of a file … unless a command in THIS answer returned it. »
* *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.
* *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.

[[boucles]]
== Garde-fous contre les boucles et les blocages

Quatre mécanismes, du plus doux au plus radical :

[cols="1,2,1",options="header"]
|===
| Mécanisme | Déclencheur | Réaction

| Détecteur de boucle, niveau 1 (`internal/detector`)
| Même triplet (outil, entrée, sortie) 3 fois de suite
| `[LOOP_DETECTED: … try a different approach]` ajouté à la sortie

| Détecteur, niveau 2
| 3 répétitions supplémentaires malgré l'avertissement
| Erreur Go `Stuck` : fin du tour, pas de nouvelle tentative, enregistrée dans la session

| Garde des arguments (`internal/engine/runaway.go`)
| Arguments d'outil en streaming périodiques (motif de 4 à 64 octets sur les 256 derniers) ou > 256 Kio
| Génération interrompue immédiatement, avec un message explicite

| Watchdog (`engine.watchdog`)
| Aucun token depuis `watchdogTimeout`
| Annulation : « stalled »
|===

Plusieurs choix fins méritent d'être notés :

* 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`.
* 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).
* Le spinner indique « Composing a tool call » : un utilisateur sait que le modèle travaille, et non qu'il est bloqué.
* `maxTurns` borne les allers-retours modèle ↔ outils (10 par défaut, 40 dans `agent.dmr.yaml`).
* `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 &`.

[[args]]
== Appels d'outils coupés ou mal formés

=== Le cas qui l'a motivé

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.

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.

Deux causes se cumulent, typiques des modèles locaux :

* un petit modèle veut tout faire en un appel, même quand le résultat est long ;
* avec un modèle « thinking », le raisonnement consomme aussi le budget `max_tokens`.

=== Ce que fait `mm`

`internal/engine/toolargs.go`, branché dans le hook `WrapModel` de `engine.Generate` :

. Deux cas sont reconnus : un JSON *coupé* (`unexpected end of JSON input`) et un JSON *mal formé*.
. 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` ».
. 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.
. La relance sans streaming n'est pas tentée pour cette erreur : la même limite couperait le même appel.
. 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 … ».

=== Ce que la mesure a montré

Test réel sur Docker Model Runner avec Mellum 12B, `max_tokens` volontairement réduit à 300 puis 800 :

* le mécanisme fonctionne : le modèle reprend la main deux fois, puis le message clair s'affiche ;
* *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.

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. Les configurations de `config.samples/` montent à 32 768 (voir <<p13>>), alors que `agent.dmr.yaml` et `agent.llamacpp.yaml` restent à 4 096. Voir aussi la piste <<p15>>.

== Modèles à raisonnement

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 :

* *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.
* 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.

== Le contexte : une ressource rare

=== Des sorties bornées

`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.

[[fetch]]
=== Une page web qui ne passe pas par le contexte

`fetch_content` (`internal/tools/fetchcontent.go`, conversion dans `internal/webmd`) récupère une page et la rend en Markdown, après avoir retiré les scripts, la navigation, les publicités et les bandeaux de cookies. Trois choix visent directement les petits modèles :

* *Enregistrer sans afficher.* Avec `output`, le Markdown est écrit dans le fichier et l'outil ne renvoie qu'une ligne (`[fetch_content: saved N lines … — the content is not shown]`). « Sauve-le dans hello.txt, ne l'affiche pas » ne coûte donc pas une page entière de contexte, et le modèle n'a pas de texte à recopier.
* *Une URL mal tapée est réparée.* `NormalizeURL` (`internal/webmd/fetch.go`) complète un nom de site sans schéma (`k33g.org` → `https://k33g.org`) et corrige un schéma sans deux-points (`https//k33g.org`), faute fréquente quand le modèle recopie la demande.
* *Une page vide est signalée, pas enregistrée* : le modèle ne confirme pas l'enregistrement d'un fichier vide.

Sans `output`, la page passe par `truncate`, comme toute sortie d'outil. Vérifié le 29/09/2026 avec Mellum 12B (`mm -p`, Docker Model Runner) : pour « récupère le contenu de https//k33g.org », puis la même demande avec « ne l'affiche pas et sauve-le dans hello.txt », le modèle a choisi `fetch_content` les deux fois, a écrit le fichier et n'a pas recopié son contenu.

=== Une compression hybride

`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 :

* un résumé précédent est *fusionné*, pas résumé à nouveau (le détail décroîtrait exponentiellement) ;
* 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 ;
* elle est envoyée *sans raisonnement* sur `dmr` et `llamacpp` (`chat_template_kwargs.enable_thinking: false`, depuis le 27/09/2026) : le raisonnement est décompté de `summaryMaxTokens` et n'est pas dans le texte rendu. Constaté avec Qwen3.8-27B sur 18 000 tokens d'historique : trois compressions de suite ont échoué sur « empty summary », le budget entier parti en réflexion ; reproduit avec qwen3.5-2b et `summaryMaxTokens: 300`, corrigé par ce réglage ;
* 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 ;
* si la fenêtre est inconnue au démarrage (serveur lancé après l'agent), elle est redemandée paresseusement (`EnsureContextWindow`).

=== Le compte exact du serveur

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.

== Skills : aider le routage

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` :

* 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 ;
* 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 ;
* 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. »

`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.

== Robustesse côté serveur local

* *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.
* *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é.
* *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine.
* *Lecture du prompt ≠ blocage* : avant son premier token, le serveur lit tout le prompt sans rien envoyer — plusieurs minutes pour 25k tokens sur un 27B local. `mm` demande `return_progress` à llama.cpp, lit les chunks `prompt_progress` pour alimenter le watchdog (et afficher « Reading the prompt 12.0k/25.3k tokens »), et accorde `firstTokenTimeout` (3 min) avant le premier token de chaque appel, `watchdogTimeout` ensuite (`internal/engine/progress.go`). Observé le 26/09/2026 : cinq questions de suite, « hello » compris, tuées en « stalled » par un watchdog de 30 s.
* *Un préfixe stable* : llama.cpp ne relit que ce qui suit le plus long début commun avec la requête précédente. Or Genkit construit la liste des outils à partir d'une map Go, dans un ordre aléatoire : chaque question envoyait les outils dans un autre ordre, et le serveur relisait tout le prompt (ticket 53, toujours présent dans Genkit v1.13.1). Depuis le 30/09/2026, `stableTools` (`internal/engine/tooldefs.go`) les trie par nom avant chaque appel. Mesuré avec Mellum 12B sur Docker Model Runner : à partir de la deuxième question, ≈ 20 tokens relus en ≈ 250 ms, contre ≈ 3 400 à 3 900 tokens en 7 à 9 s chaque fois que l'ordre changeait.
* *Flux interrompu par le serveur* : llama.cpp signale une erreur en cours de génération par un événement SSE `error:`, qu'openai-go ignore (il ne restait que `unexpected end of JSON input`). `mm` récupère ce message (`internal/engine/servererr.go`) et relance *une fois* l'appel au modèle qui a échoué, sans rejouer les outils déjà exécutés dans le tour. Observé le 26/09/2026 : un tour perdu après vingt appels d'outils et un fichier écrit.

== Une méthode : mesurer

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. Depuis le 27/09/2026, chaque fichier de session enregistre `provider` et `model`, et chaque erreur le modèle qui l'a produite (`internal/store`). Une session qui a échoué peut donc être rattachée au modèle en cause, même quand on en change en cours de route. Garder cette discipline est la meilleure garantie que les améliorations ci-dessous en soient vraiment.

[[pistes]]
== Pistes d'amélioration

Échelle d'effort : *faible* ≈ moins d'une journée, *moyen* ≈ quelques jours, *élevé* ≈ une semaine ou plus, mesures comprises.
Le résultat est celui attendu sur un petit modèle local ; « à mesurer » signale un gain plausible mais non démontré.

=== Vue d'ensemble

[cols="3,1,1,4",options="header"]
|===
| Piste | Effort | Résultat | En une phrase

| <<p1>> | Faible | Élevé | Rendre au modèle, en texte, les erreurs de validation d'arguments qui terminent encore le tour.
| <<p2>> | Moyen | Élevé | Un nom d'outil inventé (`python`, `shell`…) ne doit pas tuer le tour.
| <<p3>> | Faible | Élevé | 16 000 caractères de sortie, c'est la moitié d'une fenêtre de 8k.
| <<p4>> | Moyen | Élevé | Masquer les vieilles sorties d'outils avant de recourir au résumé.
| <<p5>> | Faible | Moyen | Donner au modèle une dernière chance de répondre quand `maxTurns` est atteint.
| <<p6>> | Faible | Moyen | Sortir les balises `<think>` du texte de réponse et de l'historique.
| <<p7>> | Faible | Moyen | Réduire le coût fixe du prompt (≈ 3,4k tokens mesurés).
| <<p8>> | Faible à moyen | Moyen | Détecter aussi les cycles A→B→A et les quasi-doublons.
| <<p9>> | Faible | Moyen | Connaître la fenêtre de contexte avec Docker Model Runner.
| <<p10>> | Faible | Moyen | Exploiter `cached_tokens` et `timings` (cache KV, vitesse de prefill).
| <<p11>> | Moyen | Élevé (sur certains modèles) | Récupérer les appels d'outils écrits en texte.
| <<p12>> | Moyen à élevé | Élevé (pour décider) | Étendre l'évaluation aux tâches complètes, modèle par modèle.
| <<p13>> | Faible | Moyen | Profils par modèle (échantillonnage, variantes de prompt).
| <<p14>> | Moyen | À mesurer | Varier la température après un `LOOP_DETECTED`.
| <<p15>> | Faible | Moyen | Signaler une réponse coupée par `max_tokens`, et prévenir les appels trop longs.
|===

Ordre conseillé : 1, 3, 5, 6 et 15 (effort faible, gain net), puis 2 et 4, puis 12 pour mesurer le reste.

[[p1]]
=== Erreurs d'arguments rendues en texte

*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 ».

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.

*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.

*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`.

[[p2]]
=== Outils inventés

*Constat.* Un nom d'outil inconnu produit `tool "python" not found` *avant* tout middleware d'outil : le tour s'arrête.

*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.

*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.

[[p3]]
=== `maxOutput` proportionnel à la fenêtre

*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é.

*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`).

*Effort : faible.* *Résultat : élevé* sur les fenêtres de 4 à 16k, nul au-delà.

[[p4]]
=== Masquage des vieilles sorties d'outils

*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.

*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.

*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 ».

[[p5]]
=== Dernière chance à `maxTurns`

*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.

*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.

*Effort : faible.* *Résultat : moyen* — une fin de tour lisible plutôt qu'une erreur.

[[p6]]
=== Balises `<think>` dans le contenu

*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.

*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`.

*Effort : faible.* *Résultat : moyen*, élevé pour les modèles concernés.

[[p7]]
=== Coût fixe du prompt

*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.

Cette mesure date d'avant `fetch_content`. Le 30/09/2026, la première requête de `mm -p hello` avec `agent.dmr.yaml` a été capturée par un faux serveur OpenAI, avec et sans `fetchContentTool` :

[cols="2,1,1,1",options="header"]
|===
| Élément (caractères) | Description | Schéma | Total

| Prompt système | | | 3 436
| `edit_file` | 1 383 | 758 | 2 141
| `plan` | 1 335 | 842 | 2 177
| `fetch_content` | 1 037 | 1 068 | 2 105
| `read_skill` (catalogue de 7 skills) | 1 373 | 222 | 1 595
| `read_file` | 311 | 534 | 845
| `bash` | 575 | 164 | 739
| `write_file` | 348 | 307 | 655
| *Requête entière* (JSON) | | | *14 993* (12 762 sans `fetch_content`)
|===

Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 436). `fetch_content` ajoute 2 231 caractères à la requête, soit +17 %. Compte du serveur, le même jour (Mellum 12B, Docker Model Runner, `timings.prompt_n`) : 3 813 tokens pour la première question d'une session, question comprise. Le schéma de `fetch_content` est le plus lourd des sept, à cause des descriptions de ses quatre paramètres optionnels (`baseUrl`, `tables`, `strikethrough`, `taskList`).

*Proposition.*

* 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. La même question se pose pour `fetchContentTool` sur les petites fenêtres, et pour les paramètres optionnels de `fetch_content`, qu'un petit modèle n'utilise presque jamais.
* Éviter les doublons entre prompt système et descriptions quand ils ne servent pas.
* Mesurer chaque retrait avec `internal/eval` : la redondance est parfois ce qui fait marcher un 4B.

*Effort : faible.* *Résultat : moyen* — prefill plus rapide à chaque requête, et plus de place pour le travail sur les petites fenêtres.

[[p8]]
=== Détecteur de boucle plus fin

*Constat.* Le détecteur ne voit que des triplets *identiques et consécutifs*. Il manque :

* les cycles A→B→A→B (lire, éditer refusé, lire, éditer refusé) ;
* les quasi-doublons (le même `edit_file` refusé, avec un `old` qui varie d'un espace).

*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).

*Effort : faible à moyen.* *Résultat : moyen* — des boucles aujourd'hui arrêtées seulement par `maxTurns` ou par l'utilisateur.

[[p9]]
=== Fenêtre de contexte avec Docker Model Runner

*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`.

*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.

*Effort : faible.* *Résultat : moyen.*

[[p10]]
=== Exploiter `cached_tokens` et `timings`

*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.

*Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite :

* afficher le taux de cache KV ;
* repérer ce qui casse la stabilité du préfixe (un prompt système qui change, un catalogue réordonné) — c'est ainsi, avec les `timings` relevés par un proxy, qu'a été trouvé l'ordre aléatoire des outils (ticket 53) ;
* alimenter `cachedReadTokens` en ACP.

Sur une machine locale, un préfixe en cache fait la différence entre une réponse immédiate et plusieurs secondes de prefill.

*Effort : faible.* *Résultat : moyen*, surtout comme outil de diagnostic.

[[p11]]
=== Appels d'outils écrits en texte

*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.

*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.

*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.

[[p12]]
=== Évaluation de bout en bout

*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.

*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.

*Effort : moyen à élevé.* *Résultat : élevé* — c'est ce qui permet de trancher toutes les autres pistes « à mesurer » et de comparer les modèles.

[[p13]]
=== Profils par modèle

*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`). Depuis le 28/09/2026, `config.samples/` en ajoute cinq, complètes, pour des modèles plus gros : Gemma 4 26B-A4B, gpt-oss 20B, Qwen3.6 35B-A3B (DMR), Ornith 1.5 35B-A3B et Qwen3.8 27B (llama-server). Toutes fixent `max_tokens: 32768`, `watchdogTimeout: 300s` et `summaryMaxTokens: 1200`. Ce sont des profils de fait, mais recopiés à la main : chaque changement de la configuration de base est à reporter dans chacun des fichiers (`fetchContentTool: true` y a été ajouté à la main le 30/09/2026).

*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.

*Effort : faible.* *Résultat : moyen*, à mesurer avec la piste 12.

[[p14]]
=== Température adaptative après une boucle

*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.

*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.

*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.

[[p15]]
=== Réponses coupées par `max_tokens`

*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é.

*Proposition.*

* 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.
* *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`.
* Au démarrage, avertir quand `max_tokens` est bas pour un modèle à raisonnement (le raisonnement consomme le même budget).

*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>>.

== Références dans le code

[cols="1,2",options="header"]
|===
| Sujet | Fichiers

| Contrat des outils, `bash`, troncature | `internal/tools/tools.go`
| Outils fichiers | `internal/tools/files.go`, `internal/fileedit/`
| Détecteur de boucle | `internal/detector/detector.go`
| Arguments qui s'emballent | `internal/engine/runaway.go`
| Watchdog, relance, historique complet | `internal/engine/engine.go`
| Raisonnement | `internal/engine/reasoning.go`, `internal/engine/thoughts.go`
| Usage en streaming | `internal/engine/usage.go`
| Appels d'outils coupés ou mal formés | `internal/engine/toolargs.go`
| Erreurs serveur en cours de flux | `internal/engine/servererr.go`
| Watchdog et progression de la lecture du prompt | `internal/engine/progress.go`
| Compression | `internal/compact/`
| Skills et catalogue | `internal/skills/`, `skills/`
| Évaluation du routage | `internal/eval/`
| Page web en Markdown (`fetch_content`) | `internal/tools/fetchcontent.go`, `internal/webmd/`
| Sessions (fournisseur et modèle enregistrés) | `internal/store/store.go`
| Configurations par modèle | `config.samples/`, `tests/`
| Explications d'erreurs | `internal/engine/explain.go`, `internal/engine/openai_compat.go`
| Ordre stable des outils (cache du serveur) | `internal/engine/tooldefs.go`, `tests/tool-order/`
|===