🛟 Updated.
015bd7a parent: 1fa0c42 modified
.tickets/issues/0053-tool-order-changes-on-every-question.yaml +28 -7 | @@ -6,7 +6,7 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-30T05:52:16.000Z |
| 9 | -updatedAt: 2026-09-30T05:57:31.000Z | |
| 9 | +updatedAt: 2026-09-30T06:11:23.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - kind::bug |
| 12 | 12 | - priority::high |
| @@ -39,31 +39,34 @@ body: | | ||
| 39 | 39 | tasks: |
| 40 | 40 | - id: 1 |
| 41 | 41 | title: Sort req.Tools by name in WrapModel, with a test (fake model, several Generate calls, same order) |
| 42 | - state: open | |
| 42 | + state: closed | |
| 43 | 43 | priority: none |
| 44 | 44 | author: |
| 45 | 45 | name: k33g |
| 46 | 46 | email: ph.charriere@gmail.com |
| 47 | 47 | createdAt: 2026-09-30T05:52:16.000Z |
| 48 | - updatedAt: 2026-09-30T05:52:16.000Z | |
| 48 | + updatedAt: 2026-09-30T06:11:23.000Z | |
| 49 | + closedAt: 2026-09-30T06:11:23.000Z | |
| 49 | 50 | - id: 2 |
| 50 | 51 | title: Measure cached_tokens / prefill time on llama-server before and after (same session, 3-4 questions) |
| 51 | - state: open | |
| 52 | + state: closed | |
| 52 | 53 | priority: none |
| 53 | 54 | author: |
| 54 | 55 | name: k33g |
| 55 | 56 | email: ph.charriere@gmail.com |
| 56 | 57 | createdAt: 2026-09-30T05:52:16.000Z |
| 57 | - updatedAt: 2026-09-30T05:52:16.000Z | |
| 58 | + updatedAt: 2026-09-30T06:11:23.000Z | |
| 59 | + closedAt: 2026-09-30T06:11:23.000Z | |
| 58 | 60 | - id: 3 |
| 59 | 61 | title: Document the result in petits-llms.adoc (p7 / p10) |
| 60 | - state: open | |
| 62 | + state: closed | |
| 61 | 63 | priority: none |
| 62 | 64 | author: |
| 63 | 65 | name: k33g |
| 64 | 66 | email: ph.charriere@gmail.com |
| 65 | 67 | createdAt: 2026-09-30T05:52:16.000Z |
| 66 | - updatedAt: 2026-09-30T05:52:16.000Z | |
| 68 | + updatedAt: 2026-09-30T06:11:23.000Z | |
| 69 | + closedAt: 2026-09-30T06:11:23.000Z | |
| 67 | 70 | comments: |
| 68 | 71 | - id: 1 |
| 69 | 72 | author: |
| @@ -80,3 +83,21 @@ comments: | ||
| 80 | 83 | on tools anywhere in ai/ or compat_oai. |
| 81 | 84 | So the fix stays on mm's side (sort req.Tools in WrapModel). An upstream |
| 82 | 85 | issue/PR on genkit-ai/genkit could fix it for everyone. |
| 86 | + - id: 2 | |
| 87 | + author: | |
| 88 | + name: k33g | |
| 89 | + email: ph.charriere@gmail.com | |
| 90 | + createdAt: 2026-09-30T06:11:23.000Z | |
| 91 | + body: | | |
| 92 | + Fixed on branch fix/tool-order (not committed yet): | |
| 93 | + - internal/engine/tooldefs.go: stableTools sorts a copy of the tool | |
| 94 | + definitions by name; called at the top of WrapModel in engine.Generate. | |
| 95 | + - Tests: internal/engine/tooldefs_test.go. The end-to-end one (12 | |
| 96 | + questions through the real llamacpp provider, recording server) failed | |
| 97 | + before the fix on every run, passes after (-count=10). | |
| 98 | + - Measured with Mellum 12B on DMR, 4 questions per session, through | |
| 99 | + tests/tool-order/proxy.py + session.py: before, each order change | |
| 100 | + re-read ~3,400 tokens (~7 s); after, questions 2-4 read 20 tokens | |
| 101 | + (~250 ms). | |
| 102 | + - Docs: docs/{en,fr}/how-to/verify-tool-order.md (how to test it), | |
| 103 | + explanation/providers.md EN+FR, petits-llms.adoc. | |
| @@ -6,7 +6,7 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-30T05:52:16.000Z | 8 | createdAt: 2026-09-30T05:52:16.000Z |
| 9 | -updatedAt: 2026-09-30T05:57:31.000Z | 9 | +updatedAt: 2026-09-30T06:11:23.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - kind::bug | 11 | - kind::bug |
| 12 | - priority::high | 12 | - priority::high |
| @@ -39,31 +39,34 @@ body: | | |||
| 39 | tasks: | 39 | tasks: |
| 40 | - id: 1 | 40 | - id: 1 |
| 41 | title: Sort req.Tools by name in WrapModel, with a test (fake model, several Generate calls, same order) | 41 | title: Sort req.Tools by name in WrapModel, with a test (fake model, several Generate calls, same order) |
| 42 | - state: open | 42 | + state: closed |
| 43 | priority: none | 43 | priority: none |
| 44 | author: | 44 | author: |
| 45 | name: k33g | 45 | name: k33g |
| 46 | email: ph.charriere@gmail.com | 46 | email: ph.charriere@gmail.com |
| 47 | createdAt: 2026-09-30T05:52:16.000Z | 47 | createdAt: 2026-09-30T05:52:16.000Z |
| 48 | - updatedAt: 2026-09-30T05:52:16.000Z | 48 | + updatedAt: 2026-09-30T06:11:23.000Z |
| 49 | + closedAt: 2026-09-30T06:11:23.000Z | ||
| 49 | - id: 2 | 50 | - id: 2 |
| 50 | title: Measure cached_tokens / prefill time on llama-server before and after (same session, 3-4 questions) | 51 | title: Measure cached_tokens / prefill time on llama-server before and after (same session, 3-4 questions) |
| 51 | - state: open | 52 | + state: closed |
| 52 | priority: none | 53 | priority: none |
| 53 | author: | 54 | author: |
| 54 | name: k33g | 55 | name: k33g |
| 55 | email: ph.charriere@gmail.com | 56 | email: ph.charriere@gmail.com |
| 56 | createdAt: 2026-09-30T05:52:16.000Z | 57 | createdAt: 2026-09-30T05:52:16.000Z |
| 57 | - updatedAt: 2026-09-30T05:52:16.000Z | 58 | + updatedAt: 2026-09-30T06:11:23.000Z |
| 59 | + closedAt: 2026-09-30T06:11:23.000Z | ||
| 58 | - id: 3 | 60 | - id: 3 |
| 59 | title: Document the result in petits-llms.adoc (p7 / p10) | 61 | title: Document the result in petits-llms.adoc (p7 / p10) |
| 60 | - state: open | 62 | + state: closed |
| 61 | priority: none | 63 | priority: none |
| 62 | author: | 64 | author: |
| 63 | name: k33g | 65 | name: k33g |
| 64 | email: ph.charriere@gmail.com | 66 | email: ph.charriere@gmail.com |
| 65 | createdAt: 2026-09-30T05:52:16.000Z | 67 | createdAt: 2026-09-30T05:52:16.000Z |
| 66 | - updatedAt: 2026-09-30T05:52:16.000Z | 68 | + updatedAt: 2026-09-30T06:11:23.000Z |
| 69 | + closedAt: 2026-09-30T06:11:23.000Z | ||
| 67 | comments: | 70 | comments: |
| 68 | - id: 1 | 71 | - id: 1 |
| 69 | author: | 72 | author: |
| @@ -80,3 +83,21 @@ comments: | |||
| 80 | on tools anywhere in ai/ or compat_oai. | 83 | on tools anywhere in ai/ or compat_oai. |
| 81 | So the fix stays on mm's side (sort req.Tools in WrapModel). An upstream | 84 | So the fix stays on mm's side (sort req.Tools in WrapModel). An upstream |
| 82 | issue/PR on genkit-ai/genkit could fix it for everyone. | 85 | issue/PR on genkit-ai/genkit could fix it for everyone. |
| 86 | + - id: 2 | ||
| 87 | + author: | ||
| 88 | + name: k33g | ||
| 89 | + email: ph.charriere@gmail.com | ||
| 90 | + createdAt: 2026-09-30T06:11:23.000Z | ||
| 91 | + body: | | ||
| 92 | + Fixed on branch fix/tool-order (not committed yet): | ||
| 93 | + - internal/engine/tooldefs.go: stableTools sorts a copy of the tool | ||
| 94 | + definitions by name; called at the top of WrapModel in engine.Generate. | ||
| 95 | + - Tests: internal/engine/tooldefs_test.go. The end-to-end one (12 | ||
| 96 | + questions through the real llamacpp provider, recording server) failed | ||
| 97 | + before the fix on every run, passes after (-count=10). | ||
| 98 | + - Measured with Mellum 12B on DMR, 4 questions per session, through | ||
| 99 | + tests/tool-order/proxy.py + session.py: before, each order change | ||
| 100 | + re-read ~3,400 tokens (~7 s); after, questions 2-4 read 20 tokens | ||
| 101 | + (~250 ms). | ||
| 102 | + - Docs: docs/{en,fr}/how-to/verify-tool-order.md (how to test it), | ||
| 103 | + explanation/providers.md EN+FR, petits-llms.adoc. | ||
added
demo/config_explanation.md +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +# Configuration Analysis: agent.dmr.2.yaml | |
| 2 | + | |
| 3 | +This file serves as the primary configuration for an AI agent, setting up its model, tool access, context management, and operational behavior. | |
| 4 | + | |
| 5 | +## 1. Provider and Model Configuration | |
| 6 | +- **Provider:** `dmr` (Distributed Model Runtime). | |
| 7 | +- **Model:** `huggingface.co/unsloth/gemma-4-e2b-it-gguf:Q4_K_M`. | |
| 8 | +- **Endpoints:** Defines `baseUrl` and `fallback` URLs for connecting to the model engine server. | |
| 9 | + | |
| 10 | +## 2. Tooling and Capabilities | |
| 11 | +The agent is equipped with several powerful tools: | |
| 12 | +- **`bashTool: true`**: Allows the agent to execute shell commands in the terminal. | |
| 13 | +- **File Tools (`editTools: true`):** Enables the agent to read, write, and edit files within the environment. | |
| 14 | +- **Web Fetching (`fetchContentTool: true`):** Allows the agent to retrieve content from the web. | |
| 15 | +- **Planning (`planTool: true`):** Enables the agent to create and manage multi-step plans. | |
| 16 | + | |
| 17 | +## 3. Language Server Protocol (LSP) | |
| 18 | +- **Configuration:** Currently configured for `gopls` for `.go` files. This allows for intelligent code assistance, definition lookup, and diagnostics for Go code. | |
| 19 | + | |
| 20 | +## 4. Agent Persona and Rules (`system` prompt) | |
| 21 | +The system prompt defines the agent's identity and operational guidelines: | |
| 22 | +- **Identity:** The agent is "Bob The Bot," a coding agent working in a terminal. | |
| 23 | +- **Core Instruction:** The agent must use the `bash` tool to explore files, run tests, and inspect the repository. | |
| 24 | +- **Interaction Protocol:** It mandates that the agent must chain commands when necessary and answer clearly in English. | |
| 25 | +- **Tool Usage Rule:** It strictly instructs the agent on how to use the `read_skill` tool before any bash command, and outlines the rules for file editing and general agent behavior. | |
| 26 | + | |
| 27 | +## 5. Context and Generation Settings | |
| 28 | +- **Sampling:** Set to a low temperature (`0.0`) to ensure deterministic and precise outputs, which is ideal for coding tasks. | |
| 29 | +- **Context Compression:** Settings are in place to manage conversation history: | |
| 30 | + - `threshold`: Defines when context compression should be considered. | |
| 31 | + - `maxMessages`: Limits the history kept to prevent context overflow. | |
| 32 | + - `keepLastTurns`: Specifies how many recent turns are kept as-is. | |
| 33 | +- **Output Control:** Settings like `previewLines` control how much output from a command is displayed to the user. | |
| 34 | +- **Thinking Visibility:** `showThinking` is enabled, which displays the agent's reasoning process, aiding in transparency. | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +# Configuration Analysis: agent.dmr.2.yaml | ||
| 2 | + | ||
| 3 | +This file serves as the primary configuration for an AI agent, setting up its model, tool access, context management, and operational behavior. | ||
| 4 | + | ||
| 5 | +## 1. Provider and Model Configuration | ||
| 6 | +- **Provider:** `dmr` (Distributed Model Runtime). | ||
| 7 | +- **Model:** `huggingface.co/unsloth/gemma-4-e2b-it-gguf:Q4_K_M`. | ||
| 8 | +- **Endpoints:** Defines `baseUrl` and `fallback` URLs for connecting to the model engine server. | ||
| 9 | + | ||
| 10 | +## 2. Tooling and Capabilities | ||
| 11 | +The agent is equipped with several powerful tools: | ||
| 12 | +- **`bashTool: true`**: Allows the agent to execute shell commands in the terminal. | ||
| 13 | +- **File Tools (`editTools: true`):** Enables the agent to read, write, and edit files within the environment. | ||
| 14 | +- **Web Fetching (`fetchContentTool: true`):** Allows the agent to retrieve content from the web. | ||
| 15 | +- **Planning (`planTool: true`):** Enables the agent to create and manage multi-step plans. | ||
| 16 | + | ||
| 17 | +## 3. Language Server Protocol (LSP) | ||
| 18 | +- **Configuration:** Currently configured for `gopls` for `.go` files. This allows for intelligent code assistance, definition lookup, and diagnostics for Go code. | ||
| 19 | + | ||
| 20 | +## 4. Agent Persona and Rules (`system` prompt) | ||
| 21 | +The system prompt defines the agent's identity and operational guidelines: | ||
| 22 | +- **Identity:** The agent is "Bob The Bot," a coding agent working in a terminal. | ||
| 23 | +- **Core Instruction:** The agent must use the `bash` tool to explore files, run tests, and inspect the repository. | ||
| 24 | +- **Interaction Protocol:** It mandates that the agent must chain commands when necessary and answer clearly in English. | ||
| 25 | +- **Tool Usage Rule:** It strictly instructs the agent on how to use the `read_skill` tool before any bash command, and outlines the rules for file editing and general agent behavior. | ||
| 26 | + | ||
| 27 | +## 5. Context and Generation Settings | ||
| 28 | +- **Sampling:** Set to a low temperature (`0.0`) to ensure deterministic and precise outputs, which is ideal for coding tasks. | ||
| 29 | +- **Context Compression:** Settings are in place to manage conversation history: | ||
| 30 | + - `threshold`: Defines when context compression should be considered. | ||
| 31 | + - `maxMessages`: Limits the history kept to prevent context overflow. | ||
| 32 | + - `keepLastTurns`: Specifies how many recent turns are kept as-is. | ||
| 33 | +- **Output Control:** Settings like `previewLines` control how much output from a command is displayed to the user. | ||
| 34 | +- **Thinking Visibility:** `showThinking` is enabled, which displays the agent's reasoning process, aiding in transparency. | ||
modified
docs/en/README.md +1 -0 | @@ -23,6 +23,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 23 | 23 | - [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md) |
| 24 | 24 | - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 25 | 25 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) |
| 26 | +- [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 | |
| 26 | 27 | - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it |
| 27 | 28 | |
| 28 | 29 | ### MCP (Model Context Protocol) How-to |
| @@ -23,6 +23,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 23 | - [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md) | 23 | - [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md) |
| 24 | - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md) | 24 | - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 25 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) | 25 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) |
| 26 | +- [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 | ||
| 26 | - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it | 27 | - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it |
| 27 | 28 | ||
| 28 | ### MCP (Model Context Protocol) How-to | 29 | ### MCP (Model Context Protocol) How-to |
modified
docs/en/explanation/providers.md +5 -0 | @@ -20,14 +20,19 @@ | ||
| 20 | 20 | |
| 21 | 21 | **A stream the server breaks is retried, and its words are kept.** llama.cpp reports a failure in the middle of a streamed answer as an SSE `error:` event (the 200 status is already sent). openai-go only reads `data:` lines, so the message used to vanish behind `unexpected end of JSON input`. The tap that reads the stream for reasoning also picks up those events, and the model call that broke is retried once — that call only: the tools that ran earlier in the turn are not replayed, and text the broken attempt already streamed is not taken back. If it breaks again, the error shows the server's own message (`the model server reported an error while generating: Failed to parse input at pos …`), or says the connection broke. Either way the history is kept: asking to continue picks the work up. |
| 22 | 22 | |
| 23 | +**The tools go out in the same order on every request.** A llama.cpp server keeps the prompt it has just read and, on the next request, reads again only what changed after the longest common beginning. The chat template puts the tools near the top — in the system block for Qwen-style templates — so their order decides how much of the history survives. Genkit builds the request's tool list from a Go map, whose order is random: each question of a session sent the same tools in another order, and the server read the whole prompt again (ticket 53; still the case in Genkit v1.13.1). `mm` now sorts the tools by name just before each model call. Measured with Mellum 12B on Docker Model Runner: from the second question on, a question reads about 20 new tokens in a quarter of a second, where it re-read about 3,400 tokens in seven seconds whenever the order changed. | |
| 24 | + | |
| 23 | 25 | ## Rejected alternatives |
| 24 | 26 | |
| 25 | 27 | - **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers. |
| 26 | 28 | - **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown. |
| 27 | 29 | - **Failing at start-up when the server is down.** Rejected because starting the server second is the normal demo situation. |
| 30 | +- **Keeping the order in which `main.go` declares the tools.** Rejected: Genkit loses that order before the request is built, and an order that depends on declaration would change with the configuration and with MCP servers anyway. The name is the one key that depends on the tools alone. | |
| 31 | +- **Waiting for a Genkit release that fixes it.** Rejected for now: v1.13.1 and the main branch still build the list from a map. | |
| 28 | 32 | |
| 29 | 33 | ## How it relates to the rest |
| 30 | 34 | |
| 31 | 35 | - The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md). |
| 32 | 36 | - The window the probe learns is what [context compression](context-compression.md) measures against. |
| 33 | 37 | - Switching servers for one run is covered in [how to run on llama.cpp](../how-to/run-with-llama-cpp.md). |
| 38 | +- Checking the tool order and measuring the cache gain on your own server: [how to check that the tools go out in a stable order](../how-to/verify-tool-order.md). | |
| @@ -20,14 +20,19 @@ | |||
| 20 | 20 | ||
| 21 | **A stream the server breaks is retried, and its words are kept.** llama.cpp reports a failure in the middle of a streamed answer as an SSE `error:` event (the 200 status is already sent). openai-go only reads `data:` lines, so the message used to vanish behind `unexpected end of JSON input`. The tap that reads the stream for reasoning also picks up those events, and the model call that broke is retried once — that call only: the tools that ran earlier in the turn are not replayed, and text the broken attempt already streamed is not taken back. If it breaks again, the error shows the server's own message (`the model server reported an error while generating: Failed to parse input at pos …`), or says the connection broke. Either way the history is kept: asking to continue picks the work up. | 21 | **A stream the server breaks is retried, and its words are kept.** llama.cpp reports a failure in the middle of a streamed answer as an SSE `error:` event (the 200 status is already sent). openai-go only reads `data:` lines, so the message used to vanish behind `unexpected end of JSON input`. The tap that reads the stream for reasoning also picks up those events, and the model call that broke is retried once — that call only: the tools that ran earlier in the turn are not replayed, and text the broken attempt already streamed is not taken back. If it breaks again, the error shows the server's own message (`the model server reported an error while generating: Failed to parse input at pos …`), or says the connection broke. Either way the history is kept: asking to continue picks the work up. |
| 22 | 22 | ||
| 23 | +**The tools go out in the same order on every request.** A llama.cpp server keeps the prompt it has just read and, on the next request, reads again only what changed after the longest common beginning. The chat template puts the tools near the top — in the system block for Qwen-style templates — so their order decides how much of the history survives. Genkit builds the request's tool list from a Go map, whose order is random: each question of a session sent the same tools in another order, and the server read the whole prompt again (ticket 53; still the case in Genkit v1.13.1). `mm` now sorts the tools by name just before each model call. Measured with Mellum 12B on Docker Model Runner: from the second question on, a question reads about 20 new tokens in a quarter of a second, where it re-read about 3,400 tokens in seven seconds whenever the order changed. | ||
| 24 | + | ||
| 23 | ## Rejected alternatives | 25 | ## Rejected alternatives |
| 24 | 26 | ||
| 25 | - **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers. | 27 | - **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers. |
| 26 | - **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown. | 28 | - **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown. |
| 27 | - **Failing at start-up when the server is down.** Rejected because starting the server second is the normal demo situation. | 29 | - **Failing at start-up when the server is down.** Rejected because starting the server second is the normal demo situation. |
| 30 | +- **Keeping the order in which `main.go` declares the tools.** Rejected: Genkit loses that order before the request is built, and an order that depends on declaration would change with the configuration and with MCP servers anyway. The name is the one key that depends on the tools alone. | ||
| 31 | +- **Waiting for a Genkit release that fixes it.** Rejected for now: v1.13.1 and the main branch still build the list from a map. | ||
| 28 | 32 | ||
| 29 | ## How it relates to the rest | 33 | ## How it relates to the rest |
| 30 | 34 | ||
| 31 | - The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md). | 35 | - The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md). |
| 32 | - The window the probe learns is what [context compression](context-compression.md) measures against. | 36 | - The window the probe learns is what [context compression](context-compression.md) measures against. |
| 33 | - Switching servers for one run is covered in [how to run on llama.cpp](../how-to/run-with-llama-cpp.md). | 37 | - Switching servers for one run is covered in [how to run on llama.cpp](../how-to/run-with-llama-cpp.md). |
| 38 | +- Checking the tool order and measuring the cache gain on your own server: [how to check that the tools go out in a stable order](../how-to/verify-tool-order.md). | ||
added
docs/en/how-to/verify-tool-order.md +131 -0 | new file mode 100644 | ||
| @@ -0,0 +1,131 @@ | ||
| 1 | +# How to check that the tools go out in a stable order | |
| 2 | + | |
| 3 | +Since ticket 53, `mm` sends the tools of every request sorted by name, so that a llama.cpp server can reuse the prompt it cached for the previous question. This guide shows how to check that change: that nothing regressed, that the order is stable, and how much prompt reading it saves on your own server and model. | |
| 4 | + | |
| 5 | +It assumes a checkout of `mm`, the Go toolchain, `python3`, and a running Docker Model Runner or `llama-server` with a model that handles tool calls. | |
| 6 | + | |
| 7 | +## 1. Run the test suite | |
| 8 | + | |
| 9 | +From the repository root: | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +go test ./... | |
| 13 | +``` | |
| 14 | + | |
| 15 | +Every package must report `ok`. Then run the tests of this change on their own, several times — the bug was random, so a single green run proves little: | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +go test ./internal/engine -run 'TestToolsGoOut|TestStableTools' -count=10 -v | |
| 19 | +``` | |
| 20 | + | |
| 21 | +| Test | What it checks | | |
| 22 | +|------|----------------| | |
| 23 | +| `TestToolsGoOutInTheSameOrderOnEveryQuestion` | Twelve questions through the real `llamacpp` provider, against a fake server that records the requests: each one carries the seven tools sorted by name. | | |
| 24 | +| `TestStableToolsSortsByName` | The list comes out sorted. | | |
| 25 | +| `TestStableToolsLeavesTheOriginalAlone` | The slice Genkit owns is not reordered in place. | | |
| 26 | +| `TestStableToolsIgnoresTheIncomingOrder` | Every rotation of the same tools gives the same list. | | |
| 27 | +| `TestStableToolsWithNoneOrOne` | A request without tools (the summary request of `/compact`) is left untouched. | | |
| 28 | + | |
| 29 | +To see the first test catch the bug, remove the line `params.Request.Tools = stableTools(params.Request.Tools)` from `internal/engine/engine.go` and run it again: it fails with lines like `question 2 sent the tools as [plan fetch_content bash …], want [bash edit_file …]`. Put the line back. | |
| 30 | + | |
| 31 | +## 2. Build the binary before and after the change | |
| 32 | + | |
| 33 | +```bash | |
| 34 | +go build -o /tmp/mm-after . | |
| 35 | +git worktree add /tmp/mm-before-src 1fa0c42 | |
| 36 | +(cd /tmp/mm-before-src && go build -o /tmp/mm-before .) | |
| 37 | +git worktree remove /tmp/mm-before-src | |
| 38 | +``` | |
| 39 | + | |
| 40 | +`1fa0c42` is the last commit without the fix; any older commit works too. | |
| 41 | + | |
| 42 | +## 3. Start the recording proxy | |
| 43 | + | |
| 44 | +In a second terminal, start `tests/tool-order/proxy.py` in front of the model server: | |
| 45 | + | |
| 46 | +```bash | |
| 47 | +python3 tests/tool-order/proxy.py --port 18770 --upstream http://localhost:12434 | |
| 48 | +``` | |
| 49 | + | |
| 50 | +It prints `proxy on http://127.0.0.1:18770 -> http://localhost:12434`, then one line per request once `mm` starts asking. | |
| 51 | + | |
| 52 | +## 4. Make a configuration that goes through the proxy | |
| 53 | + | |
| 54 | +```bash | |
| 55 | +sed -e 's#^baseUrl:.*#baseUrl: http://127.0.0.1:18770/engines/v1#' \ | |
| 56 | + -e 's#^fallback:.*#fallback: ""#' \ | |
| 57 | + -e "s#^skillsDir:.*#skillsDir: $PWD/skills#" \ | |
| 58 | + agent.dmr.yaml > /tmp/agent.proxy.yaml | |
| 59 | +``` | |
| 60 | + | |
| 61 | +`fallback: ""` keeps `mm` on the proxy even when the probe is slow. The skills directory is made absolute because, in editor mode, a relative one is read from the configuration file's directory — without it `read_skill` would be missing and the request would not be the one you use every day. | |
| 62 | + | |
| 63 | +## 5. Ask the same questions with each binary | |
| 64 | + | |
| 65 | +`tests/tool-order/session.py` asks several questions in one session, through the editor protocol (`mm -acp`): | |
| 66 | + | |
| 67 | +```bash | |
| 68 | +python3 tests/tool-order/session.py /tmp/mm-before /tmp/agent.proxy.yaml \ | |
| 69 | + "Answer in one short sentence: what is Go?" \ | |
| 70 | + "Answer in one short sentence: what is Rust?" \ | |
| 71 | + "Answer in one short sentence: what is Zig?" \ | |
| 72 | + "Answer in one short sentence: what is Odin?" | |
| 73 | +``` | |
| 74 | + | |
| 75 | +Each question prints `question N: {'stopReason': 'end_turn', …}`. Run the same command with `/tmp/mm-after`. | |
| 76 | + | |
| 77 | +Keep the questions short and free of file changes: a question that makes the model write a file asks the editor for permission, and the script has no one to answer it. | |
| 78 | + | |
| 79 | +## 6. Read the proxy's lines | |
| 80 | + | |
| 81 | +Each line is one request: | |
| 82 | + | |
| 83 | +``` | |
| 84 | +#6 tools=f2f7a1fa ['bash', 'edit_file', 'fetch_content', …] cache_n=3381 prompt_n=20 prompt_ms=253 | |
| 85 | +``` | |
| 86 | + | |
| 87 | +| Field | Meaning | | |
| 88 | +|-------|---------| | |
| 89 | +| `tools=` | A fingerprint of the tools block exactly as it was sent. | | |
| 90 | +| `[…]` | The tool names, in the order they were sent. | | |
| 91 | +| `cache_n` | Prompt tokens the server took from its cache. | | |
| 92 | +| `prompt_n` | Prompt tokens it had to read again. | | |
| 93 | +| `prompt_ms` | The time it spent reading them. | | |
| 94 | + | |
| 95 | +What to look for: | |
| 96 | + | |
| 97 | +- **After the change**, `tools=` has the same value on every line, and from the second question on `prompt_n` stays small (the new question and the previous answer) while `cache_n` covers the rest of the prompt. | |
| 98 | +- **Before the change**, `tools=` changes from one question to the next, and each time it changes `prompt_n` jumps back to the size of the whole prompt. | |
| 99 | + | |
| 100 | +The first question of a session reads the whole prompt in both cases, unless the server still holds the same prefix from an earlier session. | |
| 101 | + | |
| 102 | +Measured on 2026-09-30 with Mellum 12B on Docker Model Runner, four questions, `agent.dmr.yaml` (six tools, skills not loaded): | |
| 103 | + | |
| 104 | +| Question | Before: `prompt_n` / `prompt_ms` | After: `prompt_n` / `prompt_ms` | | |
| 105 | +|----------|------------------|------------------| | |
| 106 | +| 1 | 3355 / 6776 | 3355 / 7141 | | |
| 107 | +| 2 | 3401 / 7186 | 20 / 253 | | |
| 108 | +| 3 | 107 / 713 (the order happened to match question 1) | 20 / 257 | | |
| 109 | +| 4 | 110 / 707 | 20 / 252 | | |
| 110 | + | |
| 111 | +The gain grows with the session: before the change, a question whose tool order differs re-reads the whole history, not only the tools. | |
| 112 | + | |
| 113 | +## 7. Check the rest by hand | |
| 114 | + | |
| 115 | +The change touches every request, so check once that ordinary use still works with the new binary and your usual configuration: | |
| 116 | + | |
| 117 | +- A question that needs a tool: `/tmp/mm-after -config agent.dmr.yaml -p "list the files of this directory"` runs `bash` and answers. | |
| 118 | +- A long session that compresses: `/compact` still writes a summary (its request has no tools and is left alone). | |
| 119 | +- An MCP server, if you use one: its tools are sorted with the built-in ones and still called by their names. | |
| 120 | + | |
| 121 | +## Variants | |
| 122 | + | |
| 123 | +- **llama-server**: start the proxy with `--upstream http://localhost:8080`, and build the configuration from `agent.llamacpp.yaml` with `baseUrl: http://127.0.0.1:18770/v1`. | |
| 124 | +- **`mm` or the server in a container**: use `--upstream http://host.docker.internal:12434` for a Docker Model Runner on the host. | |
| 125 | +- **Another model**: the prompt sizes change, the pattern should not. The numbers above are an example, not a target. | |
| 126 | + | |
| 127 | +## See also | |
| 128 | + | |
| 129 | +- Why the order matters: [providers — explanation](../explanation/providers.md) | |
| 130 | +- The configuration keys used here: [configuration reference](../reference/configuration.md) | |
| 131 | +- Running a single question: [run a single prompt](run-a-single-prompt.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,131 @@ | |||
| 1 | +# How to check that the tools go out in a stable order | ||
| 2 | + | ||
| 3 | +Since ticket 53, `mm` sends the tools of every request sorted by name, so that a llama.cpp server can reuse the prompt it cached for the previous question. This guide shows how to check that change: that nothing regressed, that the order is stable, and how much prompt reading it saves on your own server and model. | ||
| 4 | + | ||
| 5 | +It assumes a checkout of `mm`, the Go toolchain, `python3`, and a running Docker Model Runner or `llama-server` with a model that handles tool calls. | ||
| 6 | + | ||
| 7 | +## 1. Run the test suite | ||
| 8 | + | ||
| 9 | +From the repository root: | ||
| 10 | + | ||
| 11 | +```bash | ||
| 12 | +go test ./... | ||
| 13 | +``` | ||
| 14 | + | ||
| 15 | +Every package must report `ok`. Then run the tests of this change on their own, several times — the bug was random, so a single green run proves little: | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +go test ./internal/engine -run 'TestToolsGoOut|TestStableTools' -count=10 -v | ||
| 19 | +``` | ||
| 20 | + | ||
| 21 | +| Test | What it checks | | ||
| 22 | +|------|----------------| | ||
| 23 | +| `TestToolsGoOutInTheSameOrderOnEveryQuestion` | Twelve questions through the real `llamacpp` provider, against a fake server that records the requests: each one carries the seven tools sorted by name. | | ||
| 24 | +| `TestStableToolsSortsByName` | The list comes out sorted. | | ||
| 25 | +| `TestStableToolsLeavesTheOriginalAlone` | The slice Genkit owns is not reordered in place. | | ||
| 26 | +| `TestStableToolsIgnoresTheIncomingOrder` | Every rotation of the same tools gives the same list. | | ||
| 27 | +| `TestStableToolsWithNoneOrOne` | A request without tools (the summary request of `/compact`) is left untouched. | | ||
| 28 | + | ||
| 29 | +To see the first test catch the bug, remove the line `params.Request.Tools = stableTools(params.Request.Tools)` from `internal/engine/engine.go` and run it again: it fails with lines like `question 2 sent the tools as [plan fetch_content bash …], want [bash edit_file …]`. Put the line back. | ||
| 30 | + | ||
| 31 | +## 2. Build the binary before and after the change | ||
| 32 | + | ||
| 33 | +```bash | ||
| 34 | +go build -o /tmp/mm-after . | ||
| 35 | +git worktree add /tmp/mm-before-src 1fa0c42 | ||
| 36 | +(cd /tmp/mm-before-src && go build -o /tmp/mm-before .) | ||
| 37 | +git worktree remove /tmp/mm-before-src | ||
| 38 | +``` | ||
| 39 | + | ||
| 40 | +`1fa0c42` is the last commit without the fix; any older commit works too. | ||
| 41 | + | ||
| 42 | +## 3. Start the recording proxy | ||
| 43 | + | ||
| 44 | +In a second terminal, start `tests/tool-order/proxy.py` in front of the model server: | ||
| 45 | + | ||
| 46 | +```bash | ||
| 47 | +python3 tests/tool-order/proxy.py --port 18770 --upstream http://localhost:12434 | ||
| 48 | +``` | ||
| 49 | + | ||
| 50 | +It prints `proxy on http://127.0.0.1:18770 -> http://localhost:12434`, then one line per request once `mm` starts asking. | ||
| 51 | + | ||
| 52 | +## 4. Make a configuration that goes through the proxy | ||
| 53 | + | ||
| 54 | +```bash | ||
| 55 | +sed -e 's#^baseUrl:.*#baseUrl: http://127.0.0.1:18770/engines/v1#' \ | ||
| 56 | + -e 's#^fallback:.*#fallback: ""#' \ | ||
| 57 | + -e "s#^skillsDir:.*#skillsDir: $PWD/skills#" \ | ||
| 58 | + agent.dmr.yaml > /tmp/agent.proxy.yaml | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +`fallback: ""` keeps `mm` on the proxy even when the probe is slow. The skills directory is made absolute because, in editor mode, a relative one is read from the configuration file's directory — without it `read_skill` would be missing and the request would not be the one you use every day. | ||
| 62 | + | ||
| 63 | +## 5. Ask the same questions with each binary | ||
| 64 | + | ||
| 65 | +`tests/tool-order/session.py` asks several questions in one session, through the editor protocol (`mm -acp`): | ||
| 66 | + | ||
| 67 | +```bash | ||
| 68 | +python3 tests/tool-order/session.py /tmp/mm-before /tmp/agent.proxy.yaml \ | ||
| 69 | + "Answer in one short sentence: what is Go?" \ | ||
| 70 | + "Answer in one short sentence: what is Rust?" \ | ||
| 71 | + "Answer in one short sentence: what is Zig?" \ | ||
| 72 | + "Answer in one short sentence: what is Odin?" | ||
| 73 | +``` | ||
| 74 | + | ||
| 75 | +Each question prints `question N: {'stopReason': 'end_turn', …}`. Run the same command with `/tmp/mm-after`. | ||
| 76 | + | ||
| 77 | +Keep the questions short and free of file changes: a question that makes the model write a file asks the editor for permission, and the script has no one to answer it. | ||
| 78 | + | ||
| 79 | +## 6. Read the proxy's lines | ||
| 80 | + | ||
| 81 | +Each line is one request: | ||
| 82 | + | ||
| 83 | +``` | ||
| 84 | +#6 tools=f2f7a1fa ['bash', 'edit_file', 'fetch_content', …] cache_n=3381 prompt_n=20 prompt_ms=253 | ||
| 85 | +``` | ||
| 86 | + | ||
| 87 | +| Field | Meaning | | ||
| 88 | +|-------|---------| | ||
| 89 | +| `tools=` | A fingerprint of the tools block exactly as it was sent. | | ||
| 90 | +| `[…]` | The tool names, in the order they were sent. | | ||
| 91 | +| `cache_n` | Prompt tokens the server took from its cache. | | ||
| 92 | +| `prompt_n` | Prompt tokens it had to read again. | | ||
| 93 | +| `prompt_ms` | The time it spent reading them. | | ||
| 94 | + | ||
| 95 | +What to look for: | ||
| 96 | + | ||
| 97 | +- **After the change**, `tools=` has the same value on every line, and from the second question on `prompt_n` stays small (the new question and the previous answer) while `cache_n` covers the rest of the prompt. | ||
| 98 | +- **Before the change**, `tools=` changes from one question to the next, and each time it changes `prompt_n` jumps back to the size of the whole prompt. | ||
| 99 | + | ||
| 100 | +The first question of a session reads the whole prompt in both cases, unless the server still holds the same prefix from an earlier session. | ||
| 101 | + | ||
| 102 | +Measured on 2026-09-30 with Mellum 12B on Docker Model Runner, four questions, `agent.dmr.yaml` (six tools, skills not loaded): | ||
| 103 | + | ||
| 104 | +| Question | Before: `prompt_n` / `prompt_ms` | After: `prompt_n` / `prompt_ms` | | ||
| 105 | +|----------|------------------|------------------| | ||
| 106 | +| 1 | 3355 / 6776 | 3355 / 7141 | | ||
| 107 | +| 2 | 3401 / 7186 | 20 / 253 | | ||
| 108 | +| 3 | 107 / 713 (the order happened to match question 1) | 20 / 257 | | ||
| 109 | +| 4 | 110 / 707 | 20 / 252 | | ||
| 110 | + | ||
| 111 | +The gain grows with the session: before the change, a question whose tool order differs re-reads the whole history, not only the tools. | ||
| 112 | + | ||
| 113 | +## 7. Check the rest by hand | ||
| 114 | + | ||
| 115 | +The change touches every request, so check once that ordinary use still works with the new binary and your usual configuration: | ||
| 116 | + | ||
| 117 | +- A question that needs a tool: `/tmp/mm-after -config agent.dmr.yaml -p "list the files of this directory"` runs `bash` and answers. | ||
| 118 | +- A long session that compresses: `/compact` still writes a summary (its request has no tools and is left alone). | ||
| 119 | +- An MCP server, if you use one: its tools are sorted with the built-in ones and still called by their names. | ||
| 120 | + | ||
| 121 | +## Variants | ||
| 122 | + | ||
| 123 | +- **llama-server**: start the proxy with `--upstream http://localhost:8080`, and build the configuration from `agent.llamacpp.yaml` with `baseUrl: http://127.0.0.1:18770/v1`. | ||
| 124 | +- **`mm` or the server in a container**: use `--upstream http://host.docker.internal:12434` for a Docker Model Runner on the host. | ||
| 125 | +- **Another model**: the prompt sizes change, the pattern should not. The numbers above are an example, not a target. | ||
| 126 | + | ||
| 127 | +## See also | ||
| 128 | + | ||
| 129 | +- Why the order matters: [providers — explanation](../explanation/providers.md) | ||
| 130 | +- The configuration keys used here: [configuration reference](../reference/configuration.md) | ||
| 131 | +- Running a single question: [run a single prompt](run-a-single-prompt.md) | ||
modified
docs/fr/README.md +1 -0 | @@ -23,6 +23,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 23 | 23 | - [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md) |
| 24 | 24 | - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 25 | 25 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) |
| 26 | +- [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 | |
| 26 | 27 | - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher |
| 27 | 28 | |
| 28 | 29 | ### Guides pratiques MCP (Model Context Protocol) |
| @@ -23,6 +23,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 23 | - [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md) | 23 | - [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md) |
| 24 | - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md) | 24 | - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 25 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) | 25 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) |
| 26 | +- [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 | ||
| 26 | - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher | 27 | - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher |
| 27 | 28 | ||
| 28 | ### Guides pratiques MCP (Model Context Protocol) | 29 | ### Guides pratiques MCP (Model Context Protocol) |
modified
docs/fr/explanation/petits-llms.adoc +4 -2 | @@ -253,6 +253,7 @@ Un petit modèle choisit mal une procédure sur son seul nom. `internal/skills` | ||
| 253 | 253 | * *Erreurs expliquées en une ligne* : `Explain` traduit « connection refused » en « start it: llama-server … --jinja », signale l'absence de `--jinja` (nécessaire aux appels d'outils), et le 401 sur un serveur sans clé. |
| 254 | 254 | * *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine. |
| 255 | 255 | * *Lecture du prompt ≠ blocage* : avant son premier token, le serveur lit tout le prompt sans rien envoyer — plusieurs minutes pour 25k tokens sur un 27B local. `mm` demande `return_progress` à llama.cpp, lit les chunks `prompt_progress` pour alimenter le watchdog (et afficher « Reading the prompt 12.0k/25.3k tokens »), et accorde `firstTokenTimeout` (3 min) avant le premier token de chaque appel, `watchdogTimeout` ensuite (`internal/engine/progress.go`). Observé le 26/09/2026 : cinq questions de suite, « hello » compris, tuées en « stalled » par un watchdog de 30 s. |
| 256 | +* *Un préfixe stable* : llama.cpp ne relit que ce qui suit le plus long début commun avec la requête précédente. Or Genkit construit la liste des outils à partir d'une map Go, dans un ordre aléatoire : chaque question envoyait les outils dans un autre ordre, et le serveur relisait tout le prompt (ticket 53, toujours présent dans Genkit v1.13.1). Depuis le 30/09/2026, `stableTools` (`internal/engine/tooldefs.go`) les trie par nom avant chaque appel. Mesuré avec Mellum 12B sur Docker Model Runner : à partir de la deuxième question, ≈ 20 tokens relus en ≈ 250 ms, contre ≈ 3 400 à 3 900 tokens en 7 à 9 s chaque fois que l'ordre changeait. | |
| 256 | 257 | * *Flux interrompu par le serveur* : llama.cpp signale une erreur en cours de génération par un événement SSE `error:`, qu'openai-go ignore (il ne restait que `unexpected end of JSON input`). `mm` récupère ce message (`internal/engine/servererr.go`) et relance *une fois* l'appel au modèle qui a échoué, sans rejouer les outils déjà exécutés dans le tour. Observé le 26/09/2026 : un tour perdu après vingt appels d'outils et un fichier écrit. |
| 257 | 258 | |
| 258 | 259 | == Une méthode : mesurer |
| @@ -368,7 +369,7 @@ Cette mesure date d'avant `fetch_content`. Le 30/09/2026, la première requête | ||
| 368 | 369 | | *Requête entière* (JSON) | | | *14 993* (12 762 sans `fetch_content`) |
| 369 | 370 | |=== |
| 370 | 371 | |
| 371 | -Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 436). `fetch_content` ajoute 2 231 caractères à la requête, soit +17 %. À ≈ 3,4k tokens pour la version à six outils, cela fait ≈ 4k tokens. Ce chiffre est une estimation par proportion : le compte exact du serveur reste à relever. Le schéma de `fetch_content` est le plus lourd des sept, à cause des descriptions de ses quatre paramètres optionnels (`baseUrl`, `tables`, `strikethrough`, `taskList`). | |
| 372 | +Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 436). `fetch_content` ajoute 2 231 caractères à la requête, soit +17 %. Compte du serveur, le même jour (Mellum 12B, Docker Model Runner, `timings.prompt_n`) : 3 813 tokens pour la première question d'une session, question comprise. Le schéma de `fetch_content` est le plus lourd des sept, à cause des descriptions de ses quatre paramètres optionnels (`baseUrl`, `tables`, `strikethrough`, `taskList`). | |
| 372 | 373 | |
| 373 | 374 | *Proposition.* |
| 374 | 375 | |
| @@ -407,7 +408,7 @@ Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 43 | ||
| 407 | 408 | *Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite : |
| 408 | 409 | |
| 409 | 410 | * afficher le taux de cache KV ; |
| 410 | -* repérer ce qui casse la stabilité du préfixe (un prompt système qui change, un catalogue réordonné) ; | |
| 411 | +* repérer ce qui casse la stabilité du préfixe (un prompt système qui change, un catalogue réordonné) — c'est ainsi, avec les `timings` relevés par un proxy, qu'a été trouvé l'ordre aléatoire des outils (ticket 53) ; | |
| 411 | 412 | * alimenter `cachedReadTokens` en ACP. |
| 412 | 413 | |
| 413 | 414 | Sur une machine locale, un préfixe en cache fait la différence entre une réponse immédiate et plusieurs secondes de prefill. |
| @@ -486,4 +487,5 @@ Sur une machine locale, un préfixe en cache fait la différence entre une répo | ||
| 486 | 487 | | Sessions (fournisseur et modèle enregistrés) | `internal/store/store.go` |
| 487 | 488 | | Configurations par modèle | `config.samples/`, `tests/` |
| 488 | 489 | | Explications d'erreurs | `internal/engine/explain.go`, `internal/engine/openai_compat.go` |
| 490 | +| Ordre stable des outils (cache du serveur) | `internal/engine/tooldefs.go`, `tests/tool-order/` | |
| 489 | 491 | |=== |
| @@ -253,6 +253,7 @@ Un petit modèle choisit mal une procédure sur son seul nom. `internal/skills` | |||
| 253 | * *Erreurs expliquées en une ligne* : `Explain` traduit « connection refused » en « start it: llama-server … --jinja », signale l'absence de `--jinja` (nécessaire aux appels d'outils), et le 401 sur un serveur sans clé. | 253 | * *Erreurs expliquées en une ligne* : `Explain` traduit « connection refused » en « start it: llama-server … --jinja », signale l'absence de `--jinja` (nécessaire aux appels d'outils), et le 401 sur un serveur sans clé. |
| 254 | * *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine. | 254 | * *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine. |
| 255 | * *Lecture du prompt ≠ blocage* : avant son premier token, le serveur lit tout le prompt sans rien envoyer — plusieurs minutes pour 25k tokens sur un 27B local. `mm` demande `return_progress` à llama.cpp, lit les chunks `prompt_progress` pour alimenter le watchdog (et afficher « Reading the prompt 12.0k/25.3k tokens »), et accorde `firstTokenTimeout` (3 min) avant le premier token de chaque appel, `watchdogTimeout` ensuite (`internal/engine/progress.go`). Observé le 26/09/2026 : cinq questions de suite, « hello » compris, tuées en « stalled » par un watchdog de 30 s. | 255 | * *Lecture du prompt ≠ blocage* : avant son premier token, le serveur lit tout le prompt sans rien envoyer — plusieurs minutes pour 25k tokens sur un 27B local. `mm` demande `return_progress` à llama.cpp, lit les chunks `prompt_progress` pour alimenter le watchdog (et afficher « Reading the prompt 12.0k/25.3k tokens »), et accorde `firstTokenTimeout` (3 min) avant le premier token de chaque appel, `watchdogTimeout` ensuite (`internal/engine/progress.go`). Observé le 26/09/2026 : cinq questions de suite, « hello » compris, tuées en « stalled » par un watchdog de 30 s. |
| 256 | +* *Un préfixe stable* : llama.cpp ne relit que ce qui suit le plus long début commun avec la requête précédente. Or Genkit construit la liste des outils à partir d'une map Go, dans un ordre aléatoire : chaque question envoyait les outils dans un autre ordre, et le serveur relisait tout le prompt (ticket 53, toujours présent dans Genkit v1.13.1). Depuis le 30/09/2026, `stableTools` (`internal/engine/tooldefs.go`) les trie par nom avant chaque appel. Mesuré avec Mellum 12B sur Docker Model Runner : à partir de la deuxième question, ≈ 20 tokens relus en ≈ 250 ms, contre ≈ 3 400 à 3 900 tokens en 7 à 9 s chaque fois que l'ordre changeait. | ||
| 256 | * *Flux interrompu par le serveur* : llama.cpp signale une erreur en cours de génération par un événement SSE `error:`, qu'openai-go ignore (il ne restait que `unexpected end of JSON input`). `mm` récupère ce message (`internal/engine/servererr.go`) et relance *une fois* l'appel au modèle qui a échoué, sans rejouer les outils déjà exécutés dans le tour. Observé le 26/09/2026 : un tour perdu après vingt appels d'outils et un fichier écrit. | 257 | * *Flux interrompu par le serveur* : llama.cpp signale une erreur en cours de génération par un événement SSE `error:`, qu'openai-go ignore (il ne restait que `unexpected end of JSON input`). `mm` récupère ce message (`internal/engine/servererr.go`) et relance *une fois* l'appel au modèle qui a échoué, sans rejouer les outils déjà exécutés dans le tour. Observé le 26/09/2026 : un tour perdu après vingt appels d'outils et un fichier écrit. |
| 257 | 258 | ||
| 258 | == Une méthode : mesurer | 259 | == Une méthode : mesurer |
| @@ -368,7 +369,7 @@ Cette mesure date d'avant `fetch_content`. Le 30/09/2026, la première requête | |||
| 368 | | *Requête entière* (JSON) | | | *14 993* (12 762 sans `fetch_content`) | 369 | | *Requête entière* (JSON) | | | *14 993* (12 762 sans `fetch_content`) |
| 369 | |=== | 370 | |=== |
| 370 | 371 | ||
| 371 | -Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 436). `fetch_content` ajoute 2 231 caractères à la requête, soit +17 %. À ≈ 3,4k tokens pour la version à six outils, cela fait ≈ 4k tokens. Ce chiffre est une estimation par proportion : le compte exact du serveur reste à relever. Le schéma de `fetch_content` est le plus lourd des sept, à cause des descriptions de ses quatre paramètres optionnels (`baseUrl`, `tables`, `strikethrough`, `taskList`). | 372 | +Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 436). `fetch_content` ajoute 2 231 caractères à la requête, soit +17 %. Compte du serveur, le même jour (Mellum 12B, Docker Model Runner, `timings.prompt_n`) : 3 813 tokens pour la première question d'une session, question comprise. Le schéma de `fetch_content` est le plus lourd des sept, à cause des descriptions de ses quatre paramètres optionnels (`baseUrl`, `tables`, `strikethrough`, `taskList`). |
| 372 | 373 | ||
| 373 | *Proposition.* | 374 | *Proposition.* |
| 374 | 375 | ||
| @@ -407,7 +408,7 @@ Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 43 | |||
| 407 | *Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite : | 408 | *Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite : |
| 408 | 409 | ||
| 409 | * afficher le taux de cache KV ; | 410 | * afficher le taux de cache KV ; |
| 410 | -* repérer ce qui casse la stabilité du préfixe (un prompt système qui change, un catalogue réordonné) ; | 411 | +* repérer ce qui casse la stabilité du préfixe (un prompt système qui change, un catalogue réordonné) — c'est ainsi, avec les `timings` relevés par un proxy, qu'a été trouvé l'ordre aléatoire des outils (ticket 53) ; |
| 411 | * alimenter `cachedReadTokens` en ACP. | 412 | * alimenter `cachedReadTokens` en ACP. |
| 412 | 413 | ||
| 413 | Sur une machine locale, un préfixe en cache fait la différence entre une réponse immédiate et plusieurs secondes de prefill. | 414 | Sur une machine locale, un préfixe en cache fait la différence entre une réponse immédiate et plusieurs secondes de prefill. |
| @@ -486,4 +487,5 @@ Sur une machine locale, un préfixe en cache fait la différence entre une répo | |||
| 486 | | Sessions (fournisseur et modèle enregistrés) | `internal/store/store.go` | 487 | | Sessions (fournisseur et modèle enregistrés) | `internal/store/store.go` |
| 487 | | Configurations par modèle | `config.samples/`, `tests/` | 488 | | Configurations par modèle | `config.samples/`, `tests/` |
| 488 | | Explications d'erreurs | `internal/engine/explain.go`, `internal/engine/openai_compat.go` | 489 | | Explications d'erreurs | `internal/engine/explain.go`, `internal/engine/openai_compat.go` |
| 490 | +| Ordre stable des outils (cache du serveur) | `internal/engine/tooldefs.go`, `tests/tool-order/` | ||
| 489 | |=== | 491 | |=== |
modified
docs/fr/explanation/providers.md +5 -0 | @@ -20,14 +20,19 @@ | ||
| 20 | 20 | |
| 21 | 21 | **Un flux que le serveur interrompt est relancé, et ses mots sont gardés.** llama.cpp signale un échec au milieu d'une réponse diffusée par un événement SSE `error:` (le statut 200 est déjà parti). openai-go ne lit que les lignes `data:` : le message disparaissait derrière `unexpected end of JSON input`. Le tap qui lit déjà le flux pour le raisonnement récupère aussi ces événements, et l'appel au modèle qui a échoué est relancé une fois — cet appel seulement : les outils déjà exécutés dans le tour ne sont pas rejoués, et le texte déjà diffusé par la tentative interrompue n'est pas retiré. S'il échoue encore, l'erreur donne le message du serveur (`the model server reported an error while generating: Failed to parse input at pos …`), ou indique que la connexion a été coupée. Dans les deux cas l'historique est conservé : demander de continuer reprend le travail. |
| 22 | 22 | |
| 23 | +**Les outils partent dans le même ordre à chaque requête.** Un serveur llama.cpp garde le prompt qu'il vient de lire et, à la requête suivante, ne relit que ce qui a changé après le plus long début commun. Le template de chat place les outils vers le haut — dans le bloc système pour les templates de type Qwen — donc leur ordre décide de la part de l'historique qui survit. Genkit construit la liste des outils de la requête à partir d'une map Go, dont l'ordre est aléatoire : chaque question d'une session envoyait les mêmes outils dans un autre ordre, et le serveur relisait le prompt entier (ticket 53 ; toujours le cas dans Genkit v1.13.1). `mm` trie désormais les outils par nom juste avant chaque appel au modèle. Mesuré avec Mellum 12B sur Docker Model Runner : à partir de la deuxième question, une question lit une vingtaine de nouveaux tokens en un quart de seconde, là où elle relisait environ 3 400 tokens en sept secondes chaque fois que l'ordre changeait. | |
| 24 | + | |
| 23 | 25 | ## Alternatives écartées |
| 24 | 26 | |
| 25 | 27 | - **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants. |
| 26 | 28 | - **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré. |
| 27 | 29 | - **Échouer au démarrage quand le serveur est arrêté.** Écarté parce que démarrer le serveur en second est la situation normale d'une démo. |
| 30 | +- **Garder l'ordre dans lequel `main.go` déclare les outils.** Écarté : Genkit perd cet ordre avant de construire la requête, et un ordre qui dépend de la déclaration changerait de toute façon avec la configuration et les serveurs MCP. Le nom est la seule clé qui ne dépend que des outils eux-mêmes. | |
| 31 | +- **Attendre une version de Genkit qui corrige le problème.** Écarté pour l'instant : la v1.13.1 et la branche principale construisent toujours la liste à partir d'une map. | |
| 28 | 32 | |
| 29 | 33 | ## Liens avec le reste |
| 30 | 34 | |
| 31 | 35 | - Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md). |
| 32 | 36 | - La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure. |
| 33 | 37 | - Changer de serveur pour une exécution est couvert dans [comment faire tourner sur llama.cpp](../how-to/run-with-llama-cpp.md). |
| 38 | +- Vérifier l'ordre des outils et mesurer le gain de cache sur votre propre serveur : [vérifier que les outils partent dans un ordre stable](../how-to/verify-tool-order.md). | |
| @@ -20,14 +20,19 @@ | |||
| 20 | 20 | ||
| 21 | **Un flux que le serveur interrompt est relancé, et ses mots sont gardés.** llama.cpp signale un échec au milieu d'une réponse diffusée par un événement SSE `error:` (le statut 200 est déjà parti). openai-go ne lit que les lignes `data:` : le message disparaissait derrière `unexpected end of JSON input`. Le tap qui lit déjà le flux pour le raisonnement récupère aussi ces événements, et l'appel au modèle qui a échoué est relancé une fois — cet appel seulement : les outils déjà exécutés dans le tour ne sont pas rejoués, et le texte déjà diffusé par la tentative interrompue n'est pas retiré. S'il échoue encore, l'erreur donne le message du serveur (`the model server reported an error while generating: Failed to parse input at pos …`), ou indique que la connexion a été coupée. Dans les deux cas l'historique est conservé : demander de continuer reprend le travail. | 21 | **Un flux que le serveur interrompt est relancé, et ses mots sont gardés.** llama.cpp signale un échec au milieu d'une réponse diffusée par un événement SSE `error:` (le statut 200 est déjà parti). openai-go ne lit que les lignes `data:` : le message disparaissait derrière `unexpected end of JSON input`. Le tap qui lit déjà le flux pour le raisonnement récupère aussi ces événements, et l'appel au modèle qui a échoué est relancé une fois — cet appel seulement : les outils déjà exécutés dans le tour ne sont pas rejoués, et le texte déjà diffusé par la tentative interrompue n'est pas retiré. S'il échoue encore, l'erreur donne le message du serveur (`the model server reported an error while generating: Failed to parse input at pos …`), ou indique que la connexion a été coupée. Dans les deux cas l'historique est conservé : demander de continuer reprend le travail. |
| 22 | 22 | ||
| 23 | +**Les outils partent dans le même ordre à chaque requête.** Un serveur llama.cpp garde le prompt qu'il vient de lire et, à la requête suivante, ne relit que ce qui a changé après le plus long début commun. Le template de chat place les outils vers le haut — dans le bloc système pour les templates de type Qwen — donc leur ordre décide de la part de l'historique qui survit. Genkit construit la liste des outils de la requête à partir d'une map Go, dont l'ordre est aléatoire : chaque question d'une session envoyait les mêmes outils dans un autre ordre, et le serveur relisait le prompt entier (ticket 53 ; toujours le cas dans Genkit v1.13.1). `mm` trie désormais les outils par nom juste avant chaque appel au modèle. Mesuré avec Mellum 12B sur Docker Model Runner : à partir de la deuxième question, une question lit une vingtaine de nouveaux tokens en un quart de seconde, là où elle relisait environ 3 400 tokens en sept secondes chaque fois que l'ordre changeait. | ||
| 24 | + | ||
| 23 | ## Alternatives écartées | 25 | ## Alternatives écartées |
| 24 | 26 | ||
| 25 | - **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants. | 27 | - **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants. |
| 26 | - **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré. | 28 | - **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré. |
| 27 | - **Échouer au démarrage quand le serveur est arrêté.** Écarté parce que démarrer le serveur en second est la situation normale d'une démo. | 29 | - **Échouer au démarrage quand le serveur est arrêté.** Écarté parce que démarrer le serveur en second est la situation normale d'une démo. |
| 30 | +- **Garder l'ordre dans lequel `main.go` déclare les outils.** Écarté : Genkit perd cet ordre avant de construire la requête, et un ordre qui dépend de la déclaration changerait de toute façon avec la configuration et les serveurs MCP. Le nom est la seule clé qui ne dépend que des outils eux-mêmes. | ||
| 31 | +- **Attendre une version de Genkit qui corrige le problème.** Écarté pour l'instant : la v1.13.1 et la branche principale construisent toujours la liste à partir d'une map. | ||
| 28 | 32 | ||
| 29 | ## Liens avec le reste | 33 | ## Liens avec le reste |
| 30 | 34 | ||
| 31 | - Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md). | 35 | - Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md). |
| 32 | - La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure. | 36 | - La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure. |
| 33 | - Changer de serveur pour une exécution est couvert dans [comment faire tourner sur llama.cpp](../how-to/run-with-llama-cpp.md). | 37 | - Changer de serveur pour une exécution est couvert dans [comment faire tourner sur llama.cpp](../how-to/run-with-llama-cpp.md). |
| 38 | +- Vérifier l'ordre des outils et mesurer le gain de cache sur votre propre serveur : [vérifier que les outils partent dans un ordre stable](../how-to/verify-tool-order.md). | ||
added
docs/fr/how-to/verify-tool-order.md +131 -0 | new file mode 100644 | ||
| @@ -0,0 +1,131 @@ | ||
| 1 | +# Vérifier que les outils partent dans un ordre stable | |
| 2 | + | |
| 3 | +Depuis le ticket 53, `mm` envoie les outils de chaque requête triés par nom, pour qu'un serveur llama.cpp puisse réutiliser le prompt qu'il a mis en cache à la question précédente. Ce guide montre comment vérifier cette modification : rien n'a régressé, l'ordre est stable, et combien de lecture de prompt elle économise sur votre serveur et votre modèle. | |
| 4 | + | |
| 5 | +Il suppose un clone de `mm`, la chaîne d'outils Go, `python3`, et un Docker Model Runner ou un `llama-server` lancé avec un modèle qui sait appeler des outils. | |
| 6 | + | |
| 7 | +## 1. Lancer la suite de tests | |
| 8 | + | |
| 9 | +Depuis la racine du dépôt : | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +go test ./... | |
| 13 | +``` | |
| 14 | + | |
| 15 | +Chaque paquet doit répondre `ok`. Lancez ensuite les tests de cette modification seuls, plusieurs fois — le bug était aléatoire, un seul passage au vert ne prouve pas grand-chose : | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +go test ./internal/engine -run 'TestToolsGoOut|TestStableTools' -count=10 -v | |
| 19 | +``` | |
| 20 | + | |
| 21 | +| Test | Ce qu'il vérifie | | |
| 22 | +|------|------------------| | |
| 23 | +| `TestToolsGoOutInTheSameOrderOnEveryQuestion` | Douze questions à travers le vrai fournisseur `llamacpp`, face à un faux serveur qui enregistre les requêtes : chacune porte les sept outils triés par nom. | | |
| 24 | +| `TestStableToolsSortsByName` | La liste sort triée. | | |
| 25 | +| `TestStableToolsLeavesTheOriginalAlone` | La tranche qui appartient à Genkit n'est pas réordonnée sur place. | | |
| 26 | +| `TestStableToolsIgnoresTheIncomingOrder` | Toutes les rotations des mêmes outils donnent la même liste. | | |
| 27 | +| `TestStableToolsWithNoneOrOne` | Une requête sans outils (la requête de résumé de `/compact`) n'est pas modifiée. | | |
| 28 | + | |
| 29 | +Pour voir le premier test attraper le bug, retirez la ligne `params.Request.Tools = stableTools(params.Request.Tools)` de `internal/engine/engine.go` et relancez-le : il échoue avec des lignes comme `question 2 sent the tools as [plan fetch_content bash …], want [bash edit_file …]`. Remettez la ligne. | |
| 30 | + | |
| 31 | +## 2. Construire le binaire d'avant et celui d'après | |
| 32 | + | |
| 33 | +```bash | |
| 34 | +go build -o /tmp/mm-after . | |
| 35 | +git worktree add /tmp/mm-before-src 1fa0c42 | |
| 36 | +(cd /tmp/mm-before-src && go build -o /tmp/mm-before .) | |
| 37 | +git worktree remove /tmp/mm-before-src | |
| 38 | +``` | |
| 39 | + | |
| 40 | +`1fa0c42` est le dernier commit sans le correctif ; n'importe quel commit plus ancien convient aussi. | |
| 41 | + | |
| 42 | +## 3. Lancer le proxy d'enregistrement | |
| 43 | + | |
| 44 | +Dans un second terminal, lancez `tests/tool-order/proxy.py` devant le serveur de modèles : | |
| 45 | + | |
| 46 | +```bash | |
| 47 | +python3 tests/tool-order/proxy.py --port 18770 --upstream http://localhost:12434 | |
| 48 | +``` | |
| 49 | + | |
| 50 | +Il affiche `proxy on http://127.0.0.1:18770 -> http://localhost:12434`, puis une ligne par requête dès que `mm` interroge le modèle. | |
| 51 | + | |
| 52 | +## 4. Préparer une configuration qui passe par le proxy | |
| 53 | + | |
| 54 | +```bash | |
| 55 | +sed -e 's#^baseUrl:.*#baseUrl: http://127.0.0.1:18770/engines/v1#' \ | |
| 56 | + -e 's#^fallback:.*#fallback: ""#' \ | |
| 57 | + -e "s#^skillsDir:.*#skillsDir: $PWD/skills#" \ | |
| 58 | + agent.dmr.yaml > /tmp/agent.proxy.yaml | |
| 59 | +``` | |
| 60 | + | |
| 61 | +`fallback: ""` garde `mm` sur le proxy même si la sonde est lente. Le dossier des skills est rendu absolu parce qu'en mode éditeur un chemin relatif est lu depuis le dossier du fichier de configuration : sans cela, `read_skill` manquerait et la requête ne serait pas celle de tous les jours. | |
| 62 | + | |
| 63 | +## 5. Poser les mêmes questions avec chaque binaire | |
| 64 | + | |
| 65 | +`tests/tool-order/session.py` pose plusieurs questions dans une même session, par le protocole des éditeurs (`mm -acp`) : | |
| 66 | + | |
| 67 | +```bash | |
| 68 | +python3 tests/tool-order/session.py /tmp/mm-before /tmp/agent.proxy.yaml \ | |
| 69 | + "Answer in one short sentence: what is Go?" \ | |
| 70 | + "Answer in one short sentence: what is Rust?" \ | |
| 71 | + "Answer in one short sentence: what is Zig?" \ | |
| 72 | + "Answer in one short sentence: what is Odin?" | |
| 73 | +``` | |
| 74 | + | |
| 75 | +Chaque question affiche `question N: {'stopReason': 'end_turn', …}`. Relancez la même commande avec `/tmp/mm-after`. | |
| 76 | + | |
| 77 | +Gardez des questions courtes, qui ne modifient aucun fichier : une question qui fait écrire un fichier au modèle demande une autorisation à l'éditeur, et le script n'a personne pour la donner. | |
| 78 | + | |
| 79 | +## 6. Lire les lignes du proxy | |
| 80 | + | |
| 81 | +Chaque ligne correspond à une requête : | |
| 82 | + | |
| 83 | +``` | |
| 84 | +#6 tools=f2f7a1fa ['bash', 'edit_file', 'fetch_content', …] cache_n=3381 prompt_n=20 prompt_ms=253 | |
| 85 | +``` | |
| 86 | + | |
| 87 | +| Champ | Signification | | |
| 88 | +|-------|---------------| | |
| 89 | +| `tools=` | Une empreinte du bloc des outils, tel qu'il a été envoyé. | | |
| 90 | +| `[…]` | Les noms des outils, dans l'ordre d'envoi. | | |
| 91 | +| `cache_n` | Les tokens du prompt repris du cache du serveur. | | |
| 92 | +| `prompt_n` | Les tokens du prompt qu'il a dû relire. | | |
| 93 | +| `prompt_ms` | Le temps passé à les relire. | | |
| 94 | + | |
| 95 | +Ce qu'il faut regarder : | |
| 96 | + | |
| 97 | +- **Après la modification**, `tools=` a la même valeur sur toutes les lignes, et à partir de la deuxième question `prompt_n` reste petit (la nouvelle question et la réponse précédente) pendant que `cache_n` couvre le reste du prompt. | |
| 98 | +- **Avant la modification**, `tools=` change d'une question à l'autre, et à chaque changement `prompt_n` remonte à la taille du prompt entier. | |
| 99 | + | |
| 100 | +La première question d'une session lit le prompt entier dans les deux cas, sauf si le serveur garde encore le même préfixe d'une session précédente. | |
| 101 | + | |
| 102 | +Mesuré le 30/09/2026 avec Mellum 12B sur Docker Model Runner, quatre questions, `agent.dmr.yaml` (six outils, skills non chargés) : | |
| 103 | + | |
| 104 | +| Question | Avant : `prompt_n` / `prompt_ms` | Après : `prompt_n` / `prompt_ms` | | |
| 105 | +|----------|------------------|------------------| | |
| 106 | +| 1 | 3355 / 6776 | 3355 / 7141 | | |
| 107 | +| 2 | 3401 / 7186 | 20 / 253 | | |
| 108 | +| 3 | 107 / 713 (l'ordre coïncidait par hasard avec la question 1) | 20 / 257 | | |
| 109 | +| 4 | 110 / 707 | 20 / 252 | | |
| 110 | + | |
| 111 | +Le gain grandit avec la session : avant la modification, une question dont l'ordre des outils change relit tout l'historique, pas seulement les outils. | |
| 112 | + | |
| 113 | +## 7. Vérifier le reste à la main | |
| 114 | + | |
| 115 | +La modification touche toutes les requêtes : vérifiez une fois que l'usage ordinaire fonctionne toujours avec le nouveau binaire et votre configuration habituelle. | |
| 116 | + | |
| 117 | +- Une question qui demande un outil : `/tmp/mm-after -config agent.dmr.yaml -p "list the files of this directory"` lance `bash` et répond. | |
| 118 | +- Une longue session qui se compresse : `/compact` écrit toujours un résumé (sa requête n'a pas d'outils et n'est pas touchée). | |
| 119 | +- Un serveur MCP, si vous en utilisez un : ses outils sont triés avec les outils intégrés et toujours appelés par leur nom. | |
| 120 | + | |
| 121 | +## Variantes | |
| 122 | + | |
| 123 | +- **llama-server** : lancez le proxy avec `--upstream http://localhost:8080`, et partez de `agent.llamacpp.yaml` avec `baseUrl: http://127.0.0.1:18770/v1`. | |
| 124 | +- **`mm` ou le serveur dans un conteneur** : utilisez `--upstream http://host.docker.internal:12434` pour un Docker Model Runner sur l'hôte. | |
| 125 | +- **Un autre modèle** : la taille des prompts change, pas le schéma. Les chiffres ci-dessus sont un exemple, pas un objectif. | |
| 126 | + | |
| 127 | +## Voir aussi | |
| 128 | + | |
| 129 | +- Pourquoi l'ordre compte : [fournisseurs — explication](../explanation/providers.md) | |
| 130 | +- Les clés de configuration utilisées ici : [référence de la configuration](../reference/configuration.md) | |
| 131 | +- Poser une seule question : [exécuter une invite unique](executer-une-invite-unique.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,131 @@ | |||
| 1 | +# Vérifier que les outils partent dans un ordre stable | ||
| 2 | + | ||
| 3 | +Depuis le ticket 53, `mm` envoie les outils de chaque requête triés par nom, pour qu'un serveur llama.cpp puisse réutiliser le prompt qu'il a mis en cache à la question précédente. Ce guide montre comment vérifier cette modification : rien n'a régressé, l'ordre est stable, et combien de lecture de prompt elle économise sur votre serveur et votre modèle. | ||
| 4 | + | ||
| 5 | +Il suppose un clone de `mm`, la chaîne d'outils Go, `python3`, et un Docker Model Runner ou un `llama-server` lancé avec un modèle qui sait appeler des outils. | ||
| 6 | + | ||
| 7 | +## 1. Lancer la suite de tests | ||
| 8 | + | ||
| 9 | +Depuis la racine du dépôt : | ||
| 10 | + | ||
| 11 | +```bash | ||
| 12 | +go test ./... | ||
| 13 | +``` | ||
| 14 | + | ||
| 15 | +Chaque paquet doit répondre `ok`. Lancez ensuite les tests de cette modification seuls, plusieurs fois — le bug était aléatoire, un seul passage au vert ne prouve pas grand-chose : | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +go test ./internal/engine -run 'TestToolsGoOut|TestStableTools' -count=10 -v | ||
| 19 | +``` | ||
| 20 | + | ||
| 21 | +| Test | Ce qu'il vérifie | | ||
| 22 | +|------|------------------| | ||
| 23 | +| `TestToolsGoOutInTheSameOrderOnEveryQuestion` | Douze questions à travers le vrai fournisseur `llamacpp`, face à un faux serveur qui enregistre les requêtes : chacune porte les sept outils triés par nom. | | ||
| 24 | +| `TestStableToolsSortsByName` | La liste sort triée. | | ||
| 25 | +| `TestStableToolsLeavesTheOriginalAlone` | La tranche qui appartient à Genkit n'est pas réordonnée sur place. | | ||
| 26 | +| `TestStableToolsIgnoresTheIncomingOrder` | Toutes les rotations des mêmes outils donnent la même liste. | | ||
| 27 | +| `TestStableToolsWithNoneOrOne` | Une requête sans outils (la requête de résumé de `/compact`) n'est pas modifiée. | | ||
| 28 | + | ||
| 29 | +Pour voir le premier test attraper le bug, retirez la ligne `params.Request.Tools = stableTools(params.Request.Tools)` de `internal/engine/engine.go` et relancez-le : il échoue avec des lignes comme `question 2 sent the tools as [plan fetch_content bash …], want [bash edit_file …]`. Remettez la ligne. | ||
| 30 | + | ||
| 31 | +## 2. Construire le binaire d'avant et celui d'après | ||
| 32 | + | ||
| 33 | +```bash | ||
| 34 | +go build -o /tmp/mm-after . | ||
| 35 | +git worktree add /tmp/mm-before-src 1fa0c42 | ||
| 36 | +(cd /tmp/mm-before-src && go build -o /tmp/mm-before .) | ||
| 37 | +git worktree remove /tmp/mm-before-src | ||
| 38 | +``` | ||
| 39 | + | ||
| 40 | +`1fa0c42` est le dernier commit sans le correctif ; n'importe quel commit plus ancien convient aussi. | ||
| 41 | + | ||
| 42 | +## 3. Lancer le proxy d'enregistrement | ||
| 43 | + | ||
| 44 | +Dans un second terminal, lancez `tests/tool-order/proxy.py` devant le serveur de modèles : | ||
| 45 | + | ||
| 46 | +```bash | ||
| 47 | +python3 tests/tool-order/proxy.py --port 18770 --upstream http://localhost:12434 | ||
| 48 | +``` | ||
| 49 | + | ||
| 50 | +Il affiche `proxy on http://127.0.0.1:18770 -> http://localhost:12434`, puis une ligne par requête dès que `mm` interroge le modèle. | ||
| 51 | + | ||
| 52 | +## 4. Préparer une configuration qui passe par le proxy | ||
| 53 | + | ||
| 54 | +```bash | ||
| 55 | +sed -e 's#^baseUrl:.*#baseUrl: http://127.0.0.1:18770/engines/v1#' \ | ||
| 56 | + -e 's#^fallback:.*#fallback: ""#' \ | ||
| 57 | + -e "s#^skillsDir:.*#skillsDir: $PWD/skills#" \ | ||
| 58 | + agent.dmr.yaml > /tmp/agent.proxy.yaml | ||
| 59 | +``` | ||
| 60 | + | ||
| 61 | +`fallback: ""` garde `mm` sur le proxy même si la sonde est lente. Le dossier des skills est rendu absolu parce qu'en mode éditeur un chemin relatif est lu depuis le dossier du fichier de configuration : sans cela, `read_skill` manquerait et la requête ne serait pas celle de tous les jours. | ||
| 62 | + | ||
| 63 | +## 5. Poser les mêmes questions avec chaque binaire | ||
| 64 | + | ||
| 65 | +`tests/tool-order/session.py` pose plusieurs questions dans une même session, par le protocole des éditeurs (`mm -acp`) : | ||
| 66 | + | ||
| 67 | +```bash | ||
| 68 | +python3 tests/tool-order/session.py /tmp/mm-before /tmp/agent.proxy.yaml \ | ||
| 69 | + "Answer in one short sentence: what is Go?" \ | ||
| 70 | + "Answer in one short sentence: what is Rust?" \ | ||
| 71 | + "Answer in one short sentence: what is Zig?" \ | ||
| 72 | + "Answer in one short sentence: what is Odin?" | ||
| 73 | +``` | ||
| 74 | + | ||
| 75 | +Chaque question affiche `question N: {'stopReason': 'end_turn', …}`. Relancez la même commande avec `/tmp/mm-after`. | ||
| 76 | + | ||
| 77 | +Gardez des questions courtes, qui ne modifient aucun fichier : une question qui fait écrire un fichier au modèle demande une autorisation à l'éditeur, et le script n'a personne pour la donner. | ||
| 78 | + | ||
| 79 | +## 6. Lire les lignes du proxy | ||
| 80 | + | ||
| 81 | +Chaque ligne correspond à une requête : | ||
| 82 | + | ||
| 83 | +``` | ||
| 84 | +#6 tools=f2f7a1fa ['bash', 'edit_file', 'fetch_content', …] cache_n=3381 prompt_n=20 prompt_ms=253 | ||
| 85 | +``` | ||
| 86 | + | ||
| 87 | +| Champ | Signification | | ||
| 88 | +|-------|---------------| | ||
| 89 | +| `tools=` | Une empreinte du bloc des outils, tel qu'il a été envoyé. | | ||
| 90 | +| `[…]` | Les noms des outils, dans l'ordre d'envoi. | | ||
| 91 | +| `cache_n` | Les tokens du prompt repris du cache du serveur. | | ||
| 92 | +| `prompt_n` | Les tokens du prompt qu'il a dû relire. | | ||
| 93 | +| `prompt_ms` | Le temps passé à les relire. | | ||
| 94 | + | ||
| 95 | +Ce qu'il faut regarder : | ||
| 96 | + | ||
| 97 | +- **Après la modification**, `tools=` a la même valeur sur toutes les lignes, et à partir de la deuxième question `prompt_n` reste petit (la nouvelle question et la réponse précédente) pendant que `cache_n` couvre le reste du prompt. | ||
| 98 | +- **Avant la modification**, `tools=` change d'une question à l'autre, et à chaque changement `prompt_n` remonte à la taille du prompt entier. | ||
| 99 | + | ||
| 100 | +La première question d'une session lit le prompt entier dans les deux cas, sauf si le serveur garde encore le même préfixe d'une session précédente. | ||
| 101 | + | ||
| 102 | +Mesuré le 30/09/2026 avec Mellum 12B sur Docker Model Runner, quatre questions, `agent.dmr.yaml` (six outils, skills non chargés) : | ||
| 103 | + | ||
| 104 | +| Question | Avant : `prompt_n` / `prompt_ms` | Après : `prompt_n` / `prompt_ms` | | ||
| 105 | +|----------|------------------|------------------| | ||
| 106 | +| 1 | 3355 / 6776 | 3355 / 7141 | | ||
| 107 | +| 2 | 3401 / 7186 | 20 / 253 | | ||
| 108 | +| 3 | 107 / 713 (l'ordre coïncidait par hasard avec la question 1) | 20 / 257 | | ||
| 109 | +| 4 | 110 / 707 | 20 / 252 | | ||
| 110 | + | ||
| 111 | +Le gain grandit avec la session : avant la modification, une question dont l'ordre des outils change relit tout l'historique, pas seulement les outils. | ||
| 112 | + | ||
| 113 | +## 7. Vérifier le reste à la main | ||
| 114 | + | ||
| 115 | +La modification touche toutes les requêtes : vérifiez une fois que l'usage ordinaire fonctionne toujours avec le nouveau binaire et votre configuration habituelle. | ||
| 116 | + | ||
| 117 | +- Une question qui demande un outil : `/tmp/mm-after -config agent.dmr.yaml -p "list the files of this directory"` lance `bash` et répond. | ||
| 118 | +- Une longue session qui se compresse : `/compact` écrit toujours un résumé (sa requête n'a pas d'outils et n'est pas touchée). | ||
| 119 | +- Un serveur MCP, si vous en utilisez un : ses outils sont triés avec les outils intégrés et toujours appelés par leur nom. | ||
| 120 | + | ||
| 121 | +## Variantes | ||
| 122 | + | ||
| 123 | +- **llama-server** : lancez le proxy avec `--upstream http://localhost:8080`, et partez de `agent.llamacpp.yaml` avec `baseUrl: http://127.0.0.1:18770/v1`. | ||
| 124 | +- **`mm` ou le serveur dans un conteneur** : utilisez `--upstream http://host.docker.internal:12434` pour un Docker Model Runner sur l'hôte. | ||
| 125 | +- **Un autre modèle** : la taille des prompts change, pas le schéma. Les chiffres ci-dessus sont un exemple, pas un objectif. | ||
| 126 | + | ||
| 127 | +## Voir aussi | ||
| 128 | + | ||
| 129 | +- Pourquoi l'ordre compte : [fournisseurs — explication](../explanation/providers.md) | ||
| 130 | +- Les clés de configuration utilisées ici : [référence de la configuration](../reference/configuration.md) | ||
| 131 | +- Poser une seule question : [exécuter une invite unique](executer-une-invite-unique.md) | ||
modified
internal/engine/engine.go +3 -0 | @@ -265,6 +265,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 265 | 265 | capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { |
| 266 | 266 | return &ai.Hooks{ |
| 267 | 267 | WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { |
| 268 | + // Same tools, same order, on every call: the server's prompt | |
| 269 | + // cache depends on it (tooldefs.go, ticket 53). | |
| 270 | + params.Request.Tools = stableTools(params.Request.Tools) | |
| 268 | 271 | full = slices.Clone(params.Request.Messages) |
| 269 | 272 | pl.expect() |
| 270 | 273 | resp, err := next(ctx, params) |
| @@ -265,6 +265,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | |||
| 265 | capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { | 265 | capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { |
| 266 | return &ai.Hooks{ | 266 | return &ai.Hooks{ |
| 267 | WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { | 267 | WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { |
| 268 | + // Same tools, same order, on every call: the server's prompt | ||
| 269 | + // cache depends on it (tooldefs.go, ticket 53). | ||
| 270 | + params.Request.Tools = stableTools(params.Request.Tools) | ||
| 268 | full = slices.Clone(params.Request.Messages) | 271 | full = slices.Clone(params.Request.Messages) |
| 269 | pl.expect() | 272 | pl.expect() |
| 270 | resp, err := next(ctx, params) | 273 | resp, err := next(ctx, params) |
added
internal/engine/tooldefs.go +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "cmp" | |
| 5 | + "slices" | |
| 6 | + | |
| 7 | + "github.com/firebase/genkit/go/ai" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// The tools of a request must go out in the same order on every model call. | |
| 11 | +// | |
| 12 | +// Genkit (v1.10, and still v1.13.1) builds the request's tool list by ranging | |
| 13 | +// over a map (ai/generate.go, GenerateWithRequest), and Go randomises map | |
| 14 | +// order: each question of a session sent the same seven tools in another | |
| 15 | +// order (ticket 53, seen 2026-09-30 through a server that recorded mm's | |
| 16 | +// requests). A llama.cpp server reuses its KV cache only for the part of the | |
| 17 | +// prompt that did not change since the previous request; chat templates render | |
| 18 | +// the tools near the top — in the system block for Qwen-style templates — so a | |
| 19 | +// new order made the server read again the tools and the whole history after | |
| 20 | +// them, on every question. | |
| 21 | +// | |
| 22 | +// Sorting by name is the order that depends on nothing but the tools | |
| 23 | +// themselves: the same set of tools gives the same prompt prefix, whatever | |
| 24 | +// the order main.go or an MCP server declared them in. | |
| 25 | + | |
| 26 | +// stableTools returns the tool definitions sorted by name. It sorts a copy: | |
| 27 | +// the slice belongs to Genkit. | |
| 28 | +// | |
| 29 | +// params.Request.Tools = stableTools(params.Request.Tools) | |
| 30 | +func stableTools(defs []*ai.ToolDefinition) []*ai.ToolDefinition { | |
| 31 | + if len(defs) < 2 { | |
| 32 | + return defs | |
| 33 | + } | |
| 34 | + return slices.SortedFunc(slices.Values(defs), func(a, b *ai.ToolDefinition) int { | |
| 35 | + return cmp.Compare(a.Name, b.Name) | |
| 36 | + }) | |
| 37 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "cmp" | ||
| 5 | + "slices" | ||
| 6 | + | ||
| 7 | + "github.com/firebase/genkit/go/ai" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// The tools of a request must go out in the same order on every model call. | ||
| 11 | +// | ||
| 12 | +// Genkit (v1.10, and still v1.13.1) builds the request's tool list by ranging | ||
| 13 | +// over a map (ai/generate.go, GenerateWithRequest), and Go randomises map | ||
| 14 | +// order: each question of a session sent the same seven tools in another | ||
| 15 | +// order (ticket 53, seen 2026-09-30 through a server that recorded mm's | ||
| 16 | +// requests). A llama.cpp server reuses its KV cache only for the part of the | ||
| 17 | +// prompt that did not change since the previous request; chat templates render | ||
| 18 | +// the tools near the top — in the system block for Qwen-style templates — so a | ||
| 19 | +// new order made the server read again the tools and the whole history after | ||
| 20 | +// them, on every question. | ||
| 21 | +// | ||
| 22 | +// Sorting by name is the order that depends on nothing but the tools | ||
| 23 | +// themselves: the same set of tools gives the same prompt prefix, whatever | ||
| 24 | +// the order main.go or an MCP server declared them in. | ||
| 25 | + | ||
| 26 | +// stableTools returns the tool definitions sorted by name. It sorts a copy: | ||
| 27 | +// the slice belongs to Genkit. | ||
| 28 | +// | ||
| 29 | +// params.Request.Tools = stableTools(params.Request.Tools) | ||
| 30 | +func stableTools(defs []*ai.ToolDefinition) []*ai.ToolDefinition { | ||
| 31 | + if len(defs) < 2 { | ||
| 32 | + return defs | ||
| 33 | + } | ||
| 34 | + return slices.SortedFunc(slices.Values(defs), func(a, b *ai.ToolDefinition) int { | ||
| 35 | + return cmp.Compare(a.Name, b.Name) | ||
| 36 | + }) | ||
| 37 | +} | ||
added
internal/engine/tooldefs_test.go +128 -0 | new file mode 100644 | ||
| @@ -0,0 +1,128 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "slices" | |
| 6 | + "testing" | |
| 7 | + | |
| 8 | + "github.com/firebase/genkit/go/ai" | |
| 9 | + "github.com/firebase/genkit/go/genkit" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// sevenTools declares tools named like mm's own, in the order main.go | |
| 13 | +// declares them — which is NOT alphabetical, so a test that only checks | |
| 14 | +// "same order as declared" could not tell the fix from luck. | |
| 15 | +func sevenTools(g *genkit.Genkit) []ai.ToolRef { | |
| 16 | + names := []string{"bash", "read_skill", "read_file", "write_file", "edit_file", "plan", "fetch_content"} | |
| 17 | + refs := make([]ai.ToolRef, 0, len(names)) | |
| 18 | + for _, name := range names { | |
| 19 | + refs = append(refs, genkit.DefineTool(g, name, "the "+name+" tool", | |
| 20 | + func(_ *ai.ToolContext, in struct { | |
| 21 | + Input string `json:"input"` | |
| 22 | + }) (string, error) { | |
| 23 | + return in.Input, nil | |
| 24 | + })) | |
| 25 | + } | |
| 26 | + return refs | |
| 27 | +} | |
| 28 | + | |
| 29 | +// toolNamesOf reads the names of the `tools` array of a recorded request, in | |
| 30 | +// the order the server received them. | |
| 31 | +func toolNamesOf(t *testing.T, body map[string]any) []string { | |
| 32 | + t.Helper() | |
| 33 | + tools, _ := body["tools"].([]any) | |
| 34 | + names := make([]string, 0, len(tools)) | |
| 35 | + for _, tool := range tools { | |
| 36 | + fn, _ := tool.(map[string]any)["function"].(map[string]any) | |
| 37 | + name, _ := fn["name"].(string) | |
| 38 | + names = append(names, name) | |
| 39 | + } | |
| 40 | + return names | |
| 41 | +} | |
| 42 | + | |
| 43 | +// Ticket 53: Genkit builds the request's tool list from a map, so each | |
| 44 | +// question of a session sent the tools in another order, and a llama.cpp | |
| 45 | +// server could not reuse the prompt it had cached. Every request of a | |
| 46 | +// session must carry the same list, sorted by name. | |
| 47 | +// | |
| 48 | +// Several questions, because one could come out sorted by chance: with seven | |
| 49 | +// tools, Genkit's order is a random rotation, sorted once in seven. | |
| 50 | +func TestToolsGoOutInTheSameOrderOnEveryQuestion(t *testing.T) { | |
| 51 | + srv, bodies := recordingLlama(t, goodAnswer) | |
| 52 | + e := engineOn(t, srv.URL+"/v1") | |
| 53 | + tools := sevenTools(e.G) | |
| 54 | + | |
| 55 | + const questions = 12 | |
| 56 | + for i := range questions { | |
| 57 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, tools); err != nil { | |
| 58 | + t.Fatalf("question %d: Generate: %v", i+1, err) | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + got := bodies() | |
| 63 | + if len(got) != questions { | |
| 64 | + t.Fatalf("%d request(s), want %d", len(got), questions) | |
| 65 | + } | |
| 66 | + want := []string{"bash", "edit_file", "fetch_content", "plan", "read_file", "read_skill", "write_file"} | |
| 67 | + for i, body := range got { | |
| 68 | + if names := toolNamesOf(t, body); !slices.Equal(names, want) { | |
| 69 | + t.Errorf("question %d sent the tools as %v, want %v", i+1, names, want) | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +func definitions(names ...string) []*ai.ToolDefinition { | |
| 75 | + defs := make([]*ai.ToolDefinition, 0, len(names)) | |
| 76 | + for _, name := range names { | |
| 77 | + defs = append(defs, &ai.ToolDefinition{Name: name}) | |
| 78 | + } | |
| 79 | + return defs | |
| 80 | +} | |
| 81 | + | |
| 82 | +func namesOf(defs []*ai.ToolDefinition) []string { | |
| 83 | + names := make([]string, 0, len(defs)) | |
| 84 | + for _, d := range defs { | |
| 85 | + names = append(names, d.Name) | |
| 86 | + } | |
| 87 | + return names | |
| 88 | +} | |
| 89 | + | |
| 90 | +func TestStableToolsSortsByName(t *testing.T) { | |
| 91 | + got := namesOf(stableTools(definitions("read_file", "bash", "write_file", "edit_file"))) | |
| 92 | + want := []string{"bash", "edit_file", "read_file", "write_file"} | |
| 93 | + if !slices.Equal(got, want) { | |
| 94 | + t.Errorf("stableTools = %v, want %v", got, want) | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +// The slice is Genkit's: sorting it in place would change a value the caller | |
| 99 | +// still holds. | |
| 100 | +func TestStableToolsLeavesTheOriginalAlone(t *testing.T) { | |
| 101 | + defs := definitions("plan", "bash") | |
| 102 | + stableTools(defs) | |
| 103 | + if got := namesOf(defs); !slices.Equal(got, []string{"plan", "bash"}) { | |
| 104 | + t.Errorf("the original became %v", got) | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +// Every rotation of the same tools gives the same list — the property the | |
| 109 | +// server's prompt cache needs. | |
| 110 | +func TestStableToolsIgnoresTheIncomingOrder(t *testing.T) { | |
| 111 | + first := namesOf(stableTools(definitions("plan", "fetch_content", "bash"))) | |
| 112 | + for _, rotation := range [][]string{{"fetch_content", "bash", "plan"}, {"bash", "plan", "fetch_content"}} { | |
| 113 | + if got := namesOf(stableTools(definitions(rotation...))); !slices.Equal(got, first) { | |
| 114 | + t.Errorf("%v gave %v, want %v", rotation, got, first) | |
| 115 | + } | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +// No tools (the summary request) or one: nothing to sort, nothing to copy. | |
| 120 | +func TestStableToolsWithNoneOrOne(t *testing.T) { | |
| 121 | + if got := stableTools(nil); got != nil { | |
| 122 | + t.Errorf("stableTools(nil) = %v, want nil", got) | |
| 123 | + } | |
| 124 | + one := definitions("bash") | |
| 125 | + if got := stableTools(one); len(got) != 1 || got[0] != one[0] { | |
| 126 | + t.Errorf("stableTools(one) = %v, want the same definition", namesOf(got)) | |
| 127 | + } | |
| 128 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,128 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "slices" | ||
| 6 | + "testing" | ||
| 7 | + | ||
| 8 | + "github.com/firebase/genkit/go/ai" | ||
| 9 | + "github.com/firebase/genkit/go/genkit" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// sevenTools declares tools named like mm's own, in the order main.go | ||
| 13 | +// declares them — which is NOT alphabetical, so a test that only checks | ||
| 14 | +// "same order as declared" could not tell the fix from luck. | ||
| 15 | +func sevenTools(g *genkit.Genkit) []ai.ToolRef { | ||
| 16 | + names := []string{"bash", "read_skill", "read_file", "write_file", "edit_file", "plan", "fetch_content"} | ||
| 17 | + refs := make([]ai.ToolRef, 0, len(names)) | ||
| 18 | + for _, name := range names { | ||
| 19 | + refs = append(refs, genkit.DefineTool(g, name, "the "+name+" tool", | ||
| 20 | + func(_ *ai.ToolContext, in struct { | ||
| 21 | + Input string `json:"input"` | ||
| 22 | + }) (string, error) { | ||
| 23 | + return in.Input, nil | ||
| 24 | + })) | ||
| 25 | + } | ||
| 26 | + return refs | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// toolNamesOf reads the names of the `tools` array of a recorded request, in | ||
| 30 | +// the order the server received them. | ||
| 31 | +func toolNamesOf(t *testing.T, body map[string]any) []string { | ||
| 32 | + t.Helper() | ||
| 33 | + tools, _ := body["tools"].([]any) | ||
| 34 | + names := make([]string, 0, len(tools)) | ||
| 35 | + for _, tool := range tools { | ||
| 36 | + fn, _ := tool.(map[string]any)["function"].(map[string]any) | ||
| 37 | + name, _ := fn["name"].(string) | ||
| 38 | + names = append(names, name) | ||
| 39 | + } | ||
| 40 | + return names | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +// Ticket 53: Genkit builds the request's tool list from a map, so each | ||
| 44 | +// question of a session sent the tools in another order, and a llama.cpp | ||
| 45 | +// server could not reuse the prompt it had cached. Every request of a | ||
| 46 | +// session must carry the same list, sorted by name. | ||
| 47 | +// | ||
| 48 | +// Several questions, because one could come out sorted by chance: with seven | ||
| 49 | +// tools, Genkit's order is a random rotation, sorted once in seven. | ||
| 50 | +func TestToolsGoOutInTheSameOrderOnEveryQuestion(t *testing.T) { | ||
| 51 | + srv, bodies := recordingLlama(t, goodAnswer) | ||
| 52 | + e := engineOn(t, srv.URL+"/v1") | ||
| 53 | + tools := sevenTools(e.G) | ||
| 54 | + | ||
| 55 | + const questions = 12 | ||
| 56 | + for i := range questions { | ||
| 57 | + if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, tools); err != nil { | ||
| 58 | + t.Fatalf("question %d: Generate: %v", i+1, err) | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + got := bodies() | ||
| 63 | + if len(got) != questions { | ||
| 64 | + t.Fatalf("%d request(s), want %d", len(got), questions) | ||
| 65 | + } | ||
| 66 | + want := []string{"bash", "edit_file", "fetch_content", "plan", "read_file", "read_skill", "write_file"} | ||
| 67 | + for i, body := range got { | ||
| 68 | + if names := toolNamesOf(t, body); !slices.Equal(names, want) { | ||
| 69 | + t.Errorf("question %d sent the tools as %v, want %v", i+1, names, want) | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +func definitions(names ...string) []*ai.ToolDefinition { | ||
| 75 | + defs := make([]*ai.ToolDefinition, 0, len(names)) | ||
| 76 | + for _, name := range names { | ||
| 77 | + defs = append(defs, &ai.ToolDefinition{Name: name}) | ||
| 78 | + } | ||
| 79 | + return defs | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +func namesOf(defs []*ai.ToolDefinition) []string { | ||
| 83 | + names := make([]string, 0, len(defs)) | ||
| 84 | + for _, d := range defs { | ||
| 85 | + names = append(names, d.Name) | ||
| 86 | + } | ||
| 87 | + return names | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +func TestStableToolsSortsByName(t *testing.T) { | ||
| 91 | + got := namesOf(stableTools(definitions("read_file", "bash", "write_file", "edit_file"))) | ||
| 92 | + want := []string{"bash", "edit_file", "read_file", "write_file"} | ||
| 93 | + if !slices.Equal(got, want) { | ||
| 94 | + t.Errorf("stableTools = %v, want %v", got, want) | ||
| 95 | + } | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +// The slice is Genkit's: sorting it in place would change a value the caller | ||
| 99 | +// still holds. | ||
| 100 | +func TestStableToolsLeavesTheOriginalAlone(t *testing.T) { | ||
| 101 | + defs := definitions("plan", "bash") | ||
| 102 | + stableTools(defs) | ||
| 103 | + if got := namesOf(defs); !slices.Equal(got, []string{"plan", "bash"}) { | ||
| 104 | + t.Errorf("the original became %v", got) | ||
| 105 | + } | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +// Every rotation of the same tools gives the same list — the property the | ||
| 109 | +// server's prompt cache needs. | ||
| 110 | +func TestStableToolsIgnoresTheIncomingOrder(t *testing.T) { | ||
| 111 | + first := namesOf(stableTools(definitions("plan", "fetch_content", "bash"))) | ||
| 112 | + for _, rotation := range [][]string{{"fetch_content", "bash", "plan"}, {"bash", "plan", "fetch_content"}} { | ||
| 113 | + if got := namesOf(stableTools(definitions(rotation...))); !slices.Equal(got, first) { | ||
| 114 | + t.Errorf("%v gave %v, want %v", rotation, got, first) | ||
| 115 | + } | ||
| 116 | + } | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// No tools (the summary request) or one: nothing to sort, nothing to copy. | ||
| 120 | +func TestStableToolsWithNoneOrOne(t *testing.T) { | ||
| 121 | + if got := stableTools(nil); got != nil { | ||
| 122 | + t.Errorf("stableTools(nil) = %v, want nil", got) | ||
| 123 | + } | ||
| 124 | + one := definitions("bash") | ||
| 125 | + if got := stableTools(one); len(got) != 1 || got[0] != one[0] { | ||
| 126 | + t.Errorf("stableTools(one) = %v, want the same definition", namesOf(got)) | ||
| 127 | + } | ||
| 128 | +} | ||
added
tests/tool-order/proxy.py +87 -0 | new file mode 100755 | ||
| @@ -0,0 +1,87 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Recording proxy between mm and an OpenAI-compatible model server. | |
| 3 | + | |
| 4 | +Prints one line per chat request: the tool order, a fingerprint of the tools | |
| 5 | +block, and the llama.cpp `timings` of the answer (tokens taken from the prompt | |
| 6 | +cache, tokens read again, time spent reading). Used to check ticket 53: the | |
| 7 | +tools must go out in the same order on every question, so that the server can | |
| 8 | +reuse its cached prompt. | |
| 9 | + | |
| 10 | + python3 tests/tool-order/proxy.py --port 18770 --upstream http://localhost:12434 | |
| 11 | + | |
| 12 | +then point mm's `baseUrl` at http://127.0.0.1:18770/engines/v1 (Docker Model | |
| 13 | +Runner) or http://127.0.0.1:18770/v1 (llama-server): the path is forwarded | |
| 14 | +as is. Standard library only. | |
| 15 | +""" | |
| 16 | +import argparse | |
| 17 | +import hashlib | |
| 18 | +import http.server | |
| 19 | +import itertools | |
| 20 | +import json | |
| 21 | +import urllib.request | |
| 22 | + | |
| 23 | +counter = itertools.count(1) | |
| 24 | + | |
| 25 | + | |
| 26 | +def describe(number, body, timings): | |
| 27 | + tools = body.get("tools") or [] | |
| 28 | + names = [t.get("function", {}).get("name", "?") for t in tools] | |
| 29 | + fingerprint = hashlib.sha256(json.dumps(tools).encode()).hexdigest()[:8] | |
| 30 | + t = timings or {} | |
| 31 | + return (f"#{number} tools={fingerprint} {names} " | |
| 32 | + f"cache_n={t.get('cache_n', '-')} prompt_n={t.get('prompt_n', '-')} " | |
| 33 | + f"prompt_ms={round(t['prompt_ms']) if 'prompt_ms' in t else '-'}") | |
| 34 | + | |
| 35 | + | |
| 36 | +class Proxy(http.server.BaseHTTPRequestHandler): | |
| 37 | + protocol_version = "HTTP/1.1" | |
| 38 | + upstream = "" | |
| 39 | + | |
| 40 | + def log_message(self, *args): | |
| 41 | + pass | |
| 42 | + | |
| 43 | + def do_GET(self): | |
| 44 | + with urllib.request.urlopen(self.upstream + self.path, timeout=30) as answer: | |
| 45 | + data = answer.read() | |
| 46 | + self.send_response(answer.status) | |
| 47 | + self.send_header("Content-Type", answer.headers.get("Content-Type", "application/json")) | |
| 48 | + self.send_header("Content-Length", str(len(data))) | |
| 49 | + self.end_headers() | |
| 50 | + self.wfile.write(data) | |
| 51 | + | |
| 52 | + def do_POST(self): | |
| 53 | + raw = self.rfile.read(int(self.headers["Content-Length"])) | |
| 54 | + request = urllib.request.Request(self.upstream + self.path, data=raw, headers={"Content-Type": "application/json"}) | |
| 55 | + timings = None | |
| 56 | + with urllib.request.urlopen(request, timeout=600) as answer: | |
| 57 | + self.send_response(answer.status) | |
| 58 | + self.send_header("Content-Type", answer.headers.get("Content-Type", "text/event-stream")) | |
| 59 | + self.send_header("Transfer-Encoding", "chunked") | |
| 60 | + self.end_headers() | |
| 61 | + for line in answer: | |
| 62 | + # llama.cpp puts `timings` in the last chunk of a stream, or in | |
| 63 | + # the body of a non-streamed answer. | |
| 64 | + if b'"timings"' in line: | |
| 65 | + try: | |
| 66 | + timings = json.loads(line.removeprefix(b"data: ")).get("timings") | |
| 67 | + except ValueError: | |
| 68 | + pass | |
| 69 | + self.wfile.write(b"%x\r\n%s\r\n" % (len(line), line)) | |
| 70 | + self.wfile.flush() | |
| 71 | + self.wfile.write(b"0\r\n\r\n") | |
| 72 | + if self.path.endswith("/chat/completions"): | |
| 73 | + print(describe(next(counter), json.loads(raw), timings), flush=True) | |
| 74 | + | |
| 75 | + | |
| 76 | +def main(): | |
| 77 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | |
| 78 | + parser.add_argument("--port", type=int, default=18770) | |
| 79 | + parser.add_argument("--upstream", default="http://localhost:12434", help="the model server, without the API path") | |
| 80 | + args = parser.parse_args() | |
| 81 | + Proxy.upstream = args.upstream.rstrip("/") | |
| 82 | + print(f"proxy on http://127.0.0.1:{args.port} -> {Proxy.upstream}", flush=True) | |
| 83 | + http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Proxy).serve_forever() | |
| 84 | + | |
| 85 | + | |
| 86 | +if __name__ == "__main__": | |
| 87 | + main() | |
| new file mode 100755 | |||
| @@ -0,0 +1,87 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""Recording proxy between mm and an OpenAI-compatible model server. | ||
| 3 | + | ||
| 4 | +Prints one line per chat request: the tool order, a fingerprint of the tools | ||
| 5 | +block, and the llama.cpp `timings` of the answer (tokens taken from the prompt | ||
| 6 | +cache, tokens read again, time spent reading). Used to check ticket 53: the | ||
| 7 | +tools must go out in the same order on every question, so that the server can | ||
| 8 | +reuse its cached prompt. | ||
| 9 | + | ||
| 10 | + python3 tests/tool-order/proxy.py --port 18770 --upstream http://localhost:12434 | ||
| 11 | + | ||
| 12 | +then point mm's `baseUrl` at http://127.0.0.1:18770/engines/v1 (Docker Model | ||
| 13 | +Runner) or http://127.0.0.1:18770/v1 (llama-server): the path is forwarded | ||
| 14 | +as is. Standard library only. | ||
| 15 | +""" | ||
| 16 | +import argparse | ||
| 17 | +import hashlib | ||
| 18 | +import http.server | ||
| 19 | +import itertools | ||
| 20 | +import json | ||
| 21 | +import urllib.request | ||
| 22 | + | ||
| 23 | +counter = itertools.count(1) | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def describe(number, body, timings): | ||
| 27 | + tools = body.get("tools") or [] | ||
| 28 | + names = [t.get("function", {}).get("name", "?") for t in tools] | ||
| 29 | + fingerprint = hashlib.sha256(json.dumps(tools).encode()).hexdigest()[:8] | ||
| 30 | + t = timings or {} | ||
| 31 | + return (f"#{number} tools={fingerprint} {names} " | ||
| 32 | + f"cache_n={t.get('cache_n', '-')} prompt_n={t.get('prompt_n', '-')} " | ||
| 33 | + f"prompt_ms={round(t['prompt_ms']) if 'prompt_ms' in t else '-'}") | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +class Proxy(http.server.BaseHTTPRequestHandler): | ||
| 37 | + protocol_version = "HTTP/1.1" | ||
| 38 | + upstream = "" | ||
| 39 | + | ||
| 40 | + def log_message(self, *args): | ||
| 41 | + pass | ||
| 42 | + | ||
| 43 | + def do_GET(self): | ||
| 44 | + with urllib.request.urlopen(self.upstream + self.path, timeout=30) as answer: | ||
| 45 | + data = answer.read() | ||
| 46 | + self.send_response(answer.status) | ||
| 47 | + self.send_header("Content-Type", answer.headers.get("Content-Type", "application/json")) | ||
| 48 | + self.send_header("Content-Length", str(len(data))) | ||
| 49 | + self.end_headers() | ||
| 50 | + self.wfile.write(data) | ||
| 51 | + | ||
| 52 | + def do_POST(self): | ||
| 53 | + raw = self.rfile.read(int(self.headers["Content-Length"])) | ||
| 54 | + request = urllib.request.Request(self.upstream + self.path, data=raw, headers={"Content-Type": "application/json"}) | ||
| 55 | + timings = None | ||
| 56 | + with urllib.request.urlopen(request, timeout=600) as answer: | ||
| 57 | + self.send_response(answer.status) | ||
| 58 | + self.send_header("Content-Type", answer.headers.get("Content-Type", "text/event-stream")) | ||
| 59 | + self.send_header("Transfer-Encoding", "chunked") | ||
| 60 | + self.end_headers() | ||
| 61 | + for line in answer: | ||
| 62 | + # llama.cpp puts `timings` in the last chunk of a stream, or in | ||
| 63 | + # the body of a non-streamed answer. | ||
| 64 | + if b'"timings"' in line: | ||
| 65 | + try: | ||
| 66 | + timings = json.loads(line.removeprefix(b"data: ")).get("timings") | ||
| 67 | + except ValueError: | ||
| 68 | + pass | ||
| 69 | + self.wfile.write(b"%x\r\n%s\r\n" % (len(line), line)) | ||
| 70 | + self.wfile.flush() | ||
| 71 | + self.wfile.write(b"0\r\n\r\n") | ||
| 72 | + if self.path.endswith("/chat/completions"): | ||
| 73 | + print(describe(next(counter), json.loads(raw), timings), flush=True) | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +def main(): | ||
| 77 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | ||
| 78 | + parser.add_argument("--port", type=int, default=18770) | ||
| 79 | + parser.add_argument("--upstream", default="http://localhost:12434", help="the model server, without the API path") | ||
| 80 | + args = parser.parse_args() | ||
| 81 | + Proxy.upstream = args.upstream.rstrip("/") | ||
| 82 | + print(f"proxy on http://127.0.0.1:{args.port} -> {Proxy.upstream}", flush=True) | ||
| 83 | + http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Proxy).serve_forever() | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +if __name__ == "__main__": | ||
| 87 | + main() | ||
added
tests/tool-order/session.py +48 -0 | new file mode 100755 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Asks several questions in ONE mm session, through ACP (`mm -acp`). | |
| 3 | + | |
| 4 | +The terminal REPL cancels a turn when the next line arrives on a pipe, so a | |
| 5 | +scripted multi-question session goes through the editor protocol instead. | |
| 6 | + | |
| 7 | + python3 tests/tool-order/session.py ./mm agent.yaml "question 1" "question 2" ... | |
| 8 | + | |
| 9 | +Prints each question with its stop reason. Standard library only. | |
| 10 | +""" | |
| 11 | +import json | |
| 12 | +import os | |
| 13 | +import subprocess | |
| 14 | +import sys | |
| 15 | + | |
| 16 | + | |
| 17 | +def main(): | |
| 18 | + if len(sys.argv) < 4: | |
| 19 | + sys.exit("usage: session.py MM_BINARY CONFIG QUESTION [QUESTION ...]") | |
| 20 | + binary, config, questions = sys.argv[1], sys.argv[2], sys.argv[3:] | |
| 21 | + mm = subprocess.Popen([binary, "-acp", "-config", config], stdin=subprocess.PIPE, stdout=subprocess.PIPE, | |
| 22 | + stderr=subprocess.DEVNULL, text=True) | |
| 23 | + | |
| 24 | + def call(request_id, method, params): | |
| 25 | + mm.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n") | |
| 26 | + mm.stdin.flush() | |
| 27 | + while True: | |
| 28 | + line = mm.stdout.readline() | |
| 29 | + if not line: | |
| 30 | + sys.exit(f"mm stopped while answering {method}") | |
| 31 | + message = json.loads(line) | |
| 32 | + # Notifications (session/update…) and requests from mm carry a | |
| 33 | + # method; the answer to our call does not. | |
| 34 | + if message.get("id") == request_id and "method" not in message: | |
| 35 | + return message | |
| 36 | + | |
| 37 | + try: | |
| 38 | + call(1, "initialize", {"protocolVersion": 1, "clientCapabilities": {}}) | |
| 39 | + session = call(2, "session/new", {"cwd": os.getcwd(), "mcpServers": []})["result"]["sessionId"] | |
| 40 | + for number, question in enumerate(questions, start=1): | |
| 41 | + answer = call(10 + number, "session/prompt", {"sessionId": session, "prompt": [{"type": "text", "text": question}]}) | |
| 42 | + print(f"question {number}: {answer.get('result', answer.get('error'))}", flush=True) | |
| 43 | + finally: | |
| 44 | + mm.kill() | |
| 45 | + | |
| 46 | + | |
| 47 | +if __name__ == "__main__": | |
| 48 | + main() | |
| new file mode 100755 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +"""Asks several questions in ONE mm session, through ACP (`mm -acp`). | ||
| 3 | + | ||
| 4 | +The terminal REPL cancels a turn when the next line arrives on a pipe, so a | ||
| 5 | +scripted multi-question session goes through the editor protocol instead. | ||
| 6 | + | ||
| 7 | + python3 tests/tool-order/session.py ./mm agent.yaml "question 1" "question 2" ... | ||
| 8 | + | ||
| 9 | +Prints each question with its stop reason. Standard library only. | ||
| 10 | +""" | ||
| 11 | +import json | ||
| 12 | +import os | ||
| 13 | +import subprocess | ||
| 14 | +import sys | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def main(): | ||
| 18 | + if len(sys.argv) < 4: | ||
| 19 | + sys.exit("usage: session.py MM_BINARY CONFIG QUESTION [QUESTION ...]") | ||
| 20 | + binary, config, questions = sys.argv[1], sys.argv[2], sys.argv[3:] | ||
| 21 | + mm = subprocess.Popen([binary, "-acp", "-config", config], stdin=subprocess.PIPE, stdout=subprocess.PIPE, | ||
| 22 | + stderr=subprocess.DEVNULL, text=True) | ||
| 23 | + | ||
| 24 | + def call(request_id, method, params): | ||
| 25 | + mm.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n") | ||
| 26 | + mm.stdin.flush() | ||
| 27 | + while True: | ||
| 28 | + line = mm.stdout.readline() | ||
| 29 | + if not line: | ||
| 30 | + sys.exit(f"mm stopped while answering {method}") | ||
| 31 | + message = json.loads(line) | ||
| 32 | + # Notifications (session/update…) and requests from mm carry a | ||
| 33 | + # method; the answer to our call does not. | ||
| 34 | + if message.get("id") == request_id and "method" not in message: | ||
| 35 | + return message | ||
| 36 | + | ||
| 37 | + try: | ||
| 38 | + call(1, "initialize", {"protocolVersion": 1, "clientCapabilities": {}}) | ||
| 39 | + session = call(2, "session/new", {"cwd": os.getcwd(), "mcpServers": []})["result"]["sessionId"] | ||
| 40 | + for number, question in enumerate(questions, start=1): | ||
| 41 | + answer = call(10 + number, "session/prompt", {"sessionId": session, "prompt": [{"type": "text", "text": question}]}) | ||
| 42 | + print(f"question {number}: {answer.get('result', answer.get('error'))}", flush=True) | ||
| 43 | + finally: | ||
| 44 | + mm.kill() | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +if __name__ == "__main__": | ||
| 48 | + main() | ||