bots-garden/mini-mepublic Fork
⑂ 050d1d0
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

Merge fix/tool-order into main

rickub committed 2026-09-30T06:21:42Z Browse files
050d1d0 parents: 2d919bd 015bd7a
added .tickets/issues/0053-tool-order-changes-on-every-question.yaml +103 -0
new file mode 100644
@@ -0,0 +1,103 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 53
3+title: Tool order changes on every question (breaks the KV prefix cache)
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-30T05:52:16.000Z
9+updatedAt: 2026-09-30T06:11:23.000Z
10+labels:
11+ - kind::bug
12+ - priority::high
13+ - llama.cpp
14+related:
15+ - 43
16+body: |
17+ The `tools` array of the chat request comes in a different order on every
18+ `Generate` call, i.e. on every user question of the same session.
19+
20+ Cause: Genkit v1.10, `ai/generate.go` (GenerateWithRequest, ~l.236-273)
21+ puts the tool definitions in a map (`toolDefMap`) and builds `toolDefs` by
22+ ranging over it — Go map iteration order is random. Within one question the
23+ same request is reused, so the order only changes between questions.
24+
25+ Seen on 2026-09-30 with a fake OpenAI server capturing mm's requests
26+ (agent.dmr.yaml, 7 tools): a rotation of the same order on 6 runs of
27+ `mm -p hello`, and on the 4 questions of one `mm -acp` session.
28+
29+ Why it matters: llama.cpp only reuses its KV cache for the part of the
30+ prompt that is identical to the previous request. Templates that render the
31+ tools in the system block (Qwen-style) would then re-read the tools AND the
32+ whole history on every question — minutes of prefill on a 20k-token session
33+ with a local 27B. Not measured yet (needs `cached_tokens`, see
34+ petits-llms.adoc, piste p10).
35+
36+ Fix candidate: sort `req.Tools` by name in mm's `WrapModel` middleware in
37+ `engine.Generate` (internal/engine/engine.go), which already sees every
38+ request.
39+tasks:
40+ - id: 1
41+ title: Sort req.Tools by name in WrapModel, with a test (fake model, several Generate calls, same order)
42+ state: closed
43+ priority: none
44+ author:
45+ name: k33g
46+ email: ph.charriere@gmail.com
47+ createdAt: 2026-09-30T05:52:16.000Z
48+ updatedAt: 2026-09-30T06:11:23.000Z
49+ closedAt: 2026-09-30T06:11:23.000Z
50+ - id: 2
51+ title: Measure cached_tokens / prefill time on llama-server before and after (same session, 3-4 questions)
52+ state: closed
53+ priority: none
54+ author:
55+ name: k33g
56+ email: ph.charriere@gmail.com
57+ createdAt: 2026-09-30T05:52:16.000Z
58+ updatedAt: 2026-09-30T06:11:23.000Z
59+ closedAt: 2026-09-30T06:11:23.000Z
60+ - id: 3
61+ title: Document the result in petits-llms.adoc (p7 / p10)
62+ state: closed
63+ priority: none
64+ author:
65+ name: k33g
66+ email: ph.charriere@gmail.com
67+ createdAt: 2026-09-30T05:52:16.000Z
68+ updatedAt: 2026-09-30T06:11:23.000Z
69+ closedAt: 2026-09-30T06:11:23.000Z
70+comments:
71+ - id: 1
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-09-30T05:57:31.000Z
76+ body: |
77+ Upgrading Genkit does not fix it. Checked on 2026-09-30:
78+ - v1.13.1 (latest; same code under github.com/firebase/genkit/go and
79+ github.com/genkit-ai/genkit/go): ai/generate.go l.470-511 still builds
80+ toolDefs by ranging over toolDefMap.
81+ - main branch (raw generate.go of the day): same lines, unchanged.
82+ - plugins/compat_oai WithTools keeps the order it is given, and no Sort
83+ on tools anywhere in ai/ or compat_oai.
84+ So the fix stays on mm's side (sort req.Tools in WrapModel). An upstream
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.
new file mode 100644
@@ -0,0 +1,103 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 53
3+title: Tool order changes on every question (breaks the KV prefix cache)
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-30T05:52:16.000Z
9+updatedAt: 2026-09-30T06:11:23.000Z
10+labels:
11+ - kind::bug
12+ - priority::high
13+ - llama.cpp
14+related:
15+ - 43
16+body: |
17+ The `tools` array of the chat request comes in a different order on every
18+ `Generate` call, i.e. on every user question of the same session.
19+
20+ Cause: Genkit v1.10, `ai/generate.go` (GenerateWithRequest, ~l.236-273)
21+ puts the tool definitions in a map (`toolDefMap`) and builds `toolDefs` by
22+ ranging over it — Go map iteration order is random. Within one question the
23+ same request is reused, so the order only changes between questions.
24+
25+ Seen on 2026-09-30 with a fake OpenAI server capturing mm's requests
26+ (agent.dmr.yaml, 7 tools): a rotation of the same order on 6 runs of
27+ `mm -p hello`, and on the 4 questions of one `mm -acp` session.
28+
29+ Why it matters: llama.cpp only reuses its KV cache for the part of the
30+ prompt that is identical to the previous request. Templates that render the
31+ tools in the system block (Qwen-style) would then re-read the tools AND the
32+ whole history on every question — minutes of prefill on a 20k-token session
33+ with a local 27B. Not measured yet (needs `cached_tokens`, see
34+ petits-llms.adoc, piste p10).
35+
36+ Fix candidate: sort `req.Tools` by name in mm's `WrapModel` middleware in
37+ `engine.Generate` (internal/engine/engine.go), which already sees every
38+ request.
39+tasks:
40+ - id: 1
41+ title: Sort req.Tools by name in WrapModel, with a test (fake model, several Generate calls, same order)
42+ state: closed
43+ priority: none
44+ author:
45+ name: k33g
46+ email: ph.charriere@gmail.com
47+ createdAt: 2026-09-30T05:52:16.000Z
48+ updatedAt: 2026-09-30T06:11:23.000Z
49+ closedAt: 2026-09-30T06:11:23.000Z
50+ - id: 2
51+ title: Measure cached_tokens / prefill time on llama-server before and after (same session, 3-4 questions)
52+ state: closed
53+ priority: none
54+ author:
55+ name: k33g
56+ email: ph.charriere@gmail.com
57+ createdAt: 2026-09-30T05:52:16.000Z
58+ updatedAt: 2026-09-30T06:11:23.000Z
59+ closedAt: 2026-09-30T06:11:23.000Z
60+ - id: 3
61+ title: Document the result in petits-llms.adoc (p7 / p10)
62+ state: closed
63+ priority: none
64+ author:
65+ name: k33g
66+ email: ph.charriere@gmail.com
67+ createdAt: 2026-09-30T05:52:16.000Z
68+ updatedAt: 2026-09-30T06:11:23.000Z
69+ closedAt: 2026-09-30T06:11:23.000Z
70+comments:
71+ - id: 1
72+ author:
73+ name: k33g
74+ email: ph.charriere@gmail.com
75+ createdAt: 2026-09-30T05:57:31.000Z
76+ body: |
77+ Upgrading Genkit does not fix it. Checked on 2026-09-30:
78+ - v1.13.1 (latest; same code under github.com/firebase/genkit/go and
79+ github.com/genkit-ai/genkit/go): ai/generate.go l.470-511 still builds
80+ toolDefs by ranging over toolDefMap.
81+ - main branch (raw generate.go of the day): same lines, unchanged.
82+ - plugins/compat_oai WithTools keeps the order it is given, and no Sort
83+ on tools anywhere in ai/ or compat_oai.
84+ So the fix stays on mm's side (sort req.Tools in WrapModel). An upstream
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.
modified config.samples/agent.dmr.gemma.yaml +5 -0
@@ -24,6 +24,11 @@ editTools: true
2424 # Set to false to disable plan creation and tracking via the `plan` tool.
2525 planTool: true
2626
27+# The built-in fetch_content tool: fetch a web page and get it as Markdown
28+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
29+# Honours the site's robots.txt. Off by default — it reaches the network.
30+fetchContentTool: true
31+
2732 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
2833 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
2934 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
@@ -24,6 +24,11 @@ editTools: true
24 # Set to false to disable plan creation and tracking via the `plan` tool.24 # Set to false to disable plan creation and tracking via the `plan` tool.
25 planTool: true25 planTool: true
26 26
27+# The built-in fetch_content tool: fetch a web page and get it as Markdown
28+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
29+# Honours the site's robots.txt. Off by default — it reaches the network.
30+fetchContentTool: true
31+
27 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).32 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
28 # The file extension picks the server. A server missing from the PATH is skipped with a warning.33 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
29 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):34 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
modified config.samples/agent.dmr.gpt-oss.yaml +5 -0
@@ -25,6 +25,11 @@ editTools: true
2525 # Set to false to disable plan creation and tracking via the `plan` tool.
2626 planTool: true
2727
28+# The built-in fetch_content tool: fetch a web page and get it as Markdown
29+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
30+# Honours the site's robots.txt. Off by default — it reaches the network.
31+fetchContentTool: true
32+
2833 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
2934 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
3035 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
@@ -25,6 +25,11 @@ editTools: true
25 # Set to false to disable plan creation and tracking via the `plan` tool.25 # Set to false to disable plan creation and tracking via the `plan` tool.
26 planTool: true26 planTool: true
27 27
28+# The built-in fetch_content tool: fetch a web page and get it as Markdown
29+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
30+# Honours the site's robots.txt. Off by default — it reaches the network.
31+fetchContentTool: true
32+
28 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).33 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
29 # The file extension picks the server. A server missing from the PATH is skipped with a warning.34 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
30 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):35 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
modified config.samples/agent.dmr.qwen3.6.yaml +5 -0
@@ -27,6 +27,11 @@ editTools: true
2727 # Set to false to disable plan creation and tracking via the `plan` tool.
2828 planTool: true
2929
30+# The built-in fetch_content tool: fetch a web page and get it as Markdown
31+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
32+# Honours the site's robots.txt. Off by default — it reaches the network.
33+fetchContentTool: true
34+
3035 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
3136 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
3237 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
@@ -27,6 +27,11 @@ editTools: true
27 # Set to false to disable plan creation and tracking via the `plan` tool.27 # Set to false to disable plan creation and tracking via the `plan` tool.
28 planTool: true28 planTool: true
29 29
30+# The built-in fetch_content tool: fetch a web page and get it as Markdown
31+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
32+# Honours the site's robots.txt. Off by default — it reaches the network.
33+fetchContentTool: true
34+
30 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).35 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
31 # The file extension picks the server. A server missing from the PATH is skipped with a warning.36 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
32 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):37 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
modified config.samples/agent.llamacpp.ornith.yaml +5 -0
@@ -30,6 +30,11 @@ editTools: true
3030 # Set to false to disable plan creation and tracking via the `plan` tool.
3131 planTool: true
3232
33+# The built-in fetch_content tool: fetch a web page and get it as Markdown
34+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
35+# Honours the site's robots.txt. Off by default — it reaches the network.
36+fetchContentTool: true
37+
3338 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
3439 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
3540 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
@@ -30,6 +30,11 @@ editTools: true
30 # Set to false to disable plan creation and tracking via the `plan` tool.30 # Set to false to disable plan creation and tracking via the `plan` tool.
31 planTool: true31 planTool: true
32 32
33+# The built-in fetch_content tool: fetch a web page and get it as Markdown
34+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
35+# Honours the site's robots.txt. Off by default — it reaches the network.
36+fetchContentTool: true
37+
33 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).38 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
34 # The file extension picks the server. A server missing from the PATH is skipped with a warning.39 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
35 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):40 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
modified config.samples/agent.llamacpp.qwen3.8.yaml +5 -0
@@ -30,6 +30,11 @@ editTools: true
3030 # Set to false to disable plan creation and tracking via the `plan` tool.
3131 planTool: true
3232
33+# The built-in fetch_content tool: fetch a web page and get it as Markdown
34+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
35+# Honours the site's robots.txt. Off by default — it reaches the network.
36+fetchContentTool: true
37+
3338 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
3439 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
3540 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
@@ -30,6 +30,11 @@ editTools: true
30 # Set to false to disable plan creation and tracking via the `plan` tool.30 # Set to false to disable plan creation and tracking via the `plan` tool.
31 planTool: true31 planTool: true
32 32
33+# The built-in fetch_content tool: fetch a web page and get it as Markdown
34+# ("récupère le contenu de https://…", optionally "et sauve le dans page.md").
35+# Honours the site's robots.txt. Off by default — it reaches the network.
36+fetchContentTool: true
37+
33 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).38 # Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename).
34 # The file extension picks the server. A server missing from the PATH is skipped with a warning.39 # The file extension picks the server. A server missing from the PATH is skipped with a warning.
35 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):40 # No server listed = no `lsp` tool. Examples (uncomment what you have installed):
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 +46 -7
@@ -6,7 +6,7 @@
66 :icons: font
77 :source-highlighter: rouge
88 :lang: fr
9-:revdate: 2026-09-26
9+:revdate: 2026-09-30
1010
1111 [abstract]
1212 Mini Me (`mm`) a été conçu pour tourner avec des modèles *locaux* — typiquement 2 à 12 milliards de paramètres servis par Docker Model Runner ou llama-server.
@@ -56,6 +56,8 @@ L'agent est né autour d'un seul outil, `bash` (`internal/tools/tools.go`). Les
5656 * `read_skill` n'existe pas si `skillsDir` ne contient aucun `.md` ;
5757 * `lsp` n'existe pas si aucun serveur de langage n'est configuré *et* présent dans le `PATH`.
5858
59+Un dernier, `fetch_content` (voir <<fetch>>), est désactivé par défaut (`fetchContentTool: true` pour l'activer, comme dans `agent.dmr.yaml`) : c'est le seul outil intégré qui accède au réseau.
60+
5961 Chaque outil déclaré coûte des tokens à chaque requête et une option de plus parmi lesquelles un petit modèle doit choisir. Moins il y en a, mieux il choisit.
6062
6163 === Des descriptions écrites pour être suivies
@@ -66,6 +68,7 @@ Les descriptions d'outils ne décrivent pas seulement l'API, elles anticipent le
6668 * `read_file` : « edit_file needs the EXACT text of the file … Do not type it from memory. »
6769 * `edit_file` : six règles *numérotées*, dont « do not switch to write_file to force the change through ».
6870 * `write_file` : rappelle qu'il écrase tout et renvoie vers `edit_file`.
71+* `fetch_content` : donne des exemples de demandes en plusieurs langues (« récupère le contenu de https://example.com »), avec la consigne « do not use curl or wget through bash for that ».
6972
7073 Le prompt système des configurations livrées (`agent.dmr.yaml`, `agent.llamacpp.yaml`) répète ces règles en sections courtes en majuscules (`SKILLS`, `FILE EDITING`, `RULES`, `BACKGROUND JOBS`). La redondance est voulue : un petit modèle suit mieux une consigne qu'il lit deux fois, à deux endroits.
7174
@@ -85,7 +88,8 @@ Commande en échec (`[exit code: …]`), délai dépassé (`[error: 30s timeout
8588 Les messages sont rédigés comme des *consignes de réparation* :
8689
8790 * `read_skill` avec un nom inconnu répond `[skill: no skill named "x" — available: a, b, c]` ;
88-* `edit_file` avec un texte en double donne les numéros de ligne des occurrences et explique comment rendre `old` unique (`internal/fileedit/edits.go`).
91+* `edit_file` avec un texte en double donne les numéros de ligne des occurrences et explique comment rendre `old` unique (`internal/fileedit/edits.go`) ;
92+* `fetch_content` classe ses échecs en trois codes, `[fetch_content: error N (…) — raison]` : 1 = page non convertible, 2 = page inaccessible (hôte, statut HTTP, `robots.txt`), 3 = argument invalide. La description dit au modèle que le code 3 signifie « corrige ton appel », pour qu'il ne s'acharne pas sur un site en panne.
8993
9094 La seule exception est volontaire : l'arrêt par le détecteur de boucle (voir <<boucles>>).
9195
@@ -193,7 +197,7 @@ Test réel sur Docker Model Runner avec Mellum 12B, `max_tokens` volontairement
193197 * le mécanisme fonctionne : le modèle reprend la main deux fois, puis le message clair s'affiche ;
194198 * *le modèle a ignoré la consigne* les deux fois et a retenté le fichier entier, même avec un budget chiffré en caractères.
195199
196-La relance rend l'échec lisible et laisse une chance aux modèles qui suivent les instructions. Elle ne remplace pas un `max_tokens` adapté : 16 384 est une valeur raisonnable pour un modèle « thinking », avec une marge sous la fenêtre de contexte. Voir aussi la piste <<p15>>.
200+La relance rend l'échec lisible et laisse une chance aux modèles qui suivent les instructions. Elle ne remplace pas un `max_tokens` adapté : 16 384 est une valeur raisonnable pour un modèle « thinking », avec une marge sous la fenêtre de contexte. Les configurations de `config.samples/` montent à 32 768 (voir <<p13>>), alors que `agent.dmr.yaml` et `agent.llamacpp.yaml` restent à 4 096. Voir aussi la piste <<p15>>.
197201
198202 == Modèles à raisonnement
199203
@@ -208,6 +212,17 @@ Le greffon OpenAI de Genkit ignore `reasoning_content`. `mm` le lit lui-même, u
208212
209213 `truncate` limite chaque sortie d'outil à `maxOutput` caractères en conservant *le début et la fin* (la fin porte souvent l'erreur ou le résumé d'une commande). `bash` ne relit jamais plus d'1 Mio par extrémité depuis le disque.
210214
215+[[fetch]]
216+=== Une page web qui ne passe pas par le contexte
217+
218+`fetch_content` (`internal/tools/fetchcontent.go`, conversion dans `internal/webmd`) récupère une page et la rend en Markdown, après avoir retiré les scripts, la navigation, les publicités et les bandeaux de cookies. Trois choix visent directement les petits modèles :
219+
220+* *Enregistrer sans afficher.* Avec `output`, le Markdown est écrit dans le fichier et l'outil ne renvoie qu'une ligne (`[fetch_content: saved N lines … — the content is not shown]`). « Sauve-le dans hello.txt, ne l'affiche pas » ne coûte donc pas une page entière de contexte, et le modèle n'a pas de texte à recopier.
221+* *Une URL mal tapée est réparée.* `NormalizeURL` (`internal/webmd/fetch.go`) complète un nom de site sans schéma (`k33g.org` → `https://k33g.org`) et corrige un schéma sans deux-points (`https//k33g.org`), faute fréquente quand le modèle recopie la demande.
222+* *Une page vide est signalée, pas enregistrée* : le modèle ne confirme pas l'enregistrement d'un fichier vide.
223+
224+Sans `output`, la page passe par `truncate`, comme toute sortie d'outil. Vérifié le 29/09/2026 avec Mellum 12B (`mm -p`, Docker Model Runner) : pour « récupère le contenu de https//k33g.org », puis la même demande avec « ne l'affiche pas et sauve-le dans hello.txt », le modèle a choisi `fetch_content` les deux fois, a écrit le fichier et n'a pas recopié son contenu.
225+
211226 === Une compression hybride
212227
213228 `internal/compact` remplace les vieux tours par *un* résumé écrit par le modèle et garde les `keepLastTurns` derniers tours intacts (le modèle travaille sur eux). Points notables :
@@ -238,11 +253,12 @@ Un petit modèle choisit mal une procédure sur son seul nom. `internal/skills`
238253 * *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é.
239254 * *Sondage de la fenêtre* : `/props` de llama-server donne la taille servie ; la bannière en affiche l'origine.
240255 * *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.
241257 * *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.
242258
243259 == Une méthode : mesurer
244260
245-La plupart des décisions sont justifiées dans le code par une *mesure datée* (« measured on qwen3.5-2b », « the demo audit of 2026-09-18 », « ticket 27 »). Le reste de `mm` est testé contre un moteur factice (rapide, déterministe) ; seul le routage des skills, qui est une propriété du modèle, est mesuré contre un vrai modèle. Garder cette discipline est la meilleure garantie que les améliorations ci-dessous en soient vraiment.
261+La plupart des décisions sont justifiées dans le code par une *mesure datée* (« measured on qwen3.5-2b », « the demo audit of 2026-09-18 », « ticket 27 »). Le reste de `mm` est testé contre un moteur factice (rapide, déterministe) ; seul le routage des skills, qui est une propriété du modèle, est mesuré contre un vrai modèle. Depuis le 27/09/2026, chaque fichier de session enregistre `provider` et `model`, et chaque erreur le modèle qui l'a produite (`internal/store`). Une session qui a échoué peut donc être rattachée au modèle en cause, même quand on en change en cours de route. Garder cette discipline est la meilleure garantie que les améliorations ci-dessous en soient vraiment.
246262
247263 [[pistes]]
248264 == Pistes d'amélioration
@@ -336,9 +352,28 @@ Le cas voisin des arguments qui ne sont pas du JSON valide (JSON coupé par `max
336352
337353 *Constat.* Mesuré sur `agent.dmr.yaml` : la première requête d'une session pèse ≈ 3,4k tokens avant toute question (prompt système ≈ 3,7k caractères, six descriptions d'outils et leurs schémas, catalogue de skills). La description de `plan` à elle seule fait ≈ 1,3k caractères, pour un outil utile seulement aux demandes en plusieurs étapes.
338354
355+Cette mesure date d'avant `fetch_content`. Le 30/09/2026, la première requête de `mm -p hello` avec `agent.dmr.yaml` a été capturée par un faux serveur OpenAI, avec et sans `fetchContentTool` :
356+
357+[cols="2,1,1,1",options="header"]
358+|===
359+| Élément (caractères) | Description | Schéma | Total
360+
361+| Prompt système | | | 3 436
362+| `edit_file` | 1 383 | 758 | 2 141
363+| `plan` | 1 335 | 842 | 2 177
364+| `fetch_content` | 1 037 | 1 068 | 2 105
365+| `read_skill` (catalogue de 7 skills) | 1 373 | 222 | 1 595
366+| `read_file` | 311 | 534 | 845
367+| `bash` | 575 | 164 | 739
368+| `write_file` | 348 | 307 | 655
369+| *Requête entière* (JSON) | | | *14 993* (12 762 sans `fetch_content`)
370+|===
371+
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`).
373+
339374 *Proposition.*
340375
341-* Condenser la description de `plan` (l'exemple de workflow en sept étapes pourrait passer dans un skill), ou désactiver `planTool` par défaut dans les configurations pour petits modèles.
376+* Condenser la description de `plan` (l'exemple de workflow en sept étapes pourrait passer dans un skill), ou désactiver `planTool` par défaut dans les configurations pour petits modèles. La même question se pose pour `fetchContentTool` sur les petites fenêtres, et pour les paramètres optionnels de `fetch_content`, qu'un petit modèle n'utilise presque jamais.
342377 * Éviter les doublons entre prompt système et descriptions quand ils ne servent pas.
343378 * Mesurer chaque retrait avec `internal/eval` : la redondance est parfois ce qui fait marcher un 4B.
344379
@@ -373,7 +408,7 @@ Le cas voisin des arguments qui ne sont pas du JSON valide (JSON coupé par `max
373408 *Proposition.* Les lire dans le middleware SSE existant (`reasoning.go` lit déjà le flux). On peut ensuite :
374409
375410 * afficher le taux de cache KV ;
376-* 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) ;
377412 * alimenter `cachedReadTokens` en ACP.
378413
379414 Sur une machine locale, un préfixe en cache fait la différence entre une réponse immédiate et plusieurs secondes de prefill.
@@ -401,7 +436,7 @@ Sur une machine locale, un préfixe en cache fait la différence entre une répo
401436 [[p13]]
402437 === Profils par modèle
403438
404-*Constat.* Les réglages utiles varient d'une famille à l'autre : pénalité de répétition, `top_k`, `min_p`, gestion du rôle `system`, longueur de prompt supportée. `tests/` contient déjà des variantes par modèle (`agent.dmr-qwen.yaml`, `agent.dmr-gemma.yaml`).
439+*Constat.* Les réglages utiles varient d'une famille à l'autre : pénalité de répétition, `top_k`, `min_p`, gestion du rôle `system`, longueur de prompt supportée. `tests/` contient déjà des variantes par modèle (`agent.dmr-qwen.yaml`, `agent.dmr-gemma.yaml`). Depuis le 28/09/2026, `config.samples/` en ajoute cinq, complètes, pour des modèles plus gros : Gemma 4 26B-A4B, gpt-oss 20B, Qwen3.6 35B-A3B (DMR), Ornith 1.5 35B-A3B et Qwen3.8 27B (llama-server). Toutes fixent `max_tokens: 32768`, `watchdogTimeout: 300s` et `summaryMaxTokens: 1200`. Ce sont des profils de fait, mais recopiés à la main : chaque changement de la configuration de base est à reporter dans chacun des fichiers (`fetchContentTool: true` y a été ajouté à la main le 30/09/2026).
405440
406441 *Proposition.* Une clé `profile:` (ou un fichier de profils) qui applique des valeurs par défaut connues par famille de modèle, surchargées par le YAML.
407442
@@ -448,5 +483,9 @@ Sur une machine locale, un préfixe en cache fait la différence entre une répo
448483 | Compression | `internal/compact/`
449484 | Skills et catalogue | `internal/skills/`, `skills/`
450485 | Évaluation du routage | `internal/eval/`
486+| Page web en Markdown (`fetch_content`) | `internal/tools/fetchcontent.go`, `internal/webmd/`
487+| Sessions (fournisseur et modèle enregistrés) | `internal/store/store.go`
488+| Configurations par modèle | `config.samples/`, `tests/`
451489 | 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/`
452491 |===
@@ -6,7 +6,7 @@
6 :icons: font6 :icons: font
7 :source-highlighter: rouge7 :source-highlighter: rouge
8 :lang: fr8 :lang: fr
9-:revdate: 2026-09-269+:revdate: 2026-09-30
10 10
11 [abstract]11 [abstract]
12 Mini Me (`mm`) a été conçu pour tourner avec des modèles *locaux* — typiquement 2 à 12 milliards de paramètres servis par Docker Model Runner ou llama-server.12 Mini Me (`mm`) a été conçu pour tourner avec des modèles *locaux* — typiquement 2 à 12 milliards de paramètres servis par Docker Model Runner ou llama-server.
@@ -56,6 +56,8 @@ L'agent est né autour d'un seul outil, `bash` (`internal/tools/tools.go`). Les
56 * `read_skill` n'existe pas si `skillsDir` ne contient aucun `.md` ;56 * `read_skill` n'existe pas si `skillsDir` ne contient aucun `.md` ;
57 * `lsp` n'existe pas si aucun serveur de langage n'est configuré *et* présent dans le `PATH`.57 * `lsp` n'existe pas si aucun serveur de langage n'est configuré *et* présent dans le `PATH`.
58 58
59+Un dernier, `fetch_content` (voir <<fetch>>), est désactivé par défaut (`fetchContentTool: true` pour l'activer, comme dans `agent.dmr.yaml`) : c'est le seul outil intégré qui accède au réseau.
60+
59 Chaque outil déclaré coûte des tokens à chaque requête et une option de plus parmi lesquelles un petit modèle doit choisir. Moins il y en a, mieux il choisit.61 Chaque outil déclaré coûte des tokens à chaque requête et une option de plus parmi lesquelles un petit modèle doit choisir. Moins il y en a, mieux il choisit.
60 62
61 === Des descriptions écrites pour être suivies63 === Des descriptions écrites pour être suivies
@@ -66,6 +68,7 @@ Les descriptions d'outils ne décrivent pas seulement l'API, elles anticipent le
66 * `read_file` : « edit_file needs the EXACT text of the file … Do not type it from memory. »68 * `read_file` : « edit_file needs the EXACT text of the file … Do not type it from memory. »
67 * `edit_file` : six règles *numérotées*, dont « do not switch to write_file to force the change through ».69 * `edit_file` : six règles *numérotées*, dont « do not switch to write_file to force the change through ».
68 * `write_file` : rappelle qu'il écrase tout et renvoie vers `edit_file`.70 * `write_file` : rappelle qu'il écrase tout et renvoie vers `edit_file`.
71+* `fetch_content` : donne des exemples de demandes en plusieurs langues (« récupère le contenu de https://example.com »), avec la consigne « do not use curl or wget through bash for that ».
69 72
70 Le prompt système des configurations livrées (`agent.dmr.yaml`, `agent.llamacpp.yaml`) répète ces règles en sections courtes en majuscules (`SKILLS`, `FILE EDITING`, `RULES`, `BACKGROUND JOBS`). La redondance est voulue : un petit modèle suit mieux une consigne qu'il lit deux fois, à deux endroits.73 Le prompt système des configurations livrées (`agent.dmr.yaml`, `agent.llamacpp.yaml`) répète ces règles en sections courtes en majuscules (`SKILLS`, `FILE EDITING`, `RULES`, `BACKGROUND JOBS`). La redondance est voulue : un petit modèle suit mieux une consigne qu'il lit deux fois, à deux endroits.
71 74
@@ -85,7 +88,8 @@ Commande en échec (`[exit code: …]`), délai dépassé (`[error: 30s timeout
85 Les messages sont rédigés comme des *consignes de réparation* :88 Les messages sont rédigés comme des *consignes de réparation* :
86 89
87 * `read_skill` avec un nom inconnu répond `[skill: no skill named "x" — available: a, b, c]` ;90 * `read_skill` avec un nom inconnu répond `[skill: no skill named "x" — available: a, b, c]` ;
88-* `edit_file` avec un texte en double donne les numéros de ligne des occurrences et explique comment rendre `old` unique (`internal/fileedit/edits.go`).91+* `edit_file` avec un texte en double donne les numéros de ligne des occurrences et explique comment rendre `old` unique (`internal/fileedit/edits.go`) ;
92+* `fetch_content` classe ses échecs en trois codes, `[fetch_content: error N (…) — raison]` : 1 = page non convertible, 2 = page inaccessible (hôte, statut HTTP, `robots.txt`), 3 = argument invalide. La description dit au modèle que le code 3 signifie « corrige ton appel », pour qu'il ne s'acharne pas sur un site en panne.
89 93
90 La seule exception est volontaire : l'arrêt par le détecteur de boucle (voir <<boucles>>).94 La seule exception est volontaire : l'arrêt par le détecteur de boucle (voir <<boucles>>).
91 95
@@ -193,7 +197,7 @@ Test réel sur Docker Model Runner avec Mellum 12B, `max_tokens` volontairement
193 * le mécanisme fonctionne : le modèle reprend la main deux fois, puis le message clair s'affiche ;197 * le mécanisme fonctionne : le modèle reprend la main deux fois, puis le message clair s'affiche ;
194 * *le modèle a ignoré la consigne* les deux fois et a retenté le fichier entier, même avec un budget chiffré en caractères.198 * *le modèle a ignoré la consigne* les deux fois et a retenté le fichier entier, même avec un budget chiffré en caractères.
195 199
196-La relance rend l'échec lisible et laisse une chance aux modèles qui suivent les instructions. Elle ne remplace pas un `max_tokens` adapté : 16 384 est une valeur raisonnable pour un modèle « thinking », avec une marge sous la fenêtre de contexte. Voir aussi la piste <<p15>>.200+La relance rend l'échec lisible et laisse une chance aux modèles qui suivent les instructions. Elle ne remplace pas un `max_tokens` adapté : 16 384 est une valeur raisonnable pour un modèle « thinking », avec une marge sous la fenêtre de contexte. Les configurations de `config.samples/` montent à 32 768 (voir <<p13>>), alors que `agent.dmr.yaml` et `agent.llamacpp.yaml` restent à 4 096. Voir aussi la piste <<p15>>.
197 201
198 == Modèles à raisonnement202 == Modèles à raisonnement
199 203
@@ -208,6 +212,17 @@ Le greffon OpenAI de Genkit ignore `reasoning_content`. `mm` le lit lui-même, u
208 212
209 `truncate` limite chaque sortie d'outil à `maxOutput` caractères en conservant *le début et la fin* (la fin porte souvent l'erreur ou le résumé d'une commande). `bash` ne relit jamais plus d'1 Mio par extrémité depuis le disque.213 `truncate` limite chaque sortie d'outil à `maxOutput` caractères en conservant *le début et la fin* (la fin porte souvent l'erreur ou le résumé d'une commande). `bash` ne relit jamais plus d'1 Mio par extrémité depuis le disque.
210 214
215+[[fetch]]
216+=== Une page web qui ne passe pas par le contexte
217+
218+`fetch_content` (`internal/tools/fetchcontent.go`, conversion dans `internal/webmd`) récupère une page et la rend en Markdown, après avoir retiré les scripts, la navigation, les publicités et les bandeaux de cookies. Trois choix visent directement les petits modèles :
219+
220+* *Enregistrer sans afficher.* Avec `output`, le Markdown est écrit dans le fichier et l'outil ne renvoie qu'une ligne (`[fetch_content: saved N lines … — the content is not shown]`). « Sauve-le dans hello.txt, ne l'affiche pas » ne coûte donc pas une page entière de contexte, et le modèle n'a pas de texte à recopier.
221+* *Une URL mal tapée est réparée.* `NormalizeURL` (`internal/webmd/fetch.go`) complète un nom de site sans schéma (`k33g.org` → `https://k33g.org`) et corrige un schéma sans deux-points (`https//k33g.org`), faute fréquente quand le modèle recopie la demande.
222+* *Une page vide est signalée, pas enregistrée* : le modèle ne confirme pas l'enregistrement d'un fichier vide.
223+
224+Sans `output`, la page passe par `truncate`, comme toute sortie d'outil. Vérifié le 29/09/2026 avec Mellum 12B (`mm -p`, Docker Model Runner) : pour « récupère le contenu de https//k33g.org », puis la même demande avec « ne l'affiche pas et sauve-le dans hello.txt », le modèle a choisi `fetch_content` les deux fois, a écrit le fichier et n'a pas recopié son contenu.
225+
211 === Une compression hybride226 === Une compression hybride
212 227
213 `internal/compact` remplace les vieux tours par *un* résumé écrit par le modèle et garde les `keepLastTurns` derniers tours intacts (le modèle travaille sur eux). Points notables :228 `internal/compact` remplace les vieux tours par *un* résumé écrit par le modèle et garde les `keepLastTurns` derniers tours intacts (le modèle travaille sur eux). Points notables :
@@ -238,11 +253,12 @@ Un petit modèle choisit mal une procédure sur son seul nom. `internal/skills`
238 * *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é.
239 * *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.
240 * *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.
241 * *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.
242 258
243 == Une méthode : mesurer259 == Une méthode : mesurer
244 260
245-La plupart des décisions sont justifiées dans le code par une *mesure datée* (« measured on qwen3.5-2b », « the demo audit of 2026-09-18 », « ticket 27 »). Le reste de `mm` est testé contre un moteur factice (rapide, déterministe) ; seul le routage des skills, qui est une propriété du modèle, est mesuré contre un vrai modèle. Garder cette discipline est la meilleure garantie que les améliorations ci-dessous en soient vraiment.261+La plupart des décisions sont justifiées dans le code par une *mesure datée* (« measured on qwen3.5-2b », « the demo audit of 2026-09-18 », « ticket 27 »). Le reste de `mm` est testé contre un moteur factice (rapide, déterministe) ; seul le routage des skills, qui est une propriété du modèle, est mesuré contre un vrai modèle. Depuis le 27/09/2026, chaque fichier de session enregistre `provider` et `model`, et chaque erreur le modèle qui l'a produite (`internal/store`). Une session qui a échoué peut donc être rattachée au modèle en cause, même quand on en change en cours de route. Garder cette discipline est la meilleure garantie que les améliorations ci-dessous en soient vraiment.
246 262
247 [[pistes]]263 [[pistes]]
248 == Pistes d'amélioration264 == Pistes d'amélioration
@@ -336,9 +352,28 @@ Le cas voisin des arguments qui ne sont pas du JSON valide (JSON coupé par `max
336 352
337 *Constat.* Mesuré sur `agent.dmr.yaml` : la première requête d'une session pèse ≈ 3,4k tokens avant toute question (prompt système ≈ 3,7k caractères, six descriptions d'outils et leurs schémas, catalogue de skills). La description de `plan` à elle seule fait ≈ 1,3k caractères, pour un outil utile seulement aux demandes en plusieurs étapes.353 *Constat.* Mesuré sur `agent.dmr.yaml` : la première requête d'une session pèse ≈ 3,4k tokens avant toute question (prompt système ≈ 3,7k caractères, six descriptions d'outils et leurs schémas, catalogue de skills). La description de `plan` à elle seule fait ≈ 1,3k caractères, pour un outil utile seulement aux demandes en plusieurs étapes.
338 354
355+Cette mesure date d'avant `fetch_content`. Le 30/09/2026, la première requête de `mm -p hello` avec `agent.dmr.yaml` a été capturée par un faux serveur OpenAI, avec et sans `fetchContentTool` :
356+
357+[cols="2,1,1,1",options="header"]
358+|===
359+| Élément (caractères) | Description | Schéma | Total
360+
361+| Prompt système | | | 3 436
362+| `edit_file` | 1 383 | 758 | 2 141
363+| `plan` | 1 335 | 842 | 2 177
364+| `fetch_content` | 1 037 | 1 068 | 2 105
365+| `read_skill` (catalogue de 7 skills) | 1 373 | 222 | 1 595
366+| `read_file` | 311 | 534 | 845
367+| `bash` | 575 | 164 | 739
368+| `write_file` | 348 | 307 | 655
369+| *Requête entière* (JSON) | | | *14 993* (12 762 sans `fetch_content`)
370+|===
371+
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`).
373+
339 *Proposition.*374 *Proposition.*
340 375
341-* Condenser la description de `plan` (l'exemple de workflow en sept étapes pourrait passer dans un skill), ou désactiver `planTool` par défaut dans les configurations pour petits modèles.376+* Condenser la description de `plan` (l'exemple de workflow en sept étapes pourrait passer dans un skill), ou désactiver `planTool` par défaut dans les configurations pour petits modèles. La même question se pose pour `fetchContentTool` sur les petites fenêtres, et pour les paramètres optionnels de `fetch_content`, qu'un petit modèle n'utilise presque jamais.
342 * Éviter les doublons entre prompt système et descriptions quand ils ne servent pas.377 * Éviter les doublons entre prompt système et descriptions quand ils ne servent pas.
343 * Mesurer chaque retrait avec `internal/eval` : la redondance est parfois ce qui fait marcher un 4B.378 * Mesurer chaque retrait avec `internal/eval` : la redondance est parfois ce qui fait marcher un 4B.
344 379
@@ -373,7 +408,7 @@ Le cas voisin des arguments qui ne sont pas du JSON valide (JSON coupé par `max
373 *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 :
374 409
375 * afficher le taux de cache KV ;410 * afficher le taux de cache KV ;
376-* 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) ;
377 * alimenter `cachedReadTokens` en ACP.412 * alimenter `cachedReadTokens` en ACP.
378 413
379 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.
@@ -401,7 +436,7 @@ Sur une machine locale, un préfixe en cache fait la différence entre une répo
401 [[p13]]436 [[p13]]
402 === Profils par modèle437 === Profils par modèle
403 438
404-*Constat.* Les réglages utiles varient d'une famille à l'autre : pénalité de répétition, `top_k`, `min_p`, gestion du rôle `system`, longueur de prompt supportée. `tests/` contient déjà des variantes par modèle (`agent.dmr-qwen.yaml`, `agent.dmr-gemma.yaml`).439+*Constat.* Les réglages utiles varient d'une famille à l'autre : pénalité de répétition, `top_k`, `min_p`, gestion du rôle `system`, longueur de prompt supportée. `tests/` contient déjà des variantes par modèle (`agent.dmr-qwen.yaml`, `agent.dmr-gemma.yaml`). Depuis le 28/09/2026, `config.samples/` en ajoute cinq, complètes, pour des modèles plus gros : Gemma 4 26B-A4B, gpt-oss 20B, Qwen3.6 35B-A3B (DMR), Ornith 1.5 35B-A3B et Qwen3.8 27B (llama-server). Toutes fixent `max_tokens: 32768`, `watchdogTimeout: 300s` et `summaryMaxTokens: 1200`. Ce sont des profils de fait, mais recopiés à la main : chaque changement de la configuration de base est à reporter dans chacun des fichiers (`fetchContentTool: true` y a été ajouté à la main le 30/09/2026).
405 440
406 *Proposition.* Une clé `profile:` (ou un fichier de profils) qui applique des valeurs par défaut connues par famille de modèle, surchargées par le YAML.441 *Proposition.* Une clé `profile:` (ou un fichier de profils) qui applique des valeurs par défaut connues par famille de modèle, surchargées par le YAML.
407 442
@@ -448,5 +483,9 @@ Sur une machine locale, un préfixe en cache fait la différence entre une répo
448 | Compression | `internal/compact/`483 | Compression | `internal/compact/`
449 | Skills et catalogue | `internal/skills/`, `skills/`484 | Skills et catalogue | `internal/skills/`, `skills/`
450 | Évaluation du routage | `internal/eval/`485 | Évaluation du routage | `internal/eval/`
486+| Page web en Markdown (`fetch_content`) | `internal/tools/fetchcontent.go`, `internal/webmd/`
487+| Sessions (fournisseur et modèle enregistrés) | `internal/store/store.go`
488+| Configurations par modèle | `config.samples/`, `tests/`
451 | 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/`
452 |===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()