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

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

Download ZIP

🛟 Updated.

k33g committed 2026-10-26T00:37:21+01:00 Browse files
6fcf1c7 parent: 19072cf
modified .tickets/issues/0061-llmman-provider-download-the-model-automatically.yaml +111 -5
@@ -1,15 +1,121 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 61
33 title: "llmman provider: download the model automatically if does not exist"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 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
1113 milestone: v1.0.1
1214 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
15121 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: 612 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: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-01T05:48:44.919Z8 createdAt: 2026-10-01T05:48:44.919Z
9-updatedAt: 2026-10-22T17:11:46.417Z9+updatedAt: 2026-10-25T23:35:37.303Z
10-labels: []10+labels:
11+ - enhancement
12+ - To be tested
11 milestone: v1.0.113 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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-22T06:06:35.493Z
9-updatedAt: 2026-10-22T17:08:19.090Z
9+updatedAt: 2026-10-25T23:10:18.858Z
1010 labels: []
11-milestone: v1.0.1
1211 body: ""
1312 tasks: []
1413 comments: []
@@ -6,9 +6,8 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-22T06:06:35.493Z8 createdAt: 2026-10-22T06:06:35.493Z
9-updatedAt: 2026-10-22T17:08:19.090Z9+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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 142
33 title: "Memory: how it works ?"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-24T11:48:07.733Z
9-updatedAt: 2026-10-24T14:00:00.000Z
9+updatedAt: 2026-10-26T00:15:00.000Z
1010 labels: []
1111 milestone: v1.0.1
1212 body: |
@@ -73,4 +73,16 @@ body: |
7373 So a skill loaded in an old session comes back with `/resume`, as a tool
7474 result in the history.
7575 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: 1422 id: 142
3 title: "Memory: how it works ?"3 title: "Memory: how it works ?"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-24T11:48:07.733Z8 createdAt: 2026-10-24T11:48:07.733Z
9-updatedAt: 2026-10-24T14:00:00.000Z9+updatedAt: 2026-10-26T00:15:00.000Z
10 labels: []10 labels: []
11 milestone: v1.0.111 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 tool73 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 143
33 title: Improve management of the skills in memory
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-24T11:49:03.667Z
9-updatedAt: 2026-10-24T15:00:00.000Z
9+updatedAt: 2026-10-26T00:15:00.000Z
1010 labels: []
1111 milestone: v1.0.1
1212 related:
@@ -89,4 +89,17 @@ body: |
8989 skill it was following?
9090 - Whether the loop detector fires on a reload across turns.
9191 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: 1432 id: 143
3 title: Improve management of the skills in memory3 title: Improve management of the skills in memory
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-24T11:49:03.667Z8 createdAt: 2026-10-24T11:49:03.667Z
9-updatedAt: 2026-10-24T15:00:00.000Z9+updatedAt: 2026-10-26T00:15:00.000Z
10 labels: []10 labels: []
11 milestone: v1.0.111 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 158
33 title: Calculate the duration of a completion
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T18:15:39.756Z
9-updatedAt: 2026-10-25T22:40:00.000Z
9+updatedAt: 2026-10-25T23:05:21.835Z
1010 labels:
1111 - enhancement
1212 - To be tested
1313 milestone: v1.0.1
14-body: |+
14+body: |
1515 ## Need
1616
1717 See how long a completion takes: in the window's footer, the duration should tick while the model works.
@@ -55,7 +55,7 @@ tasks:
5555 email: ph.charriere@gmail.com
5656 createdAt: 2026-10-25T22:20:00.000Z
5757 - id: 3
58- title: "Tests + live check in the window (pty)"
58+ title: Tests + live check in the window (pty)
5959 state: closed
6060 priority: none
6161 author:
@@ -63,7 +63,7 @@ tasks:
6363 email: ph.charriere@gmail.com
6464 createdAt: 2026-10-25T22:20:00.000Z
6565 - id: 4
66- title: "Docs EN + FR"
66+ title: Docs EN + FR
6767 state: closed
6868 priority: none
6969 author:
@@ -71,7 +71,7 @@ tasks:
7171 email: ph.charriere@gmail.com
7272 createdAt: 2026-10-25T22:20:00.000Z
7373 - id: 5
74- title: "Config key showCompletionTime (default true) + tests + docs"
74+ title: Config key showCompletionTime (default true) + tests + docs
7575 state: closed
7676 priority: none
7777 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: 1582 id: 158
3 title: Calculate the duration of a completion3 title: Calculate the duration of a completion
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-25T18:15:39.756Z8 createdAt: 2026-10-25T18:15:39.756Z
9-updatedAt: 2026-10-25T22:40:00.000Z9+updatedAt: 2026-10-25T23:05:21.835Z
10 labels:10 labels:
11 - enhancement11 - enhancement
12 - To be tested12 - To be tested
13 milestone: v1.0.113 milestone: v1.0.1
14-body: |+14+body: |
15 ## Need15 ## 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.com55 email: ph.charriere@gmail.com
56 createdAt: 2026-10-25T22:20:00.000Z56 createdAt: 2026-10-25T22:20:00.000Z
57 - id: 357 - id: 3
58- title: "Tests + live check in the window (pty)"58+ title: Tests + live check in the window (pty)
59 state: closed59 state: closed
60 priority: none60 priority: none
61 author:61 author:
@@ -63,7 +63,7 @@ tasks:
63 email: ph.charriere@gmail.com63 email: ph.charriere@gmail.com
64 createdAt: 2026-10-25T22:20:00.000Z64 createdAt: 2026-10-25T22:20:00.000Z
65 - id: 465 - id: 4
66- title: "Docs EN + FR"66+ title: Docs EN + FR
67 state: closed67 state: closed
68 priority: none68 priority: none
69 author:69 author:
@@ -71,7 +71,7 @@ tasks:
71 email: ph.charriere@gmail.com71 email: ph.charriere@gmail.com
72 createdAt: 2026-10-25T22:20:00.000Z72 createdAt: 2026-10-25T22:20:00.000Z
73 - id: 573 - id: 5
74- title: "Config key showCompletionTime (default true) + tests + docs"74+ title: Config key showCompletionTime (default true) + tests + docs
75 state: closed75 state: closed
76 priority: none76 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 161
33 title: Unload model should be an option of the usage of multi agents
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T22:29:12.597Z
9-updatedAt: 2026-10-25T23:59:00.000Z
9+updatedAt: 2026-10-25T23:05:06.886Z
1010 labels:
1111 - enhancement
1212 - To be tested
@@ -87,7 +87,7 @@ tasks:
8787 email: ph.charriere@gmail.com
8888 createdAt: 2026-10-25T23:59:00.000Z
8989 - id: 5
90- title: "Tests + live check with llmman"
90+ title: Tests + live check with llmman
9191 state: closed
9292 priority: none
9393 author:
@@ -95,7 +95,7 @@ tasks:
9595 email: ph.charriere@gmail.com
9696 createdAt: 2026-10-25T23:59:00.000Z
9797 - 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
9999 state: closed
100100 priority: none
101101 author:
@@ -103,11 +103,13 @@ tasks:
103103 email: ph.charriere@gmail.com
104104 createdAt: 2026-10-25T23:59:00.000Z
105105 - 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
108108 priority: none
109109 author:
110110 name: k33g
111111 email: ph.charriere@gmail.com
112112 createdAt: 2026-10-25T23:59:00.000Z
113+ updatedAt: 2026-10-25T23:05:00.278Z
114+ closedAt: 2026-10-25T23:05:00.278Z
113115 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: 1612 id: 161
3 title: Unload model should be an option of the usage of multi agents3 title: Unload model should be an option of the usage of multi agents
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-25T22:29:12.597Z8 createdAt: 2026-10-25T22:29:12.597Z
9-updatedAt: 2026-10-25T23:59:00.000Z9+updatedAt: 2026-10-25T23:05:06.886Z
10 labels:10 labels:
11 - enhancement11 - enhancement
12 - To be tested12 - To be tested
@@ -87,7 +87,7 @@ tasks:
87 email: ph.charriere@gmail.com87 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T23:59:00.000Z88 createdAt: 2026-10-25T23:59:00.000Z
89 - id: 589 - id: 5
90- title: "Tests + live check with llmman"90+ title: Tests + live check with llmman
91 state: closed91 state: closed
92 priority: none92 priority: none
93 author:93 author:
@@ -95,7 +95,7 @@ tasks:
95 email: ph.charriere@gmail.com95 email: ph.charriere@gmail.com
96 createdAt: 2026-10-25T23:59:00.000Z96 createdAt: 2026-10-25T23:59:00.000Z
97 - id: 697 - 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: closed99 state: closed
100 priority: none100 priority: none
101 author:101 author:
@@ -103,11 +103,13 @@ tasks:
103 email: ph.charriere@gmail.com103 email: ph.charriere@gmail.com
104 createdAt: 2026-10-25T23:59:00.000Z104 createdAt: 2026-10-25T23:59:00.000Z
105 - id: 7105 - id: 7
106- title: "Check in the window and in an editor"106+ title: Check in the window and in an editor
107- state: open107+ state: closed
108 priority: none108 priority: none
109 author:109 author:
110 name: k33g110 name: k33g
111 email: ph.charriere@gmail.com111 email: ph.charriere@gmail.com
112 createdAt: 2026-10-25T23:59:00.000Z112 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
1010 llmman serve
1111 ```
1212
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:
1414
1515 ```bash
1616 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
2828 ## Variants
2929
3030 - **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).
3132 - **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.
3233 - **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`.
3334 - **`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 serve10 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 ```bash15 ```bash
16 llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M16 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 ## Variants28 ## 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 @@
3232 | `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). |
3333 | `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). |
3434 | `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. |
3536 | `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`). |
3637 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
3738 | `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/
1010 llmman serve
1111 ```
1212
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 :
1414
1515 ```bash
1616 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/
2828 ## Variantes
2929
3030 - **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).
3132 - **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.
3233 - **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`.
3334 - **`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 serve10 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 ```bash15 ```bash
16 llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M16 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 ## Variantes28 ## 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 @@
3232 | `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). |
3333 | `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). |
3434 | `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. |
3536 | `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`). |
3637 | `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é. |
3738 | `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 {
8787 // 158). Display only: nothing else depends on it.
8888 ShowCompletionTime bool `yaml:"showCompletionTime"`
8989
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+
9097 // System: the system prompt — what the agent is, and what it may do.
9198 System string `yaml:"system"`
9299
@@ -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 {
6262
6363 // unload: how this server unloads a model (unload.go); nil = it cannot.
6464 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
6569 }
6670
6771 // DefaultURLs returns the address a provider uses when the config gives no
@@ -125,6 +129,7 @@ func init() {
125129 progress: true,
126130 thinkingOff: thinkingOffTemplate,
127131 unload: unloadKeepAlive,
132+ pull: pullOllamaAPI,
128133 })
129134 register(&openaiCompat{
130135 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 unloadFunc64 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 no71 // 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() {
236236 fail("[engine error: %v ]", err)
237237 os.Exit(1)
238238 }
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+ }
239245 // mmConfigs.<name>.unloadModels (ticket 161): the pool frees this
240246 // agent's model before the hand-off, the asked agent's after it.
241247 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 this245 // 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+}