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

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

Download ZIP

🛟 Updated.

k33g committed 2026-09-30T08:20:57+02:00 Browse files
015bd7a parent: 1fa0c42
modified .tickets/issues/0053-tool-order-changes-on-every-question.yaml +28 -7
@@ -6,7 +6,7 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-30T05:52:16.000Z
9-updatedAt: 2026-09-30T05:57:31.000Z
9+updatedAt: 2026-09-30T06:11:23.000Z
1010 labels:
1111 - kind::bug
1212 - priority::high
@@ -39,31 +39,34 @@ body: |
3939 tasks:
4040 - id: 1
4141 title: Sort req.Tools by name in WrapModel, with a test (fake model, several Generate calls, same order)
42- state: open
42+ state: closed
4343 priority: none
4444 author:
4545 name: k33g
4646 email: ph.charriere@gmail.com
4747 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
4950 - id: 2
5051 title: Measure cached_tokens / prefill time on llama-server before and after (same session, 3-4 questions)
51- state: open
52+ state: closed
5253 priority: none
5354 author:
5455 name: k33g
5556 email: ph.charriere@gmail.com
5657 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
5860 - id: 3
5961 title: Document the result in petits-llms.adoc (p7 / p10)
60- state: open
62+ state: closed
6163 priority: none
6264 author:
6365 name: k33g
6466 email: ph.charriere@gmail.com
6567 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
6770 comments:
6871 - id: 1
6972 author:
@@ -80,3 +83,21 @@ comments:
8083 on tools anywhere in ai/ or compat_oai.
8184 So the fix stays on mm's side (sort req.Tools in WrapModel). An upstream
8285 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: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-30T05:52:16.000Z8 createdAt: 2026-09-30T05:52:16.000Z
9-updatedAt: 2026-09-30T05:57:31.000Z9+updatedAt: 2026-09-30T06:11:23.000Z
10 labels:10 labels:
11 - kind::bug11 - kind::bug
12 - priority::high12 - priority::high
@@ -39,31 +39,34 @@ body: |
39 tasks:39 tasks:
40 - id: 140 - 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: open42+ state: closed
43 priority: none43 priority: none
44 author:44 author:
45 name: k33g45 name: k33g
46 email: ph.charriere@gmail.com46 email: ph.charriere@gmail.com
47 createdAt: 2026-09-30T05:52:16.000Z47 createdAt: 2026-09-30T05:52:16.000Z
48- updatedAt: 2026-09-30T05:52:16.000Z48+ updatedAt: 2026-09-30T06:11:23.000Z
49+ closedAt: 2026-09-30T06:11:23.000Z
49 - id: 250 - 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: open52+ state: closed
52 priority: none53 priority: none
53 author:54 author:
54 name: k33g55 name: k33g
55 email: ph.charriere@gmail.com56 email: ph.charriere@gmail.com
56 createdAt: 2026-09-30T05:52:16.000Z57 createdAt: 2026-09-30T05:52:16.000Z
57- updatedAt: 2026-09-30T05:52:16.000Z58+ updatedAt: 2026-09-30T06:11:23.000Z
59+ closedAt: 2026-09-30T06:11:23.000Z
58 - id: 360 - 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: open62+ state: closed
61 priority: none63 priority: none
62 author:64 author:
63 name: k33g65 name: k33g
64 email: ph.charriere@gmail.com66 email: ph.charriere@gmail.com
65 createdAt: 2026-09-30T05:52:16.000Z67 createdAt: 2026-09-30T05:52:16.000Z
66- updatedAt: 2026-09-30T05:52:16.000Z68+ updatedAt: 2026-09-30T06:11:23.000Z
69+ closedAt: 2026-09-30T06:11:23.000Z
67 comments:70 comments:
68 - id: 171 - 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 upstream84 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)
2323 - [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md)
2424 - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md)
2525 - [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
2627 - [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
2728
2829 ### 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 it27 - [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-to29 ### MCP (Model Context Protocol) How-to
modified docs/en/explanation/providers.md +5 -0
@@ -20,14 +20,19 @@
2020
2121 **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.
2222
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+
2325 ## Rejected alternatives
2426
2527 - **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers.
2628 - **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown.
2729 - **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.
2832
2933 ## How it relates to the rest
3034
3135 - The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md).
3236 - The window the probe learns is what [context compression](context-compression.md) measures against.
3337 - 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 alternatives25 ## 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 rest33 ## 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:
2323 - [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md)
2424 - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md)
2525 - [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
2627 - [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
2728
2829 ### 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'afficher27 - [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`
253253 * *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é.
254254 * *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine.
255255 * *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.
256257 * *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.
257258
258259 == Une méthode : mesurer
@@ -368,7 +369,7 @@ Cette mesure date d'avant `fetch_content`. Le 30/09/2026, la première requête
368369 | *Requête entière* (JSON) | | | *14 993* (12 762 sans `fetch_content`)
369370 |===
370371
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`).
372373
373374 *Proposition.*
374375
@@ -407,7 +408,7 @@ Les outils pèsent trois fois le prompt système (10 257 caractères contre 3 43
407408 *Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite :
408409
409410 * 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) ;
411412 * alimenter `cachedReadTokens` en ACP.
412413
413414 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
486487 | Sessions (fournisseur et modèle enregistrés) | `internal/store/store.go`
487488 | Configurations par modèle | `config.samples/`, `tests/`
488489 | 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/`
489491 |===
@@ -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 : mesurer259 == 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 @@
2020
2121 **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.
2222
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+
2325 ## Alternatives écartées
2426
2527 - **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants.
2628 - **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré.
2729 - **É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.
2832
2933 ## Liens avec le reste
3034
3135 - Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md).
3236 - La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure.
3337 - 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ées25 ## 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 reste33 ## 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
265265 capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) {
266266 return &ai.Hooks{
267267 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)
268271 full = slices.Clone(params.Request.Messages)
269272 pl.expect()
270273 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()