🛟 Updated.
82c52a1 parent: 460cf23 modified
docker-sbx/template/Dockerfile +10 -3 | @@ -128,9 +128,16 @@ ARG GOPLS_VERSION=v0.23.0 | ||
| 128 | 128 | ARG X_TOOLS_VERSION=v0.49.0 |
| 129 | 129 | ARG STATICCHECK_VERSION=v0.7.0 |
| 130 | 130 | |
| 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 | |
| 134 | 141 | |
| 135 | 142 | # Prove the binary runs on this platform before the image is tagged. |
| 136 | 143 | RUN mm --version && mm-tui --version |
| @@ -128,9 +128,16 @@ ARG GOPLS_VERSION=v0.23.0 | |||
| 128 | ARG X_TOOLS_VERSION=v0.49.0 | 128 | ARG X_TOOLS_VERSION=v0.49.0 |
| 129 | ARG STATICCHECK_VERSION=v0.7.0 | 129 | 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 --version | 143 | 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 | ||
| 83 | 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 | 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 | 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`. | | |
| 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. | | |
| 88 | 88 | | Failure | `internal error` with `error: <one-line explanation>`. | |
| 89 | 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 | 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 | 98 | | `tool_call_update` status `in_progress` | Permission granted, tool executing. | |
| 99 | 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 | 102 | ## Available commands |
| 102 | 103 | |
| @@ -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 commands | 102 | ## Available commands |
| 102 | 103 | ||
modified
docs/en/reference/configuration.md +1 -1 | @@ -21,7 +21,7 @@ | ||
| 21 | 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 | 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 | 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 | 25 | | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | |
| 26 | 26 | | `context` | map | see below | Automatic history compression. | |
| 27 | 27 | | `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 | ||
| 83 | 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 | 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 | 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`. | | |
| 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. | | |
| 88 | 88 | | Échec | `internal error` avec `error: <explication en une ligne>`. | |
| 89 | 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 | 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 | 98 | | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | |
| 99 | 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 | 102 | ## Commandes disponibles |
| 102 | 103 | |
| @@ -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 disponibles | 102 | ## Commandes disponibles |
| 102 | 103 | ||
modified
docs/fr/reference/configuration.md +1 -1 | @@ -21,7 +21,7 @@ | ||
| 21 | 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 | 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 | 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 | 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 | 26 | | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. | |
| 27 | 27 | | `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 { | ||
| 59 | 59 | // allowAlways remembers the tools the user granted for the whole session |
| 60 | 60 | // ("allow always" in the permission dialog). |
| 61 | 61 | 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 | 67 | type front struct { |
| @@ -386,6 +389,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 386 | 389 | s.messages = append(s.messages, ai.NewUserTextMessage(input)) |
| 387 | 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 | 401 | resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools) |
| 390 | 402 | |
| 391 | 403 | // 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 | 414 | fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n", |
| 403 | 415 | len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn)) |
| 404 | 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 | 423 | switch { |
| 407 | 424 | case turnCtx.Err() != nil: |
| @@ -412,7 +429,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 412 | 429 | // "Internal error: stream error: context canceled" instead of honouring |
| 413 | 430 | // the spec's contract). Either way the answer is the same: respond to |
| 414 | 431 | // 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 | |
| 416 | 433 | case err != nil: |
| 417 | 434 | // One line, in the provider's words — the same text the REPL prints |
| 418 | 435 | // 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 | 438 | f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err)) |
| 422 | 439 | return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained}) |
| 423 | 440 | } |
| 424 | - return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil | |
| 441 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil | |
| 425 | 442 | } |
| 426 | 443 | |
| 427 | 444 | // 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 | 491 | f.archive(sid, s) |
| 475 | 492 | forgotten := resetSession(s, f.system) |
| 476 | 493 | f.e.ForgetInputTokens() |
| 494 | + s.usage.lastUsed = 0 // measured on the history just dropped; the totals stay, the tokens were spent | |
| 477 | 495 | f.persist(sid, s) |
| 478 | 496 | fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten) |
| 479 | 497 | _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{ |
| 480 | 498 | SessionId: sid, |
| 481 | 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 | 505 | // 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 | 515 | switch outcome { |
| 497 | 516 | case compact.Compressed: |
| 498 | 517 | f.e.ForgetInputTokens() // the server's measure described the history just replaced |
| 518 | + s.usage.lastUsed = 0 | |
| 499 | 519 | f.persist(sid, s) |
| 500 | 520 | case compact.Failed: |
| 501 | 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 | 525 | SessionId: sid, |
| 506 | 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 | 532 | // 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 session | 59 | // 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]bool | 61 | 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 even | 403 | // 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 honouring | 429 | // "Internal error: stream error: context canceled" instead of honouring |
| 413 | // the spec's contract). Either way the answer is the same: respond to | 430 | // 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}, nil | 432 | + 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 prints | 434 | // One line, in the provider's words — the same text the REPL prints |
| 418 | // between brackets, delivered as a JSON-RPC error — and recorded in | 435 | // 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}, nil | 441 | + 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 end | 444 | // 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 with | 505 | // 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 replaced | 517 | 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 is | 532 | // 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 { | ||
| 131 | 131 | if sentence, ok := stuckTurn(err); ok { |
| 132 | 132 | 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 | 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 | 247 | // `New` is called once per `Generate`, which keeps `full` captured at the |
| 242 | 248 | // right level. |
| 243 | 249 | 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 | 253 | var gaps gap |
| 245 | 254 | // think is the turn's thought block: see reasoning.go and thoughts below. |
| 246 | 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 | 259 | WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { |
| 251 | 260 | full = slices.Clone(params.Request.Messages) |
| 252 | 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 | 287 | // The server's own count of what it just read: the one exact |
| 254 | 288 | // measure of the context size, kept for the compression |
| 255 | 289 | // trigger. Zero means the server reported nothing. |
| 256 | 290 | if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 { |
| 257 | 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 | 306 | return resp, err |
| 260 | 307 | }, |
| 261 | 308 | // 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 | 440 | // turn has happened, which is enough to tell the two apart. |
| 394 | 441 | // The loop detector's stop is never retried either: replaying the turn |
| 395 | 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 | 448 | spinner.Start("Retrying without streaming") |
| 398 | 449 | if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil { |
| 399 | 450 | 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 sentence | 132 | 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 the | 247 | // `New` is called once per `Generate`, which keeps `full` captured at the |
| 242 | // right level. | 248 | // right level. |
| 243 | var full []*ai.Message | 249 | 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 gap | 253 | 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 exact | 287 | // The server's own count of what it just read: the one exact |
| 254 | // measure of the context size, kept for the compression | 288 | // 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, err | 306 | return resp, err |
| 260 | }, | 307 | }, |
| 261 | // The tool is about to write to the screen: we forget the | 308 | // 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 turn | 441 | // 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 | ||
| 130 | 130 | Provider: p.name, // model prefix: "<provider>/<id>" |
| 131 | 131 | BaseURL: b.BaseURL, |
| 132 | 132 | 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 | |
| 134 | 134 | // 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)}, | |
| 137 | 138 | } |
| 138 | 139 | g := genkit.Init(ctx, genkit.WithPlugins(client)) |
| 139 | 140 | 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 middleware | 133 | + // 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 off | 134 | // 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 { | ||
| 82 | 82 | 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 | 101 | // The active sink is set once per turn by the ACP façade and read by the tools |
| 86 | 102 | // from Genkit's goroutines — hence the lock, not a plain variable. |
| 87 | 103 | var ( |
| @@ -82,6 +82,22 @@ type Sink interface { | |||
| 82 | WorkDir() string | 82 | 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 tools | 101 | // 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 ( |