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

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

Download ZIP

🛟 Updated.

k33g committed 2026-10-26T00:04:48+01:00 Browse files
19072cf parent: d973158
modified .tickets/issues/0161-unload-model-should-be-an-option-of-the-usage-of.yaml +101 -3
@@ -6,10 +6,108 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T22:29:12.597Z
9-updatedAt: 2026-10-25T22:29:34.372Z
10-labels: []
9+updatedAt: 2026-10-25T23:59:00.000Z
10+labels:
11+ - enhancement
12+ - To be tested
1113 milestone: v1.0.1
1214 body: |
1315 `mmConfigs`
14-tasks: []
16+
17+ Instead of using hooks to unload models, it should be an option in:
18+ ```
19+ mmConfigs:
20+ go:
21+ path: agent.go.yaml
22+ description: >-
23+ Go specialist. Explains Go concepts and how to do something in Go,
24+ with short examples, like documentation. For any question about Go.
25+ yolo: true
26+ rust:
27+ path: agent.rust.yaml
28+ description: >-
29+ Rust specialist. Explains Rust concepts and how to do something in
30+ Rust, with short examples, like documentation. For any question about
31+ Rust.
32+ yolo: true
33+ ```
34+
35+ ## Decision (2026-10-25)
36+
37+ One boolean on the asked agent's entry: `unloadModels: true` (rejected: a `before` / `after` pair, or both forms). Off by default. Not the top-level `unloadModels`, which declares `/unload` and `unload_model`.
38+
39+ ## What was done
40+
41+ - `config.AgentRef.UnloadModels` (`mmConfigs.<name>.unloadModels`); `config.ReadModel(path)` reads another agent's provider / model / baseUrl / fallback / apiKeyEnv without touching `Cfg` (no provider = the built-in default).
42+ - `engine.UnloadAgent(ctx, path)`: unloads the model of another agent's file on THAT agent's server (resolved like a decider: no `AGENT_BASE_URL`). The asked agent's own `/unload` was not used: that agent may have `unloadModels: false`, and `/unload` would then reach its model as a question.
43+ - `ask.Unloads{Asker, Asked}` + `Pool.SetUnloads`, installed by main (`Asker` = the engine's own `Unload`; `mm --ask` installs `Asked` only). `Pool.Delegate`: `preAsk` → unload the asker's model → the ask → unload the asked agent's model (context not cancellable: an aborted ask still frees it) → `postAsk`. A deny unloads nothing. Each unload is a `🧹 …` notice; a failure is a line, never an error.
44+ - Line mode: `LineSink.Notice` starts its line after streamed text that did not end one, and `beatingSink` (ask_agent) passes notices on — before, a notice during an ask stuck to the answer in `-p`.
45+ - New demo `demos/coding-helpers-unload-option` (copies of the specialists, `unloadModels: true` on both entries, no hooks); `demos/coding-helpers` untouched.
46+
47+ ## Verified
48+
49+ - Tests: config `TestAgentRefUnloadModels`, `TestReadModel`; engine `TestUnloadAgentUsesTheOtherAgentsFile` (fake Ollama, openai provider, missing file); ask `TestUnloadModelsFreesBothModelsAroundTheHandOff` (order checked against the child process), `TestUnloadModelsOffOrDeniedUnloadsNothing`, `TestUnloadModelsAfterACancelledAsk`, `TestLineSinkNoticeStartsItsOwnLine`. `go vet ./...` and `go test ./...` green.
50+ - Live (llmman, `/api/ps` sampled twice a second): Rust question 31 s, Go question 79 s — MiniCPM5 → nothing → Mellum2 → nothing → MiniCPM5, never both.
51+ - Not verified live: the window and an editor (same `Delegate`, notices through their sinks).
52+
53+ ## Docs
54+
55+ EN + FR: `reference/configuration.md` (`mmConfigs` row), `reference/hooks.md` (order with the unloads), `how-to/unload-a-model.md` (new section + variant), `how-to/let-the-agent-call-other-agents.md` (variant); demo READMEs.
56+tasks:
57+ - id: 1
58+ title: "config: mmConfigs.<name>.unloadModels + config.ReadModel"
59+ state: closed
60+ priority: none
61+ author:
62+ name: k33g
63+ email: ph.charriere@gmail.com
64+ createdAt: 2026-10-25T23:59:00.000Z
65+ - id: 2
66+ title: "engine.UnloadAgent: unload another agent's model on its own server"
67+ state: closed
68+ priority: none
69+ author:
70+ name: k33g
71+ email: ph.charriere@gmail.com
72+ createdAt: 2026-10-25T23:59:00.000Z
73+ - id: 3
74+ title: "ask: Unloads + SetUnloads, Delegate unloads around the hand-off; main wiring (mm --ask: asked only)"
75+ state: closed
76+ priority: none
77+ author:
78+ name: k33g
79+ email: ph.charriere@gmail.com
80+ createdAt: 2026-10-25T23:59:00.000Z
81+ - id: 4
82+ title: "Line mode: notices during an ask on their own line (LineSink.Notice, beatingSink)"
83+ state: closed
84+ priority: none
85+ author:
86+ name: k33g
87+ email: ph.charriere@gmail.com
88+ createdAt: 2026-10-25T23:59:00.000Z
89+ - id: 5
90+ title: "Tests + live check with llmman"
91+ state: closed
92+ priority: none
93+ author:
94+ name: k33g
95+ email: ph.charriere@gmail.com
96+ createdAt: 2026-10-25T23:59:00.000Z
97+ - id: 6
98+ title: "New demo demos/coding-helpers-unload-option; docs EN + FR"
99+ state: closed
100+ priority: none
101+ author:
102+ name: k33g
103+ email: ph.charriere@gmail.com
104+ createdAt: 2026-10-25T23:59:00.000Z
105+ - id: 7
106+ title: "Check in the window and in an editor"
107+ state: open
108+ priority: none
109+ author:
110+ name: k33g
111+ email: ph.charriere@gmail.com
112+ createdAt: 2026-10-25T23:59:00.000Z
15113 comments: []
@@ -6,10 +6,108 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-25T22:29:12.597Z8 createdAt: 2026-10-25T22:29:12.597Z
9-updatedAt: 2026-10-25T22:29:34.372Z9+updatedAt: 2026-10-25T23:59:00.000Z
10-labels: []10+labels:
11+ - enhancement
12+ - To be tested
11 milestone: v1.0.113 milestone: v1.0.1
12 body: |14 body: |
13 `mmConfigs`15 `mmConfigs`
14-tasks: []16+
17+ Instead of using hooks to unload models, it should be an option in:
18+ ```
19+ mmConfigs:
20+ go:
21+ path: agent.go.yaml
22+ description: >-
23+ Go specialist. Explains Go concepts and how to do something in Go,
24+ with short examples, like documentation. For any question about Go.
25+ yolo: true
26+ rust:
27+ path: agent.rust.yaml
28+ description: >-
29+ Rust specialist. Explains Rust concepts and how to do something in
30+ Rust, with short examples, like documentation. For any question about
31+ Rust.
32+ yolo: true
33+ ```
34+
35+ ## Decision (2026-10-25)
36+
37+ One boolean on the asked agent's entry: `unloadModels: true` (rejected: a `before` / `after` pair, or both forms). Off by default. Not the top-level `unloadModels`, which declares `/unload` and `unload_model`.
38+
39+ ## What was done
40+
41+ - `config.AgentRef.UnloadModels` (`mmConfigs.<name>.unloadModels`); `config.ReadModel(path)` reads another agent's provider / model / baseUrl / fallback / apiKeyEnv without touching `Cfg` (no provider = the built-in default).
42+ - `engine.UnloadAgent(ctx, path)`: unloads the model of another agent's file on THAT agent's server (resolved like a decider: no `AGENT_BASE_URL`). The asked agent's own `/unload` was not used: that agent may have `unloadModels: false`, and `/unload` would then reach its model as a question.
43+ - `ask.Unloads{Asker, Asked}` + `Pool.SetUnloads`, installed by main (`Asker` = the engine's own `Unload`; `mm --ask` installs `Asked` only). `Pool.Delegate`: `preAsk` → unload the asker's model → the ask → unload the asked agent's model (context not cancellable: an aborted ask still frees it) → `postAsk`. A deny unloads nothing. Each unload is a `🧹 …` notice; a failure is a line, never an error.
44+ - Line mode: `LineSink.Notice` starts its line after streamed text that did not end one, and `beatingSink` (ask_agent) passes notices on — before, a notice during an ask stuck to the answer in `-p`.
45+ - New demo `demos/coding-helpers-unload-option` (copies of the specialists, `unloadModels: true` on both entries, no hooks); `demos/coding-helpers` untouched.
46+
47+ ## Verified
48+
49+ - Tests: config `TestAgentRefUnloadModels`, `TestReadModel`; engine `TestUnloadAgentUsesTheOtherAgentsFile` (fake Ollama, openai provider, missing file); ask `TestUnloadModelsFreesBothModelsAroundTheHandOff` (order checked against the child process), `TestUnloadModelsOffOrDeniedUnloadsNothing`, `TestUnloadModelsAfterACancelledAsk`, `TestLineSinkNoticeStartsItsOwnLine`. `go vet ./...` and `go test ./...` green.
50+ - Live (llmman, `/api/ps` sampled twice a second): Rust question 31 s, Go question 79 s — MiniCPM5 → nothing → Mellum2 → nothing → MiniCPM5, never both.
51+ - Not verified live: the window and an editor (same `Delegate`, notices through their sinks).
52+
53+ ## Docs
54+
55+ EN + FR: `reference/configuration.md` (`mmConfigs` row), `reference/hooks.md` (order with the unloads), `how-to/unload-a-model.md` (new section + variant), `how-to/let-the-agent-call-other-agents.md` (variant); demo READMEs.
56+tasks:
57+ - id: 1
58+ title: "config: mmConfigs.<name>.unloadModels + config.ReadModel"
59+ state: closed
60+ priority: none
61+ author:
62+ name: k33g
63+ email: ph.charriere@gmail.com
64+ createdAt: 2026-10-25T23:59:00.000Z
65+ - id: 2
66+ title: "engine.UnloadAgent: unload another agent's model on its own server"
67+ state: closed
68+ priority: none
69+ author:
70+ name: k33g
71+ email: ph.charriere@gmail.com
72+ createdAt: 2026-10-25T23:59:00.000Z
73+ - id: 3
74+ title: "ask: Unloads + SetUnloads, Delegate unloads around the hand-off; main wiring (mm --ask: asked only)"
75+ state: closed
76+ priority: none
77+ author:
78+ name: k33g
79+ email: ph.charriere@gmail.com
80+ createdAt: 2026-10-25T23:59:00.000Z
81+ - id: 4
82+ title: "Line mode: notices during an ask on their own line (LineSink.Notice, beatingSink)"
83+ state: closed
84+ priority: none
85+ author:
86+ name: k33g
87+ email: ph.charriere@gmail.com
88+ createdAt: 2026-10-25T23:59:00.000Z
89+ - id: 5
90+ title: "Tests + live check with llmman"
91+ state: closed
92+ priority: none
93+ author:
94+ name: k33g
95+ email: ph.charriere@gmail.com
96+ createdAt: 2026-10-25T23:59:00.000Z
97+ - id: 6
98+ title: "New demo demos/coding-helpers-unload-option; docs EN + FR"
99+ state: closed
100+ priority: none
101+ author:
102+ name: k33g
103+ email: ph.charriere@gmail.com
104+ createdAt: 2026-10-25T23:59:00.000Z
105+ - id: 7
106+ title: "Check in the window and in an editor"
107+ state: open
108+ priority: none
109+ author:
110+ name: k33g
111+ email: ph.charriere@gmail.com
112+ createdAt: 2026-10-25T23:59:00.000Z
15 comments: []113 comments: []
added demos/coding-helpers-unload-option/README.en.md +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+# Coding helpers, with the `unloadModels` option
2+
3+*[Version française](README.fr.md)*
4+
5+The same team as [`demos/coding-helpers`](../coding-helpers/) — a coordinator that hands Go and Rust questions to a specialist — with one difference: the two models are never in the server's memory together, and no hook is needed for it. Each `mmConfigs` entry sets `unloadModels: true` (ticket 161).
6+
7+| Agent | File | Model | Role |
8+|---|---|---|---|
9+| 🧭 coordinator | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1.6 GB) | picks `go` or `rust`, sends the question with the conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8.1 GB) | documents Go |
11+| 🦀 rust | `agent.rust.yaml` | the same Mellum2 model | documents Rust |
12+
13+The two specialists are copies of those of `demos/coding-helpers`. Only `agent.yaml` differs: `unloadModels: true` on both entries, and no `hooks:` block.
14+
15+## Run
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers-unload-option
23+mm # reads ./agent.yaml
24+```
25+
26+Watch the server in another terminal: `llmman ps` (or `curl -s :17434/api/ps`).
27+
28+## The option
29+
30+```yaml
31+mmConfigs:
32+ go:
33+ path: agent.go.yaml
34+ description: >-
35+ Go specialist. …
36+ yolo: true
37+ unloadModels: true
38+ rust:
39+ path: agent.rust.yaml
40+ description: >-
41+ Rust specialist. …
42+ yolo: true
43+ unloadModels: true
44+```
45+
46+On every hand-off to `go` or `rust` — the coordinator's `ask_agent`, your `/ask-go` or `/ask-rust`:
47+
48+1. mm unloads the coordinator's model (MiniCPM5) right before the specialist starts;
49+2. the specialist loads Mellum2 and answers;
50+3. mm unloads Mellum2 once the answer is in, before the coordinator is reloaded to read it.
51+
52+Each unload is one line:
53+
54+```text
55+🗣 ask_agent: rust (llmman/huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M) — …
56+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
57+…the specialist's answer…
58+🧹 unloaded huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
59+```
60+
61+The price: Mellum2 is reloaded on every question, even two in a row to the same specialist. Without the option, it stays loaded beside the coordinator.
62+
63+## Measured results
64+
65+On 2026-10-25, llmman on the host, `mm -config agent.yaml -p "…"`, `/api/ps` sampled twice a second:
66+
67+| Question | Sent to | Loaded, in order | Time |
68+|---|---|---|---|
69+| "explique-moi ce qu'est le pattern matching en Rust, avec un exemple" | rust ✅ | MiniCPM5 → nothing → Mellum2 → nothing → MiniCPM5 | 31 s |
70+| "how do I read a file line by line in Go?" | go ✅ | MiniCPM5 → nothing → Mellum2 → nothing → MiniCPM5 | 79 s |
71+
72+The two models were never listed together.
73+
74+## Hooks or option?
75+
76+`demos/coding-helpers` gets the same result with a `preAsk` and a `postAsk` hook (`hooks/unload.js`). The option needs no script, no Node.js, and works for any provider mm can unload with (llmman, Ollama, llama-server in router mode); each model is unloaded on its own agent's server. Hooks remain for what the option does not do: unloading another model, or only under a condition.
77+
78+See [How to free a model's memory](../../docs/en/how-to/unload-a-model.md) and the [`mmConfigs` reference](../../docs/en/reference/configuration.md).
new file mode 100644
@@ -0,0 +1,78 @@
1+# Coding helpers, with the `unloadModels` option
2+
3+*[Version française](README.fr.md)*
4+
5+The same team as [`demos/coding-helpers`](../coding-helpers/) — a coordinator that hands Go and Rust questions to a specialist — with one difference: the two models are never in the server's memory together, and no hook is needed for it. Each `mmConfigs` entry sets `unloadModels: true` (ticket 161).
6+
7+| Agent | File | Model | Role |
8+|---|---|---|---|
9+| 🧭 coordinator | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1.6 GB) | picks `go` or `rust`, sends the question with the conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8.1 GB) | documents Go |
11+| 🦀 rust | `agent.rust.yaml` | the same Mellum2 model | documents Rust |
12+
13+The two specialists are copies of those of `demos/coding-helpers`. Only `agent.yaml` differs: `unloadModels: true` on both entries, and no `hooks:` block.
14+
15+## Run
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers-unload-option
23+mm # reads ./agent.yaml
24+```
25+
26+Watch the server in another terminal: `llmman ps` (or `curl -s :17434/api/ps`).
27+
28+## The option
29+
30+```yaml
31+mmConfigs:
32+ go:
33+ path: agent.go.yaml
34+ description: >-
35+ Go specialist. …
36+ yolo: true
37+ unloadModels: true
38+ rust:
39+ path: agent.rust.yaml
40+ description: >-
41+ Rust specialist. …
42+ yolo: true
43+ unloadModels: true
44+```
45+
46+On every hand-off to `go` or `rust` — the coordinator's `ask_agent`, your `/ask-go` or `/ask-rust`:
47+
48+1. mm unloads the coordinator's model (MiniCPM5) right before the specialist starts;
49+2. the specialist loads Mellum2 and answers;
50+3. mm unloads Mellum2 once the answer is in, before the coordinator is reloaded to read it.
51+
52+Each unload is one line:
53+
54+```text
55+🗣 ask_agent: rust (llmman/huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M) — …
56+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
57+…the specialist's answer…
58+🧹 unloaded huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
59+```
60+
61+The price: Mellum2 is reloaded on every question, even two in a row to the same specialist. Without the option, it stays loaded beside the coordinator.
62+
63+## Measured results
64+
65+On 2026-10-25, llmman on the host, `mm -config agent.yaml -p "…"`, `/api/ps` sampled twice a second:
66+
67+| Question | Sent to | Loaded, in order | Time |
68+|---|---|---|---|
69+| "explique-moi ce qu'est le pattern matching en Rust, avec un exemple" | rust ✅ | MiniCPM5 → nothing → Mellum2 → nothing → MiniCPM5 | 31 s |
70+| "how do I read a file line by line in Go?" | go ✅ | MiniCPM5 → nothing → Mellum2 → nothing → MiniCPM5 | 79 s |
71+
72+The two models were never listed together.
73+
74+## Hooks or option?
75+
76+`demos/coding-helpers` gets the same result with a `preAsk` and a `postAsk` hook (`hooks/unload.js`). The option needs no script, no Node.js, and works for any provider mm can unload with (llmman, Ollama, llama-server in router mode); each model is unloaded on its own agent's server. Hooks remain for what the option does not do: unloading another model, or only under a condition.
77+
78+See [How to free a model's memory](../../docs/en/how-to/unload-a-model.md) and the [`mmConfigs` reference](../../docs/en/reference/configuration.md).
added demos/coding-helpers-unload-option/README.fr.md +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+# Coding helpers, avec l'option `unloadModels`
2+
3+*[English version](README.en.md)*
4+
5+La même équipe que [`demos/coding-helpers`](../coding-helpers/) — un coordinateur qui confie les questions de Go et de Rust à un spécialiste — avec une différence : les deux modèles ne sont jamais ensemble dans la mémoire du serveur, et aucun hook n'est nécessaire pour cela. Chaque entrée de `mmConfigs` met `unloadModels: true` (ticket 161).
6+
7+| Agent | Fichier | Modèle | Rôle |
8+|---|---|---|---|
9+| 🧭 coordinateur | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1,6 Go) | choisit `go` ou `rust`, envoie la question avec la conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8,1 Go) | documente Go |
11+| 🦀 rust | `agent.rust.yaml` | le même modèle Mellum2 | documente Rust |
12+
13+Les deux spécialistes sont des copies de ceux de `demos/coding-helpers`. Seul `agent.yaml` change : `unloadModels: true` sur les deux entrées, et pas de bloc `hooks:`.
14+
15+## Lancer
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers-unload-option
23+mm # lit ./agent.yaml
24+```
25+
26+Surveillez le serveur dans un autre terminal : `llmman ps` (ou `curl -s :17434/api/ps`).
27+
28+## L'option
29+
30+```yaml
31+mmConfigs:
32+ go:
33+ path: agent.go.yaml
34+ description: >-
35+ Go specialist. …
36+ yolo: true
37+ unloadModels: true
38+ rust:
39+ path: agent.rust.yaml
40+ description: >-
41+ Rust specialist. …
42+ yolo: true
43+ unloadModels: true
44+```
45+
46+À chaque passage de main à `go` ou `rust` — l'`ask_agent` du coordinateur, votre `/ask-go` ou `/ask-rust` :
47+
48+1. mm décharge le modèle du coordinateur (MiniCPM5) juste avant que le spécialiste démarre ;
49+2. le spécialiste charge Mellum2 et répond ;
50+3. mm décharge Mellum2 une fois la réponse reçue, avant que le coordinateur soit rechargé pour la lire.
51+
52+Chaque déchargement tient en une ligne :
53+
54+```text
55+🗣 ask_agent: rust (llmman/huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M) — …
56+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
57+…la réponse du spécialiste…
58+🧹 unloaded huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
59+```
60+
61+Le prix : Mellum2 est rechargé à chaque question, même pour deux questions de suite au même spécialiste. Sans l'option, il reste chargé à côté du coordinateur.
62+
63+## Résultats mesurés
64+
65+Le 2026-10-25, llmman sur l'hôte, `mm -config agent.yaml -p "…"`, `/api/ps` interrogé deux fois par seconde :
66+
67+| Question | Envoyée à | Chargé, dans l'ordre | Durée |
68+|---|---|---|---|
69+| « explique-moi ce qu'est le pattern matching en Rust, avec un exemple » | rust ✅ | MiniCPM5 → rien → Mellum2 → rien → MiniCPM5 | 31 s |
70+| « how do I read a file line by line in Go? » | go ✅ | MiniCPM5 → rien → Mellum2 → rien → MiniCPM5 | 79 s |
71+
72+Les deux modèles n'ont jamais été listés ensemble.
73+
74+## Hooks ou option ?
75+
76+`demos/coding-helpers` obtient le même résultat avec un hook `preAsk` et un hook `postAsk` (`hooks/unload.js`). L'option ne demande ni script ni Node.js, et marche avec tout fournisseur que mm sait décharger (llmman, Ollama, llama-server en mode router) ; chaque modèle est déchargé sur le serveur de son agent. Les hooks restent pour ce que l'option ne fait pas : décharger un autre modèle, ou seulement sous condition.
77+
78+Voir [Libérer la mémoire d'un modèle](../../docs/fr/how-to/unload-a-model.md) et la [référence de `mmConfigs`](../../docs/fr/reference/configuration.md).
new file mode 100644
@@ -0,0 +1,78 @@
1+# Coding helpers, avec l'option `unloadModels`
2+
3+*[English version](README.en.md)*
4+
5+La même équipe que [`demos/coding-helpers`](../coding-helpers/) — un coordinateur qui confie les questions de Go et de Rust à un spécialiste — avec une différence : les deux modèles ne sont jamais ensemble dans la mémoire du serveur, et aucun hook n'est nécessaire pour cela. Chaque entrée de `mmConfigs` met `unloadModels: true` (ticket 161).
6+
7+| Agent | Fichier | Modèle | Rôle |
8+|---|---|---|---|
9+| 🧭 coordinateur | `agent.yaml` | MiniCPM5 2B (`hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M`, 1,6 Go) | choisit `go` ou `rust`, envoie la question avec la conversation |
10+| 🐹 go | `agent.go.yaml` | Mellum2 12B A2.5B thinking (`huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M`, 8,1 Go) | documente Go |
11+| 🦀 rust | `agent.rust.yaml` | le même modèle Mellum2 | documente Rust |
12+
13+Les deux spécialistes sont des copies de ceux de `demos/coding-helpers`. Seul `agent.yaml` change : `unloadModels: true` sur les deux entrées, et pas de bloc `hooks:`.
14+
15+## Lancer
16+
17+```bash
18+llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
19+llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
20+LLMMAN_CONTEXT_LENGTH=32768 llmman serve
21+
22+cd demos/coding-helpers-unload-option
23+mm # lit ./agent.yaml
24+```
25+
26+Surveillez le serveur dans un autre terminal : `llmman ps` (ou `curl -s :17434/api/ps`).
27+
28+## L'option
29+
30+```yaml
31+mmConfigs:
32+ go:
33+ path: agent.go.yaml
34+ description: >-
35+ Go specialist. …
36+ yolo: true
37+ unloadModels: true
38+ rust:
39+ path: agent.rust.yaml
40+ description: >-
41+ Rust specialist. …
42+ yolo: true
43+ unloadModels: true
44+```
45+
46+À chaque passage de main à `go` ou `rust` — l'`ask_agent` du coordinateur, votre `/ask-go` ou `/ask-rust` :
47+
48+1. mm décharge le modèle du coordinateur (MiniCPM5) juste avant que le spécialiste démarre ;
49+2. le spécialiste charge Mellum2 et répond ;
50+3. mm décharge Mellum2 une fois la réponse reçue, avant que le coordinateur soit rechargé pour la lire.
51+
52+Chaque déchargement tient en une ligne :
53+
54+```text
55+🗣 ask_agent: rust (llmman/huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M) — …
56+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
57+…la réponse du spécialiste…
58+🧹 unloaded huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
59+```
60+
61+Le prix : Mellum2 est rechargé à chaque question, même pour deux questions de suite au même spécialiste. Sans l'option, il reste chargé à côté du coordinateur.
62+
63+## Résultats mesurés
64+
65+Le 2026-10-25, llmman sur l'hôte, `mm -config agent.yaml -p "…"`, `/api/ps` interrogé deux fois par seconde :
66+
67+| Question | Envoyée à | Chargé, dans l'ordre | Durée |
68+|---|---|---|---|
69+| « explique-moi ce qu'est le pattern matching en Rust, avec un exemple » | rust ✅ | MiniCPM5 → rien → Mellum2 → rien → MiniCPM5 | 31 s |
70+| « how do I read a file line by line in Go? » | go ✅ | MiniCPM5 → rien → Mellum2 → rien → MiniCPM5 | 79 s |
71+
72+Les deux modèles n'ont jamais été listés ensemble.
73+
74+## Hooks ou option ?
75+
76+`demos/coding-helpers` obtient le même résultat avec un hook `preAsk` et un hook `postAsk` (`hooks/unload.js`). L'option ne demande ni script ni Node.js, et marche avec tout fournisseur que mm sait décharger (llmman, Ollama, llama-server en mode router) ; chaque modèle est déchargé sur le serveur de son agent. Les hooks restent pour ce que l'option ne fait pas : décharger un autre modèle, ou seulement sous condition.
77+
78+Voir [Libérer la mémoire d'un modèle](../../docs/fr/how-to/unload-a-model.md) et la [référence de `mmConfigs`](../../docs/fr/reference/configuration.md).
added demos/coding-helpers-unload-option/agent.go.yaml +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# Go specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.go.yaml`. Interactive documentation: it
3+# explains a Go concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+#
10+# A copy of demos/coding-helpers/agent.go.yaml: the specialists are unchanged.
11+
12+provider: llmman
13+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
14+baseUrl: http://127.0.0.1:17434/v1
15+fallback: http://host.docker.internal:17434/v1
16+contextWindow: 32768
17+
18+sampling:
19+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
20+ # thinking models loop).
21+ temperature: 0.6
22+ top_p: 0.95
23+ # The reasoning plus an explanation with examples.
24+ max_tokens: 8192
25+ # On by default in the template; written so the choice is visible.
26+ chat_template_kwargs:
27+ enable_thinking: true
28+
29+showThinking: false
30+watchdogTimeout: 3m
31+firstTokenTimeout: 3m
32+
33+# No tools: one answer.
34+maxTurns: 2
35+maxOutput: 20000
36+
37+yolo: false
38+bashTool: false
39+editTools: false
40+planTool: false
41+fetchContentTool: false
42+askUserTool: false
43+skillsDir: no-skills
44+
45+agentsMd:
46+ enabled: false
47+
48+sessions:
49+ enabled: false
50+
51+acp:
52+ name: go
53+ title: 🐹 Go specialist
54+
55+system: |
56+ You are Go documentation that answers questions. People ask what a Go
57+ concept is or how something is done in Go; you explain it and show
58+ examples, like a good reference page would.
59+
60+ If the conversation so far is included, read it: "the same thing" means
61+ what was asked or shown before — explain it for Go.
62+
63+ Your answer:
64+ 1. What it is and when to use it, in a few sentences.
65+ 2. Short examples in ```go code blocks — a snippet per point, only
66+ the lines that show the idea (no main function, no imports unless they
67+ matter to the point).
68+ 3. The pitfalls or the idiomatic way, in a short list, when there are
69+ any.
70+
71+ RULES
72+ - Only Go. A question about another language (Python, Java…): answer in
73+ one sentence that you document Go only — never answer it with Go.
74+ - You document; you do not build. Write a complete, runnable program only
75+ when the user explicitly asks for one ("write me a program", "a full
76+ example I can run").
77+ - Standard library unless the question names a package.
78+ - Keep it short and correct: an example that does not compile is worse
79+ than no example.
new file mode 100644
@@ -0,0 +1,79 @@
1+# Go specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.go.yaml`. Interactive documentation: it
3+# explains a Go concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+#
10+# A copy of demos/coding-helpers/agent.go.yaml: the specialists are unchanged.
11+
12+provider: llmman
13+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
14+baseUrl: http://127.0.0.1:17434/v1
15+fallback: http://host.docker.internal:17434/v1
16+contextWindow: 32768
17+
18+sampling:
19+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
20+ # thinking models loop).
21+ temperature: 0.6
22+ top_p: 0.95
23+ # The reasoning plus an explanation with examples.
24+ max_tokens: 8192
25+ # On by default in the template; written so the choice is visible.
26+ chat_template_kwargs:
27+ enable_thinking: true
28+
29+showThinking: false
30+watchdogTimeout: 3m
31+firstTokenTimeout: 3m
32+
33+# No tools: one answer.
34+maxTurns: 2
35+maxOutput: 20000
36+
37+yolo: false
38+bashTool: false
39+editTools: false
40+planTool: false
41+fetchContentTool: false
42+askUserTool: false
43+skillsDir: no-skills
44+
45+agentsMd:
46+ enabled: false
47+
48+sessions:
49+ enabled: false
50+
51+acp:
52+ name: go
53+ title: 🐹 Go specialist
54+
55+system: |
56+ You are Go documentation that answers questions. People ask what a Go
57+ concept is or how something is done in Go; you explain it and show
58+ examples, like a good reference page would.
59+
60+ If the conversation so far is included, read it: "the same thing" means
61+ what was asked or shown before — explain it for Go.
62+
63+ Your answer:
64+ 1. What it is and when to use it, in a few sentences.
65+ 2. Short examples in ```go code blocks — a snippet per point, only
66+ the lines that show the idea (no main function, no imports unless they
67+ matter to the point).
68+ 3. The pitfalls or the idiomatic way, in a short list, when there are
69+ any.
70+
71+ RULES
72+ - Only Go. A question about another language (Python, Java…): answer in
73+ one sentence that you document Go only — never answer it with Go.
74+ - You document; you do not build. Write a complete, runnable program only
75+ when the user explicitly asks for one ("write me a program", "a full
76+ example I can run").
77+ - Standard library unless the question names a package.
78+ - Keep it short and correct: an example that does not compile is worse
79+ than no example.
added demos/coding-helpers-unload-option/agent.rust.yaml +79 -0
new file mode 100644
@@ -0,0 +1,79 @@
1+# Rust specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.rust.yaml`. Interactive documentation: it
3+# explains a Rust concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+#
10+# A copy of demos/coding-helpers/agent.rust.yaml: the specialists are unchanged.
11+
12+provider: llmman
13+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
14+baseUrl: http://127.0.0.1:17434/v1
15+fallback: http://host.docker.internal:17434/v1
16+contextWindow: 32768
17+
18+sampling:
19+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
20+ # thinking models loop).
21+ temperature: 0.6
22+ top_p: 0.95
23+ # The reasoning plus an explanation with examples.
24+ max_tokens: 8192
25+ # On by default in the template; written so the choice is visible.
26+ chat_template_kwargs:
27+ enable_thinking: true
28+
29+showThinking: false
30+watchdogTimeout: 3m
31+firstTokenTimeout: 3m
32+
33+# No tools: one answer.
34+maxTurns: 2
35+maxOutput: 20000
36+
37+yolo: false
38+bashTool: false
39+editTools: false
40+planTool: false
41+fetchContentTool: false
42+askUserTool: false
43+skillsDir: no-skills
44+
45+agentsMd:
46+ enabled: false
47+
48+sessions:
49+ enabled: false
50+
51+acp:
52+ name: rust
53+ title: 🦀 Rust specialist
54+
55+system: |
56+ You are Rust documentation that answers questions. People ask what a Rust
57+ concept is or how something is done in Rust; you explain it and show
58+ examples, like a good reference page would.
59+
60+ If the conversation so far is included, read it: "the same thing" means
61+ what was asked or shown before — explain it for Rust.
62+
63+ Your answer:
64+ 1. What it is and when to use it, in a few sentences.
65+ 2. Short examples in ```rust code blocks — a snippet per point, only
66+ the lines that show the idea (no main function, no imports unless they
67+ matter to the point).
68+ 3. The pitfalls or the idiomatic way, in a short list, when there are
69+ any.
70+
71+ RULES
72+ - Only Rust. A question about another language (Python, Java…): answer in
73+ one sentence that you document Rust only — never answer it with Rust.
74+ - You document; you do not build. Write a complete, runnable program only
75+ when the user explicitly asks for one ("write me a program", "a full
76+ example I can run").
77+ - When an example needs a crate (serde…), name it and give its Cargo.toml line.
78+ - Keep it short and correct: an example that does not compile is worse
79+ than no example.
new file mode 100644
@@ -0,0 +1,79 @@
1+# Rust specialist — asked by the coordinator (agent.yaml) through `ask_agent`,
2+# or by you directly: `mm agent.rust.yaml`. Interactive documentation: it
3+# explains a Rust concept with short examples in its answer. No tools — it
4+# builds nothing, writes no file, and only writes a whole program when asked
5+# to.
6+#
7+# Mellum2 12B A2.5B thinking (JetBrains, a mixture of experts: 12B weights,
8+# 2.5B active per token): a code model that reasons before answering.
9+#
10+# A copy of demos/coding-helpers/agent.rust.yaml: the specialists are unchanged.
11+
12+provider: llmman
13+model: huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
14+baseUrl: http://127.0.0.1:17434/v1
15+fallback: http://host.docker.internal:17434/v1
16+contextWindow: 32768
17+
18+sampling:
19+ # No sampling in the GGUF; values usual for a thinking model (never 0.0:
20+ # thinking models loop).
21+ temperature: 0.6
22+ top_p: 0.95
23+ # The reasoning plus an explanation with examples.
24+ max_tokens: 8192
25+ # On by default in the template; written so the choice is visible.
26+ chat_template_kwargs:
27+ enable_thinking: true
28+
29+showThinking: false
30+watchdogTimeout: 3m
31+firstTokenTimeout: 3m
32+
33+# No tools: one answer.
34+maxTurns: 2
35+maxOutput: 20000
36+
37+yolo: false
38+bashTool: false
39+editTools: false
40+planTool: false
41+fetchContentTool: false
42+askUserTool: false
43+skillsDir: no-skills
44+
45+agentsMd:
46+ enabled: false
47+
48+sessions:
49+ enabled: false
50+
51+acp:
52+ name: rust
53+ title: 🦀 Rust specialist
54+
55+system: |
56+ You are Rust documentation that answers questions. People ask what a Rust
57+ concept is or how something is done in Rust; you explain it and show
58+ examples, like a good reference page would.
59+
60+ If the conversation so far is included, read it: "the same thing" means
61+ what was asked or shown before — explain it for Rust.
62+
63+ Your answer:
64+ 1. What it is and when to use it, in a few sentences.
65+ 2. Short examples in ```rust code blocks — a snippet per point, only
66+ the lines that show the idea (no main function, no imports unless they
67+ matter to the point).
68+ 3. The pitfalls or the idiomatic way, in a short list, when there are
69+ any.
70+
71+ RULES
72+ - Only Rust. A question about another language (Python, Java…): answer in
73+ one sentence that you document Rust only — never answer it with Rust.
74+ - You document; you do not build. Write a complete, runnable program only
75+ when the user explicitly asks for one ("write me a program", "a full
76+ example I can run").
77+ - When an example needs a crate (serde…), name it and give its Cargo.toml line.
78+ - Keep it short and correct: an example that does not compile is worse
79+ than no example.
added demos/coding-helpers-unload-option/agent.yaml +117 -0
new file mode 100644
@@ -0,0 +1,117 @@
1+# Coding helpers — interactive documentation for Go and Rust: ask what a
2+# concept is or how something is done, get an explanation with short
3+# examples. You talk with this coordinator; it picks the specialist with
4+# the built-in `ask_agent` tool:
5+#
6+# go — agent.go.yaml, a Go specialist
7+# rust — agent.rust.yaml, a Rust specialist
8+#
9+# "hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON"
10+# "comment je fais la même chose en Rust ?"
11+# "explique-moi ce qu'est le pattern matching en Rust, avec un exemple"
12+#
13+# The second question only makes sense after the first: the coordinator
14+# sends the conversation along (`with_context`), so the Rust specialist
15+# knows what "the same thing" is.
16+#
17+# Models — never in memory together, so at most 8.1 GB at a time:
18+#
19+# coordinator MiniCPM5 2B hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M 1.6 GB
20+# go, rust Mellum2 12B thinking huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M 8.1 GB (one model for both)
21+#
22+# unloadModels: true on both mmConfigs entries (ticket 161): mm unloads
23+# the COORDINATOR's model right before a specialist starts, and the
24+# SPECIALIST's model once it has answered, before the coordinator is
25+# reloaded to read the answer. No hook: demos/coding-helpers does the
26+# same with hooks/unload.js.
27+#
28+# llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
29+# llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
30+# LLMMAN_CONTEXT_LENGTH=32768 llmman serve
31+#
32+# cd demos/coding-helpers-unload-option
33+# mm (reads ./agent.yaml)
34+
35+provider: llmman
36+model: hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
37+baseUrl: http://127.0.0.1:17434/v1
38+fallback: http://host.docker.internal:17434/v1
39+contextWindow: 32768
40+
41+# The team. The description is what the coordinator reads to choose;
42+# unloadModels: true makes the two models take turns in memory (the price:
43+# Mellum2 is reloaded on every question).
44+mmConfigs:
45+ go:
46+ path: agent.go.yaml
47+ description: >-
48+ Go specialist. Explains Go concepts and how to do something in Go,
49+ with short examples, like documentation. For any question about Go.
50+ yolo: true
51+ unloadModels: true
52+ rust:
53+ path: agent.rust.yaml
54+ description: >-
55+ Rust specialist. Explains Rust concepts and how to do something in
56+ Rust, with short examples, like documentation. For any question about
57+ Rust.
58+ yolo: true
59+ unloadModels: true
60+
61+sampling:
62+ # The GGUF says 1.0 / 0.95; routing wants a steady hand.
63+ temperature: 0.3
64+ top_p: 0.95
65+ max_tokens: 2048
66+ parallel_tool_calls: false
67+ # MiniCPM5's template reads enable_thinking and has no default: set it.
68+ chat_template_kwargs:
69+ enable_thinking: false
70+
71+# During ask_agent every event of the specialist counts as a sign of life;
72+# a 12B thinking model may reason a while before its first word.
73+watchdogTimeout: 5m
74+firstTokenTimeout: 3m
75+
76+# one call, one sentence.
77+maxTurns: 3
78+maxOutput: 20000
79+displayCommands: true
80+
81+yolo: true
82+bashTool: false
83+editTools: false
84+planTool: false
85+fetchContentTool: false
86+askUserTool: false
87+skillsDir: no-skills
88+
89+agentsMd:
90+ enabled: false
91+
92+sessions:
93+ enabled: false
94+
95+acp:
96+ name: coding-helpers-unload-option
97+ title: 🧭 Coding helpers (Go, Rust)
98+
99+# Written for a 2B model: short, no fake call written out in the text —
100+# measured, MiniCPM5 copies such patterns into its answer instead of calling
101+# the tool.
102+system: |
103+ You are the front desk of two programming specialists: go and rust.
104+
105+ When the user asks something about programming, call the ask_agent tool
106+ once:
107+ - agent: go if the question is about Go, rust if it is about Rust. When
108+ the user says "the same thing", "la même chose", "now in …", pick the
109+ language they name now.
110+ - prompt: the user's message, copied exactly.
111+ - with_context: true, so the specialist sees the conversation so far.
112+
113+ When the tool has answered, write one short sentence in the user's
114+ language saying the explanation is above.
115+
116+ If the language is neither Go nor Rust, say in one sentence that you
117+ only know Go and Rust. When the user only greets you, greet back.
new file mode 100644
@@ -0,0 +1,117 @@
1+# Coding helpers — interactive documentation for Go and Rust: ask what a
2+# concept is or how something is done, get an explanation with short
3+# examples. You talk with this coordinator; it picks the specialist with
4+# the built-in `ask_agent` tool:
5+#
6+# go — agent.go.yaml, a Go specialist
7+# rust — agent.rust.yaml, a Rust specialist
8+#
9+# "hey, explique-moi comment utiliser une struct Go et la sérialiser en JSON"
10+# "comment je fais la même chose en Rust ?"
11+# "explique-moi ce qu'est le pattern matching en Rust, avec un exemple"
12+#
13+# The second question only makes sense after the first: the coordinator
14+# sends the conversation along (`with_context`), so the Rust specialist
15+# knows what "the same thing" is.
16+#
17+# Models — never in memory together, so at most 8.1 GB at a time:
18+#
19+# coordinator MiniCPM5 2B hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M 1.6 GB
20+# go, rust Mellum2 12B thinking huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M 8.1 GB (one model for both)
21+#
22+# unloadModels: true on both mmConfigs entries (ticket 161): mm unloads
23+# the COORDINATOR's model right before a specialist starts, and the
24+# SPECIALIST's model once it has answered, before the coordinator is
25+# reloaded to read the answer. No hook: demos/coding-helpers does the
26+# same with hooks/unload.js.
27+#
28+# llmman pull hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
29+# llmman pull huggingface.co/jetbrains/mellum2-12b-a2.5b-thinking-gguf-q4_k_m:Q4_K_M
30+# LLMMAN_CONTEXT_LENGTH=32768 llmman serve
31+#
32+# cd demos/coding-helpers-unload-option
33+# mm (reads ./agent.yaml)
34+
35+provider: llmman
36+model: hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
37+baseUrl: http://127.0.0.1:17434/v1
38+fallback: http://host.docker.internal:17434/v1
39+contextWindow: 32768
40+
41+# The team. The description is what the coordinator reads to choose;
42+# unloadModels: true makes the two models take turns in memory (the price:
43+# Mellum2 is reloaded on every question).
44+mmConfigs:
45+ go:
46+ path: agent.go.yaml
47+ description: >-
48+ Go specialist. Explains Go concepts and how to do something in Go,
49+ with short examples, like documentation. For any question about Go.
50+ yolo: true
51+ unloadModels: true
52+ rust:
53+ path: agent.rust.yaml
54+ description: >-
55+ Rust specialist. Explains Rust concepts and how to do something in
56+ Rust, with short examples, like documentation. For any question about
57+ Rust.
58+ yolo: true
59+ unloadModels: true
60+
61+sampling:
62+ # The GGUF says 1.0 / 0.95; routing wants a steady hand.
63+ temperature: 0.3
64+ top_p: 0.95
65+ max_tokens: 2048
66+ parallel_tool_calls: false
67+ # MiniCPM5's template reads enable_thinking and has no default: set it.
68+ chat_template_kwargs:
69+ enable_thinking: false
70+
71+# During ask_agent every event of the specialist counts as a sign of life;
72+# a 12B thinking model may reason a while before its first word.
73+watchdogTimeout: 5m
74+firstTokenTimeout: 3m
75+
76+# one call, one sentence.
77+maxTurns: 3
78+maxOutput: 20000
79+displayCommands: true
80+
81+yolo: true
82+bashTool: false
83+editTools: false
84+planTool: false
85+fetchContentTool: false
86+askUserTool: false
87+skillsDir: no-skills
88+
89+agentsMd:
90+ enabled: false
91+
92+sessions:
93+ enabled: false
94+
95+acp:
96+ name: coding-helpers-unload-option
97+ title: 🧭 Coding helpers (Go, Rust)
98+
99+# Written for a 2B model: short, no fake call written out in the text —
100+# measured, MiniCPM5 copies such patterns into its answer instead of calling
101+# the tool.
102+system: |
103+ You are the front desk of two programming specialists: go and rust.
104+
105+ When the user asks something about programming, call the ask_agent tool
106+ once:
107+ - agent: go if the question is about Go, rust if it is about Rust. When
108+ the user says "the same thing", "la même chose", "now in …", pick the
109+ language they name now.
110+ - prompt: the user's message, copied exactly.
111+ - with_context: true, so the specialist sees the conversation so far.
112+
113+ When the tool has answered, write one short sentence in the user's
114+ language saying the explanation is above.
115+
116+ If the language is neither Go nor Rust, say in one sentence that you
117+ only know Go and Rust. When the user only greets you, greet back.
modified docs/en/how-to/let-the-agent-call-other-agents.md +1 -0
@@ -63,6 +63,7 @@ This guide shows how to give your agent a team: other mm agents it hands work to
6363 - **Send the conversation along.** The model sets `with_context: true` when the agent needs what was said — a reviewer giving a second opinion. Say so in the agent's description ("call it with with_context true").
6464 - **Ask an agent yourself.** `/ask-<name>` and `/ask-with-context-<name>` still work, and keep the exchange in the history. See [How to ask another mm agent](ask-another-agent.md).
6565 - **Long work.** Each event the agent streams keeps your agent's watchdog alive, but a long silence — a large model loading, a long first prompt — does not: raise your `watchdogTimeout` to cover it (10m for a 48 GB model on a laptop).
66+- **Not enough memory for both models.** Add `unloadModels: true` to the agent's entry: your agent's model is unloaded before it starts, its model once it has answered. See [How to free a model's memory](unload-a-model.md#never-load-two-models-together-during-a-hand-off).
6667 - **From a script.** `mm --ask <name>` remains the way for a shell command. See [How to ask another agent from a skill or a script](ask-another-agent-from-a-skill.md).
6768 - **Working examples.** `demos/llmman/agent.llmman.yaml` (Bob with Jan, Lucy and a reviewer) and `demos/mini-mes/riker/agent.riker.yaml` (a coordinator, a specifier and a coder).
6869
@@ -63,6 +63,7 @@ This guide shows how to give your agent a team: other mm agents it hands work to
63 - **Send the conversation along.** The model sets `with_context: true` when the agent needs what was said — a reviewer giving a second opinion. Say so in the agent's description ("call it with with_context true").63 - **Send the conversation along.** The model sets `with_context: true` when the agent needs what was said — a reviewer giving a second opinion. Say so in the agent's description ("call it with with_context true").
64 - **Ask an agent yourself.** `/ask-<name>` and `/ask-with-context-<name>` still work, and keep the exchange in the history. See [How to ask another mm agent](ask-another-agent.md).64 - **Ask an agent yourself.** `/ask-<name>` and `/ask-with-context-<name>` still work, and keep the exchange in the history. See [How to ask another mm agent](ask-another-agent.md).
65 - **Long work.** Each event the agent streams keeps your agent's watchdog alive, but a long silence — a large model loading, a long first prompt — does not: raise your `watchdogTimeout` to cover it (10m for a 48 GB model on a laptop).65 - **Long work.** Each event the agent streams keeps your agent's watchdog alive, but a long silence — a large model loading, a long first prompt — does not: raise your `watchdogTimeout` to cover it (10m for a 48 GB model on a laptop).
66+- **Not enough memory for both models.** Add `unloadModels: true` to the agent's entry: your agent's model is unloaded before it starts, its model once it has answered. See [How to free a model's memory](unload-a-model.md#never-load-two-models-together-during-a-hand-off).
66 - **From a script.** `mm --ask <name>` remains the way for a shell command. See [How to ask another agent from a skill or a script](ask-another-agent-from-a-skill.md).67 - **From a script.** `mm --ask <name>` remains the way for a shell command. See [How to ask another agent from a skill or a script](ask-another-agent-from-a-skill.md).
67 - **Working examples.** `demos/llmman/agent.llmman.yaml` (Bob with Jan, Lucy and a reviewer) and `demos/mini-mes/riker/agent.riker.yaml` (a coordinator, a specifier and a coder).68 - **Working examples.** `demos/llmman/agent.llmman.yaml` (Bob with Jan, Lucy and a reviewer) and `demos/mini-mes/riker/agent.riker.yaml` (a coordinator, a specifier and a coder).
68 69
modified docs/en/how-to/unload-a-model.md +23 -1
@@ -45,10 +45,32 @@ system: |
4545
4646 Write the model's name exactly as the server lists it: the model cannot guess it. `demos/unload-models/` does the expert's unload with a `postAsk` hook instead (see the variants below); its coordinator still calls `unload_model` when the user asks it to free the expert's memory.
4747
48+## Never load two models together during a hand-off
49+
50+To have the two models of a hand-off take turns in memory, set `unloadModels: true` on the asked agent's `mmConfigs` entry — no prompt, no hook:
51+
52+```yaml
53+mmConfigs:
54+ expert:
55+ path: agent.expert.yaml
56+ description: Programming expert.
57+ unloadModels: true
58+```
59+
60+On every hand-off to `expert` (`ask_agent`, `/ask-expert`, `mm --ask expert`), mm unloads this agent's model right before the expert starts, and the expert's model once it has answered, before this agent is reloaded to read the answer. Each unload shows one line:
61+
62+```text
63+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
64+…the expert's answer…
65+🧹 unloaded hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M
66+```
67+
68+The price is a reload of the expert's model on every question. `demos/coding-helpers-unload-option/` does it for two specialists; `demos/coding-helpers/` does the same with hooks.
69+
4870 ## Variants
4971
5072 - **The agent's own model.** `unload_model` with an empty `model` unloads it, but only until the agent's next step: the agent needs its model to read the tool's result, and the server reloads it. Use `/unload` between two questions instead — or, to free it while another agent works, a `preAsk` hook that unloads the model named in its input (`"model": "<provider>/<model>"`) right before the asked agent starts: `demos/unload-models/hooks/unload.js self`.
51-- **A guarantee instead of an instruction.** A small model may skip the step (measured: 4 times out of 5 with MiniCPM5 2B in the first version of `demos/unload-models`), and the agent's own model is already reloaded beside the other one when it calls the tool. For a certain unload, with the two models never loaded together, have a `postAsk` hook call the server itself — `curl -s <server>/api/generate -d '{"model":"…","keep_alive":0}'` — once the asked agent has answered and before the asking agent reads the answer: `demos/unload-models/hooks/unload.js asked` reads the asked agent's model from `ask.agentModel`. See [Control the hand-offs with hooks](control-agent-calls-with-hooks.md).
73+- **A guarantee instead of an instruction.** The `unloadModels` option above is the simplest. Hooks remain for an unload it does not cover — another model than the two of the hand-off, or one that depends on a condition. A small model may skip the step (measured: 4 times out of 5 with MiniCPM5 2B in the first version of `demos/unload-models`), and the agent's own model is already reloaded beside the other one when it calls the tool. For a certain unload, with the two models never loaded together, have a `postAsk` hook call the server itself — `curl -s <server>/api/generate -d '{"model":"…","keep_alive":0}'` — once the asked agent has answered and before the asking agent reads the answer: `demos/unload-models/hooks/unload.js asked` reads the asked agent's model from `ask.agentModel`. See [Control the hand-offs with hooks](control-agent-calls-with-hooks.md).
5274 - **llama-server.** Only router mode (started without `-m`) can unload a model. A single-model server answers with the alternatives: `--sleep-idle-seconds N` frees the memory after N idle seconds, or stop the server.
5375 - **Turn it off.** `unloadModels: false` removes both `/unload` and the tool — for an agent that must not have one more tool, such as a guard or a narrow specialist.
5476
@@ -45,10 +45,32 @@ system: |
45 45
46 Write the model's name exactly as the server lists it: the model cannot guess it. `demos/unload-models/` does the expert's unload with a `postAsk` hook instead (see the variants below); its coordinator still calls `unload_model` when the user asks it to free the expert's memory.46 Write the model's name exactly as the server lists it: the model cannot guess it. `demos/unload-models/` does the expert's unload with a `postAsk` hook instead (see the variants below); its coordinator still calls `unload_model` when the user asks it to free the expert's memory.
47 47
48+## Never load two models together during a hand-off
49+
50+To have the two models of a hand-off take turns in memory, set `unloadModels: true` on the asked agent's `mmConfigs` entry — no prompt, no hook:
51+
52+```yaml
53+mmConfigs:
54+ expert:
55+ path: agent.expert.yaml
56+ description: Programming expert.
57+ unloadModels: true
58+```
59+
60+On every hand-off to `expert` (`ask_agent`, `/ask-expert`, `mm --ask expert`), mm unloads this agent's model right before the expert starts, and the expert's model once it has answered, before this agent is reloaded to read the answer. Each unload shows one line:
61+
62+```text
63+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
64+…the expert's answer…
65+🧹 unloaded hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M
66+```
67+
68+The price is a reload of the expert's model on every question. `demos/coding-helpers-unload-option/` does it for two specialists; `demos/coding-helpers/` does the same with hooks.
69+
48 ## Variants70 ## Variants
49 71
50 - **The agent's own model.** `unload_model` with an empty `model` unloads it, but only until the agent's next step: the agent needs its model to read the tool's result, and the server reloads it. Use `/unload` between two questions instead — or, to free it while another agent works, a `preAsk` hook that unloads the model named in its input (`"model": "<provider>/<model>"`) right before the asked agent starts: `demos/unload-models/hooks/unload.js self`.72 - **The agent's own model.** `unload_model` with an empty `model` unloads it, but only until the agent's next step: the agent needs its model to read the tool's result, and the server reloads it. Use `/unload` between two questions instead — or, to free it while another agent works, a `preAsk` hook that unloads the model named in its input (`"model": "<provider>/<model>"`) right before the asked agent starts: `demos/unload-models/hooks/unload.js self`.
51-- **A guarantee instead of an instruction.** A small model may skip the step (measured: 4 times out of 5 with MiniCPM5 2B in the first version of `demos/unload-models`), and the agent's own model is already reloaded beside the other one when it calls the tool. For a certain unload, with the two models never loaded together, have a `postAsk` hook call the server itself — `curl -s <server>/api/generate -d '{"model":"…","keep_alive":0}'` — once the asked agent has answered and before the asking agent reads the answer: `demos/unload-models/hooks/unload.js asked` reads the asked agent's model from `ask.agentModel`. See [Control the hand-offs with hooks](control-agent-calls-with-hooks.md).73+- **A guarantee instead of an instruction.** The `unloadModels` option above is the simplest. Hooks remain for an unload it does not cover — another model than the two of the hand-off, or one that depends on a condition. A small model may skip the step (measured: 4 times out of 5 with MiniCPM5 2B in the first version of `demos/unload-models`), and the agent's own model is already reloaded beside the other one when it calls the tool. For a certain unload, with the two models never loaded together, have a `postAsk` hook call the server itself — `curl -s <server>/api/generate -d '{"model":"…","keep_alive":0}'` — once the asked agent has answered and before the asking agent reads the answer: `demos/unload-models/hooks/unload.js asked` reads the asked agent's model from `ask.agentModel`. See [Control the hand-offs with hooks](control-agent-calls-with-hooks.md).
52 - **llama-server.** Only router mode (started without `-m`) can unload a model. A single-model server answers with the alternatives: `--sleep-idle-seconds N` frees the memory after N idle seconds, or stop the server.74 - **llama-server.** Only router mode (started without `-m`) can unload a model. A single-model server answers with the alternatives: `--sleep-idle-seconds N` frees the memory after N idle seconds, or stop the server.
53 - **Turn it off.** `unloadModels: false` removes both `/unload` and the tool — for an agent that must not have one more tool, such as a guard or a narrow specialist.75 - **Turn it off.** `unloadModels: false` removes both `/unload` and the tool — for an agent that must not have one more tool, such as a guard or a narrow specialist.
54 76
modified docs/en/reference/configuration.md +1 -1
@@ -48,7 +48,7 @@
4848 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
4949 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
5050 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |
51-| `mmConfigs` | map name → path, or name → `{path, description, yolo, askUser}` | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)), and the list declares the [`ask_agent`](tools.md#ask_agent) tool, through which the model calls them itself. The long form adds `description` (when to call that agent; shown in `ask_agent`'s description, falling back to the agent file's `acp.title`) and `yolo` (decides that agent's tool calls when it is asked, by `/ask`, `ask_agent` or `mm --ask`, instead of this agent's `yolo`: `true` they run, `false` each one is asked; absent, this agent's `yolo` decides) and `askUser` (whether that agent may ask the user questions when it is asked: `true` its `ask_user` questions come up to this agent's user, `false` its `ask_user` tool is not declared; absent, that agent's own `askUserTool` decides). `path` must not be empty. A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. |
51+| `mmConfigs` | map name → path, or name → `{path, description, yolo, askUser, unloadModels}` | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)), and the list declares the [`ask_agent`](tools.md#ask_agent) tool, through which the model calls them itself. The long form adds `description` (when to call that agent; shown in `ask_agent`'s description, falling back to the agent file's `acp.title`) and `yolo` (decides that agent's tool calls when it is asked, by `/ask`, `ask_agent` or `mm --ask`, instead of this agent's `yolo`: `true` they run, `false` each one is asked; absent, this agent's `yolo` decides) and `askUser` (whether that agent may ask the user questions when it is asked: `true` its `ask_user` questions come up to this agent's user, `false` its `ask_user` tool is not declared; absent, that agent's own `askUserTool` decides) and `unloadModels` (default `false`; `true`: every hand-off to that agent — `ask_agent`, `/ask…`, `mm --ask` — unloads this agent's model right before that agent starts, and that agent's model once it has answered, before this agent reads the answer, so the two models are never loaded together; each unload is a `🧹` notice, a failed one does not stop the hand-off; this agent's model is unloaded on its own server, that agent's on the server its file names; `mm --ask` only unloads that agent's model; not the top-level `unloadModels`, which declares `/unload` and `unload_model`). `path` must not be empty. A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. |
5252
5353 ## `context` keys
5454
@@ -48,7 +48,7 @@
48 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |48 | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
49 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |49 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
50 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |50 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |
51-| `mmConfigs` | map name → path, or name → `{path, description, yolo, askUser}` | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)), and the list declares the [`ask_agent`](tools.md#ask_agent) tool, through which the model calls them itself. The long form adds `description` (when to call that agent; shown in `ask_agent`'s description, falling back to the agent file's `acp.title`) and `yolo` (decides that agent's tool calls when it is asked, by `/ask`, `ask_agent` or `mm --ask`, instead of this agent's `yolo`: `true` they run, `false` each one is asked; absent, this agent's `yolo` decides) and `askUser` (whether that agent may ask the user questions when it is asked: `true` its `ask_user` questions come up to this agent's user, `false` its `ask_user` tool is not declared; absent, that agent's own `askUserTool` decides). `path` must not be empty. A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. |51+| `mmConfigs` | map name → path, or name → `{path, description, yolo, askUser, unloadModels}` | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)), and the list declares the [`ask_agent`](tools.md#ask_agent) tool, through which the model calls them itself. The long form adds `description` (when to call that agent; shown in `ask_agent`'s description, falling back to the agent file's `acp.title`) and `yolo` (decides that agent's tool calls when it is asked, by `/ask`, `ask_agent` or `mm --ask`, instead of this agent's `yolo`: `true` they run, `false` each one is asked; absent, this agent's `yolo` decides) and `askUser` (whether that agent may ask the user questions when it is asked: `true` its `ask_user` questions come up to this agent's user, `false` its `ask_user` tool is not declared; absent, that agent's own `askUserTool` decides) and `unloadModels` (default `false`; `true`: every hand-off to that agent — `ask_agent`, `/ask…`, `mm --ask` — unloads this agent's model right before that agent starts, and that agent's model once it has answered, before this agent reads the answer, so the two models are never loaded together; each unload is a `🧹` notice, a failed one does not stop the hand-off; this agent's model is unloaded on its own server, that agent's on the server its file names; `mm --ask` only unloads that agent's model; not the top-level `unloadModels`, which declares `/unload` and `unload_model`). `path` must not be empty. A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. |
52 52
53 ## `context` keys53 ## `context` keys
54 54
modified docs/en/reference/hooks.md +1 -1
@@ -61,7 +61,7 @@ hooks:
6161
6262 All six run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`. An agent started by `/ask`, `ask_agent` or `mm --ask` runs the hooks of **its own** configuration file; `preAsk` and `postAsk` are the **asking** agent's. A `preGeneration` hook does not run on a context-compression summary.
6363
64-On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preAsk` → the asked agent (its own hooks) → `postAsk` → `postToolCall`. On `/ask…` and `mm --ask`: `preAsk` → the asked agent → `postAsk`.
64+On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preAsk` → the asked agent (its own hooks) → `postAsk` → `postToolCall`. On `/ask…` and `mm --ask`: `preAsk` → the asked agent → `postAsk`. When the agent's `mmConfigs` entry sets `unloadModels: true`, the unloads sit inside the hand-off: `preAsk` → this agent's model unloaded → the asked agent → its model unloaded → `postAsk`; a `preAsk` deny unloads nothing.
6565
6666 ## Execution
6767
@@ -61,7 +61,7 @@ hooks:
61 61
62 All six run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`. An agent started by `/ask`, `ask_agent` or `mm --ask` runs the hooks of **its own** configuration file; `preAsk` and `postAsk` are the **asking** agent's. A `preGeneration` hook does not run on a context-compression summary.62 All six run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`. An agent started by `/ask`, `ask_agent` or `mm --ask` runs the hooks of **its own** configuration file; `preAsk` and `postAsk` are the **asking** agent's. A `preGeneration` hook does not run on a context-compression summary.
63 63
64-On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preAsk` → the asked agent (its own hooks) → `postAsk` → `postToolCall`. On `/ask…` and `mm --ask`: `preAsk` → the asked agent → `postAsk`.64+On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preAsk` → the asked agent (its own hooks) → `postAsk` → `postToolCall`. On `/ask…` and `mm --ask`: `preAsk` → the asked agent → `postAsk`. When the agent's `mmConfigs` entry sets `unloadModels: true`, the unloads sit inside the hand-off: `preAsk` → this agent's model unloaded → the asked agent → its model unloaded → `postAsk`; a `preAsk` deny unloads nothing.
65 65
66 ## Execution66 ## Execution
67 67
modified docs/fr/how-to/let-the-agent-call-other-agents.md +1 -0
@@ -63,6 +63,7 @@ Ce guide montre comment donner une équipe à votre agent : d'autres agents mm a
6363 - **Envoyer la conversation.** Le modèle met `with_context: true` quand l'agent a besoin de ce qui a été dit — un relecteur qui donne un deuxième avis. Dites-le dans la description de l'agent (« call it with with_context true »).
6464 - **Interroger un agent vous-même.** `/ask-<nom>` et `/ask-with-context-<nom>` marchent toujours, et gardent l'échange dans l'historique. Voir [Interroger un autre agent mm](ask-another-agent.md).
6565 - **Travail long.** Chaque événement diffusé par l'agent maintient en vie le watchdog de votre agent, mais pas un long silence — un gros modèle qui se charge, un long premier prompt : relevez votre `watchdogTimeout` pour le couvrir (10m pour un modèle de 48 Go sur un portable).
66+- **Pas assez de mémoire pour les deux modèles.** Ajoutez `unloadModels: true` à l'entrée de l'agent : le modèle de votre agent est déchargé avant qu'il démarre, le sien une fois qu'il a répondu. Voir [Libérer la mémoire d'un modèle](unload-a-model.md#ne-jamais-charger-deux-modèles-ensemble-pendant-un-passage-de-main).
6667 - **Depuis un script.** `mm --ask <nom>` reste la voie pour une commande shell. Voir [Interroger un autre agent depuis une skill ou un script](ask-another-agent-from-a-skill.md).
6768 - **Exemples complets.** `demos/llmman/agent.llmman.yaml` (Bob avec Jan, Lucy et un relecteur) et `demos/mini-mes/riker/agent.riker.yaml` (un coordinateur, un spécificateur et un codeur).
6869
@@ -63,6 +63,7 @@ Ce guide montre comment donner une équipe à votre agent : d'autres agents mm a
63 - **Envoyer la conversation.** Le modèle met `with_context: true` quand l'agent a besoin de ce qui a été dit — un relecteur qui donne un deuxième avis. Dites-le dans la description de l'agent (« call it with with_context true »).63 - **Envoyer la conversation.** Le modèle met `with_context: true` quand l'agent a besoin de ce qui a été dit — un relecteur qui donne un deuxième avis. Dites-le dans la description de l'agent (« call it with with_context true »).
64 - **Interroger un agent vous-même.** `/ask-<nom>` et `/ask-with-context-<nom>` marchent toujours, et gardent l'échange dans l'historique. Voir [Interroger un autre agent mm](ask-another-agent.md).64 - **Interroger un agent vous-même.** `/ask-<nom>` et `/ask-with-context-<nom>` marchent toujours, et gardent l'échange dans l'historique. Voir [Interroger un autre agent mm](ask-another-agent.md).
65 - **Travail long.** Chaque événement diffusé par l'agent maintient en vie le watchdog de votre agent, mais pas un long silence — un gros modèle qui se charge, un long premier prompt : relevez votre `watchdogTimeout` pour le couvrir (10m pour un modèle de 48 Go sur un portable).65 - **Travail long.** Chaque événement diffusé par l'agent maintient en vie le watchdog de votre agent, mais pas un long silence — un gros modèle qui se charge, un long premier prompt : relevez votre `watchdogTimeout` pour le couvrir (10m pour un modèle de 48 Go sur un portable).
66+- **Pas assez de mémoire pour les deux modèles.** Ajoutez `unloadModels: true` à l'entrée de l'agent : le modèle de votre agent est déchargé avant qu'il démarre, le sien une fois qu'il a répondu. Voir [Libérer la mémoire d'un modèle](unload-a-model.md#ne-jamais-charger-deux-modèles-ensemble-pendant-un-passage-de-main).
66 - **Depuis un script.** `mm --ask <nom>` reste la voie pour une commande shell. Voir [Interroger un autre agent depuis une skill ou un script](ask-another-agent-from-a-skill.md).67 - **Depuis un script.** `mm --ask <nom>` reste la voie pour une commande shell. Voir [Interroger un autre agent depuis une skill ou un script](ask-another-agent-from-a-skill.md).
67 - **Exemples complets.** `demos/llmman/agent.llmman.yaml` (Bob avec Jan, Lucy et un relecteur) et `demos/mini-mes/riker/agent.riker.yaml` (un coordinateur, un spécificateur et un codeur).68 - **Exemples complets.** `demos/llmman/agent.llmman.yaml` (Bob avec Jan, Lucy et un relecteur) et `demos/mini-mes/riker/agent.riker.yaml` (un coordinateur, un spécificateur et un codeur).
68 69
modified docs/fr/how-to/unload-a-model.md +23 -1
@@ -45,10 +45,32 @@ system: |
4545
4646 Écrivez le nom du modèle exactement comme le serveur le liste : le modèle ne peut pas le deviner. `demos/unload-models/` décharge plutôt l'expert avec un hook `postAsk` (voir les variantes ci-dessous) ; son coordinateur appelle toujours `unload_model` quand l'utilisateur lui demande de libérer la mémoire de l'expert.
4747
48+## Ne jamais charger deux modèles ensemble pendant un passage de main
49+
50+Pour que les deux modèles d'un passage de main se relaient en mémoire, mettez `unloadModels: true` sur l'entrée `mmConfigs` de l'agent interrogé — sans consigne ni hook :
51+
52+```yaml
53+mmConfigs:
54+ expert:
55+ path: agent.expert.yaml
56+ description: Programming expert.
57+ unloadModels: true
58+```
59+
60+À chaque passage de main à `expert` (`ask_agent`, `/ask-expert`, `mm --ask expert`), mm décharge le modèle de cet agent juste avant que l'expert démarre, puis le modèle de l'expert une fois qu'il a répondu, avant que cet agent soit rechargé pour lire la réponse. Chaque déchargement affiche une ligne :
61+
62+```text
63+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
64+…la réponse de l'expert…
65+🧹 unloaded hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M
66+```
67+
68+Le prix : le modèle de l'expert est rechargé à chaque question. `demos/coding-helpers-unload-option/` le fait pour deux spécialistes ; `demos/coding-helpers/` fait la même chose avec des hooks.
69+
4870 ## Variantes
4971
5072 - **Le modèle de l'agent lui-même.** `unload_model` avec un `model` vide le décharge, mais seulement jusqu'à l'étape suivante de l'agent : il a besoin de son modèle pour lire le résultat de l'outil, et le serveur le recharge. Utilisez plutôt `/unload` entre deux questions — ou, pour le libérer pendant qu'un autre agent travaille, un hook `preAsk` qui décharge le modèle nommé dans son entrée (`"model": "<fournisseur>/<modèle>"`) juste avant le lancement de l'agent interrogé : `demos/unload-models/hooks/unload.js self`.
51-- **Une garantie plutôt qu'une consigne.** Un petit modèle peut sauter l'étape (mesuré : 4 fois sur 5 avec MiniCPM5 2B dans la première version de `demos/unload-models`), et le modèle de l'agent est déjà rechargé à côté de l'autre quand il appelle l'outil. Pour un déchargement certain, sans jamais les deux modèles chargés ensemble, faites appeler le serveur par un hook `postAsk` — `curl -s <serveur>/api/generate -d '{"model":"…","keep_alive":0}'` — une fois que l'agent interrogé a répondu et avant que l'agent qui l'a interrogé lise la réponse : `demos/unload-models/hooks/unload.js asked` lit le modèle de l'agent interrogé dans `ask.agentModel`. Voir [Contrôler les relais avec des hooks](control-agent-calls-with-hooks.md).
73+- **Une garantie plutôt qu'une consigne.** L'option `unloadModels` ci-dessus est la plus simple. Les hooks restent pour un déchargement qu'elle ne couvre pas — un autre modèle que les deux du passage de main, ou un déchargement qui dépend d'une condition. Un petit modèle peut sauter l'étape (mesuré : 4 fois sur 5 avec MiniCPM5 2B dans la première version de `demos/unload-models`), et le modèle de l'agent est déjà rechargé à côté de l'autre quand il appelle l'outil. Pour un déchargement certain, sans jamais les deux modèles chargés ensemble, faites appeler le serveur par un hook `postAsk` — `curl -s <serveur>/api/generate -d '{"model":"…","keep_alive":0}'` — une fois que l'agent interrogé a répondu et avant que l'agent qui l'a interrogé lise la réponse : `demos/unload-models/hooks/unload.js asked` lit le modèle de l'agent interrogé dans `ask.agentModel`. Voir [Contrôler les relais avec des hooks](control-agent-calls-with-hooks.md).
5274 - **llama-server.** Seul le mode router (lancé sans `-m`) peut décharger un modèle. Un serveur à modèle unique répond avec les alternatives : `--sleep-idle-seconds N` libère la mémoire après N secondes d'inactivité, ou arrêtez le serveur.
5375 - **Le désactiver.** `unloadModels: false` retire à la fois `/unload` et l'outil — pour un agent qui ne doit pas avoir un outil de plus, comme une garde ou un spécialiste étroit.
5476
@@ -45,10 +45,32 @@ system: |
45 45
46 Écrivez le nom du modèle exactement comme le serveur le liste : le modèle ne peut pas le deviner. `demos/unload-models/` décharge plutôt l'expert avec un hook `postAsk` (voir les variantes ci-dessous) ; son coordinateur appelle toujours `unload_model` quand l'utilisateur lui demande de libérer la mémoire de l'expert.46 Écrivez le nom du modèle exactement comme le serveur le liste : le modèle ne peut pas le deviner. `demos/unload-models/` décharge plutôt l'expert avec un hook `postAsk` (voir les variantes ci-dessous) ; son coordinateur appelle toujours `unload_model` quand l'utilisateur lui demande de libérer la mémoire de l'expert.
47 47
48+## Ne jamais charger deux modèles ensemble pendant un passage de main
49+
50+Pour que les deux modèles d'un passage de main se relaient en mémoire, mettez `unloadModels: true` sur l'entrée `mmConfigs` de l'agent interrogé — sans consigne ni hook :
51+
52+```yaml
53+mmConfigs:
54+ expert:
55+ path: agent.expert.yaml
56+ description: Programming expert.
57+ unloadModels: true
58+```
59+
60+À chaque passage de main à `expert` (`ask_agent`, `/ask-expert`, `mm --ask expert`), mm décharge le modèle de cet agent juste avant que l'expert démarre, puis le modèle de l'expert une fois qu'il a répondu, avant que cet agent soit rechargé pour lire la réponse. Chaque déchargement affiche une ligne :
61+
62+```text
63+🧹 unloaded hf.co/openbmb/MiniCPM5-2B-GGUF:Q4_K_M
64+…la réponse de l'expert…
65+🧹 unloaded hf.co/unsloth/Qwen3.5-9B-GGUF:Q4_K_M
66+```
67+
68+Le prix : le modèle de l'expert est rechargé à chaque question. `demos/coding-helpers-unload-option/` le fait pour deux spécialistes ; `demos/coding-helpers/` fait la même chose avec des hooks.
69+
48 ## Variantes70 ## Variantes
49 71
50 - **Le modèle de l'agent lui-même.** `unload_model` avec un `model` vide le décharge, mais seulement jusqu'à l'étape suivante de l'agent : il a besoin de son modèle pour lire le résultat de l'outil, et le serveur le recharge. Utilisez plutôt `/unload` entre deux questions — ou, pour le libérer pendant qu'un autre agent travaille, un hook `preAsk` qui décharge le modèle nommé dans son entrée (`"model": "<fournisseur>/<modèle>"`) juste avant le lancement de l'agent interrogé : `demos/unload-models/hooks/unload.js self`.72 - **Le modèle de l'agent lui-même.** `unload_model` avec un `model` vide le décharge, mais seulement jusqu'à l'étape suivante de l'agent : il a besoin de son modèle pour lire le résultat de l'outil, et le serveur le recharge. Utilisez plutôt `/unload` entre deux questions — ou, pour le libérer pendant qu'un autre agent travaille, un hook `preAsk` qui décharge le modèle nommé dans son entrée (`"model": "<fournisseur>/<modèle>"`) juste avant le lancement de l'agent interrogé : `demos/unload-models/hooks/unload.js self`.
51-- **Une garantie plutôt qu'une consigne.** Un petit modèle peut sauter l'étape (mesuré : 4 fois sur 5 avec MiniCPM5 2B dans la première version de `demos/unload-models`), et le modèle de l'agent est déjà rechargé à côté de l'autre quand il appelle l'outil. Pour un déchargement certain, sans jamais les deux modèles chargés ensemble, faites appeler le serveur par un hook `postAsk` — `curl -s <serveur>/api/generate -d '{"model":"…","keep_alive":0}'` — une fois que l'agent interrogé a répondu et avant que l'agent qui l'a interrogé lise la réponse : `demos/unload-models/hooks/unload.js asked` lit le modèle de l'agent interrogé dans `ask.agentModel`. Voir [Contrôler les relais avec des hooks](control-agent-calls-with-hooks.md).73+- **Une garantie plutôt qu'une consigne.** L'option `unloadModels` ci-dessus est la plus simple. Les hooks restent pour un déchargement qu'elle ne couvre pas — un autre modèle que les deux du passage de main, ou un déchargement qui dépend d'une condition. Un petit modèle peut sauter l'étape (mesuré : 4 fois sur 5 avec MiniCPM5 2B dans la première version de `demos/unload-models`), et le modèle de l'agent est déjà rechargé à côté de l'autre quand il appelle l'outil. Pour un déchargement certain, sans jamais les deux modèles chargés ensemble, faites appeler le serveur par un hook `postAsk` — `curl -s <serveur>/api/generate -d '{"model":"…","keep_alive":0}'` — une fois que l'agent interrogé a répondu et avant que l'agent qui l'a interrogé lise la réponse : `demos/unload-models/hooks/unload.js asked` lit le modèle de l'agent interrogé dans `ask.agentModel`. Voir [Contrôler les relais avec des hooks](control-agent-calls-with-hooks.md).
52 - **llama-server.** Seul le mode router (lancé sans `-m`) peut décharger un modèle. Un serveur à modèle unique répond avec les alternatives : `--sleep-idle-seconds N` libère la mémoire après N secondes d'inactivité, ou arrêtez le serveur.74 - **llama-server.** Seul le mode router (lancé sans `-m`) peut décharger un modèle. Un serveur à modèle unique répond avec les alternatives : `--sleep-idle-seconds N` libère la mémoire après N secondes d'inactivité, ou arrêtez le serveur.
53 - **Le désactiver.** `unloadModels: false` retire à la fois `/unload` et l'outil — pour un agent qui ne doit pas avoir un outil de plus, comme une garde ou un spécialiste étroit.75 - **Le désactiver.** `unloadModels: false` retire à la fois `/unload` et l'outil — pour un agent qui ne doit pas avoir un outil de plus, comme une garde ou un spécialiste étroit.
54 76
modified docs/fr/reference/configuration.md +1 -1
@@ -48,7 +48,7 @@
4848 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
4949 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
5050 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |
51-| `mmConfigs` | dictionnaire nom → chemin, ou nom → `{path, description, yolo, askUser}` | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)), et la liste déclare l'outil [`ask_agent`](tools.md#ask_agent), par lequel le modèle les appelle lui-même. La forme longue ajoute `description` (quand appeler cet agent ; affichée dans la description d'`ask_agent`, à défaut l'`acp.title` du fichier de l'agent) et `yolo` (décide des appels d'outils de cet agent quand il est interrogé, par `/ask`, `ask_agent` ou `mm --ask`, à la place du `yolo` de cet agent-ci : `true` ils s'exécutent, `false` chacun est demandé ; absent, le `yolo` de cet agent-ci décide) et `askUser` (si cet agent peut poser des questions à l'utilisateur quand il est interrogé : `true` ses questions `ask_user` remontent à l'utilisateur de cet agent-ci, `false` son outil `ask_user` n'est pas déclaré ; absent, le `askUserTool` de cet agent décide). `path` ne doit pas être vide. Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. |
51+| `mmConfigs` | dictionnaire nom → chemin, ou nom → `{path, description, yolo, askUser, unloadModels}` | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)), et la liste déclare l'outil [`ask_agent`](tools.md#ask_agent), par lequel le modèle les appelle lui-même. La forme longue ajoute `description` (quand appeler cet agent ; affichée dans la description d'`ask_agent`, à défaut l'`acp.title` du fichier de l'agent) et `yolo` (décide des appels d'outils de cet agent quand il est interrogé, par `/ask`, `ask_agent` ou `mm --ask`, à la place du `yolo` de cet agent-ci : `true` ils s'exécutent, `false` chacun est demandé ; absent, le `yolo` de cet agent-ci décide) et `askUser` (si cet agent peut poser des questions à l'utilisateur quand il est interrogé : `true` ses questions `ask_user` remontent à l'utilisateur de cet agent-ci, `false` son outil `ask_user` n'est pas déclaré ; absent, le `askUserTool` de cet agent décide) et `unloadModels` (`false` par défaut ; `true` : chaque passage de main à cet agent — `ask_agent`, `/ask…`, `mm --ask` — décharge le modèle de cet agent-ci juste avant que l'autre démarre, puis le modèle de l'autre une fois qu'il a répondu, avant que cet agent-ci lise la réponse : les deux modèles ne sont jamais chargés ensemble ; chaque déchargement est une notice `🧹`, un échec n'arrête pas le passage de main ; le modèle de cet agent-ci est déchargé sur son serveur, celui de l'autre sur le serveur que nomme son fichier ; `mm --ask` ne décharge que le modèle de l'autre ; à ne pas confondre avec la clé `unloadModels` de premier niveau, qui déclare `/unload` et `unload_model`). `path` ne doit pas être vide. Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. |
5252
5353 ## Clés de `context`
5454
@@ -48,7 +48,7 @@
48 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |48 | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
49 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |49 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
50 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |50 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |
51-| `mmConfigs` | dictionnaire nom → chemin, ou nom → `{path, description, yolo, askUser}` | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)), et la liste déclare l'outil [`ask_agent`](tools.md#ask_agent), par lequel le modèle les appelle lui-même. La forme longue ajoute `description` (quand appeler cet agent ; affichée dans la description d'`ask_agent`, à défaut l'`acp.title` du fichier de l'agent) et `yolo` (décide des appels d'outils de cet agent quand il est interrogé, par `/ask`, `ask_agent` ou `mm --ask`, à la place du `yolo` de cet agent-ci : `true` ils s'exécutent, `false` chacun est demandé ; absent, le `yolo` de cet agent-ci décide) et `askUser` (si cet agent peut poser des questions à l'utilisateur quand il est interrogé : `true` ses questions `ask_user` remontent à l'utilisateur de cet agent-ci, `false` son outil `ask_user` n'est pas déclaré ; absent, le `askUserTool` de cet agent décide). `path` ne doit pas être vide. Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. |51+| `mmConfigs` | dictionnaire nom → chemin, ou nom → `{path, description, yolo, askUser, unloadModels}` | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)), et la liste déclare l'outil [`ask_agent`](tools.md#ask_agent), par lequel le modèle les appelle lui-même. La forme longue ajoute `description` (quand appeler cet agent ; affichée dans la description d'`ask_agent`, à défaut l'`acp.title` du fichier de l'agent) et `yolo` (décide des appels d'outils de cet agent quand il est interrogé, par `/ask`, `ask_agent` ou `mm --ask`, à la place du `yolo` de cet agent-ci : `true` ils s'exécutent, `false` chacun est demandé ; absent, le `yolo` de cet agent-ci décide) et `askUser` (si cet agent peut poser des questions à l'utilisateur quand il est interrogé : `true` ses questions `ask_user` remontent à l'utilisateur de cet agent-ci, `false` son outil `ask_user` n'est pas déclaré ; absent, le `askUserTool` de cet agent décide) et `unloadModels` (`false` par défaut ; `true` : chaque passage de main à cet agent — `ask_agent`, `/ask…`, `mm --ask` — décharge le modèle de cet agent-ci juste avant que l'autre démarre, puis le modèle de l'autre une fois qu'il a répondu, avant que cet agent-ci lise la réponse : les deux modèles ne sont jamais chargés ensemble ; chaque déchargement est une notice `🧹`, un échec n'arrête pas le passage de main ; le modèle de cet agent-ci est déchargé sur son serveur, celui de l'autre sur le serveur que nomme son fichier ; `mm --ask` ne décharge que le modèle de l'autre ; à ne pas confondre avec la clé `unloadModels` de premier niveau, qui déclare `/unload` et `unload_model`). `path` ne doit pas être vide. Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. |
52 52
53 ## Clés de `context`53 ## Clés de `context`
54 54
modified docs/fr/reference/hooks.md +1 -1
@@ -61,7 +61,7 @@ hooks:
6161
6262 Les six s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`. Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` exécute les hooks de **son propre** fichier de configuration ; `preAsk` et `postAsk` sont ceux de l'agent **qui appelle**. Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.
6363
64-Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisation → `preAsk` → l'agent appelé (avec ses propres hooks) → `postAsk` → `postToolCall`. Sur `/ask…` et `mm --ask` : `preAsk` → l'agent appelé → `postAsk`.
64+Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisation → `preAsk` → l'agent appelé (avec ses propres hooks) → `postAsk` → `postToolCall`. Sur `/ask…` et `mm --ask` : `preAsk` → l'agent appelé → `postAsk`. Quand l'entrée `mmConfigs` de l'agent met `unloadModels: true`, les déchargements se placent dans le passage de main : `preAsk` → modèle de cet agent déchargé → l'agent appelé → son modèle déchargé → `postAsk` ; un refus de `preAsk` ne décharge rien.
6565
6666 ## Exécution
6767
@@ -61,7 +61,7 @@ hooks:
61 61
62 Les six s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`. Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` exécute les hooks de **son propre** fichier de configuration ; `preAsk` et `postAsk` sont ceux de l'agent **qui appelle**. Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.62 Les six s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`. Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` exécute les hooks de **son propre** fichier de configuration ; `preAsk` et `postAsk` sont ceux de l'agent **qui appelle**. Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.
63 63
64-Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisation → `preAsk` → l'agent appelé (avec ses propres hooks) → `postAsk` → `postToolCall`. Sur `/ask…` et `mm --ask` : `preAsk` → l'agent appelé → `postAsk`.64+Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisation → `preAsk` → l'agent appelé (avec ses propres hooks) → `postAsk` → `postToolCall`. Sur `/ask…` et `mm --ask` : `preAsk` → l'agent appelé → `postAsk`. Quand l'entrée `mmConfigs` de l'agent met `unloadModels: true`, les déchargements se placent dans le passage de main : `preAsk` → modèle de cet agent déchargé → l'agent appelé → son modèle déchargé → `postAsk` ; un refus de `preAsk` ne décharge rien.
65 65
66 ## Exécution66 ## Exécution
67 67
modified internal/ask/delegate.go +47 -0
@@ -62,6 +62,9 @@ func IsDenied(err error) bool {
6262 // Delegate is the one way every hand-off goes — the ask_agent tool, the /ask
6363 // commands, mm --ask: the asker's preAsk hooks, the prompt the asked agent
6464 // receives (Prompt), the ask itself (Ask), then the asker's postAsk hooks.
65+// When the agent's mmConfigs entry sets unloadModels, the asker's model is
66+// unloaded right before the ask and the asked agent's right after it, before
67+// the postAsk hooks (see Unloads).
6568 // Without hooks it is exactly Ask on Prompt(Question, Transcript).
6669 //
6770 // A *DeniedError means a preAsk hook refused. Otherwise the error is the
@@ -88,9 +91,17 @@ func (p *Pool) Delegate(ctx context.Context, req Request, cwd string, sink ui.Si
8891 call.Prompt, call.Transcript = d.Prompt, d.Transcript
8992 prompt := hooks.WithContext(Prompt(d.Prompt, d.Transcript), d.Context)
9093
94+ unload := config.Cfg.MMConfigs[req.Agent].UnloadModels
95+ if unload {
96+ p.unloadAsker(ctx, sink)
97+ }
9198 start := time.Now()
9299 answer, err := p.Ask(ctx, req.Agent, path, cwd, prompt, sink)
93100 in.Answer, in.DurationMs = answer, time.Since(start).Milliseconds()
101+ if unload {
102+ // Not the ask's context: an aborted ask still leaves its model loaded.
103+ p.unloadAsked(context.WithoutCancel(ctx), path, sink)
104+ }
94105 if err != nil {
95106 in.Error = err.Error()
96107 }
@@ -127,3 +138,39 @@ func agentModel(path string) string {
127138 }
128139 return ""
129140 }
141+
142+// Unloads is how the pool frees the server's memory around a hand-off to an
143+// agent whose mmConfigs entry sets `unloadModels: true` (ticket 161): the
144+// two models are then never loaded together. main fills it from the engine;
145+// a nil field skips that side (mm --ask has no model of its own to free).
146+//
147+// pool.SetUnloads(ask.Unloads{
148+// Asker: func(ctx context.Context) string { return e.Unload(ctx, "") },
149+// Asked: engine.UnloadAgent,
150+// })
151+type Unloads struct {
152+ // Asker unloads the asking agent's own model, right before the asked
153+ // agent starts: the asker needs it again only to read the answer, and
154+ // the server reloads it then by itself.
155+ Asker func(ctx context.Context) string
156+ // Asked unloads the model of the asked agent's config file, once it has
157+ // answered and before the asker reads the answer.
158+ Asked func(ctx context.Context, configPath string) string
159+}
160+
161+// SetUnloads installs the unload functions; call it before the first ask.
162+func (p *Pool) SetUnloads(u Unloads) { p.unloads = u }
163+
164+// unloadAsker and unloadAsked run one side and show its line as a notice:
165+// an unload is a saving, never a reason to stop the hand-off.
166+func (p *Pool) unloadAsker(ctx context.Context, sink ui.Sink) {
167+ if p.unloads.Asker != nil {
168+ p.notifier(sink)("🧹 " + p.unloads.Asker(ctx))
169+ }
170+}
171+
172+func (p *Pool) unloadAsked(ctx context.Context, configPath string, sink ui.Sink) {
173+ if p.unloads.Asked != nil {
174+ p.notifier(sink)("🧹 " + p.unloads.Asked(ctx, configPath))
175+ }
176+}
@@ -62,6 +62,9 @@ func IsDenied(err error) bool {
62 // Delegate is the one way every hand-off goes — the ask_agent tool, the /ask62 // Delegate is the one way every hand-off goes — the ask_agent tool, the /ask
63 // commands, mm --ask: the asker's preAsk hooks, the prompt the asked agent63 // commands, mm --ask: the asker's preAsk hooks, the prompt the asked agent
64 // receives (Prompt), the ask itself (Ask), then the asker's postAsk hooks.64 // receives (Prompt), the ask itself (Ask), then the asker's postAsk hooks.
65+// When the agent's mmConfigs entry sets unloadModels, the asker's model is
66+// unloaded right before the ask and the asked agent's right after it, before
67+// the postAsk hooks (see Unloads).
65 // Without hooks it is exactly Ask on Prompt(Question, Transcript).68 // Without hooks it is exactly Ask on Prompt(Question, Transcript).
66 //69 //
67 // A *DeniedError means a preAsk hook refused. Otherwise the error is the70 // A *DeniedError means a preAsk hook refused. Otherwise the error is the
@@ -88,9 +91,17 @@ func (p *Pool) Delegate(ctx context.Context, req Request, cwd string, sink ui.Si
88 call.Prompt, call.Transcript = d.Prompt, d.Transcript91 call.Prompt, call.Transcript = d.Prompt, d.Transcript
89 prompt := hooks.WithContext(Prompt(d.Prompt, d.Transcript), d.Context)92 prompt := hooks.WithContext(Prompt(d.Prompt, d.Transcript), d.Context)
90 93
94+ unload := config.Cfg.MMConfigs[req.Agent].UnloadModels
95+ if unload {
96+ p.unloadAsker(ctx, sink)
97+ }
91 start := time.Now()98 start := time.Now()
92 answer, err := p.Ask(ctx, req.Agent, path, cwd, prompt, sink)99 answer, err := p.Ask(ctx, req.Agent, path, cwd, prompt, sink)
93 in.Answer, in.DurationMs = answer, time.Since(start).Milliseconds()100 in.Answer, in.DurationMs = answer, time.Since(start).Milliseconds()
101+ if unload {
102+ // Not the ask's context: an aborted ask still leaves its model loaded.
103+ p.unloadAsked(context.WithoutCancel(ctx), path, sink)
104+ }
94 if err != nil {105 if err != nil {
95 in.Error = err.Error()106 in.Error = err.Error()
96 }107 }
@@ -127,3 +138,39 @@ func agentModel(path string) string {
127 }138 }
128 return ""139 return ""
129 }140 }
141+
142+// Unloads is how the pool frees the server's memory around a hand-off to an
143+// agent whose mmConfigs entry sets `unloadModels: true` (ticket 161): the
144+// two models are then never loaded together. main fills it from the engine;
145+// a nil field skips that side (mm --ask has no model of its own to free).
146+//
147+// pool.SetUnloads(ask.Unloads{
148+// Asker: func(ctx context.Context) string { return e.Unload(ctx, "") },
149+// Asked: engine.UnloadAgent,
150+// })
151+type Unloads struct {
152+ // Asker unloads the asking agent's own model, right before the asked
153+ // agent starts: the asker needs it again only to read the answer, and
154+ // the server reloads it then by itself.
155+ Asker func(ctx context.Context) string
156+ // Asked unloads the model of the asked agent's config file, once it has
157+ // answered and before the asker reads the answer.
158+ Asked func(ctx context.Context, configPath string) string
159+}
160+
161+// SetUnloads installs the unload functions; call it before the first ask.
162+func (p *Pool) SetUnloads(u Unloads) { p.unloads = u }
163+
164+// unloadAsker and unloadAsked run one side and show its line as a notice:
165+// an unload is a saving, never a reason to stop the hand-off.
166+func (p *Pool) unloadAsker(ctx context.Context, sink ui.Sink) {
167+ if p.unloads.Asker != nil {
168+ p.notifier(sink)("🧹 " + p.unloads.Asker(ctx))
169+ }
170+}
171+
172+func (p *Pool) unloadAsked(ctx context.Context, configPath string, sink ui.Sink) {
173+ if p.unloads.Asked != nil {
174+ p.notifier(sink)("🧹 " + p.unloads.Asked(ctx, configPath))
175+ }
176+}
modified internal/ask/linesink.go +23 -1
@@ -3,6 +3,7 @@ package ask
33 import (
44 "context"
55 "fmt"
6+ "strings"
67
78 "mm/internal/config"
89 "mm/internal/consent"
@@ -19,13 +20,33 @@ import (
1920 // answer, err := pool.Ask(ctx, "homer", path, cwd, prompt, &ask.LineSink{Cwd: cwd})
2021 type LineSink struct {
2122 Cwd string
23+ // midLine says the last text printed did not end its line: a notice
24+ // then starts a new one instead of sticking to the answer.
25+ midLine bool
2226 }
2327
24-var _ ui.Sink = (*LineSink)(nil)
28+var (
29+ _ ui.Sink = (*LineSink)(nil)
30+ _ ui.Noticer = (*LineSink)(nil)
31+)
2532
2633 func (s *LineSink) Text(text string) {
2734 spinner.Stop()
2835 fmt.Print(text)
36+ if text != "" {
37+ s.midLine = !strings.HasSuffix(text, "\n")
38+ }
39+}
40+
41+// Notice prints one of mm's own lines during the ask — an unload
42+// (mmConfigs.<name>.unloadModels), a hook's report — on a line of its own.
43+func (s *LineSink) Notice(text string) {
44+ spinner.Stop()
45+ if s.midLine {
46+ fmt.Println()
47+ s.midLine = false
48+ }
49+ fmt.Println(text)
2950 }
3051
3152 func (s *LineSink) Reasoning(text string) {
@@ -43,6 +64,7 @@ func (s *LineSink) Reasoning(text string) {
4364 func (s *LineSink) ToolStart(ev ui.ToolEvent) {
4465 spinner.Stop()
4566 fmt.Printf("\n🛠️ %s\n", ev.Title)
67+ s.midLine = false
4668 }
4769
4870 func (s *LineSink) ToolEnd(_ string, res ui.ToolResult) {
@@ -3,6 +3,7 @@ package ask
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 7
7 "mm/internal/config"8 "mm/internal/config"
8 "mm/internal/consent"9 "mm/internal/consent"
@@ -19,13 +20,33 @@ import (
19 // answer, err := pool.Ask(ctx, "homer", path, cwd, prompt, &ask.LineSink{Cwd: cwd})20 // answer, err := pool.Ask(ctx, "homer", path, cwd, prompt, &ask.LineSink{Cwd: cwd})
20 type LineSink struct {21 type LineSink struct {
21 Cwd string22 Cwd string
23+ // midLine says the last text printed did not end its line: a notice
24+ // then starts a new one instead of sticking to the answer.
25+ midLine bool
22 }26 }
23 27
24-var _ ui.Sink = (*LineSink)(nil)28+var (
29+ _ ui.Sink = (*LineSink)(nil)
30+ _ ui.Noticer = (*LineSink)(nil)
31+)
25 32
26 func (s *LineSink) Text(text string) {33 func (s *LineSink) Text(text string) {
27 spinner.Stop()34 spinner.Stop()
28 fmt.Print(text)35 fmt.Print(text)
36+ if text != "" {
37+ s.midLine = !strings.HasSuffix(text, "\n")
38+ }
39+}
40+
41+// Notice prints one of mm's own lines during the ask — an unload
42+// (mmConfigs.<name>.unloadModels), a hook's report — on a line of its own.
43+func (s *LineSink) Notice(text string) {
44+ spinner.Stop()
45+ if s.midLine {
46+ fmt.Println()
47+ s.midLine = false
48+ }
49+ fmt.Println(text)
29 }50 }
30 51
31 func (s *LineSink) Reasoning(text string) {52 func (s *LineSink) Reasoning(text string) {
@@ -43,6 +64,7 @@ func (s *LineSink) Reasoning(text string) {
43 func (s *LineSink) ToolStart(ev ui.ToolEvent) {64 func (s *LineSink) ToolStart(ev ui.ToolEvent) {
44 spinner.Stop()65 spinner.Stop()
45 fmt.Printf("\n🛠️ %s\n", ev.Title)66 fmt.Printf("\n🛠️ %s\n", ev.Title)
67+ s.midLine = false
46 }68 }
47 69
48 func (s *LineSink) ToolEnd(_ string, res ui.ToolResult) {70 func (s *LineSink) ToolEnd(_ string, res ui.ToolResult) {
added internal/ask/linesink_test.go +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+package ask
2+
3+import (
4+ "io"
5+ "os"
6+ "testing"
7+)
8+
9+// stdout runs fn and returns what it printed.
10+func stdout(t *testing.T, fn func()) string {
11+ t.Helper()
12+ r, w, err := os.Pipe()
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ saved := os.Stdout
17+ os.Stdout = w
18+ fn()
19+ os.Stdout = saved
20+ _ = w.Close()
21+ out, _ := io.ReadAll(r)
22+ return string(out)
23+}
24+
25+// A notice after an answer that did not end its line starts a new one; a
26+// notice before any text, or after a full line, adds no blank line.
27+func TestLineSinkNoticeStartsItsOwnLine(t *testing.T) {
28+ got := stdout(t, func() {
29+ s := &LineSink{}
30+ s.Notice("🧹 unloaded asker")
31+ s.Text("the answer")
32+ s.Notice("🧹 unloaded bob")
33+ s.Text("more\n")
34+ s.Notice("done")
35+ })
36+ want := "🧹 unloaded asker\nthe answer\n🧹 unloaded bob\nmore\ndone\n"
37+ if got != want {
38+ t.Errorf("printed %q, want %q", got, want)
39+ }
40+}
new file mode 100644
@@ -0,0 +1,40 @@
1+package ask
2+
3+import (
4+ "io"
5+ "os"
6+ "testing"
7+)
8+
9+// stdout runs fn and returns what it printed.
10+func stdout(t *testing.T, fn func()) string {
11+ t.Helper()
12+ r, w, err := os.Pipe()
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ saved := os.Stdout
17+ os.Stdout = w
18+ fn()
19+ os.Stdout = saved
20+ _ = w.Close()
21+ out, _ := io.ReadAll(r)
22+ return string(out)
23+}
24+
25+// A notice after an answer that did not end its line starts a new one; a
26+// notice before any text, or after a full line, adds no blank line.
27+func TestLineSinkNoticeStartsItsOwnLine(t *testing.T) {
28+ got := stdout(t, func() {
29+ s := &LineSink{}
30+ s.Notice("🧹 unloaded asker")
31+ s.Text("the answer")
32+ s.Notice("🧹 unloaded bob")
33+ s.Text("more\n")
34+ s.Notice("done")
35+ })
36+ want := "🧹 unloaded asker\nthe answer\n🧹 unloaded bob\nmore\ndone\n"
37+ if got != want {
38+ t.Errorf("printed %q, want %q", got, want)
39+ }
40+}
modified internal/ask/pool.go +2 -0
@@ -33,6 +33,8 @@ type Pool struct {
3333 // notify receives the hooks' reports and notices (Delegate); nil = the
3434 // sink's notice line, else ui.Notice.
3535 notify func(string)
36+ // unloads frees the server's memory around a hand-off (SetUnloads).
37+ unloads Unloads
3638
3739 mu sync.Mutex
3840 children map[string]*child
@@ -33,6 +33,8 @@ type Pool struct {
33 // notify receives the hooks' reports and notices (Delegate); nil = the33 // notify receives the hooks' reports and notices (Delegate); nil = the
34 // sink's notice line, else ui.Notice.34 // sink's notice line, else ui.Notice.
35 notify func(string)35 notify func(string)
36+ // unloads frees the server's memory around a hand-off (SetUnloads).
37+ unloads Unloads
36 38
37 mu sync.Mutex39 mu sync.Mutex
38 children map[string]*child40 children map[string]*child
added internal/ask/unload_test.go +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+package ask
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "mm/internal/config"
10+ "mm/internal/hooks"
11+)
12+
13+// unloadLog records the pool's unload calls, and whether the asked agent's
14+// process was running at that moment — which is how the order is checked.
15+type unloadLog struct {
16+ calls []string
17+}
18+
19+func (l *unloadLog) unloads(p *Pool) Unloads {
20+ running := func() string {
21+ p.mu.Lock()
22+ defer p.mu.Unlock()
23+ if len(p.children) == 0 {
24+ return "before the ask"
25+ }
26+ return "after the ask"
27+ }
28+ return Unloads{
29+ Asker: func(context.Context) string {
30+ l.calls = append(l.calls, "asker "+running())
31+ return "unloaded asker-model"
32+ },
33+ Asked: func(_ context.Context, path string) string {
34+ l.calls = append(l.calls, "asked "+running()+" "+path)
35+ return "unloaded bob-model"
36+ },
37+ }
38+}
39+
40+// withUnload turns unloadModels on for bob (withAgent restores config.Cfg).
41+func withUnload(t *testing.T) string {
42+ t.Helper()
43+ withAgent(t)
44+ ref := config.Cfg.MMConfigs["bob"]
45+ ref.UnloadModels = true
46+ config.Cfg.MMConfigs = map[string]config.AgentRef{"bob": ref}
47+ return ref.Path
48+}
49+
50+// unloadModels: the asker's model goes right before bob starts, bob's once
51+// it has answered; each line is a notice.
52+func TestUnloadModelsFreesBothModelsAroundTheHandOff(t *testing.T) {
53+ path := withUnload(t)
54+ p := fakePool(t, "echo")
55+ log := &unloadLog{}
56+ p.SetUnloads(log.unloads(p))
57+ sink := &noticing{}
58+ res, err := p.Delegate(context.Background(), Request{Agent: "bob", Question: "q", Via: hooks.ViaCommand}, t.TempDir(), sink)
59+ if err != nil || res.Answer != "session 1: q" {
60+ t.Fatalf("Delegate = %q, %v", res.Answer, err)
61+ }
62+ want := "asker before the ask|asked after the ask " + path
63+ if got := strings.Join(log.calls, "|"); got != want {
64+ t.Errorf("calls = %q, want %q", got, want)
65+ }
66+ if got := strings.Join(sink.notices, "|"); got != "🧹 unloaded asker-model|🧹 unloaded bob-model" {
67+ t.Errorf("notices = %q", got)
68+ }
69+}
70+
71+// Without the option, or when a preAsk hook refuses the hand-off, nothing is
72+// unloaded.
73+func TestUnloadModelsOffOrDeniedUnloadsNothing(t *testing.T) {
74+ withAgent(t)
75+ p := fakePool(t, "echo")
76+ log := &unloadLog{}
77+ p.SetUnloads(log.unloads(p))
78+ if _, err := p.Delegate(context.Background(), Request{Agent: "bob", Question: "q"}, t.TempDir(), &noticing{}); err != nil {
79+ t.Fatal(err)
80+ }
81+ withUnload(t)
82+ withHooks(t, config.HooksConfig{PreAsk: []config.Hook{shHook(`echo '{"action":"deny","reason":"no"}'`)}})
83+ if _, err := p.Delegate(context.Background(), Request{Agent: "bob", Question: "q"}, t.TempDir(), &noticing{}); !IsDenied(err) {
84+ t.Fatalf("err = %v, want a deny", err)
85+ }
86+ if len(log.calls) != 0 {
87+ t.Errorf("unloaded: %q", log.calls)
88+ }
89+}
90+
91+// An ask that was cancelled still leaves bob's model loaded: it is unloaded
92+// all the same, with a context that is not the cancelled one.
93+func TestUnloadModelsAfterACancelledAsk(t *testing.T) {
94+ withUnload(t)
95+ p := fakePool(t, "slow")
96+ var askedCtxErr error
97+ asked := false
98+ p.SetUnloads(Unloads{Asked: func(ctx context.Context, _ string) string {
99+ asked, askedCtxErr = true, ctx.Err()
100+ return "unloaded bob-model"
101+ }})
102+ ctx, cancel := context.WithCancel(context.Background())
103+ time.AfterFunc(300*time.Millisecond, cancel)
104+ if _, err := p.Delegate(ctx, Request{Agent: "bob", Question: "q"}, t.TempDir(), &noticing{}); err == nil {
105+ t.Fatal("the ask was not cancelled")
106+ }
107+ if !asked || askedCtxErr != nil {
108+ t.Errorf("asked %v, its context error %v", asked, askedCtxErr)
109+ }
110+}
new file mode 100644
@@ -0,0 +1,110 @@
1+package ask
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "mm/internal/config"
10+ "mm/internal/hooks"
11+)
12+
13+// unloadLog records the pool's unload calls, and whether the asked agent's
14+// process was running at that moment — which is how the order is checked.
15+type unloadLog struct {
16+ calls []string
17+}
18+
19+func (l *unloadLog) unloads(p *Pool) Unloads {
20+ running := func() string {
21+ p.mu.Lock()
22+ defer p.mu.Unlock()
23+ if len(p.children) == 0 {
24+ return "before the ask"
25+ }
26+ return "after the ask"
27+ }
28+ return Unloads{
29+ Asker: func(context.Context) string {
30+ l.calls = append(l.calls, "asker "+running())
31+ return "unloaded asker-model"
32+ },
33+ Asked: func(_ context.Context, path string) string {
34+ l.calls = append(l.calls, "asked "+running()+" "+path)
35+ return "unloaded bob-model"
36+ },
37+ }
38+}
39+
40+// withUnload turns unloadModels on for bob (withAgent restores config.Cfg).
41+func withUnload(t *testing.T) string {
42+ t.Helper()
43+ withAgent(t)
44+ ref := config.Cfg.MMConfigs["bob"]
45+ ref.UnloadModels = true
46+ config.Cfg.MMConfigs = map[string]config.AgentRef{"bob": ref}
47+ return ref.Path
48+}
49+
50+// unloadModels: the asker's model goes right before bob starts, bob's once
51+// it has answered; each line is a notice.
52+func TestUnloadModelsFreesBothModelsAroundTheHandOff(t *testing.T) {
53+ path := withUnload(t)
54+ p := fakePool(t, "echo")
55+ log := &unloadLog{}
56+ p.SetUnloads(log.unloads(p))
57+ sink := &noticing{}
58+ res, err := p.Delegate(context.Background(), Request{Agent: "bob", Question: "q", Via: hooks.ViaCommand}, t.TempDir(), sink)
59+ if err != nil || res.Answer != "session 1: q" {
60+ t.Fatalf("Delegate = %q, %v", res.Answer, err)
61+ }
62+ want := "asker before the ask|asked after the ask " + path
63+ if got := strings.Join(log.calls, "|"); got != want {
64+ t.Errorf("calls = %q, want %q", got, want)
65+ }
66+ if got := strings.Join(sink.notices, "|"); got != "🧹 unloaded asker-model|🧹 unloaded bob-model" {
67+ t.Errorf("notices = %q", got)
68+ }
69+}
70+
71+// Without the option, or when a preAsk hook refuses the hand-off, nothing is
72+// unloaded.
73+func TestUnloadModelsOffOrDeniedUnloadsNothing(t *testing.T) {
74+ withAgent(t)
75+ p := fakePool(t, "echo")
76+ log := &unloadLog{}
77+ p.SetUnloads(log.unloads(p))
78+ if _, err := p.Delegate(context.Background(), Request{Agent: "bob", Question: "q"}, t.TempDir(), &noticing{}); err != nil {
79+ t.Fatal(err)
80+ }
81+ withUnload(t)
82+ withHooks(t, config.HooksConfig{PreAsk: []config.Hook{shHook(`echo '{"action":"deny","reason":"no"}'`)}})
83+ if _, err := p.Delegate(context.Background(), Request{Agent: "bob", Question: "q"}, t.TempDir(), &noticing{}); !IsDenied(err) {
84+ t.Fatalf("err = %v, want a deny", err)
85+ }
86+ if len(log.calls) != 0 {
87+ t.Errorf("unloaded: %q", log.calls)
88+ }
89+}
90+
91+// An ask that was cancelled still leaves bob's model loaded: it is unloaded
92+// all the same, with a context that is not the cancelled one.
93+func TestUnloadModelsAfterACancelledAsk(t *testing.T) {
94+ withUnload(t)
95+ p := fakePool(t, "slow")
96+ var askedCtxErr error
97+ asked := false
98+ p.SetUnloads(Unloads{Asked: func(ctx context.Context, _ string) string {
99+ asked, askedCtxErr = true, ctx.Err()
100+ return "unloaded bob-model"
101+ }})
102+ ctx, cancel := context.WithCancel(context.Background())
103+ time.AfterFunc(300*time.Millisecond, cancel)
104+ if _, err := p.Delegate(ctx, Request{Agent: "bob", Question: "q"}, t.TempDir(), &noticing{}); err == nil {
105+ t.Fatal("the ask was not cancelled")
106+ }
107+ if !asked || askedCtxErr != nil {
108+ t.Errorf("asked %v, its context error %v", asked, askedCtxErr)
109+ }
110+}
modified internal/config/agents.go +33 -0
@@ -95,6 +95,7 @@ func AgentNames() []string {
9595 // description: Implements a specification file, with tests.
9696 // yolo: true
9797 // askUser: false
98+// unloadModels: true
9899 type AgentRef struct {
99100 // Path of the agent's config file.
100101 Path string `yaml:"path"`
@@ -110,6 +111,13 @@ type AgentRef struct {
110111 // agent's user, false = the tool is not declared. Absent = the agent's
111112 // own askUserTool decides.
112113 AskUser *bool `yaml:"askUser"`
114+ // UnloadModels makes every hand-off to THIS agent free the server's
115+ // memory around it (ticket 161), so the two models are never loaded
116+ // together: the asking agent's model is unloaded right before this agent
117+ // starts, and this agent's model once it has answered, before the asking
118+ // agent reads the answer. Off by default. Not the same key as the
119+ // top-level `unloadModels`, which declares /unload and unload_model.
120+ UnloadModels bool `yaml:"unloadModels"`
113121 }
114122
115123 // UnmarshalYAML reads either spelling of an AgentRef.
@@ -126,3 +134,28 @@ func (r *AgentRef) UnmarshalYAML(node *yaml.Node) error {
126134 *r = AgentRef(p)
127135 return nil
128136 }
137+
138+// ReadModel reads the keys that locate another agent's model — provider,
139+// model, baseUrl, fallback, apiKeyEnv — from its config file, into a Config
140+// of its own: never into Cfg, which is the running agent's. A file without
141+// `provider` gets mm's built-in default, as it would when it runs.
142+//
143+// other, err := config.ReadModel("agent.go.yaml")
144+// // other.Provider == "llmman", other.Model == "huggingface.co/…:Q4_K_M"
145+func ReadModel(path string) (Config, error) {
146+ data, err := os.ReadFile(path)
147+ if err != nil {
148+ return Config{}, err
149+ }
150+ var c Config
151+ if err := yaml.Unmarshal(data, &c); err != nil {
152+ return Config{}, fmt.Errorf("%s: %w", path, err)
153+ }
154+ if c.Provider == "" {
155+ c.Provider = "dmr" // the built-in default; Cfg holds the RUNNING agent's
156+ }
157+ if strings.TrimSpace(c.Model) == "" {
158+ return Config{}, fmt.Errorf("%s: model must not be empty", path)
159+ }
160+ return c, nil
161+}
@@ -95,6 +95,7 @@ func AgentNames() []string {
95 // description: Implements a specification file, with tests.95 // description: Implements a specification file, with tests.
96 // yolo: true96 // yolo: true
97 // askUser: false97 // askUser: false
98+// unloadModels: true
98 type AgentRef struct {99 type AgentRef struct {
99 // Path of the agent's config file.100 // Path of the agent's config file.
100 Path string `yaml:"path"`101 Path string `yaml:"path"`
@@ -110,6 +111,13 @@ type AgentRef struct {
110 // agent's user, false = the tool is not declared. Absent = the agent's111 // agent's user, false = the tool is not declared. Absent = the agent's
111 // own askUserTool decides.112 // own askUserTool decides.
112 AskUser *bool `yaml:"askUser"`113 AskUser *bool `yaml:"askUser"`
114+ // UnloadModels makes every hand-off to THIS agent free the server's
115+ // memory around it (ticket 161), so the two models are never loaded
116+ // together: the asking agent's model is unloaded right before this agent
117+ // starts, and this agent's model once it has answered, before the asking
118+ // agent reads the answer. Off by default. Not the same key as the
119+ // top-level `unloadModels`, which declares /unload and unload_model.
120+ UnloadModels bool `yaml:"unloadModels"`
113 }121 }
114 122
115 // UnmarshalYAML reads either spelling of an AgentRef.123 // UnmarshalYAML reads either spelling of an AgentRef.
@@ -126,3 +134,28 @@ func (r *AgentRef) UnmarshalYAML(node *yaml.Node) error {
126 *r = AgentRef(p)134 *r = AgentRef(p)
127 return nil135 return nil
128 }136 }
137+
138+// ReadModel reads the keys that locate another agent's model — provider,
139+// model, baseUrl, fallback, apiKeyEnv — from its config file, into a Config
140+// of its own: never into Cfg, which is the running agent's. A file without
141+// `provider` gets mm's built-in default, as it would when it runs.
142+//
143+// other, err := config.ReadModel("agent.go.yaml")
144+// // other.Provider == "llmman", other.Model == "huggingface.co/…:Q4_K_M"
145+func ReadModel(path string) (Config, error) {
146+ data, err := os.ReadFile(path)
147+ if err != nil {
148+ return Config{}, err
149+ }
150+ var c Config
151+ if err := yaml.Unmarshal(data, &c); err != nil {
152+ return Config{}, fmt.Errorf("%s: %w", path, err)
153+ }
154+ if c.Provider == "" {
155+ c.Provider = "dmr" // the built-in default; Cfg holds the RUNNING agent's
156+ }
157+ if strings.TrimSpace(c.Model) == "" {
158+ return Config{}, fmt.Errorf("%s: model must not be empty", path)
159+ }
160+ return c, nil
161+}
added internal/config/unloadmodels_test.go +52 -0
new file mode 100644
@@ -0,0 +1,52 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// mmConfigs.<name>.unloadModels is off unless the entry sets it (ticket 161).
11+func TestAgentRefUnloadModels(t *testing.T) {
12+ dir := t.TempDir()
13+ for _, name := range []string{"agent.go.yaml", "agent.rust.yaml"} {
14+ if err := os.WriteFile(filepath.Join(dir, name), []byte("model: m\n"), 0o644); err != nil {
15+ t.Fatal(err)
16+ }
17+ }
18+ body := "model: m\nmmConfigs:\n go:\n path: " + filepath.Join(dir, "agent.go.yaml") + "\n unloadModels: true\n rust: " + filepath.Join(dir, "agent.rust.yaml") + "\n"
19+ if err := loadYAML(t, body); err != nil {
20+ t.Fatal(err)
21+ }
22+ if !Cfg.MMConfigs["go"].UnloadModels || Cfg.MMConfigs["rust"].UnloadModels {
23+ t.Errorf("mmConfigs = %+v", Cfg.MMConfigs)
24+ }
25+}
26+
27+// ReadModel reads another agent's model without touching Cfg; no provider
28+// means mm's built-in default, not the running agent's.
29+func TestReadModel(t *testing.T) {
30+ defaults := Cfg
31+ t.Cleanup(func() { Cfg = defaults })
32+ Cfg.Provider, Cfg.Model = "llmman", "running"
33+ dir := t.TempDir()
34+ path := filepath.Join(dir, "agent.other.yaml")
35+ if err := os.WriteFile(path, []byte("model: other\nbaseUrl: http://x/v1\n"), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ c, err := ReadModel(path)
39+ if err != nil || c.Provider != "dmr" || c.Model != "other" || c.BaseURL != "http://x/v1" {
40+ t.Errorf("ReadModel = %+v, %v", c, err)
41+ }
42+ if Cfg.Model != "running" {
43+ t.Error("ReadModel changed the running agent's config")
44+ }
45+ empty := filepath.Join(dir, "agent.empty.yaml")
46+ if err := os.WriteFile(empty, []byte("provider: ollama\n"), 0o644); err != nil {
47+ t.Fatal(err)
48+ }
49+ if _, err := ReadModel(empty); err == nil || !strings.Contains(err.Error(), "model must not be empty") {
50+ t.Errorf("empty model: %v", err)
51+ }
52+}
new file mode 100644
@@ -0,0 +1,52 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// mmConfigs.<name>.unloadModels is off unless the entry sets it (ticket 161).
11+func TestAgentRefUnloadModels(t *testing.T) {
12+ dir := t.TempDir()
13+ for _, name := range []string{"agent.go.yaml", "agent.rust.yaml"} {
14+ if err := os.WriteFile(filepath.Join(dir, name), []byte("model: m\n"), 0o644); err != nil {
15+ t.Fatal(err)
16+ }
17+ }
18+ body := "model: m\nmmConfigs:\n go:\n path: " + filepath.Join(dir, "agent.go.yaml") + "\n unloadModels: true\n rust: " + filepath.Join(dir, "agent.rust.yaml") + "\n"
19+ if err := loadYAML(t, body); err != nil {
20+ t.Fatal(err)
21+ }
22+ if !Cfg.MMConfigs["go"].UnloadModels || Cfg.MMConfigs["rust"].UnloadModels {
23+ t.Errorf("mmConfigs = %+v", Cfg.MMConfigs)
24+ }
25+}
26+
27+// ReadModel reads another agent's model without touching Cfg; no provider
28+// means mm's built-in default, not the running agent's.
29+func TestReadModel(t *testing.T) {
30+ defaults := Cfg
31+ t.Cleanup(func() { Cfg = defaults })
32+ Cfg.Provider, Cfg.Model = "llmman", "running"
33+ dir := t.TempDir()
34+ path := filepath.Join(dir, "agent.other.yaml")
35+ if err := os.WriteFile(path, []byte("model: other\nbaseUrl: http://x/v1\n"), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ c, err := ReadModel(path)
39+ if err != nil || c.Provider != "dmr" || c.Model != "other" || c.BaseURL != "http://x/v1" {
40+ t.Errorf("ReadModel = %+v, %v", c, err)
41+ }
42+ if Cfg.Model != "running" {
43+ t.Error("ReadModel changed the running agent's config")
44+ }
45+ empty := filepath.Join(dir, "agent.empty.yaml")
46+ if err := os.WriteFile(empty, []byte("provider: ollama\n"), 0o644); err != nil {
47+ t.Fatal(err)
48+ }
49+ if _, err := ReadModel(empty); err == nil || !strings.Contains(err.Error(), "model must not be empty") {
50+ t.Errorf("empty model: %v", err)
51+ }
52+}
modified internal/engine/decision.go +2 -1
@@ -97,7 +97,8 @@ func lastUserText(messages []*ai.Message) string {
9797 // ResolveDecider resolves the agent file of a skill-routing decider: like
9898 // the provider's Resolve — baseUrl, fallback probe, key from its variable —
9999 // but WITHOUT the AGENT_BASE_URL override, which names the RUNNING agent's
100-// server and would send the decision question to the chat model.
100+// server and would send the decision question to the chat model. UnloadAgent
101+// resolves another agent's file the same way, for the same reason.
101102 //
102103 // d, _ := config.ReadDecider(config.Cfg.SkillRouting.Decider)
103104 // b, err := engine.ResolveDecider(d)
@@ -97,7 +97,8 @@ func lastUserText(messages []*ai.Message) string {
97 // ResolveDecider resolves the agent file of a skill-routing decider: like97 // ResolveDecider resolves the agent file of a skill-routing decider: like
98 // the provider's Resolve — baseUrl, fallback probe, key from its variable —98 // the provider's Resolve — baseUrl, fallback probe, key from its variable —
99 // but WITHOUT the AGENT_BASE_URL override, which names the RUNNING agent's99 // but WITHOUT the AGENT_BASE_URL override, which names the RUNNING agent's
100-// server and would send the decision question to the chat model.100+// server and would send the decision question to the chat model. UnloadAgent
101+// resolves another agent's file the same way, for the same reason.
101 //102 //
102 // d, _ := config.ReadDecider(config.Cfg.SkillRouting.Decider)103 // d, _ := config.ReadDecider(config.Cfg.SkillRouting.Decider)
103 // b, err := engine.ResolveDecider(d)104 // b, err := engine.ResolveDecider(d)
modified internal/engine/unload.go +34 -0
@@ -11,6 +11,8 @@ import (
1111 "slices"
1212 "strings"
1313 "time"
14+
15+ "mm/internal/config"
1416 )
1517
1618 // Unloading a model from the server's memory (ticket 101): /unload and the
@@ -58,6 +60,38 @@ func (e *Engine) Unload(ctx context.Context, model string) string {
5860 return line
5961 }
6062
63+// UnloadAgent unloads the model of ANOTHER agent's config file, on that
64+// agent's server: its provider, baseUrl (or fallback) and key, resolved like
65+// a decider's — without AGENT_BASE_URL, which names the running agent's
66+// server. It is the asking side of `mmConfigs.<name>.unloadModels` (ticket
67+// 161): the asked agent's own /unload cannot be relied on, since that agent
68+// may have turned the command off. One line, never an error, like Unload.
69+//
70+// fmt.Println(engine.UnloadAgent(ctx, "agent.go.yaml")) // "unloaded huggingface.co/…:Q4_K_M"
71+func UnloadAgent(ctx context.Context, configPath string) string {
72+ cfg, err := config.ReadModel(configPath)
73+ if err != nil {
74+ return fmt.Sprintf("cannot unload the model of %s: %v", configPath, err)
75+ }
76+ p, err := Lookup(cfg.Provider)
77+ if err != nil {
78+ return fmt.Sprintf("cannot unload %s: %v", cfg.Model, err)
79+ }
80+ b, err := ResolveDecider(cfg)
81+ if err != nil {
82+ return fmt.Sprintf("cannot unload %s: %v", cfg.Model, err)
83+ }
84+ u, ok := p.(Unloader)
85+ if !ok {
86+ return fmt.Sprintf("cannot unload %s: the %s provider cannot unload a model", cfg.Model, cfg.Provider)
87+ }
88+ line, err := u.Unload(ctx, b, cfg.Model)
89+ if err != nil {
90+ return fmt.Sprintf("cannot unload %s: %v", cfg.Model, err)
91+ }
92+ return line
93+}
94+
6195 // Unload implements Unloader with the server's own way, when it has one.
6296 func (p *openaiCompat) Unload(ctx context.Context, b Backend, model string) (string, error) {
6397 if p.unload == nil {
@@ -11,6 +11,8 @@ import (
11 "slices"11 "slices"
12 "strings"12 "strings"
13 "time"13 "time"
14+
15+ "mm/internal/config"
14 )16 )
15 17
16 // Unloading a model from the server's memory (ticket 101): /unload and the18 // Unloading a model from the server's memory (ticket 101): /unload and the
@@ -58,6 +60,38 @@ func (e *Engine) Unload(ctx context.Context, model string) string {
58 return line60 return line
59 }61 }
60 62
63+// UnloadAgent unloads the model of ANOTHER agent's config file, on that
64+// agent's server: its provider, baseUrl (or fallback) and key, resolved like
65+// a decider's — without AGENT_BASE_URL, which names the running agent's
66+// server. It is the asking side of `mmConfigs.<name>.unloadModels` (ticket
67+// 161): the asked agent's own /unload cannot be relied on, since that agent
68+// may have turned the command off. One line, never an error, like Unload.
69+//
70+// fmt.Println(engine.UnloadAgent(ctx, "agent.go.yaml")) // "unloaded huggingface.co/…:Q4_K_M"
71+func UnloadAgent(ctx context.Context, configPath string) string {
72+ cfg, err := config.ReadModel(configPath)
73+ if err != nil {
74+ return fmt.Sprintf("cannot unload the model of %s: %v", configPath, err)
75+ }
76+ p, err := Lookup(cfg.Provider)
77+ if err != nil {
78+ return fmt.Sprintf("cannot unload %s: %v", cfg.Model, err)
79+ }
80+ b, err := ResolveDecider(cfg)
81+ if err != nil {
82+ return fmt.Sprintf("cannot unload %s: %v", cfg.Model, err)
83+ }
84+ u, ok := p.(Unloader)
85+ if !ok {
86+ return fmt.Sprintf("cannot unload %s: the %s provider cannot unload a model", cfg.Model, cfg.Provider)
87+ }
88+ line, err := u.Unload(ctx, b, cfg.Model)
89+ if err != nil {
90+ return fmt.Sprintf("cannot unload %s: %v", cfg.Model, err)
91+ }
92+ return line
93+}
94+
61 // Unload implements Unloader with the server's own way, when it has one.95 // Unload implements Unloader with the server's own way, when it has one.
62 func (p *openaiCompat) Unload(ctx context.Context, b Backend, model string) (string, error) {96 func (p *openaiCompat) Unload(ctx context.Context, b Backend, model string) (string, error) {
63 if p.unload == nil {97 if p.unload == nil {
modified internal/engine/unload_test.go +32 -0
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "net/http"
77 "net/http/httptest"
8+ "os"
89 "strings"
910 "sync"
1011 "testing"
@@ -137,3 +138,34 @@ func TestEngineUnloadLines(t *testing.T) {
137138 t.Errorf("unreachable Unload = %q", got)
138139 }
139140 }
141+
142+// UnloadAgent reads ANOTHER agent's file and unloads its model on that
143+// agent's server; a provider without an unload route, or a file that cannot
144+// be read, gives a line saying so.
145+func TestUnloadAgentUsesTheOtherAgentsFile(t *testing.T) {
146+ f := &fakeOllama{loaded: []string{"qwen3.5:9b"}}
147+ srv := f.server(t)
148+ dir := t.TempDir()
149+ write := func(name, body string) string {
150+ p := dir + "/" + name
151+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
152+ t.Fatal(err)
153+ }
154+ return p
155+ }
156+ ctx := context.Background()
157+ ollama := write("agent.expert.yaml", "provider: ollama\nmodel: qwen3.5:9b\nbaseUrl: "+srv.URL+"/v1\nfallback: \"\"\n")
158+ if got := UnloadAgent(ctx, ollama); got != "unloaded qwen3.5:9b" {
159+ t.Errorf("ollama: %q", got)
160+ }
161+ if len(f.generate) != 1 || f.generate[0]["model"] != "qwen3.5:9b" {
162+ t.Errorf("generate requests = %v", f.generate)
163+ }
164+ openai := write("agent.openai.yaml", "provider: openai\nmodel: gpt\nbaseUrl: "+srv.URL+"/v1\n")
165+ if got := UnloadAgent(ctx, openai); !strings.HasPrefix(got, "cannot unload gpt:") {
166+ t.Errorf("openai: %q", got)
167+ }
168+ if got := UnloadAgent(ctx, dir+"/absent.yaml"); !strings.HasPrefix(got, "cannot unload the model of "+dir+"/absent.yaml") {
169+ t.Errorf("absent: %q", got)
170+ }
171+}
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "net/http"6 "net/http"
7 "net/http/httptest"7 "net/http/httptest"
8+ "os"
8 "strings"9 "strings"
9 "sync"10 "sync"
10 "testing"11 "testing"
@@ -137,3 +138,34 @@ func TestEngineUnloadLines(t *testing.T) {
137 t.Errorf("unreachable Unload = %q", got)138 t.Errorf("unreachable Unload = %q", got)
138 }139 }
139 }140 }
141+
142+// UnloadAgent reads ANOTHER agent's file and unloads its model on that
143+// agent's server; a provider without an unload route, or a file that cannot
144+// be read, gives a line saying so.
145+func TestUnloadAgentUsesTheOtherAgentsFile(t *testing.T) {
146+ f := &fakeOllama{loaded: []string{"qwen3.5:9b"}}
147+ srv := f.server(t)
148+ dir := t.TempDir()
149+ write := func(name, body string) string {
150+ p := dir + "/" + name
151+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
152+ t.Fatal(err)
153+ }
154+ return p
155+ }
156+ ctx := context.Background()
157+ ollama := write("agent.expert.yaml", "provider: ollama\nmodel: qwen3.5:9b\nbaseUrl: "+srv.URL+"/v1\nfallback: \"\"\n")
158+ if got := UnloadAgent(ctx, ollama); got != "unloaded qwen3.5:9b" {
159+ t.Errorf("ollama: %q", got)
160+ }
161+ if len(f.generate) != 1 || f.generate[0]["model"] != "qwen3.5:9b" {
162+ t.Errorf("generate requests = %v", f.generate)
163+ }
164+ openai := write("agent.openai.yaml", "provider: openai\nmodel: gpt\nbaseUrl: "+srv.URL+"/v1\n")
165+ if got := UnloadAgent(ctx, openai); !strings.HasPrefix(got, "cannot unload gpt:") {
166+ t.Errorf("openai: %q", got)
167+ }
168+ if got := UnloadAgent(ctx, dir+"/absent.yaml"); !strings.HasPrefix(got, "cannot unload the model of "+dir+"/absent.yaml") {
169+ t.Errorf("absent: %q", got)
170+ }
171+}
modified internal/tools/askagent.go +11 -1
@@ -180,7 +180,17 @@ func beating(ctx context.Context, s ui.Sink) ui.Sink {
180180 return beatingSink{Sink: s, beat: engine.Heartbeat(ctx)}
181181 }
182182
183-func (b beatingSink) Text(s string) { b.beat(); b.Sink.Text(s) }
183+func (b beatingSink) Text(s string) { b.beat(); b.Sink.Text(s) }
184+
185+// Notice passes mm's own lines (an unload, a hook's report) on to the sink
186+// when it has a notice line, else to ui.Notice — the wrapper must not hide it.
187+func (b beatingSink) Notice(s string) {
188+ if n, ok := b.Sink.(ui.Noticer); ok {
189+ n.Notice(s)
190+ return
191+ }
192+ ui.Notice(s)
193+}
184194 func (b beatingSink) Reasoning(s string) { b.beat(); b.Sink.Reasoning(s) }
185195 func (b beatingSink) ToolStart(ev ui.ToolEvent) { b.beat(); b.Sink.ToolStart(ev) }
186196 func (b beatingSink) ToolRunning(id string) { b.beat(); b.Sink.ToolRunning(id) }
@@ -180,7 +180,17 @@ func beating(ctx context.Context, s ui.Sink) ui.Sink {
180 return beatingSink{Sink: s, beat: engine.Heartbeat(ctx)}180 return beatingSink{Sink: s, beat: engine.Heartbeat(ctx)}
181 }181 }
182 182
183-func (b beatingSink) Text(s string) { b.beat(); b.Sink.Text(s) }183+func (b beatingSink) Text(s string) { b.beat(); b.Sink.Text(s) }
184+
185+// Notice passes mm's own lines (an unload, a hook's report) on to the sink
186+// when it has a notice line, else to ui.Notice — the wrapper must not hide it.
187+func (b beatingSink) Notice(s string) {
188+ if n, ok := b.Sink.(ui.Noticer); ok {
189+ n.Notice(s)
190+ return
191+ }
192+ ui.Notice(s)
193+}
184 func (b beatingSink) Reasoning(s string) { b.beat(); b.Sink.Reasoning(s) }194 func (b beatingSink) Reasoning(s string) { b.beat(); b.Sink.Reasoning(s) }
185 func (b beatingSink) ToolStart(ev ui.ToolEvent) { b.beat(); b.Sink.ToolStart(ev) }195 func (b beatingSink) ToolStart(ev ui.ToolEvent) { b.beat(); b.Sink.ToolStart(ev) }
186 func (b beatingSink) ToolRunning(id string) { b.beat(); b.Sink.ToolRunning(id) }196 func (b beatingSink) ToolRunning(id string) { b.beat(); b.Sink.ToolRunning(id) }
modified main.go +9 -0
@@ -236,6 +236,12 @@ func main() {
236236 fail("[engine error: %v ]", err)
237237 os.Exit(1)
238238 }
239+ // mmConfigs.<name>.unloadModels (ticket 161): the pool frees this
240+ // agent's model before the hand-off, the asked agent's after it.
241+ asker.SetUnloads(ask.Unloads{
242+ Asker: func(ctx context.Context) string { return e.Unload(ctx, "") },
243+ Asked: engine.UnloadAgent,
244+ })
239245
240246 // The loop detector: the same call with the same result three times in a
241247 // row is flagged (the model is told to change approach); three flagged
@@ -840,6 +846,9 @@ func runAsk(ctx context.Context, name string, withContext bool, prompt string) i
840846 }
841847 pool := ask.NewPool()
842848 defer pool.Close()
849+ // mm --ask has no model of its own loaded: only the asked agent's is
850+ // freed (mmConfigs.<name>.unloadModels).
851+ pool.SetUnloads(ask.Unloads{Asked: engine.UnloadAgent})
843852 cwd, _ := os.Getwd()
844853 err = pool.CLI(ctx, req, cwd, os.Stdout, os.Stderr)
845854 switch {
@@ -236,6 +236,12 @@ func main() {
236 fail("[engine error: %v ]", err)236 fail("[engine error: %v ]", err)
237 os.Exit(1)237 os.Exit(1)
238 }238 }
239+ // mmConfigs.<name>.unloadModels (ticket 161): the pool frees this
240+ // agent's model before the hand-off, the asked agent's after it.
241+ asker.SetUnloads(ask.Unloads{
242+ Asker: func(ctx context.Context) string { return e.Unload(ctx, "") },
243+ Asked: engine.UnloadAgent,
244+ })
239 245
240 // The loop detector: the same call with the same result three times in a246 // The loop detector: the same call with the same result three times in a
241 // row is flagged (the model is told to change approach); three flagged247 // row is flagged (the model is told to change approach); three flagged
@@ -840,6 +846,9 @@ func runAsk(ctx context.Context, name string, withContext bool, prompt string) i
840 }846 }
841 pool := ask.NewPool()847 pool := ask.NewPool()
842 defer pool.Close()848 defer pool.Close()
849+ // mm --ask has no model of its own loaded: only the asked agent's is
850+ // freed (mmConfigs.<name>.unloadModels).
851+ pool.SetUnloads(ask.Unloads{Asked: engine.UnloadAgent})
843 cwd, _ := os.Getwd()852 cwd, _ := os.Getwd()
844 err = pool.CLI(ctx, req, cwd, os.Stdout, os.Stderr)853 err = pool.CLI(ctx, req, cwd, os.Stdout, os.Stderr)
845 switch {854 switch {