🛟 Updated.
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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | 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 | |
| 11 | 13 | milestone: v1.0.1 |
| 12 | 14 | body: | |
| 13 | 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 | 113 | comments: [] |
| @@ -6,10 +6,108 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-25T22:29:12.597Z | 8 | createdAt: 2026-10-25T22:29:12.597Z |
| 9 | -updatedAt: 2026-10-25T22:29:34.372Z | 9 | +updatedAt: 2026-10-25T23:59:00.000Z |
| 10 | -labels: [] | 10 | +labels: |
| 11 | + - enhancement | ||
| 12 | + - To be tested | ||
| 11 | milestone: v1.0.1 | 13 | 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 | ||
| 63 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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: | | ||
| 45 | 45 | |
| 46 | 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 | 70 | ## Variants |
| 49 | 71 | |
| 50 | 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 | 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 | 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 | |
| @@ -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 | ## Variants | 70 | ## 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 @@ | ||
| 48 | 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 | 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 | 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 | 53 | ## `context` keys |
| 54 | 54 | |
| @@ -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` keys | 53 | ## `context` keys |
| 54 | 54 | ||
modified
docs/en/reference/hooks.md +1 -1 | @@ -61,7 +61,7 @@ hooks: | ||
| 61 | 61 | |
| 62 | 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 | 66 | ## Execution |
| 67 | 67 | |
| @@ -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 | ## Execution | 66 | ## 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 | ||
| 63 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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: | | ||
| 45 | 45 | |
| 46 | 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 | 70 | ## Variantes |
| 49 | 71 | |
| 50 | 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 | 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 | 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 | |
| @@ -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 | ## Variantes | 70 | ## 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 @@ | ||
| 48 | 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 | 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 | 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 | 53 | ## Clés de `context` |
| 54 | 54 | |
| @@ -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: | ||
| 61 | 61 | |
| 62 | 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 | 66 | ## Exécution |
| 67 | 67 | |
| @@ -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écution | 66 | ## Exécution |
| 67 | 67 | ||
modified
internal/ask/delegate.go +47 -0 | @@ -62,6 +62,9 @@ func IsDenied(err error) bool { | ||
| 62 | 62 | // Delegate is the one way every hand-off goes — the ask_agent tool, the /ask |
| 63 | 63 | // commands, mm --ask: the asker's preAsk hooks, the prompt the asked agent |
| 64 | 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 | 68 | // Without hooks it is exactly Ask on Prompt(Question, Transcript). |
| 66 | 69 | // |
| 67 | 70 | // 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 | 91 | call.Prompt, call.Transcript = d.Prompt, d.Transcript |
| 89 | 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 | 98 | start := time.Now() |
| 92 | 99 | answer, err := p.Ask(ctx, req.Agent, path, cwd, prompt, sink) |
| 93 | 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 | 105 | if err != nil { |
| 95 | 106 | in.Error = err.Error() |
| 96 | 107 | } |
| @@ -127,3 +138,39 @@ func agentModel(path string) string { | ||
| 127 | 138 | } |
| 128 | 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 | +} | |
| @@ -62,6 +62,9 @@ func IsDenied(err error) bool { | |||
| 62 | // Delegate is the one way every hand-off goes — the ask_agent tool, the /ask | 62 | // 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 agent | 63 | // 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 the | 70 | // 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.Transcript | 91 | 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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "fmt" |
| 6 | + "strings" | |
| 6 | 7 | |
| 7 | 8 | "mm/internal/config" |
| 8 | 9 | "mm/internal/consent" |
| @@ -19,13 +20,33 @@ import ( | ||
| 19 | 20 | // answer, err := pool.Ask(ctx, "homer", path, cwd, prompt, &ask.LineSink{Cwd: cwd}) |
| 20 | 21 | type LineSink struct { |
| 21 | 22 | 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 | 33 | func (s *LineSink) Text(text string) { |
| 27 | 34 | spinner.Stop() |
| 28 | 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 | 52 | func (s *LineSink) Reasoning(text string) { |
| @@ -43,6 +64,7 @@ func (s *LineSink) Reasoning(text string) { | ||
| 43 | 64 | func (s *LineSink) ToolStart(ev ui.ToolEvent) { |
| 44 | 65 | spinner.Stop() |
| 45 | 66 | fmt.Printf("\n🛠️ %s\n", ev.Title) |
| 67 | + s.midLine = false | |
| 46 | 68 | } |
| 47 | 69 | |
| 48 | 70 | 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 string | 22 | 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 { | ||
| 33 | 33 | // notify receives the hooks' reports and notices (Delegate); nil = the |
| 34 | 34 | // sink's notice line, else ui.Notice. |
| 35 | 35 | notify func(string) |
| 36 | + // unloads frees the server's memory around a hand-off (SetUnloads). | |
| 37 | + unloads Unloads | |
| 36 | 38 | |
| 37 | 39 | mu sync.Mutex |
| 38 | 40 | children map[string]*child |
| @@ -33,6 +33,8 @@ type Pool struct { | |||
| 33 | // notify receives the hooks' reports and notices (Delegate); nil = the | 33 | // 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.Mutex | 39 | mu sync.Mutex |
| 38 | children map[string]*child | 40 | 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 := ¬icing{} | |
| 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(), ¬icing{}); 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(), ¬icing{}); !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(), ¬icing{}); 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 := ¬icing{} | ||
| 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(), ¬icing{}); 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(), ¬icing{}); !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(), ¬icing{}); 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 { | ||
| 95 | 95 | // description: Implements a specification file, with tests. |
| 96 | 96 | // yolo: true |
| 97 | 97 | // askUser: false |
| 98 | +// unloadModels: true | |
| 98 | 99 | type AgentRef struct { |
| 99 | 100 | // Path of the agent's config file. |
| 100 | 101 | Path string `yaml:"path"` |
| @@ -110,6 +111,13 @@ type AgentRef struct { | ||
| 110 | 111 | // agent's user, false = the tool is not declared. Absent = the agent's |
| 111 | 112 | // own askUserTool decides. |
| 112 | 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 | 123 | // UnmarshalYAML reads either spelling of an AgentRef. |
| @@ -126,3 +134,28 @@ func (r *AgentRef) UnmarshalYAML(node *yaml.Node) error { | ||
| 126 | 134 | *r = AgentRef(p) |
| 127 | 135 | 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 | +} | |
| @@ -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: true | 96 | // yolo: true |
| 97 | // askUser: false | 97 | // 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's | 111 | // 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 nil | 135 | 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 { | ||
| 97 | 97 | // ResolveDecider resolves the agent file of a skill-routing decider: like |
| 98 | 98 | // the provider's Resolve — baseUrl, fallback probe, key from its variable — |
| 99 | 99 | // 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 | 103 | // d, _ := config.ReadDecider(config.Cfg.SkillRouting.Decider) |
| 103 | 104 | // 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: like | 97 | // 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's | 99 | // 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 ( | ||
| 11 | 11 | "slices" |
| 12 | 12 | "strings" |
| 13 | 13 | "time" |
| 14 | + | |
| 15 | + "mm/internal/config" | |
| 14 | 16 | ) |
| 15 | 17 | |
| 16 | 18 | // 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 | 60 | 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 | 95 | // Unload implements Unloader with the server's own way, when it has one. |
| 62 | 96 | func (p *openaiCompat) Unload(ctx context.Context, b Backend, model string) (string, error) { |
| 63 | 97 | 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 the | 18 | // 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 line | 60 | 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 ( | ||
| 5 | 5 | "encoding/json" |
| 6 | 6 | "net/http" |
| 7 | 7 | "net/http/httptest" |
| 8 | + "os" | |
| 8 | 9 | "strings" |
| 9 | 10 | "sync" |
| 10 | 11 | "testing" |
| @@ -137,3 +138,34 @@ func TestEngineUnloadLines(t *testing.T) { | ||
| 137 | 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 | +} | |
| @@ -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 { | ||
| 180 | 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 | 194 | func (b beatingSink) Reasoning(s string) { b.beat(); b.Sink.Reasoning(s) } |
| 185 | 195 | func (b beatingSink) ToolStart(ev ui.ToolEvent) { b.beat(); b.Sink.ToolStart(ev) } |
| 186 | 196 | 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() { | ||
| 236 | 236 | fail("[engine error: %v ]", err) |
| 237 | 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 | 246 | // The loop detector: the same call with the same result three times in a |
| 241 | 247 | // 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 | 847 | pool := ask.NewPool() |
| 842 | 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 | 852 | cwd, _ := os.Getwd() |
| 844 | 853 | err = pool.CLI(ctx, req, cwd, os.Stdout, os.Stderr) |
| 845 | 854 | 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 a | 246 | // 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 flagged | 247 | // 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 { |