🛟 Updated.
6fcf1c7 parent: 19072cf modified
.tickets/issues/0061-llmman-provider-download-the-model-automatically.yaml +111 -5 | @@ -1,15 +1,121 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 61 |
| 3 | 3 | title: "llmman provider: download the model automatically if does not exist" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-01T05:48:44.919Z |
| 9 | -updatedAt: 2026-10-22T17:11:46.417Z | |
| 10 | -labels: [] | |
| 9 | +updatedAt: 2026-10-25T23:35:37.303Z | |
| 10 | +labels: | |
| 11 | + - enhancement | |
| 12 | + - To be tested | |
| 11 | 13 | milestone: v1.0.1 |
| 12 | 14 | epic: Providers (LLM Engine) |
| 13 | -body: "" | |
| 14 | -tasks: [] | |
| 15 | +related: | |
| 16 | + - 162 | |
| 17 | + - 163 | |
| 18 | + - 164 | |
| 19 | +body: | | |
| 20 | + ## Today | |
| 21 | + | |
| 22 | + - At start-up the llmman probe only warns when the model is not in `/v1/models`: `llmman: model "…" is not in the store — the first question will pull it (or: llmman pull …)` (`internal/engine/llmman.go`, `apips.go`). | |
| 23 | + - llmman then pulls the model on the first chat request, but nothing shows the progress, and the watchdog (`firstTokenTimeout`) may cut a question that waits for a multi-GB download (not measured). A wrong name only shows up as a failed first question. | |
| 24 | + | |
| 25 | + ## Decision (2026-10-25) | |
| 26 | + | |
| 27 | + - A config key turns it on (user's choice): e.g. `autoPull: true` (name to settle when implementing). Off = today's behaviour (the warning). | |
| 28 | + - Pull at START-UP (user's choice), not at the first question: mm waits for the download before the agent is ready. | |
| 29 | + | |
| 30 | + ## Measured on llmman 0.1.571 (2026-10-25) | |
| 31 | + | |
| 32 | + `POST /api/pull {"model": "hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q4_K_M"}` streams one JSON object per line, Ollama's format: | |
| 33 | + | |
| 34 | + ``` | |
| 35 | + {"status":"pulling manifest"} | |
| 36 | + {"status":"pulling"} | |
| 37 | + {"completed":0,"status":"pulling","total":105454144} | |
| 38 | + … | |
| 39 | + {"completed":105454144,"status":"pulling","total":105454144} | |
| 40 | + {"status":"success"} | |
| 41 | + ``` | |
| 42 | + | |
| 43 | + - 18 lines for 105 MB: progress comes in coarse steps. | |
| 44 | + - A model already in the store: `pulling manifest` then `success` at once (4 ms). | |
| 45 | + - A wrong or inaccessible name: `{"status":"pulling manifest"}` then `{"error":"HF model info: GET https://huggingface.co/api/models/nobody/does-not-exist-GGUF: HTTP 401"}`. | |
| 46 | + | |
| 47 | + ## Plan | |
| 48 | + | |
| 49 | + 1. Config key (bool, default false), docs EN + FR. | |
| 50 | + 2. When the probe finds the model missing and the key is on: `POST /api/pull`, read the stream, show the progress (`⬇ pulling <model> — 2.1/8.1 GB (26 %)`: one updating line in the terminal, notices in the window, stderr in ACP), stop on `success`, fail start-up with llmman's own message on `error`. | |
| 51 | + 3. Also for the models mm starts itself: `mmConfigs` agents (each child mm pulls its own at its start-up) and a skill/tool-routing decider (same server API). | |
| 52 | + 4. Tests with a fake `/api/pull` (progress, success, error, already present); live check with SmolLM2-135M. | |
| 53 | + | |
| 54 | + ## What was done (2026-10-26) | |
| 55 | + | |
| 56 | + - Key `autoPull` (bool, default `false`). | |
| 57 | + - `internal/engine/pull.go`: `PullProgress`, `pullOllamaAPI` (POST /api/pull, reads the stream: progress, `error` → the server's words, stream ending without `success` → failure), `EnsureModel` (this engine) and `EnsureAgentModel` (another agent's file, resolved like `UnloadAgent`); `ErrCannotPull` (provider without a pull: only llmman has one), `ErrServerDown`. The model is looked up in `/v1/models` first: nothing is pulled when it is there. | |
| 58 | + - `pull_wiring.go` (main): right after the engine is created, in every mode (`-p` included): the agent's own model, then the `mmConfigs` agents' and the `skillRouting` / `toolRouting` deciders'. Progress on stderr — one line rewritten in place on a terminal, a line per tenth elsewhere; MB under 1 GB, tenths of a second under 10 s. Own model failing = exit 1 with `[engine error: autoPull: <model>: the server says: …]`; any other failure = a warning. An asked agent (child of an /ask) pulls nothing: its asker did, and its start-up is bounded (60 s). | |
| 59 | + | |
| 60 | + ## Verified | |
| 61 | + | |
| 62 | + - Tests: engine (pull, already present, server error, cut stream, garbage, server down, provider without a pull), main (off / on, progress lines, own model failing stops, an agent's failing warns, present model not pulled, terminal rendering, units). `go vet ./...` and `go test ./...` green. | |
| 63 | + - Live on llmman 0.1.571: `SmolLM2-135M-Instruct-GGUF:Q8_0` pulled through a pty — 22 in-place updates from 0 % to 100 %, `⬇ pulled …`, then the answer; second run: nothing pulled; a wrong name: exit 1 with llmman's `HTTP 401` message. (Q8_0 deleted afterwards; Q4_K_M left in the store.) | |
| 64 | + | |
| 65 | + ## Docs | |
| 66 | + | |
| 67 | + EN + FR: `reference/configuration.md` (`autoPull`), `how-to/run-with-llmman.md` (step 2 + variant). | |
| 68 | + | |
| 69 | + Related: 162 (Ollama), 163 (Docker Model Runner). | |
| 70 | +tasks: | |
| 71 | + - id: 1 | |
| 72 | + title: Config key autoPull (default false) | |
| 73 | + state: closed | |
| 74 | + priority: none | |
| 75 | + author: | |
| 76 | + name: k33g | |
| 77 | + email: ph.charriere@gmail.com | |
| 78 | + createdAt: 2026-10-26T00:50:00.000Z | |
| 79 | + - id: 2 | |
| 80 | + title: "engine: pull over /api/pull, EnsureModel, EnsureAgentModel" | |
| 81 | + state: closed | |
| 82 | + priority: none | |
| 83 | + author: | |
| 84 | + name: k33g | |
| 85 | + email: ph.charriere@gmail.com | |
| 86 | + createdAt: 2026-10-26T00:50:00.000Z | |
| 87 | + - id: 3 | |
| 88 | + title: "main: pull at start-up (own model, mmConfigs agents, routing deciders), progress on stderr" | |
| 89 | + state: closed | |
| 90 | + priority: none | |
| 91 | + author: | |
| 92 | + name: k33g | |
| 93 | + email: ph.charriere@gmail.com | |
| 94 | + createdAt: 2026-10-26T00:50:00.000Z | |
| 95 | + - id: 4 | |
| 96 | + title: Tests + live check with SmolLM2-135M | |
| 97 | + state: closed | |
| 98 | + priority: none | |
| 99 | + author: | |
| 100 | + name: k33g | |
| 101 | + email: ph.charriere@gmail.com | |
| 102 | + createdAt: 2026-10-26T00:50:00.000Z | |
| 103 | + - id: 5 | |
| 104 | + title: Docs EN + FR | |
| 105 | + state: closed | |
| 106 | + priority: none | |
| 107 | + author: | |
| 108 | + name: k33g | |
| 109 | + email: ph.charriere@gmail.com | |
| 110 | + createdAt: 2026-10-26T00:50:00.000Z | |
| 111 | + - id: 6 | |
| 112 | + title: "Check in the window mode and in an editor (ACP: progress on stderr)" | |
| 113 | + state: closed | |
| 114 | + priority: none | |
| 115 | + author: | |
| 116 | + name: k33g | |
| 117 | + email: ph.charriere@gmail.com | |
| 118 | + createdAt: 2026-10-26T00:50:00.000Z | |
| 119 | + updatedAt: 2026-10-25T23:35:23.499Z | |
| 120 | + closedAt: 2026-10-25T23:35:23.499Z | |
| 15 | 121 | comments: [] |
| @@ -1,15 +1,121 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 61 | 2 | id: 61 |
| 3 | title: "llmman provider: download the model automatically if does not exist" | 3 | title: "llmman provider: download the model automatically if does not exist" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-01T05:48:44.919Z | 8 | createdAt: 2026-10-01T05:48:44.919Z |
| 9 | -updatedAt: 2026-10-22T17:11:46.417Z | 9 | +updatedAt: 2026-10-25T23:35:37.303Z |
| 10 | -labels: [] | 10 | +labels: |
| 11 | + - enhancement | ||
| 12 | + - To be tested | ||
| 11 | milestone: v1.0.1 | 13 | milestone: v1.0.1 |
| 12 | epic: Providers (LLM Engine) | 14 | epic: Providers (LLM Engine) |
| 13 | -body: "" | 15 | +related: |
| 14 | -tasks: [] | 16 | + - 162 |
| 17 | + - 163 | ||
| 18 | + - 164 | ||
| 19 | +body: | | ||
| 20 | + ## Today | ||
| 21 | + | ||
| 22 | + - At start-up the llmman probe only warns when the model is not in `/v1/models`: `llmman: model "…" is not in the store — the first question will pull it (or: llmman pull …)` (`internal/engine/llmman.go`, `apips.go`). | ||
| 23 | + - llmman then pulls the model on the first chat request, but nothing shows the progress, and the watchdog (`firstTokenTimeout`) may cut a question that waits for a multi-GB download (not measured). A wrong name only shows up as a failed first question. | ||
| 24 | + | ||
| 25 | + ## Decision (2026-10-25) | ||
| 26 | + | ||
| 27 | + - A config key turns it on (user's choice): e.g. `autoPull: true` (name to settle when implementing). Off = today's behaviour (the warning). | ||
| 28 | + - Pull at START-UP (user's choice), not at the first question: mm waits for the download before the agent is ready. | ||
| 29 | + | ||
| 30 | + ## Measured on llmman 0.1.571 (2026-10-25) | ||
| 31 | + | ||
| 32 | + `POST /api/pull {"model": "hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q4_K_M"}` streams one JSON object per line, Ollama's format: | ||
| 33 | + | ||
| 34 | + ``` | ||
| 35 | + {"status":"pulling manifest"} | ||
| 36 | + {"status":"pulling"} | ||
| 37 | + {"completed":0,"status":"pulling","total":105454144} | ||
| 38 | + … | ||
| 39 | + {"completed":105454144,"status":"pulling","total":105454144} | ||
| 40 | + {"status":"success"} | ||
| 41 | + ``` | ||
| 42 | + | ||
| 43 | + - 18 lines for 105 MB: progress comes in coarse steps. | ||
| 44 | + - A model already in the store: `pulling manifest` then `success` at once (4 ms). | ||
| 45 | + - A wrong or inaccessible name: `{"status":"pulling manifest"}` then `{"error":"HF model info: GET https://huggingface.co/api/models/nobody/does-not-exist-GGUF: HTTP 401"}`. | ||
| 46 | + | ||
| 47 | + ## Plan | ||
| 48 | + | ||
| 49 | + 1. Config key (bool, default false), docs EN + FR. | ||
| 50 | + 2. When the probe finds the model missing and the key is on: `POST /api/pull`, read the stream, show the progress (`⬇ pulling <model> — 2.1/8.1 GB (26 %)`: one updating line in the terminal, notices in the window, stderr in ACP), stop on `success`, fail start-up with llmman's own message on `error`. | ||
| 51 | + 3. Also for the models mm starts itself: `mmConfigs` agents (each child mm pulls its own at its start-up) and a skill/tool-routing decider (same server API). | ||
| 52 | + 4. Tests with a fake `/api/pull` (progress, success, error, already present); live check with SmolLM2-135M. | ||
| 53 | + | ||
| 54 | + ## What was done (2026-10-26) | ||
| 55 | + | ||
| 56 | + - Key `autoPull` (bool, default `false`). | ||
| 57 | + - `internal/engine/pull.go`: `PullProgress`, `pullOllamaAPI` (POST /api/pull, reads the stream: progress, `error` → the server's words, stream ending without `success` → failure), `EnsureModel` (this engine) and `EnsureAgentModel` (another agent's file, resolved like `UnloadAgent`); `ErrCannotPull` (provider without a pull: only llmman has one), `ErrServerDown`. The model is looked up in `/v1/models` first: nothing is pulled when it is there. | ||
| 58 | + - `pull_wiring.go` (main): right after the engine is created, in every mode (`-p` included): the agent's own model, then the `mmConfigs` agents' and the `skillRouting` / `toolRouting` deciders'. Progress on stderr — one line rewritten in place on a terminal, a line per tenth elsewhere; MB under 1 GB, tenths of a second under 10 s. Own model failing = exit 1 with `[engine error: autoPull: <model>: the server says: …]`; any other failure = a warning. An asked agent (child of an /ask) pulls nothing: its asker did, and its start-up is bounded (60 s). | ||
| 59 | + | ||
| 60 | + ## Verified | ||
| 61 | + | ||
| 62 | + - Tests: engine (pull, already present, server error, cut stream, garbage, server down, provider without a pull), main (off / on, progress lines, own model failing stops, an agent's failing warns, present model not pulled, terminal rendering, units). `go vet ./...` and `go test ./...` green. | ||
| 63 | + - Live on llmman 0.1.571: `SmolLM2-135M-Instruct-GGUF:Q8_0` pulled through a pty — 22 in-place updates from 0 % to 100 %, `⬇ pulled …`, then the answer; second run: nothing pulled; a wrong name: exit 1 with llmman's `HTTP 401` message. (Q8_0 deleted afterwards; Q4_K_M left in the store.) | ||
| 64 | + | ||
| 65 | + ## Docs | ||
| 66 | + | ||
| 67 | + EN + FR: `reference/configuration.md` (`autoPull`), `how-to/run-with-llmman.md` (step 2 + variant). | ||
| 68 | + | ||
| 69 | + Related: 162 (Ollama), 163 (Docker Model Runner). | ||
| 70 | +tasks: | ||
| 71 | + - id: 1 | ||
| 72 | + title: Config key autoPull (default false) | ||
| 73 | + state: closed | ||
| 74 | + priority: none | ||
| 75 | + author: | ||
| 76 | + name: k33g | ||
| 77 | + email: ph.charriere@gmail.com | ||
| 78 | + createdAt: 2026-10-26T00:50:00.000Z | ||
| 79 | + - id: 2 | ||
| 80 | + title: "engine: pull over /api/pull, EnsureModel, EnsureAgentModel" | ||
| 81 | + state: closed | ||
| 82 | + priority: none | ||
| 83 | + author: | ||
| 84 | + name: k33g | ||
| 85 | + email: ph.charriere@gmail.com | ||
| 86 | + createdAt: 2026-10-26T00:50:00.000Z | ||
| 87 | + - id: 3 | ||
| 88 | + title: "main: pull at start-up (own model, mmConfigs agents, routing deciders), progress on stderr" | ||
| 89 | + state: closed | ||
| 90 | + priority: none | ||
| 91 | + author: | ||
| 92 | + name: k33g | ||
| 93 | + email: ph.charriere@gmail.com | ||
| 94 | + createdAt: 2026-10-26T00:50:00.000Z | ||
| 95 | + - id: 4 | ||
| 96 | + title: Tests + live check with SmolLM2-135M | ||
| 97 | + state: closed | ||
| 98 | + priority: none | ||
| 99 | + author: | ||
| 100 | + name: k33g | ||
| 101 | + email: ph.charriere@gmail.com | ||
| 102 | + createdAt: 2026-10-26T00:50:00.000Z | ||
| 103 | + - id: 5 | ||
| 104 | + title: Docs EN + FR | ||
| 105 | + state: closed | ||
| 106 | + priority: none | ||
| 107 | + author: | ||
| 108 | + name: k33g | ||
| 109 | + email: ph.charriere@gmail.com | ||
| 110 | + createdAt: 2026-10-26T00:50:00.000Z | ||
| 111 | + - id: 6 | ||
| 112 | + title: "Check in the window mode and in an editor (ACP: progress on stderr)" | ||
| 113 | + state: closed | ||
| 114 | + priority: none | ||
| 115 | + author: | ||
| 116 | + name: k33g | ||
| 117 | + email: ph.charriere@gmail.com | ||
| 118 | + createdAt: 2026-10-26T00:50:00.000Z | ||
| 119 | + updatedAt: 2026-10-25T23:35:23.499Z | ||
| 120 | + closedAt: 2026-10-25T23:35:23.499Z | ||
| 15 | comments: [] | 121 | comments: [] |
modified
.tickets/issues/0124-create-a-blog-post-about-llmman-alone-and-llmman.yaml +1 -2 | @@ -6,9 +6,8 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-22T06:06:35.493Z |
| 9 | -updatedAt: 2026-10-22T17:08:19.090Z | |
| 9 | +updatedAt: 2026-10-25T23:10:18.858Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.1 | |
| 12 | 11 | body: "" |
| 13 | 12 | tasks: [] |
| 14 | 13 | comments: [] |
| @@ -6,9 +6,8 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-22T06:06:35.493Z | 8 | createdAt: 2026-10-22T06:06:35.493Z |
| 9 | -updatedAt: 2026-10-22T17:08:19.090Z | 9 | +updatedAt: 2026-10-25T23:10:18.858Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.1 | ||
| 12 | body: "" | 11 | body: "" |
| 13 | tasks: [] | 12 | tasks: [] |
| 14 | comments: [] | 13 | comments: [] |
modified
.tickets/issues/0142-memory-how-it-works.yaml +15 -3 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 142 |
| 3 | 3 | title: "Memory: how it works ?" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-24T11:48:07.733Z |
| 9 | -updatedAt: 2026-10-24T14:00:00.000Z | |
| 9 | +updatedAt: 2026-10-26T00:15:00.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v1.0.1 |
| 12 | 12 | body: | |
| @@ -73,4 +73,16 @@ body: | | ||
| 73 | 73 | So a skill loaded in an old session comes back with `/resume`, as a tool |
| 74 | 74 | result in the history. |
| 75 | 75 | tasks: [] |
| 76 | -comments: [] | |
| 76 | +comments: | |
| 77 | + - id: 1 | |
| 78 | + author: | |
| 79 | + name: k33g | |
| 80 | + email: ph.charriere@gmail.com | |
| 81 | + createdAt: 2026-10-26T00:15:00.000Z | |
| 82 | + body: | | |
| 83 | + Closed: the question is answered, nothing to change in the code. | |
| 84 | + | |
| 85 | + - The body above is the answer, checked in the code on 2026-10-24 and still true on 2026-10-25: a skill's text enters the history as the `read_skill` tool response; the `$skill` reference is one short line; the catalogue is the tool's description, sent with every request, so a skill can always be loaded again. | |
| 86 | + - A compression (automatic or `/compact`) replaces every turn but the last `keepLastTurns` (3) by a summary: the skills read in those older turns leave the history; those of the recent turns stay, which is where a procedure is usually still running. | |
| 87 | + - ↑ recalls only the text of a question; `/resume` reloads a whole session, loaded skills included. | |
| 88 | + - What to do about it is ticket 143, closed with the reasons. | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 142 | 2 | id: 142 |
| 3 | title: "Memory: how it works ?" | 3 | title: "Memory: how it works ?" |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-24T11:48:07.733Z | 8 | createdAt: 2026-10-24T11:48:07.733Z |
| 9 | -updatedAt: 2026-10-24T14:00:00.000Z | 9 | +updatedAt: 2026-10-26T00:15:00.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v1.0.1 | 11 | milestone: v1.0.1 |
| 12 | body: | | 12 | body: | |
| @@ -73,4 +73,16 @@ body: | | |||
| 73 | So a skill loaded in an old session comes back with `/resume`, as a tool | 73 | So a skill loaded in an old session comes back with `/resume`, as a tool |
| 74 | result in the history. | 74 | result in the history. |
| 75 | tasks: [] | 75 | tasks: [] |
| 76 | -comments: [] | 76 | +comments: |
| 77 | + - id: 1 | ||
| 78 | + author: | ||
| 79 | + name: k33g | ||
| 80 | + email: ph.charriere@gmail.com | ||
| 81 | + createdAt: 2026-10-26T00:15:00.000Z | ||
| 82 | + body: | | ||
| 83 | + Closed: the question is answered, nothing to change in the code. | ||
| 84 | + | ||
| 85 | + - The body above is the answer, checked in the code on 2026-10-24 and still true on 2026-10-25: a skill's text enters the history as the `read_skill` tool response; the `$skill` reference is one short line; the catalogue is the tool's description, sent with every request, so a skill can always be loaded again. | ||
| 86 | + - A compression (automatic or `/compact`) replaces every turn but the last `keepLastTurns` (3) by a summary: the skills read in those older turns leave the history; those of the recent turns stay, which is where a procedure is usually still running. | ||
| 87 | + - ↑ recalls only the text of a question; `/resume` reloads a whole session, loaded skills included. | ||
| 88 | + - What to do about it is ticket 143, closed with the reasons. | ||
modified
.tickets/issues/0143-improve-management-of-the-skills-in-memory.yaml +16 -3 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 143 |
| 3 | 3 | title: Improve management of the skills in memory |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-24T11:49:03.667Z |
| 9 | -updatedAt: 2026-10-24T15:00:00.000Z | |
| 9 | +updatedAt: 2026-10-26T00:15:00.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v1.0.1 |
| 12 | 12 | related: |
| @@ -89,4 +89,17 @@ body: | | ||
| 89 | 89 | skill it was following? |
| 90 | 90 | - Whether the loop detector fires on a reload across turns. |
| 91 | 91 | tasks: [] |
| 92 | -comments: [] | |
| 92 | +comments: | |
| 93 | + - id: 1 | |
| 94 | + author: | |
| 95 | + name: k33g | |
| 96 | + email: ph.charriere@gmail.com | |
| 97 | + createdAt: 2026-10-26T00:15:00.000Z | |
| 98 | + body: | | |
| 99 | + Closed without change: option 3 — today's behaviour — is kept as the rule, and nothing else is worth doing now. Discussed on 2026-10-25 ("is there any point in removing the skills from the history at compression time?"). | |
| 100 | + | |
| 101 | + - Removing them from the history at compression time: already done. The summary replaces the old turns, and their `read_skill` responses with them. | |
| 102 | + - Removing them from the kept turns (or after each turn, option 2): rejected. A skill read recently is usually the procedure being followed; without it a small model reloads it (one more tool round trip) or improvises — the very thing skills, `📖 N skill(s)` and decider routing were added against. Rewriting old messages also breaks the server's prefix cache. | |
| 103 | + - Dedup of a skill read twice (b): small gain, not worth the risk that a small model attends poorly to a copy far up. | |
| 104 | + - The one real gain left is in the SUMMARY REQUEST, which carries the old turns raw, skills included, at the moment the context is nearly full. But skills are small (~100–3000 tokens) next to `bash` outputs (up to `maxOutput` each): if that request is ever lightened, it should be by shortening every large tool output (a `read_skill` response becoming `[skill X read]`), not by a skill-only rule. No ticket opened for it; to do only if a measurement on real sessions shows the summary request overflowing. | |
| 105 | + - Not done either: line (a), a code-written list of the skills used before the summary. It can be revived on its own if a model is seen not reloading the skill it was following after a compression. | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 143 | 2 | id: 143 |
| 3 | title: Improve management of the skills in memory | 3 | title: Improve management of the skills in memory |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-24T11:49:03.667Z | 8 | createdAt: 2026-10-24T11:49:03.667Z |
| 9 | -updatedAt: 2026-10-24T15:00:00.000Z | 9 | +updatedAt: 2026-10-26T00:15:00.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v1.0.1 | 11 | milestone: v1.0.1 |
| 12 | related: | 12 | related: |
| @@ -89,4 +89,17 @@ body: | | |||
| 89 | skill it was following? | 89 | skill it was following? |
| 90 | - Whether the loop detector fires on a reload across turns. | 90 | - Whether the loop detector fires on a reload across turns. |
| 91 | tasks: [] | 91 | tasks: [] |
| 92 | -comments: [] | 92 | +comments: |
| 93 | + - id: 1 | ||
| 94 | + author: | ||
| 95 | + name: k33g | ||
| 96 | + email: ph.charriere@gmail.com | ||
| 97 | + createdAt: 2026-10-26T00:15:00.000Z | ||
| 98 | + body: | | ||
| 99 | + Closed without change: option 3 — today's behaviour — is kept as the rule, and nothing else is worth doing now. Discussed on 2026-10-25 ("is there any point in removing the skills from the history at compression time?"). | ||
| 100 | + | ||
| 101 | + - Removing them from the history at compression time: already done. The summary replaces the old turns, and their `read_skill` responses with them. | ||
| 102 | + - Removing them from the kept turns (or after each turn, option 2): rejected. A skill read recently is usually the procedure being followed; without it a small model reloads it (one more tool round trip) or improvises — the very thing skills, `📖 N skill(s)` and decider routing were added against. Rewriting old messages also breaks the server's prefix cache. | ||
| 103 | + - Dedup of a skill read twice (b): small gain, not worth the risk that a small model attends poorly to a copy far up. | ||
| 104 | + - The one real gain left is in the SUMMARY REQUEST, which carries the old turns raw, skills included, at the moment the context is nearly full. But skills are small (~100–3000 tokens) next to `bash` outputs (up to `maxOutput` each): if that request is ever lightened, it should be by shortening every large tool output (a `read_skill` response becoming `[skill X read]`), not by a skill-only rule. No ticket opened for it; to do only if a measurement on real sessions shows the summary request overflowing. | ||
| 105 | + - Not done either: line (a), a code-written list of the skills used before the summary. It can be revived on its own if a model is seen not reloading the skill it was following after a compression. | ||
modified
.tickets/issues/0158-calculate-the-duration-of-a-completion.yaml +6 -6 | @@ -1,17 +1,17 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 158 |
| 3 | 3 | title: Calculate the duration of a completion |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-25T18:15:39.756Z |
| 9 | -updatedAt: 2026-10-25T22:40:00.000Z | |
| 9 | +updatedAt: 2026-10-25T23:05:21.835Z | |
| 10 | 10 | labels: |
| 11 | 11 | - enhancement |
| 12 | 12 | - To be tested |
| 13 | 13 | milestone: v1.0.1 |
| 14 | -body: |+ | |
| 14 | +body: | | |
| 15 | 15 | ## Need |
| 16 | 16 | |
| 17 | 17 | See how long a completion takes: in the window's footer, the duration should tick while the model works. |
| @@ -55,7 +55,7 @@ tasks: | ||
| 55 | 55 | email: ph.charriere@gmail.com |
| 56 | 56 | createdAt: 2026-10-25T22:20:00.000Z |
| 57 | 57 | - id: 3 |
| 58 | - title: "Tests + live check in the window (pty)" | |
| 58 | + title: Tests + live check in the window (pty) | |
| 59 | 59 | state: closed |
| 60 | 60 | priority: none |
| 61 | 61 | author: |
| @@ -63,7 +63,7 @@ tasks: | ||
| 63 | 63 | email: ph.charriere@gmail.com |
| 64 | 64 | createdAt: 2026-10-25T22:20:00.000Z |
| 65 | 65 | - id: 4 |
| 66 | - title: "Docs EN + FR" | |
| 66 | + title: Docs EN + FR | |
| 67 | 67 | state: closed |
| 68 | 68 | priority: none |
| 69 | 69 | author: |
| @@ -71,7 +71,7 @@ tasks: | ||
| 71 | 71 | email: ph.charriere@gmail.com |
| 72 | 72 | createdAt: 2026-10-25T22:20:00.000Z |
| 73 | 73 | - id: 5 |
| 74 | - title: "Config key showCompletionTime (default true) + tests + docs" | |
| 74 | + title: Config key showCompletionTime (default true) + tests + docs | |
| 75 | 75 | state: closed |
| 76 | 76 | priority: none |
| 77 | 77 | author: |
| @@ -1,17 +1,17 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 158 | 2 | id: 158 |
| 3 | title: Calculate the duration of a completion | 3 | title: Calculate the duration of a completion |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-25T18:15:39.756Z | 8 | createdAt: 2026-10-25T18:15:39.756Z |
| 9 | -updatedAt: 2026-10-25T22:40:00.000Z | 9 | +updatedAt: 2026-10-25T23:05:21.835Z |
| 10 | labels: | 10 | labels: |
| 11 | - enhancement | 11 | - enhancement |
| 12 | - To be tested | 12 | - To be tested |
| 13 | milestone: v1.0.1 | 13 | milestone: v1.0.1 |
| 14 | -body: |+ | 14 | +body: | |
| 15 | ## Need | 15 | ## Need |
| 16 | 16 | ||
| 17 | See how long a completion takes: in the window's footer, the duration should tick while the model works. | 17 | See how long a completion takes: in the window's footer, the duration should tick while the model works. |
| @@ -55,7 +55,7 @@ tasks: | |||
| 55 | email: ph.charriere@gmail.com | 55 | email: ph.charriere@gmail.com |
| 56 | createdAt: 2026-10-25T22:20:00.000Z | 56 | createdAt: 2026-10-25T22:20:00.000Z |
| 57 | - id: 3 | 57 | - id: 3 |
| 58 | - title: "Tests + live check in the window (pty)" | 58 | + title: Tests + live check in the window (pty) |
| 59 | state: closed | 59 | state: closed |
| 60 | priority: none | 60 | priority: none |
| 61 | author: | 61 | author: |
| @@ -63,7 +63,7 @@ tasks: | |||
| 63 | email: ph.charriere@gmail.com | 63 | email: ph.charriere@gmail.com |
| 64 | createdAt: 2026-10-25T22:20:00.000Z | 64 | createdAt: 2026-10-25T22:20:00.000Z |
| 65 | - id: 4 | 65 | - id: 4 |
| 66 | - title: "Docs EN + FR" | 66 | + title: Docs EN + FR |
| 67 | state: closed | 67 | state: closed |
| 68 | priority: none | 68 | priority: none |
| 69 | author: | 69 | author: |
| @@ -71,7 +71,7 @@ tasks: | |||
| 71 | email: ph.charriere@gmail.com | 71 | email: ph.charriere@gmail.com |
| 72 | createdAt: 2026-10-25T22:20:00.000Z | 72 | createdAt: 2026-10-25T22:20:00.000Z |
| 73 | - id: 5 | 73 | - id: 5 |
| 74 | - title: "Config key showCompletionTime (default true) + tests + docs" | 74 | + title: Config key showCompletionTime (default true) + tests + docs |
| 75 | state: closed | 75 | state: closed |
| 76 | priority: none | 76 | priority: none |
| 77 | author: | 77 | author: |
modified
.tickets/issues/0161-unload-model-should-be-an-option-of-the-usage-of.yaml +8 -6 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 161 |
| 3 | 3 | title: Unload model should be an option of the usage of multi agents |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | 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-25T23:59:00.000Z | |
| 9 | +updatedAt: 2026-10-25T23:05:06.886Z | |
| 10 | 10 | labels: |
| 11 | 11 | - enhancement |
| 12 | 12 | - To be tested |
| @@ -87,7 +87,7 @@ tasks: | ||
| 87 | 87 | email: ph.charriere@gmail.com |
| 88 | 88 | createdAt: 2026-10-25T23:59:00.000Z |
| 89 | 89 | - id: 5 |
| 90 | - title: "Tests + live check with llmman" | |
| 90 | + title: Tests + live check with llmman | |
| 91 | 91 | state: closed |
| 92 | 92 | priority: none |
| 93 | 93 | author: |
| @@ -95,7 +95,7 @@ tasks: | ||
| 95 | 95 | email: ph.charriere@gmail.com |
| 96 | 96 | createdAt: 2026-10-25T23:59:00.000Z |
| 97 | 97 | - id: 6 |
| 98 | - title: "New demo demos/coding-helpers-unload-option; docs EN + FR" | |
| 98 | + title: New demo demos/coding-helpers-unload-option; docs EN + FR | |
| 99 | 99 | state: closed |
| 100 | 100 | priority: none |
| 101 | 101 | author: |
| @@ -103,11 +103,13 @@ tasks: | ||
| 103 | 103 | email: ph.charriere@gmail.com |
| 104 | 104 | createdAt: 2026-10-25T23:59:00.000Z |
| 105 | 105 | - id: 7 |
| 106 | - title: "Check in the window and in an editor" | |
| 107 | - state: open | |
| 106 | + title: Check in the window and in an editor | |
| 107 | + state: closed | |
| 108 | 108 | priority: none |
| 109 | 109 | author: |
| 110 | 110 | name: k33g |
| 111 | 111 | email: ph.charriere@gmail.com |
| 112 | 112 | createdAt: 2026-10-25T23:59:00.000Z |
| 113 | + updatedAt: 2026-10-25T23:05:00.278Z | |
| 114 | + closedAt: 2026-10-25T23:05:00.278Z | |
| 113 | 115 | comments: [] |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 161 | 2 | id: 161 |
| 3 | title: Unload model should be an option of the usage of multi agents | 3 | title: Unload model should be an option of the usage of multi agents |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | 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-25T23:59:00.000Z | 9 | +updatedAt: 2026-10-25T23:05:06.886Z |
| 10 | labels: | 10 | labels: |
| 11 | - enhancement | 11 | - enhancement |
| 12 | - To be tested | 12 | - To be tested |
| @@ -87,7 +87,7 @@ tasks: | |||
| 87 | email: ph.charriere@gmail.com | 87 | email: ph.charriere@gmail.com |
| 88 | createdAt: 2026-10-25T23:59:00.000Z | 88 | createdAt: 2026-10-25T23:59:00.000Z |
| 89 | - id: 5 | 89 | - id: 5 |
| 90 | - title: "Tests + live check with llmman" | 90 | + title: Tests + live check with llmman |
| 91 | state: closed | 91 | state: closed |
| 92 | priority: none | 92 | priority: none |
| 93 | author: | 93 | author: |
| @@ -95,7 +95,7 @@ tasks: | |||
| 95 | email: ph.charriere@gmail.com | 95 | email: ph.charriere@gmail.com |
| 96 | createdAt: 2026-10-25T23:59:00.000Z | 96 | createdAt: 2026-10-25T23:59:00.000Z |
| 97 | - id: 6 | 97 | - id: 6 |
| 98 | - title: "New demo demos/coding-helpers-unload-option; docs EN + FR" | 98 | + title: New demo demos/coding-helpers-unload-option; docs EN + FR |
| 99 | state: closed | 99 | state: closed |
| 100 | priority: none | 100 | priority: none |
| 101 | author: | 101 | author: |
| @@ -103,11 +103,13 @@ tasks: | |||
| 103 | email: ph.charriere@gmail.com | 103 | email: ph.charriere@gmail.com |
| 104 | createdAt: 2026-10-25T23:59:00.000Z | 104 | createdAt: 2026-10-25T23:59:00.000Z |
| 105 | - id: 7 | 105 | - id: 7 |
| 106 | - title: "Check in the window and in an editor" | 106 | + title: Check in the window and in an editor |
| 107 | - state: open | 107 | + state: closed |
| 108 | priority: none | 108 | priority: none |
| 109 | author: | 109 | author: |
| 110 | name: k33g | 110 | name: k33g |
| 111 | email: ph.charriere@gmail.com | 111 | email: ph.charriere@gmail.com |
| 112 | createdAt: 2026-10-25T23:59:00.000Z | 112 | createdAt: 2026-10-25T23:59:00.000Z |
| 113 | + updatedAt: 2026-10-25T23:05:00.278Z | ||
| 114 | + closedAt: 2026-10-25T23:05:00.278Z | ||
| 113 | comments: [] | 115 | comments: [] |
added
.tickets/issues/0162-ollama-provider-download-the-model-automatically.yaml +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 162 | |
| 3 | +title: "ollama provider: download the model automatically if it does not exist" | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-25T23:40:00.000Z | |
| 9 | +updatedAt: 2026-10-25T23:40:00.000Z | |
| 10 | +labels: | |
| 11 | + - enhancement | |
| 12 | +milestone: v1.0.1 | |
| 13 | +epic: Providers (LLM Engine) | |
| 14 | +related: | |
| 15 | + - 61 | |
| 16 | + - 163 | |
| 17 | + - 164 | |
| 18 | +body: |+ | |
| 19 | + The same as ticket 61 (llmman), for Ollama: with the config key on, pull the model at start-up when the server does not have it, showing the progress. | |
| 20 | + | |
| 21 | + ## Today | |
| 22 | + | |
| 23 | + - The Ollama probe warns when the model is missing, with the hint `ollama pull <model>` (`internal/engine/openai_compat.go`, `notFoundHint`). | |
| 24 | + - Unlike llmman, Ollama's OpenAI API does NOT pull on the first request (`internal/engine/ollama.go`): the first question fails with a "model not found" error. | |
| 25 | + | |
| 26 | + ## Plan | |
| 27 | + | |
| 28 | + - Reuse ticket 61's code: Ollama's `POST /api/pull {"model": …}` is the API llmman copies — the same streamed lines (`status`, `completed` / `total`, `success`, `error`). Only the base URL changes. | |
| 29 | + - Same key, same start-up behaviour, same display. | |
| 30 | + - To verify on a real Ollama (none reachable from the sandbox on 2026-10-25): the stream's exact fields, the error for an unknown name, and how a model name with a tag (`qwen3.5:2b`) or `hf.co/…` is accepted. | |
| 31 | + - Tests with the fake `/api/pull` of 61; docs EN + FR. | |
| 32 | +tasks: [] | |
| 33 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 162 | ||
| 3 | +title: "ollama provider: download the model automatically if it does not exist" | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-25T23:40:00.000Z | ||
| 9 | +updatedAt: 2026-10-25T23:40:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - enhancement | ||
| 12 | +milestone: v1.0.1 | ||
| 13 | +epic: Providers (LLM Engine) | ||
| 14 | +related: | ||
| 15 | + - 61 | ||
| 16 | + - 163 | ||
| 17 | + - 164 | ||
| 18 | +body: |+ | ||
| 19 | + The same as ticket 61 (llmman), for Ollama: with the config key on, pull the model at start-up when the server does not have it, showing the progress. | ||
| 20 | + | ||
| 21 | + ## Today | ||
| 22 | + | ||
| 23 | + - The Ollama probe warns when the model is missing, with the hint `ollama pull <model>` (`internal/engine/openai_compat.go`, `notFoundHint`). | ||
| 24 | + - Unlike llmman, Ollama's OpenAI API does NOT pull on the first request (`internal/engine/ollama.go`): the first question fails with a "model not found" error. | ||
| 25 | + | ||
| 26 | + ## Plan | ||
| 27 | + | ||
| 28 | + - Reuse ticket 61's code: Ollama's `POST /api/pull {"model": …}` is the API llmman copies — the same streamed lines (`status`, `completed` / `total`, `success`, `error`). Only the base URL changes. | ||
| 29 | + - Same key, same start-up behaviour, same display. | ||
| 30 | + - To verify on a real Ollama (none reachable from the sandbox on 2026-10-25): the stream's exact fields, the error for an unknown name, and how a model name with a tag (`qwen3.5:2b`) or `hf.co/…` is accepted. | ||
| 31 | + - Tests with the fake `/api/pull` of 61; docs EN + FR. | ||
| 32 | +tasks: [] | ||
| 33 | +comments: [] | ||
added
.tickets/issues/0163-dmr-provider-download-the-model-automatically.yaml +36 -0 | new file mode 100644 | ||
| @@ -0,0 +1,36 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 163 | |
| 3 | +title: "dmr provider (Docker Model Runner): download the model automatically if it does not exist" | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-25T23:40:00.000Z | |
| 9 | +updatedAt: 2026-10-25T23:40:00.000Z | |
| 10 | +labels: | |
| 11 | + - enhancement | |
| 12 | +milestone: v1.0.1 | |
| 13 | +epic: Providers (LLM Engine) | |
| 14 | +related: | |
| 15 | + - 61 | |
| 16 | + - 162 | |
| 17 | + - 164 | |
| 18 | +body: |+ | |
| 19 | + The same as ticket 61 (llmman), for Docker Model Runner: with the config key on, pull the model at start-up when DMR does not have it, showing the progress. | |
| 20 | + | |
| 21 | + ## Today | |
| 22 | + | |
| 23 | + - The DMR probe warns when the model is not in the server's list: `dmr: model "…" is not in the server's list — docker model pull …` (`internal/engine/openai_compat.go`). Asking for a model that is not pulled returns an error (measured earlier, see the comment there). | |
| 24 | + | |
| 25 | + ## Open points (DMR has no Ollama API) | |
| 26 | + | |
| 27 | + - Find DMR's own pull route and its stream format. `docker model pull` talks to the Model Runner over its HTTP API (the endpoint is believed to be `POST /models/create {"from": "<model>"}` on the runner's base, without `/engines/v1` — NOT verified: no DMR reachable from the sandbox on 2026-10-25). | |
| 28 | + - Alternative if the route is not usable: run `docker model pull <model>` as a command and relay its output — needs the docker CLI where mm runs, which is not the case in a sandbox. | |
| 29 | + - Model names: `ai/smollm2`, `hf.co/…`. | |
| 30 | + | |
| 31 | + ## Plan | |
| 32 | + | |
| 33 | + - Same key, same start-up behaviour and display as 61; a DMR-specific pull behind the provider (`openaiCompat` gets a `pull` function, like `unload`). | |
| 34 | + - Tests with a fake server; live check on a machine with Docker Desktop; docs EN + FR. | |
| 35 | +tasks: [] | |
| 36 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,36 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 163 | ||
| 3 | +title: "dmr provider (Docker Model Runner): download the model automatically if it does not exist" | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-25T23:40:00.000Z | ||
| 9 | +updatedAt: 2026-10-25T23:40:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - enhancement | ||
| 12 | +milestone: v1.0.1 | ||
| 13 | +epic: Providers (LLM Engine) | ||
| 14 | +related: | ||
| 15 | + - 61 | ||
| 16 | + - 162 | ||
| 17 | + - 164 | ||
| 18 | +body: |+ | ||
| 19 | + The same as ticket 61 (llmman), for Docker Model Runner: with the config key on, pull the model at start-up when DMR does not have it, showing the progress. | ||
| 20 | + | ||
| 21 | + ## Today | ||
| 22 | + | ||
| 23 | + - The DMR probe warns when the model is not in the server's list: `dmr: model "…" is not in the server's list — docker model pull …` (`internal/engine/openai_compat.go`). Asking for a model that is not pulled returns an error (measured earlier, see the comment there). | ||
| 24 | + | ||
| 25 | + ## Open points (DMR has no Ollama API) | ||
| 26 | + | ||
| 27 | + - Find DMR's own pull route and its stream format. `docker model pull` talks to the Model Runner over its HTTP API (the endpoint is believed to be `POST /models/create {"from": "<model>"}` on the runner's base, without `/engines/v1` — NOT verified: no DMR reachable from the sandbox on 2026-10-25). | ||
| 28 | + - Alternative if the route is not usable: run `docker model pull <model>` as a command and relay its output — needs the docker CLI where mm runs, which is not the case in a sandbox. | ||
| 29 | + - Model names: `ai/smollm2`, `hf.co/…`. | ||
| 30 | + | ||
| 31 | + ## Plan | ||
| 32 | + | ||
| 33 | + - Same key, same start-up behaviour and display as 61; a DMR-specific pull behind the provider (`openaiCompat` gets a `pull` function, like `unload`). | ||
| 34 | + - Tests with a fake server; live check on a machine with Docker Desktop; docs EN + FR. | ||
| 35 | +tasks: [] | ||
| 36 | +comments: [] | ||
added
.tickets/issues/0164-llamacpp-provider-download-the-model-automatically.yaml +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 164 | |
| 3 | +title: "llamacpp provider: download the model automatically if it does not exist" | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-26T01:00:00.000Z | |
| 9 | +updatedAt: 2026-10-26T01:00:00.000Z | |
| 10 | +labels: | |
| 11 | + - enhancement | |
| 12 | +milestone: v1.0.1 | |
| 13 | +epic: Providers (LLM Engine) | |
| 14 | +related: | |
| 15 | + - 61 | |
| 16 | + - 162 | |
| 17 | + - 163 | |
| 18 | +body: |+ | |
| 19 | + The same as ticket 61 (llmman, done), for llama-server: with `autoPull: true`, have the model at start-up when the server does not, showing the progress. | |
| 20 | + | |
| 21 | + ## Today | |
| 22 | + | |
| 23 | + - `autoPull` on a `llamacpp` agent prints `[warning: autoPull: <model> (llamacpp): this provider cannot pull a model]` and goes on (`engine.ErrCannotPull`: the provider has no `pull` function). | |
| 24 | + - The probe already warns when the model is not served: `llama-server serves one model; check baseUrl ends with /v1 (model: …)`. | |
| 25 | + | |
| 26 | + ## Two cases, not alike | |
| 27 | + | |
| 28 | + - **Single-model llama-server** (`-m <file.gguf>` or `-hf <repo>:<quant>`): the model is chosen when the SERVER starts, and `-hf` already downloads it then. There is nothing mm can ask over HTTP. The right answer is a clear line instead of the generic warning: e.g. `llama-server serves one model, fixed at its start — start it with -hf <repo>:<quant> to download it`. | |
| 29 | + - **Router mode** (llama-server started without `-m`): mm already uses `POST /models/unload` there (`internal/engine/unload.go`); it also has `GET /models` and `POST /models/load`. To find out: can the router load (and so download) a Hugging Face model that is not in its cache or models dir, and does it report progress? If yes, `pull` = `/models/load` + progress; if it can only load what is already on disk, router mode is in the same case as single-model. | |
| 30 | + | |
| 31 | + ## Plan | |
| 32 | + | |
| 33 | + 1. Check on a real llama-server (none reachable from the sandbox on 2026-10-26; build in use: see `docs/*/how-to/run-with-llama-cpp.md`): router `/models` list, `/models/load` with an absent `-hf`-style id, the answer and any progress. | |
| 34 | + 2. Implement what (1) allows: a `pull` for the router, or an `ErrCannotPull` variant whose message says what to do for llama-server. | |
| 35 | + 3. Tests with a fake server; docs EN + FR (`reference/configuration.md` `autoPull`, `how-to/run-with-llama-cpp.md`). | |
| 36 | +tasks: [] | |
| 37 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 164 | ||
| 3 | +title: "llamacpp provider: download the model automatically if it does not exist" | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-26T01:00:00.000Z | ||
| 9 | +updatedAt: 2026-10-26T01:00:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - enhancement | ||
| 12 | +milestone: v1.0.1 | ||
| 13 | +epic: Providers (LLM Engine) | ||
| 14 | +related: | ||
| 15 | + - 61 | ||
| 16 | + - 162 | ||
| 17 | + - 163 | ||
| 18 | +body: |+ | ||
| 19 | + The same as ticket 61 (llmman, done), for llama-server: with `autoPull: true`, have the model at start-up when the server does not, showing the progress. | ||
| 20 | + | ||
| 21 | + ## Today | ||
| 22 | + | ||
| 23 | + - `autoPull` on a `llamacpp` agent prints `[warning: autoPull: <model> (llamacpp): this provider cannot pull a model]` and goes on (`engine.ErrCannotPull`: the provider has no `pull` function). | ||
| 24 | + - The probe already warns when the model is not served: `llama-server serves one model; check baseUrl ends with /v1 (model: …)`. | ||
| 25 | + | ||
| 26 | + ## Two cases, not alike | ||
| 27 | + | ||
| 28 | + - **Single-model llama-server** (`-m <file.gguf>` or `-hf <repo>:<quant>`): the model is chosen when the SERVER starts, and `-hf` already downloads it then. There is nothing mm can ask over HTTP. The right answer is a clear line instead of the generic warning: e.g. `llama-server serves one model, fixed at its start — start it with -hf <repo>:<quant> to download it`. | ||
| 29 | + - **Router mode** (llama-server started without `-m`): mm already uses `POST /models/unload` there (`internal/engine/unload.go`); it also has `GET /models` and `POST /models/load`. To find out: can the router load (and so download) a Hugging Face model that is not in its cache or models dir, and does it report progress? If yes, `pull` = `/models/load` + progress; if it can only load what is already on disk, router mode is in the same case as single-model. | ||
| 30 | + | ||
| 31 | + ## Plan | ||
| 32 | + | ||
| 33 | + 1. Check on a real llama-server (none reachable from the sandbox on 2026-10-26; build in use: see `docs/*/how-to/run-with-llama-cpp.md`): router `/models` list, `/models/load` with an absent `-hf`-style id, the answer and any progress. | ||
| 34 | + 2. Implement what (1) allows: a `pull` for the router, or an `ErrCannotPull` variant whose message says what to do for llama-server. | ||
| 35 | + 3. Tests with a fake server; docs EN + FR (`reference/configuration.md` `autoPull`, `how-to/run-with-llama-cpp.md`). | ||
| 36 | +tasks: [] | ||
| 37 | +comments: [] | ||
added
demos/auto-pull/README.en.md +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +# Auto-pull — mm downloads the missing models at start-up | |
| 2 | + | |
| 3 | +*[Version française](README.fr.md)* | |
| 4 | + | |
| 5 | +With `autoPull: true`, mm checks at start-up that llmman has the models it will use, and downloads the missing ones before the first question, showing the progress (ticket 61). The models here are tiny (SmolLM2 135M, about 100 MB each): the downloads take seconds. | |
| 6 | + | |
| 7 | +| File | Model | What it shows | | |
| 8 | +|---|---|---| | |
| 9 | +| `agent.yaml` | SmolLM2 135M `Q5_K_M` | the agent's own model pulled at start-up | | |
| 10 | +| `agent.team.yaml` + `agent.helper.yaml` | `Q5_K_M` for the coordinator, `Q6_K` for the helper | the `mmConfigs` agents' models pulled too, one after the other | | |
| 11 | +| `agent.typo.yaml` | a misspelt name | mm stops at once with llmman's message | | |
| 12 | + | |
| 13 | +## Run | |
| 14 | + | |
| 15 | +Start llmman (`llmman serve`), then: | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +cd demos/auto-pull | |
| 19 | +mm -config agent.yaml -p "Say hello in three words." | |
| 20 | +``` | |
| 21 | + | |
| 22 | +The first run downloads the model. On a terminal, one line is rewritten in place on stderr: | |
| 23 | + | |
| 24 | +```text | |
| 25 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M — 52 MB / 105 MB (49 %) | |
| 26 | +``` | |
| 27 | + | |
| 28 | +then: | |
| 29 | + | |
| 30 | +```text | |
| 31 | +⬇ pulled hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M in 3.4s | |
| 32 | + | |
| 33 | +🤖 Hello! | |
| 34 | +``` | |
| 35 | + | |
| 36 | +Run it again: the model is in the store, nothing is pulled, the answer comes straight away. `llmman list` (or `curl -s :17434/v1/models`) shows the new model. | |
| 37 | + | |
| 38 | +## The team | |
| 39 | + | |
| 40 | +```bash | |
| 41 | +mm -config agent.team.yaml -p "Ask helper to say hello." | |
| 42 | +``` | |
| 43 | + | |
| 44 | +Two downloads: the coordinator's `Q5_K_M` (instant if `agent.yaml` already pulled it), then the helper's `Q6_K`. The helper pulls nothing itself when it is asked: its asker already did. Talk to it with `mm -config agent.team.yaml --ask helper -p "say hello"`, or `/ask-helper say hello` in the REPL — a 135M model rarely calls `ask_agent` on its own. | |
| 45 | + | |
| 46 | +## A wrong name | |
| 47 | + | |
| 48 | +```bash | |
| 49 | +mm -config agent.typo.yaml -p "hi" | |
| 50 | +``` | |
| 51 | + | |
| 52 | +```text | |
| 53 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M | |
| 54 | +[engine error: autoPull: hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M: the server says: HF model info: GET https://huggingface.co/api/models/unsloth/SmolLM2-135M-Instruct-GUFF: HTTP 401 ] | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Exit code 1. Without `autoPull`, the same file starts with a warning and the first question fails. | |
| 58 | + | |
| 59 | +## See the download again | |
| 60 | + | |
| 61 | +llmman keeps a deleted model's file in its cache: after `llmman rm` (or `DELETE /api/delete`), pulling the same model again is instant. To watch a real download, put another quantization in `model:` — `Q3_K_M`, `Q2_K`, `F16`… (all listed on [the model page](https://huggingface.co/unsloth/SmolLM2-135M-Instruct-GGUF)). | |
| 62 | + | |
| 63 | +## Limits | |
| 64 | + | |
| 65 | +- llmman only. With another provider, `autoPull` gives a warning and mm goes on (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp). | |
| 66 | +- `mm --ask` does not pull: start the coordinator once (`-p` or the REPL) to pull the team's models. | |
| 67 | +- In a pipe or a log, the progress is one line per tenth instead of one line rewritten in place. | |
| 68 | + | |
| 69 | +See [`autoPull`](../../docs/en/reference/configuration.md) and [Run mm with llmman](../../docs/en/how-to/run-with-llmman.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +# Auto-pull — mm downloads the missing models at start-up | ||
| 2 | + | ||
| 3 | +*[Version française](README.fr.md)* | ||
| 4 | + | ||
| 5 | +With `autoPull: true`, mm checks at start-up that llmman has the models it will use, and downloads the missing ones before the first question, showing the progress (ticket 61). The models here are tiny (SmolLM2 135M, about 100 MB each): the downloads take seconds. | ||
| 6 | + | ||
| 7 | +| File | Model | What it shows | | ||
| 8 | +|---|---|---| | ||
| 9 | +| `agent.yaml` | SmolLM2 135M `Q5_K_M` | the agent's own model pulled at start-up | | ||
| 10 | +| `agent.team.yaml` + `agent.helper.yaml` | `Q5_K_M` for the coordinator, `Q6_K` for the helper | the `mmConfigs` agents' models pulled too, one after the other | | ||
| 11 | +| `agent.typo.yaml` | a misspelt name | mm stops at once with llmman's message | | ||
| 12 | + | ||
| 13 | +## Run | ||
| 14 | + | ||
| 15 | +Start llmman (`llmman serve`), then: | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +cd demos/auto-pull | ||
| 19 | +mm -config agent.yaml -p "Say hello in three words." | ||
| 20 | +``` | ||
| 21 | + | ||
| 22 | +The first run downloads the model. On a terminal, one line is rewritten in place on stderr: | ||
| 23 | + | ||
| 24 | +```text | ||
| 25 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M — 52 MB / 105 MB (49 %) | ||
| 26 | +``` | ||
| 27 | + | ||
| 28 | +then: | ||
| 29 | + | ||
| 30 | +```text | ||
| 31 | +⬇ pulled hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M in 3.4s | ||
| 32 | + | ||
| 33 | +🤖 Hello! | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +Run it again: the model is in the store, nothing is pulled, the answer comes straight away. `llmman list` (or `curl -s :17434/v1/models`) shows the new model. | ||
| 37 | + | ||
| 38 | +## The team | ||
| 39 | + | ||
| 40 | +```bash | ||
| 41 | +mm -config agent.team.yaml -p "Ask helper to say hello." | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +Two downloads: the coordinator's `Q5_K_M` (instant if `agent.yaml` already pulled it), then the helper's `Q6_K`. The helper pulls nothing itself when it is asked: its asker already did. Talk to it with `mm -config agent.team.yaml --ask helper -p "say hello"`, or `/ask-helper say hello` in the REPL — a 135M model rarely calls `ask_agent` on its own. | ||
| 45 | + | ||
| 46 | +## A wrong name | ||
| 47 | + | ||
| 48 | +```bash | ||
| 49 | +mm -config agent.typo.yaml -p "hi" | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +```text | ||
| 53 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M | ||
| 54 | +[engine error: autoPull: hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M: the server says: HF model info: GET https://huggingface.co/api/models/unsloth/SmolLM2-135M-Instruct-GUFF: HTTP 401 ] | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +Exit code 1. Without `autoPull`, the same file starts with a warning and the first question fails. | ||
| 58 | + | ||
| 59 | +## See the download again | ||
| 60 | + | ||
| 61 | +llmman keeps a deleted model's file in its cache: after `llmman rm` (or `DELETE /api/delete`), pulling the same model again is instant. To watch a real download, put another quantization in `model:` — `Q3_K_M`, `Q2_K`, `F16`… (all listed on [the model page](https://huggingface.co/unsloth/SmolLM2-135M-Instruct-GGUF)). | ||
| 62 | + | ||
| 63 | +## Limits | ||
| 64 | + | ||
| 65 | +- llmman only. With another provider, `autoPull` gives a warning and mm goes on (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp). | ||
| 66 | +- `mm --ask` does not pull: start the coordinator once (`-p` or the REPL) to pull the team's models. | ||
| 67 | +- In a pipe or a log, the progress is one line per tenth instead of one line rewritten in place. | ||
| 68 | + | ||
| 69 | +See [`autoPull`](../../docs/en/reference/configuration.md) and [Run mm with llmman](../../docs/en/how-to/run-with-llmman.md). | ||
added
demos/auto-pull/README.fr.md +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +# Auto-pull — mm télécharge les modèles manquants au démarrage | |
| 2 | + | |
| 3 | +*[English version](README.en.md)* | |
| 4 | + | |
| 5 | +Avec `autoPull: true`, mm vérifie au démarrage que llmman a les modèles qu'il va utiliser, et télécharge ceux qui manquent avant la première question, en affichant la progression (ticket 61). Les modèles d'ici sont minuscules (SmolLM2 135M, une centaine de Mo chacun) : les téléchargements prennent quelques secondes. | |
| 6 | + | |
| 7 | +| Fichier | Modèle | Ce qu'il montre | | |
| 8 | +|---|---|---| | |
| 9 | +| `agent.yaml` | SmolLM2 135M `Q5_K_M` | le modèle de l'agent téléchargé au démarrage | | |
| 10 | +| `agent.team.yaml` + `agent.helper.yaml` | `Q5_K_M` pour le coordinateur, `Q6_K` pour l'assistant | les modèles des agents `mmConfigs` téléchargés aussi, l'un après l'autre | | |
| 11 | +| `agent.typo.yaml` | un nom mal écrit | mm s'arrête tout de suite avec le message de llmman | | |
| 12 | + | |
| 13 | +## Lancer | |
| 14 | + | |
| 15 | +Démarrez llmman (`llmman serve`), puis : | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +cd demos/auto-pull | |
| 19 | +mm -config agent.yaml -p "Say hello in three words." | |
| 20 | +``` | |
| 21 | + | |
| 22 | +Le premier lancement télécharge le modèle. Dans un terminal, une ligne est réécrite sur place sur stderr : | |
| 23 | + | |
| 24 | +```text | |
| 25 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M — 52 MB / 105 MB (49 %) | |
| 26 | +``` | |
| 27 | + | |
| 28 | +puis : | |
| 29 | + | |
| 30 | +```text | |
| 31 | +⬇ pulled hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M in 3.4s | |
| 32 | + | |
| 33 | +🤖 Hello! | |
| 34 | +``` | |
| 35 | + | |
| 36 | +Relancez : le modèle est dans le store, rien n'est téléchargé, la réponse arrive tout de suite. `llmman list` (ou `curl -s :17434/v1/models`) montre le nouveau modèle. | |
| 37 | + | |
| 38 | +## L'équipe | |
| 39 | + | |
| 40 | +```bash | |
| 41 | +mm -config agent.team.yaml -p "Ask helper to say hello." | |
| 42 | +``` | |
| 43 | + | |
| 44 | +Deux téléchargements : le `Q5_K_M` du coordinateur (instantané si `agent.yaml` l'a déjà téléchargé), puis le `Q6_K` de l'assistant. L'assistant ne télécharge rien lui-même quand il est interrogé : celui qui l'interroge l'a déjà fait. Parlez-lui avec `mm -config agent.team.yaml --ask helper -p "say hello"`, ou `/ask-helper say hello` dans le REPL — un modèle de 135M appelle rarement `ask_agent` de lui-même. | |
| 45 | + | |
| 46 | +## Un nom faux | |
| 47 | + | |
| 48 | +```bash | |
| 49 | +mm -config agent.typo.yaml -p "hi" | |
| 50 | +``` | |
| 51 | + | |
| 52 | +```text | |
| 53 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M | |
| 54 | +[engine error: autoPull: hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M: the server says: HF model info: GET https://huggingface.co/api/models/unsloth/SmolLM2-135M-Instruct-GUFF: HTTP 401 ] | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Code de sortie 1. Sans `autoPull`, le même fichier démarre avec un avertissement et c'est la première question qui échoue. | |
| 58 | + | |
| 59 | +## Revoir le téléchargement | |
| 60 | + | |
| 61 | +llmman garde dans son cache le fichier d'un modèle supprimé : après `llmman rm` (ou `DELETE /api/delete`), retélécharger le même modèle est instantané. Pour voir un vrai téléchargement, mettez une autre quantification dans `model:` — `Q3_K_M`, `Q2_K`, `F16`… (toutes listées sur [la page du modèle](https://huggingface.co/unsloth/SmolLM2-135M-Instruct-GGUF)). | |
| 62 | + | |
| 63 | +## Limites | |
| 64 | + | |
| 65 | +- llmman seulement. Avec un autre fournisseur, `autoPull` donne un avertissement et mm continue (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp). | |
| 66 | +- `mm --ask` ne télécharge pas : lancez une fois le coordinateur (`-p` ou le REPL) pour télécharger les modèles de l'équipe. | |
| 67 | +- Dans un pipe ou un log, la progression tient en une ligne par dixième au lieu d'une ligne réécrite sur place. | |
| 68 | + | |
| 69 | +Voir [`autoPull`](../../docs/fr/reference/configuration.md) et [Lancer mm avec llmman](../../docs/fr/how-to/run-with-llmman.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +# Auto-pull — mm télécharge les modèles manquants au démarrage | ||
| 2 | + | ||
| 3 | +*[English version](README.en.md)* | ||
| 4 | + | ||
| 5 | +Avec `autoPull: true`, mm vérifie au démarrage que llmman a les modèles qu'il va utiliser, et télécharge ceux qui manquent avant la première question, en affichant la progression (ticket 61). Les modèles d'ici sont minuscules (SmolLM2 135M, une centaine de Mo chacun) : les téléchargements prennent quelques secondes. | ||
| 6 | + | ||
| 7 | +| Fichier | Modèle | Ce qu'il montre | | ||
| 8 | +|---|---|---| | ||
| 9 | +| `agent.yaml` | SmolLM2 135M `Q5_K_M` | le modèle de l'agent téléchargé au démarrage | | ||
| 10 | +| `agent.team.yaml` + `agent.helper.yaml` | `Q5_K_M` pour le coordinateur, `Q6_K` pour l'assistant | les modèles des agents `mmConfigs` téléchargés aussi, l'un après l'autre | | ||
| 11 | +| `agent.typo.yaml` | un nom mal écrit | mm s'arrête tout de suite avec le message de llmman | | ||
| 12 | + | ||
| 13 | +## Lancer | ||
| 14 | + | ||
| 15 | +Démarrez llmman (`llmman serve`), puis : | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +cd demos/auto-pull | ||
| 19 | +mm -config agent.yaml -p "Say hello in three words." | ||
| 20 | +``` | ||
| 21 | + | ||
| 22 | +Le premier lancement télécharge le modèle. Dans un terminal, une ligne est réécrite sur place sur stderr : | ||
| 23 | + | ||
| 24 | +```text | ||
| 25 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M — 52 MB / 105 MB (49 %) | ||
| 26 | +``` | ||
| 27 | + | ||
| 28 | +puis : | ||
| 29 | + | ||
| 30 | +```text | ||
| 31 | +⬇ pulled hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M in 3.4s | ||
| 32 | + | ||
| 33 | +🤖 Hello! | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +Relancez : le modèle est dans le store, rien n'est téléchargé, la réponse arrive tout de suite. `llmman list` (ou `curl -s :17434/v1/models`) montre le nouveau modèle. | ||
| 37 | + | ||
| 38 | +## L'équipe | ||
| 39 | + | ||
| 40 | +```bash | ||
| 41 | +mm -config agent.team.yaml -p "Ask helper to say hello." | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +Deux téléchargements : le `Q5_K_M` du coordinateur (instantané si `agent.yaml` l'a déjà téléchargé), puis le `Q6_K` de l'assistant. L'assistant ne télécharge rien lui-même quand il est interrogé : celui qui l'interroge l'a déjà fait. Parlez-lui avec `mm -config agent.team.yaml --ask helper -p "say hello"`, ou `/ask-helper say hello` dans le REPL — un modèle de 135M appelle rarement `ask_agent` de lui-même. | ||
| 45 | + | ||
| 46 | +## Un nom faux | ||
| 47 | + | ||
| 48 | +```bash | ||
| 49 | +mm -config agent.typo.yaml -p "hi" | ||
| 50 | +``` | ||
| 51 | + | ||
| 52 | +```text | ||
| 53 | +⬇ pulling hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M | ||
| 54 | +[engine error: autoPull: hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M: the server says: HF model info: GET https://huggingface.co/api/models/unsloth/SmolLM2-135M-Instruct-GUFF: HTTP 401 ] | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +Code de sortie 1. Sans `autoPull`, le même fichier démarre avec un avertissement et c'est la première question qui échoue. | ||
| 58 | + | ||
| 59 | +## Revoir le téléchargement | ||
| 60 | + | ||
| 61 | +llmman garde dans son cache le fichier d'un modèle supprimé : après `llmman rm` (ou `DELETE /api/delete`), retélécharger le même modèle est instantané. Pour voir un vrai téléchargement, mettez une autre quantification dans `model:` — `Q3_K_M`, `Q2_K`, `F16`… (toutes listées sur [la page du modèle](https://huggingface.co/unsloth/SmolLM2-135M-Instruct-GGUF)). | ||
| 62 | + | ||
| 63 | +## Limites | ||
| 64 | + | ||
| 65 | +- llmman seulement. Avec un autre fournisseur, `autoPull` donne un avertissement et mm continue (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp). | ||
| 66 | +- `mm --ask` ne télécharge pas : lancez une fois le coordinateur (`-p` ou le REPL) pour télécharger les modèles de l'équipe. | ||
| 67 | +- Dans un pipe ou un log, la progression tient en une ligne par dixième au lieu d'une ligne réécrite sur place. | ||
| 68 | + | ||
| 69 | +Voir [`autoPull`](../../docs/fr/reference/configuration.md) et [Lancer mm avec llmman](../../docs/fr/how-to/run-with-llmman.md). | ||
added
demos/auto-pull/agent.helper.yaml +26 -0 | new file mode 100644 | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +# The helper of agent.team.yaml: SmolLM2 135M, Q6_K (about 140 MB). Its | |
| 2 | +# model is pulled by the coordinator's autoPull, not by this file. | |
| 3 | + | |
| 4 | +provider: llmman | |
| 5 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q6_K | |
| 6 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 7 | +fallback: http://host.docker.internal:17434/v1 | |
| 8 | + | |
| 9 | +sampling: | |
| 10 | + temperature: 0.2 | |
| 11 | + max_tokens: 60 | |
| 12 | + | |
| 13 | +bashTool: false | |
| 14 | +editTools: false | |
| 15 | +planTool: false | |
| 16 | +askUserTool: false | |
| 17 | +fetchContentTool: false | |
| 18 | +unloadModels: false | |
| 19 | +skillsDir: no-skills | |
| 20 | +agentsMd: | |
| 21 | + enabled: false | |
| 22 | +sessions: | |
| 23 | + enabled: false | |
| 24 | + | |
| 25 | +system: | | |
| 26 | + You say hello, in one short sentence. | |
| new file mode 100644 | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | +# The helper of agent.team.yaml: SmolLM2 135M, Q6_K (about 140 MB). Its | ||
| 2 | +# model is pulled by the coordinator's autoPull, not by this file. | ||
| 3 | + | ||
| 4 | +provider: llmman | ||
| 5 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q6_K | ||
| 6 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 7 | +fallback: http://host.docker.internal:17434/v1 | ||
| 8 | + | ||
| 9 | +sampling: | ||
| 10 | + temperature: 0.2 | ||
| 11 | + max_tokens: 60 | ||
| 12 | + | ||
| 13 | +bashTool: false | ||
| 14 | +editTools: false | ||
| 15 | +planTool: false | ||
| 16 | +askUserTool: false | ||
| 17 | +fetchContentTool: false | ||
| 18 | +unloadModels: false | ||
| 19 | +skillsDir: no-skills | ||
| 20 | +agentsMd: | ||
| 21 | + enabled: false | ||
| 22 | +sessions: | ||
| 23 | + enabled: false | ||
| 24 | + | ||
| 25 | +system: | | ||
| 26 | + You say hello, in one short sentence. | ||
added
demos/auto-pull/agent.team.yaml +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +# Auto-pull for a team — the models of the mmConfigs agents are pulled too, | |
| 2 | +# at the coordinator's start-up, one after the other: | |
| 3 | +# | |
| 4 | +# mm -config agent.team.yaml -p "Ask helper to say hello." | |
| 5 | +# | |
| 6 | +# The coordinator uses the same model as agent.yaml (Q5_K_M); the helper | |
| 7 | +# (agent.helper.yaml) another quantization (Q6_K), so two downloads show. | |
| 8 | +# The helper itself pulls nothing when it is asked: its asker already did. | |
| 9 | + | |
| 10 | +provider: llmman | |
| 11 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M | |
| 12 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 13 | +fallback: http://host.docker.internal:17434/v1 | |
| 14 | + | |
| 15 | +autoPull: true | |
| 16 | + | |
| 17 | +mmConfigs: | |
| 18 | + helper: | |
| 19 | + path: agent.helper.yaml | |
| 20 | + description: A helper that says hello. | |
| 21 | + | |
| 22 | +sampling: | |
| 23 | + temperature: 0.2 | |
| 24 | + max_tokens: 60 | |
| 25 | + | |
| 26 | +bashTool: false | |
| 27 | +editTools: false | |
| 28 | +planTool: false | |
| 29 | +askUserTool: false | |
| 30 | +fetchContentTool: false | |
| 31 | +skillsDir: no-skills | |
| 32 | +agentsMd: | |
| 33 | + enabled: false | |
| 34 | +sessions: | |
| 35 | + enabled: false | |
| 36 | + | |
| 37 | +system: | | |
| 38 | + You are a friendly assistant. Answer in one short sentence. | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +# Auto-pull for a team — the models of the mmConfigs agents are pulled too, | ||
| 2 | +# at the coordinator's start-up, one after the other: | ||
| 3 | +# | ||
| 4 | +# mm -config agent.team.yaml -p "Ask helper to say hello." | ||
| 5 | +# | ||
| 6 | +# The coordinator uses the same model as agent.yaml (Q5_K_M); the helper | ||
| 7 | +# (agent.helper.yaml) another quantization (Q6_K), so two downloads show. | ||
| 8 | +# The helper itself pulls nothing when it is asked: its asker already did. | ||
| 9 | + | ||
| 10 | +provider: llmman | ||
| 11 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M | ||
| 12 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 13 | +fallback: http://host.docker.internal:17434/v1 | ||
| 14 | + | ||
| 15 | +autoPull: true | ||
| 16 | + | ||
| 17 | +mmConfigs: | ||
| 18 | + helper: | ||
| 19 | + path: agent.helper.yaml | ||
| 20 | + description: A helper that says hello. | ||
| 21 | + | ||
| 22 | +sampling: | ||
| 23 | + temperature: 0.2 | ||
| 24 | + max_tokens: 60 | ||
| 25 | + | ||
| 26 | +bashTool: false | ||
| 27 | +editTools: false | ||
| 28 | +planTool: false | ||
| 29 | +askUserTool: false | ||
| 30 | +fetchContentTool: false | ||
| 31 | +skillsDir: no-skills | ||
| 32 | +agentsMd: | ||
| 33 | + enabled: false | ||
| 34 | +sessions: | ||
| 35 | + enabled: false | ||
| 36 | + | ||
| 37 | +system: | | ||
| 38 | + You are a friendly assistant. Answer in one short sentence. | ||
added
demos/auto-pull/agent.typo.yaml +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +# A misspelt model name: with autoPull, mm stops at once with llmman's own | |
| 2 | +# message (exit 1), instead of a first question that fails later. | |
| 3 | +# | |
| 4 | +# mm -config agent.typo.yaml -p "hi" | |
| 5 | + | |
| 6 | +provider: llmman | |
| 7 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M | |
| 8 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 9 | +fallback: http://host.docker.internal:17434/v1 | |
| 10 | + | |
| 11 | +autoPull: true | |
| 12 | + | |
| 13 | +skillsDir: no-skills | |
| 14 | +agentsMd: | |
| 15 | + enabled: false | |
| 16 | +sessions: | |
| 17 | + enabled: false | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +# A misspelt model name: with autoPull, mm stops at once with llmman's own | ||
| 2 | +# message (exit 1), instead of a first question that fails later. | ||
| 3 | +# | ||
| 4 | +# mm -config agent.typo.yaml -p "hi" | ||
| 5 | + | ||
| 6 | +provider: llmman | ||
| 7 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GUFF:Q4_K_M | ||
| 8 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 9 | +fallback: http://host.docker.internal:17434/v1 | ||
| 10 | + | ||
| 11 | +autoPull: true | ||
| 12 | + | ||
| 13 | +skillsDir: no-skills | ||
| 14 | +agentsMd: | ||
| 15 | + enabled: false | ||
| 16 | +sessions: | ||
| 17 | + enabled: false | ||
added
demos/auto-pull/agent.yaml +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +# Auto-pull — ticket 61: with `autoPull: true`, mm downloads at start-up a | |
| 2 | +# model llmman does not have yet, and shows the progress on stderr. | |
| 3 | +# | |
| 4 | +# llmman serve | |
| 5 | +# cd demos/auto-pull | |
| 6 | +# mm -config agent.yaml -p "Say hello in three words." | |
| 7 | +# | |
| 8 | +# SmolLM2 135M Instruct, Q5_K_M: about 100 MB, so the download takes seconds. | |
| 9 | +# The first run pulls it; the next ones find it in the store and pull nothing. | |
| 10 | +# To see the download again, pick another quantization (Q3_K_M, Q6_K, F16…): | |
| 11 | +# llmman keeps a deleted model's file in its cache, so a re-pull of the same | |
| 12 | +# one is instant. | |
| 13 | + | |
| 14 | +provider: llmman | |
| 15 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M | |
| 16 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 17 | +fallback: http://host.docker.internal:17434/v1 | |
| 18 | + | |
| 19 | +autoPull: true | |
| 20 | + | |
| 21 | +sampling: | |
| 22 | + temperature: 0.2 | |
| 23 | + max_tokens: 60 | |
| 24 | + | |
| 25 | +# A 135M model: no tools, no skills, just an answer. | |
| 26 | +bashTool: false | |
| 27 | +editTools: false | |
| 28 | +planTool: false | |
| 29 | +askUserTool: false | |
| 30 | +fetchContentTool: false | |
| 31 | +skillsDir: no-skills | |
| 32 | +agentsMd: | |
| 33 | + enabled: false | |
| 34 | +sessions: | |
| 35 | + enabled: false | |
| 36 | + | |
| 37 | +system: | | |
| 38 | + You are a friendly assistant. Answer in one short sentence. | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +# Auto-pull — ticket 61: with `autoPull: true`, mm downloads at start-up a | ||
| 2 | +# model llmman does not have yet, and shows the progress on stderr. | ||
| 3 | +# | ||
| 4 | +# llmman serve | ||
| 5 | +# cd demos/auto-pull | ||
| 6 | +# mm -config agent.yaml -p "Say hello in three words." | ||
| 7 | +# | ||
| 8 | +# SmolLM2 135M Instruct, Q5_K_M: about 100 MB, so the download takes seconds. | ||
| 9 | +# The first run pulls it; the next ones find it in the store and pull nothing. | ||
| 10 | +# To see the download again, pick another quantization (Q3_K_M, Q6_K, F16…): | ||
| 11 | +# llmman keeps a deleted model's file in its cache, so a re-pull of the same | ||
| 12 | +# one is instant. | ||
| 13 | + | ||
| 14 | +provider: llmman | ||
| 15 | +model: hf.co/unsloth/SmolLM2-135M-Instruct-GGUF:Q5_K_M | ||
| 16 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 17 | +fallback: http://host.docker.internal:17434/v1 | ||
| 18 | + | ||
| 19 | +autoPull: true | ||
| 20 | + | ||
| 21 | +sampling: | ||
| 22 | + temperature: 0.2 | ||
| 23 | + max_tokens: 60 | ||
| 24 | + | ||
| 25 | +# A 135M model: no tools, no skills, just an answer. | ||
| 26 | +bashTool: false | ||
| 27 | +editTools: false | ||
| 28 | +planTool: false | ||
| 29 | +askUserTool: false | ||
| 30 | +fetchContentTool: false | ||
| 31 | +skillsDir: no-skills | ||
| 32 | +agentsMd: | ||
| 33 | + enabled: false | ||
| 34 | +sessions: | ||
| 35 | + enabled: false | ||
| 36 | + | ||
| 37 | +system: | | ||
| 38 | + You are a friendly assistant. Answer in one short sentence. | ||
modified
docs/en/how-to/run-with-llmman.md +2 -1 | @@ -10,7 +10,7 @@ This guide shows how to point `mm` at [llmman](https://github.com/llmmanorg/llmm | ||
| 10 | 10 | llmman serve |
| 11 | 11 | ``` |
| 12 | 12 | |
| 13 | -2. Optionally pull the model first. Otherwise llmman pulls it on the first question, and that question waits for the download: | |
| 13 | +2. Optionally pull the model first — or set `autoPull: true` and `mm` pulls it at start-up (see the variants). Otherwise llmman pulls it on the first question, and that question waits for the download: | |
| 14 | 14 | |
| 15 | 15 | ```bash |
| 16 | 16 | llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M |
| @@ -28,6 +28,7 @@ This guide shows how to point `mm` at [llmman](https://github.com/llmmanorg/llmm | ||
| 28 | 28 | ## Variants |
| 29 | 29 | |
| 30 | 30 | - **Another model.** Set `model:` to the id exactly as `llmman list` (or `GET /v1/models`) spells it. |
| 31 | +- **Let mm pull the model.** Add `autoPull: true`: at start-up, a model llmman does not have is downloaded with its progress (`⬇ pulling … (35 %)`), before the first question — and a misspelt name stops `mm` at once with llmman's message. The models of the `mmConfigs` agents and of the routing deciders are pulled too. See [`autoPull`](../reference/configuration.md). | |
| 31 | 32 | - **From a Docker sandbox.** Nothing to change: when `127.0.0.1:17434` does not answer, `mm` falls back to `http://host.docker.internal:17434/v1`, the daemon on the host. |
| 32 | 33 | - **Daemon started with `LLMMAN_API_KEYS`** (mandatory when it listens beyond loopback). Export one of the keys in `LLMMAN_API_KEY`, the provider's default variable, or name another variable under `apiKeyEnv`. |
| 33 | 34 | - **`LLMMAN_CONTEXT_LENGTH` set on the daemon.** `/api/ps` already reports it once the model is loaded; set `contextWindow` to the same value if you want compression to know it from the first question. |
| @@ -10,7 +10,7 @@ This guide shows how to point `mm` at [llmman](https://github.com/llmmanorg/llmm | |||
| 10 | llmman serve | 10 | llmman serve |
| 11 | ``` | 11 | ``` |
| 12 | 12 | ||
| 13 | -2. Optionally pull the model first. Otherwise llmman pulls it on the first question, and that question waits for the download: | 13 | +2. Optionally pull the model first — or set `autoPull: true` and `mm` pulls it at start-up (see the variants). Otherwise llmman pulls it on the first question, and that question waits for the download: |
| 14 | 14 | ||
| 15 | ```bash | 15 | ```bash |
| 16 | llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | 16 | llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M |
| @@ -28,6 +28,7 @@ This guide shows how to point `mm` at [llmman](https://github.com/llmmanorg/llmm | |||
| 28 | ## Variants | 28 | ## Variants |
| 29 | 29 | ||
| 30 | - **Another model.** Set `model:` to the id exactly as `llmman list` (or `GET /v1/models`) spells it. | 30 | - **Another model.** Set `model:` to the id exactly as `llmman list` (or `GET /v1/models`) spells it. |
| 31 | +- **Let mm pull the model.** Add `autoPull: true`: at start-up, a model llmman does not have is downloaded with its progress (`⬇ pulling … (35 %)`), before the first question — and a misspelt name stops `mm` at once with llmman's message. The models of the `mmConfigs` agents and of the routing deciders are pulled too. See [`autoPull`](../reference/configuration.md). | ||
| 31 | - **From a Docker sandbox.** Nothing to change: when `127.0.0.1:17434` does not answer, `mm` falls back to `http://host.docker.internal:17434/v1`, the daemon on the host. | 32 | - **From a Docker sandbox.** Nothing to change: when `127.0.0.1:17434` does not answer, `mm` falls back to `http://host.docker.internal:17434/v1`, the daemon on the host. |
| 32 | - **Daemon started with `LLMMAN_API_KEYS`** (mandatory when it listens beyond loopback). Export one of the keys in `LLMMAN_API_KEY`, the provider's default variable, or name another variable under `apiKeyEnv`. | 33 | - **Daemon started with `LLMMAN_API_KEYS`** (mandatory when it listens beyond loopback). Export one of the keys in `LLMMAN_API_KEY`, the provider's default variable, or name another variable under `apiKeyEnv`. |
| 33 | - **`LLMMAN_CONTEXT_LENGTH` set on the daemon.** `/api/ps` already reports it once the model is loaded; set `contextWindow` to the same value if you want compression to know it from the first question. | 34 | - **`LLMMAN_CONTEXT_LENGTH` set on the daemon.** `/api/ps` already reports it once the model is loaded; set `contextWindow` to the same value if you want compression to know it from the first question. |
modified
docs/en/reference/configuration.md +1 -0 | @@ -32,6 +32,7 @@ | ||
| 32 | 32 | | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). | |
| 33 | 33 | | `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). | |
| 34 | 34 | | `unloadModels` | bool | `true` | The `/unload [model]` command (terminal, window, editor) and the `unload_model` tool, which free a model from the server's memory: llmman and Ollama, llama-server in router mode. `false` removes both. The tool is never declared beside a `structuredOutput` or a `decision` block. See [tools](tools.md#unload_model) and [How to free a model's memory](../how-to/unload-a-model.md). | |
| 35 | +| `autoPull` | bool | `false` | At start-up, download the models this agent will use and the server does not list: its own, its `mmConfigs` agents', its `skillRouting` / `toolRouting` deciders'. The progress goes to stderr — one line rewritten in place on a terminal (`⬇ pulling <model> — 52 MB / 145 MB (35 %)`), a line per tenth elsewhere — then `⬇ pulled <model> in 0.8s`. The agent's own model failing stops `mm` with the server's message (`[engine error: autoPull: <model>: the server says: …]`); another model failing, a server that does not answer, or a provider that cannot pull is a `[warning: autoPull: …]`. An agent started by `/ask` or `ask_agent` pulls nothing. llmman only (`POST /api/pull`); other providers: a warning. | | |
| 35 | 36 | | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). | |
| 36 | 37 | | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | |
| 37 | 38 | | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). | |
| @@ -32,6 +32,7 @@ | |||
| 32 | | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). | | 32 | | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). | |
| 33 | | `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). | | 33 | | `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). | |
| 34 | | `unloadModels` | bool | `true` | The `/unload [model]` command (terminal, window, editor) and the `unload_model` tool, which free a model from the server's memory: llmman and Ollama, llama-server in router mode. `false` removes both. The tool is never declared beside a `structuredOutput` or a `decision` block. See [tools](tools.md#unload_model) and [How to free a model's memory](../how-to/unload-a-model.md). | | 34 | | `unloadModels` | bool | `true` | The `/unload [model]` command (terminal, window, editor) and the `unload_model` tool, which free a model from the server's memory: llmman and Ollama, llama-server in router mode. `false` removes both. The tool is never declared beside a `structuredOutput` or a `decision` block. See [tools](tools.md#unload_model) and [How to free a model's memory](../how-to/unload-a-model.md). | |
| 35 | +| `autoPull` | bool | `false` | At start-up, download the models this agent will use and the server does not list: its own, its `mmConfigs` agents', its `skillRouting` / `toolRouting` deciders'. The progress goes to stderr — one line rewritten in place on a terminal (`⬇ pulling <model> — 52 MB / 145 MB (35 %)`), a line per tenth elsewhere — then `⬇ pulled <model> in 0.8s`. The agent's own model failing stops `mm` with the server's message (`[engine error: autoPull: <model>: the server says: …]`); another model failing, a server that does not answer, or a provider that cannot pull is a `[warning: autoPull: …]`. An agent started by `/ask` or `ask_agent` pulls nothing. llmman only (`POST /api/pull`); other providers: a warning. | | ||
| 35 | | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). | | 36 | | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). | |
| 36 | | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | | 37 | | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | |
| 37 | | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). | | 38 | | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). | |
modified
docs/fr/how-to/run-with-llmman.md +2 -1 | @@ -10,7 +10,7 @@ Ce guide montre comment brancher `mm` sur [llmman](https://github.com/llmmanorg/ | ||
| 10 | 10 | llmman serve |
| 11 | 11 | ``` |
| 12 | 12 | |
| 13 | -2. Téléchargez le modèle au préalable si vous le souhaitez. Sinon llmman le télécharge à la première question, qui attend la fin du téléchargement : | |
| 13 | +2. Téléchargez le modèle au préalable si vous le souhaitez — ou mettez `autoPull: true` et `mm` le télécharge au démarrage (voir les variantes). Sinon llmman le télécharge à la première question, qui attend la fin du téléchargement : | |
| 14 | 14 | |
| 15 | 15 | ```bash |
| 16 | 16 | llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M |
| @@ -28,6 +28,7 @@ Ce guide montre comment brancher `mm` sur [llmman](https://github.com/llmmanorg/ | ||
| 28 | 28 | ## Variantes |
| 29 | 29 | |
| 30 | 30 | - **Un autre modèle.** Mettez dans `model:` l'identifiant exactement tel que `llmman list` (ou `GET /v1/models`) l'écrit. |
| 31 | +- **Laisser mm télécharger le modèle.** Ajoutez `autoPull: true` : au démarrage, un modèle que llmman n'a pas est téléchargé avec sa progression (`⬇ pulling … (35 %)`), avant la première question — et un nom mal écrit arrête `mm` tout de suite avec le message de llmman. Les modèles des agents `mmConfigs` et des deciders de routage sont téléchargés aussi. Voir [`autoPull`](../reference/configuration.md). | |
| 31 | 32 | - **Depuis un Docker Sandbox.** Rien à changer : quand `127.0.0.1:17434` ne répond pas, `mm` se replie sur `http://host.docker.internal:17434/v1`, le démon de l'hôte. |
| 32 | 33 | - **Démon lancé avec `LLMMAN_API_KEYS`** (obligatoire quand il écoute au-delà de la boucle locale). Exportez une des clés dans `LLMMAN_API_KEY`, la variable par défaut du fournisseur, ou nommez une autre variable dans `apiKeyEnv`. |
| 33 | 34 | - **`LLMMAN_CONTEXT_LENGTH` réglé sur le démon.** `/api/ps` la rapporte déjà une fois le modèle chargé ; mettez la même valeur dans `contextWindow` si vous voulez que la compression la connaisse dès la première question. |
| @@ -10,7 +10,7 @@ Ce guide montre comment brancher `mm` sur [llmman](https://github.com/llmmanorg/ | |||
| 10 | llmman serve | 10 | llmman serve |
| 11 | ``` | 11 | ``` |
| 12 | 12 | ||
| 13 | -2. Téléchargez le modèle au préalable si vous le souhaitez. Sinon llmman le télécharge à la première question, qui attend la fin du téléchargement : | 13 | +2. Téléchargez le modèle au préalable si vous le souhaitez — ou mettez `autoPull: true` et `mm` le télécharge au démarrage (voir les variantes). Sinon llmman le télécharge à la première question, qui attend la fin du téléchargement : |
| 14 | 14 | ||
| 15 | ```bash | 15 | ```bash |
| 16 | llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | 16 | llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M |
| @@ -28,6 +28,7 @@ Ce guide montre comment brancher `mm` sur [llmman](https://github.com/llmmanorg/ | |||
| 28 | ## Variantes | 28 | ## Variantes |
| 29 | 29 | ||
| 30 | - **Un autre modèle.** Mettez dans `model:` l'identifiant exactement tel que `llmman list` (ou `GET /v1/models`) l'écrit. | 30 | - **Un autre modèle.** Mettez dans `model:` l'identifiant exactement tel que `llmman list` (ou `GET /v1/models`) l'écrit. |
| 31 | +- **Laisser mm télécharger le modèle.** Ajoutez `autoPull: true` : au démarrage, un modèle que llmman n'a pas est téléchargé avec sa progression (`⬇ pulling … (35 %)`), avant la première question — et un nom mal écrit arrête `mm` tout de suite avec le message de llmman. Les modèles des agents `mmConfigs` et des deciders de routage sont téléchargés aussi. Voir [`autoPull`](../reference/configuration.md). | ||
| 31 | - **Depuis un Docker Sandbox.** Rien à changer : quand `127.0.0.1:17434` ne répond pas, `mm` se replie sur `http://host.docker.internal:17434/v1`, le démon de l'hôte. | 32 | - **Depuis un Docker Sandbox.** Rien à changer : quand `127.0.0.1:17434` ne répond pas, `mm` se replie sur `http://host.docker.internal:17434/v1`, le démon de l'hôte. |
| 32 | - **Démon lancé avec `LLMMAN_API_KEYS`** (obligatoire quand il écoute au-delà de la boucle locale). Exportez une des clés dans `LLMMAN_API_KEY`, la variable par défaut du fournisseur, ou nommez une autre variable dans `apiKeyEnv`. | 33 | - **Démon lancé avec `LLMMAN_API_KEYS`** (obligatoire quand il écoute au-delà de la boucle locale). Exportez une des clés dans `LLMMAN_API_KEY`, la variable par défaut du fournisseur, ou nommez une autre variable dans `apiKeyEnv`. |
| 33 | - **`LLMMAN_CONTEXT_LENGTH` réglé sur le démon.** `/api/ps` la rapporte déjà une fois le modèle chargé ; mettez la même valeur dans `contextWindow` si vous voulez que la compression la connaisse dès la première question. | 34 | - **`LLMMAN_CONTEXT_LENGTH` réglé sur le démon.** `/api/ps` la rapporte déjà une fois le modèle chargé ; mettez la même valeur dans `contextWindow` si vous voulez que la compression la connaisse dès la première question. |
modified
docs/fr/reference/configuration.md +1 -0 | @@ -32,6 +32,7 @@ | ||
| 32 | 32 | | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). | |
| 33 | 33 | | `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). | |
| 34 | 34 | | `unloadModels` | bool | `true` | La commande `/unload [modèle]` (terminal, fenêtre, éditeur) et l'outil `unload_model`, qui libèrent un modèle de la mémoire du serveur : llmman et Ollama, llama-server en mode router. `false` retire les deux. L'outil n'est jamais déclaré à côté d'une `structuredOutput` ni d'un bloc `decision`. Voir les [outils](tools.md#unload_model) et [Libérer la mémoire d'un modèle](../how-to/unload-a-model.md). | |
| 35 | +| `autoPull` | booléen | `false` | Au démarrage, télécharge les modèles que cet agent utilisera et que le serveur ne liste pas : le sien, ceux de ses agents `mmConfigs`, ceux de ses deciders `skillRouting` / `toolRouting`. La progression va sur stderr — une ligne réécrite sur place dans un terminal (`⬇ pulling <modèle> — 52 MB / 145 MB (35 %)`), une ligne par dixième ailleurs — puis `⬇ pulled <modèle> in 0.8s`. Un échec sur le modèle de l'agent arrête `mm` avec le message du serveur (`[engine error: autoPull: <modèle>: the server says: …]`) ; un échec sur un autre modèle, un serveur qui ne répond pas ou un fournisseur qui ne sait pas télécharger donne un `[warning: autoPull: …]`. Un agent lancé par `/ask` ou `ask_agent` ne télécharge rien. llmman seulement (`POST /api/pull`) ; les autres fournisseurs : un avertissement. | | |
| 35 | 36 | | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). | |
| 36 | 37 | | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. | |
| 37 | 38 | | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). | |
| @@ -32,6 +32,7 @@ | |||
| 32 | | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). | | 32 | | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). | |
| 33 | | `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). | | 33 | | `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). | |
| 34 | | `unloadModels` | bool | `true` | La commande `/unload [modèle]` (terminal, fenêtre, éditeur) et l'outil `unload_model`, qui libèrent un modèle de la mémoire du serveur : llmman et Ollama, llama-server en mode router. `false` retire les deux. L'outil n'est jamais déclaré à côté d'une `structuredOutput` ni d'un bloc `decision`. Voir les [outils](tools.md#unload_model) et [Libérer la mémoire d'un modèle](../how-to/unload-a-model.md). | | 34 | | `unloadModels` | bool | `true` | La commande `/unload [modèle]` (terminal, fenêtre, éditeur) et l'outil `unload_model`, qui libèrent un modèle de la mémoire du serveur : llmman et Ollama, llama-server en mode router. `false` retire les deux. L'outil n'est jamais déclaré à côté d'une `structuredOutput` ni d'un bloc `decision`. Voir les [outils](tools.md#unload_model) et [Libérer la mémoire d'un modèle](../how-to/unload-a-model.md). | |
| 35 | +| `autoPull` | booléen | `false` | Au démarrage, télécharge les modèles que cet agent utilisera et que le serveur ne liste pas : le sien, ceux de ses agents `mmConfigs`, ceux de ses deciders `skillRouting` / `toolRouting`. La progression va sur stderr — une ligne réécrite sur place dans un terminal (`⬇ pulling <modèle> — 52 MB / 145 MB (35 %)`), une ligne par dixième ailleurs — puis `⬇ pulled <modèle> in 0.8s`. Un échec sur le modèle de l'agent arrête `mm` avec le message du serveur (`[engine error: autoPull: <modèle>: the server says: …]`) ; un échec sur un autre modèle, un serveur qui ne répond pas ou un fournisseur qui ne sait pas télécharger donne un `[warning: autoPull: …]`. Un agent lancé par `/ask` ou `ask_agent` ne télécharge rien. llmman seulement (`POST /api/pull`) ; les autres fournisseurs : un avertissement. | | ||
| 35 | | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). | | 36 | | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). | |
| 36 | | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. | | 37 | | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. | |
| 37 | | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). | | 38 | | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). | |
modified
internal/config/config.go +7 -0 | @@ -87,6 +87,13 @@ type Config struct { | ||
| 87 | 87 | // 158). Display only: nothing else depends on it. |
| 88 | 88 | ShowCompletionTime bool `yaml:"showCompletionTime"` |
| 89 | 89 | |
| 90 | + // AutoPull: at start-up, download the models this mm will use and the | |
| 91 | + // server does not have yet — its own, its mmConfigs agents', its routing | |
| 92 | + // deciders' — showing the progress (ticket 61). Off by default: a | |
| 93 | + // download of several GB is not something to start unasked. llmman only | |
| 94 | + // for now (tickets 162, 163 for Ollama and Docker Model Runner). | |
| 95 | + AutoPull bool `yaml:"autoPull"` | |
| 96 | + | |
| 90 | 97 | // System: the system prompt — what the agent is, and what it may do. |
| 91 | 98 | System string `yaml:"system"` |
| 92 | 99 | |
| @@ -87,6 +87,13 @@ type Config struct { | |||
| 87 | // 158). Display only: nothing else depends on it. | 87 | // 158). Display only: nothing else depends on it. |
| 88 | ShowCompletionTime bool `yaml:"showCompletionTime"` | 88 | ShowCompletionTime bool `yaml:"showCompletionTime"` |
| 89 | 89 | ||
| 90 | + // AutoPull: at start-up, download the models this mm will use and the | ||
| 91 | + // server does not have yet — its own, its mmConfigs agents', its routing | ||
| 92 | + // deciders' — showing the progress (ticket 61). Off by default: a | ||
| 93 | + // download of several GB is not something to start unasked. llmman only | ||
| 94 | + // for now (tickets 162, 163 for Ollama and Docker Model Runner). | ||
| 95 | + AutoPull bool `yaml:"autoPull"` | ||
| 96 | + | ||
| 90 | // System: the system prompt — what the agent is, and what it may do. | 97 | // System: the system prompt — what the agent is, and what it may do. |
| 91 | System string `yaml:"system"` | 98 | System string `yaml:"system"` |
| 92 | 99 | ||
modified
internal/engine/openai_compat.go +5 -0 | @@ -62,6 +62,10 @@ type openaiCompat struct { | ||
| 62 | 62 | |
| 63 | 63 | // unload: how this server unloads a model (unload.go); nil = it cannot. |
| 64 | 64 | unload unloadFunc |
| 65 | + | |
| 66 | + // pull: how this server downloads a model it does not have (pull.go, | |
| 67 | + // `autoPull`); nil = mm cannot ask it to. | |
| 68 | + pull pullFunc | |
| 65 | 69 | } |
| 66 | 70 | |
| 67 | 71 | // DefaultURLs returns the address a provider uses when the config gives no |
| @@ -125,6 +129,7 @@ func init() { | ||
| 125 | 129 | progress: true, |
| 126 | 130 | thinkingOff: thinkingOffTemplate, |
| 127 | 131 | unload: unloadKeepAlive, |
| 132 | + pull: pullOllamaAPI, | |
| 128 | 133 | }) |
| 129 | 134 | register(&openaiCompat{ |
| 130 | 135 | name: "ollama", |
| @@ -62,6 +62,10 @@ type openaiCompat struct { | |||
| 62 | 62 | ||
| 63 | // unload: how this server unloads a model (unload.go); nil = it cannot. | 63 | // unload: how this server unloads a model (unload.go); nil = it cannot. |
| 64 | unload unloadFunc | 64 | unload unloadFunc |
| 65 | + | ||
| 66 | + // pull: how this server downloads a model it does not have (pull.go, | ||
| 67 | + // `autoPull`); nil = mm cannot ask it to. | ||
| 68 | + pull pullFunc | ||
| 65 | } | 69 | } |
| 66 | 70 | ||
| 67 | // DefaultURLs returns the address a provider uses when the config gives no | 71 | // DefaultURLs returns the address a provider uses when the config gives no |
| @@ -125,6 +129,7 @@ func init() { | |||
| 125 | progress: true, | 129 | progress: true, |
| 126 | thinkingOff: thinkingOffTemplate, | 130 | thinkingOff: thinkingOffTemplate, |
| 127 | unload: unloadKeepAlive, | 131 | unload: unloadKeepAlive, |
| 132 | + pull: pullOllamaAPI, | ||
| 128 | }) | 133 | }) |
| 129 | register(&openaiCompat{ | 134 | register(&openaiCompat{ |
| 130 | name: "ollama", | 135 | name: "ollama", |
added
internal/engine/pull.go +162 -0 | new file mode 100644 | ||
| @@ -0,0 +1,162 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bufio" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "io" | |
| 10 | + "net/http" | |
| 11 | + "strings" | |
| 12 | + | |
| 13 | + "mm/internal/config" | |
| 14 | +) | |
| 15 | + | |
| 16 | +// Pulling a model the server does not have yet (ticket 61): with `autoPull: | |
| 17 | +// true`, mm downloads it at start-up and shows the progress, instead of a | |
| 18 | +// first question that waits minutes on a silent download (llmman pulls on | |
| 19 | +// the first request by itself) — or that fails on a misspelt name. | |
| 20 | +// | |
| 21 | +// - llmman: POST /api/pull {"model": m}, Ollama's streamed format, one JSON | |
| 22 | +// object per line (measured on llmman 0.1.571, 2026-10-25): | |
| 23 | +// {"status":"pulling manifest"} | |
| 24 | +// {"completed":0,"status":"pulling","total":105454144} | |
| 25 | +// … | |
| 26 | +// {"status":"success"} — or {"error":"HF model info: … HTTP 401"} | |
| 27 | +// - the other servers: no pull here yet (tickets 162 Ollama, 163 DMR). | |
| 28 | + | |
| 29 | +// PullProgress is one step of a download, as the server reports it. | |
| 30 | +// Completed and Total are bytes; Total is 0 while the size is unknown. | |
| 31 | +type PullProgress struct { | |
| 32 | + Model string | |
| 33 | + Status string | |
| 34 | + Completed int64 | |
| 35 | + Total int64 | |
| 36 | +} | |
| 37 | + | |
| 38 | +// pullFunc downloads model to the server behind b, reporting each step to | |
| 39 | +// progress, and returns once the server said success. An error carries the | |
| 40 | +// server's own words. | |
| 41 | +type pullFunc func(ctx context.Context, b Backend, model string, progress func(PullProgress)) error | |
| 42 | + | |
| 43 | +// ErrCannotPull is what EnsureModel returns for a provider that has no way | |
| 44 | +// to pull a model in this build. | |
| 45 | +var ErrCannotPull = errors.New("this provider cannot pull a model") | |
| 46 | + | |
| 47 | +// ErrServerDown is what EnsureModel returns when the server does not answer: | |
| 48 | +// nothing can be checked, and the server may well be started later. | |
| 49 | +var ErrServerDown = errors.New("the server does not answer") | |
| 50 | + | |
| 51 | +// pullClient has no timeout: a download takes as long as it takes; ctx (the | |
| 52 | +// user's Ctrl-C) is what stops it. | |
| 53 | +var pullClient = &http.Client{} | |
| 54 | + | |
| 55 | +// EnsureModel pulls this engine's model when the server does not list it. | |
| 56 | +// pulled says a download took place. Errors: ErrCannotPull, ErrServerDown, | |
| 57 | +// or the server's reason for a failed pull. | |
| 58 | +// | |
| 59 | +// pulled, err := e.EnsureModel(ctx, func(p engine.PullProgress) { … }) | |
| 60 | +func (e *Engine) EnsureModel(ctx context.Context, progress func(PullProgress)) (bool, error) { | |
| 61 | + return ensureModel(ctx, e.Provider, e.Backend, progress) | |
| 62 | +} | |
| 63 | + | |
| 64 | +// EnsureAgentModel is EnsureModel for ANOTHER agent's config file — an | |
| 65 | +// mmConfigs agent, a routing decider — on that agent's server, resolved | |
| 66 | +// like UnloadAgent. It returns the model's name for the caller's lines. | |
| 67 | +// | |
| 68 | +// model, pulled, err := engine.EnsureAgentModel(ctx, "agent.go.yaml", report) | |
| 69 | +func EnsureAgentModel(ctx context.Context, configPath string, progress func(PullProgress)) (string, bool, error) { | |
| 70 | + cfg, err := config.ReadModel(configPath) | |
| 71 | + if err != nil { | |
| 72 | + return "", false, err | |
| 73 | + } | |
| 74 | + p, err := Lookup(cfg.Provider) | |
| 75 | + if err != nil { | |
| 76 | + return cfg.Model, false, err | |
| 77 | + } | |
| 78 | + b, err := ResolveDecider(cfg) | |
| 79 | + if err != nil { | |
| 80 | + return cfg.Model, false, err | |
| 81 | + } | |
| 82 | + pulled, err := ensureModel(ctx, p, b, progress) | |
| 83 | + return cfg.Model, pulled, err | |
| 84 | +} | |
| 85 | + | |
| 86 | +func ensureModel(ctx context.Context, p Provider, b Backend, progress func(PullProgress)) (bool, error) { | |
| 87 | + compat, ok := p.(*openaiCompat) | |
| 88 | + if !ok || compat.pull == nil { | |
| 89 | + return false, ErrCannotPull | |
| 90 | + } | |
| 91 | + var list modelList | |
| 92 | + if err := getWithKey(ctx, b, strings.TrimSuffix(b.BaseURL, "/")+"/models", &list); err != nil { | |
| 93 | + return false, ErrServerDown | |
| 94 | + } | |
| 95 | + if list.has(b.Model) { | |
| 96 | + return false, nil | |
| 97 | + } | |
| 98 | + return true, compat.pull(ctx, b, b.Model, progress) | |
| 99 | +} | |
| 100 | + | |
| 101 | +// pullOllamaAPI is the llmman (and Ollama) way: POST /api/pull, then one | |
| 102 | +// JSON object per line until "success" or an "error". | |
| 103 | +func pullOllamaAPI(ctx context.Context, b Backend, model string, progress func(PullProgress)) error { | |
| 104 | + body, err := json.Marshal(map[string]any{"model": model, "stream": true}) | |
| 105 | + if err != nil { | |
| 106 | + return err | |
| 107 | + } | |
| 108 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rootURL(b.BaseURL)+"/api/pull", strings.NewReader(string(body))) | |
| 109 | + if err != nil { | |
| 110 | + return err | |
| 111 | + } | |
| 112 | + req.Header.Set("Content-Type", "application/json") | |
| 113 | + if b.APIKey != "" { | |
| 114 | + req.Header.Set("Authorization", "Bearer "+b.APIKey) | |
| 115 | + } | |
| 116 | + resp, err := pullClient.Do(req) | |
| 117 | + if err != nil { | |
| 118 | + return fmt.Errorf("the server cannot be reached: %w", err) | |
| 119 | + } | |
| 120 | + defer func() { _ = resp.Body.Close() }() | |
| 121 | + if resp.StatusCode/100 != 2 { | |
| 122 | + return &httpStatusError{code: resp.StatusCode} | |
| 123 | + } | |
| 124 | + return readPullStream(resp.Body, model, progress) | |
| 125 | +} | |
| 126 | + | |
| 127 | +// readPullStream reads the streamed lines: each status goes to progress, the | |
| 128 | +// first "error" ends the pull with the server's words, "success" ends it | |
| 129 | +// well. A stream that stops before either is a failure too: the download | |
| 130 | +// did not finish. | |
| 131 | +func readPullStream(r io.Reader, model string, progress func(PullProgress)) error { | |
| 132 | + scanner := bufio.NewScanner(r) | |
| 133 | + scanner.Buffer(make([]byte, 64*1024), 1024*1024) | |
| 134 | + for scanner.Scan() { | |
| 135 | + line := strings.TrimSpace(scanner.Text()) | |
| 136 | + if line == "" { | |
| 137 | + continue | |
| 138 | + } | |
| 139 | + var step struct { | |
| 140 | + Status string `json:"status"` | |
| 141 | + Error string `json:"error"` | |
| 142 | + Completed int64 `json:"completed"` | |
| 143 | + Total int64 `json:"total"` | |
| 144 | + } | |
| 145 | + if err := json.Unmarshal([]byte(line), &step); err != nil { | |
| 146 | + return fmt.Errorf("unreadable pull progress: %q", line) | |
| 147 | + } | |
| 148 | + if step.Error != "" { | |
| 149 | + return fmt.Errorf("the server says: %s", step.Error) | |
| 150 | + } | |
| 151 | + if progress != nil { | |
| 152 | + progress(PullProgress{Model: model, Status: step.Status, Completed: step.Completed, Total: step.Total}) | |
| 153 | + } | |
| 154 | + if step.Status == "success" { | |
| 155 | + return nil | |
| 156 | + } | |
| 157 | + } | |
| 158 | + if err := scanner.Err(); err != nil { | |
| 159 | + return fmt.Errorf("the download was cut: %w", err) | |
| 160 | + } | |
| 161 | + return errors.New("the download ended without success") | |
| 162 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,162 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bufio" | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "errors" | ||
| 8 | + "fmt" | ||
| 9 | + "io" | ||
| 10 | + "net/http" | ||
| 11 | + "strings" | ||
| 12 | + | ||
| 13 | + "mm/internal/config" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +// Pulling a model the server does not have yet (ticket 61): with `autoPull: | ||
| 17 | +// true`, mm downloads it at start-up and shows the progress, instead of a | ||
| 18 | +// first question that waits minutes on a silent download (llmman pulls on | ||
| 19 | +// the first request by itself) — or that fails on a misspelt name. | ||
| 20 | +// | ||
| 21 | +// - llmman: POST /api/pull {"model": m}, Ollama's streamed format, one JSON | ||
| 22 | +// object per line (measured on llmman 0.1.571, 2026-10-25): | ||
| 23 | +// {"status":"pulling manifest"} | ||
| 24 | +// {"completed":0,"status":"pulling","total":105454144} | ||
| 25 | +// … | ||
| 26 | +// {"status":"success"} — or {"error":"HF model info: … HTTP 401"} | ||
| 27 | +// - the other servers: no pull here yet (tickets 162 Ollama, 163 DMR). | ||
| 28 | + | ||
| 29 | +// PullProgress is one step of a download, as the server reports it. | ||
| 30 | +// Completed and Total are bytes; Total is 0 while the size is unknown. | ||
| 31 | +type PullProgress struct { | ||
| 32 | + Model string | ||
| 33 | + Status string | ||
| 34 | + Completed int64 | ||
| 35 | + Total int64 | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// pullFunc downloads model to the server behind b, reporting each step to | ||
| 39 | +// progress, and returns once the server said success. An error carries the | ||
| 40 | +// server's own words. | ||
| 41 | +type pullFunc func(ctx context.Context, b Backend, model string, progress func(PullProgress)) error | ||
| 42 | + | ||
| 43 | +// ErrCannotPull is what EnsureModel returns for a provider that has no way | ||
| 44 | +// to pull a model in this build. | ||
| 45 | +var ErrCannotPull = errors.New("this provider cannot pull a model") | ||
| 46 | + | ||
| 47 | +// ErrServerDown is what EnsureModel returns when the server does not answer: | ||
| 48 | +// nothing can be checked, and the server may well be started later. | ||
| 49 | +var ErrServerDown = errors.New("the server does not answer") | ||
| 50 | + | ||
| 51 | +// pullClient has no timeout: a download takes as long as it takes; ctx (the | ||
| 52 | +// user's Ctrl-C) is what stops it. | ||
| 53 | +var pullClient = &http.Client{} | ||
| 54 | + | ||
| 55 | +// EnsureModel pulls this engine's model when the server does not list it. | ||
| 56 | +// pulled says a download took place. Errors: ErrCannotPull, ErrServerDown, | ||
| 57 | +// or the server's reason for a failed pull. | ||
| 58 | +// | ||
| 59 | +// pulled, err := e.EnsureModel(ctx, func(p engine.PullProgress) { … }) | ||
| 60 | +func (e *Engine) EnsureModel(ctx context.Context, progress func(PullProgress)) (bool, error) { | ||
| 61 | + return ensureModel(ctx, e.Provider, e.Backend, progress) | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +// EnsureAgentModel is EnsureModel for ANOTHER agent's config file — an | ||
| 65 | +// mmConfigs agent, a routing decider — on that agent's server, resolved | ||
| 66 | +// like UnloadAgent. It returns the model's name for the caller's lines. | ||
| 67 | +// | ||
| 68 | +// model, pulled, err := engine.EnsureAgentModel(ctx, "agent.go.yaml", report) | ||
| 69 | +func EnsureAgentModel(ctx context.Context, configPath string, progress func(PullProgress)) (string, bool, error) { | ||
| 70 | + cfg, err := config.ReadModel(configPath) | ||
| 71 | + if err != nil { | ||
| 72 | + return "", false, err | ||
| 73 | + } | ||
| 74 | + p, err := Lookup(cfg.Provider) | ||
| 75 | + if err != nil { | ||
| 76 | + return cfg.Model, false, err | ||
| 77 | + } | ||
| 78 | + b, err := ResolveDecider(cfg) | ||
| 79 | + if err != nil { | ||
| 80 | + return cfg.Model, false, err | ||
| 81 | + } | ||
| 82 | + pulled, err := ensureModel(ctx, p, b, progress) | ||
| 83 | + return cfg.Model, pulled, err | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +func ensureModel(ctx context.Context, p Provider, b Backend, progress func(PullProgress)) (bool, error) { | ||
| 87 | + compat, ok := p.(*openaiCompat) | ||
| 88 | + if !ok || compat.pull == nil { | ||
| 89 | + return false, ErrCannotPull | ||
| 90 | + } | ||
| 91 | + var list modelList | ||
| 92 | + if err := getWithKey(ctx, b, strings.TrimSuffix(b.BaseURL, "/")+"/models", &list); err != nil { | ||
| 93 | + return false, ErrServerDown | ||
| 94 | + } | ||
| 95 | + if list.has(b.Model) { | ||
| 96 | + return false, nil | ||
| 97 | + } | ||
| 98 | + return true, compat.pull(ctx, b, b.Model, progress) | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +// pullOllamaAPI is the llmman (and Ollama) way: POST /api/pull, then one | ||
| 102 | +// JSON object per line until "success" or an "error". | ||
| 103 | +func pullOllamaAPI(ctx context.Context, b Backend, model string, progress func(PullProgress)) error { | ||
| 104 | + body, err := json.Marshal(map[string]any{"model": model, "stream": true}) | ||
| 105 | + if err != nil { | ||
| 106 | + return err | ||
| 107 | + } | ||
| 108 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rootURL(b.BaseURL)+"/api/pull", strings.NewReader(string(body))) | ||
| 109 | + if err != nil { | ||
| 110 | + return err | ||
| 111 | + } | ||
| 112 | + req.Header.Set("Content-Type", "application/json") | ||
| 113 | + if b.APIKey != "" { | ||
| 114 | + req.Header.Set("Authorization", "Bearer "+b.APIKey) | ||
| 115 | + } | ||
| 116 | + resp, err := pullClient.Do(req) | ||
| 117 | + if err != nil { | ||
| 118 | + return fmt.Errorf("the server cannot be reached: %w", err) | ||
| 119 | + } | ||
| 120 | + defer func() { _ = resp.Body.Close() }() | ||
| 121 | + if resp.StatusCode/100 != 2 { | ||
| 122 | + return &httpStatusError{code: resp.StatusCode} | ||
| 123 | + } | ||
| 124 | + return readPullStream(resp.Body, model, progress) | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// readPullStream reads the streamed lines: each status goes to progress, the | ||
| 128 | +// first "error" ends the pull with the server's words, "success" ends it | ||
| 129 | +// well. A stream that stops before either is a failure too: the download | ||
| 130 | +// did not finish. | ||
| 131 | +func readPullStream(r io.Reader, model string, progress func(PullProgress)) error { | ||
| 132 | + scanner := bufio.NewScanner(r) | ||
| 133 | + scanner.Buffer(make([]byte, 64*1024), 1024*1024) | ||
| 134 | + for scanner.Scan() { | ||
| 135 | + line := strings.TrimSpace(scanner.Text()) | ||
| 136 | + if line == "" { | ||
| 137 | + continue | ||
| 138 | + } | ||
| 139 | + var step struct { | ||
| 140 | + Status string `json:"status"` | ||
| 141 | + Error string `json:"error"` | ||
| 142 | + Completed int64 `json:"completed"` | ||
| 143 | + Total int64 `json:"total"` | ||
| 144 | + } | ||
| 145 | + if err := json.Unmarshal([]byte(line), &step); err != nil { | ||
| 146 | + return fmt.Errorf("unreadable pull progress: %q", line) | ||
| 147 | + } | ||
| 148 | + if step.Error != "" { | ||
| 149 | + return fmt.Errorf("the server says: %s", step.Error) | ||
| 150 | + } | ||
| 151 | + if progress != nil { | ||
| 152 | + progress(PullProgress{Model: model, Status: step.Status, Completed: step.Completed, Total: step.Total}) | ||
| 153 | + } | ||
| 154 | + if step.Status == "success" { | ||
| 155 | + return nil | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | + if err := scanner.Err(); err != nil { | ||
| 159 | + return fmt.Errorf("the download was cut: %w", err) | ||
| 160 | + } | ||
| 161 | + return errors.New("the download ended without success") | ||
| 162 | +} | ||
added
internal/engine/pull_test.go +127 -0 | new file mode 100644 | ||
| @@ -0,0 +1,127 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "errors" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "os" | |
| 10 | + "path/filepath" | |
| 11 | + "strings" | |
| 12 | + "sync" | |
| 13 | + "testing" | |
| 14 | +) | |
| 15 | + | |
| 16 | +// fakeStore is a llmman-style server: /v1/models lists the store, and | |
| 17 | +// /api/pull streams the lines it is given, adding the model on success. | |
| 18 | +type fakeStore struct { | |
| 19 | + mu sync.Mutex | |
| 20 | + models []string | |
| 21 | + lines []string // the /api/pull stream; the last one decides | |
| 22 | + pulls []string | |
| 23 | +} | |
| 24 | + | |
| 25 | +func (f *fakeStore) server(t *testing.T) *httptest.Server { | |
| 26 | + t.Helper() | |
| 27 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 28 | + f.mu.Lock() | |
| 29 | + defer f.mu.Unlock() | |
| 30 | + switch r.URL.Path { | |
| 31 | + case "/v1/models": | |
| 32 | + data := []map[string]string{} | |
| 33 | + for _, m := range f.models { | |
| 34 | + data = append(data, map[string]string{"id": m}) | |
| 35 | + } | |
| 36 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) | |
| 37 | + case "/api/pull": | |
| 38 | + var body map[string]any | |
| 39 | + _ = json.NewDecoder(r.Body).Decode(&body) | |
| 40 | + model := body["model"].(string) | |
| 41 | + f.pulls = append(f.pulls, model) | |
| 42 | + for _, l := range f.lines { | |
| 43 | + _, _ = w.Write([]byte(l + "\n")) | |
| 44 | + } | |
| 45 | + if len(f.lines) > 0 && strings.Contains(f.lines[len(f.lines)-1], "success") { | |
| 46 | + f.models = append(f.models, model) | |
| 47 | + } | |
| 48 | + default: | |
| 49 | + http.NotFound(w, r) | |
| 50 | + } | |
| 51 | + })) | |
| 52 | + t.Cleanup(srv.Close) | |
| 53 | + return srv | |
| 54 | +} | |
| 55 | + | |
| 56 | +// agentFile writes an agent file for provider/model on srv. | |
| 57 | +func agentFile(t *testing.T, provider, model, baseURL string) string { | |
| 58 | + t.Helper() | |
| 59 | + path := filepath.Join(t.TempDir(), "agent.yaml") | |
| 60 | + body := "provider: " + provider + "\nmodel: " + model + "\nbaseUrl: " + baseURL + "\nfallback: \"\"\n" | |
| 61 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 62 | + t.Fatal(err) | |
| 63 | + } | |
| 64 | + return path | |
| 65 | +} | |
| 66 | + | |
| 67 | +var measuredPull = []string{ | |
| 68 | + `{"status":"pulling manifest"}`, | |
| 69 | + `{"completed":0,"status":"pulling","total":105454144}`, | |
| 70 | + `{"completed":52727072,"status":"pulling","total":105454144}`, | |
| 71 | + `{"completed":105454144,"status":"pulling","total":105454144}`, | |
| 72 | + `{"status":"success"}`, | |
| 73 | +} | |
| 74 | + | |
| 75 | +// A missing model is pulled, every step reported; a present one is not. | |
| 76 | +func TestEnsureAgentModelPullsWhatIsMissing(t *testing.T) { | |
| 77 | + f := &fakeStore{lines: measuredPull} | |
| 78 | + srv := f.server(t) | |
| 79 | + path := agentFile(t, "llmman", "smol", srv.URL+"/v1") | |
| 80 | + var steps []PullProgress | |
| 81 | + model, pulled, err := EnsureAgentModel(context.Background(), path, func(p PullProgress) { steps = append(steps, p) }) | |
| 82 | + if err != nil || !pulled || model != "smol" { | |
| 83 | + t.Fatalf("EnsureAgentModel = %q, %v, %v", model, pulled, err) | |
| 84 | + } | |
| 85 | + if len(steps) != 5 || steps[2].Completed != 52727072 || steps[2].Total != 105454144 || steps[4].Status != "success" { | |
| 86 | + t.Errorf("steps = %+v", steps) | |
| 87 | + } | |
| 88 | + _, pulled, err = EnsureAgentModel(context.Background(), path, nil) | |
| 89 | + if err != nil || pulled || len(f.pulls) != 1 { | |
| 90 | + t.Errorf("second call: pulled %v, err %v, pulls %v", pulled, err, f.pulls) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +// The server's error ends the pull with its own words; a stream that stops | |
| 95 | +// without success is a failure too. | |
| 96 | +func TestEnsureAgentModelReportsAFailedPull(t *testing.T) { | |
| 97 | + for name, tc := range map[string]struct { | |
| 98 | + lines []string | |
| 99 | + want string | |
| 100 | + }{ | |
| 101 | + "server error": {[]string{`{"status":"pulling manifest"}`, `{"error":"HF model info: HTTP 401"}`}, "the server says: HF model info: HTTP 401"}, | |
| 102 | + "cut": {[]string{`{"status":"pulling manifest"}`}, "ended without success"}, | |
| 103 | + "garbage": {[]string{`not json`}, "unreadable pull progress"}, | |
| 104 | + } { | |
| 105 | + t.Run(name, func(t *testing.T) { | |
| 106 | + srv := (&fakeStore{lines: tc.lines}).server(t) | |
| 107 | + _, pulled, err := EnsureAgentModel(context.Background(), agentFile(t, "llmman", "nope", srv.URL+"/v1"), nil) | |
| 108 | + if !pulled || err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 109 | + t.Errorf("pulled %v, err %v, want %q", pulled, err, tc.want) | |
| 110 | + } | |
| 111 | + }) | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +// Nothing to check on a server that does not answer, nothing to do for a | |
| 116 | +// provider with no pull. | |
| 117 | +func TestEnsureAgentModelWithoutAServerOrAPull(t *testing.T) { | |
| 118 | + srv := (&fakeStore{}).server(t) | |
| 119 | + down := agentFile(t, "llmman", "m", "http://127.0.0.1:1/v1") | |
| 120 | + if _, _, err := EnsureAgentModel(context.Background(), down, nil); !errors.Is(err, ErrServerDown) { | |
| 121 | + t.Errorf("down: %v", err) | |
| 122 | + } | |
| 123 | + ollama := agentFile(t, "ollama", "m", srv.URL+"/v1") | |
| 124 | + if _, _, err := EnsureAgentModel(context.Background(), ollama, nil); !errors.Is(err, ErrCannotPull) { | |
| 125 | + t.Errorf("ollama: %v", err) | |
| 126 | + } | |
| 127 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,127 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "errors" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 9 | + "os" | ||
| 10 | + "path/filepath" | ||
| 11 | + "strings" | ||
| 12 | + "sync" | ||
| 13 | + "testing" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +// fakeStore is a llmman-style server: /v1/models lists the store, and | ||
| 17 | +// /api/pull streams the lines it is given, adding the model on success. | ||
| 18 | +type fakeStore struct { | ||
| 19 | + mu sync.Mutex | ||
| 20 | + models []string | ||
| 21 | + lines []string // the /api/pull stream; the last one decides | ||
| 22 | + pulls []string | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +func (f *fakeStore) server(t *testing.T) *httptest.Server { | ||
| 26 | + t.Helper() | ||
| 27 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 28 | + f.mu.Lock() | ||
| 29 | + defer f.mu.Unlock() | ||
| 30 | + switch r.URL.Path { | ||
| 31 | + case "/v1/models": | ||
| 32 | + data := []map[string]string{} | ||
| 33 | + for _, m := range f.models { | ||
| 34 | + data = append(data, map[string]string{"id": m}) | ||
| 35 | + } | ||
| 36 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) | ||
| 37 | + case "/api/pull": | ||
| 38 | + var body map[string]any | ||
| 39 | + _ = json.NewDecoder(r.Body).Decode(&body) | ||
| 40 | + model := body["model"].(string) | ||
| 41 | + f.pulls = append(f.pulls, model) | ||
| 42 | + for _, l := range f.lines { | ||
| 43 | + _, _ = w.Write([]byte(l + "\n")) | ||
| 44 | + } | ||
| 45 | + if len(f.lines) > 0 && strings.Contains(f.lines[len(f.lines)-1], "success") { | ||
| 46 | + f.models = append(f.models, model) | ||
| 47 | + } | ||
| 48 | + default: | ||
| 49 | + http.NotFound(w, r) | ||
| 50 | + } | ||
| 51 | + })) | ||
| 52 | + t.Cleanup(srv.Close) | ||
| 53 | + return srv | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +// agentFile writes an agent file for provider/model on srv. | ||
| 57 | +func agentFile(t *testing.T, provider, model, baseURL string) string { | ||
| 58 | + t.Helper() | ||
| 59 | + path := filepath.Join(t.TempDir(), "agent.yaml") | ||
| 60 | + body := "provider: " + provider + "\nmodel: " + model + "\nbaseUrl: " + baseURL + "\nfallback: \"\"\n" | ||
| 61 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | ||
| 62 | + t.Fatal(err) | ||
| 63 | + } | ||
| 64 | + return path | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +var measuredPull = []string{ | ||
| 68 | + `{"status":"pulling manifest"}`, | ||
| 69 | + `{"completed":0,"status":"pulling","total":105454144}`, | ||
| 70 | + `{"completed":52727072,"status":"pulling","total":105454144}`, | ||
| 71 | + `{"completed":105454144,"status":"pulling","total":105454144}`, | ||
| 72 | + `{"status":"success"}`, | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +// A missing model is pulled, every step reported; a present one is not. | ||
| 76 | +func TestEnsureAgentModelPullsWhatIsMissing(t *testing.T) { | ||
| 77 | + f := &fakeStore{lines: measuredPull} | ||
| 78 | + srv := f.server(t) | ||
| 79 | + path := agentFile(t, "llmman", "smol", srv.URL+"/v1") | ||
| 80 | + var steps []PullProgress | ||
| 81 | + model, pulled, err := EnsureAgentModel(context.Background(), path, func(p PullProgress) { steps = append(steps, p) }) | ||
| 82 | + if err != nil || !pulled || model != "smol" { | ||
| 83 | + t.Fatalf("EnsureAgentModel = %q, %v, %v", model, pulled, err) | ||
| 84 | + } | ||
| 85 | + if len(steps) != 5 || steps[2].Completed != 52727072 || steps[2].Total != 105454144 || steps[4].Status != "success" { | ||
| 86 | + t.Errorf("steps = %+v", steps) | ||
| 87 | + } | ||
| 88 | + _, pulled, err = EnsureAgentModel(context.Background(), path, nil) | ||
| 89 | + if err != nil || pulled || len(f.pulls) != 1 { | ||
| 90 | + t.Errorf("second call: pulled %v, err %v, pulls %v", pulled, err, f.pulls) | ||
| 91 | + } | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// The server's error ends the pull with its own words; a stream that stops | ||
| 95 | +// without success is a failure too. | ||
| 96 | +func TestEnsureAgentModelReportsAFailedPull(t *testing.T) { | ||
| 97 | + for name, tc := range map[string]struct { | ||
| 98 | + lines []string | ||
| 99 | + want string | ||
| 100 | + }{ | ||
| 101 | + "server error": {[]string{`{"status":"pulling manifest"}`, `{"error":"HF model info: HTTP 401"}`}, "the server says: HF model info: HTTP 401"}, | ||
| 102 | + "cut": {[]string{`{"status":"pulling manifest"}`}, "ended without success"}, | ||
| 103 | + "garbage": {[]string{`not json`}, "unreadable pull progress"}, | ||
| 104 | + } { | ||
| 105 | + t.Run(name, func(t *testing.T) { | ||
| 106 | + srv := (&fakeStore{lines: tc.lines}).server(t) | ||
| 107 | + _, pulled, err := EnsureAgentModel(context.Background(), agentFile(t, "llmman", "nope", srv.URL+"/v1"), nil) | ||
| 108 | + if !pulled || err == nil || !strings.Contains(err.Error(), tc.want) { | ||
| 109 | + t.Errorf("pulled %v, err %v, want %q", pulled, err, tc.want) | ||
| 110 | + } | ||
| 111 | + }) | ||
| 112 | + } | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +// Nothing to check on a server that does not answer, nothing to do for a | ||
| 116 | +// provider with no pull. | ||
| 117 | +func TestEnsureAgentModelWithoutAServerOrAPull(t *testing.T) { | ||
| 118 | + srv := (&fakeStore{}).server(t) | ||
| 119 | + down := agentFile(t, "llmman", "m", "http://127.0.0.1:1/v1") | ||
| 120 | + if _, _, err := EnsureAgentModel(context.Background(), down, nil); !errors.Is(err, ErrServerDown) { | ||
| 121 | + t.Errorf("down: %v", err) | ||
| 122 | + } | ||
| 123 | + ollama := agentFile(t, "ollama", "m", srv.URL+"/v1") | ||
| 124 | + if _, _, err := EnsureAgentModel(context.Background(), ollama, nil); !errors.Is(err, ErrCannotPull) { | ||
| 125 | + t.Errorf("ollama: %v", err) | ||
| 126 | + } | ||
| 127 | +} | ||
modified
main.go +6 -0 | @@ -236,6 +236,12 @@ func main() { | ||
| 236 | 236 | fail("[engine error: %v ]", err) |
| 237 | 237 | os.Exit(1) |
| 238 | 238 | } |
| 239 | + // autoPull (ticket 61): the missing models are downloaded before the | |
| 240 | + // agent is ready, the progress on stderr. | |
| 241 | + if err := autoPull(ctx, e, os.Stderr, stderrIsTerminal()); err != nil { | |
| 242 | + fail("[engine error: %v ]", err) | |
| 243 | + os.Exit(1) | |
| 244 | + } | |
| 239 | 245 | // mmConfigs.<name>.unloadModels (ticket 161): the pool frees this |
| 240 | 246 | // agent's model before the hand-off, the asked agent's after it. |
| 241 | 247 | asker.SetUnloads(ask.Unloads{ |
| @@ -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 | + // autoPull (ticket 61): the missing models are downloaded before the | ||
| 240 | + // agent is ready, the progress on stderr. | ||
| 241 | + if err := autoPull(ctx, e, os.Stderr, stderrIsTerminal()); err != nil { | ||
| 242 | + fail("[engine error: %v ]", err) | ||
| 243 | + os.Exit(1) | ||
| 244 | + } | ||
| 239 | // mmConfigs.<name>.unloadModels (ticket 161): the pool frees this | 245 | // mmConfigs.<name>.unloadModels (ticket 161): the pool frees this |
| 240 | // agent's model before the hand-off, the asked agent's after it. | 246 | // agent's model before the hand-off, the asked agent's after it. |
| 241 | asker.SetUnloads(ask.Unloads{ | 247 | asker.SetUnloads(ask.Unloads{ |
added
pull_wiring.go +138 -0 | new file mode 100644 | ||
| @@ -0,0 +1,138 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "os" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "mm/internal/ask" | |
| 12 | + "mm/internal/config" | |
| 13 | + "mm/internal/engine" | |
| 14 | + "mm/internal/spinner" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// autoPull is `autoPull: true` (ticket 61): before anything else starts, it | |
| 18 | +// downloads the models this mm will use and the server does not have yet — | |
| 19 | +// its own, then its mmConfigs agents' and its routing deciders' — with the | |
| 20 | +// progress on w (stderr: never the answer of -p, never the ACP stream). | |
| 21 | +// | |
| 22 | +// Only the agent's own model can stop mm: without it there is nothing to | |
| 23 | +// run. Another model that cannot be pulled is a warning — that agent or that | |
| 24 | +// decider fails later, where it is used. An asked agent (a child of an /ask) | |
| 25 | +// pulls nothing: its asker already did, and its start-up is bounded. | |
| 26 | +func autoPull(ctx context.Context, e *engine.Engine, w io.Writer, tty bool) error { | |
| 27 | + if !config.Cfg.AutoPull || ask.Depth() > 0 { | |
| 28 | + return nil | |
| 29 | + } | |
| 30 | + report := newPullReport(w, tty) | |
| 31 | + pulled, err := e.EnsureModel(ctx, report.step) | |
| 32 | + report.done(e.Backend.Model, pulled, err) | |
| 33 | + switch { | |
| 34 | + case errors.Is(err, engine.ErrCannotPull), errors.Is(err, engine.ErrServerDown): | |
| 35 | + fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", e.Backend.Model, e.Backend.Provider, err) | |
| 36 | + case err != nil: | |
| 37 | + return fmt.Errorf("autoPull: %s: %w", e.Backend.Model, err) | |
| 38 | + } | |
| 39 | + for _, path := range otherModelFiles() { | |
| 40 | + model, pulled, err := engine.EnsureAgentModel(ctx, path, report.step) | |
| 41 | + report.done(model, pulled, err) | |
| 42 | + if err != nil && !errors.Is(err, engine.ErrCannotPull) { | |
| 43 | + fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", model, path, err) | |
| 44 | + } | |
| 45 | + } | |
| 46 | + return nil | |
| 47 | +} | |
| 48 | + | |
| 49 | +// otherModelFiles lists the config files of the other models this mm will | |
| 50 | +// use: its mmConfigs agents (in name order), then its routing deciders. | |
| 51 | +func otherModelFiles() []string { | |
| 52 | + var paths []string | |
| 53 | + for _, name := range config.AgentNames() { | |
| 54 | + paths = append(paths, config.Cfg.MMConfigs[name].Path) | |
| 55 | + } | |
| 56 | + for _, decider := range []string{config.Cfg.SkillRouting.Decider, config.Cfg.ToolRouting.Decider} { | |
| 57 | + if decider != "" { | |
| 58 | + paths = append(paths, decider) | |
| 59 | + } | |
| 60 | + } | |
| 61 | + return paths | |
| 62 | +} | |
| 63 | + | |
| 64 | +// pullReport shows a download. On a terminal, one line rewritten in place; | |
| 65 | +// elsewhere (a log, an editor's stderr) a line per tenth, so a long download | |
| 66 | +// does not write a thousand lines. | |
| 67 | +type pullReport struct { | |
| 68 | + w io.Writer | |
| 69 | + tty bool | |
| 70 | + started time.Time | |
| 71 | + shown int // the last tenth printed, off a terminal | |
| 72 | + live bool | |
| 73 | +} | |
| 74 | + | |
| 75 | +func newPullReport(w io.Writer, tty bool) *pullReport { | |
| 76 | + return &pullReport{w: w, tty: tty, shown: -1} | |
| 77 | +} | |
| 78 | + | |
| 79 | +// step is engine.PullProgress's receiver. | |
| 80 | +func (r *pullReport) step(p engine.PullProgress) { | |
| 81 | + if !r.live { | |
| 82 | + r.live, r.started, r.shown = true, time.Now(), -1 | |
| 83 | + } | |
| 84 | + line := "⬇ pulling " + p.Model | |
| 85 | + tenth := 0 | |
| 86 | + if p.Total > 0 { | |
| 87 | + line += fmt.Sprintf(" — %s / %s (%d %%)", size(p.Completed, p.Total), size(p.Total, p.Total), p.Completed*100/p.Total) | |
| 88 | + tenth = int(p.Completed * 10 / p.Total) | |
| 89 | + } | |
| 90 | + if r.tty { | |
| 91 | + fmt.Fprintf(r.w, "\r%s\033[K", line) | |
| 92 | + return | |
| 93 | + } | |
| 94 | + if tenth > r.shown { | |
| 95 | + r.shown = tenth | |
| 96 | + fmt.Fprintln(r.w, line) | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +// done closes a download's line: what was pulled and how long it took, or | |
| 101 | +// nothing when the model was already there. | |
| 102 | +func (r *pullReport) done(model string, pulled bool, err error) { | |
| 103 | + if r.live && r.tty { | |
| 104 | + fmt.Fprint(r.w, "\r\033[K") | |
| 105 | + } | |
| 106 | + if pulled && err == nil { | |
| 107 | + fmt.Fprintf(r.w, "⬇ pulled %s in %s\n", model, elapsed(time.Since(r.started))) | |
| 108 | + } | |
| 109 | + r.live = false | |
| 110 | +} | |
| 111 | + | |
| 112 | +// size prints n in the unit that suits the whole download: GB from 1 GB | |
| 113 | +// (8.1 GB, as the model cards say), MB below (145 MB rather than 0.1 GB). | |
| 114 | +func size(n, total int64) string { | |
| 115 | + if total >= 1e9 { | |
| 116 | + return fmt.Sprintf("%.1f GB", float64(n)/1e9) | |
| 117 | + } | |
| 118 | + return fmt.Sprintf("%d MB", n/1e6) | |
| 119 | +} | |
| 120 | + | |
| 121 | +// elapsed gives tenths under ten seconds (a small model pulls in 0.8s, not | |
| 122 | +// "0s"), whole seconds beyond. | |
| 123 | +func elapsed(d time.Duration) string { | |
| 124 | + if d < 10*time.Second { | |
| 125 | + return fmt.Sprintf("%.1fs", d.Seconds()) | |
| 126 | + } | |
| 127 | + return d.Round(time.Second).String() | |
| 128 | +} | |
| 129 | + | |
| 130 | +// stderrIsTerminal reports whether the progress can be rewritten in place. | |
| 131 | +func stderrIsTerminal() bool { | |
| 132 | + return spinner.Styled() && isCharDevice(os.Stderr) | |
| 133 | +} | |
| 134 | + | |
| 135 | +func isCharDevice(f *os.File) bool { | |
| 136 | + info, err := f.Stat() | |
| 137 | + return err == nil && info.Mode()&os.ModeCharDevice != 0 | |
| 138 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,138 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "fmt" | ||
| 7 | + "io" | ||
| 8 | + "os" | ||
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "mm/internal/ask" | ||
| 12 | + "mm/internal/config" | ||
| 13 | + "mm/internal/engine" | ||
| 14 | + "mm/internal/spinner" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// autoPull is `autoPull: true` (ticket 61): before anything else starts, it | ||
| 18 | +// downloads the models this mm will use and the server does not have yet — | ||
| 19 | +// its own, then its mmConfigs agents' and its routing deciders' — with the | ||
| 20 | +// progress on w (stderr: never the answer of -p, never the ACP stream). | ||
| 21 | +// | ||
| 22 | +// Only the agent's own model can stop mm: without it there is nothing to | ||
| 23 | +// run. Another model that cannot be pulled is a warning — that agent or that | ||
| 24 | +// decider fails later, where it is used. An asked agent (a child of an /ask) | ||
| 25 | +// pulls nothing: its asker already did, and its start-up is bounded. | ||
| 26 | +func autoPull(ctx context.Context, e *engine.Engine, w io.Writer, tty bool) error { | ||
| 27 | + if !config.Cfg.AutoPull || ask.Depth() > 0 { | ||
| 28 | + return nil | ||
| 29 | + } | ||
| 30 | + report := newPullReport(w, tty) | ||
| 31 | + pulled, err := e.EnsureModel(ctx, report.step) | ||
| 32 | + report.done(e.Backend.Model, pulled, err) | ||
| 33 | + switch { | ||
| 34 | + case errors.Is(err, engine.ErrCannotPull), errors.Is(err, engine.ErrServerDown): | ||
| 35 | + fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", e.Backend.Model, e.Backend.Provider, err) | ||
| 36 | + case err != nil: | ||
| 37 | + return fmt.Errorf("autoPull: %s: %w", e.Backend.Model, err) | ||
| 38 | + } | ||
| 39 | + for _, path := range otherModelFiles() { | ||
| 40 | + model, pulled, err := engine.EnsureAgentModel(ctx, path, report.step) | ||
| 41 | + report.done(model, pulled, err) | ||
| 42 | + if err != nil && !errors.Is(err, engine.ErrCannotPull) { | ||
| 43 | + fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", model, path, err) | ||
| 44 | + } | ||
| 45 | + } | ||
| 46 | + return nil | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +// otherModelFiles lists the config files of the other models this mm will | ||
| 50 | +// use: its mmConfigs agents (in name order), then its routing deciders. | ||
| 51 | +func otherModelFiles() []string { | ||
| 52 | + var paths []string | ||
| 53 | + for _, name := range config.AgentNames() { | ||
| 54 | + paths = append(paths, config.Cfg.MMConfigs[name].Path) | ||
| 55 | + } | ||
| 56 | + for _, decider := range []string{config.Cfg.SkillRouting.Decider, config.Cfg.ToolRouting.Decider} { | ||
| 57 | + if decider != "" { | ||
| 58 | + paths = append(paths, decider) | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | + return paths | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +// pullReport shows a download. On a terminal, one line rewritten in place; | ||
| 65 | +// elsewhere (a log, an editor's stderr) a line per tenth, so a long download | ||
| 66 | +// does not write a thousand lines. | ||
| 67 | +type pullReport struct { | ||
| 68 | + w io.Writer | ||
| 69 | + tty bool | ||
| 70 | + started time.Time | ||
| 71 | + shown int // the last tenth printed, off a terminal | ||
| 72 | + live bool | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +func newPullReport(w io.Writer, tty bool) *pullReport { | ||
| 76 | + return &pullReport{w: w, tty: tty, shown: -1} | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +// step is engine.PullProgress's receiver. | ||
| 80 | +func (r *pullReport) step(p engine.PullProgress) { | ||
| 81 | + if !r.live { | ||
| 82 | + r.live, r.started, r.shown = true, time.Now(), -1 | ||
| 83 | + } | ||
| 84 | + line := "⬇ pulling " + p.Model | ||
| 85 | + tenth := 0 | ||
| 86 | + if p.Total > 0 { | ||
| 87 | + line += fmt.Sprintf(" — %s / %s (%d %%)", size(p.Completed, p.Total), size(p.Total, p.Total), p.Completed*100/p.Total) | ||
| 88 | + tenth = int(p.Completed * 10 / p.Total) | ||
| 89 | + } | ||
| 90 | + if r.tty { | ||
| 91 | + fmt.Fprintf(r.w, "\r%s\033[K", line) | ||
| 92 | + return | ||
| 93 | + } | ||
| 94 | + if tenth > r.shown { | ||
| 95 | + r.shown = tenth | ||
| 96 | + fmt.Fprintln(r.w, line) | ||
| 97 | + } | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +// done closes a download's line: what was pulled and how long it took, or | ||
| 101 | +// nothing when the model was already there. | ||
| 102 | +func (r *pullReport) done(model string, pulled bool, err error) { | ||
| 103 | + if r.live && r.tty { | ||
| 104 | + fmt.Fprint(r.w, "\r\033[K") | ||
| 105 | + } | ||
| 106 | + if pulled && err == nil { | ||
| 107 | + fmt.Fprintf(r.w, "⬇ pulled %s in %s\n", model, elapsed(time.Since(r.started))) | ||
| 108 | + } | ||
| 109 | + r.live = false | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +// size prints n in the unit that suits the whole download: GB from 1 GB | ||
| 113 | +// (8.1 GB, as the model cards say), MB below (145 MB rather than 0.1 GB). | ||
| 114 | +func size(n, total int64) string { | ||
| 115 | + if total >= 1e9 { | ||
| 116 | + return fmt.Sprintf("%.1f GB", float64(n)/1e9) | ||
| 117 | + } | ||
| 118 | + return fmt.Sprintf("%d MB", n/1e6) | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +// elapsed gives tenths under ten seconds (a small model pulls in 0.8s, not | ||
| 122 | +// "0s"), whole seconds beyond. | ||
| 123 | +func elapsed(d time.Duration) string { | ||
| 124 | + if d < 10*time.Second { | ||
| 125 | + return fmt.Sprintf("%.1fs", d.Seconds()) | ||
| 126 | + } | ||
| 127 | + return d.Round(time.Second).String() | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +// stderrIsTerminal reports whether the progress can be rewritten in place. | ||
| 131 | +func stderrIsTerminal() bool { | ||
| 132 | + return spinner.Styled() && isCharDevice(os.Stderr) | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +func isCharDevice(f *os.File) bool { | ||
| 136 | + info, err := f.Stat() | ||
| 137 | + return err == nil && info.Mode()&os.ModeCharDevice != 0 | ||
| 138 | +} | ||
added
pull_wiring_test.go +148 -0 | new file mode 100644 | ||
| @@ -0,0 +1,148 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "os" | |
| 10 | + "path/filepath" | |
| 11 | + "strings" | |
| 12 | + "testing" | |
| 13 | + "time" | |
| 14 | + | |
| 15 | + "mm/internal/config" | |
| 16 | + "mm/internal/engine" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// storeServer is a llmman-style server holding `models`; /api/pull answers | |
| 20 | +// with `lines` (one JSON object each) and records what it was asked. | |
| 21 | +func storeServer(t *testing.T, models []string, lines []string, pulls *[]string) string { | |
| 22 | + t.Helper() | |
| 23 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 24 | + switch r.URL.Path { | |
| 25 | + case "/v1/models": | |
| 26 | + data := []map[string]string{} | |
| 27 | + for _, m := range models { | |
| 28 | + data = append(data, map[string]string{"id": m}) | |
| 29 | + } | |
| 30 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) | |
| 31 | + case "/api/pull": | |
| 32 | + var body map[string]string | |
| 33 | + _ = json.NewDecoder(r.Body).Decode(&body) | |
| 34 | + *pulls = append(*pulls, body["model"]) | |
| 35 | + for _, l := range lines { | |
| 36 | + _, _ = w.Write([]byte(l + "\n")) | |
| 37 | + } | |
| 38 | + default: | |
| 39 | + http.NotFound(w, r) | |
| 40 | + } | |
| 41 | + })) | |
| 42 | + t.Cleanup(srv.Close) | |
| 43 | + return srv.URL + "/v1" | |
| 44 | +} | |
| 45 | + | |
| 46 | +var pullOK = []string{ | |
| 47 | + `{"status":"pulling manifest"}`, | |
| 48 | + `{"completed":0,"status":"pulling","total":2000000000}`, | |
| 49 | + `{"completed":1000000000,"status":"pulling","total":2000000000}`, | |
| 50 | + `{"completed":2000000000,"status":"pulling","total":2000000000}`, | |
| 51 | + `{"status":"success"}`, | |
| 52 | +} | |
| 53 | + | |
| 54 | +// llmmanEngine is an engine on the fake server, with autoPull set as asked. | |
| 55 | +func llmmanEngine(t *testing.T, baseURL string, autoPull bool, agents map[string]config.AgentRef) *engine.Engine { | |
| 56 | + t.Helper() | |
| 57 | + none := "" | |
| 58 | + withConfig(t, func(c *config.Config) { | |
| 59 | + c.Provider, c.Model, c.BaseURL, c.Fallback, c.AutoPull = "llmman", "smol", baseURL, &none, autoPull | |
| 60 | + c.MMConfigs = agents | |
| 61 | + }) | |
| 62 | + e, err := engine.New(context.Background()) | |
| 63 | + if err != nil { | |
| 64 | + t.Fatal(err) | |
| 65 | + } | |
| 66 | + return e | |
| 67 | +} | |
| 68 | + | |
| 69 | +// Off, nothing is asked; on, the missing model is pulled with its progress, | |
| 70 | +// one line per new tenth off a terminal (0 % is the first line), and a | |
| 71 | +// closing line. | |
| 72 | +func TestAutoPullPullsTheMissingModel(t *testing.T) { | |
| 73 | + var pulls []string | |
| 74 | + url := storeServer(t, nil, pullOK, &pulls) | |
| 75 | + var out bytes.Buffer | |
| 76 | + if err := autoPull(context.Background(), llmmanEngine(t, url, false, nil), &out, false); err != nil || len(pulls) != 0 || out.Len() != 0 { | |
| 77 | + t.Fatalf("off: err %v, pulls %v, out %q", err, pulls, out.String()) | |
| 78 | + } | |
| 79 | + if err := autoPull(context.Background(), llmmanEngine(t, url, true, nil), &out, false); err != nil { | |
| 80 | + t.Fatal(err) | |
| 81 | + } | |
| 82 | + got := out.String() | |
| 83 | + for _, want := range []string{"⬇ pulling smol\n", "⬇ pulling smol — 1.0 GB / 2.0 GB (50 %)\n", "⬇ pulling smol — 2.0 GB / 2.0 GB (100 %)\n", "⬇ pulled smol in "} { | |
| 84 | + if !strings.Contains(got, want) { | |
| 85 | + t.Errorf("missing %q in:\n%s", want, got) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + if len(pulls) != 1 || pulls[0] != "smol" { | |
| 89 | + t.Errorf("pulls = %v", pulls) | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +// The agent's own model failing stops mm with the server's words; an | |
| 94 | +// mmConfigs agent's model failing is a warning; a model present is silent. | |
| 95 | +func TestAutoPullFailures(t *testing.T) { | |
| 96 | + var pulls []string | |
| 97 | + bad := storeServer(t, nil, []string{`{"error":"HF model info: HTTP 401"}`}, &pulls) | |
| 98 | + err := autoPull(context.Background(), llmmanEngine(t, bad, true, nil), &bytes.Buffer{}, false) | |
| 99 | + if err == nil || !strings.Contains(err.Error(), "autoPull: smol: the server says: HF model info: HTTP 401") { | |
| 100 | + t.Errorf("own model: %v", err) | |
| 101 | + } | |
| 102 | + | |
| 103 | + ok := storeServer(t, []string{"smol"}, nil, &pulls) | |
| 104 | + child := filepath.Join(t.TempDir(), "agent.go.yaml") | |
| 105 | + if err := os.WriteFile(child, []byte("provider: llmman\nmodel: big\nbaseUrl: "+bad+"\nfallback: \"\"\n"), 0o644); err != nil { | |
| 106 | + t.Fatal(err) | |
| 107 | + } | |
| 108 | + var out bytes.Buffer | |
| 109 | + pulls = nil | |
| 110 | + e := llmmanEngine(t, ok, true, map[string]config.AgentRef{"go": {Path: child}}) | |
| 111 | + if err := autoPull(context.Background(), e, &out, false); err != nil { | |
| 112 | + t.Fatalf("an agent's model stopped mm: %v", err) | |
| 113 | + } | |
| 114 | + if !strings.Contains(out.String(), "[warning: autoPull: big ("+child+"): the server says: HF model info: HTTP 401]") { | |
| 115 | + t.Errorf("out:\n%s", out.String()) | |
| 116 | + } | |
| 117 | + if len(pulls) != 1 || pulls[0] != "big" { | |
| 118 | + t.Errorf("pulls = %v (the present smol must not be pulled)", pulls) | |
| 119 | + } | |
| 120 | +} | |
| 121 | + | |
| 122 | +// On a terminal the progress is one line rewritten in place, cleared at the | |
| 123 | +// end. | |
| 124 | +func TestPullReportOnATerminal(t *testing.T) { | |
| 125 | + var out bytes.Buffer | |
| 126 | + r := newPullReport(&out, true) | |
| 127 | + r.step(engine.PullProgress{Model: "m", Status: "pulling", Completed: 5e8, Total: 1e9}) | |
| 128 | + r.done("m", true, nil) | |
| 129 | + if got := out.String(); !strings.HasPrefix(got, "\r⬇ pulling m — 0.5 GB / 1.0 GB (50 %)\033[K\r\033[K⬇ pulled m in ") { | |
| 130 | + t.Errorf("out %q", got) | |
| 131 | + } | |
| 132 | +} | |
| 133 | + | |
| 134 | +// Sizes in GB from 1 GB, MB below; durations in tenths under ten seconds. | |
| 135 | +func TestPullReportUnits(t *testing.T) { | |
| 136 | + if got := size(52_000_000, 145_000_000) + " / " + size(145_000_000, 145_000_000); got != "52 MB / 145 MB" { | |
| 137 | + t.Errorf("small: %q", got) | |
| 138 | + } | |
| 139 | + if got := size(2_100_000_000, 8_100_000_000); got != "2.1 GB" { | |
| 140 | + t.Errorf("large: %q", got) | |
| 141 | + } | |
| 142 | + if got := elapsed(840 * time.Millisecond); got != "0.8s" { | |
| 143 | + t.Errorf("short: %q", got) | |
| 144 | + } | |
| 145 | + if got := elapsed(123400 * time.Millisecond); got != "2m3s" { | |
| 146 | + t.Errorf("long: %q", got) | |
| 147 | + } | |
| 148 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,148 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 9 | + "os" | ||
| 10 | + "path/filepath" | ||
| 11 | + "strings" | ||
| 12 | + "testing" | ||
| 13 | + "time" | ||
| 14 | + | ||
| 15 | + "mm/internal/config" | ||
| 16 | + "mm/internal/engine" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// storeServer is a llmman-style server holding `models`; /api/pull answers | ||
| 20 | +// with `lines` (one JSON object each) and records what it was asked. | ||
| 21 | +func storeServer(t *testing.T, models []string, lines []string, pulls *[]string) string { | ||
| 22 | + t.Helper() | ||
| 23 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 24 | + switch r.URL.Path { | ||
| 25 | + case "/v1/models": | ||
| 26 | + data := []map[string]string{} | ||
| 27 | + for _, m := range models { | ||
| 28 | + data = append(data, map[string]string{"id": m}) | ||
| 29 | + } | ||
| 30 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) | ||
| 31 | + case "/api/pull": | ||
| 32 | + var body map[string]string | ||
| 33 | + _ = json.NewDecoder(r.Body).Decode(&body) | ||
| 34 | + *pulls = append(*pulls, body["model"]) | ||
| 35 | + for _, l := range lines { | ||
| 36 | + _, _ = w.Write([]byte(l + "\n")) | ||
| 37 | + } | ||
| 38 | + default: | ||
| 39 | + http.NotFound(w, r) | ||
| 40 | + } | ||
| 41 | + })) | ||
| 42 | + t.Cleanup(srv.Close) | ||
| 43 | + return srv.URL + "/v1" | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +var pullOK = []string{ | ||
| 47 | + `{"status":"pulling manifest"}`, | ||
| 48 | + `{"completed":0,"status":"pulling","total":2000000000}`, | ||
| 49 | + `{"completed":1000000000,"status":"pulling","total":2000000000}`, | ||
| 50 | + `{"completed":2000000000,"status":"pulling","total":2000000000}`, | ||
| 51 | + `{"status":"success"}`, | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +// llmmanEngine is an engine on the fake server, with autoPull set as asked. | ||
| 55 | +func llmmanEngine(t *testing.T, baseURL string, autoPull bool, agents map[string]config.AgentRef) *engine.Engine { | ||
| 56 | + t.Helper() | ||
| 57 | + none := "" | ||
| 58 | + withConfig(t, func(c *config.Config) { | ||
| 59 | + c.Provider, c.Model, c.BaseURL, c.Fallback, c.AutoPull = "llmman", "smol", baseURL, &none, autoPull | ||
| 60 | + c.MMConfigs = agents | ||
| 61 | + }) | ||
| 62 | + e, err := engine.New(context.Background()) | ||
| 63 | + if err != nil { | ||
| 64 | + t.Fatal(err) | ||
| 65 | + } | ||
| 66 | + return e | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +// Off, nothing is asked; on, the missing model is pulled with its progress, | ||
| 70 | +// one line per new tenth off a terminal (0 % is the first line), and a | ||
| 71 | +// closing line. | ||
| 72 | +func TestAutoPullPullsTheMissingModel(t *testing.T) { | ||
| 73 | + var pulls []string | ||
| 74 | + url := storeServer(t, nil, pullOK, &pulls) | ||
| 75 | + var out bytes.Buffer | ||
| 76 | + if err := autoPull(context.Background(), llmmanEngine(t, url, false, nil), &out, false); err != nil || len(pulls) != 0 || out.Len() != 0 { | ||
| 77 | + t.Fatalf("off: err %v, pulls %v, out %q", err, pulls, out.String()) | ||
| 78 | + } | ||
| 79 | + if err := autoPull(context.Background(), llmmanEngine(t, url, true, nil), &out, false); err != nil { | ||
| 80 | + t.Fatal(err) | ||
| 81 | + } | ||
| 82 | + got := out.String() | ||
| 83 | + for _, want := range []string{"⬇ pulling smol\n", "⬇ pulling smol — 1.0 GB / 2.0 GB (50 %)\n", "⬇ pulling smol — 2.0 GB / 2.0 GB (100 %)\n", "⬇ pulled smol in "} { | ||
| 84 | + if !strings.Contains(got, want) { | ||
| 85 | + t.Errorf("missing %q in:\n%s", want, got) | ||
| 86 | + } | ||
| 87 | + } | ||
| 88 | + if len(pulls) != 1 || pulls[0] != "smol" { | ||
| 89 | + t.Errorf("pulls = %v", pulls) | ||
| 90 | + } | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +// The agent's own model failing stops mm with the server's words; an | ||
| 94 | +// mmConfigs agent's model failing is a warning; a model present is silent. | ||
| 95 | +func TestAutoPullFailures(t *testing.T) { | ||
| 96 | + var pulls []string | ||
| 97 | + bad := storeServer(t, nil, []string{`{"error":"HF model info: HTTP 401"}`}, &pulls) | ||
| 98 | + err := autoPull(context.Background(), llmmanEngine(t, bad, true, nil), &bytes.Buffer{}, false) | ||
| 99 | + if err == nil || !strings.Contains(err.Error(), "autoPull: smol: the server says: HF model info: HTTP 401") { | ||
| 100 | + t.Errorf("own model: %v", err) | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + ok := storeServer(t, []string{"smol"}, nil, &pulls) | ||
| 104 | + child := filepath.Join(t.TempDir(), "agent.go.yaml") | ||
| 105 | + if err := os.WriteFile(child, []byte("provider: llmman\nmodel: big\nbaseUrl: "+bad+"\nfallback: \"\"\n"), 0o644); err != nil { | ||
| 106 | + t.Fatal(err) | ||
| 107 | + } | ||
| 108 | + var out bytes.Buffer | ||
| 109 | + pulls = nil | ||
| 110 | + e := llmmanEngine(t, ok, true, map[string]config.AgentRef{"go": {Path: child}}) | ||
| 111 | + if err := autoPull(context.Background(), e, &out, false); err != nil { | ||
| 112 | + t.Fatalf("an agent's model stopped mm: %v", err) | ||
| 113 | + } | ||
| 114 | + if !strings.Contains(out.String(), "[warning: autoPull: big ("+child+"): the server says: HF model info: HTTP 401]") { | ||
| 115 | + t.Errorf("out:\n%s", out.String()) | ||
| 116 | + } | ||
| 117 | + if len(pulls) != 1 || pulls[0] != "big" { | ||
| 118 | + t.Errorf("pulls = %v (the present smol must not be pulled)", pulls) | ||
| 119 | + } | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +// On a terminal the progress is one line rewritten in place, cleared at the | ||
| 123 | +// end. | ||
| 124 | +func TestPullReportOnATerminal(t *testing.T) { | ||
| 125 | + var out bytes.Buffer | ||
| 126 | + r := newPullReport(&out, true) | ||
| 127 | + r.step(engine.PullProgress{Model: "m", Status: "pulling", Completed: 5e8, Total: 1e9}) | ||
| 128 | + r.done("m", true, nil) | ||
| 129 | + if got := out.String(); !strings.HasPrefix(got, "\r⬇ pulling m — 0.5 GB / 1.0 GB (50 %)\033[K\r\033[K⬇ pulled m in ") { | ||
| 130 | + t.Errorf("out %q", got) | ||
| 131 | + } | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +// Sizes in GB from 1 GB, MB below; durations in tenths under ten seconds. | ||
| 135 | +func TestPullReportUnits(t *testing.T) { | ||
| 136 | + if got := size(52_000_000, 145_000_000) + " / " + size(145_000_000, 145_000_000); got != "52 MB / 145 MB" { | ||
| 137 | + t.Errorf("small: %q", got) | ||
| 138 | + } | ||
| 139 | + if got := size(2_100_000_000, 8_100_000_000); got != "2.1 GB" { | ||
| 140 | + t.Errorf("large: %q", got) | ||
| 141 | + } | ||
| 142 | + if got := elapsed(840 * time.Millisecond); got != "0.8s" { | ||
| 143 | + t.Errorf("short: %q", got) | ||
| 144 | + } | ||
| 145 | + if got := elapsed(123400 * time.Millisecond); got != "2m3s" { | ||
| 146 | + t.Errorf("long: %q", got) | ||
| 147 | + } | ||
| 148 | +} | ||