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

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

Download ZIP

Improve memory usage #16

Merged@k33g wants to merge improve-mem-usage into main
Merged as 21d4231d05e0.
16 files changed
modified docker-sbx/template/Dockerfile +10 -3
@@ -128,9 +128,16 @@ ARG GOPLS_VERSION=v0.23.0
128128 ARG X_TOOLS_VERSION=v0.49.0
129129 ARG STATICCHECK_VERSION=v0.7.0
130130
131-RUN go install golang.org/x/tools/gopls@${GOPLS_VERSION} \
132- && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \
133- && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}
131+# Retried: parallel multi-arch builds occasionally hit DNS timeouts on
132+# Docker Desktop's resolver. The module cache survives within this RUN,
133+# so a retry only fetches what's still missing.
134+RUN for i in 1 2 3 4 5; do \
135+ go install golang.org/x/tools/gopls@${GOPLS_VERSION} \
136+ && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \
137+ && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION} \
138+ && exit 0; \
139+ echo "go install failed (attempt $i), retrying..." >&2; sleep $((i * 5)); \
140+ done; exit 1
134141
135142 # Prove the binary runs on this platform before the image is tagged.
136143 RUN mm --version && mm-tui --version
@@ -128,9 +128,16 @@ ARG GOPLS_VERSION=v0.23.0
128 ARG X_TOOLS_VERSION=v0.49.0128 ARG X_TOOLS_VERSION=v0.49.0
129 ARG STATICCHECK_VERSION=v0.7.0129 ARG STATICCHECK_VERSION=v0.7.0
130 130
131-RUN go install golang.org/x/tools/gopls@${GOPLS_VERSION} \131+# Retried: parallel multi-arch builds occasionally hit DNS timeouts on
132- && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \132+# Docker Desktop's resolver. The module cache survives within this RUN,
133- && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION}133+# so a retry only fetches what's still missing.
134+RUN for i in 1 2 3 4 5; do \
135+ go install golang.org/x/tools/gopls@${GOPLS_VERSION} \
136+ && go install golang.org/x/tools/cmd/goimports@${X_TOOLS_VERSION} \
137+ && go install honnef.co/go/tools/cmd/staticcheck@${STATICCHECK_VERSION} \
138+ && exit 0; \
139+ echo "go install failed (attempt $i), retrying..." >&2; sleep $((i * 5)); \
140+ done; exit 1
134 141
135 # Prove the binary runs on this platform before the image is tagged.142 # Prove the binary runs on this platform before the image is tagged.
136 RUN mm --version && mm-tui --version143 RUN mm --version && mm-tui --version
modified docs/en/reference/acp.md +3 -2
@@ -83,8 +83,8 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
8383 | `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
8484 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
8585 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
86-| Success | `stopReason: end_turn`. |
87-| Cancelled | `stopReason: cancelled`. |
86+| Success | `stopReason: end_turn`, plus `usage` (the session's **totals**: `inputTokens`, `outputTokens`, `totalTokens`, and `thoughtTokens` / `cachedReadTokens` when the server reported them) once the server has reported any count; absent otherwise. Also on `/new` and `/compact`. |
87+| Cancelled | `stopReason: cancelled`, with `usage` as above. |
8888 | Failure | `internal error` with `error: <one-line explanation>`. |
8989 | Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |
9090
@@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
9797 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
9898 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
9999 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
100+| `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. |
100101
101102 ## Available commands
102103
@@ -83,8 +83,8 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
83 | `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |83 | `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
84 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |84 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
85 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |85 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
86-| Success | `stopReason: end_turn`. |86+| Success | `stopReason: end_turn`, plus `usage` (the session's **totals**: `inputTokens`, `outputTokens`, `totalTokens`, and `thoughtTokens` / `cachedReadTokens` when the server reported them) once the server has reported any count; absent otherwise. Also on `/new` and `/compact`. |
87-| Cancelled | `stopReason: cancelled`. |87+| Cancelled | `stopReason: cancelled`, with `usage` as above. |
88 | Failure | `internal error` with `error: <one-line explanation>`. |88 | Failure | `internal error` with `error: <one-line explanation>`. |
89 | Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |89 | Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. |
90 90
@@ -97,6 +97,7 @@ No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N
97 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |97 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
98 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |98 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
99 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |99 | `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. |
100+| `usage_update` (UNSTABLE in ACP) | After every model call that reported usage (`used` = its input + output tokens), and at the end of every turn, `/new` and `/compact` (`used` = the larger of the last measure and the history's estimate). `size` is the context window the agent knows (`contextWindow` or the probe), `0` when unknown. No `cost`: the servers are local. |
100 101
101 ## Available commands102 ## Available commands
102 103
modified docs/en/reference/configuration.md +1 -1
@@ -21,7 +21,7 @@
2121 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
2222 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |
2323 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
24-| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |
24+| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
2525 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
2626 | `context` | map | see below | Automatic history compression. |
2727 | `sessions` | map | see below | Persistent, per-project sessions. |
@@ -21,7 +21,7 @@
21 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |21 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
22 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |22 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |
23 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |23 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
24-| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |24+| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
25 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |25 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
26 | `context` | map | see below | Automatic history compression. |26 | `context` | map | see below | Automatic history compression. |
27 | `sessions` | map | see below | Persistent, per-project sessions. |27 | `sessions` | map | see below | Persistent, per-project sessions. |
added docs/fr/explanation/petits-llms.adoc +447 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified docs/fr/reference/acp.md +3 -2
@@ -83,8 +83,8 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
8383 | `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
8484 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
8585 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
86-| Succès | `stopReason: end_turn`. |
87-| Annulé | `stopReason: cancelled`. |
86+| Succès | `stopReason: end_turn`, plus `usage` (les **totaux** de la session : `inputTokens`, `outputTokens`, `totalTokens`, et `thoughtTokens` / `cachedReadTokens` quand le serveur les a donnés) dès que le serveur a rapporté un compte ; absent sinon. Aussi pour `/new` et `/compact`. |
87+| Annulé | `stopReason: cancelled`, avec `usage` comme ci-dessus. |
8888 | Échec | `internal error` avec `error: <explication en une ligne>`. |
8989 | Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |
9090
@@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
9797 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
9898 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
9999 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
100+| `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. |
100101
101102 ## Commandes disponibles
102103
@@ -83,8 +83,8 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
83 | `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |83 | `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
84 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |84 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
85 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |85 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
86-| Succès | `stopReason: end_turn`. |86+| Succès | `stopReason: end_turn`, plus `usage` (les **totaux** de la session : `inputTokens`, `outputTokens`, `totalTokens`, et `thoughtTokens` / `cachedReadTokens` quand le serveur les a donnés) dès que le serveur a rapporté un compte ; absent sinon. Aussi pour `/new` et `/compact`. |
87-| Annulé | `stopReason: cancelled`. |87+| Annulé | `stopReason: cancelled`, avec `usage` comme ci-dessus. |
88 | Échec | `internal error` avec `error: <explication en une ligne>`. |88 | Échec | `internal error` avec `error: <explication en une ligne>`. |
89 | Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |89 | Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. |
90 90
@@ -97,6 +97,7 @@ Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id
97 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |97 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
98 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |98 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
99 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |99 | `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. |
100+| `usage_update` (UNSTABLE dans ACP) | Après chaque appel au modèle qui a rapporté un usage (`used` = ses tokens d'entrée + de sortie), et à la fin de chaque tour, de `/new` et de `/compact` (`used` = le plus grand de la dernière mesure et de l'estimation de l'historique). `size` est la fenêtre de contexte connue de l'agent (`contextWindow` ou la sonde), `0` si inconnue. Pas de `cost` : les serveurs sont locaux. |
100 101
101 ## Commandes disponibles102 ## Commandes disponibles
102 103
modified docs/fr/reference/configuration.md +1 -1
@@ -21,7 +21,7 @@
2121 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
2222 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |
2323 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
24-| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |
24+| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
2525 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
2626 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
2727 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
@@ -21,7 +21,7 @@
21 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |21 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
22 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |22 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |
23 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |23 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
24-| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |24+| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
25 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |25 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
26 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |26 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
27 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |27 | `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
modified internal/acp/acp.go +25 -4
@@ -59,6 +59,9 @@ type session struct {
5959 // allowAlways remembers the tools the user granted for the whole session
6060 // ("allow always" in the permission dialog).
6161 allowAlways map[string]bool
62+ // usage is the token accounting: totals for the session/prompt response,
63+ // the last measure for the context meter (usage.go).
64+ usage usage
6265 }
6366
6467 type front struct {
@@ -386,6 +389,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
386389 s.messages = append(s.messages, ai.NewUserTextMessage(input))
387390 before := len(s.messages)
388391
392+ // The meter's `size`. The start-up probe runs once, and a server started
393+ // after the agent left it unknown for good; asking again while it is
394+ // unknown is the REPL's rule (agent.go), and costs nothing once known.
395+ if f.e.ContextWindow == 0 {
396+ if w, src := f.e.EnsureContextWindow(turnCtx); w > 0 {
397+ fmt.Fprintf(os.Stderr, "[acp] ctx: %d (%s)\n", w, src)
398+ }
399+ }
400+
389401 resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)
390402
391403 // Same rule as the REPL, for the same reason: keep the FULL history even
@@ -402,6 +414,11 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
402414 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",
403415 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))
404416 f.persist(p.SessionId, s)
417+ // The meter once more, on the history as the turn left it: the last model
418+ // call's measure predates the answer, and a server that reports nothing
419+ // sent no update at all. The request's context, not the turn's: a
420+ // cancelled turn still changed the history.
421+ f.sendUsage(context.WithoutCancel(ctx), p.SessionId, contextUsed(s))
405422
406423 switch {
407424 case turnCtx.Err() != nil:
@@ -412,7 +429,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
412429 // "Internal error: stream error: context canceled" instead of honouring
413430 // the spec's contract). Either way the answer is the same: respond to
414431 // session/prompt anyway, with stopReason "cancelled".
415- return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil
432+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil
416433 case err != nil:
417434 // One line, in the provider's words — the same text the REPL prints
418435 // between brackets, delivered as a JSON-RPC error — and recorded in
@@ -421,7 +438,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
421438 f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))
422439 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})
423440 }
424- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
441+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
425442 }
426443
427444 // slashCommand runs input when it is one of the commands this front end
@@ -474,13 +491,15 @@ func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *sessi
474491 f.archive(sid, s)
475492 forgotten := resetSession(s, f.system)
476493 f.e.ForgetInputTokens()
494+ s.usage.lastUsed = 0 // measured on the history just dropped; the totals stay, the tokens were spent
477495 f.persist(sid, s)
478496 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
479497 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
480498 SessionId: sid,
481499 Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),
482500 })
483- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
501+ f.sendUsage(ctx, sid, contextUsed(s))
502+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
484503 }
485504
486505 // compactSession is the ACP side of /compact: the REPL's compactHistory with
@@ -496,6 +515,7 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
496515 switch outcome {
497516 case compact.Compressed:
498517 f.e.ForgetInputTokens() // the server's measure described the history just replaced
518+ s.usage.lastUsed = 0
499519 f.persist(sid, s)
500520 case compact.Failed:
501521 f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))
@@ -505,7 +525,8 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
505525 SessionId: sid,
506526 Update: sdk.UpdateAgentMessageText(line),
507527 })
508- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}
528+ f.sendUsage(ctx, sid, contextUsed(s))
529+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
509530 }
510531
511532 // compactHistory compresses the session's history in place when there is
@@ -59,6 +59,9 @@ type session struct {
59 // allowAlways remembers the tools the user granted for the whole session59 // allowAlways remembers the tools the user granted for the whole session
60 // ("allow always" in the permission dialog).60 // ("allow always" in the permission dialog).
61 allowAlways map[string]bool61 allowAlways map[string]bool
62+ // usage is the token accounting: totals for the session/prompt response,
63+ // the last measure for the context meter (usage.go).
64+ usage usage
62 }65 }
63 66
64 type front struct {67 type front struct {
@@ -386,6 +389,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
386 s.messages = append(s.messages, ai.NewUserTextMessage(input))389 s.messages = append(s.messages, ai.NewUserTextMessage(input))
387 before := len(s.messages)390 before := len(s.messages)
388 391
392+ // The meter's `size`. The start-up probe runs once, and a server started
393+ // after the agent left it unknown for good; asking again while it is
394+ // unknown is the REPL's rule (agent.go), and costs nothing once known.
395+ if f.e.ContextWindow == 0 {
396+ if w, src := f.e.EnsureContextWindow(turnCtx); w > 0 {
397+ fmt.Fprintf(os.Stderr, "[acp] ctx: %d (%s)\n", w, src)
398+ }
399+ }
400+
389 resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)401 resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)
390 402
391 // Same rule as the REPL, for the same reason: keep the FULL history even403 // Same rule as the REPL, for the same reason: keep the FULL history even
@@ -402,6 +414,11 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
402 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",414 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s) · %d lsp call(s)\n",
403 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))415 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn), engine.LSPCalls(turn))
404 f.persist(p.SessionId, s)416 f.persist(p.SessionId, s)
417+ // The meter once more, on the history as the turn left it: the last model
418+ // call's measure predates the answer, and a server that reports nothing
419+ // sent no update at all. The request's context, not the turn's: a
420+ // cancelled turn still changed the history.
421+ f.sendUsage(context.WithoutCancel(ctx), p.SessionId, contextUsed(s))
405 422
406 switch {423 switch {
407 case turnCtx.Err() != nil:424 case turnCtx.Err() != nil:
@@ -412,7 +429,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
412 // "Internal error: stream error: context canceled" instead of honouring429 // "Internal error: stream error: context canceled" instead of honouring
413 // the spec's contract). Either way the answer is the same: respond to430 // the spec's contract). Either way the answer is the same: respond to
414 // session/prompt anyway, with stopReason "cancelled".431 // session/prompt anyway, with stopReason "cancelled".
415- return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil432+ return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil
416 case err != nil:433 case err != nil:
417 // One line, in the provider's words — the same text the REPL prints434 // One line, in the provider's words — the same text the REPL prints
418 // between brackets, delivered as a JSON-RPC error — and recorded in435 // between brackets, delivered as a JSON-RPC error — and recorded in
@@ -421,7 +438,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
421 f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))438 f.fail(p.SessionId, s, store.NewFailure(asked, store.FailGeneration, explained, err))
422 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})439 return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": explained})
423 }440 }
424- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil441+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil
425 }442 }
426 443
427 // slashCommand runs input when it is one of the commands this front end444 // slashCommand runs input when it is one of the commands this front end
@@ -474,13 +491,15 @@ func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *sessi
474 f.archive(sid, s)491 f.archive(sid, s)
475 forgotten := resetSession(s, f.system)492 forgotten := resetSession(s, f.system)
476 f.e.ForgetInputTokens()493 f.e.ForgetInputTokens()
494+ s.usage.lastUsed = 0 // measured on the history just dropped; the totals stay, the tokens were spent
477 f.persist(sid, s)495 f.persist(sid, s)
478 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)496 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
479 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{497 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
480 SessionId: sid,498 SessionId: sid,
481 Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),499 Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)),
482 })500 })
483- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}501+ f.sendUsage(ctx, sid, contextUsed(s))
502+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
484 }503 }
485 504
486 // compactSession is the ACP side of /compact: the REPL's compactHistory with505 // compactSession is the ACP side of /compact: the REPL's compactHistory with
@@ -496,6 +515,7 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
496 switch outcome {515 switch outcome {
497 case compact.Compressed:516 case compact.Compressed:
498 f.e.ForgetInputTokens() // the server's measure described the history just replaced517 f.e.ForgetInputTokens() // the server's measure described the history just replaced
518+ s.usage.lastUsed = 0
499 f.persist(sid, s)519 f.persist(sid, s)
500 case compact.Failed:520 case compact.Failed:
501 f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))521 f.fail(sid, s, store.NewFailure(s.messages, store.FailCompaction, line, nil))
@@ -505,7 +525,8 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
505 SessionId: sid,525 SessionId: sid,
506 Update: sdk.UpdateAgentMessageText(line),526 Update: sdk.UpdateAgentMessageText(line),
507 })527 })
508- return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}528+ f.sendUsage(ctx, sid, contextUsed(s))
529+ return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}
509 }530 }
510 531
511 // compactHistory compresses the session's history in place when there is532 // compactHistory compresses the session's history in place when there is
added internal/acp/usage.go +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+package acp
2+
3+import (
4+ "context"
5+
6+ "mm/internal/compact"
7+ "mm/internal/ui"
8+
9+ sdk "github.com/coder/acp-go-sdk"
10+)
11+
12+// Token accounting, the two shapes ACP gives it (both UNSTABLE in the spec,
13+// and gated by no capability — an agent may send them unasked):
14+//
15+// - usage_update, a session/update: how full the context is now (`used`
16+// out of `size`). Sent after every model call, and once more at the end
17+// of a turn, a /new or a /compact, so the client's meter follows the
18+// history even when the server reports nothing.
19+// - `usage` in the session/prompt response: the session's TOTALS so far,
20+// the spec's definition — a client computes a turn's cost as the
21+// difference with the previous response.
22+//
23+// The counts come from the server (engine → ui.UsageSink, one report per
24+// model call). A server that reports nothing leaves the totals at zero, and
25+// the response then carries no `usage` at all rather than a row of zeros that
26+// would read as "this turn was free".
27+
28+// usage is one session's running totals. Guarded by turnMu like the rest of
29+// the session's turn state: only the turn in flight touches it.
30+type usage struct {
31+ input, output, thought, cachedRead int
32+ // lastUsed is the context the last model call filled — what it read plus
33+ // what it wrote, which the next call will read back. 0 = not measured
34+ // since the history last changed shape (new session, compression).
35+ lastUsed int
36+}
37+
38+func (u *usage) add(r ui.Usage) {
39+ u.input += r.InputTokens
40+ u.output += r.OutputTokens
41+ u.thought += r.ThoughtTokens
42+ u.cachedRead += r.CachedReadTokens
43+ if r.InputTokens > 0 {
44+ u.lastUsed = r.InputTokens + r.OutputTokens
45+ }
46+}
47+
48+// totals is the `usage` of a session/prompt response, nil while the server
49+// has reported nothing.
50+func (u *usage) totals() *sdk.Usage {
51+ if u.input == 0 && u.output == 0 {
52+ return nil
53+ }
54+ out := &sdk.Usage{
55+ InputTokens: u.input,
56+ OutputTokens: u.output,
57+ TotalTokens: u.input + u.output,
58+ }
59+ if u.thought > 0 {
60+ out.ThoughtTokens = sdk.Ptr(u.thought)
61+ }
62+ if u.cachedRead > 0 {
63+ out.CachedReadTokens = sdk.Ptr(u.cachedRead)
64+ }
65+ return out
66+}
67+
68+// Usage is the sink's side: one model call's counts. Accumulated, then the
69+// meter is refreshed with what that call measured.
70+func (k *sink) Usage(r ui.Usage) {
71+ k.s.usage.add(r)
72+ if r.InputTokens > 0 {
73+ k.f.sendUsage(k.ctx, k.sid, r.InputTokens+r.OutputTokens)
74+ }
75+}
76+
77+var _ ui.UsageSink = (*sink)(nil)
78+
79+// contextUsed is how full the session's context is: the server's measure of
80+// the last call when there is one, the character estimate of the history
81+// when it is larger — the same rule as the terminal UI's header, and for the
82+// same reason: the estimate cannot see the tools' JSON or the chat template,
83+// but it does see a history that changed since the last call.
84+func contextUsed(s *session) int {
85+ return max(s.usage.lastUsed, compact.Estimate(s.messages))
86+}
87+
88+// sendUsage pushes a usage_update. `size` is the window the engine knows —
89+// the yaml's contextWindow or what the probe learned — and 0 when neither
90+// says: the spec has no "unknown", and a client can tell 0 is not a window.
91+func (f *front) sendUsage(ctx context.Context, sid sdk.SessionId, used int) {
92+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
93+ SessionId: sid,
94+ Update: sdk.SessionUpdate{UsageUpdate: &sdk.SessionUsageUpdate{
95+ Used: used,
96+ Size: f.e.ContextWindow,
97+ }},
98+ })
99+}
new file mode 100644
@@ -0,0 +1,99 @@
1+package acp
2+
3+import (
4+ "context"
5+
6+ "mm/internal/compact"
7+ "mm/internal/ui"
8+
9+ sdk "github.com/coder/acp-go-sdk"
10+)
11+
12+// Token accounting, the two shapes ACP gives it (both UNSTABLE in the spec,
13+// and gated by no capability — an agent may send them unasked):
14+//
15+// - usage_update, a session/update: how full the context is now (`used`
16+// out of `size`). Sent after every model call, and once more at the end
17+// of a turn, a /new or a /compact, so the client's meter follows the
18+// history even when the server reports nothing.
19+// - `usage` in the session/prompt response: the session's TOTALS so far,
20+// the spec's definition — a client computes a turn's cost as the
21+// difference with the previous response.
22+//
23+// The counts come from the server (engine → ui.UsageSink, one report per
24+// model call). A server that reports nothing leaves the totals at zero, and
25+// the response then carries no `usage` at all rather than a row of zeros that
26+// would read as "this turn was free".
27+
28+// usage is one session's running totals. Guarded by turnMu like the rest of
29+// the session's turn state: only the turn in flight touches it.
30+type usage struct {
31+ input, output, thought, cachedRead int
32+ // lastUsed is the context the last model call filled — what it read plus
33+ // what it wrote, which the next call will read back. 0 = not measured
34+ // since the history last changed shape (new session, compression).
35+ lastUsed int
36+}
37+
38+func (u *usage) add(r ui.Usage) {
39+ u.input += r.InputTokens
40+ u.output += r.OutputTokens
41+ u.thought += r.ThoughtTokens
42+ u.cachedRead += r.CachedReadTokens
43+ if r.InputTokens > 0 {
44+ u.lastUsed = r.InputTokens + r.OutputTokens
45+ }
46+}
47+
48+// totals is the `usage` of a session/prompt response, nil while the server
49+// has reported nothing.
50+func (u *usage) totals() *sdk.Usage {
51+ if u.input == 0 && u.output == 0 {
52+ return nil
53+ }
54+ out := &sdk.Usage{
55+ InputTokens: u.input,
56+ OutputTokens: u.output,
57+ TotalTokens: u.input + u.output,
58+ }
59+ if u.thought > 0 {
60+ out.ThoughtTokens = sdk.Ptr(u.thought)
61+ }
62+ if u.cachedRead > 0 {
63+ out.CachedReadTokens = sdk.Ptr(u.cachedRead)
64+ }
65+ return out
66+}
67+
68+// Usage is the sink's side: one model call's counts. Accumulated, then the
69+// meter is refreshed with what that call measured.
70+func (k *sink) Usage(r ui.Usage) {
71+ k.s.usage.add(r)
72+ if r.InputTokens > 0 {
73+ k.f.sendUsage(k.ctx, k.sid, r.InputTokens+r.OutputTokens)
74+ }
75+}
76+
77+var _ ui.UsageSink = (*sink)(nil)
78+
79+// contextUsed is how full the session's context is: the server's measure of
80+// the last call when there is one, the character estimate of the history
81+// when it is larger — the same rule as the terminal UI's header, and for the
82+// same reason: the estimate cannot see the tools' JSON or the chat template,
83+// but it does see a history that changed since the last call.
84+func contextUsed(s *session) int {
85+ return max(s.usage.lastUsed, compact.Estimate(s.messages))
86+}
87+
88+// sendUsage pushes a usage_update. `size` is the window the engine knows —
89+// the yaml's contextWindow or what the probe learned — and 0 when neither
90+// says: the spec has no "unknown", and a client can tell 0 is not a window.
91+func (f *front) sendUsage(ctx context.Context, sid sdk.SessionId, used int) {
92+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
93+ SessionId: sid,
94+ Update: sdk.SessionUpdate{UsageUpdate: &sdk.SessionUsageUpdate{
95+ Used: used,
96+ Size: f.e.ContextWindow,
97+ }},
98+ })
99+}
added internal/acp/usage_test.go +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+package acp
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/ui"
7+)
8+
9+// The response carries session TOTALS (the spec's definition), the meter the
10+// context the last call filled; a server that reported nothing leaves no
11+// `usage` at all rather than zeros.
12+func TestUsageTotals(t *testing.T) {
13+ var u usage
14+ if u.totals() != nil {
15+ t.Fatal("no report yet, but totals are not nil")
16+ }
17+ u.add(ui.Usage{InputTokens: 1000, OutputTokens: 50})
18+ u.add(ui.Usage{InputTokens: 1200, OutputTokens: 80, ThoughtTokens: 30, CachedReadTokens: 900})
19+
20+ got := u.totals()
21+ if got.InputTokens != 2200 || got.OutputTokens != 130 || got.TotalTokens != 2330 {
22+ t.Errorf("totals = %+v, want 2200 in / 130 out / 2330 total", got)
23+ }
24+ if got.ThoughtTokens == nil || *got.ThoughtTokens != 30 {
25+ t.Errorf("thoughtTokens = %v, want 30", got.ThoughtTokens)
26+ }
27+ if got.CachedReadTokens == nil || *got.CachedReadTokens != 900 {
28+ t.Errorf("cachedReadTokens = %v, want 900", got.CachedReadTokens)
29+ }
30+ if u.lastUsed != 1280 {
31+ t.Errorf("lastUsed = %d, want 1280 (last call's input + output)", u.lastUsed)
32+ }
33+
34+ // A call that measured nothing does not erase the last measure.
35+ u.add(ui.Usage{})
36+ if u.lastUsed != 1280 {
37+ t.Errorf("lastUsed after an empty report = %d, want 1280", u.lastUsed)
38+ }
39+}
40+
41+// Optional fields stay absent when the server never gave them.
42+func TestUsageTotalsOmitsUnreported(t *testing.T) {
43+ u := usage{input: 10, output: 2}
44+ got := u.totals()
45+ if got.ThoughtTokens != nil || got.CachedReadTokens != nil {
46+ t.Errorf("totals = %+v, want no thought/cached fields", got)
47+ }
48+}
new file mode 100644
@@ -0,0 +1,48 @@
1+package acp
2+
3+import (
4+ "testing"
5+
6+ "mm/internal/ui"
7+)
8+
9+// The response carries session TOTALS (the spec's definition), the meter the
10+// context the last call filled; a server that reported nothing leaves no
11+// `usage` at all rather than zeros.
12+func TestUsageTotals(t *testing.T) {
13+ var u usage
14+ if u.totals() != nil {
15+ t.Fatal("no report yet, but totals are not nil")
16+ }
17+ u.add(ui.Usage{InputTokens: 1000, OutputTokens: 50})
18+ u.add(ui.Usage{InputTokens: 1200, OutputTokens: 80, ThoughtTokens: 30, CachedReadTokens: 900})
19+
20+ got := u.totals()
21+ if got.InputTokens != 2200 || got.OutputTokens != 130 || got.TotalTokens != 2330 {
22+ t.Errorf("totals = %+v, want 2200 in / 130 out / 2330 total", got)
23+ }
24+ if got.ThoughtTokens == nil || *got.ThoughtTokens != 30 {
25+ t.Errorf("thoughtTokens = %v, want 30", got.ThoughtTokens)
26+ }
27+ if got.CachedReadTokens == nil || *got.CachedReadTokens != 900 {
28+ t.Errorf("cachedReadTokens = %v, want 900", got.CachedReadTokens)
29+ }
30+ if u.lastUsed != 1280 {
31+ t.Errorf("lastUsed = %d, want 1280 (last call's input + output)", u.lastUsed)
32+ }
33+
34+ // A call that measured nothing does not erase the last measure.
35+ u.add(ui.Usage{})
36+ if u.lastUsed != 1280 {
37+ t.Errorf("lastUsed after an empty report = %d, want 1280", u.lastUsed)
38+ }
39+}
40+
41+// Optional fields stay absent when the server never gave them.
42+func TestUsageTotalsOmitsUnreported(t *testing.T) {
43+ u := usage{input: 10, output: 2}
44+ got := u.totals()
45+ if got.ThoughtTokens != nil || got.CachedReadTokens != nil {
46+ t.Errorf("totals = %+v, want no thought/cached fields", got)
47+ }
48+}
modified internal/engine/engine.go +52 -1
@@ -131,6 +131,12 @@ func (e *Engine) Explain(err error) string {
131131 if sentence, ok := stuckTurn(err); ok {
132132 return sentence
133133 }
134+ // A tool call with broken arguments: the raw error quotes them whole —
135+ // a truncated document, on the day it was seen — so it gets a line of
136+ // its own (toolargs.go).
137+ if line, ok := badToolArgs(err); ok {
138+ return line
139+ }
134140 return e.Provider.Explain(err, e.Backend)
135141 }
136142
@@ -241,6 +247,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
241247 // `New` is called once per `Generate`, which keeps `full` captured at the
242248 // right level.
243249 var full []*ai.Message
250+ // argRetries counts the broken tool calls handed back to the model in
251+ // this generation (toolargs.go).
252+ argRetries := 0
244253 var gaps gap
245254 // think is the turn's thought block: see reasoning.go and thoughts below.
246255 // Declared here because emit, defined further down, closes over it.
@@ -250,12 +259,50 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
250259 WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {
251260 full = slices.Clone(params.Request.Messages)
252261 resp, err := next(ctx, params)
262+ // A tool call whose arguments did not parse — cut off by
263+ // max_tokens, most of the time — is given back to the model
264+ // with a note instead of ending the turn. The note goes into
265+ // params.Request itself: Genkit builds the next turn's request
266+ // from that same value, so it stays in the conversation — the
267+ // model needs it again when it writes the second piece — and
268+ // `full` carries it into the history.
269+ for argRetries < maxArgRetries && ctx.Err() == nil {
270+ truncated, malformed := argsError(err)
271+ if !truncated && !malformed {
272+ break
273+ }
274+ argRetries++
275+ what := "invalid JSON"
276+ if truncated {
277+ what = "cut off"
278+ }
279+ // ui.Out: stderr in ACP mode, never the JSON-RPC stream.
280+ spinner.Stop()
281+ fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n",
282+ what, lastRunes(err.Error(), 60), argRetries, maxArgRetries)
283+ params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated))
284+ full = slices.Clone(params.Request.Messages)
285+ resp, err = next(ctx, params)
286+ }
253287 // The server's own count of what it just read: the one exact
254288 // measure of the context size, kept for the compression
255289 // trigger. Zero means the server reported nothing.
256290 if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {
257291 e.lastInput.Store(int64(resp.Usage.InputTokens))
258292 }
293+ // An event-consuming front end (ACP) wants the counts of
294+ // every call, not just the last input: the client shows the
295+ // context meter and the tokens a turn cost.
296+ if resp != nil && resp.Usage != nil {
297+ if s, ok := ui.ActiveSink().(ui.UsageSink); ok {
298+ s.Usage(ui.Usage{
299+ InputTokens: resp.Usage.InputTokens,
300+ OutputTokens: resp.Usage.OutputTokens,
301+ ThoughtTokens: resp.Usage.ThoughtsTokens,
302+ CachedReadTokens: resp.Usage.CachedContentTokens,
303+ })
304+ }
305+ }
259306 return resp, err
260307 },
261308 // The tool is about to write to the screen: we forget the
@@ -393,7 +440,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
393440 // turn has happened, which is enough to tell the two apart.
394441 // The loop detector's stop is never retried either: replaying the turn
395442 // would run the repeated tool once more before stopping again.
396- if _, stuck := stuckTurn(err); err != nil && !printed && !toolsRan && !stuck {
443+ // Nor is a tool call whose arguments did not parse: the hook above has
444+ // already handed it back, and the same limit would cut the same call.
445+ _, stuck := stuckTurn(err)
446+ truncated, malformed := argsError(err)
447+ if err != nil && !printed && !toolsRan && !stuck && !truncated && !malformed {
397448 spinner.Start("Retrying without streaming")
398449 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
399450 emit(resp.Text())
@@ -131,6 +131,12 @@ func (e *Engine) Explain(err error) string {
131 if sentence, ok := stuckTurn(err); ok {131 if sentence, ok := stuckTurn(err); ok {
132 return sentence132 return sentence
133 }133 }
134+ // A tool call with broken arguments: the raw error quotes them whole —
135+ // a truncated document, on the day it was seen — so it gets a line of
136+ // its own (toolargs.go).
137+ if line, ok := badToolArgs(err); ok {
138+ return line
139+ }
134 return e.Provider.Explain(err, e.Backend)140 return e.Provider.Explain(err, e.Backend)
135 }141 }
136 142
@@ -241,6 +247,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
241 // `New` is called once per `Generate`, which keeps `full` captured at the247 // `New` is called once per `Generate`, which keeps `full` captured at the
242 // right level.248 // right level.
243 var full []*ai.Message249 var full []*ai.Message
250+ // argRetries counts the broken tool calls handed back to the model in
251+ // this generation (toolargs.go).
252+ argRetries := 0
244 var gaps gap253 var gaps gap
245 // think is the turn's thought block: see reasoning.go and thoughts below.254 // think is the turn's thought block: see reasoning.go and thoughts below.
246 // Declared here because emit, defined further down, closes over it.255 // Declared here because emit, defined further down, closes over it.
@@ -250,12 +259,50 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
250 WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {259 WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) {
251 full = slices.Clone(params.Request.Messages)260 full = slices.Clone(params.Request.Messages)
252 resp, err := next(ctx, params)261 resp, err := next(ctx, params)
262+ // A tool call whose arguments did not parse — cut off by
263+ // max_tokens, most of the time — is given back to the model
264+ // with a note instead of ending the turn. The note goes into
265+ // params.Request itself: Genkit builds the next turn's request
266+ // from that same value, so it stays in the conversation — the
267+ // model needs it again when it writes the second piece — and
268+ // `full` carries it into the history.
269+ for argRetries < maxArgRetries && ctx.Err() == nil {
270+ truncated, malformed := argsError(err)
271+ if !truncated && !malformed {
272+ break
273+ }
274+ argRetries++
275+ what := "invalid JSON"
276+ if truncated {
277+ what = "cut off"
278+ }
279+ // ui.Out: stderr in ACP mode, never the JSON-RPC stream.
280+ spinner.Stop()
281+ fmt.Fprintf(ui.Out, "\n[tool call arguments did not parse (%s: …%s) — handing it back to the model, %d/%d]\n",
282+ what, lastRunes(err.Error(), 60), argRetries, maxArgRetries)
283+ params.Request.Messages = append(slices.Clone(params.Request.Messages), argsRetryNote(truncated))
284+ full = slices.Clone(params.Request.Messages)
285+ resp, err = next(ctx, params)
286+ }
253 // The server's own count of what it just read: the one exact287 // The server's own count of what it just read: the one exact
254 // measure of the context size, kept for the compression288 // measure of the context size, kept for the compression
255 // trigger. Zero means the server reported nothing.289 // trigger. Zero means the server reported nothing.
256 if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {290 if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 {
257 e.lastInput.Store(int64(resp.Usage.InputTokens))291 e.lastInput.Store(int64(resp.Usage.InputTokens))
258 }292 }
293+ // An event-consuming front end (ACP) wants the counts of
294+ // every call, not just the last input: the client shows the
295+ // context meter and the tokens a turn cost.
296+ if resp != nil && resp.Usage != nil {
297+ if s, ok := ui.ActiveSink().(ui.UsageSink); ok {
298+ s.Usage(ui.Usage{
299+ InputTokens: resp.Usage.InputTokens,
300+ OutputTokens: resp.Usage.OutputTokens,
301+ ThoughtTokens: resp.Usage.ThoughtsTokens,
302+ CachedReadTokens: resp.Usage.CachedContentTokens,
303+ })
304+ }
305+ }
259 return resp, err306 return resp, err
260 },307 },
261 // The tool is about to write to the screen: we forget the308 // The tool is about to write to the screen: we forget the
@@ -393,7 +440,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
393 // turn has happened, which is enough to tell the two apart.440 // turn has happened, which is enough to tell the two apart.
394 // The loop detector's stop is never retried either: replaying the turn441 // The loop detector's stop is never retried either: replaying the turn
395 // would run the repeated tool once more before stopping again.442 // would run the repeated tool once more before stopping again.
396- if _, stuck := stuckTurn(err); err != nil && !printed && !toolsRan && !stuck {443+ // Nor is a tool call whose arguments did not parse: the hook above has
444+ // already handed it back, and the same limit would cut the same call.
445+ _, stuck := stuckTurn(err)
446+ truncated, malformed := argsError(err)
447+ if err != nil && !printed && !toolsRan && !stuck && !truncated && !malformed {
397 spinner.Start("Retrying without streaming")448 spinner.Start("Retrying without streaming")
398 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {449 if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil {
399 emit(resp.Text())450 emit(resp.Text())
modified internal/engine/openai_compat.go +4 -3
@@ -130,10 +130,11 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str
130130 Provider: p.name, // model prefix: "<provider>/<id>"
131131 BaseURL: b.BaseURL,
132132 APIKey: b.APIKey,
133- // The one hook the plugin leaves open, and all we need: a middleware
133+ // The one hook the plugin leaves open, and all we need: middlewares
134134 // on the HTTP client, so a thinking model's reasoning can be read off
135- // the wire the plugin drops it from (reasoning.go).
136- Opts: []option.RequestOption{option.WithMiddleware(reasoningTap)},
135+ // the wire the plugin drops it from (reasoning.go), and a streamed
136+ // request asks for the token counts it would otherwise lack (usage.go).
137+ Opts: []option.RequestOption{option.WithMiddleware(streamUsage, reasoningTap)},
137138 }
138139 g := genkit.Init(ctx, genkit.WithPlugins(client))
139140 client.DefineModel(p.name, b.Model, ai.ModelOptions{
@@ -130,10 +130,11 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str
130 Provider: p.name, // model prefix: "<provider>/<id>"130 Provider: p.name, // model prefix: "<provider>/<id>"
131 BaseURL: b.BaseURL,131 BaseURL: b.BaseURL,
132 APIKey: b.APIKey,132 APIKey: b.APIKey,
133- // The one hook the plugin leaves open, and all we need: a middleware133+ // The one hook the plugin leaves open, and all we need: middlewares
134 // on the HTTP client, so a thinking model's reasoning can be read off134 // on the HTTP client, so a thinking model's reasoning can be read off
135- // the wire the plugin drops it from (reasoning.go).135+ // the wire the plugin drops it from (reasoning.go), and a streamed
136- Opts: []option.RequestOption{option.WithMiddleware(reasoningTap)},136+ // request asks for the token counts it would otherwise lack (usage.go).
137+ Opts: []option.RequestOption{option.WithMiddleware(streamUsage, reasoningTap)},
137 }138 }
138 g := genkit.Init(ctx, genkit.WithPlugins(client))139 g := genkit.Init(ctx, genkit.WithPlugins(client))
139 client.DefineModel(p.name, b.Model, ai.ModelOptions{140 client.DefineModel(p.name, b.Model, ai.ModelOptions{
added internal/engine/toolargs.go +111 -0
new file mode 100644
@@ -0,0 +1,111 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/config"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// A tool call whose arguments are not valid JSON never reaches the tool: the
13+// compat_oai plugin decodes them while converting the server's response
14+// (`could not parse tool args: …`), and that error ends the whole generation.
15+// The model never reads it, so it cannot do what every other failure lets it
16+// do — read why and try again.
17+//
18+// The common cause is not a model that cannot write JSON: it is max_tokens.
19+// Seen on 2026-09-26 with a Qwen3 27B asked to write a document: one
20+// write_file of ~12,500 characters (~4k tokens) against `max_tokens: 4096`,
21+// cut mid-sentence, "unexpected end of JSON input", turn lost — and the error
22+// line the user saw was the whole truncated document.
23+//
24+// So the call is given back to the model once or twice, with a note saying
25+// what happened and how to get out of it (smaller pieces), before the error is
26+// let through — and then with a one-line explanation (badToolArgs) instead of
27+// the raw JSON.
28+
29+// maxArgRetries is how many times one generation hands a broken call back.
30+// A model that truncates twice in a row on the same file will do it a third
31+// time; past that the user has to change something (max_tokens, the request).
32+const maxArgRetries = 2
33+
34+// argsError classifies an error from the model call: truncated (the JSON stops
35+// before its end — max_tokens, almost always), malformed (it is complete but
36+// not JSON), or neither.
37+func argsError(err error) (truncated, malformed bool) {
38+ if err == nil {
39+ return false, false
40+ }
41+ msg := err.Error()
42+ if !strings.Contains(msg, "could not parse tool args") {
43+ return false, false
44+ }
45+ if strings.Contains(msg, "unexpected end of JSON input") || strings.Contains(msg, "unexpected EOF") {
46+ return true, false
47+ }
48+ return false, true
49+}
50+
51+// maxTokens is the configured max_tokens, "" when the sampling sets none.
52+func maxTokens() string {
53+ if v, ok := config.Cfg.Sampling["max_tokens"]; ok && v != nil {
54+ return fmt.Sprint(v)
55+ }
56+ return ""
57+}
58+
59+// pieceChars is a size one piece can safely have: half the answer limit, at
60+// roughly three characters per token. 0 when the limit is unknown.
61+func pieceChars() int {
62+ var n int
63+ if _, err := fmt.Sscan(maxTokens(), &n); err != nil || n <= 0 {
64+ return 0
65+ }
66+ return n * 3 / 2
67+}
68+
69+// argsRetryNote is what the model reads in place of the call it could not
70+// finish: a user message, since there is no tool response to attach it to —
71+// the call never became one.
72+func argsRetryNote(truncated bool) *ai.Message {
73+ var b strings.Builder
74+ b.WriteString("[mm: your last tool call was not run: ")
75+ if truncated {
76+ b.WriteString("it was cut off before its end")
77+ if mt := maxTokens(); mt != "" {
78+ b.WriteString(" (the answer limit is " + mt + " tokens)")
79+ }
80+ b.WriteString(", so its arguments were incomplete JSON. ")
81+ // A number the model can act on: "smaller pieces" alone was measured
82+ // to change nothing — the 12B retried the whole file, twice.
83+ b.WriteString("Do NOT retry the same call. Split the work: call write_file now with ONLY the first part")
84+ if n := pieceChars(); n > 0 {
85+ fmt.Fprintf(&b, " (at most about %d characters)", n)
86+ }
87+ b.WriteString(", then add each following part with its own edit_file call " +
88+ "(old = the last line already written, new = that line followed by the next part).]")
89+ } else {
90+ b.WriteString("its arguments were not valid JSON. " +
91+ "Call the tool again with a single JSON object matching its parameters; escape quotes, backslashes and newlines inside strings.]")
92+ }
93+ return ai.NewUserTextMessage(b.String())
94+}
95+
96+// badToolArgs is Explain's line for the same error once the retries are
97+// spent: what happened and which knob to turn, never the JSON itself.
98+func badToolArgs(err error) (string, bool) {
99+ truncated, malformed := argsError(err)
100+ switch {
101+ case truncated:
102+ limit := "the answer limit"
103+ if mt := maxTokens(); mt != "" {
104+ limit = "max_tokens (" + mt + ")"
105+ }
106+ return fmt.Sprintf("the model hit %s while writing a tool call, so its arguments were cut off — raise sampling.max_tokens in the config, or ask for a smaller change (a long file written in several parts)", limit), true
107+ case malformed:
108+ return "the model wrote a tool call whose arguments are not valid JSON — rephrase the request, or try another model", true
109+ }
110+ return "", false
111+}
new file mode 100644
@@ -0,0 +1,111 @@
1+package engine
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/config"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// A tool call whose arguments are not valid JSON never reaches the tool: the
13+// compat_oai plugin decodes them while converting the server's response
14+// (`could not parse tool args: …`), and that error ends the whole generation.
15+// The model never reads it, so it cannot do what every other failure lets it
16+// do — read why and try again.
17+//
18+// The common cause is not a model that cannot write JSON: it is max_tokens.
19+// Seen on 2026-09-26 with a Qwen3 27B asked to write a document: one
20+// write_file of ~12,500 characters (~4k tokens) against `max_tokens: 4096`,
21+// cut mid-sentence, "unexpected end of JSON input", turn lost — and the error
22+// line the user saw was the whole truncated document.
23+//
24+// So the call is given back to the model once or twice, with a note saying
25+// what happened and how to get out of it (smaller pieces), before the error is
26+// let through — and then with a one-line explanation (badToolArgs) instead of
27+// the raw JSON.
28+
29+// maxArgRetries is how many times one generation hands a broken call back.
30+// A model that truncates twice in a row on the same file will do it a third
31+// time; past that the user has to change something (max_tokens, the request).
32+const maxArgRetries = 2
33+
34+// argsError classifies an error from the model call: truncated (the JSON stops
35+// before its end — max_tokens, almost always), malformed (it is complete but
36+// not JSON), or neither.
37+func argsError(err error) (truncated, malformed bool) {
38+ if err == nil {
39+ return false, false
40+ }
41+ msg := err.Error()
42+ if !strings.Contains(msg, "could not parse tool args") {
43+ return false, false
44+ }
45+ if strings.Contains(msg, "unexpected end of JSON input") || strings.Contains(msg, "unexpected EOF") {
46+ return true, false
47+ }
48+ return false, true
49+}
50+
51+// maxTokens is the configured max_tokens, "" when the sampling sets none.
52+func maxTokens() string {
53+ if v, ok := config.Cfg.Sampling["max_tokens"]; ok && v != nil {
54+ return fmt.Sprint(v)
55+ }
56+ return ""
57+}
58+
59+// pieceChars is a size one piece can safely have: half the answer limit, at
60+// roughly three characters per token. 0 when the limit is unknown.
61+func pieceChars() int {
62+ var n int
63+ if _, err := fmt.Sscan(maxTokens(), &n); err != nil || n <= 0 {
64+ return 0
65+ }
66+ return n * 3 / 2
67+}
68+
69+// argsRetryNote is what the model reads in place of the call it could not
70+// finish: a user message, since there is no tool response to attach it to —
71+// the call never became one.
72+func argsRetryNote(truncated bool) *ai.Message {
73+ var b strings.Builder
74+ b.WriteString("[mm: your last tool call was not run: ")
75+ if truncated {
76+ b.WriteString("it was cut off before its end")
77+ if mt := maxTokens(); mt != "" {
78+ b.WriteString(" (the answer limit is " + mt + " tokens)")
79+ }
80+ b.WriteString(", so its arguments were incomplete JSON. ")
81+ // A number the model can act on: "smaller pieces" alone was measured
82+ // to change nothing — the 12B retried the whole file, twice.
83+ b.WriteString("Do NOT retry the same call. Split the work: call write_file now with ONLY the first part")
84+ if n := pieceChars(); n > 0 {
85+ fmt.Fprintf(&b, " (at most about %d characters)", n)
86+ }
87+ b.WriteString(", then add each following part with its own edit_file call " +
88+ "(old = the last line already written, new = that line followed by the next part).]")
89+ } else {
90+ b.WriteString("its arguments were not valid JSON. " +
91+ "Call the tool again with a single JSON object matching its parameters; escape quotes, backslashes and newlines inside strings.]")
92+ }
93+ return ai.NewUserTextMessage(b.String())
94+}
95+
96+// badToolArgs is Explain's line for the same error once the retries are
97+// spent: what happened and which knob to turn, never the JSON itself.
98+func badToolArgs(err error) (string, bool) {
99+ truncated, malformed := argsError(err)
100+ switch {
101+ case truncated:
102+ limit := "the answer limit"
103+ if mt := maxTokens(); mt != "" {
104+ limit = "max_tokens (" + mt + ")"
105+ }
106+ return fmt.Sprintf("the model hit %s while writing a tool call, so its arguments were cut off — raise sampling.max_tokens in the config, or ask for a smaller change (a long file written in several parts)", limit), true
107+ case malformed:
108+ return "the model wrote a tool call whose arguments are not valid JSON — rephrase the request, or try another model", true
109+ }
110+ return "", false
111+}
added internal/engine/toolargs_test.go +132 -0
new file mode 100644
@@ -0,0 +1,132 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+
11+ "github.com/firebase/genkit/go/ai"
12+ "github.com/firebase/genkit/go/genkit"
13+)
14+
15+// The plugin's error for a tool call cut off by max_tokens, as it was seen on
16+// 2026-09-26 (shortened: the real one quoted the whole document).
17+var truncatedArgs = errors.New(`could not parse tool args: unmarshal failed to parse json string {"path":"docs/M.md","content":"# Mémoire: unexpected end of JSON input`)
18+
19+// scriptedGenkit registers a model that plays `steps` in order — a response
20+// or an error — and records every request it was sent, plus the fake `bash`.
21+func scriptedGenkit(t *testing.T, steps ...any) (*genkit.Genkit, *[]*ai.ModelRequest) {
22+ t.Helper()
23+ g := genkit.Init(context.Background())
24+ var seen []*ai.ModelRequest
25+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
26+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
27+ func(_ context.Context, req *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
28+ seen = append(seen, req)
29+ if len(seen) > len(steps) {
30+ t.Fatalf("the model was called %d times for %d planned steps", len(seen), len(steps))
31+ }
32+ switch s := steps[len(seen)-1].(type) {
33+ case error:
34+ return nil, s
35+ default:
36+ return s.(*ai.ModelResponse), nil
37+ }
38+ })
39+ genkit.DefineTool(g, "bash", "run a shell command",
40+ func(_ *ai.ToolContext, in struct {
41+ Command string `json:"command"`
42+ }) (string, error) {
43+ return "hello from " + in.Command, nil
44+ })
45+ return g, &seen
46+}
47+
48+// The shape of the session that motivated this: a command ran, then the
49+// model's next call was cut off. The call is handed back with a note, the
50+// model answers, the turn succeeds — and the note stays in the history the
51+// front end keeps, where the model will read it again for the next piece.
52+func TestTruncatedToolCallIsHandedBack(t *testing.T) {
53+ bash := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}, Ref: "1"}))
54+ g, seen := scriptedGenkit(t, bash, truncatedArgs, modelTurn(ai.NewTextPart("written in two parts")))
55+
56+ resp, full, err := testEngine(g).Generate(context.Background(),
57+ []*ai.Message{ai.NewUserTextMessage("write the document")},
58+ []ai.ToolRef{ai.ToolName("bash")})
59+ if err != nil {
60+ t.Fatalf("Generate: %v — the broken call should have been handed back", err)
61+ }
62+ if resp.Text() != "written in two parts" {
63+ t.Errorf("answer = %q", resp.Text())
64+ }
65+ if len(*seen) != 3 {
66+ t.Fatalf("model called %d times, want 3 (bash, cut off, retry)", len(*seen))
67+ }
68+ retry := (*seen)[2].Messages
69+ note := retry[len(retry)-1]
70+ if note.Role != ai.RoleUser || !strings.Contains(note.Text(), "cut off") || !strings.Contains(note.Text(), "Split the work") {
71+ t.Errorf("the retry does not end with the note: %s %q", note.Role, note.Text())
72+ }
73+ found := false
74+ for _, m := range full {
75+ if m.Role == ai.RoleUser && strings.Contains(m.Text(), "[mm: your last tool call was not run") {
76+ found = true
77+ }
78+ }
79+ if !found {
80+ t.Error("the note is not in the history handed back")
81+ }
82+ if Commands(full) != 1 {
83+ t.Errorf("Commands(full) = %d, want the ls that ran before", Commands(full))
84+ }
85+}
86+
87+// A model that keeps truncating is not retried forever: after maxArgRetries
88+// the error goes through, and Explain says what to change instead of quoting
89+// the JSON.
90+func TestTruncatedToolCallGivesUpAndExplains(t *testing.T) {
91+ old := config.Cfg.Sampling
92+ config.Cfg.Sampling = map[string]any{"max_tokens": 4096}
93+ defer func() { config.Cfg.Sampling = old }()
94+
95+ steps := []any{truncatedArgs}
96+ for range maxArgRetries {
97+ steps = append(steps, truncatedArgs)
98+ }
99+ g, seen := scriptedGenkit(t, steps...)
100+ e := testEngine(g)
101+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
102+
103+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("write it")}, nil)
104+ if err == nil {
105+ t.Fatal("Generate succeeded on a model that never finishes its call")
106+ }
107+ if len(*seen) != 1+maxArgRetries {
108+ t.Errorf("model called %d times, want %d", len(*seen), 1+maxArgRetries)
109+ }
110+ got := e.Explain(err)
111+ if !strings.Contains(got, "max_tokens (4096)") || strings.Contains(got, "Mémoire") {
112+ t.Errorf("Explain = %q, want the limit named and no JSON quoted", got)
113+ }
114+}
115+
116+func TestArgsError(t *testing.T) {
117+ cases := []struct {
118+ err error
119+ truncated, malformed bool
120+ }{
121+ {nil, false, false},
122+ {errors.New("connection refused"), false, false},
123+ {truncatedArgs, true, false},
124+ {errors.New(`could not parse tool args: unmarshal failed to parse json string {"a":1}}: invalid character '}'`), false, true},
125+ }
126+ for _, c := range cases {
127+ tr, ma := argsError(c.err)
128+ if tr != c.truncated || ma != c.malformed {
129+ t.Errorf("argsError(%v) = %v, %v; want %v, %v", c.err, tr, ma, c.truncated, c.malformed)
130+ }
131+ }
132+}
new file mode 100644
@@ -0,0 +1,132 @@
1+package engine
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+
11+ "github.com/firebase/genkit/go/ai"
12+ "github.com/firebase/genkit/go/genkit"
13+)
14+
15+// The plugin's error for a tool call cut off by max_tokens, as it was seen on
16+// 2026-09-26 (shortened: the real one quoted the whole document).
17+var truncatedArgs = errors.New(`could not parse tool args: unmarshal failed to parse json string {"path":"docs/M.md","content":"# Mémoire: unexpected end of JSON input`)
18+
19+// scriptedGenkit registers a model that plays `steps` in order — a response
20+// or an error — and records every request it was sent, plus the fake `bash`.
21+func scriptedGenkit(t *testing.T, steps ...any) (*genkit.Genkit, *[]*ai.ModelRequest) {
22+ t.Helper()
23+ g := genkit.Init(context.Background())
24+ var seen []*ai.ModelRequest
25+ genkit.DefineModel(g, "dmr/"+config.Cfg.Model,
26+ &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}},
27+ func(_ context.Context, req *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) {
28+ seen = append(seen, req)
29+ if len(seen) > len(steps) {
30+ t.Fatalf("the model was called %d times for %d planned steps", len(seen), len(steps))
31+ }
32+ switch s := steps[len(seen)-1].(type) {
33+ case error:
34+ return nil, s
35+ default:
36+ return s.(*ai.ModelResponse), nil
37+ }
38+ })
39+ genkit.DefineTool(g, "bash", "run a shell command",
40+ func(_ *ai.ToolContext, in struct {
41+ Command string `json:"command"`
42+ }) (string, error) {
43+ return "hello from " + in.Command, nil
44+ })
45+ return g, &seen
46+}
47+
48+// The shape of the session that motivated this: a command ran, then the
49+// model's next call was cut off. The call is handed back with a note, the
50+// model answers, the turn succeeds — and the note stays in the history the
51+// front end keeps, where the model will read it again for the next piece.
52+func TestTruncatedToolCallIsHandedBack(t *testing.T) {
53+ bash := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}, Ref: "1"}))
54+ g, seen := scriptedGenkit(t, bash, truncatedArgs, modelTurn(ai.NewTextPart("written in two parts")))
55+
56+ resp, full, err := testEngine(g).Generate(context.Background(),
57+ []*ai.Message{ai.NewUserTextMessage("write the document")},
58+ []ai.ToolRef{ai.ToolName("bash")})
59+ if err != nil {
60+ t.Fatalf("Generate: %v — the broken call should have been handed back", err)
61+ }
62+ if resp.Text() != "written in two parts" {
63+ t.Errorf("answer = %q", resp.Text())
64+ }
65+ if len(*seen) != 3 {
66+ t.Fatalf("model called %d times, want 3 (bash, cut off, retry)", len(*seen))
67+ }
68+ retry := (*seen)[2].Messages
69+ note := retry[len(retry)-1]
70+ if note.Role != ai.RoleUser || !strings.Contains(note.Text(), "cut off") || !strings.Contains(note.Text(), "Split the work") {
71+ t.Errorf("the retry does not end with the note: %s %q", note.Role, note.Text())
72+ }
73+ found := false
74+ for _, m := range full {
75+ if m.Role == ai.RoleUser && strings.Contains(m.Text(), "[mm: your last tool call was not run") {
76+ found = true
77+ }
78+ }
79+ if !found {
80+ t.Error("the note is not in the history handed back")
81+ }
82+ if Commands(full) != 1 {
83+ t.Errorf("Commands(full) = %d, want the ls that ran before", Commands(full))
84+ }
85+}
86+
87+// A model that keeps truncating is not retried forever: after maxArgRetries
88+// the error goes through, and Explain says what to change instead of quoting
89+// the JSON.
90+func TestTruncatedToolCallGivesUpAndExplains(t *testing.T) {
91+ old := config.Cfg.Sampling
92+ config.Cfg.Sampling = map[string]any{"max_tokens": 4096}
93+ defer func() { config.Cfg.Sampling = old }()
94+
95+ steps := []any{truncatedArgs}
96+ for range maxArgRetries {
97+ steps = append(steps, truncatedArgs)
98+ }
99+ g, seen := scriptedGenkit(t, steps...)
100+ e := testEngine(g)
101+ e.Provider, e.Backend = registry["dmr"], Backend{Provider: "dmr"}
102+
103+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("write it")}, nil)
104+ if err == nil {
105+ t.Fatal("Generate succeeded on a model that never finishes its call")
106+ }
107+ if len(*seen) != 1+maxArgRetries {
108+ t.Errorf("model called %d times, want %d", len(*seen), 1+maxArgRetries)
109+ }
110+ got := e.Explain(err)
111+ if !strings.Contains(got, "max_tokens (4096)") || strings.Contains(got, "Mémoire") {
112+ t.Errorf("Explain = %q, want the limit named and no JSON quoted", got)
113+ }
114+}
115+
116+func TestArgsError(t *testing.T) {
117+ cases := []struct {
118+ err error
119+ truncated, malformed bool
120+ }{
121+ {nil, false, false},
122+ {errors.New("connection refused"), false, false},
123+ {truncatedArgs, true, false},
124+ {errors.New(`could not parse tool args: unmarshal failed to parse json string {"a":1}}: invalid character '}'`), false, true},
125+ }
126+ for _, c := range cases {
127+ tr, ma := argsError(c.err)
128+ if tr != c.truncated || ma != c.malformed {
129+ t.Errorf("argsError(%v) = %v, %v; want %v, %v", c.err, tr, ma, c.truncated, c.malformed)
130+ }
131+ }
132+}
added internal/engine/usage.go +68 -0
new file mode 100644
@@ -0,0 +1,68 @@
1+package engine
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+
9+ "github.com/openai/openai-go/option"
10+)
11+
12+// A streamed chat completion carries no token counts unless the request asks
13+// for them with `stream_options: {"include_usage": true}` — then the server
14+// adds one last chunk, with empty choices, holding the `usage` object.
15+//
16+// Genkit's compat_oai plugin never asks. Measured on Docker Model Runner
17+// (llama.cpp backend), 2026-09-26: without the option the last chunk has
18+// `timings` and no `usage`; with it, `usage` arrives. So in streaming mode —
19+// every generation — resp.Usage stayed at zero, which left two things blind:
20+//
21+// - the compression trigger, which fell back to the character estimate
22+// instead of the server's exact count (lastInput);
23+// - the ACP front end, which had nothing to report as usage_update or in
24+// the session/prompt response.
25+//
26+// The plugin's accumulator already sums the `usage` of every chunk, empty
27+// choices included, so asking is all it takes. It cannot go in the sampling
28+// config: that is sent on the non-streamed calls too (the retry, the
29+// summariser), and OpenAI rejects stream_options when stream is false.
30+
31+// streamUsage is the openai-go middleware that adds the option to a request
32+// that streams and has not chosen for itself. Anything else — not JSON, not
33+// streamed, stream_options already set — goes through untouched.
34+func streamUsage(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
35+ if req.Method == http.MethodPost && req.Body != nil {
36+ if body, err := io.ReadAll(req.Body); err == nil {
37+ _ = req.Body.Close()
38+ body = withIncludeUsage(body)
39+ req.Body = io.NopCloser(bytes.NewReader(body))
40+ req.ContentLength = int64(len(body))
41+ req.GetBody = func() (io.ReadCloser, error) {
42+ return io.NopCloser(bytes.NewReader(body)), nil
43+ }
44+ }
45+ }
46+ return next(req)
47+}
48+
49+// withIncludeUsage returns body with stream_options.include_usage set when it
50+// is a streamed request that says nothing about it, body unchanged otherwise.
51+func withIncludeUsage(body []byte) []byte {
52+ var m map[string]json.RawMessage
53+ if json.Unmarshal(body, &m) != nil {
54+ return body
55+ }
56+ if string(m["stream"]) != "true" {
57+ return body
58+ }
59+ if _, set := m["stream_options"]; set {
60+ return body
61+ }
62+ m["stream_options"] = json.RawMessage(`{"include_usage":true}`)
63+ out, err := json.Marshal(m)
64+ if err != nil {
65+ return body
66+ }
67+ return out
68+}
new file mode 100644
@@ -0,0 +1,68 @@
1+package engine
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+
9+ "github.com/openai/openai-go/option"
10+)
11+
12+// A streamed chat completion carries no token counts unless the request asks
13+// for them with `stream_options: {"include_usage": true}` — then the server
14+// adds one last chunk, with empty choices, holding the `usage` object.
15+//
16+// Genkit's compat_oai plugin never asks. Measured on Docker Model Runner
17+// (llama.cpp backend), 2026-09-26: without the option the last chunk has
18+// `timings` and no `usage`; with it, `usage` arrives. So in streaming mode —
19+// every generation — resp.Usage stayed at zero, which left two things blind:
20+//
21+// - the compression trigger, which fell back to the character estimate
22+// instead of the server's exact count (lastInput);
23+// - the ACP front end, which had nothing to report as usage_update or in
24+// the session/prompt response.
25+//
26+// The plugin's accumulator already sums the `usage` of every chunk, empty
27+// choices included, so asking is all it takes. It cannot go in the sampling
28+// config: that is sent on the non-streamed calls too (the retry, the
29+// summariser), and OpenAI rejects stream_options when stream is false.
30+
31+// streamUsage is the openai-go middleware that adds the option to a request
32+// that streams and has not chosen for itself. Anything else — not JSON, not
33+// streamed, stream_options already set — goes through untouched.
34+func streamUsage(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
35+ if req.Method == http.MethodPost && req.Body != nil {
36+ if body, err := io.ReadAll(req.Body); err == nil {
37+ _ = req.Body.Close()
38+ body = withIncludeUsage(body)
39+ req.Body = io.NopCloser(bytes.NewReader(body))
40+ req.ContentLength = int64(len(body))
41+ req.GetBody = func() (io.ReadCloser, error) {
42+ return io.NopCloser(bytes.NewReader(body)), nil
43+ }
44+ }
45+ }
46+ return next(req)
47+}
48+
49+// withIncludeUsage returns body with stream_options.include_usage set when it
50+// is a streamed request that says nothing about it, body unchanged otherwise.
51+func withIncludeUsage(body []byte) []byte {
52+ var m map[string]json.RawMessage
53+ if json.Unmarshal(body, &m) != nil {
54+ return body
55+ }
56+ if string(m["stream"]) != "true" {
57+ return body
58+ }
59+ if _, set := m["stream_options"]; set {
60+ return body
61+ }
62+ m["stream_options"] = json.RawMessage(`{"include_usage":true}`)
63+ out, err := json.Marshal(m)
64+ if err != nil {
65+ return body
66+ }
67+ return out
68+}
added internal/engine/usage_test.go +60 -0
new file mode 100644
@@ -0,0 +1,60 @@
1+package engine
2+
3+import (
4+ "encoding/json"
5+ "io"
6+ "net/http"
7+ "strings"
8+ "testing"
9+)
10+
11+// A streamed request asks for the token counts; everything else — a request
12+// that does not stream (the retry, the summariser: OpenAI rejects the option
13+// there), one that already chose, a body that is not JSON — is left as is.
14+func TestWithIncludeUsage(t *testing.T) {
15+ var m map[string]any
16+ got := withIncludeUsage([]byte(`{"model":"m","stream":true,"messages":[]}`))
17+ if err := json.Unmarshal(got, &m); err != nil {
18+ t.Fatalf("not JSON any more: %s", got)
19+ }
20+ so, _ := m["stream_options"].(map[string]any)
21+ if so["include_usage"] != true {
22+ t.Errorf("streamed request: stream_options = %v, want include_usage true", m["stream_options"])
23+ }
24+ if m["model"] != "m" {
25+ t.Errorf("the rest of the body changed: %s", got)
26+ }
27+
28+ for _, body := range []string{
29+ `{"model":"m","messages":[]}`,
30+ `{"model":"m","stream":false}`,
31+ `{"stream":true,"stream_options":{"include_usage":false}}`,
32+ `not json`,
33+ } {
34+ if got := withIncludeUsage([]byte(body)); string(got) != body {
35+ t.Errorf("%s was changed to %s", body, got)
36+ }
37+ }
38+}
39+
40+// The middleware hands the rewritten body on with a matching length, and a
41+// GetBody that replays it — what net/http needs to retry or redirect.
42+func TestStreamUsageRewritesTheRequest(t *testing.T) {
43+ req, _ := http.NewRequest(http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(`{"stream":true}`))
44+ var seen string
45+ _, _ = streamUsage(req, func(r *http.Request) (*http.Response, error) {
46+ b, _ := io.ReadAll(r.Body)
47+ seen = string(b)
48+ if r.ContentLength != int64(len(b)) {
49+ t.Errorf("ContentLength %d, body %d bytes", r.ContentLength, len(b))
50+ }
51+ again, _ := r.GetBody()
52+ if b2, _ := io.ReadAll(again); string(b2) != seen {
53+ t.Errorf("GetBody replays %q, want %q", b2, seen)
54+ }
55+ return &http.Response{StatusCode: 200, Body: http.NoBody}, nil
56+ })
57+ if !strings.Contains(seen, `"include_usage":true`) {
58+ t.Errorf("body sent: %s", seen)
59+ }
60+}
new file mode 100644
@@ -0,0 +1,60 @@
1+package engine
2+
3+import (
4+ "encoding/json"
5+ "io"
6+ "net/http"
7+ "strings"
8+ "testing"
9+)
10+
11+// A streamed request asks for the token counts; everything else — a request
12+// that does not stream (the retry, the summariser: OpenAI rejects the option
13+// there), one that already chose, a body that is not JSON — is left as is.
14+func TestWithIncludeUsage(t *testing.T) {
15+ var m map[string]any
16+ got := withIncludeUsage([]byte(`{"model":"m","stream":true,"messages":[]}`))
17+ if err := json.Unmarshal(got, &m); err != nil {
18+ t.Fatalf("not JSON any more: %s", got)
19+ }
20+ so, _ := m["stream_options"].(map[string]any)
21+ if so["include_usage"] != true {
22+ t.Errorf("streamed request: stream_options = %v, want include_usage true", m["stream_options"])
23+ }
24+ if m["model"] != "m" {
25+ t.Errorf("the rest of the body changed: %s", got)
26+ }
27+
28+ for _, body := range []string{
29+ `{"model":"m","messages":[]}`,
30+ `{"model":"m","stream":false}`,
31+ `{"stream":true,"stream_options":{"include_usage":false}}`,
32+ `not json`,
33+ } {
34+ if got := withIncludeUsage([]byte(body)); string(got) != body {
35+ t.Errorf("%s was changed to %s", body, got)
36+ }
37+ }
38+}
39+
40+// The middleware hands the rewritten body on with a matching length, and a
41+// GetBody that replays it — what net/http needs to retry or redirect.
42+func TestStreamUsageRewritesTheRequest(t *testing.T) {
43+ req, _ := http.NewRequest(http.MethodPost, "http://x/v1/chat/completions", strings.NewReader(`{"stream":true}`))
44+ var seen string
45+ _, _ = streamUsage(req, func(r *http.Request) (*http.Response, error) {
46+ b, _ := io.ReadAll(r.Body)
47+ seen = string(b)
48+ if r.ContentLength != int64(len(b)) {
49+ t.Errorf("ContentLength %d, body %d bytes", r.ContentLength, len(b))
50+ }
51+ again, _ := r.GetBody()
52+ if b2, _ := io.ReadAll(again); string(b2) != seen {
53+ t.Errorf("GetBody replays %q, want %q", b2, seen)
54+ }
55+ return &http.Response{StatusCode: 200, Body: http.NoBody}, nil
56+ })
57+ if !strings.Contains(seen, `"include_usage":true`) {
58+ t.Errorf("body sent: %s", seen)
59+ }
60+}
modified internal/ui/ui.go +16 -0
Large diff collapsed to keep the page fast — load it when you need it.