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