added
.tickets/issues/0066-talk-to-another-mm.yaml +391 -0 | new file mode 100644 | ||
| @@ -0,0 +1,391 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 66 | |
| 3 | +title: talk to another mm | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-02T18:48:36.275Z | |
| 9 | +updatedAt: 2026-10-02T20:30:00.000Z | |
| 10 | +labels: | |
| 11 | + - kind::feature | |
| 12 | +related: | |
| 13 | + - 57 | |
| 14 | + - 59 | |
| 15 | +body: | | |
| 16 | + Goal: from the current session, send a prompt to ANOTHER mm (another config: | |
| 17 | + other model, provider, system prompt, tools, skills) and bring its answer | |
| 18 | + back into the current conversation, so the current model can use it. | |
| 19 | + | |
| 20 | + /ask bob <prompt> | |
| 21 | + /ask riker <prompt> | |
| 22 | + | |
| 23 | + Config (user's proposal): | |
| 24 | + | |
| 25 | + mm-configs: | |
| 26 | + bob: path/to/agent.bob.yaml | |
| 27 | + riker: path/to/agent.riker.yaml | |
| 28 | + | |
| 29 | + Feasibility: yes. Checked 2026-10-02 (no code written). | |
| 30 | + | |
| 31 | + Recommended transport: ACP over stdio, mm talking to a child `mm -acp`. | |
| 32 | + - The child is started with os.Executable() (same mm build, not whatever is | |
| 33 | + on PATH) and `-config <path>`, cwd = the current working directory. | |
| 34 | + - The parent is an ACP CLIENT: github.com/coder/acp-go-sdk (already a | |
| 35 | + dependency, v0.13.5) has NewClientSideConnection + the Client interface. | |
| 36 | + - mm's ACP server only ever calls two client methods (grep internal/acp): | |
| 37 | + SessionUpdate (message/thought/tool-call/usage chunks) and | |
| 38 | + RequestPermission. The rest of the Client interface (fs, terminal) can be | |
| 39 | + stubs returning "not supported", and the parent advertises no fs/terminal | |
| 40 | + capability. | |
| 41 | + - Flow: initialize -> session/new {cwd} -> session/prompt {text} -> collect | |
| 42 | + agent_message_chunk text until the prompt response (stopReason) -> | |
| 43 | + insert the answer in the current history. | |
| 44 | + - Proof it works as a protocol: tests/tool-order/session.py and the sidekick | |
| 45 | + web client already drive `mm -acp` from outside. | |
| 46 | + | |
| 47 | + Why ACP rather than the alternatives: | |
| 48 | + - `mm -p` subprocess (one-shot): simpler, but stdout mixes the answer with | |
| 49 | + thoughts, tool echoes, ANSI and the final command summary (fragile | |
| 50 | + parsing); line mode never asks permission, so bob would run bash/write | |
| 51 | + with NO gate; no cancel except kill; no usage; no follow-up turns. | |
| 52 | + - In-process second Engine: no process, but config.Cfg is a global read | |
| 53 | + everywhere (engine, tools, compact, store...). Two configs in one process | |
| 54 | + means de-globalising the config first: a large refactor for this feature. | |
| 55 | + The subprocess also isolates bob's tools, MCP servers and skills. | |
| 56 | + - ACP gives, for free: structured events (tool calls can be shown as | |
| 57 | + "bob: bash ls"), permission requests that can be FORWARDED to the user | |
| 58 | + through the current front end's existing sink.Allow (line mode: no gate, | |
| 59 | + window: inline prompt, editor: the editor's dialog), session/cancel for | |
| 60 | + /abort and Ctrl+C, usage, and a session id -> multi-turn with bob | |
| 61 | + (keep the child alive, reuse the session: "/ask bob and what about X?"). | |
| 62 | + | |
| 63 | + Proposed behaviour (to confirm): | |
| 64 | + - One child per name, started lazily on the first /ask, kept alive for the | |
| 65 | + parent session (start-up cost), killed on /quit and exit; a fresh ACP | |
| 66 | + session (session/new) per /ask if 3 is confirmed. | |
| 67 | + - The /ask exchange enters the CURRENT history as one user message, e.g. | |
| 68 | + [bob (ollama/qwen3.5:2b) was asked: <prompt>] | |
| 69 | + <bob's answer> | |
| 70 | + so the current model sees it on the next question and the session file | |
| 71 | + persists and replays it on /resume. Must keep the history valid for | |
| 72 | + the next model call (user/model alternation: merge into the next user | |
| 73 | + message, or a user+model pair "[noted]" — to decide in the code). | |
| 74 | + - Bob's answer is shown while it streams, prefixed/styled as bob's | |
| 75 | + (decided: displayed, kept for the next question). | |
| 76 | + - Bob's thoughts: not shown (or dim), never inserted in the history. | |
| 77 | + - Long answers: capped (maxOutput-like) before insertion — the current | |
| 78 | + model's context pays for them. | |
| 79 | + | |
| 80 | + Risks / things to decide: | |
| 81 | + - Ticket 57: a permission forwarded to the user and answered slowly will | |
| 82 | + trip the CHILD's watchdog exactly like today in an editor. | |
| 83 | + - Ticket 59 (yolo): which permission policy for the child — forward to the | |
| 84 | + user (default), or auto per config entry? | |
| 85 | + - Two agents on the same working tree: the parent waits while bob works | |
| 86 | + (/ask is blocking), so no concurrent edits; but bob may change files the | |
| 87 | + parent model "remembers" — say so in the inserted message? | |
| 88 | + - Recursion: /ask is a USER command, the model cannot call it, so no | |
| 89 | + automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth | |
| 90 | + (env var MM_ASK_DEPTH) and refuse cycles. | |
| 91 | + - Paths in mm-configs: relative to the PARENT config file's directory (the | |
| 92 | + ACP mode already resolves skillsDir that way; a path relative to the cwd | |
| 93 | + breaks when the config is elsewhere). The child's own relative paths | |
| 94 | + (skillsDir) resolve against ITS config dir in -acp mode. | |
| 95 | + - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+); | |
| 96 | + hence keep the child alive. | |
| 97 | + - Child stderr: capture it and show it only on failure (start error, | |
| 98 | + unknown config, provider down -> one line: "bob: nothing answers at …"). | |
| 99 | + | |
| 100 | + Demo ready (2026-10-02): demo-llmman/agent.llmman.yaml declares | |
| 101 | + mmConfigs: | |
| 102 | + jan: agent.jan-nano.yaml # hf.co/Menlo/Jan-nano-gguf:Q4_K_M | |
| 103 | + lucy: agent.lucy.yaml # hf.co/Menlo/Lucy-gguf:Q4_K_M | |
| 104 | + (the key is ignored until implemented: config loading is not strict). Both | |
| 105 | + configs answered a bash-tool prompt live through llmman. | |
| 106 | + | |
| 107 | + Decisions (user, 2026-10-02): | |
| 108 | + 1. Key in camelCase: `mmConfigs:` (name -> path of a config file). | |
| 109 | + 2. Bob's answer is DISPLAYED, and stays in the current history for the | |
| 110 | + NEXT question; the current model does not answer right away. | |
| 111 | + 3. Two commands: | |
| 112 | + - /ask <name> <prompt>: bob receives the typed prompt only; | |
| 113 | + - /ask-with-context <name> <prompt>: bob also receives the context of | |
| 114 | + the current conversation. | |
| 115 | + Interpretation to confirm: every /ask starts a FRESH bob session (no | |
| 116 | + memory between two /ask); continuity comes from /ask-with-context. | |
| 117 | + 4. Both commands work in the three front ends (Terminal, Window, Editor). | |
| 118 | + 5. Context: only with /ask-with-context (see 3). | |
| 119 | + | |
| 120 | + Editor visibility (checked): yes. The ACP facade already sends | |
| 121 | + `available_commands_update` built from command.For(Editor) | |
| 122 | + (internal/acp/acp.go availableCommands), and skills are already advertised | |
| 123 | + with `input.unstructured.hint`. Today the built-in commands are sent | |
| 124 | + WITHOUT an input hint: /ask and /ask-with-context need one, e.g. | |
| 125 | + "bob|riker <prompt>", built from mmConfigs. The editor completes the | |
| 126 | + command name, not the argument; alternative to discuss: one command per | |
| 127 | + agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents. | |
| 128 | + The CHILD's own available_commands_update is ignored by the parent. | |
| 129 | + | |
| 130 | + Decided too (user, 2026-10-02): | |
| 131 | + a. Context for /ask-with-context = a text transcript of the current | |
| 132 | + history: user questions and assistant answers, each tool call as one | |
| 133 | + line (name + short args), no tool outputs, no reasoning, the compaction | |
| 134 | + summary if any; capped (chars), newest turns kept first. Sent as: | |
| 135 | + [Conversation so far, from <parent agent name>] | |
| 136 | + ... | |
| 137 | + [Question] | |
| 138 | + <prompt> | |
| 139 | + b. Every /ask (and /ask-with-context) starts a FRESH bob session | |
| 140 | + (session/new); no memory on bob's side between two calls. | |
| 141 | + c. Editors: ONE COMMAND PER AGENT, generated from mmConfigs — | |
| 142 | + /ask-bob, /ask-with-context-bob, /ask-riker, … each with an input hint | |
| 143 | + "<prompt>". Consequences: | |
| 144 | + - agent names become command names: validate them in config | |
| 145 | + (lowercase letters, digits, '-'; error otherwise); | |
| 146 | + - a skill or built-in with the same name: the existing guard in | |
| 147 | + availableCommands (commandNamed) keeps the command and drops the skill, | |
| 148 | + with the banner warning — extend it to the generated names; | |
| 149 | + - terminal and window (confirmed by the user, 2026-10-02): accept the | |
| 150 | + per-agent forms there too (same table, no drift), and keep | |
| 151 | + `/ask <name> <prompt>` as the generic form; the window's "/" picker | |
| 152 | + lists the per-agent commands. | |
| 153 | + | |
| 154 | + Proposed behaviour (to confirm): | |
| 155 | + - One child per name, started lazily on the first /ask, kept alive for the | |
| 156 | + parent session (start-up cost), killed on /quit and exit; a fresh ACP | |
| 157 | + session (session/new) per /ask if 3 is confirmed. | |
| 158 | + - The /ask exchange enters the CURRENT history as one user message, e.g. | |
| 159 | + [bob (ollama/qwen3.5:2b) was asked: <prompt>] | |
| 160 | + <bob's answer> | |
| 161 | + so the current model sees it on the next question and the session file | |
| 162 | + persists and replays it on /resume. Must keep the history valid for | |
| 163 | + the next model call (user/model alternation: merge into the next user | |
| 164 | + message, or a user+model pair "[noted]" — to decide in the code). | |
| 165 | + - Bob's answer is shown while it streams, prefixed/styled as bob's | |
| 166 | + (decided: displayed, kept for the next question). | |
| 167 | + - Bob's thoughts: not shown (or dim), never inserted in the history. | |
| 168 | + - Long answers: capped (maxOutput-like) before insertion — the current | |
| 169 | + model's context pays for them. | |
| 170 | + | |
| 171 | + Risks / things to decide: | |
| 172 | + - Ticket 57: a permission forwarded to the user and answered slowly will | |
| 173 | + trip the CHILD's watchdog exactly like today in an editor. | |
| 174 | + - Ticket 59 (yolo): which permission policy for the child — forward to the | |
| 175 | + user (default), or auto per config entry? | |
| 176 | + - Two agents on the same working tree: the parent waits while bob works | |
| 177 | + (/ask is blocking), so no concurrent edits; but bob may change files the | |
| 178 | + parent model "remembers" — say so in the inserted message? | |
| 179 | + - Recursion: /ask is a USER command, the model cannot call it, so no | |
| 180 | + automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth | |
| 181 | + (env var MM_ASK_DEPTH) and refuse cycles. | |
| 182 | + - Paths in mm-configs: relative to the PARENT config file's directory (the | |
| 183 | + ACP mode already resolves skillsDir that way; a path relative to the cwd | |
| 184 | + breaks when the config is elsewhere). The child's own relative paths | |
| 185 | + (skillsDir) resolve against ITS config dir in -acp mode. | |
| 186 | + - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+); | |
| 187 | + hence keep the child alive. | |
| 188 | + - Child stderr: capture it and show it only on failure (start error, | |
| 189 | + unknown config, provider down -> one line: "bob: nothing answers at …"). | |
| 190 | + | |
| 191 | + Decisions (user, 2026-10-02): | |
| 192 | + 1. Key in camelCase: `mmConfigs:` (name -> path of a config file). | |
| 193 | + 2. Bob's answer is DISPLAYED, and stays in the current history for the | |
| 194 | + NEXT question; the current model does not answer right away. | |
| 195 | + 3. Two commands: | |
| 196 | + - /ask <name> <prompt>: bob receives the typed prompt only; | |
| 197 | + - /ask-with-context <name> <prompt>: bob also receives the context of | |
| 198 | + the current conversation. | |
| 199 | + Interpretation to confirm: every /ask starts a FRESH bob session (no | |
| 200 | + memory between two /ask); continuity comes from /ask-with-context. | |
| 201 | + 4. Both commands work in the three front ends (Terminal, Window, Editor). | |
| 202 | + 5. Context: only with /ask-with-context (see 3). | |
| 203 | + | |
| 204 | + Editor visibility (checked): yes. The ACP facade already sends | |
| 205 | + `available_commands_update` built from command.For(Editor) | |
| 206 | + (internal/acp/acp.go availableCommands), and skills are already advertised | |
| 207 | + with `input.unstructured.hint`. Today the built-in commands are sent | |
| 208 | + WITHOUT an input hint: /ask and /ask-with-context need one, e.g. | |
| 209 | + "bob|riker <prompt>", built from mmConfigs. The editor completes the | |
| 210 | + command name, not the argument; alternative to discuss: one command per | |
| 211 | + agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents. | |
| 212 | + The CHILD's own available_commands_update is ignored by the parent. | |
| 213 | + | |
| 214 | + Still open: | |
| 215 | + a. What "context" is for /ask-with-context. Proposal: a text transcript of | |
| 216 | + the current history — user questions and assistant answers, each tool | |
| 217 | + call as one line (name + short args), no tool outputs, no reasoning, | |
| 218 | + the compaction summary if any — capped (chars) to protect bob's | |
| 219 | + window, newest turns kept first. Sent as the first part of the prompt: | |
| 220 | + [Conversation so far, from <parent agent name>] | |
| 221 | + ... | |
| 222 | + [Question] | |
| 223 | + <prompt> | |
| 224 | + b. Fresh bob session per /ask (interpretation of 3) — confirm. | |
| 225 | + c. Editor: one /ask with a hint, or one command per agent? | |
| 226 | +tasks: | |
| 227 | + - id: 1 | |
| 228 | + title: "Answer the open questions (key name, immediate answer, multi-turn, fronts, context passed)" | |
| 229 | + state: closed | |
| 230 | + priority: none | |
| 231 | + author: | |
| 232 | + name: k33g | |
| 233 | + email: ph.charriere@gmail.com | |
| 234 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 235 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 236 | + - id: 2 | |
| 237 | + title: "Confirm the remaining points a/b/c (context format, fresh session per /ask, editor hint vs one command per agent)" | |
| 238 | + state: closed | |
| 239 | + priority: none | |
| 240 | + author: | |
| 241 | + name: k33g | |
| 242 | + email: ph.charriere@gmail.com | |
| 243 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 244 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 245 | + - id: 3 | |
| 246 | + title: "config: mmConfigs map (validation: name = [a-z0-9-]+ since it becomes a command name, file exists; paths relative to the config file dir)" | |
| 247 | + state: closed | |
| 248 | + priority: none | |
| 249 | + author: | |
| 250 | + name: k33g | |
| 251 | + email: ph.charriere@gmail.com | |
| 252 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 253 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 254 | + - id: 4 | |
| 255 | + title: "internal/askagent (name TBD): ACP client over a child `mm -acp -config <path>` (os.Executable), lazy start, one child per name, shutdown" | |
| 256 | + state: closed | |
| 257 | + priority: none | |
| 258 | + author: | |
| 259 | + name: k33g | |
| 260 | + email: ph.charriere@gmail.com | |
| 261 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 262 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 263 | + - id: 5 | |
| 264 | + title: "Client side: SessionUpdate -> stream text/tool-call lines to the current sink; RequestPermission -> current sink.Allow; fs/terminal stubs" | |
| 265 | + state: closed | |
| 266 | + priority: none | |
| 267 | + author: | |
| 268 | + name: k33g | |
| 269 | + email: ph.charriere@gmail.com | |
| 270 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 271 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 272 | + - id: 6 | |
| 273 | + title: "command table: /ask <name> <prompt>, /ask-with-context <name> <prompt>, and per-agent /ask-<name>, /ask-with-context-<name> generated from mmConfigs (all fronts); ACP advertises the per-agent ones with hint <prompt>; window picker; name clash guard" | |
| 274 | + state: closed | |
| 275 | + priority: none | |
| 276 | + author: | |
| 277 | + name: k33g | |
| 278 | + email: ph.charriere@gmail.com | |
| 279 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 280 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 281 | + - id: 7 | |
| 282 | + title: "Context builder for /ask-with-context: transcript of the current history (text + one line per tool call), capped" | |
| 283 | + state: closed | |
| 284 | + priority: none | |
| 285 | + author: | |
| 286 | + name: k33g | |
| 287 | + email: ph.charriere@gmail.com | |
| 288 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 289 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 290 | + - id: 8 | |
| 291 | + title: "Insert the exchange in the current history (format, cap, valid alternation), persisted by the store and replayed on /resume" | |
| 292 | + state: closed | |
| 293 | + priority: none | |
| 294 | + author: | |
| 295 | + name: k33g | |
| 296 | + email: ph.charriere@gmail.com | |
| 297 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 298 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 299 | + - id: 9 | |
| 300 | + title: "/abort, Ctrl+C and editor cancel -> session/cancel on the child; child crash -> one-line error, history untouched" | |
| 301 | + state: closed | |
| 302 | + priority: none | |
| 303 | + author: | |
| 304 | + name: k33g | |
| 305 | + email: ph.charriere@gmail.com | |
| 306 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 307 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 308 | + - id: 10 | |
| 309 | + title: "Tests: fake child agent (acp-go-sdk agent side) for the client; end-to-end with two real configs (demo-ollama + demo-llmman)" | |
| 310 | + state: closed | |
| 311 | + priority: none | |
| 312 | + author: | |
| 313 | + name: k33g | |
| 314 | + email: ph.charriere@gmail.com | |
| 315 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 316 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 317 | + - id: 11 | |
| 318 | + title: "Docs EN+FR: how-to ask another agent, reference (config key, commands), explanation (why ACP, why a subprocess)" | |
| 319 | + state: closed | |
| 320 | + priority: none | |
| 321 | + author: | |
| 322 | + name: k33g | |
| 323 | + email: ph.charriere@gmail.com | |
| 324 | + createdAt: 2026-10-02T19:30:00.000Z | |
| 325 | + updatedAt: 2026-10-02T20:00:00.000Z | |
| 326 | + - id: 12 | |
| 327 | + title: "Quality gate (qlty) where available — not run: qlty absent from this sandbox" | |
| 328 | + state: open | |
| 329 | + priority: none | |
| 330 | + author: | |
| 331 | + name: k33g | |
| 332 | + email: ph.charriere@gmail.com | |
| 333 | + createdAt: 2026-10-02T20:30:00.000Z | |
| 334 | + updatedAt: 2026-10-02T20:30:00.000Z | |
| 335 | + - id: 13 | |
| 336 | + title: "Live check of the window mode (/ask-jan in mm -tui) — unit-tested only" | |
| 337 | + state: open | |
| 338 | + priority: none | |
| 339 | + author: | |
| 340 | + name: k33g | |
| 341 | + email: ph.charriere@gmail.com | |
| 342 | + createdAt: 2026-10-02T20:30:00.000Z | |
| 343 | + updatedAt: 2026-10-02T20:30:00.000Z | |
| 344 | + - id: 14 | |
| 345 | + title: "Review, commit, merge" | |
| 346 | + state: open | |
| 347 | + priority: none | |
| 348 | + author: | |
| 349 | + name: k33g | |
| 350 | + email: ph.charriere@gmail.com | |
| 351 | + createdAt: 2026-10-02T20:30:00.000Z | |
| 352 | + updatedAt: 2026-10-02T20:30:00.000Z | |
| 353 | +comments: | |
| 354 | + - id: 1 | |
| 355 | + author: | |
| 356 | + name: claude | |
| 357 | + email: noreply@anthropic.com | |
| 358 | + createdAt: 2026-10-02T20:30:00.000Z | |
| 359 | + body: | | |
| 360 | + **Implemented** on branch `feature/talk-to-another-mm` (uncommitted, 2026-10-02). | |
| 361 | + | |
| 362 | + - config: `mmConfigs` (internal/config/agents.go) — names [a-z0-9-], not | |
| 363 | + starting with `with-context`; relative paths resolved against the config | |
| 364 | + file's dir at Load; missing file = start-up warning. `AGENT_SESSIONS=off` | |
| 365 | + env override (applyEnv). | |
| 366 | + - command: /ask family generated by SetAgents (internal/command/ask.go); | |
| 367 | + `Hint` field; ParseAsk returns (req, isAsk, err). | |
| 368 | + - internal/ask (new): Transcript/Prompt (context.go), Exchange as a | |
| 369 | + user+model pair + Describe (exchange.go), ChildEnv/Depth/CanAsk | |
| 370 | + (env.go, MaxDepth 2), ACP client (client.go), Pool with one lazy child | |
| 371 | + per agent + fresh session per ask (pool.go), Pool.Run + Runner interface | |
| 372 | + + Label + Missing (run.go). | |
| 373 | + - Front ends: agent/ask.go (Ctrl+C, /abort, /quit), tui/ask.go (job | |
| 374 | + "asking <agent>"), acp/ask.go (per-agent commands with `<prompt>` hint); | |
| 375 | + main.go wiring; store.FailAsk. | |
| 376 | + - Traps found: the SDK dispatches the child's requests concurrently with | |
| 377 | + its notifications — a permission request can overtake its tool_call | |
| 378 | + (client shows the call first, once); the SDK's default slog would print | |
| 379 | + "connection closed" on the user's terminal (silenced); the parent's | |
| 380 | + AGENT_* env would override the child's config (stripped). | |
| 381 | + - Tests: config, command, ask (fake ACP agent = the test binary re-run, | |
| 382 | + no network), agent, tui, acp. `go test ./...` green 3x; ask tests 15x. | |
| 383 | + `-race` not available (no C compiler in the sandbox). | |
| 384 | + - Live (host llmman): line mode `/ask-jan` → answer kept, the current | |
| 385 | + model quoted it next turn; ACP `/ask-with-context-lucy` → per-agent | |
| 386 | + commands announced, Lucy knew the name given earlier, her bash | |
| 387 | + permission went to the client titled "lucy · bash: …"; no child | |
| 388 | + session files, no leftover processes. | |
| 389 | + - Docs EN+FR: how-to/ask-another-agent.md, explanation/asking-another-agent.md, | |
| 390 | + reference (slash-commands, configuration, cli), architecture table, | |
| 391 | + READMEs, docs/diagrams/packages.drawio (internal/ask + edges). | |
| new file mode 100644 | |||
| @@ -0,0 +1,391 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 66 | ||
| 3 | +title: talk to another mm | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-02T18:48:36.275Z | ||
| 9 | +updatedAt: 2026-10-02T20:30:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::feature | ||
| 12 | +related: | ||
| 13 | + - 57 | ||
| 14 | + - 59 | ||
| 15 | +body: | | ||
| 16 | + Goal: from the current session, send a prompt to ANOTHER mm (another config: | ||
| 17 | + other model, provider, system prompt, tools, skills) and bring its answer | ||
| 18 | + back into the current conversation, so the current model can use it. | ||
| 19 | + | ||
| 20 | + /ask bob <prompt> | ||
| 21 | + /ask riker <prompt> | ||
| 22 | + | ||
| 23 | + Config (user's proposal): | ||
| 24 | + | ||
| 25 | + mm-configs: | ||
| 26 | + bob: path/to/agent.bob.yaml | ||
| 27 | + riker: path/to/agent.riker.yaml | ||
| 28 | + | ||
| 29 | + Feasibility: yes. Checked 2026-10-02 (no code written). | ||
| 30 | + | ||
| 31 | + Recommended transport: ACP over stdio, mm talking to a child `mm -acp`. | ||
| 32 | + - The child is started with os.Executable() (same mm build, not whatever is | ||
| 33 | + on PATH) and `-config <path>`, cwd = the current working directory. | ||
| 34 | + - The parent is an ACP CLIENT: github.com/coder/acp-go-sdk (already a | ||
| 35 | + dependency, v0.13.5) has NewClientSideConnection + the Client interface. | ||
| 36 | + - mm's ACP server only ever calls two client methods (grep internal/acp): | ||
| 37 | + SessionUpdate (message/thought/tool-call/usage chunks) and | ||
| 38 | + RequestPermission. The rest of the Client interface (fs, terminal) can be | ||
| 39 | + stubs returning "not supported", and the parent advertises no fs/terminal | ||
| 40 | + capability. | ||
| 41 | + - Flow: initialize -> session/new {cwd} -> session/prompt {text} -> collect | ||
| 42 | + agent_message_chunk text until the prompt response (stopReason) -> | ||
| 43 | + insert the answer in the current history. | ||
| 44 | + - Proof it works as a protocol: tests/tool-order/session.py and the sidekick | ||
| 45 | + web client already drive `mm -acp` from outside. | ||
| 46 | + | ||
| 47 | + Why ACP rather than the alternatives: | ||
| 48 | + - `mm -p` subprocess (one-shot): simpler, but stdout mixes the answer with | ||
| 49 | + thoughts, tool echoes, ANSI and the final command summary (fragile | ||
| 50 | + parsing); line mode never asks permission, so bob would run bash/write | ||
| 51 | + with NO gate; no cancel except kill; no usage; no follow-up turns. | ||
| 52 | + - In-process second Engine: no process, but config.Cfg is a global read | ||
| 53 | + everywhere (engine, tools, compact, store...). Two configs in one process | ||
| 54 | + means de-globalising the config first: a large refactor for this feature. | ||
| 55 | + The subprocess also isolates bob's tools, MCP servers and skills. | ||
| 56 | + - ACP gives, for free: structured events (tool calls can be shown as | ||
| 57 | + "bob: bash ls"), permission requests that can be FORWARDED to the user | ||
| 58 | + through the current front end's existing sink.Allow (line mode: no gate, | ||
| 59 | + window: inline prompt, editor: the editor's dialog), session/cancel for | ||
| 60 | + /abort and Ctrl+C, usage, and a session id -> multi-turn with bob | ||
| 61 | + (keep the child alive, reuse the session: "/ask bob and what about X?"). | ||
| 62 | + | ||
| 63 | + Proposed behaviour (to confirm): | ||
| 64 | + - One child per name, started lazily on the first /ask, kept alive for the | ||
| 65 | + parent session (start-up cost), killed on /quit and exit; a fresh ACP | ||
| 66 | + session (session/new) per /ask if 3 is confirmed. | ||
| 67 | + - The /ask exchange enters the CURRENT history as one user message, e.g. | ||
| 68 | + [bob (ollama/qwen3.5:2b) was asked: <prompt>] | ||
| 69 | + <bob's answer> | ||
| 70 | + so the current model sees it on the next question and the session file | ||
| 71 | + persists and replays it on /resume. Must keep the history valid for | ||
| 72 | + the next model call (user/model alternation: merge into the next user | ||
| 73 | + message, or a user+model pair "[noted]" — to decide in the code). | ||
| 74 | + - Bob's answer is shown while it streams, prefixed/styled as bob's | ||
| 75 | + (decided: displayed, kept for the next question). | ||
| 76 | + - Bob's thoughts: not shown (or dim), never inserted in the history. | ||
| 77 | + - Long answers: capped (maxOutput-like) before insertion — the current | ||
| 78 | + model's context pays for them. | ||
| 79 | + | ||
| 80 | + Risks / things to decide: | ||
| 81 | + - Ticket 57: a permission forwarded to the user and answered slowly will | ||
| 82 | + trip the CHILD's watchdog exactly like today in an editor. | ||
| 83 | + - Ticket 59 (yolo): which permission policy for the child — forward to the | ||
| 84 | + user (default), or auto per config entry? | ||
| 85 | + - Two agents on the same working tree: the parent waits while bob works | ||
| 86 | + (/ask is blocking), so no concurrent edits; but bob may change files the | ||
| 87 | + parent model "remembers" — say so in the inserted message? | ||
| 88 | + - Recursion: /ask is a USER command, the model cannot call it, so no | ||
| 89 | + automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth | ||
| 90 | + (env var MM_ASK_DEPTH) and refuse cycles. | ||
| 91 | + - Paths in mm-configs: relative to the PARENT config file's directory (the | ||
| 92 | + ACP mode already resolves skillsDir that way; a path relative to the cwd | ||
| 93 | + breaks when the config is elsewhere). The child's own relative paths | ||
| 94 | + (skillsDir) resolve against ITS config dir in -acp mode. | ||
| 95 | + - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+); | ||
| 96 | + hence keep the child alive. | ||
| 97 | + - Child stderr: capture it and show it only on failure (start error, | ||
| 98 | + unknown config, provider down -> one line: "bob: nothing answers at …"). | ||
| 99 | + | ||
| 100 | + Demo ready (2026-10-02): demo-llmman/agent.llmman.yaml declares | ||
| 101 | + mmConfigs: | ||
| 102 | + jan: agent.jan-nano.yaml # hf.co/Menlo/Jan-nano-gguf:Q4_K_M | ||
| 103 | + lucy: agent.lucy.yaml # hf.co/Menlo/Lucy-gguf:Q4_K_M | ||
| 104 | + (the key is ignored until implemented: config loading is not strict). Both | ||
| 105 | + configs answered a bash-tool prompt live through llmman. | ||
| 106 | + | ||
| 107 | + Decisions (user, 2026-10-02): | ||
| 108 | + 1. Key in camelCase: `mmConfigs:` (name -> path of a config file). | ||
| 109 | + 2. Bob's answer is DISPLAYED, and stays in the current history for the | ||
| 110 | + NEXT question; the current model does not answer right away. | ||
| 111 | + 3. Two commands: | ||
| 112 | + - /ask <name> <prompt>: bob receives the typed prompt only; | ||
| 113 | + - /ask-with-context <name> <prompt>: bob also receives the context of | ||
| 114 | + the current conversation. | ||
| 115 | + Interpretation to confirm: every /ask starts a FRESH bob session (no | ||
| 116 | + memory between two /ask); continuity comes from /ask-with-context. | ||
| 117 | + 4. Both commands work in the three front ends (Terminal, Window, Editor). | ||
| 118 | + 5. Context: only with /ask-with-context (see 3). | ||
| 119 | + | ||
| 120 | + Editor visibility (checked): yes. The ACP facade already sends | ||
| 121 | + `available_commands_update` built from command.For(Editor) | ||
| 122 | + (internal/acp/acp.go availableCommands), and skills are already advertised | ||
| 123 | + with `input.unstructured.hint`. Today the built-in commands are sent | ||
| 124 | + WITHOUT an input hint: /ask and /ask-with-context need one, e.g. | ||
| 125 | + "bob|riker <prompt>", built from mmConfigs. The editor completes the | ||
| 126 | + command name, not the argument; alternative to discuss: one command per | ||
| 127 | + agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents. | ||
| 128 | + The CHILD's own available_commands_update is ignored by the parent. | ||
| 129 | + | ||
| 130 | + Decided too (user, 2026-10-02): | ||
| 131 | + a. Context for /ask-with-context = a text transcript of the current | ||
| 132 | + history: user questions and assistant answers, each tool call as one | ||
| 133 | + line (name + short args), no tool outputs, no reasoning, the compaction | ||
| 134 | + summary if any; capped (chars), newest turns kept first. Sent as: | ||
| 135 | + [Conversation so far, from <parent agent name>] | ||
| 136 | + ... | ||
| 137 | + [Question] | ||
| 138 | + <prompt> | ||
| 139 | + b. Every /ask (and /ask-with-context) starts a FRESH bob session | ||
| 140 | + (session/new); no memory on bob's side between two calls. | ||
| 141 | + c. Editors: ONE COMMAND PER AGENT, generated from mmConfigs — | ||
| 142 | + /ask-bob, /ask-with-context-bob, /ask-riker, … each with an input hint | ||
| 143 | + "<prompt>". Consequences: | ||
| 144 | + - agent names become command names: validate them in config | ||
| 145 | + (lowercase letters, digits, '-'; error otherwise); | ||
| 146 | + - a skill or built-in with the same name: the existing guard in | ||
| 147 | + availableCommands (commandNamed) keeps the command and drops the skill, | ||
| 148 | + with the banner warning — extend it to the generated names; | ||
| 149 | + - terminal and window (confirmed by the user, 2026-10-02): accept the | ||
| 150 | + per-agent forms there too (same table, no drift), and keep | ||
| 151 | + `/ask <name> <prompt>` as the generic form; the window's "/" picker | ||
| 152 | + lists the per-agent commands. | ||
| 153 | + | ||
| 154 | + Proposed behaviour (to confirm): | ||
| 155 | + - One child per name, started lazily on the first /ask, kept alive for the | ||
| 156 | + parent session (start-up cost), killed on /quit and exit; a fresh ACP | ||
| 157 | + session (session/new) per /ask if 3 is confirmed. | ||
| 158 | + - The /ask exchange enters the CURRENT history as one user message, e.g. | ||
| 159 | + [bob (ollama/qwen3.5:2b) was asked: <prompt>] | ||
| 160 | + <bob's answer> | ||
| 161 | + so the current model sees it on the next question and the session file | ||
| 162 | + persists and replays it on /resume. Must keep the history valid for | ||
| 163 | + the next model call (user/model alternation: merge into the next user | ||
| 164 | + message, or a user+model pair "[noted]" — to decide in the code). | ||
| 165 | + - Bob's answer is shown while it streams, prefixed/styled as bob's | ||
| 166 | + (decided: displayed, kept for the next question). | ||
| 167 | + - Bob's thoughts: not shown (or dim), never inserted in the history. | ||
| 168 | + - Long answers: capped (maxOutput-like) before insertion — the current | ||
| 169 | + model's context pays for them. | ||
| 170 | + | ||
| 171 | + Risks / things to decide: | ||
| 172 | + - Ticket 57: a permission forwarded to the user and answered slowly will | ||
| 173 | + trip the CHILD's watchdog exactly like today in an editor. | ||
| 174 | + - Ticket 59 (yolo): which permission policy for the child — forward to the | ||
| 175 | + user (default), or auto per config entry? | ||
| 176 | + - Two agents on the same working tree: the parent waits while bob works | ||
| 177 | + (/ask is blocking), so no concurrent edits; but bob may change files the | ||
| 178 | + parent model "remembers" — say so in the inserted message? | ||
| 179 | + - Recursion: /ask is a USER command, the model cannot call it, so no | ||
| 180 | + automatic loop. If it ever becomes a tool ("ask_agent"), cap the depth | ||
| 181 | + (env var MM_ASK_DEPTH) and refuse cycles. | ||
| 182 | + - Paths in mm-configs: relative to the PARENT config file's directory (the | ||
| 183 | + ACP mode already resolves skillsDir that way; a path relative to the cwd | ||
| 184 | + breaks when the config is elsewhere). The child's own relative paths | ||
| 185 | + (skillsDir) resolve against ITS config dir in -acp mode. | ||
| 186 | + - Start-up cost: Genkit init + probe + MCP servers per child (~1 s+); | ||
| 187 | + hence keep the child alive. | ||
| 188 | + - Child stderr: capture it and show it only on failure (start error, | ||
| 189 | + unknown config, provider down -> one line: "bob: nothing answers at …"). | ||
| 190 | + | ||
| 191 | + Decisions (user, 2026-10-02): | ||
| 192 | + 1. Key in camelCase: `mmConfigs:` (name -> path of a config file). | ||
| 193 | + 2. Bob's answer is DISPLAYED, and stays in the current history for the | ||
| 194 | + NEXT question; the current model does not answer right away. | ||
| 195 | + 3. Two commands: | ||
| 196 | + - /ask <name> <prompt>: bob receives the typed prompt only; | ||
| 197 | + - /ask-with-context <name> <prompt>: bob also receives the context of | ||
| 198 | + the current conversation. | ||
| 199 | + Interpretation to confirm: every /ask starts a FRESH bob session (no | ||
| 200 | + memory between two /ask); continuity comes from /ask-with-context. | ||
| 201 | + 4. Both commands work in the three front ends (Terminal, Window, Editor). | ||
| 202 | + 5. Context: only with /ask-with-context (see 3). | ||
| 203 | + | ||
| 204 | + Editor visibility (checked): yes. The ACP facade already sends | ||
| 205 | + `available_commands_update` built from command.For(Editor) | ||
| 206 | + (internal/acp/acp.go availableCommands), and skills are already advertised | ||
| 207 | + with `input.unstructured.hint`. Today the built-in commands are sent | ||
| 208 | + WITHOUT an input hint: /ask and /ask-with-context need one, e.g. | ||
| 209 | + "bob|riker <prompt>", built from mmConfigs. The editor completes the | ||
| 210 | + command name, not the argument; alternative to discuss: one command per | ||
| 211 | + agent (/ask-bob, /ask-with-context-bob) so the picker lists the agents. | ||
| 212 | + The CHILD's own available_commands_update is ignored by the parent. | ||
| 213 | + | ||
| 214 | + Still open: | ||
| 215 | + a. What "context" is for /ask-with-context. Proposal: a text transcript of | ||
| 216 | + the current history — user questions and assistant answers, each tool | ||
| 217 | + call as one line (name + short args), no tool outputs, no reasoning, | ||
| 218 | + the compaction summary if any — capped (chars) to protect bob's | ||
| 219 | + window, newest turns kept first. Sent as the first part of the prompt: | ||
| 220 | + [Conversation so far, from <parent agent name>] | ||
| 221 | + ... | ||
| 222 | + [Question] | ||
| 223 | + <prompt> | ||
| 224 | + b. Fresh bob session per /ask (interpretation of 3) — confirm. | ||
| 225 | + c. Editor: one /ask with a hint, or one command per agent? | ||
| 226 | +tasks: | ||
| 227 | + - id: 1 | ||
| 228 | + title: "Answer the open questions (key name, immediate answer, multi-turn, fronts, context passed)" | ||
| 229 | + state: closed | ||
| 230 | + priority: none | ||
| 231 | + author: | ||
| 232 | + name: k33g | ||
| 233 | + email: ph.charriere@gmail.com | ||
| 234 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 235 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 236 | + - id: 2 | ||
| 237 | + title: "Confirm the remaining points a/b/c (context format, fresh session per /ask, editor hint vs one command per agent)" | ||
| 238 | + state: closed | ||
| 239 | + priority: none | ||
| 240 | + author: | ||
| 241 | + name: k33g | ||
| 242 | + email: ph.charriere@gmail.com | ||
| 243 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 244 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 245 | + - id: 3 | ||
| 246 | + title: "config: mmConfigs map (validation: name = [a-z0-9-]+ since it becomes a command name, file exists; paths relative to the config file dir)" | ||
| 247 | + state: closed | ||
| 248 | + priority: none | ||
| 249 | + author: | ||
| 250 | + name: k33g | ||
| 251 | + email: ph.charriere@gmail.com | ||
| 252 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 253 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 254 | + - id: 4 | ||
| 255 | + title: "internal/askagent (name TBD): ACP client over a child `mm -acp -config <path>` (os.Executable), lazy start, one child per name, shutdown" | ||
| 256 | + state: closed | ||
| 257 | + priority: none | ||
| 258 | + author: | ||
| 259 | + name: k33g | ||
| 260 | + email: ph.charriere@gmail.com | ||
| 261 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 262 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 263 | + - id: 5 | ||
| 264 | + title: "Client side: SessionUpdate -> stream text/tool-call lines to the current sink; RequestPermission -> current sink.Allow; fs/terminal stubs" | ||
| 265 | + state: closed | ||
| 266 | + priority: none | ||
| 267 | + author: | ||
| 268 | + name: k33g | ||
| 269 | + email: ph.charriere@gmail.com | ||
| 270 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 271 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 272 | + - id: 6 | ||
| 273 | + title: "command table: /ask <name> <prompt>, /ask-with-context <name> <prompt>, and per-agent /ask-<name>, /ask-with-context-<name> generated from mmConfigs (all fronts); ACP advertises the per-agent ones with hint <prompt>; window picker; name clash guard" | ||
| 274 | + state: closed | ||
| 275 | + priority: none | ||
| 276 | + author: | ||
| 277 | + name: k33g | ||
| 278 | + email: ph.charriere@gmail.com | ||
| 279 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 280 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 281 | + - id: 7 | ||
| 282 | + title: "Context builder for /ask-with-context: transcript of the current history (text + one line per tool call), capped" | ||
| 283 | + state: closed | ||
| 284 | + priority: none | ||
| 285 | + author: | ||
| 286 | + name: k33g | ||
| 287 | + email: ph.charriere@gmail.com | ||
| 288 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 289 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 290 | + - id: 8 | ||
| 291 | + title: "Insert the exchange in the current history (format, cap, valid alternation), persisted by the store and replayed on /resume" | ||
| 292 | + state: closed | ||
| 293 | + priority: none | ||
| 294 | + author: | ||
| 295 | + name: k33g | ||
| 296 | + email: ph.charriere@gmail.com | ||
| 297 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 298 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 299 | + - id: 9 | ||
| 300 | + title: "/abort, Ctrl+C and editor cancel -> session/cancel on the child; child crash -> one-line error, history untouched" | ||
| 301 | + state: closed | ||
| 302 | + priority: none | ||
| 303 | + author: | ||
| 304 | + name: k33g | ||
| 305 | + email: ph.charriere@gmail.com | ||
| 306 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 307 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 308 | + - id: 10 | ||
| 309 | + title: "Tests: fake child agent (acp-go-sdk agent side) for the client; end-to-end with two real configs (demo-ollama + demo-llmman)" | ||
| 310 | + state: closed | ||
| 311 | + priority: none | ||
| 312 | + author: | ||
| 313 | + name: k33g | ||
| 314 | + email: ph.charriere@gmail.com | ||
| 315 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 316 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 317 | + - id: 11 | ||
| 318 | + title: "Docs EN+FR: how-to ask another agent, reference (config key, commands), explanation (why ACP, why a subprocess)" | ||
| 319 | + state: closed | ||
| 320 | + priority: none | ||
| 321 | + author: | ||
| 322 | + name: k33g | ||
| 323 | + email: ph.charriere@gmail.com | ||
| 324 | + createdAt: 2026-10-02T19:30:00.000Z | ||
| 325 | + updatedAt: 2026-10-02T20:00:00.000Z | ||
| 326 | + - id: 12 | ||
| 327 | + title: "Quality gate (qlty) where available — not run: qlty absent from this sandbox" | ||
| 328 | + state: open | ||
| 329 | + priority: none | ||
| 330 | + author: | ||
| 331 | + name: k33g | ||
| 332 | + email: ph.charriere@gmail.com | ||
| 333 | + createdAt: 2026-10-02T20:30:00.000Z | ||
| 334 | + updatedAt: 2026-10-02T20:30:00.000Z | ||
| 335 | + - id: 13 | ||
| 336 | + title: "Live check of the window mode (/ask-jan in mm -tui) — unit-tested only" | ||
| 337 | + state: open | ||
| 338 | + priority: none | ||
| 339 | + author: | ||
| 340 | + name: k33g | ||
| 341 | + email: ph.charriere@gmail.com | ||
| 342 | + createdAt: 2026-10-02T20:30:00.000Z | ||
| 343 | + updatedAt: 2026-10-02T20:30:00.000Z | ||
| 344 | + - id: 14 | ||
| 345 | + title: "Review, commit, merge" | ||
| 346 | + state: open | ||
| 347 | + priority: none | ||
| 348 | + author: | ||
| 349 | + name: k33g | ||
| 350 | + email: ph.charriere@gmail.com | ||
| 351 | + createdAt: 2026-10-02T20:30:00.000Z | ||
| 352 | + updatedAt: 2026-10-02T20:30:00.000Z | ||
| 353 | +comments: | ||
| 354 | + - id: 1 | ||
| 355 | + author: | ||
| 356 | + name: claude | ||
| 357 | + email: noreply@anthropic.com | ||
| 358 | + createdAt: 2026-10-02T20:30:00.000Z | ||
| 359 | + body: | | ||
| 360 | + **Implemented** on branch `feature/talk-to-another-mm` (uncommitted, 2026-10-02). | ||
| 361 | + | ||
| 362 | + - config: `mmConfigs` (internal/config/agents.go) — names [a-z0-9-], not | ||
| 363 | + starting with `with-context`; relative paths resolved against the config | ||
| 364 | + file's dir at Load; missing file = start-up warning. `AGENT_SESSIONS=off` | ||
| 365 | + env override (applyEnv). | ||
| 366 | + - command: /ask family generated by SetAgents (internal/command/ask.go); | ||
| 367 | + `Hint` field; ParseAsk returns (req, isAsk, err). | ||
| 368 | + - internal/ask (new): Transcript/Prompt (context.go), Exchange as a | ||
| 369 | + user+model pair + Describe (exchange.go), ChildEnv/Depth/CanAsk | ||
| 370 | + (env.go, MaxDepth 2), ACP client (client.go), Pool with one lazy child | ||
| 371 | + per agent + fresh session per ask (pool.go), Pool.Run + Runner interface | ||
| 372 | + + Label + Missing (run.go). | ||
| 373 | + - Front ends: agent/ask.go (Ctrl+C, /abort, /quit), tui/ask.go (job | ||
| 374 | + "asking <agent>"), acp/ask.go (per-agent commands with `<prompt>` hint); | ||
| 375 | + main.go wiring; store.FailAsk. | ||
| 376 | + - Traps found: the SDK dispatches the child's requests concurrently with | ||
| 377 | + its notifications — a permission request can overtake its tool_call | ||
| 378 | + (client shows the call first, once); the SDK's default slog would print | ||
| 379 | + "connection closed" on the user's terminal (silenced); the parent's | ||
| 380 | + AGENT_* env would override the child's config (stripped). | ||
| 381 | + - Tests: config, command, ask (fake ACP agent = the test binary re-run, | ||
| 382 | + no network), agent, tui, acp. `go test ./...` green 3x; ask tests 15x. | ||
| 383 | + `-race` not available (no C compiler in the sandbox). | ||
| 384 | + - Live (host llmman): line mode `/ask-jan` → answer kept, the current | ||
| 385 | + model quoted it next turn; ACP `/ask-with-context-lucy` → per-agent | ||
| 386 | + commands announced, Lucy knew the name given earlier, her bash | ||
| 387 | + permission went to the client titled "lucy · bash: …"; no child | ||
| 388 | + session files, no leftover processes. | ||
| 389 | + - Docs EN+FR: how-to/ask-another-agent.md, explanation/asking-another-agent.md, | ||
| 390 | + reference (slash-commands, configuration, cli), architecture table, | ||
| 391 | + READMEs, docs/diagrams/packages.drawio (internal/ask + edges). | ||
added
demo-llmman/agent.jan-nano.yaml +199 -0 | new file mode 100644 | ||
| @@ -0,0 +1,199 @@ | ||
| 1 | +# Agent settings — Jan: mini-me on hf.co/Menlo/Jan-nano-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman) | |
| 2 | +# through its OpenAI-compatible endpoint (/v1/chat/completions). | |
| 3 | +# | |
| 4 | +# llmman pull hf.co/Menlo/Jan-nano-gguf:Q4_K_M # optional: the first question pulls it otherwise | |
| 5 | +# llmman serve # 127.0.0.1:17434 | |
| 6 | +# | |
| 7 | +# llmman loads the model on the first request (in its own llama-server) and | |
| 8 | +# unloads it after five idle minutes. | |
| 9 | +provider: llmman | |
| 10 | + | |
| 11 | +# The model id exactly as `llmman list` / GET /v1/models spells it. | |
| 12 | +model: hf.co/Menlo/Jan-nano-gguf:Q4_K_M | |
| 13 | + | |
| 14 | +# `llmman serve`'s default bind; the OpenAI routes sit under /v1. | |
| 15 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 16 | + | |
| 17 | +# From a Docker sandbox, the daemon runs on the host. | |
| 18 | +fallback: http://host.docker.internal:17434/v1 | |
| 19 | + | |
| 20 | +# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it | |
| 21 | +# listens beyond loopback); unset otherwise. The key never goes in this file. | |
| 22 | +# apiKeyEnv: LLMMAN_API_KEY | |
| 23 | + | |
| 24 | +# The `bash` tool. | |
| 25 | +# `false` removes it: the model can still read and edit files (editTools) and load skills, | |
| 26 | +# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool". | |
| 27 | +bashTool: true | |
| 28 | + | |
| 29 | +# The built-in file tools: read_file, write_file, edit_file. | |
| 30 | +editTools: true | |
| 31 | + | |
| 32 | +# The built-in plan tool for managing multi-step plans. | |
| 33 | +# Set to false to disable plan creation and tracking via the `plan` tool. | |
| 34 | +planTool: true | |
| 35 | + | |
| 36 | +# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename). | |
| 37 | +# The file extension picks the server. A server missing from the PATH is skipped with a warning. | |
| 38 | +# No server listed = no `lsp` tool. Examples (uncomment what you have installed): | |
| 39 | +# lsp: | |
| 40 | +# servers: | |
| 41 | +# - name: gopls | |
| 42 | +# command: gopls | |
| 43 | +# extensions: [".go"] | |
| 44 | +# - name: typescript | |
| 45 | +# command: typescript-language-server | |
| 46 | +# args: ["--stdio"] | |
| 47 | +# extensions: [".ts", ".tsx", ".js", ".jsx"] | |
| 48 | +# - name: python | |
| 49 | +# command: pyright-langserver | |
| 50 | +# args: ["--stdio"] | |
| 51 | +# extensions: [".py"] | |
| 52 | +# - name: rust | |
| 53 | +# command: rust-analyzer | |
| 54 | +# extensions: [".rs"] | |
| 55 | + | |
| 56 | +# How many tokens the server actually serves. | |
| 57 | +# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is | |
| 58 | +# learned before the second question. Set it here if you want it from the start | |
| 59 | +# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable. | |
| 60 | +contextWindow: 0 | |
| 61 | + | |
| 62 | +# Max number of characters a tool returns to the model. | |
| 63 | +# Context safeguard: beyond that, the output is truncated (beginning + end kept). | |
| 64 | +maxOutput: 16000 | |
| 65 | + | |
| 66 | +# Max number of model <-> tools round trips for a single question. | |
| 67 | +# Raise it for tasks that need long chains of commands. | |
| 68 | +maxTurns: 40 | |
| 69 | + | |
| 70 | +# Where the `read_skill` tool looks for its markdown procedures. | |
| 71 | +# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/. | |
| 72 | +skillsDir: ../skills | |
| 73 | + | |
| 74 | +# Persistent sessions, per project. | |
| 75 | +# When enabled, every conversation is saved as one JSON file under `dir`. | |
| 76 | +# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread. | |
| 77 | +sessions: | |
| 78 | + enabled: true | |
| 79 | + dir: .mm/sessions | |
| 80 | + | |
| 81 | +# How many lines of a command's output are echoed to the terminal. | |
| 82 | +# 0 disables it. | |
| 83 | +previewLines: 20 | |
| 84 | + | |
| 85 | +displayCommands: true | |
| 86 | +# Show what a thinking model reasons about before it answers (dim italic in the | |
| 87 | +# terminal, its own style in the window, a thought chunk in an editor). | |
| 88 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | |
| 89 | +# model thinks — this only says whether to show it. | |
| 90 | +showThinking: true | |
| 91 | + | |
| 92 | + | |
| 93 | +# The system prompt: what the agent is, and what it is allowed to do. | |
| 94 | +# This is the knob to play with — it is the shortest path to changing behaviour. | |
| 95 | +system: | | |
| 96 | + Your name is Jan. | |
| 97 | + You are a coding agent working in a terminal. | |
| 98 | + You have a "bash" tool to run shell commands. | |
| 99 | + Use it to explore files, run tests, inspect the repository, etc. | |
| 100 | + Chain several commands if needed, then answer clearly in English. | |
| 101 | + | |
| 102 | + A request often mixes things you answer from yourself ("say hello") with | |
| 103 | + things only a command can answer ("list the files"). Handle every part, in | |
| 104 | + the order asked, and run a command for each part that needs one. | |
| 105 | + Never state the contents of a file, the output of a command, or the state of | |
| 106 | + the repository unless a command in THIS answer returned it. What you did not | |
| 107 | + read, you do not know: run the command instead of recalling it. | |
| 108 | + | |
| 109 | + SKILLS | |
| 110 | + You have a second tool, `read_skill`. Its description lists the procedures | |
| 111 | + available for this project — one per kind of task. | |
| 112 | + | |
| 113 | + Any request to DO something to a Go project is a skill, not a shell command | |
| 114 | + you invent. Match the request against that list, call `read_skill` FIRST, | |
| 115 | + before any bash command, and then follow what it says step by step. | |
| 116 | + | |
| 117 | + FILE EDITING | |
| 118 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | |
| 119 | + They are how a file gets read and changed here: each change is exact, | |
| 120 | + checked before it is written, and comes back as a diff with line numbers. | |
| 121 | + bash is for running things — building, testing, listing, searching. | |
| 122 | + | |
| 123 | + - Read before you write: call `read_file` on the file (numbered=true when | |
| 124 | + you need line numbers). You cannot target text you have not seen; never | |
| 125 | + rely on what you think you remember about a file. | |
| 126 | + - To change an existing file, call `edit_file` with one or more {old, new} | |
| 127 | + pairs. `old` is copied from the file character for character — same | |
| 128 | + spaces, same indentation, same line breaks — and appears exactly once: | |
| 129 | + add the surrounding lines until it is unique. Several pairs are applied | |
| 130 | + together, against the original file. An empty `new` deletes the text. | |
| 131 | + - Call `write_file` only to create a file, or to rewrite one entirely and | |
| 132 | + on purpose. On an existing file it replaces everything, including what | |
| 133 | + you did not intend to touch. | |
| 134 | + - Read the diff the tool returns: it says exactly what changed and on which | |
| 135 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | |
| 136 | + edits — read the file again and fix `old`. Do not fall back to | |
| 137 | + `write_file` to force the change through. | |
| 138 | + - After editing code, run the narrowest check with bash: the formatter, the | |
| 139 | + compiler, or the test covering that file. | |
| 140 | + | |
| 141 | + RULES | |
| 142 | + - Keep everything the file already does, unless the user asked to remove it. | |
| 143 | + - Touch only the files the request is about. Do not add tests, files or | |
| 144 | + features that were not asked for. | |
| 145 | + - Never run a git command unless the user says git, commit or push. | |
| 146 | + - Never move, rename or delete a file unless the user asked for it. | |
| 147 | + - Then answer in English, in a few lines. | |
| 148 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | |
| 149 | + to understand the options, then run the command. | |
| 150 | + | |
| 151 | + BACKGROUND JOBS | |
| 152 | + Never let a command block the answer. Anything that serves, watches or runs | |
| 153 | + long goes to the background, with BOTH streams redirected and its pid kept: | |
| 154 | + | |
| 155 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | |
| 156 | + | |
| 157 | + Redirecting only stdout still blocks until the process exits. Read the | |
| 158 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | |
| 159 | + rule you cannot guess. Stop every job you started before you finish, and say | |
| 160 | + which ones you left running. | |
| 161 | + | |
| 162 | +# Generation settings (OpenAI API keys). Low temperature for a coding agent: | |
| 163 | +# we want precise and reproducible answers, not creativity. | |
| 164 | +sampling: | |
| 165 | + temperature: 0.0 | |
| 166 | + parallel_tool_calls: false | |
| 167 | + top_p: 0.9 | |
| 168 | + max_tokens: 4096 | |
| 169 | + | |
| 170 | +# Silence allowed between two tokens once the answer has started. | |
| 171 | +watchdogTimeout: 30s | |
| 172 | + | |
| 173 | +# Silence allowed while the model reads the prompt, before the first token: | |
| 174 | +# on a laptop a 25k-token prompt can take minutes. llama-server reports its | |
| 175 | +# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report | |
| 176 | +# restarts this clock, so it only has to cover one batch of the prompt. | |
| 177 | +firstTokenTimeout: 3m | |
| 178 | + | |
| 179 | +# Context compression. | |
| 180 | +# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to. | |
| 181 | +context: | |
| 182 | + enabled: true | |
| 183 | + | |
| 184 | + # Compress when the history reaches this share of the window. | |
| 185 | + threshold: 75 | |
| 186 | + | |
| 187 | + # Fallback on the message count, for when the window is unknown. | |
| 188 | + # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history. | |
| 189 | + maxMessages: 80 | |
| 190 | + | |
| 191 | + # How many recent questions (with their commands and answers) are kept as-is instead of being summarised. | |
| 192 | + # The model works on the last few turns, so summarising them would make it redo what it just did. | |
| 193 | + keepLastTurns: 3 | |
| 194 | + | |
| 195 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider | |
| 196 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | |
| 197 | + summaryMaxTokens: 1200 | |
| 198 | + | |
| 199 | + showStats: true | |
| new file mode 100644 | |||
| @@ -0,0 +1,199 @@ | |||
| 1 | +# Agent settings — Jan: mini-me on hf.co/Menlo/Jan-nano-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman) | ||
| 2 | +# through its OpenAI-compatible endpoint (/v1/chat/completions). | ||
| 3 | +# | ||
| 4 | +# llmman pull hf.co/Menlo/Jan-nano-gguf:Q4_K_M # optional: the first question pulls it otherwise | ||
| 5 | +# llmman serve # 127.0.0.1:17434 | ||
| 6 | +# | ||
| 7 | +# llmman loads the model on the first request (in its own llama-server) and | ||
| 8 | +# unloads it after five idle minutes. | ||
| 9 | +provider: llmman | ||
| 10 | + | ||
| 11 | +# The model id exactly as `llmman list` / GET /v1/models spells it. | ||
| 12 | +model: hf.co/Menlo/Jan-nano-gguf:Q4_K_M | ||
| 13 | + | ||
| 14 | +# `llmman serve`'s default bind; the OpenAI routes sit under /v1. | ||
| 15 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 16 | + | ||
| 17 | +# From a Docker sandbox, the daemon runs on the host. | ||
| 18 | +fallback: http://host.docker.internal:17434/v1 | ||
| 19 | + | ||
| 20 | +# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it | ||
| 21 | +# listens beyond loopback); unset otherwise. The key never goes in this file. | ||
| 22 | +# apiKeyEnv: LLMMAN_API_KEY | ||
| 23 | + | ||
| 24 | +# The `bash` tool. | ||
| 25 | +# `false` removes it: the model can still read and edit files (editTools) and load skills, | ||
| 26 | +# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool". | ||
| 27 | +bashTool: true | ||
| 28 | + | ||
| 29 | +# The built-in file tools: read_file, write_file, edit_file. | ||
| 30 | +editTools: true | ||
| 31 | + | ||
| 32 | +# The built-in plan tool for managing multi-step plans. | ||
| 33 | +# Set to false to disable plan creation and tracking via the `plan` tool. | ||
| 34 | +planTool: true | ||
| 35 | + | ||
| 36 | +# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename). | ||
| 37 | +# The file extension picks the server. A server missing from the PATH is skipped with a warning. | ||
| 38 | +# No server listed = no `lsp` tool. Examples (uncomment what you have installed): | ||
| 39 | +# lsp: | ||
| 40 | +# servers: | ||
| 41 | +# - name: gopls | ||
| 42 | +# command: gopls | ||
| 43 | +# extensions: [".go"] | ||
| 44 | +# - name: typescript | ||
| 45 | +# command: typescript-language-server | ||
| 46 | +# args: ["--stdio"] | ||
| 47 | +# extensions: [".ts", ".tsx", ".js", ".jsx"] | ||
| 48 | +# - name: python | ||
| 49 | +# command: pyright-langserver | ||
| 50 | +# args: ["--stdio"] | ||
| 51 | +# extensions: [".py"] | ||
| 52 | +# - name: rust | ||
| 53 | +# command: rust-analyzer | ||
| 54 | +# extensions: [".rs"] | ||
| 55 | + | ||
| 56 | +# How many tokens the server actually serves. | ||
| 57 | +# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is | ||
| 58 | +# learned before the second question. Set it here if you want it from the start | ||
| 59 | +# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable. | ||
| 60 | +contextWindow: 0 | ||
| 61 | + | ||
| 62 | +# Max number of characters a tool returns to the model. | ||
| 63 | +# Context safeguard: beyond that, the output is truncated (beginning + end kept). | ||
| 64 | +maxOutput: 16000 | ||
| 65 | + | ||
| 66 | +# Max number of model <-> tools round trips for a single question. | ||
| 67 | +# Raise it for tasks that need long chains of commands. | ||
| 68 | +maxTurns: 40 | ||
| 69 | + | ||
| 70 | +# Where the `read_skill` tool looks for its markdown procedures. | ||
| 71 | +# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/. | ||
| 72 | +skillsDir: ../skills | ||
| 73 | + | ||
| 74 | +# Persistent sessions, per project. | ||
| 75 | +# When enabled, every conversation is saved as one JSON file under `dir`. | ||
| 76 | +# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread. | ||
| 77 | +sessions: | ||
| 78 | + enabled: true | ||
| 79 | + dir: .mm/sessions | ||
| 80 | + | ||
| 81 | +# How many lines of a command's output are echoed to the terminal. | ||
| 82 | +# 0 disables it. | ||
| 83 | +previewLines: 20 | ||
| 84 | + | ||
| 85 | +displayCommands: true | ||
| 86 | +# Show what a thinking model reasons about before it answers (dim italic in the | ||
| 87 | +# terminal, its own style in the window, a thought chunk in an editor). | ||
| 88 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | ||
| 89 | +# model thinks — this only says whether to show it. | ||
| 90 | +showThinking: true | ||
| 91 | + | ||
| 92 | + | ||
| 93 | +# The system prompt: what the agent is, and what it is allowed to do. | ||
| 94 | +# This is the knob to play with — it is the shortest path to changing behaviour. | ||
| 95 | +system: | | ||
| 96 | + Your name is Jan. | ||
| 97 | + You are a coding agent working in a terminal. | ||
| 98 | + You have a "bash" tool to run shell commands. | ||
| 99 | + Use it to explore files, run tests, inspect the repository, etc. | ||
| 100 | + Chain several commands if needed, then answer clearly in English. | ||
| 101 | + | ||
| 102 | + A request often mixes things you answer from yourself ("say hello") with | ||
| 103 | + things only a command can answer ("list the files"). Handle every part, in | ||
| 104 | + the order asked, and run a command for each part that needs one. | ||
| 105 | + Never state the contents of a file, the output of a command, or the state of | ||
| 106 | + the repository unless a command in THIS answer returned it. What you did not | ||
| 107 | + read, you do not know: run the command instead of recalling it. | ||
| 108 | + | ||
| 109 | + SKILLS | ||
| 110 | + You have a second tool, `read_skill`. Its description lists the procedures | ||
| 111 | + available for this project — one per kind of task. | ||
| 112 | + | ||
| 113 | + Any request to DO something to a Go project is a skill, not a shell command | ||
| 114 | + you invent. Match the request against that list, call `read_skill` FIRST, | ||
| 115 | + before any bash command, and then follow what it says step by step. | ||
| 116 | + | ||
| 117 | + FILE EDITING | ||
| 118 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | ||
| 119 | + They are how a file gets read and changed here: each change is exact, | ||
| 120 | + checked before it is written, and comes back as a diff with line numbers. | ||
| 121 | + bash is for running things — building, testing, listing, searching. | ||
| 122 | + | ||
| 123 | + - Read before you write: call `read_file` on the file (numbered=true when | ||
| 124 | + you need line numbers). You cannot target text you have not seen; never | ||
| 125 | + rely on what you think you remember about a file. | ||
| 126 | + - To change an existing file, call `edit_file` with one or more {old, new} | ||
| 127 | + pairs. `old` is copied from the file character for character — same | ||
| 128 | + spaces, same indentation, same line breaks — and appears exactly once: | ||
| 129 | + add the surrounding lines until it is unique. Several pairs are applied | ||
| 130 | + together, against the original file. An empty `new` deletes the text. | ||
| 131 | + - Call `write_file` only to create a file, or to rewrite one entirely and | ||
| 132 | + on purpose. On an existing file it replaces everything, including what | ||
| 133 | + you did not intend to touch. | ||
| 134 | + - Read the diff the tool returns: it says exactly what changed and on which | ||
| 135 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | ||
| 136 | + edits — read the file again and fix `old`. Do not fall back to | ||
| 137 | + `write_file` to force the change through. | ||
| 138 | + - After editing code, run the narrowest check with bash: the formatter, the | ||
| 139 | + compiler, or the test covering that file. | ||
| 140 | + | ||
| 141 | + RULES | ||
| 142 | + - Keep everything the file already does, unless the user asked to remove it. | ||
| 143 | + - Touch only the files the request is about. Do not add tests, files or | ||
| 144 | + features that were not asked for. | ||
| 145 | + - Never run a git command unless the user says git, commit or push. | ||
| 146 | + - Never move, rename or delete a file unless the user asked for it. | ||
| 147 | + - Then answer in English, in a few lines. | ||
| 148 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | ||
| 149 | + to understand the options, then run the command. | ||
| 150 | + | ||
| 151 | + BACKGROUND JOBS | ||
| 152 | + Never let a command block the answer. Anything that serves, watches or runs | ||
| 153 | + long goes to the background, with BOTH streams redirected and its pid kept: | ||
| 154 | + | ||
| 155 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | ||
| 156 | + | ||
| 157 | + Redirecting only stdout still blocks until the process exits. Read the | ||
| 158 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | ||
| 159 | + rule you cannot guess. Stop every job you started before you finish, and say | ||
| 160 | + which ones you left running. | ||
| 161 | + | ||
| 162 | +# Generation settings (OpenAI API keys). Low temperature for a coding agent: | ||
| 163 | +# we want precise and reproducible answers, not creativity. | ||
| 164 | +sampling: | ||
| 165 | + temperature: 0.0 | ||
| 166 | + parallel_tool_calls: false | ||
| 167 | + top_p: 0.9 | ||
| 168 | + max_tokens: 4096 | ||
| 169 | + | ||
| 170 | +# Silence allowed between two tokens once the answer has started. | ||
| 171 | +watchdogTimeout: 30s | ||
| 172 | + | ||
| 173 | +# Silence allowed while the model reads the prompt, before the first token: | ||
| 174 | +# on a laptop a 25k-token prompt can take minutes. llama-server reports its | ||
| 175 | +# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report | ||
| 176 | +# restarts this clock, so it only has to cover one batch of the prompt. | ||
| 177 | +firstTokenTimeout: 3m | ||
| 178 | + | ||
| 179 | +# Context compression. | ||
| 180 | +# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to. | ||
| 181 | +context: | ||
| 182 | + enabled: true | ||
| 183 | + | ||
| 184 | + # Compress when the history reaches this share of the window. | ||
| 185 | + threshold: 75 | ||
| 186 | + | ||
| 187 | + # Fallback on the message count, for when the window is unknown. | ||
| 188 | + # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history. | ||
| 189 | + maxMessages: 80 | ||
| 190 | + | ||
| 191 | + # How many recent questions (with their commands and answers) are kept as-is instead of being summarised. | ||
| 192 | + # The model works on the last few turns, so summarising them would make it redo what it just did. | ||
| 193 | + keepLastTurns: 3 | ||
| 194 | + | ||
| 195 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider | ||
| 196 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | ||
| 197 | + summaryMaxTokens: 1200 | ||
| 198 | + | ||
| 199 | + showStats: true | ||
modified
demo-llmman/agent.llmman.yaml +9 -0 | @@ -17,6 +17,15 @@ baseUrl: http://127.0.0.1:17434/v1 | ||
| 17 | 17 | # From a Docker sandbox, the daemon runs on the host. |
| 18 | 18 | fallback: http://host.docker.internal:17434/v1 |
| 19 | 19 | |
| 20 | +# Other mm agents this one can ask. Each name becomes /ask-<name> and | |
| 21 | +# /ask-with-context-<name> (also `/ask <name> <prompt>` in the terminal and the | |
| 22 | +# window); the answer is shown and kept in the history for the next question. | |
| 23 | +# Each agent runs as a child `mm -acp` started on its first /ask. Paths are | |
| 24 | +# relative to THIS file's directory. Names: lowercase letters, digits and '-'. | |
| 25 | +mmConfigs: | |
| 26 | + jan: agent.jan-nano.yaml | |
| 27 | + lucy: agent.lucy.yaml | |
| 28 | + | |
| 20 | 29 | # Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it |
| 21 | 30 | # listens beyond loopback); unset otherwise. The key never goes in this file. |
| 22 | 31 | # apiKeyEnv: LLMMAN_API_KEY |
| @@ -17,6 +17,15 @@ baseUrl: http://127.0.0.1:17434/v1 | |||
| 17 | # From a Docker sandbox, the daemon runs on the host. | 17 | # From a Docker sandbox, the daemon runs on the host. |
| 18 | fallback: http://host.docker.internal:17434/v1 | 18 | fallback: http://host.docker.internal:17434/v1 |
| 19 | 19 | ||
| 20 | +# Other mm agents this one can ask. Each name becomes /ask-<name> and | ||
| 21 | +# /ask-with-context-<name> (also `/ask <name> <prompt>` in the terminal and the | ||
| 22 | +# window); the answer is shown and kept in the history for the next question. | ||
| 23 | +# Each agent runs as a child `mm -acp` started on its first /ask. Paths are | ||
| 24 | +# relative to THIS file's directory. Names: lowercase letters, digits and '-'. | ||
| 25 | +mmConfigs: | ||
| 26 | + jan: agent.jan-nano.yaml | ||
| 27 | + lucy: agent.lucy.yaml | ||
| 28 | + | ||
| 20 | # Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it | 29 | # Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it |
| 21 | # listens beyond loopback); unset otherwise. The key never goes in this file. | 30 | # listens beyond loopback); unset otherwise. The key never goes in this file. |
| 22 | # apiKeyEnv: LLMMAN_API_KEY | 31 | # apiKeyEnv: LLMMAN_API_KEY |
added
demo-llmman/agent.lucy.yaml +199 -0 | new file mode 100644 | ||
| @@ -0,0 +1,199 @@ | ||
| 1 | +# Agent settings — Lucy: mini-me on hf.co/Menlo/Lucy-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman) | |
| 2 | +# through its OpenAI-compatible endpoint (/v1/chat/completions). | |
| 3 | +# | |
| 4 | +# llmman pull hf.co/Menlo/Lucy-gguf:Q4_K_M # optional: the first question pulls it otherwise | |
| 5 | +# llmman serve # 127.0.0.1:17434 | |
| 6 | +# | |
| 7 | +# llmman loads the model on the first request (in its own llama-server) and | |
| 8 | +# unloads it after five idle minutes. | |
| 9 | +provider: llmman | |
| 10 | + | |
| 11 | +# The model id exactly as `llmman list` / GET /v1/models spells it. | |
| 12 | +model: hf.co/Menlo/Lucy-gguf:Q4_K_M | |
| 13 | + | |
| 14 | +# `llmman serve`'s default bind; the OpenAI routes sit under /v1. | |
| 15 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 16 | + | |
| 17 | +# From a Docker sandbox, the daemon runs on the host. | |
| 18 | +fallback: http://host.docker.internal:17434/v1 | |
| 19 | + | |
| 20 | +# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it | |
| 21 | +# listens beyond loopback); unset otherwise. The key never goes in this file. | |
| 22 | +# apiKeyEnv: LLMMAN_API_KEY | |
| 23 | + | |
| 24 | +# The `bash` tool. | |
| 25 | +# `false` removes it: the model can still read and edit files (editTools) and load skills, | |
| 26 | +# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool". | |
| 27 | +bashTool: true | |
| 28 | + | |
| 29 | +# The built-in file tools: read_file, write_file, edit_file. | |
| 30 | +editTools: true | |
| 31 | + | |
| 32 | +# The built-in plan tool for managing multi-step plans. | |
| 33 | +# Set to false to disable plan creation and tracking via the `plan` tool. | |
| 34 | +planTool: true | |
| 35 | + | |
| 36 | +# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename). | |
| 37 | +# The file extension picks the server. A server missing from the PATH is skipped with a warning. | |
| 38 | +# No server listed = no `lsp` tool. Examples (uncomment what you have installed): | |
| 39 | +# lsp: | |
| 40 | +# servers: | |
| 41 | +# - name: gopls | |
| 42 | +# command: gopls | |
| 43 | +# extensions: [".go"] | |
| 44 | +# - name: typescript | |
| 45 | +# command: typescript-language-server | |
| 46 | +# args: ["--stdio"] | |
| 47 | +# extensions: [".ts", ".tsx", ".js", ".jsx"] | |
| 48 | +# - name: python | |
| 49 | +# command: pyright-langserver | |
| 50 | +# args: ["--stdio"] | |
| 51 | +# extensions: [".py"] | |
| 52 | +# - name: rust | |
| 53 | +# command: rust-analyzer | |
| 54 | +# extensions: [".rs"] | |
| 55 | + | |
| 56 | +# How many tokens the server actually serves. | |
| 57 | +# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is | |
| 58 | +# learned before the second question. Set it here if you want it from the start | |
| 59 | +# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable. | |
| 60 | +contextWindow: 0 | |
| 61 | + | |
| 62 | +# Max number of characters a tool returns to the model. | |
| 63 | +# Context safeguard: beyond that, the output is truncated (beginning + end kept). | |
| 64 | +maxOutput: 16000 | |
| 65 | + | |
| 66 | +# Max number of model <-> tools round trips for a single question. | |
| 67 | +# Raise it for tasks that need long chains of commands. | |
| 68 | +maxTurns: 40 | |
| 69 | + | |
| 70 | +# Where the `read_skill` tool looks for its markdown procedures. | |
| 71 | +# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/. | |
| 72 | +skillsDir: ../skills | |
| 73 | + | |
| 74 | +# Persistent sessions, per project. | |
| 75 | +# When enabled, every conversation is saved as one JSON file under `dir`. | |
| 76 | +# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread. | |
| 77 | +sessions: | |
| 78 | + enabled: true | |
| 79 | + dir: .mm/sessions | |
| 80 | + | |
| 81 | +# How many lines of a command's output are echoed to the terminal. | |
| 82 | +# 0 disables it. | |
| 83 | +previewLines: 20 | |
| 84 | + | |
| 85 | +displayCommands: true | |
| 86 | +# Show what a thinking model reasons about before it answers (dim italic in the | |
| 87 | +# terminal, its own style in the window, a thought chunk in an editor). | |
| 88 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | |
| 89 | +# model thinks — this only says whether to show it. | |
| 90 | +showThinking: true | |
| 91 | + | |
| 92 | + | |
| 93 | +# The system prompt: what the agent is, and what it is allowed to do. | |
| 94 | +# This is the knob to play with — it is the shortest path to changing behaviour. | |
| 95 | +system: | | |
| 96 | + Your name is Lucy. | |
| 97 | + You are a coding agent working in a terminal. | |
| 98 | + You have a "bash" tool to run shell commands. | |
| 99 | + Use it to explore files, run tests, inspect the repository, etc. | |
| 100 | + Chain several commands if needed, then answer clearly in English. | |
| 101 | + | |
| 102 | + A request often mixes things you answer from yourself ("say hello") with | |
| 103 | + things only a command can answer ("list the files"). Handle every part, in | |
| 104 | + the order asked, and run a command for each part that needs one. | |
| 105 | + Never state the contents of a file, the output of a command, or the state of | |
| 106 | + the repository unless a command in THIS answer returned it. What you did not | |
| 107 | + read, you do not know: run the command instead of recalling it. | |
| 108 | + | |
| 109 | + SKILLS | |
| 110 | + You have a second tool, `read_skill`. Its description lists the procedures | |
| 111 | + available for this project — one per kind of task. | |
| 112 | + | |
| 113 | + Any request to DO something to a Go project is a skill, not a shell command | |
| 114 | + you invent. Match the request against that list, call `read_skill` FIRST, | |
| 115 | + before any bash command, and then follow what it says step by step. | |
| 116 | + | |
| 117 | + FILE EDITING | |
| 118 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | |
| 119 | + They are how a file gets read and changed here: each change is exact, | |
| 120 | + checked before it is written, and comes back as a diff with line numbers. | |
| 121 | + bash is for running things — building, testing, listing, searching. | |
| 122 | + | |
| 123 | + - Read before you write: call `read_file` on the file (numbered=true when | |
| 124 | + you need line numbers). You cannot target text you have not seen; never | |
| 125 | + rely on what you think you remember about a file. | |
| 126 | + - To change an existing file, call `edit_file` with one or more {old, new} | |
| 127 | + pairs. `old` is copied from the file character for character — same | |
| 128 | + spaces, same indentation, same line breaks — and appears exactly once: | |
| 129 | + add the surrounding lines until it is unique. Several pairs are applied | |
| 130 | + together, against the original file. An empty `new` deletes the text. | |
| 131 | + - Call `write_file` only to create a file, or to rewrite one entirely and | |
| 132 | + on purpose. On an existing file it replaces everything, including what | |
| 133 | + you did not intend to touch. | |
| 134 | + - Read the diff the tool returns: it says exactly what changed and on which | |
| 135 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | |
| 136 | + edits — read the file again and fix `old`. Do not fall back to | |
| 137 | + `write_file` to force the change through. | |
| 138 | + - After editing code, run the narrowest check with bash: the formatter, the | |
| 139 | + compiler, or the test covering that file. | |
| 140 | + | |
| 141 | + RULES | |
| 142 | + - Keep everything the file already does, unless the user asked to remove it. | |
| 143 | + - Touch only the files the request is about. Do not add tests, files or | |
| 144 | + features that were not asked for. | |
| 145 | + - Never run a git command unless the user says git, commit or push. | |
| 146 | + - Never move, rename or delete a file unless the user asked for it. | |
| 147 | + - Then answer in English, in a few lines. | |
| 148 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | |
| 149 | + to understand the options, then run the command. | |
| 150 | + | |
| 151 | + BACKGROUND JOBS | |
| 152 | + Never let a command block the answer. Anything that serves, watches or runs | |
| 153 | + long goes to the background, with BOTH streams redirected and its pid kept: | |
| 154 | + | |
| 155 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | |
| 156 | + | |
| 157 | + Redirecting only stdout still blocks until the process exits. Read the | |
| 158 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | |
| 159 | + rule you cannot guess. Stop every job you started before you finish, and say | |
| 160 | + which ones you left running. | |
| 161 | + | |
| 162 | +# Generation settings (OpenAI API keys). Low temperature for a coding agent: | |
| 163 | +# we want precise and reproducible answers, not creativity. | |
| 164 | +sampling: | |
| 165 | + temperature: 0.0 | |
| 166 | + parallel_tool_calls: false | |
| 167 | + top_p: 0.9 | |
| 168 | + max_tokens: 4096 | |
| 169 | + | |
| 170 | +# Silence allowed between two tokens once the answer has started. | |
| 171 | +watchdogTimeout: 30s | |
| 172 | + | |
| 173 | +# Silence allowed while the model reads the prompt, before the first token: | |
| 174 | +# on a laptop a 25k-token prompt can take minutes. llama-server reports its | |
| 175 | +# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report | |
| 176 | +# restarts this clock, so it only has to cover one batch of the prompt. | |
| 177 | +firstTokenTimeout: 3m | |
| 178 | + | |
| 179 | +# Context compression. | |
| 180 | +# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to. | |
| 181 | +context: | |
| 182 | + enabled: true | |
| 183 | + | |
| 184 | + # Compress when the history reaches this share of the window. | |
| 185 | + threshold: 75 | |
| 186 | + | |
| 187 | + # Fallback on the message count, for when the window is unknown. | |
| 188 | + # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history. | |
| 189 | + maxMessages: 80 | |
| 190 | + | |
| 191 | + # How many recent questions (with their commands and answers) are kept as-is instead of being summarised. | |
| 192 | + # The model works on the last few turns, so summarising them would make it redo what it just did. | |
| 193 | + keepLastTurns: 3 | |
| 194 | + | |
| 195 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider | |
| 196 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | |
| 197 | + summaryMaxTokens: 1200 | |
| 198 | + | |
| 199 | + showStats: true | |
| new file mode 100644 | |||
| @@ -0,0 +1,199 @@ | |||
| 1 | +# Agent settings — Lucy: mini-me on hf.co/Menlo/Lucy-gguf:Q4_K_M, served by llmman (https://github.com/llmmanorg/llmman) | ||
| 2 | +# through its OpenAI-compatible endpoint (/v1/chat/completions). | ||
| 3 | +# | ||
| 4 | +# llmman pull hf.co/Menlo/Lucy-gguf:Q4_K_M # optional: the first question pulls it otherwise | ||
| 5 | +# llmman serve # 127.0.0.1:17434 | ||
| 6 | +# | ||
| 7 | +# llmman loads the model on the first request (in its own llama-server) and | ||
| 8 | +# unloads it after five idle minutes. | ||
| 9 | +provider: llmman | ||
| 10 | + | ||
| 11 | +# The model id exactly as `llmman list` / GET /v1/models spells it. | ||
| 12 | +model: hf.co/Menlo/Lucy-gguf:Q4_K_M | ||
| 13 | + | ||
| 14 | +# `llmman serve`'s default bind; the OpenAI routes sit under /v1. | ||
| 15 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 16 | + | ||
| 17 | +# From a Docker sandbox, the daemon runs on the host. | ||
| 18 | +fallback: http://host.docker.internal:17434/v1 | ||
| 19 | + | ||
| 20 | +# Only when the daemon was started with LLMMAN_API_KEYS (mandatory when it | ||
| 21 | +# listens beyond loopback); unset otherwise. The key never goes in this file. | ||
| 22 | +# apiKeyEnv: LLMMAN_API_KEY | ||
| 23 | + | ||
| 24 | +# The `bash` tool. | ||
| 25 | +# `false` removes it: the model can still read and edit files (editTools) and load skills, | ||
| 26 | +# but never runs a command. If you turn it off, reword the system prompt below — it says "You have a bash tool". | ||
| 27 | +bashTool: true | ||
| 28 | + | ||
| 29 | +# The built-in file tools: read_file, write_file, edit_file. | ||
| 30 | +editTools: true | ||
| 31 | + | ||
| 32 | +# The built-in plan tool for managing multi-step plans. | ||
| 33 | +# Set to false to disable plan creation and tracking via the `plan` tool. | ||
| 34 | +planTool: true | ||
| 35 | + | ||
| 36 | +# Language servers for the `lsp` tool (definition, references, hover, symbols, diagnostics, rename). | ||
| 37 | +# The file extension picks the server. A server missing from the PATH is skipped with a warning. | ||
| 38 | +# No server listed = no `lsp` tool. Examples (uncomment what you have installed): | ||
| 39 | +# lsp: | ||
| 40 | +# servers: | ||
| 41 | +# - name: gopls | ||
| 42 | +# command: gopls | ||
| 43 | +# extensions: [".go"] | ||
| 44 | +# - name: typescript | ||
| 45 | +# command: typescript-language-server | ||
| 46 | +# args: ["--stdio"] | ||
| 47 | +# extensions: [".ts", ".tsx", ".js", ".jsx"] | ||
| 48 | +# - name: python | ||
| 49 | +# command: pyright-langserver | ||
| 50 | +# args: ["--stdio"] | ||
| 51 | +# extensions: [".py"] | ||
| 52 | +# - name: rust | ||
| 53 | +# command: rust-analyzer | ||
| 54 | +# extensions: [".rs"] | ||
| 55 | + | ||
| 56 | +# How many tokens the server actually serves. | ||
| 57 | +# 0 = ask llmman: /api/ps tells it once the model is loaded, so the window is | ||
| 58 | +# learned before the second question. Set it here if you want it from the start | ||
| 59 | +# — and to the same value as LLMMAN_CONTEXT_LENGTH if you set that variable. | ||
| 60 | +contextWindow: 0 | ||
| 61 | + | ||
| 62 | +# Max number of characters a tool returns to the model. | ||
| 63 | +# Context safeguard: beyond that, the output is truncated (beginning + end kept). | ||
| 64 | +maxOutput: 16000 | ||
| 65 | + | ||
| 66 | +# Max number of model <-> tools round trips for a single question. | ||
| 67 | +# Raise it for tasks that need long chains of commands. | ||
| 68 | +maxTurns: 40 | ||
| 69 | + | ||
| 70 | +# Where the `read_skill` tool looks for its markdown procedures. | ||
| 71 | +# The path is relative to the CURRENT DIRECTORY: run the agent from demo-llmman/. | ||
| 72 | +skillsDir: ../skills | ||
| 73 | + | ||
| 74 | +# Persistent sessions, per project. | ||
| 75 | +# When enabled, every conversation is saved as one JSON file under `dir`. | ||
| 76 | +# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread. | ||
| 77 | +sessions: | ||
| 78 | + enabled: true | ||
| 79 | + dir: .mm/sessions | ||
| 80 | + | ||
| 81 | +# How many lines of a command's output are echoed to the terminal. | ||
| 82 | +# 0 disables it. | ||
| 83 | +previewLines: 20 | ||
| 84 | + | ||
| 85 | +displayCommands: true | ||
| 86 | +# Show what a thinking model reasons about before it answers (dim italic in the | ||
| 87 | +# terminal, its own style in the window, a thought chunk in an editor). | ||
| 88 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | ||
| 89 | +# model thinks — this only says whether to show it. | ||
| 90 | +showThinking: true | ||
| 91 | + | ||
| 92 | + | ||
| 93 | +# The system prompt: what the agent is, and what it is allowed to do. | ||
| 94 | +# This is the knob to play with — it is the shortest path to changing behaviour. | ||
| 95 | +system: | | ||
| 96 | + Your name is Lucy. | ||
| 97 | + You are a coding agent working in a terminal. | ||
| 98 | + You have a "bash" tool to run shell commands. | ||
| 99 | + Use it to explore files, run tests, inspect the repository, etc. | ||
| 100 | + Chain several commands if needed, then answer clearly in English. | ||
| 101 | + | ||
| 102 | + A request often mixes things you answer from yourself ("say hello") with | ||
| 103 | + things only a command can answer ("list the files"). Handle every part, in | ||
| 104 | + the order asked, and run a command for each part that needs one. | ||
| 105 | + Never state the contents of a file, the output of a command, or the state of | ||
| 106 | + the repository unless a command in THIS answer returned it. What you did not | ||
| 107 | + read, you do not know: run the command instead of recalling it. | ||
| 108 | + | ||
| 109 | + SKILLS | ||
| 110 | + You have a second tool, `read_skill`. Its description lists the procedures | ||
| 111 | + available for this project — one per kind of task. | ||
| 112 | + | ||
| 113 | + Any request to DO something to a Go project is a skill, not a shell command | ||
| 114 | + you invent. Match the request against that list, call `read_skill` FIRST, | ||
| 115 | + before any bash command, and then follow what it says step by step. | ||
| 116 | + | ||
| 117 | + FILE EDITING | ||
| 118 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | ||
| 119 | + They are how a file gets read and changed here: each change is exact, | ||
| 120 | + checked before it is written, and comes back as a diff with line numbers. | ||
| 121 | + bash is for running things — building, testing, listing, searching. | ||
| 122 | + | ||
| 123 | + - Read before you write: call `read_file` on the file (numbered=true when | ||
| 124 | + you need line numbers). You cannot target text you have not seen; never | ||
| 125 | + rely on what you think you remember about a file. | ||
| 126 | + - To change an existing file, call `edit_file` with one or more {old, new} | ||
| 127 | + pairs. `old` is copied from the file character for character — same | ||
| 128 | + spaces, same indentation, same line breaks — and appears exactly once: | ||
| 129 | + add the surrounding lines until it is unique. Several pairs are applied | ||
| 130 | + together, against the original file. An empty `new` deletes the text. | ||
| 131 | + - Call `write_file` only to create a file, or to rewrite one entirely and | ||
| 132 | + on purpose. On an existing file it replaces everything, including what | ||
| 133 | + you did not intend to touch. | ||
| 134 | + - Read the diff the tool returns: it says exactly what changed and on which | ||
| 135 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | ||
| 136 | + edits — read the file again and fix `old`. Do not fall back to | ||
| 137 | + `write_file` to force the change through. | ||
| 138 | + - After editing code, run the narrowest check with bash: the formatter, the | ||
| 139 | + compiler, or the test covering that file. | ||
| 140 | + | ||
| 141 | + RULES | ||
| 142 | + - Keep everything the file already does, unless the user asked to remove it. | ||
| 143 | + - Touch only the files the request is about. Do not add tests, files or | ||
| 144 | + features that were not asked for. | ||
| 145 | + - Never run a git command unless the user says git, commit or push. | ||
| 146 | + - Never move, rename or delete a file unless the user asked for it. | ||
| 147 | + - Then answer in English, in a few lines. | ||
| 148 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | ||
| 149 | + to understand the options, then run the command. | ||
| 150 | + | ||
| 151 | + BACKGROUND JOBS | ||
| 152 | + Never let a command block the answer. Anything that serves, watches or runs | ||
| 153 | + long goes to the background, with BOTH streams redirected and its pid kept: | ||
| 154 | + | ||
| 155 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | ||
| 156 | + | ||
| 157 | + Redirecting only stdout still blocks until the process exits. Read the | ||
| 158 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | ||
| 159 | + rule you cannot guess. Stop every job you started before you finish, and say | ||
| 160 | + which ones you left running. | ||
| 161 | + | ||
| 162 | +# Generation settings (OpenAI API keys). Low temperature for a coding agent: | ||
| 163 | +# we want precise and reproducible answers, not creativity. | ||
| 164 | +sampling: | ||
| 165 | + temperature: 0.0 | ||
| 166 | + parallel_tool_calls: false | ||
| 167 | + top_p: 0.9 | ||
| 168 | + max_tokens: 4096 | ||
| 169 | + | ||
| 170 | +# Silence allowed between two tokens once the answer has started. | ||
| 171 | +watchdogTimeout: 30s | ||
| 172 | + | ||
| 173 | +# Silence allowed while the model reads the prompt, before the first token: | ||
| 174 | +# on a laptop a 25k-token prompt can take minutes. llama-server reports its | ||
| 175 | +# progress meanwhile, through llmman ("Reading the prompt 12.0k/25.3k tokens"), and each report | ||
| 176 | +# restarts this clock, so it only has to cover one batch of the prompt. | ||
| 177 | +firstTokenTimeout: 3m | ||
| 178 | + | ||
| 179 | +# Context compression. | ||
| 180 | +# ON here: the agent learns the window from /api/ps, so `threshold` has a number to apply to. | ||
| 181 | +context: | ||
| 182 | + enabled: true | ||
| 183 | + | ||
| 184 | + # Compress when the history reaches this share of the window. | ||
| 185 | + threshold: 75 | ||
| 186 | + | ||
| 187 | + # Fallback on the message count, for when the window is unknown. | ||
| 188 | + # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history. | ||
| 189 | + maxMessages: 80 | ||
| 190 | + | ||
| 191 | + # How many recent questions (with their commands and answers) are kept as-is instead of being summarised. | ||
| 192 | + # The model works on the last few turns, so summarising them would make it redo what it just did. | ||
| 193 | + keepLastTurns: 3 | ||
| 194 | + | ||
| 195 | + # max_tokens of the summary request. Reasoning is turned off for it on this provider | ||
| 196 | + # (a thinking model would otherwise spend the budget thinking and write nothing). | ||
| 197 | + summaryMaxTokens: 1200 | ||
| 198 | + | ||
| 199 | + showStats: true | ||
modified
docs/diagrams/packages.drawio +1 -1 | @@ -1,3 +1,3 @@ | ||
| 1 | 1 | <mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell> |
| 2 | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry"/></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| @@ -1,3 +1,3 @@ | |||
| 1 | <mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell> | 1 | <mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell> |
| 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell> | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry"/></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
docs/en/README.md +2 -0 | @@ -13,6 +13,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 13 | 13 | - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 14 | 14 | - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demo-llmman/` |
| 15 | 15 | - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/` |
| 16 | +- [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>` | |
| 16 | 17 | - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) |
| 17 | 18 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the template and the two kits under `docker-sbx/`, window mode and editor |
| 18 | 19 | - [Run mini-me with the v3 sandbox kits](how-to/run-with-sandbox-kits-v3.md) — the same two kits for the kit specification v3, under `docker-sbx-v3/` |
| @@ -56,6 +57,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 56 | 57 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission |
| 57 | 58 | - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits |
| 58 | 59 | - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive |
| 60 | +- [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, the depth limit | |
| 59 | 61 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 60 | 62 | |
| 61 | 63 | ### MCP (Model Context Protocol) Explanation |
| @@ -13,6 +13,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 13 | - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) | 13 | - [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 14 | - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demo-llmman/` | 14 | - [Run mini-me on llmman](how-to/run-with-llmman.md) — `provider: llmman`, the demo config in `demo-llmman/` |
| 15 | - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/` | 15 | - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/` |
| 16 | +- [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>` | ||
| 16 | - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) | 17 | - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) |
| 17 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the template and the two kits under `docker-sbx/`, window mode and editor | 18 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the template and the two kits under `docker-sbx/`, window mode and editor |
| 18 | - [Run mini-me with the v3 sandbox kits](how-to/run-with-sandbox-kits-v3.md) — the same two kits for the kit specification v3, under `docker-sbx-v3/` | 19 | - [Run mini-me with the v3 sandbox kits](how-to/run-with-sandbox-kits-v3.md) — the same two kits for the kit specification v3, under `docker-sbx-v3/` |
| @@ -56,6 +57,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 56 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission | 57 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission |
| 57 | - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits | 58 | - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits |
| 58 | - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive | 59 | - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive |
| 60 | +- [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, the depth limit | ||
| 59 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | 61 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 60 | 62 | ||
| 61 | ### MCP (Model Context Protocol) Explanation | 63 | ### MCP (Model Context Protocol) Explanation |
modified
docs/en/explanation/architecture.md +1 -0 | @@ -15,6 +15,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e | ||
| 15 | 15 | | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. | |
| 16 | 16 | | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. | |
| 17 | 17 | | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. | |
| 18 | +| `ask` | `/ask-<name>`: starts another mm agent (config `mmConfigs`) as a child `mm -acp`, talks to it as an ACP client, streams its answer and tool calls through the asking front end's `ui.Sink`, and returns the exchange for the history. See [Asking another agent](asking-another-agent.md). | | |
| 18 | 19 | | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. | |
| 19 | 20 | | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. | |
| 20 | 21 | | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. | |
| @@ -15,6 +15,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e | |||
| 15 | | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. | | 15 | | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. | |
| 16 | | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. | | 16 | | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. | |
| 17 | | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. | | 17 | | `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. | |
| 18 | +| `ask` | `/ask-<name>`: starts another mm agent (config `mmConfigs`) as a child `mm -acp`, talks to it as an ACP client, streams its answer and tool calls through the asking front end's `ui.Sink`, and returns the exchange for the history. See [Asking another agent](asking-another-agent.md). | | ||
| 18 | | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. | | 19 | | `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. | |
| 19 | | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. | | 20 | | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. | |
| 20 | | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. | | 21 | | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. | |
added
docs/en/explanation/asking-another-agent.md +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +# Asking another agent — explanation | |
| 2 | + | |
| 3 | +## What is this about? | |
| 4 | + | |
| 5 | +`/ask-bob <prompt>` lets the agent you are talking to consult another mm agent: another model, another system prompt, other tools and skills. Bob's answer is shown, then kept in your conversation so your own model can use it on your next question. This page explains how that is built and why. | |
| 6 | + | |
| 7 | +## Why it is designed this way | |
| 8 | + | |
| 9 | +**Another agent is another process.** Bob runs as a child `mm -acp -config <bob's file>`, the same binary as the agent that asks. Running him inside the same process was the obvious alternative and the wrong one: mm's configuration is one global, read by the engine, the tools, the compaction and the sessions alike, and bob's differs in all of them. A second engine in the same process would first have meant pulling that global apart everywhere. A child keeps each agent whole, with its own tools, skills and MCP servers. | |
| 10 | + | |
| 11 | +**The two talk ACP.** mm already speaks the Agent Client Protocol as a server, for editors. Asking bob makes the asking agent an ACP *client* of its child. The protocol gives, for free, what scraping a terminal never would: bob's answer as text, his tool calls as structured events, his permission requests as requests that can be answered, and a standard way to cancel. mm's ACP server only ever calls two client methods — session updates and permission requests — so the client side is small; file and terminal requests are refused as absent. | |
| 12 | + | |
| 13 | +**Bob's permissions are yours to give.** When bob wants to run a command, the request is forwarded to the front end you are using: in an editor its own dialog, in the window the `permissions` setting and its inline prompt, in the line mode nothing — exactly as for your own agent's tools there. Bob's tools are named `bob/<tool>`, so allowing your agent's `bash` "for this session" does not allow bob's. | |
| 14 | + | |
| 15 | +**The answer is a pair of messages.** It enters your history as a user message (`[asked bob (provider/model): …]`) followed by an assistant message (`[bob's answer]` and the text). One message would have been simpler, but your next question is a user message too, and several chat templates (Gemma's, Mistral's) refuse two user messages in a row. The marks say who said what, so your model does not mistake bob's answer for its own. The answer is capped like a tool's output, at `maxOutput` characters: it lands in your model's window. | |
| 16 | + | |
| 17 | +**Each ask is a fresh conversation for bob.** Bob keeps no memory between two asks. When he needs to know what you were doing, `/ask-with-context-bob` sends a transcript: questions, answers, one line per tool call — never the tool outputs, which would cost his window what they already cost yours, never your system prompt or reasoning. Continuity is explicit, chosen per question, rather than a second conversation drifting on its own. | |
| 18 | + | |
| 19 | +**One child per agent, started once.** Starting mm costs its engine set-up, its server probe and its MCP servers. The first ask starts bob; later asks reuse him, each with a new session. He stops when your agent exits. | |
| 20 | + | |
| 21 | +**The child is fenced off from what is meant for its parent.** Variables like `AGENT_MODEL`, `AGENT_PROVIDER` or `AGENT_BASE_URL` override a configuration; inherited by bob, they would silently turn him into a copy of you. They are removed from his environment. Bob also runs with `AGENT_SESSIONS=off`: he works in your project, and his session files would otherwise appear in your `/sessions` and `/resume`. | |
| 22 | + | |
| 23 | +**Chains stop.** `/ask` is a command a person types; no model can call it, so no agent loops on its own. A person could still type `/ask` into an agent that was itself asked, so each child is told its depth (`MM_ASK_DEPTH`): your agent may ask bob, bob may ask riker, riker may not ask anyone. | |
| 24 | + | |
| 25 | +## Rejected alternatives | |
| 26 | + | |
| 27 | +- **`mm -p "<prompt>"` as a subprocess.** One process per question and nothing to keep alive — but its output is a terminal screen (answer, reasoning, tool echoes, colours, the command recap) that would have to be parsed; it never asks permission, so bob would run commands unchecked; it cannot be cancelled except by killing it. | |
| 28 | +- **A second engine in the same process.** Fast, no process — at the price of turning the global configuration into a per-agent one throughout the code base. | |
| 29 | +- **Letting the current model answer at once with bob's input.** The user chose otherwise: bob's answer is shown and kept, and you decide what to ask next. | |
| 30 | +- **Keeping bob's own conversation across asks.** Two conversations evolving in parallel, one of them invisible, are harder to reason about than an explicit transcript sent when asked. | |
| 31 | +- **A generic `/ask <name>` only, in editors too.** An editor's `/` menu completes a command name, never its argument; one command per agent is the only way for the menu to list the agents. | |
| 32 | + | |
| 33 | +## How it relates to the rest | |
| 34 | + | |
| 35 | +- The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side. | |
| 36 | +- Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools. | |
| 37 | +- The exchange is part of the history like any other turn: it is saved with the [session](persistent-sessions.md), replayed by `/resume`, and summarised by [context compression](context-compression.md) when it gets old. | |
| 38 | +- Using it: [how to ask another agent](../how-to/ask-another-agent.md). Every command and message: [slash commands reference](../reference/slash-commands.md#asking-another-agent). | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +# Asking another agent — explanation | ||
| 2 | + | ||
| 3 | +## What is this about? | ||
| 4 | + | ||
| 5 | +`/ask-bob <prompt>` lets the agent you are talking to consult another mm agent: another model, another system prompt, other tools and skills. Bob's answer is shown, then kept in your conversation so your own model can use it on your next question. This page explains how that is built and why. | ||
| 6 | + | ||
| 7 | +## Why it is designed this way | ||
| 8 | + | ||
| 9 | +**Another agent is another process.** Bob runs as a child `mm -acp -config <bob's file>`, the same binary as the agent that asks. Running him inside the same process was the obvious alternative and the wrong one: mm's configuration is one global, read by the engine, the tools, the compaction and the sessions alike, and bob's differs in all of them. A second engine in the same process would first have meant pulling that global apart everywhere. A child keeps each agent whole, with its own tools, skills and MCP servers. | ||
| 10 | + | ||
| 11 | +**The two talk ACP.** mm already speaks the Agent Client Protocol as a server, for editors. Asking bob makes the asking agent an ACP *client* of its child. The protocol gives, for free, what scraping a terminal never would: bob's answer as text, his tool calls as structured events, his permission requests as requests that can be answered, and a standard way to cancel. mm's ACP server only ever calls two client methods — session updates and permission requests — so the client side is small; file and terminal requests are refused as absent. | ||
| 12 | + | ||
| 13 | +**Bob's permissions are yours to give.** When bob wants to run a command, the request is forwarded to the front end you are using: in an editor its own dialog, in the window the `permissions` setting and its inline prompt, in the line mode nothing — exactly as for your own agent's tools there. Bob's tools are named `bob/<tool>`, so allowing your agent's `bash` "for this session" does not allow bob's. | ||
| 14 | + | ||
| 15 | +**The answer is a pair of messages.** It enters your history as a user message (`[asked bob (provider/model): …]`) followed by an assistant message (`[bob's answer]` and the text). One message would have been simpler, but your next question is a user message too, and several chat templates (Gemma's, Mistral's) refuse two user messages in a row. The marks say who said what, so your model does not mistake bob's answer for its own. The answer is capped like a tool's output, at `maxOutput` characters: it lands in your model's window. | ||
| 16 | + | ||
| 17 | +**Each ask is a fresh conversation for bob.** Bob keeps no memory between two asks. When he needs to know what you were doing, `/ask-with-context-bob` sends a transcript: questions, answers, one line per tool call — never the tool outputs, which would cost his window what they already cost yours, never your system prompt or reasoning. Continuity is explicit, chosen per question, rather than a second conversation drifting on its own. | ||
| 18 | + | ||
| 19 | +**One child per agent, started once.** Starting mm costs its engine set-up, its server probe and its MCP servers. The first ask starts bob; later asks reuse him, each with a new session. He stops when your agent exits. | ||
| 20 | + | ||
| 21 | +**The child is fenced off from what is meant for its parent.** Variables like `AGENT_MODEL`, `AGENT_PROVIDER` or `AGENT_BASE_URL` override a configuration; inherited by bob, they would silently turn him into a copy of you. They are removed from his environment. Bob also runs with `AGENT_SESSIONS=off`: he works in your project, and his session files would otherwise appear in your `/sessions` and `/resume`. | ||
| 22 | + | ||
| 23 | +**Chains stop.** `/ask` is a command a person types; no model can call it, so no agent loops on its own. A person could still type `/ask` into an agent that was itself asked, so each child is told its depth (`MM_ASK_DEPTH`): your agent may ask bob, bob may ask riker, riker may not ask anyone. | ||
| 24 | + | ||
| 25 | +## Rejected alternatives | ||
| 26 | + | ||
| 27 | +- **`mm -p "<prompt>"` as a subprocess.** One process per question and nothing to keep alive — but its output is a terminal screen (answer, reasoning, tool echoes, colours, the command recap) that would have to be parsed; it never asks permission, so bob would run commands unchecked; it cannot be cancelled except by killing it. | ||
| 28 | +- **A second engine in the same process.** Fast, no process — at the price of turning the global configuration into a per-agent one throughout the code base. | ||
| 29 | +- **Letting the current model answer at once with bob's input.** The user chose otherwise: bob's answer is shown and kept, and you decide what to ask next. | ||
| 30 | +- **Keeping bob's own conversation across asks.** Two conversations evolving in parallel, one of them invisible, are harder to reason about than an explicit transcript sent when asked. | ||
| 31 | +- **A generic `/ask <name>` only, in editors too.** An editor's `/` menu completes a command name, never its argument; one command per agent is the only way for the menu to list the agents. | ||
| 32 | + | ||
| 33 | +## How it relates to the rest | ||
| 34 | + | ||
| 35 | +- The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side. | ||
| 36 | +- Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools. | ||
| 37 | +- The exchange is part of the history like any other turn: it is saved with the [session](persistent-sessions.md), replayed by `/resume`, and summarised by [context compression](context-compression.md) when it gets old. | ||
| 38 | +- Using it: [how to ask another agent](../how-to/ask-another-agent.md). Every command and message: [slash commands reference](../reference/slash-commands.md#asking-another-agent). | ||
added
docs/en/how-to/ask-another-agent.md +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +# How to ask another mm agent from your session | |
| 2 | + | |
| 3 | +This guide shows how to put a question to another mm agent — another model, another system prompt, other tools — and keep its answer in your conversation. It assumes you already run `mm` with a working provider. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Write (or reuse) one configuration file per agent you want to ask. Each is an ordinary mm configuration; `demo-llmman/` ships two, for llmman: | |
| 8 | + | |
| 9 | + - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, named Jan; | |
| 10 | + - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, named Lucy. | |
| 11 | + | |
| 12 | +2. Declare them in YOUR configuration, under `mmConfigs`. Paths are relative to the directory of the file you write them in: | |
| 13 | + | |
| 14 | + ```yaml | |
| 15 | + mmConfigs: | |
| 16 | + jan: agent.jan-nano.yaml | |
| 17 | + lucy: agent.lucy.yaml | |
| 18 | + ``` | |
| 19 | + | |
| 20 | + `demo-llmman/agent.llmman.yaml` already does this. | |
| 21 | + | |
| 22 | +3. Start `mm` with that configuration: | |
| 23 | + | |
| 24 | + ```bash | |
| 25 | + cd demo-llmman | |
| 26 | + ../mm agent.llmman.yaml | |
| 27 | + ``` | |
| 28 | + | |
| 29 | + An agent whose file is missing shows up as a `[warning: mmConfigs: …]` line; the others work. | |
| 30 | + | |
| 31 | +4. Ask a question: | |
| 32 | + | |
| 33 | + ```text | |
| 34 | + > /ask-jan In one short sentence: what is the capital of France? | |
| 35 | + 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)… | |
| 36 | + The capital of France is Paris. | |
| 37 | + 🗣 jan's answer is in the history for your next question | |
| 38 | + ``` | |
| 39 | + | |
| 40 | + The first ask of an agent starts it, so it takes a little longer; the next ones reuse it. | |
| 41 | + | |
| 42 | +5. Use the answer: your next question goes to your own model, which now sees Jan's answer. | |
| 43 | + | |
| 44 | + ```text | |
| 45 | + > What did Jan answer? | |
| 46 | + 🤖 Jan answered that the capital of France is Paris. | |
| 47 | + ``` | |
| 48 | + | |
| 49 | +## Variants | |
| 50 | + | |
| 51 | +- **Let the other agent see the conversation.** Use `/ask-with-context-lucy <prompt>`: Lucy receives a transcript of the conversation (questions, answers, one line per tool call — never tool outputs) before your question. | |
| 52 | +- **In the terminal or the window, by name.** `/ask lucy <prompt>` and `/ask-with-context lucy <prompt>` are the same commands with the agent as an argument. | |
| 53 | +- **In an editor (ACP).** The menu lists `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy`; when Lucy wants to run a command, the editor's own permission dialog asks you. | |
| 54 | +- **Stop a long answer.** `/abort`, `Esc` or `Ctrl+C` — nothing is kept from a cancelled ask. | |
| 55 | +- **Agents on other providers.** Any mm configuration works: an Ollama agent next to an llmman one is just another `mmConfigs` entry. | |
| 56 | + | |
| 57 | +## See also | |
| 58 | + | |
| 59 | +- Every form, message and error: [slash commands reference](../reference/slash-commands.md#asking-another-agent) | |
| 60 | +- The `mmConfigs` key: [configuration reference](../reference/configuration.md) | |
| 61 | +- Why another agent is a separate process spoken to over ACP: [asking another agent — explanation](../explanation/asking-another-agent.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +# How to ask another mm agent from your session | ||
| 2 | + | ||
| 3 | +This guide shows how to put a question to another mm agent — another model, another system prompt, other tools — and keep its answer in your conversation. It assumes you already run `mm` with a working provider. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Write (or reuse) one configuration file per agent you want to ask. Each is an ordinary mm configuration; `demo-llmman/` ships two, for llmman: | ||
| 8 | + | ||
| 9 | + - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, named Jan; | ||
| 10 | + - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, named Lucy. | ||
| 11 | + | ||
| 12 | +2. Declare them in YOUR configuration, under `mmConfigs`. Paths are relative to the directory of the file you write them in: | ||
| 13 | + | ||
| 14 | + ```yaml | ||
| 15 | + mmConfigs: | ||
| 16 | + jan: agent.jan-nano.yaml | ||
| 17 | + lucy: agent.lucy.yaml | ||
| 18 | + ``` | ||
| 19 | + | ||
| 20 | + `demo-llmman/agent.llmman.yaml` already does this. | ||
| 21 | + | ||
| 22 | +3. Start `mm` with that configuration: | ||
| 23 | + | ||
| 24 | + ```bash | ||
| 25 | + cd demo-llmman | ||
| 26 | + ../mm agent.llmman.yaml | ||
| 27 | + ``` | ||
| 28 | + | ||
| 29 | + An agent whose file is missing shows up as a `[warning: mmConfigs: …]` line; the others work. | ||
| 30 | + | ||
| 31 | +4. Ask a question: | ||
| 32 | + | ||
| 33 | + ```text | ||
| 34 | + > /ask-jan In one short sentence: what is the capital of France? | ||
| 35 | + 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)… | ||
| 36 | + The capital of France is Paris. | ||
| 37 | + 🗣 jan's answer is in the history for your next question | ||
| 38 | + ``` | ||
| 39 | + | ||
| 40 | + The first ask of an agent starts it, so it takes a little longer; the next ones reuse it. | ||
| 41 | + | ||
| 42 | +5. Use the answer: your next question goes to your own model, which now sees Jan's answer. | ||
| 43 | + | ||
| 44 | + ```text | ||
| 45 | + > What did Jan answer? | ||
| 46 | + 🤖 Jan answered that the capital of France is Paris. | ||
| 47 | + ``` | ||
| 48 | + | ||
| 49 | +## Variants | ||
| 50 | + | ||
| 51 | +- **Let the other agent see the conversation.** Use `/ask-with-context-lucy <prompt>`: Lucy receives a transcript of the conversation (questions, answers, one line per tool call — never tool outputs) before your question. | ||
| 52 | +- **In the terminal or the window, by name.** `/ask lucy <prompt>` and `/ask-with-context lucy <prompt>` are the same commands with the agent as an argument. | ||
| 53 | +- **In an editor (ACP).** The menu lists `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy`; when Lucy wants to run a command, the editor's own permission dialog asks you. | ||
| 54 | +- **Stop a long answer.** `/abort`, `Esc` or `Ctrl+C` — nothing is kept from a cancelled ask. | ||
| 55 | +- **Agents on other providers.** Any mm configuration works: an Ollama agent next to an llmman one is just another `mmConfigs` entry. | ||
| 56 | + | ||
| 57 | +## See also | ||
| 58 | + | ||
| 59 | +- Every form, message and error: [slash commands reference](../reference/slash-commands.md#asking-another-agent) | ||
| 60 | +- The `mmConfigs` key: [configuration reference](../reference/configuration.md) | ||
| 61 | +- Why another agent is a separate process spoken to over ACP: [asking another agent — explanation](../explanation/asking-another-agent.md) | ||
modified
docs/en/reference/cli.md +3 -1 | @@ -55,6 +55,8 @@ The first existing source wins. | ||
| 55 | 55 | | `AGENT_MODEL` | Overrides the `model` key. | |
| 56 | 56 | | `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. | |
| 57 | 57 | | `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. | |
| 58 | +| `AGENT_SESSIONS` | `off` turns session persistence off for this run, whatever `sessions.enabled` says. Set by `/ask` on the agents it starts. | | |
| 59 | +| `MM_ASK_DEPTH` | How many `/ask` levels deep this agent runs (unset = 0). Set by `/ask`; at 2, `/ask` is refused. | | |
| 58 | 60 | | Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`, `LLMMAN_API_KEY` for `llmman`, `OLLAMA_API_KEY` for `ollama`. | |
| 59 | 61 | |
| 60 | 62 | ## Start-up output |
| @@ -63,7 +65,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is | ||
| 63 | 65 | |
| 64 | 66 | | Line | Content | |
| 65 | 67 | |------|---------| |
| 66 | -| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all. | | |
| 68 | +| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`). | | |
| 67 | 69 | | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. | |
| 68 | 70 | | `Agent ready. Commands: …` | Terminal mode only. | |
| 69 | 71 | | `Shortcut: Ctrl+C …` | Terminal mode only. | |
| @@ -55,6 +55,8 @@ The first existing source wins. | |||
| 55 | | `AGENT_MODEL` | Overrides the `model` key. | | 55 | | `AGENT_MODEL` | Overrides the `model` key. | |
| 56 | | `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. | | 56 | | `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. | |
| 57 | | `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. | | 57 | | `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. | |
| 58 | +| `AGENT_SESSIONS` | `off` turns session persistence off for this run, whatever `sessions.enabled` says. Set by `/ask` on the agents it starts. | | ||
| 59 | +| `MM_ASK_DEPTH` | How many `/ask` levels deep this agent runs (unset = 0). Set by `/ask`; at 2, `/ask` is refused. | | ||
| 58 | | Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`, `LLMMAN_API_KEY` for `llmman`, `OLLAMA_API_KEY` for `ollama`. | | 60 | | Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`, `LLMMAN_API_KEY` for `llmman`, `OLLAMA_API_KEY` for `ollama`. | |
| 59 | 61 | ||
| 60 | ## Start-up output | 62 | ## Start-up output |
| @@ -63,7 +65,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is | |||
| 63 | 65 | ||
| 64 | | Line | Content | | 66 | | Line | Content | |
| 65 | |------|---------| | 67 | |------|---------| |
| 66 | -| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all. | | 68 | +| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all, an `mmConfigs` file not found (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`). | |
| 67 | | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. | | 69 | | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. | |
| 68 | | `Agent ready. Commands: …` | Terminal mode only. | | 70 | | `Agent ready. Commands: …` | Terminal mode only. | |
| 69 | | `Shortcut: Ctrl+C …` | Terminal mode only. | | 71 | | `Shortcut: Ctrl+C …` | Terminal mode only. | |
modified
docs/en/reference/configuration.md +4 -0 | @@ -31,6 +31,7 @@ | ||
| 31 | 31 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 32 | 32 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | |
| 33 | 33 | | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. | |
| 34 | +| `mmConfigs` | map name → path | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)). A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. | | |
| 34 | 35 | |
| 35 | 36 | ## `context` keys |
| 36 | 37 | |
| @@ -117,6 +118,9 @@ Reported as `[config error: <path>: <message>]`, exit code 1. | ||
| 117 | 118 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot | |
| 118 | 119 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension | |
| 119 | 120 | | `permissions must be "auto" or "ask"` | any other value | |
| 121 | +| `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | | |
| 122 | +| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | | |
| 123 | +| `mmConfigs.<name>: the path must not be empty` | `<name>: ""` | | |
| 120 | 124 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | |
| 121 | 125 | |
| 122 | 126 | A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`. |
| @@ -31,6 +31,7 @@ | |||
| 31 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | | 31 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 32 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | | 32 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | |
| 33 | | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. | | 33 | | `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. | |
| 34 | +| `mmConfigs` | map name → path | none | Other mm agents this one can ask. Each name generates `/ask-<name>` and `/ask-with-context-<name>` (see [slash commands](slash-commands.md#asking-another-agent)). A relative path is relative to the directory of the file that declares it. Names: lowercase letters, digits and `-`, starting with a letter or a digit, not starting with `with-context`. A path whose file does not exist is a start-up warning, not an error. | | ||
| 34 | 35 | ||
| 35 | ## `context` keys | 36 | ## `context` keys |
| 36 | 37 | ||
| @@ -117,6 +118,9 @@ Reported as `[config error: <path>: <message>]`, exit code 1. | |||
| 117 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot | | 118 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot | |
| 118 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension | | 119 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension | |
| 119 | | `permissions must be "auto" or "ask"` | any other value | | 120 | | `permissions must be "auto" or "ask"` | any other value | |
| 121 | +| `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | | ||
| 122 | +| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | | ||
| 123 | +| `mmConfigs.<name>: the path must not be empty` | `<name>: ""` | | ||
| 120 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | | 124 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | |
| 121 | 125 | ||
| 122 | A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`. | 126 | A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`. |
modified
docs/en/reference/slash-commands.md +45 -0 | @@ -17,6 +17,49 @@ | ||
| 17 | 17 | |
| 18 | 18 | With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored. |
| 19 | 19 | |
| 20 | +## Asking another agent | |
| 21 | + | |
| 22 | +Present only when `mmConfigs` declares at least one agent; `/help` and the `/` picker list them. `<name>` is a key of `mmConfigs`. | |
| 23 | + | |
| 24 | +| Command | Front ends | Effect | | |
| 25 | +|---------|------------|--------| | |
| 26 | +| `/ask-<name> <prompt>` | terminal, window, editor | Sends `<prompt>` alone to `<name>`. | | |
| 27 | +| `/ask-with-context-<name> <prompt>` | terminal, window, editor | Sends a transcript of the current conversation, then `<prompt>`. | | |
| 28 | +| `/ask <name> <prompt>` | terminal, window | Same as `/ask-<name> <prompt>`. | | |
| 29 | +| `/ask-with-context <name> <prompt>` | terminal, window | Same as `/ask-with-context-<name> <prompt>`. | | |
| 30 | + | |
| 31 | +While it runs: | |
| 32 | + | |
| 33 | +| Item | Behaviour | | |
| 34 | +|------|-----------| | |
| 35 | +| First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. | | |
| 36 | +| `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. | | |
| 37 | +| `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. | | |
| 38 | +| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` | | |
| 39 | +| `/quit` | Cancels the ask and exits. | | |
| 40 | +| Any other line (line mode) | `⚠️ <name> is still answering — /abort to stop it.`; the line is dropped. | | |
| 41 | + | |
| 42 | +When it ends: | |
| 43 | + | |
| 44 | +| Outcome | History | Message | | |
| 45 | +|---------|---------|---------| | |
| 46 | +| Answered | Two messages appended: user `[asked <name> (<provider>/<model>)[, with this conversation as context]: <prompt>]`, assistant `[<name>'s answer]` + the answer (capped at `maxOutput` characters, beginning and end kept). Saved with the session. | `🗣 <name>'s answer is in the history for your next question` | | |
| 47 | +| Cancelled | Unchanged. | `🛑 Ask aborted — nothing kept.` | | |
| 48 | +| Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). | | |
| 49 | + | |
| 50 | +The transcript sent by the context forms holds the user's questions, the assistant's answers, one line per tool call (`Assistant ran <tool>: <args>`, args cut at 160 characters) and the compaction summary if any; never the system prompt, tool outputs or reasoning. Capped at `maxOutput` characters, newest lines kept, `[… earlier conversation omitted …]` marking the cut. | |
| 51 | + | |
| 52 | +| Error | Cause | | |
| 53 | +|-------|-------| | |
| 54 | +| `usage: /ask <agent> <prompt> — agents: …` | `/ask` without a name. | | |
| 55 | +| `unknown agent "x" — agents: …` | `/ask x …` with a name not in `mmConfigs`. | | |
| 56 | +| `nothing to ask: type the question after the command` | No prompt. | | |
| 57 | +| `<name>: config file <path>: no such file or directory` | The file of `mmConfigs.<name>` does not exist. | | |
| 58 | +| `<name> did not start: … — <last stderr line>` | The agent's process exited or did not answer `initialize` within 60 s. | | |
| 59 | +| `<name> failed: …` | The agent's turn ended with an error (its own one-line explanation). | | |
| 60 | +| `<name> gave no answer` | The agent ended its turn without any text. | | |
| 61 | +| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` is 2. | | |
| 62 | + | |
| 20 | 63 | ## `@path` in a question |
| 21 | 64 | |
| 22 | 65 | | Input | Effect | |
| @@ -132,4 +175,6 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th | ||
| 132 | 175 | | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | |
| 133 | 176 | | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | |
| 134 | 177 | |
| 178 | +With `mmConfigs`, `/ask-<name>` and `/ask-with-context-<name>` are advertised too, each with the input hint `<prompt>`; the generic `/ask <name>` is not, since an editor's menu completes a command name but not its argument. See [Asking another agent](#asking-another-agent). | |
| 179 | + | |
| 135 | 180 | `/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands). |
| @@ -17,6 +17,49 @@ | |||
| 17 | 17 | ||
| 18 | With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored. | 18 | With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored. |
| 19 | 19 | ||
| 20 | +## Asking another agent | ||
| 21 | + | ||
| 22 | +Present only when `mmConfigs` declares at least one agent; `/help` and the `/` picker list them. `<name>` is a key of `mmConfigs`. | ||
| 23 | + | ||
| 24 | +| Command | Front ends | Effect | | ||
| 25 | +|---------|------------|--------| | ||
| 26 | +| `/ask-<name> <prompt>` | terminal, window, editor | Sends `<prompt>` alone to `<name>`. | | ||
| 27 | +| `/ask-with-context-<name> <prompt>` | terminal, window, editor | Sends a transcript of the current conversation, then `<prompt>`. | | ||
| 28 | +| `/ask <name> <prompt>` | terminal, window | Same as `/ask-<name> <prompt>`. | | ||
| 29 | +| `/ask-with-context <name> <prompt>` | terminal, window | Same as `/ask-with-context-<name> <prompt>`. | | ||
| 30 | + | ||
| 31 | +While it runs: | ||
| 32 | + | ||
| 33 | +| Item | Behaviour | | ||
| 34 | +|------|-----------| | ||
| 35 | +| First line | `🗣 asking <name> (<provider>/<model>)…`, or `…, with the conversation…` for the context forms. | | ||
| 36 | +| `<name>`'s answer | Streamed where answers appear. Its reasoning is shown as reasoning when `showThinking` is on. | | ||
| 37 | +| `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. | | ||
| 38 | +| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` | | ||
| 39 | +| `/quit` | Cancels the ask and exits. | | ||
| 40 | +| Any other line (line mode) | `⚠️ <name> is still answering — /abort to stop it.`; the line is dropped. | | ||
| 41 | + | ||
| 42 | +When it ends: | ||
| 43 | + | ||
| 44 | +| Outcome | History | Message | | ||
| 45 | +|---------|---------|---------| | ||
| 46 | +| Answered | Two messages appended: user `[asked <name> (<provider>/<model>)[, with this conversation as context]: <prompt>]`, assistant `[<name>'s answer]` + the answer (capped at `maxOutput` characters, beginning and end kept). Saved with the session. | `🗣 <name>'s answer is in the history for your next question` | | ||
| 47 | +| Cancelled | Unchanged. | `🛑 Ask aborted — nothing kept.` | | ||
| 48 | +| Failed | Unchanged; recorded in the session file with `where: "ask"`. | `[error: …]` (terminal, window) or a JSON-RPC error (editor). | | ||
| 49 | + | ||
| 50 | +The transcript sent by the context forms holds the user's questions, the assistant's answers, one line per tool call (`Assistant ran <tool>: <args>`, args cut at 160 characters) and the compaction summary if any; never the system prompt, tool outputs or reasoning. Capped at `maxOutput` characters, newest lines kept, `[… earlier conversation omitted …]` marking the cut. | ||
| 51 | + | ||
| 52 | +| Error | Cause | | ||
| 53 | +|-------|-------| | ||
| 54 | +| `usage: /ask <agent> <prompt> — agents: …` | `/ask` without a name. | | ||
| 55 | +| `unknown agent "x" — agents: …` | `/ask x …` with a name not in `mmConfigs`. | | ||
| 56 | +| `nothing to ask: type the question after the command` | No prompt. | | ||
| 57 | +| `<name>: config file <path>: no such file or directory` | The file of `mmConfigs.<name>` does not exist. | | ||
| 58 | +| `<name> did not start: … — <last stderr line>` | The agent's process exited or did not answer `initialize` within 60 s. | | ||
| 59 | +| `<name> failed: …` | The agent's turn ended with an error (its own one-line explanation). | | ||
| 60 | +| `<name> gave no answer` | The agent ended its turn without any text. | | ||
| 61 | +| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` is 2. | | ||
| 62 | + | ||
| 20 | ## `@path` in a question | 63 | ## `@path` in a question |
| 21 | 64 | ||
| 22 | | Input | Effect | | 65 | | Input | Effect | |
| @@ -132,4 +175,6 @@ Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to th | |||
| 132 | | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | | 175 | | `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | |
| 133 | | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | | 176 | | `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | |
| 134 | 177 | ||
| 178 | +With `mmConfigs`, `/ask-<name>` and `/ask-with-context-<name>` are advertised too, each with the input hint `<prompt>`; the generic `/ask <name>` is not, since an editor's menu completes a command name but not its argument. See [Asking another agent](#asking-another-agent). | ||
| 179 | + | ||
| 135 | `/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands). | 180 | `/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. Each **skill** is offered there as a command of its own — ACP has no picker for skills, so `/greetings Bob` in the editor's menu is the equivalent of `$greetings Bob` in the window, and it is rewritten into exactly that. See the [ACP reference](acp.md#skills-as-commands). |
modified
docs/fr/README.md +2 -0 | @@ -13,6 +13,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 13 | 13 | - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 14 | 14 | - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demo-llmman/` |
| 15 | 15 | - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/` |
| 16 | +- [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>` | |
| 16 | 17 | - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) |
| 17 | 18 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le template et les deux kits sous `docker-sbx/`, mode fenêtre et éditeur |
| 18 | 19 | - [Faire tourner mini-me avec les kits sandbox v3](how-to/run-with-sandbox-kits-v3.md) — les deux mêmes kits pour la spécification de kit v3, sous `docker-sbx-v3/` |
| @@ -56,6 +57,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 56 | 57 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission |
| 57 | 58 | - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues |
| 58 | 59 | - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie |
| 60 | +- [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, la limite de profondeur | |
| 59 | 61 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 60 | 62 | |
| 61 | 63 | ### Explication MCP (Model Context Protocol) |
| @@ -13,6 +13,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 13 | - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) | 13 | - [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) |
| 14 | - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demo-llmman/` | 14 | - [Faire tourner mini-me sur llmman](how-to/run-with-llmman.md) — `provider: llmman`, la config de démo dans `demo-llmman/` |
| 15 | - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/` | 15 | - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/` |
| 16 | +- [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>` | ||
| 16 | - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) | 17 | - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) |
| 17 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le template et les deux kits sous `docker-sbx/`, mode fenêtre et éditeur | 18 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le template et les deux kits sous `docker-sbx/`, mode fenêtre et éditeur |
| 18 | - [Faire tourner mini-me avec les kits sandbox v3](how-to/run-with-sandbox-kits-v3.md) — les deux mêmes kits pour la spécification de kit v3, sous `docker-sbx-v3/` | 19 | - [Faire tourner mini-me avec les kits sandbox v3](how-to/run-with-sandbox-kits-v3.md) — les deux mêmes kits pour la spécification de kit v3, sous `docker-sbx-v3/` |
| @@ -56,6 +57,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 56 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission | 57 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission |
| 57 | - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues | 58 | - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues |
| 58 | - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie | 59 | - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie |
| 60 | +- [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, la limite de profondeur | ||
| 59 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | 61 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 60 | 62 | ||
| 61 | ### Explication MCP (Model Context Protocol) | 63 | ### Explication MCP (Model Context Protocol) |
modified
docs/fr/explanation/architecture.md +1 -0 | @@ -15,6 +15,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages | ||
| 15 | 15 | | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. | |
| 16 | 16 | | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. | |
| 17 | 17 | | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. | |
| 18 | +| `ask` | `/ask-<nom>` : démarre un autre agent mm (clé `mmConfigs`) comme enfant `mm -acp`, lui parle en client ACP, affiche sa réponse et ses appels d'outils via le `ui.Sink` de la façade qui pose la question, et renvoie l'échange pour l'historique. Voir [Interroger un autre agent](asking-another-agent.md). | | |
| 18 | 19 | | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. | |
| 19 | 20 | | `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. | |
| 20 | 21 | | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. | |
| @@ -15,6 +15,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages | |||
| 15 | | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. | | 15 | | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. | |
| 16 | | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. | | 16 | | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. | |
| 17 | | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. | | 17 | | `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. | |
| 18 | +| `ask` | `/ask-<nom>` : démarre un autre agent mm (clé `mmConfigs`) comme enfant `mm -acp`, lui parle en client ACP, affiche sa réponse et ses appels d'outils via le `ui.Sink` de la façade qui pose la question, et renvoie l'échange pour l'historique. Voir [Interroger un autre agent](asking-another-agent.md). | | ||
| 18 | | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. | | 19 | | `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. | |
| 19 | | `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. | | 20 | | `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. | |
| 20 | | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. | | 21 | | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. | |
added
docs/fr/explanation/asking-another-agent.md +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +# Interroger un autre agent — explication | |
| 2 | + | |
| 3 | +## De quoi s'agit-il ? | |
| 4 | + | |
| 5 | +`/ask-bob <prompt>` permet à l'agent avec qui vous parlez de consulter un autre agent mm : un autre modèle, un autre prompt système, d'autres outils et skills. La réponse de bob est affichée, puis gardée dans votre conversation pour que votre propre modèle s'en serve à votre question suivante. Cette page explique comment c'est construit et pourquoi. | |
| 6 | + | |
| 7 | +## Pourquoi c'est conçu ainsi | |
| 8 | + | |
| 9 | +**Un autre agent est un autre processus.** Bob tourne comme un enfant `mm -acp -config <fichier de bob>`, le même binaire que l'agent qui l'interroge. Le faire tourner dans le même processus était l'alternative évidente, et la mauvaise : la configuration de mm est une variable globale, lue par le moteur, les outils, la compaction et les sessions, et celle de bob diffère sur tous ces points. Un second moteur dans le même processus aurait d'abord demandé de démanteler cette variable globale partout. Un enfant garde chaque agent entier, avec ses propres outils, skills et serveurs MCP. | |
| 10 | + | |
| 11 | +**Les deux parlent ACP.** mm parle déjà l'Agent Client Protocol comme serveur, pour les éditeurs. Interroger bob fait de l'agent qui pose la question un *client* ACP de son enfant. Le protocole apporte gratuitement ce que lire un terminal n'aurait jamais donné : la réponse de bob sous forme de texte, ses appels d'outils comme des événements structurés, ses demandes de permission comme des requêtes auxquelles on peut répondre, et une façon standard d'annuler. Le serveur ACP de mm n'appelle que deux méthodes du client — les mises à jour de session et les demandes de permission — donc le côté client est petit ; les requêtes de fichiers et de terminal sont refusées comme absentes. | |
| 12 | + | |
| 13 | +**Les permissions de bob, c'est vous qui les donnez.** Quand bob veut lancer une commande, la demande est transmise à la façade que vous utilisez : dans un éditeur, sa propre boîte de dialogue ; dans la fenêtre, le réglage `permissions` et sa question dans la zone de saisie ; en mode ligne, rien — exactement comme pour les outils de votre propre agent à cet endroit. Les outils de bob s'appellent `bob/<outil>`, donc autoriser le `bash` de votre agent « pour cette session » n'autorise pas celui de bob. | |
| 14 | + | |
| 15 | +**La réponse est une paire de messages.** Elle entre dans votre historique comme un message utilisateur (`[asked bob (fournisseur/modèle): …]`) suivi d'un message de l'assistant (`[bob's answer]` et le texte). Un seul message aurait été plus simple, mais votre question suivante est aussi un message utilisateur, et plusieurs templates de chat (ceux de Gemma, de Mistral) refusent deux messages utilisateur à la suite. Les marques disent qui a dit quoi, pour que votre modèle ne prenne pas la réponse de bob pour la sienne. La réponse est plafonnée comme la sortie d'un outil, à `maxOutput` caractères : elle atterrit dans la fenêtre de votre modèle. | |
| 16 | + | |
| 17 | +**Chaque demande est une conversation neuve pour bob.** Bob ne garde aucune mémoire entre deux demandes. Quand il doit savoir ce que vous faisiez, `/ask-with-context-bob` envoie une transcription : questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils, qui coûteraient à sa fenêtre ce qu'elles ont déjà coûté à la vôtre, jamais votre prompt système ni le raisonnement. La continuité est explicite, choisie à chaque question, plutôt qu'une seconde conversation qui dérive de son côté. | |
| 18 | + | |
| 19 | +**Un enfant par agent, démarré une fois.** Démarrer mm coûte la mise en place de son moteur, la sonde du serveur et ses serveurs MCP. La première demande démarre bob ; les suivantes le réutilisent, chacune avec une nouvelle session. Il s'arrête quand votre agent se termine. | |
| 20 | + | |
| 21 | +**L'enfant est protégé de ce qui est destiné à son parent.** Des variables comme `AGENT_MODEL`, `AGENT_PROVIDER` ou `AGENT_BASE_URL` surchargent une configuration ; héritées par bob, elles en feraient silencieusement une copie de vous. Elles sont retirées de son environnement. Bob tourne aussi avec `AGENT_SESSIONS=off` : il travaille dans votre projet, et ses fichiers de session apparaîtraient sinon dans vos `/sessions` et `/resume`. | |
| 22 | + | |
| 23 | +**Les chaînes s'arrêtent.** `/ask` est une commande tapée par une personne ; aucun modèle ne peut l'appeler, donc aucun agent ne boucle tout seul. Une personne pourrait quand même taper `/ask` dans un agent lui-même interrogé, alors chaque enfant connaît sa profondeur (`MM_ASK_DEPTH`) : votre agent peut interroger bob, bob peut interroger riker, riker ne peut interroger personne. | |
| 24 | + | |
| 25 | +## Alternatives écartées | |
| 26 | + | |
| 27 | +- **`mm -p "<prompt>"` en sous-processus.** Un processus par question et rien à garder en vie — mais sa sortie est un écran de terminal (réponse, raisonnement, échos d'outils, couleurs, récapitulatif des commandes) qu'il faudrait analyser ; il ne demande jamais de permission, donc bob lancerait des commandes sans contrôle ; on ne peut l'annuler qu'en le tuant. | |
| 28 | +- **Un second moteur dans le même processus.** Rapide, sans processus — au prix de transformer la configuration globale en configuration par agent dans tout le code. | |
| 29 | +- **Faire répondre le modèle courant tout de suite avec l'apport de bob.** L'utilisateur a choisi autrement : la réponse de bob est affichée et gardée, et vous décidez de la question suivante. | |
| 30 | +- **Garder la conversation de bob d'une demande à l'autre.** Deux conversations qui évoluent en parallèle, dont une invisible, sont plus difficiles à suivre qu'une transcription explicite envoyée quand on la demande. | |
| 31 | +- **Seulement un `/ask <nom>` générique, y compris dans les éditeurs.** Le menu `/` d'un éditeur complète un nom de commande, jamais son argument ; une commande par agent est la seule façon pour que le menu liste les agents. | |
| 32 | + | |
| 33 | +## Lien avec le reste | |
| 34 | + | |
| 35 | +- L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté. | |
| 36 | +- Ses appels d'outils et ses permissions passent par la même interface d'événements que les trois façades implémentent pour les outils de votre propre agent. | |
| 37 | +- L'échange fait partie de l'historique comme n'importe quel tour : il est enregistré avec la [session](persistent-sessions.md), rejoué par `/resume`, et résumé par la [compression du contexte](context-compression.md) quand il vieillit. | |
| 38 | +- L'utiliser : [comment interroger un autre agent](../how-to/ask-another-agent.md). Toutes les commandes et messages : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent). | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +# Interroger un autre agent — explication | ||
| 2 | + | ||
| 3 | +## De quoi s'agit-il ? | ||
| 4 | + | ||
| 5 | +`/ask-bob <prompt>` permet à l'agent avec qui vous parlez de consulter un autre agent mm : un autre modèle, un autre prompt système, d'autres outils et skills. La réponse de bob est affichée, puis gardée dans votre conversation pour que votre propre modèle s'en serve à votre question suivante. Cette page explique comment c'est construit et pourquoi. | ||
| 6 | + | ||
| 7 | +## Pourquoi c'est conçu ainsi | ||
| 8 | + | ||
| 9 | +**Un autre agent est un autre processus.** Bob tourne comme un enfant `mm -acp -config <fichier de bob>`, le même binaire que l'agent qui l'interroge. Le faire tourner dans le même processus était l'alternative évidente, et la mauvaise : la configuration de mm est une variable globale, lue par le moteur, les outils, la compaction et les sessions, et celle de bob diffère sur tous ces points. Un second moteur dans le même processus aurait d'abord demandé de démanteler cette variable globale partout. Un enfant garde chaque agent entier, avec ses propres outils, skills et serveurs MCP. | ||
| 10 | + | ||
| 11 | +**Les deux parlent ACP.** mm parle déjà l'Agent Client Protocol comme serveur, pour les éditeurs. Interroger bob fait de l'agent qui pose la question un *client* ACP de son enfant. Le protocole apporte gratuitement ce que lire un terminal n'aurait jamais donné : la réponse de bob sous forme de texte, ses appels d'outils comme des événements structurés, ses demandes de permission comme des requêtes auxquelles on peut répondre, et une façon standard d'annuler. Le serveur ACP de mm n'appelle que deux méthodes du client — les mises à jour de session et les demandes de permission — donc le côté client est petit ; les requêtes de fichiers et de terminal sont refusées comme absentes. | ||
| 12 | + | ||
| 13 | +**Les permissions de bob, c'est vous qui les donnez.** Quand bob veut lancer une commande, la demande est transmise à la façade que vous utilisez : dans un éditeur, sa propre boîte de dialogue ; dans la fenêtre, le réglage `permissions` et sa question dans la zone de saisie ; en mode ligne, rien — exactement comme pour les outils de votre propre agent à cet endroit. Les outils de bob s'appellent `bob/<outil>`, donc autoriser le `bash` de votre agent « pour cette session » n'autorise pas celui de bob. | ||
| 14 | + | ||
| 15 | +**La réponse est une paire de messages.** Elle entre dans votre historique comme un message utilisateur (`[asked bob (fournisseur/modèle): …]`) suivi d'un message de l'assistant (`[bob's answer]` et le texte). Un seul message aurait été plus simple, mais votre question suivante est aussi un message utilisateur, et plusieurs templates de chat (ceux de Gemma, de Mistral) refusent deux messages utilisateur à la suite. Les marques disent qui a dit quoi, pour que votre modèle ne prenne pas la réponse de bob pour la sienne. La réponse est plafonnée comme la sortie d'un outil, à `maxOutput` caractères : elle atterrit dans la fenêtre de votre modèle. | ||
| 16 | + | ||
| 17 | +**Chaque demande est une conversation neuve pour bob.** Bob ne garde aucune mémoire entre deux demandes. Quand il doit savoir ce que vous faisiez, `/ask-with-context-bob` envoie une transcription : questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils, qui coûteraient à sa fenêtre ce qu'elles ont déjà coûté à la vôtre, jamais votre prompt système ni le raisonnement. La continuité est explicite, choisie à chaque question, plutôt qu'une seconde conversation qui dérive de son côté. | ||
| 18 | + | ||
| 19 | +**Un enfant par agent, démarré une fois.** Démarrer mm coûte la mise en place de son moteur, la sonde du serveur et ses serveurs MCP. La première demande démarre bob ; les suivantes le réutilisent, chacune avec une nouvelle session. Il s'arrête quand votre agent se termine. | ||
| 20 | + | ||
| 21 | +**L'enfant est protégé de ce qui est destiné à son parent.** Des variables comme `AGENT_MODEL`, `AGENT_PROVIDER` ou `AGENT_BASE_URL` surchargent une configuration ; héritées par bob, elles en feraient silencieusement une copie de vous. Elles sont retirées de son environnement. Bob tourne aussi avec `AGENT_SESSIONS=off` : il travaille dans votre projet, et ses fichiers de session apparaîtraient sinon dans vos `/sessions` et `/resume`. | ||
| 22 | + | ||
| 23 | +**Les chaînes s'arrêtent.** `/ask` est une commande tapée par une personne ; aucun modèle ne peut l'appeler, donc aucun agent ne boucle tout seul. Une personne pourrait quand même taper `/ask` dans un agent lui-même interrogé, alors chaque enfant connaît sa profondeur (`MM_ASK_DEPTH`) : votre agent peut interroger bob, bob peut interroger riker, riker ne peut interroger personne. | ||
| 24 | + | ||
| 25 | +## Alternatives écartées | ||
| 26 | + | ||
| 27 | +- **`mm -p "<prompt>"` en sous-processus.** Un processus par question et rien à garder en vie — mais sa sortie est un écran de terminal (réponse, raisonnement, échos d'outils, couleurs, récapitulatif des commandes) qu'il faudrait analyser ; il ne demande jamais de permission, donc bob lancerait des commandes sans contrôle ; on ne peut l'annuler qu'en le tuant. | ||
| 28 | +- **Un second moteur dans le même processus.** Rapide, sans processus — au prix de transformer la configuration globale en configuration par agent dans tout le code. | ||
| 29 | +- **Faire répondre le modèle courant tout de suite avec l'apport de bob.** L'utilisateur a choisi autrement : la réponse de bob est affichée et gardée, et vous décidez de la question suivante. | ||
| 30 | +- **Garder la conversation de bob d'une demande à l'autre.** Deux conversations qui évoluent en parallèle, dont une invisible, sont plus difficiles à suivre qu'une transcription explicite envoyée quand on la demande. | ||
| 31 | +- **Seulement un `/ask <nom>` générique, y compris dans les éditeurs.** Le menu `/` d'un éditeur complète un nom de commande, jamais son argument ; une commande par agent est la seule façon pour que le menu liste les agents. | ||
| 32 | + | ||
| 33 | +## Lien avec le reste | ||
| 34 | + | ||
| 35 | +- L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté. | ||
| 36 | +- Ses appels d'outils et ses permissions passent par la même interface d'événements que les trois façades implémentent pour les outils de votre propre agent. | ||
| 37 | +- L'échange fait partie de l'historique comme n'importe quel tour : il est enregistré avec la [session](persistent-sessions.md), rejoué par `/resume`, et résumé par la [compression du contexte](context-compression.md) quand il vieillit. | ||
| 38 | +- L'utiliser : [comment interroger un autre agent](../how-to/ask-another-agent.md). Toutes les commandes et messages : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent). | ||
added
docs/fr/how-to/ask-another-agent.md +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +# Comment interroger un autre agent mm depuis votre session | |
| 2 | + | |
| 3 | +Ce guide montre comment poser une question à un autre agent mm — un autre modèle, un autre prompt système, d'autres outils — et garder sa réponse dans votre conversation. Il suppose que vous faites déjà tourner `mm` avec un fournisseur qui marche. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Écrivez (ou réutilisez) un fichier de configuration par agent à interroger. Chacun est une configuration mm ordinaire ; `demo-llmman/` en fournit deux, pour llmman : | |
| 8 | + | |
| 9 | + - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, nommé Jan ; | |
| 10 | + - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, nommée Lucy. | |
| 11 | + | |
| 12 | +2. Déclarez-les dans VOTRE configuration, sous `mmConfigs`. Les chemins sont relatifs au dossier du fichier où vous les écrivez : | |
| 13 | + | |
| 14 | + ```yaml | |
| 15 | + mmConfigs: | |
| 16 | + jan: agent.jan-nano.yaml | |
| 17 | + lucy: agent.lucy.yaml | |
| 18 | + ``` | |
| 19 | + | |
| 20 | + `demo-llmman/agent.llmman.yaml` le fait déjà. | |
| 21 | + | |
| 22 | +3. Lancez `mm` avec cette configuration : | |
| 23 | + | |
| 24 | + ```bash | |
| 25 | + cd demo-llmman | |
| 26 | + ../mm agent.llmman.yaml | |
| 27 | + ``` | |
| 28 | + | |
| 29 | + Un agent dont le fichier manque apparaît sur une ligne `[warning: mmConfigs: …]` ; les autres fonctionnent. | |
| 30 | + | |
| 31 | +4. Posez une question : | |
| 32 | + | |
| 33 | + ```text | |
| 34 | + > /ask-jan In one short sentence: what is the capital of France? | |
| 35 | + 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)… | |
| 36 | + The capital of France is Paris. | |
| 37 | + 🗣 jan's answer is in the history for your next question | |
| 38 | + ``` | |
| 39 | + | |
| 40 | + La première demande à un agent le démarre, elle prend donc un peu plus de temps ; les suivantes le réutilisent. | |
| 41 | + | |
| 42 | +5. Servez-vous de la réponse : votre question suivante va à votre propre modèle, qui voit maintenant la réponse de Jan. | |
| 43 | + | |
| 44 | + ```text | |
| 45 | + > What did Jan answer? | |
| 46 | + 🤖 Jan answered that the capital of France is Paris. | |
| 47 | + ``` | |
| 48 | + | |
| 49 | +## Variantes | |
| 50 | + | |
| 51 | +- **Laisser l'autre agent voir la conversation.** Utilisez `/ask-with-context-lucy <prompt>` : Lucy reçoit une transcription de la conversation (questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils) avant votre question. | |
| 52 | +- **Dans le terminal ou la fenêtre, par nom.** `/ask lucy <prompt>` et `/ask-with-context lucy <prompt>` sont les mêmes commandes, avec l'agent en argument. | |
| 53 | +- **Dans un éditeur (ACP).** Le menu liste `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy` ; quand Lucy veut lancer une commande, c'est la boîte de dialogue de permission de l'éditeur qui vous le demande. | |
| 54 | +- **Arrêter une longue réponse.** `/abort`, `Échap` ou `Ctrl+C` — rien n'est gardé d'une demande annulée. | |
| 55 | +- **Des agents sur d'autres fournisseurs.** N'importe quelle configuration mm convient : un agent Ollama à côté d'un agent llmman n'est qu'une entrée de plus dans `mmConfigs`. | |
| 56 | + | |
| 57 | +## Voir aussi | |
| 58 | + | |
| 59 | +- Toutes les formes, messages et erreurs : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent) | |
| 60 | +- La clé `mmConfigs` : [référence de configuration](../reference/configuration.md) | |
| 61 | +- Pourquoi un autre agent est un processus à part, interrogé en ACP : [interroger un autre agent — explication](../explanation/asking-another-agent.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +# Comment interroger un autre agent mm depuis votre session | ||
| 2 | + | ||
| 3 | +Ce guide montre comment poser une question à un autre agent mm — un autre modèle, un autre prompt système, d'autres outils — et garder sa réponse dans votre conversation. Il suppose que vous faites déjà tourner `mm` avec un fournisseur qui marche. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Écrivez (ou réutilisez) un fichier de configuration par agent à interroger. Chacun est une configuration mm ordinaire ; `demo-llmman/` en fournit deux, pour llmman : | ||
| 8 | + | ||
| 9 | + - `demo-llmman/agent.jan-nano.yaml` — `hf.co/Menlo/Jan-nano-gguf:Q4_K_M`, nommé Jan ; | ||
| 10 | + - `demo-llmman/agent.lucy.yaml` — `hf.co/Menlo/Lucy-gguf:Q4_K_M`, nommée Lucy. | ||
| 11 | + | ||
| 12 | +2. Déclarez-les dans VOTRE configuration, sous `mmConfigs`. Les chemins sont relatifs au dossier du fichier où vous les écrivez : | ||
| 13 | + | ||
| 14 | + ```yaml | ||
| 15 | + mmConfigs: | ||
| 16 | + jan: agent.jan-nano.yaml | ||
| 17 | + lucy: agent.lucy.yaml | ||
| 18 | + ``` | ||
| 19 | + | ||
| 20 | + `demo-llmman/agent.llmman.yaml` le fait déjà. | ||
| 21 | + | ||
| 22 | +3. Lancez `mm` avec cette configuration : | ||
| 23 | + | ||
| 24 | + ```bash | ||
| 25 | + cd demo-llmman | ||
| 26 | + ../mm agent.llmman.yaml | ||
| 27 | + ``` | ||
| 28 | + | ||
| 29 | + Un agent dont le fichier manque apparaît sur une ligne `[warning: mmConfigs: …]` ; les autres fonctionnent. | ||
| 30 | + | ||
| 31 | +4. Posez une question : | ||
| 32 | + | ||
| 33 | + ```text | ||
| 34 | + > /ask-jan In one short sentence: what is the capital of France? | ||
| 35 | + 🗣 asking jan (llmman/hf.co/Menlo/Jan-nano-gguf:Q4_K_M)… | ||
| 36 | + The capital of France is Paris. | ||
| 37 | + 🗣 jan's answer is in the history for your next question | ||
| 38 | + ``` | ||
| 39 | + | ||
| 40 | + La première demande à un agent le démarre, elle prend donc un peu plus de temps ; les suivantes le réutilisent. | ||
| 41 | + | ||
| 42 | +5. Servez-vous de la réponse : votre question suivante va à votre propre modèle, qui voit maintenant la réponse de Jan. | ||
| 43 | + | ||
| 44 | + ```text | ||
| 45 | + > What did Jan answer? | ||
| 46 | + 🤖 Jan answered that the capital of France is Paris. | ||
| 47 | + ``` | ||
| 48 | + | ||
| 49 | +## Variantes | ||
| 50 | + | ||
| 51 | +- **Laisser l'autre agent voir la conversation.** Utilisez `/ask-with-context-lucy <prompt>` : Lucy reçoit une transcription de la conversation (questions, réponses, une ligne par appel d'outil — jamais les sorties d'outils) avant votre question. | ||
| 52 | +- **Dans le terminal ou la fenêtre, par nom.** `/ask lucy <prompt>` et `/ask-with-context lucy <prompt>` sont les mêmes commandes, avec l'agent en argument. | ||
| 53 | +- **Dans un éditeur (ACP).** Le menu liste `/ask-jan`, `/ask-with-context-jan`, `/ask-lucy`, `/ask-with-context-lucy` ; quand Lucy veut lancer une commande, c'est la boîte de dialogue de permission de l'éditeur qui vous le demande. | ||
| 54 | +- **Arrêter une longue réponse.** `/abort`, `Échap` ou `Ctrl+C` — rien n'est gardé d'une demande annulée. | ||
| 55 | +- **Des agents sur d'autres fournisseurs.** N'importe quelle configuration mm convient : un agent Ollama à côté d'un agent llmman n'est qu'une entrée de plus dans `mmConfigs`. | ||
| 56 | + | ||
| 57 | +## Voir aussi | ||
| 58 | + | ||
| 59 | +- Toutes les formes, messages et erreurs : [référence des commandes slash](../reference/slash-commands.md#interroger-un-autre-agent) | ||
| 60 | +- La clé `mmConfigs` : [référence de configuration](../reference/configuration.md) | ||
| 61 | +- Pourquoi un autre agent est un processus à part, interrogé en ACP : [interroger un autre agent — explication](../explanation/asking-another-agent.md) | ||
modified
docs/fr/reference/cli.md +3 -1 | @@ -55,6 +55,8 @@ La première source existante gagne. | ||
| 55 | 55 | | `AGENT_MODEL` | Surcharge la clé `model`. | |
| 56 | 56 | | `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. | |
| 57 | 57 | | `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. | |
| 58 | +| `AGENT_SESSIONS` | `off` désactive l'enregistrement des sessions pour cette exécution, quoi que dise `sessions.enabled`. Posée par `/ask` sur les agents qu'il lance. | | |
| 59 | +| `MM_ASK_DEPTH` | Nombre de niveaux de `/ask` au-dessus de cet agent (absente = 0). Posée par `/ask` ; à 2, `/ask` est refusée. | | |
| 58 | 60 | | Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`, `LLMMAN_API_KEY` pour `llmman`, `OLLAMA_API_KEY` pour `ollama`. | |
| 59 | 61 | |
| 60 | 62 | ## Sortie de démarrage |
| @@ -63,7 +65,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std | ||
| 63 | 65 | |
| 64 | 66 | | Ligne | Contenu | |
| 65 | 67 | |-------|---------| |
| 66 | -| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré. | | |
| 68 | +| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`). | | |
| 67 | 69 | | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. | |
| 68 | 70 | | `Agent ready. Commands: …` | Mode terminal seulement. | |
| 69 | 71 | | `Shortcut: Ctrl+C …` | Mode terminal seulement. | |
| @@ -55,6 +55,8 @@ La première source existante gagne. | |||
| 55 | | `AGENT_MODEL` | Surcharge la clé `model`. | | 55 | | `AGENT_MODEL` | Surcharge la clé `model`. | |
| 56 | | `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. | | 56 | | `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. | |
| 57 | | `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. | | 57 | | `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. | |
| 58 | +| `AGENT_SESSIONS` | `off` désactive l'enregistrement des sessions pour cette exécution, quoi que dise `sessions.enabled`. Posée par `/ask` sur les agents qu'il lance. | | ||
| 59 | +| `MM_ASK_DEPTH` | Nombre de niveaux de `/ask` au-dessus de cet agent (absente = 0). Posée par `/ask` ; à 2, `/ask` est refusée. | | ||
| 58 | | Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`, `LLMMAN_API_KEY` pour `llmman`, `OLLAMA_API_KEY` pour `ollama`. | | 60 | | Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`, `LLMMAN_API_KEY` pour `llmman`, `OLLAMA_API_KEY` pour `ollama`. | |
| 59 | 61 | ||
| 60 | ## Sortie de démarrage | 62 | ## Sortie de démarrage |
| @@ -63,7 +65,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std | |||
| 63 | 65 | ||
| 64 | | Ligne | Contenu | | 66 | | Ligne | Contenu | |
| 65 | |-------|---------| | 67 | |-------|---------| |
| 66 | -| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré. | | 68 | +| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré, un fichier de `mmConfigs` introuvable (`mmConfigs: <name> → <path> not found — /ask-<name> will fail`). | |
| 67 | | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. | | 69 | | `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. | |
| 68 | | `Agent ready. Commands: …` | Mode terminal seulement. | | 70 | | `Agent ready. Commands: …` | Mode terminal seulement. | |
| 69 | | `Shortcut: Ctrl+C …` | Mode terminal seulement. | | 71 | | `Shortcut: Ctrl+C …` | Mode terminal seulement. | |
modified
docs/fr/reference/configuration.md +4 -0 | @@ -31,6 +31,7 @@ | ||
| 31 | 31 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 32 | 32 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | |
| 33 | 33 | | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. | |
| 34 | +| `mmConfigs` | dictionnaire nom → chemin | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)). Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. | | |
| 34 | 35 | |
| 35 | 36 | ## Clés de `context` |
| 36 | 37 | |
| @@ -117,6 +118,9 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1 | ||
| 117 | 118 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point | |
| 118 | 119 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension | |
| 119 | 120 | | `permissions must be "auto" or "ask"` | toute autre valeur | |
| 121 | +| `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | | |
| 122 | +| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | | |
| 123 | +| `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""` | | |
| 120 | 124 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 121 | 125 | |
| 122 | 126 | Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`. |
| @@ -31,6 +31,7 @@ | |||
| 31 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | | 31 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 32 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | | 32 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | |
| 33 | | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. | | 33 | | `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. | |
| 34 | +| `mmConfigs` | dictionnaire nom → chemin | aucun | Les autres agents mm que celui-ci peut interroger. Chaque nom génère `/ask-<nom>` et `/ask-with-context-<nom>` (voir les [commandes slash](slash-commands.md#interroger-un-autre-agent)). Un chemin relatif l'est au dossier du fichier qui le déclare. Noms : lettres minuscules, chiffres et `-`, commençant par une lettre ou un chiffre, ne commençant pas par `with-context`. Un chemin dont le fichier n'existe pas donne un avertissement au démarrage, pas une erreur. | | ||
| 34 | 35 | ||
| 35 | ## Clés de `context` | 36 | ## Clés de `context` |
| 36 | 37 | ||
| @@ -117,6 +118,9 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1 | |||
| 117 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point | | 118 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point | |
| 118 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension | | 119 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension | |
| 119 | | `permissions must be "auto" or "ask"` | toute autre valeur | | 120 | | `permissions must be "auto" or "ask"` | toute autre valeur | |
| 121 | +| `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | | ||
| 122 | +| `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | | ||
| 123 | +| `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""` | | ||
| 120 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | | 124 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 121 | 125 | ||
| 122 | Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`. | 126 | Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`. |
modified
docs/fr/reference/slash-commands.md +45 -0 | @@ -17,6 +17,49 @@ | ||
| 17 | 17 | |
| 18 | 18 | Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée. |
| 19 | 19 | |
| 20 | +## Interroger un autre agent | |
| 21 | + | |
| 22 | +Présentes seulement quand `mmConfigs` déclare au moins un agent ; `/help` et le sélecteur `/` les listent. `<nom>` est une clé de `mmConfigs`. | |
| 23 | + | |
| 24 | +| Commande | Façades | Effet | | |
| 25 | +|----------|---------|-------| | |
| 26 | +| `/ask-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie `<prompt>` seul à `<nom>`. | | |
| 27 | +| `/ask-with-context-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie une transcription de la conversation courante, puis `<prompt>`. | | |
| 28 | +| `/ask <nom> <prompt>` | terminal, fenêtre | Comme `/ask-<nom> <prompt>`. | | |
| 29 | +| `/ask-with-context <nom> <prompt>` | terminal, fenêtre | Comme `/ask-with-context-<nom> <prompt>`. | | |
| 30 | + | |
| 31 | +Pendant l'exécution : | |
| 32 | + | |
| 33 | +| Élément | Comportement | | |
| 34 | +|---------|--------------| | |
| 35 | +| Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. | | |
| 36 | +| Réponse de `<nom>` | Affichée au fil de l'eau là où s'affichent les réponses. Son raisonnement est affiché comme un raisonnement quand `showThinking` est actif. | | |
| 37 | +| Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. | | |
| 38 | +| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` | | |
| 39 | +| `/quit` | Annule la demande et quitte. | | |
| 40 | +| Toute autre ligne (mode ligne) | `⚠️ <nom> is still answering — /abort to stop it.` ; la ligne est ignorée. | | |
| 41 | + | |
| 42 | +À la fin : | |
| 43 | + | |
| 44 | +| Issue | Historique | Message | | |
| 45 | +|-------|------------|---------| | |
| 46 | +| Réponse obtenue | Deux messages ajoutés : utilisateur `[asked <nom> (<fournisseur>/<modèle>)[, with this conversation as context]: <prompt>]`, assistant `[<nom>'s answer]` + la réponse (plafonnée à `maxOutput` caractères, début et fin conservés). Enregistrés avec la session. | `🗣 <nom>'s answer is in the history for your next question` | | |
| 47 | +| Annulée | Inchangé. | `🛑 Ask aborted — nothing kept.` | | |
| 48 | +| Échec | Inchangé ; enregistré dans le fichier de session avec `where: "ask"`. | `[error: …]` (terminal, fenêtre) ou une erreur JSON-RPC (éditeur). | | |
| 49 | + | |
| 50 | +La transcription envoyée par les formes avec contexte contient les questions de l'utilisateur, les réponses de l'assistant, une ligne par appel d'outil (`Assistant ran <outil>: <arguments>`, arguments coupés à 160 caractères) et le résumé de compaction s'il existe ; jamais le prompt système, les sorties d'outils ni le raisonnement. Plafonnée à `maxOutput` caractères, en gardant les lignes les plus récentes, `[… earlier conversation omitted …]` marquant la coupure. | |
| 51 | + | |
| 52 | +| Erreur | Cause | | |
| 53 | +|--------|-------| | |
| 54 | +| `usage: /ask <agent> <prompt> — agents: …` | `/ask` sans nom. | | |
| 55 | +| `unknown agent "x" — agents: …` | `/ask x …` avec un nom absent de `mmConfigs`. | | |
| 56 | +| `nothing to ask: type the question after the command` | Pas de prompt. | | |
| 57 | +| `<nom>: config file <chemin>: no such file or directory` | Le fichier de `mmConfigs.<nom>` n'existe pas. | | |
| 58 | +| `<nom> did not start: … — <dernière ligne de stderr>` | Le processus de l'agent s'est arrêté ou n'a pas répondu à `initialize` en 60 s. | | |
| 59 | +| `<nom> failed: …` | Le tour de l'agent s'est terminé sur une erreur (sa propre explication en une ligne). | | |
| 60 | +| `<nom> gave no answer` | L'agent a terminé son tour sans aucun texte. | | |
| 61 | +| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` vaut 2. | | |
| 62 | + | |
| 20 | 63 | ## `@chemin` dans une question |
| 21 | 64 | |
| 22 | 65 | | Saisie | Effet | |
| @@ -134,4 +177,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée | ||
| 134 | 177 | |
| 135 | 178 | Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes). |
| 136 | 179 | |
| 180 | +Avec `mmConfigs`, `/ask-<nom>` et `/ask-with-context-<nom>` sont annoncées aussi, chacune avec l'indication de saisie `<prompt>` ; le `/ask <nom>` générique ne l'est pas, car le menu d'un éditeur complète un nom de commande mais pas son argument. Voir [Interroger un autre agent](#interroger-un-autre-agent). | |
| 181 | + | |
| 137 | 182 | `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). |
| @@ -17,6 +17,49 @@ | |||
| 17 | 17 | ||
| 18 | Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée. | 18 | Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée. |
| 19 | 19 | ||
| 20 | +## Interroger un autre agent | ||
| 21 | + | ||
| 22 | +Présentes seulement quand `mmConfigs` déclare au moins un agent ; `/help` et le sélecteur `/` les listent. `<nom>` est une clé de `mmConfigs`. | ||
| 23 | + | ||
| 24 | +| Commande | Façades | Effet | | ||
| 25 | +|----------|---------|-------| | ||
| 26 | +| `/ask-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie `<prompt>` seul à `<nom>`. | | ||
| 27 | +| `/ask-with-context-<nom> <prompt>` | terminal, fenêtre, éditeur | Envoie une transcription de la conversation courante, puis `<prompt>`. | | ||
| 28 | +| `/ask <nom> <prompt>` | terminal, fenêtre | Comme `/ask-<nom> <prompt>`. | | ||
| 29 | +| `/ask-with-context <nom> <prompt>` | terminal, fenêtre | Comme `/ask-with-context-<nom> <prompt>`. | | ||
| 30 | + | ||
| 31 | +Pendant l'exécution : | ||
| 32 | + | ||
| 33 | +| Élément | Comportement | | ||
| 34 | +|---------|--------------| | ||
| 35 | +| Première ligne | `🗣 asking <nom> (<fournisseur>/<modèle>)…`, ou `…, with the conversation…` pour les formes avec contexte. | | ||
| 36 | +| Réponse de `<nom>` | Affichée au fil de l'eau là où s'affichent les réponses. Son raisonnement est affiché comme un raisonnement quand `showThinking` est actif. | | ||
| 37 | +| Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. | | ||
| 38 | +| `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` | | ||
| 39 | +| `/quit` | Annule la demande et quitte. | | ||
| 40 | +| Toute autre ligne (mode ligne) | `⚠️ <nom> is still answering — /abort to stop it.` ; la ligne est ignorée. | | ||
| 41 | + | ||
| 42 | +À la fin : | ||
| 43 | + | ||
| 44 | +| Issue | Historique | Message | | ||
| 45 | +|-------|------------|---------| | ||
| 46 | +| Réponse obtenue | Deux messages ajoutés : utilisateur `[asked <nom> (<fournisseur>/<modèle>)[, with this conversation as context]: <prompt>]`, assistant `[<nom>'s answer]` + la réponse (plafonnée à `maxOutput` caractères, début et fin conservés). Enregistrés avec la session. | `🗣 <nom>'s answer is in the history for your next question` | | ||
| 47 | +| Annulée | Inchangé. | `🛑 Ask aborted — nothing kept.` | | ||
| 48 | +| Échec | Inchangé ; enregistré dans le fichier de session avec `where: "ask"`. | `[error: …]` (terminal, fenêtre) ou une erreur JSON-RPC (éditeur). | | ||
| 49 | + | ||
| 50 | +La transcription envoyée par les formes avec contexte contient les questions de l'utilisateur, les réponses de l'assistant, une ligne par appel d'outil (`Assistant ran <outil>: <arguments>`, arguments coupés à 160 caractères) et le résumé de compaction s'il existe ; jamais le prompt système, les sorties d'outils ni le raisonnement. Plafonnée à `maxOutput` caractères, en gardant les lignes les plus récentes, `[… earlier conversation omitted …]` marquant la coupure. | ||
| 51 | + | ||
| 52 | +| Erreur | Cause | | ||
| 53 | +|--------|-------| | ||
| 54 | +| `usage: /ask <agent> <prompt> — agents: …` | `/ask` sans nom. | | ||
| 55 | +| `unknown agent "x" — agents: …` | `/ask x …` avec un nom absent de `mmConfigs`. | | ||
| 56 | +| `nothing to ask: type the question after the command` | Pas de prompt. | | ||
| 57 | +| `<nom>: config file <chemin>: no such file or directory` | Le fichier de `mmConfigs.<nom>` n'existe pas. | | ||
| 58 | +| `<nom> did not start: … — <dernière ligne de stderr>` | Le processus de l'agent s'est arrêté ou n'a pas répondu à `initialize` en 60 s. | | ||
| 59 | +| `<nom> failed: …` | Le tour de l'agent s'est terminé sur une erreur (sa propre explication en une ligne). | | ||
| 60 | +| `<nom> gave no answer` | L'agent a terminé son tour sans aucun texte. | | ||
| 61 | +| `this agent was itself asked through 2 level(s) of /ask; it cannot ask further (max 2)` | `MM_ASK_DEPTH` vaut 2. | | ||
| 62 | + | ||
| 20 | ## `@chemin` dans une question | 63 | ## `@chemin` dans une question |
| 21 | 64 | ||
| 22 | | Saisie | Effet | | 65 | | Saisie | Effet | |
| @@ -134,4 +177,6 @@ Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncée | |||
| 134 | 177 | ||
| 135 | Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes). | 178 | Chaque **skill** y est proposé comme une commande à part entière : ACP n'a pas de sélecteur pour les skills, donc `/greetings Bob` dans le menu de l'éditeur est l'équivalent de `$greetings Bob` dans la fenêtre, et il est réécrit en exactement ça — voir la [référence ACP](acp.md#les-skills-comme-commandes). |
| 136 | 179 | ||
| 180 | +Avec `mmConfigs`, `/ask-<nom>` et `/ask-with-context-<nom>` sont annoncées aussi, chacune avec l'indication de saisie `<prompt>` ; le `/ask <nom>` générique ne l'est pas, car le menu d'un éditeur complète un nom de commande mais pas son argument. Voir [Interroger un autre agent](#interroger-un-autre-agent). | ||
| 181 | + | ||
| 137 | `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). | 182 | `/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). |
modified
internal/acp/acp.go +22 -3 | @@ -29,6 +29,7 @@ import ( | ||
| 29 | 29 | "strings" |
| 30 | 30 | "sync" |
| 31 | 31 | |
| 32 | + "mm/internal/ask" | |
| 32 | 33 | "mm/internal/command" |
| 33 | 34 | "mm/internal/compact" |
| 34 | 35 | "mm/internal/config" |
| @@ -76,6 +77,9 @@ type front struct { | ||
| 76 | 77 | // so one running agent keeps one store per project. |
| 77 | 78 | sessionsDir string |
| 78 | 79 | |
| 80 | + // asker runs /ask-<agent> (internal/ask); nil when none is configured. | |
| 81 | + asker ask.Runner | |
| 82 | + | |
| 79 | 83 | mu sync.Mutex // guards sessions |
| 80 | 84 | sessions map[sdk.SessionId]*session |
| 81 | 85 | |
| @@ -91,8 +95,11 @@ var _ sdk.Agent = (*front)(nil) | ||
| 91 | 95 | // Run wires the agent side of an ACP connection on stdio and blocks until the |
| 92 | 96 | // client closes it. Same arguments as agent.Run: main.go's wiring — engine, |
| 93 | 97 | // system prompt, tool list — is identical for the two front ends. |
| 94 | -func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) { | |
| 98 | +// | |
| 99 | +// asker runs the /ask-<agent> commands; nil when no agent is configured. | |
| 100 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, asker ask.Runner) { | |
| 95 | 101 | f := newFront(e, system, tools, os.Stdout, os.Stdin) |
| 102 | + f.asker = asker | |
| 96 | 103 | select { |
| 97 | 104 | case <-f.conn.Done(): // the editor closed our stdin: we are done |
| 98 | 105 | case <-ctx.Done(): |
| @@ -281,7 +288,13 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | ||
| 281 | 288 | func availableCommands() []sdk.AvailableCommand { |
| 282 | 289 | var out []sdk.AvailableCommand |
| 283 | 290 | for _, c := range command.For(command.Editor) { |
| 284 | - out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description}) | |
| 291 | + cmd := sdk.AvailableCommand{Name: c.Name, Description: c.Description} | |
| 292 | + if c.Hint != "" { | |
| 293 | + // /ask-bob takes the question: the editor shows the hint where | |
| 294 | + // it goes, as it does for a skill's parameters. | |
| 295 | + cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.Hint}} | |
| 296 | + } | |
| 297 | + out = append(out, cmd) | |
| 285 | 298 | } |
| 286 | 299 | for _, s := range skills.Declared() { |
| 287 | 300 | if commandNamed(s.Name) { |
| @@ -370,9 +383,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 370 | 383 | |
| 371 | 384 | // The sink is what turns the tools' events into session/update messages; |
| 372 | 385 | // installing it is what switches engine+tools to "ACP mode" for this turn. |
| 373 | - ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s}) | |
| 386 | + k := &sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s} | |
| 387 | + ui.SetActive(k) | |
| 374 | 388 | defer ui.SetActive(nil) |
| 375 | 389 | |
| 390 | + // /ask-bob: another agent answers instead of our model (ask.go). | |
| 391 | + if req, isAsk, err := command.ParseAsk(input); isAsk { | |
| 392 | + return f.askAgent(ctx, turnCtx, p.SessionId, s, k, req, err) | |
| 393 | + } | |
| 394 | + | |
| 376 | 395 | // A typed "@path" — the editor's picker bypassed, or an editor without |
| 377 | 396 | // one — is understood like the resource_link the picker would have sent, |
| 378 | 397 | // against the session's cwd, which is the project the editor opened. |
| @@ -29,6 +29,7 @@ import ( | |||
| 29 | "strings" | 29 | "strings" |
| 30 | "sync" | 30 | "sync" |
| 31 | 31 | ||
| 32 | + "mm/internal/ask" | ||
| 32 | "mm/internal/command" | 33 | "mm/internal/command" |
| 33 | "mm/internal/compact" | 34 | "mm/internal/compact" |
| 34 | "mm/internal/config" | 35 | "mm/internal/config" |
| @@ -76,6 +77,9 @@ type front struct { | |||
| 76 | // so one running agent keeps one store per project. | 77 | // so one running agent keeps one store per project. |
| 77 | sessionsDir string | 78 | sessionsDir string |
| 78 | 79 | ||
| 80 | + // asker runs /ask-<agent> (internal/ask); nil when none is configured. | ||
| 81 | + asker ask.Runner | ||
| 82 | + | ||
| 79 | mu sync.Mutex // guards sessions | 83 | mu sync.Mutex // guards sessions |
| 80 | sessions map[sdk.SessionId]*session | 84 | sessions map[sdk.SessionId]*session |
| 81 | 85 | ||
| @@ -91,8 +95,11 @@ var _ sdk.Agent = (*front)(nil) | |||
| 91 | // Run wires the agent side of an ACP connection on stdio and blocks until the | 95 | // Run wires the agent side of an ACP connection on stdio and blocks until the |
| 92 | // client closes it. Same arguments as agent.Run: main.go's wiring — engine, | 96 | // client closes it. Same arguments as agent.Run: main.go's wiring — engine, |
| 93 | // system prompt, tool list — is identical for the two front ends. | 97 | // system prompt, tool list — is identical for the two front ends. |
| 94 | -func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) { | 98 | +// |
| 99 | +// asker runs the /ask-<agent> commands; nil when no agent is configured. | ||
| 100 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, asker ask.Runner) { | ||
| 95 | f := newFront(e, system, tools, os.Stdout, os.Stdin) | 101 | f := newFront(e, system, tools, os.Stdout, os.Stdin) |
| 102 | + f.asker = asker | ||
| 96 | select { | 103 | select { |
| 97 | case <-f.conn.Done(): // the editor closed our stdin: we are done | 104 | case <-f.conn.Done(): // the editor closed our stdin: we are done |
| 98 | case <-ctx.Done(): | 105 | case <-ctx.Done(): |
| @@ -281,7 +288,13 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | |||
| 281 | func availableCommands() []sdk.AvailableCommand { | 288 | func availableCommands() []sdk.AvailableCommand { |
| 282 | var out []sdk.AvailableCommand | 289 | var out []sdk.AvailableCommand |
| 283 | for _, c := range command.For(command.Editor) { | 290 | for _, c := range command.For(command.Editor) { |
| 284 | - out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description}) | 291 | + cmd := sdk.AvailableCommand{Name: c.Name, Description: c.Description} |
| 292 | + if c.Hint != "" { | ||
| 293 | + // /ask-bob takes the question: the editor shows the hint where | ||
| 294 | + // it goes, as it does for a skill's parameters. | ||
| 295 | + cmd.Input = &sdk.AvailableCommandInput{Unstructured: &sdk.UnstructuredCommandInput{Hint: c.Hint}} | ||
| 296 | + } | ||
| 297 | + out = append(out, cmd) | ||
| 285 | } | 298 | } |
| 286 | for _, s := range skills.Declared() { | 299 | for _, s := range skills.Declared() { |
| 287 | if commandNamed(s.Name) { | 300 | if commandNamed(s.Name) { |
| @@ -370,9 +383,15 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | |||
| 370 | 383 | ||
| 371 | // The sink is what turns the tools' events into session/update messages; | 384 | // The sink is what turns the tools' events into session/update messages; |
| 372 | // installing it is what switches engine+tools to "ACP mode" for this turn. | 385 | // installing it is what switches engine+tools to "ACP mode" for this turn. |
| 373 | - ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s}) | 386 | + k := &sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s} |
| 387 | + ui.SetActive(k) | ||
| 374 | defer ui.SetActive(nil) | 388 | defer ui.SetActive(nil) |
| 375 | 389 | ||
| 390 | + // /ask-bob: another agent answers instead of our model (ask.go). | ||
| 391 | + if req, isAsk, err := command.ParseAsk(input); isAsk { | ||
| 392 | + return f.askAgent(ctx, turnCtx, p.SessionId, s, k, req, err) | ||
| 393 | + } | ||
| 394 | + | ||
| 376 | // A typed "@path" — the editor's picker bypassed, or an editor without | 395 | // A typed "@path" — the editor's picker bypassed, or an editor without |
| 377 | // one — is understood like the resource_link the picker would have sent, | 396 | // one — is understood like the resource_link the picker would have sent, |
| 378 | // against the session's cwd, which is the project the editor opened. | 397 | // against the session's cwd, which is the project the editor opened. |
added
internal/acp/ask.go +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + | |
| 8 | + "mm/internal/ask" | |
| 9 | + "mm/internal/command" | |
| 10 | + "mm/internal/store" | |
| 11 | + | |
| 12 | + sdk "github.com/coder/acp-go-sdk" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// askAgent is /ask-<agent> in an editor: the other agent's answer streams | |
| 16 | +// into the thread as agent text, its tool calls as tool calls (named after | |
| 17 | +// it), its permission requests as this session's own dialog — and the | |
| 18 | +// exchange joins the history for the next question. Like a turn, it can be | |
| 19 | +// cancelled (stopReason "cancelled"), and a failure is a JSON-RPC error in the | |
| 20 | +// agent's words and a record in the session file; either way the history is | |
| 21 | +// left as it was. | |
| 22 | +func (f *front) askAgent(ctx, turnCtx context.Context, sid sdk.SessionId, s *session, k *sink, req command.AskRequest, parseErr error) (sdk.PromptResponse, error) { | |
| 23 | + if parseErr != nil { | |
| 24 | + k.Text("⚠️ " + parseErr.Error()) | |
| 25 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil | |
| 26 | + } | |
| 27 | + k.Text(ask.Label(req) + "\n\n") | |
| 28 | + exchange, err := f.asker.Run(turnCtx, req, s.messages, s.cwd, k) | |
| 29 | + switch { | |
| 30 | + case turnCtx.Err() != nil: | |
| 31 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil | |
| 32 | + case err != nil: | |
| 33 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s failed: %v\n", sid, req.Agent, err) | |
| 34 | + f.fail(sid, s, store.NewFailure(s.messages, store.FailAsk, err.Error(), err)) | |
| 35 | + return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": err.Error()}) | |
| 36 | + } | |
| 37 | + s.messages = append(s.messages, exchange...) | |
| 38 | + f.persist(sid, s) | |
| 39 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s answered\n", sid, req.Agent) | |
| 40 | + f.sendUsage(context.WithoutCancel(ctx), sid, contextUsed(s)) | |
| 41 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "os" | ||
| 7 | + | ||
| 8 | + "mm/internal/ask" | ||
| 9 | + "mm/internal/command" | ||
| 10 | + "mm/internal/store" | ||
| 11 | + | ||
| 12 | + sdk "github.com/coder/acp-go-sdk" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// askAgent is /ask-<agent> in an editor: the other agent's answer streams | ||
| 16 | +// into the thread as agent text, its tool calls as tool calls (named after | ||
| 17 | +// it), its permission requests as this session's own dialog — and the | ||
| 18 | +// exchange joins the history for the next question. Like a turn, it can be | ||
| 19 | +// cancelled (stopReason "cancelled"), and a failure is a JSON-RPC error in the | ||
| 20 | +// agent's words and a record in the session file; either way the history is | ||
| 21 | +// left as it was. | ||
| 22 | +func (f *front) askAgent(ctx, turnCtx context.Context, sid sdk.SessionId, s *session, k *sink, req command.AskRequest, parseErr error) (sdk.PromptResponse, error) { | ||
| 23 | + if parseErr != nil { | ||
| 24 | + k.Text("⚠️ " + parseErr.Error()) | ||
| 25 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil | ||
| 26 | + } | ||
| 27 | + k.Text(ask.Label(req) + "\n\n") | ||
| 28 | + exchange, err := f.asker.Run(turnCtx, req, s.messages, s.cwd, k) | ||
| 29 | + switch { | ||
| 30 | + case turnCtx.Err() != nil: | ||
| 31 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled, Usage: s.usage.totals()}, nil | ||
| 32 | + case err != nil: | ||
| 33 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s failed: %v\n", sid, req.Agent, err) | ||
| 34 | + f.fail(sid, s, store.NewFailure(s.messages, store.FailAsk, err.Error(), err)) | ||
| 35 | + return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": err.Error()}) | ||
| 36 | + } | ||
| 37 | + s.messages = append(s.messages, exchange...) | ||
| 38 | + f.persist(sid, s) | ||
| 39 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /ask %s answered\n", sid, req.Agent) | ||
| 40 | + f.sendUsage(context.WithoutCancel(ctx), sid, contextUsed(s)) | ||
| 41 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn, Usage: s.usage.totals()}, nil | ||
| 42 | +} | ||
added
internal/acp/ask_test.go +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | + | |
| 8 | + "mm/internal/command" | |
| 9 | + "mm/internal/config" | |
| 10 | + "mm/internal/engine" | |
| 11 | + "mm/internal/ui" | |
| 12 | + | |
| 13 | + sdk "github.com/coder/acp-go-sdk" | |
| 14 | + | |
| 15 | + "github.com/firebase/genkit/go/ai" | |
| 16 | +) | |
| 17 | + | |
| 18 | +// fakeAsker answers through the sink, as the real pool streams. | |
| 19 | +type fakeAsker struct{ answer string } | |
| 20 | + | |
| 21 | +func (a fakeAsker) Run(_ context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) { | |
| 22 | + sink.Text(a.answer) | |
| 23 | + return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil | |
| 24 | +} | |
| 25 | + | |
| 26 | +// withBob configures one agent and its generated commands for one test. | |
| 27 | +func withBob(t *testing.T) { | |
| 28 | + t.Helper() | |
| 29 | + saved := config.Cfg | |
| 30 | + t.Cleanup(func() { config.Cfg = saved; command.SetAgents(nil) }) | |
| 31 | + config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"} | |
| 32 | + command.SetAgents([]string{"bob"}) | |
| 33 | +} | |
| 34 | + | |
| 35 | +// The editor is offered one command per agent, each with the <prompt> hint, | |
| 36 | +// and never the generic /ask, whose argument it could not complete. | |
| 37 | +func TestAskCommandsAreAnnouncedPerAgent(t *testing.T) { | |
| 38 | + withBob(t) | |
| 39 | + hints := map[string]string{} | |
| 40 | + for _, c := range availableCommands() { | |
| 41 | + if c.Input != nil && c.Input.Unstructured != nil { | |
| 42 | + hints[c.Name] = c.Input.Unstructured.Hint | |
| 43 | + } else { | |
| 44 | + hints[c.Name] = "" | |
| 45 | + } | |
| 46 | + } | |
| 47 | + for _, name := range []string{"ask-bob", "ask-with-context-bob"} { | |
| 48 | + if hint, ok := hints[name]; !ok || hint != "<prompt>" { | |
| 49 | + t.Errorf("%s: announced %v with hint %q, want <prompt>", name, ok, hint) | |
| 50 | + } | |
| 51 | + } | |
| 52 | + if _, ok := hints["ask"]; ok { | |
| 53 | + t.Error("the generic /ask is announced to the editor") | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +// /ask-bob over the wire: bob's answer streams as agent text after the label, | |
| 58 | +// the turn ends normally, and the exchange is in the session's history. | |
| 59 | +func TestAskOverACP(t *testing.T) { | |
| 60 | + withBob(t) | |
| 61 | + w := newWire(t, "sys") | |
| 62 | + f := frontOf(t, w) | |
| 63 | + f.e = &engine.Engine{} | |
| 64 | + f.asker = fakeAsker{answer: "four"} | |
| 65 | + w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`) | |
| 66 | + w.until(1) | |
| 67 | + w.send(2, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`) | |
| 68 | + _, resp := w.until(2) | |
| 69 | + sid := resp["result"].(map[string]any)["sessionId"].(string) | |
| 70 | + | |
| 71 | + w.send(3, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/ask-bob what is 2+2?"}]}`) | |
| 72 | + notes, resp := w.until(3) | |
| 73 | + if stop := resp["result"].(map[string]any)["stopReason"]; stop != "end_turn" { | |
| 74 | + t.Errorf("stopReason = %v", stop) | |
| 75 | + } | |
| 76 | + var text strings.Builder | |
| 77 | + for _, n := range notes { | |
| 78 | + if n["sessionUpdate"] == "agent_message_chunk" { | |
| 79 | + text.WriteString(n["content"].(map[string]any)["text"].(string)) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + if got := text.String(); !strings.Contains(got, "🗣 asking bob") || !strings.HasSuffix(got, "four") { | |
| 83 | + t.Errorf("agent text = %q", got) | |
| 84 | + } | |
| 85 | + msgs := f.sessions[sdk.SessionId(sid)].messages | |
| 86 | + if len(msgs) != 3 || msgs[2].Text() != "four" { | |
| 87 | + t.Errorf("history: %d messages", len(msgs)) | |
| 88 | + } | |
| 89 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "strings" | ||
| 6 | + "testing" | ||
| 7 | + | ||
| 8 | + "mm/internal/command" | ||
| 9 | + "mm/internal/config" | ||
| 10 | + "mm/internal/engine" | ||
| 11 | + "mm/internal/ui" | ||
| 12 | + | ||
| 13 | + sdk "github.com/coder/acp-go-sdk" | ||
| 14 | + | ||
| 15 | + "github.com/firebase/genkit/go/ai" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +// fakeAsker answers through the sink, as the real pool streams. | ||
| 19 | +type fakeAsker struct{ answer string } | ||
| 20 | + | ||
| 21 | +func (a fakeAsker) Run(_ context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) { | ||
| 22 | + sink.Text(a.answer) | ||
| 23 | + return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +// withBob configures one agent and its generated commands for one test. | ||
| 27 | +func withBob(t *testing.T) { | ||
| 28 | + t.Helper() | ||
| 29 | + saved := config.Cfg | ||
| 30 | + t.Cleanup(func() { config.Cfg = saved; command.SetAgents(nil) }) | ||
| 31 | + config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"} | ||
| 32 | + command.SetAgents([]string{"bob"}) | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +// The editor is offered one command per agent, each with the <prompt> hint, | ||
| 36 | +// and never the generic /ask, whose argument it could not complete. | ||
| 37 | +func TestAskCommandsAreAnnouncedPerAgent(t *testing.T) { | ||
| 38 | + withBob(t) | ||
| 39 | + hints := map[string]string{} | ||
| 40 | + for _, c := range availableCommands() { | ||
| 41 | + if c.Input != nil && c.Input.Unstructured != nil { | ||
| 42 | + hints[c.Name] = c.Input.Unstructured.Hint | ||
| 43 | + } else { | ||
| 44 | + hints[c.Name] = "" | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + for _, name := range []string{"ask-bob", "ask-with-context-bob"} { | ||
| 48 | + if hint, ok := hints[name]; !ok || hint != "<prompt>" { | ||
| 49 | + t.Errorf("%s: announced %v with hint %q, want <prompt>", name, ok, hint) | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + if _, ok := hints["ask"]; ok { | ||
| 53 | + t.Error("the generic /ask is announced to the editor") | ||
| 54 | + } | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +// /ask-bob over the wire: bob's answer streams as agent text after the label, | ||
| 58 | +// the turn ends normally, and the exchange is in the session's history. | ||
| 59 | +func TestAskOverACP(t *testing.T) { | ||
| 60 | + withBob(t) | ||
| 61 | + w := newWire(t, "sys") | ||
| 62 | + f := frontOf(t, w) | ||
| 63 | + f.e = &engine.Engine{} | ||
| 64 | + f.asker = fakeAsker{answer: "four"} | ||
| 65 | + w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`) | ||
| 66 | + w.until(1) | ||
| 67 | + w.send(2, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`) | ||
| 68 | + _, resp := w.until(2) | ||
| 69 | + sid := resp["result"].(map[string]any)["sessionId"].(string) | ||
| 70 | + | ||
| 71 | + w.send(3, "session/prompt", `{"sessionId":"`+sid+`","prompt":[{"type":"text","text":"/ask-bob what is 2+2?"}]}`) | ||
| 72 | + notes, resp := w.until(3) | ||
| 73 | + if stop := resp["result"].(map[string]any)["stopReason"]; stop != "end_turn" { | ||
| 74 | + t.Errorf("stopReason = %v", stop) | ||
| 75 | + } | ||
| 76 | + var text strings.Builder | ||
| 77 | + for _, n := range notes { | ||
| 78 | + if n["sessionUpdate"] == "agent_message_chunk" { | ||
| 79 | + text.WriteString(n["content"].(map[string]any)["text"].(string)) | ||
| 80 | + } | ||
| 81 | + } | ||
| 82 | + if got := text.String(); !strings.Contains(got, "🗣 asking bob") || !strings.HasSuffix(got, "four") { | ||
| 83 | + t.Errorf("agent text = %q", got) | ||
| 84 | + } | ||
| 85 | + msgs := f.sessions[sdk.SessionId(sid)].messages | ||
| 86 | + if len(msgs) != 3 || msgs[2].Text() != "four" { | ||
| 87 | + t.Errorf("history: %d messages", len(msgs)) | ||
| 88 | + } | ||
| 89 | +} | ||
modified
internal/agent/agent.go +20 -1 | @@ -10,6 +10,7 @@ import ( | ||
| 10 | 10 | "os/signal" |
| 11 | 11 | "strings" |
| 12 | 12 | |
| 13 | + "mm/internal/ask" | |
| 13 | 14 | "mm/internal/clipboard" |
| 14 | 15 | "mm/internal/command" |
| 15 | 16 | "mm/internal/compact" |
| @@ -38,7 +39,9 @@ type genResult struct { | ||
| 38 | 39 | |
| 39 | 40 | // Run starts the loop: read user input → generate (with automatic tool calls) → |
| 40 | 41 | // print → start over, keeping the history. |
| 41 | -func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) { | |
| 42 | +// | |
| 43 | +// pool runs the /ask commands (another mm agent); nil when none is configured. | |
| 44 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector, pool ask.Runner) { | |
| 42 | 45 | messages := session.Fresh(system) |
| 43 | 46 | ctx, cancelAll := context.WithCancel(ctx) |
| 44 | 47 | defer cancelAll() |
| @@ -140,6 +143,22 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | ||
| 140 | 143 | // DURING a generation, such a command goes through the "new command |
| 141 | 144 | // received" branch below, which cancels the turn and re-injects it |
| 142 | 145 | // here — so it always applies to the finished history. |
| 146 | + // /ask-bob and its family: another agent answers, and its answer | |
| 147 | + // joins the history for the next question. Between two generations | |
| 148 | + // too, for the same reason as the commands below. | |
| 149 | + if req, ok, err := command.ParseAsk(input); ok { | |
| 150 | + if err != nil { | |
| 151 | + fmt.Println("⚠️ " + err.Error()) | |
| 152 | + continue | |
| 153 | + } | |
| 154 | + var quit bool | |
| 155 | + messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh}.run(ctx, req, messages) | |
| 156 | + if quit { | |
| 157 | + fmt.Println("Goodbye.") | |
| 158 | + return | |
| 159 | + } | |
| 160 | + continue | |
| 161 | + } | |
| 143 | 162 | if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled { |
| 144 | 163 | messages = next |
| 145 | 164 | continue |
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | "os/signal" | 10 | "os/signal" |
| 11 | "strings" | 11 | "strings" |
| 12 | 12 | ||
| 13 | + "mm/internal/ask" | ||
| 13 | "mm/internal/clipboard" | 14 | "mm/internal/clipboard" |
| 14 | "mm/internal/command" | 15 | "mm/internal/command" |
| 15 | "mm/internal/compact" | 16 | "mm/internal/compact" |
| @@ -38,7 +39,9 @@ type genResult struct { | |||
| 38 | 39 | ||
| 39 | // Run starts the loop: read user input → generate (with automatic tool calls) → | 40 | // Run starts the loop: read user input → generate (with automatic tool calls) → |
| 40 | // print → start over, keeping the history. | 41 | // print → start over, keeping the history. |
| 41 | -func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) { | 42 | +// |
| 43 | +// pool runs the /ask commands (another mm agent); nil when none is configured. | ||
| 44 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector, pool ask.Runner) { | ||
| 42 | messages := session.Fresh(system) | 45 | messages := session.Fresh(system) |
| 43 | ctx, cancelAll := context.WithCancel(ctx) | 46 | ctx, cancelAll := context.WithCancel(ctx) |
| 44 | defer cancelAll() | 47 | defer cancelAll() |
| @@ -140,6 +143,22 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | |||
| 140 | // DURING a generation, such a command goes through the "new command | 143 | // DURING a generation, such a command goes through the "new command |
| 141 | // received" branch below, which cancels the turn and re-injects it | 144 | // received" branch below, which cancels the turn and re-injects it |
| 142 | // here — so it always applies to the finished history. | 145 | // here — so it always applies to the finished history. |
| 146 | + // /ask-bob and its family: another agent answers, and its answer | ||
| 147 | + // joins the history for the next question. Between two generations | ||
| 148 | + // too, for the same reason as the commands below. | ||
| 149 | + if req, ok, err := command.ParseAsk(input); ok { | ||
| 150 | + if err != nil { | ||
| 151 | + fmt.Println("⚠️ " + err.Error()) | ||
| 152 | + continue | ||
| 153 | + } | ||
| 154 | + var quit bool | ||
| 155 | + messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh}.run(ctx, req, messages) | ||
| 156 | + if quit { | ||
| 157 | + fmt.Println("Goodbye.") | ||
| 158 | + return | ||
| 159 | + } | ||
| 160 | + continue | ||
| 161 | + } | ||
| 143 | if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled { | 162 | if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled { |
| 144 | messages = next | 163 | messages = next |
| 145 | continue | 164 | continue |
added
internal/agent/ask.go +125 -0 | new file mode 100644 | ||
| @@ -0,0 +1,125 @@ | ||
| 1 | +package agent | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + | |
| 8 | + "mm/internal/ask" | |
| 9 | + "mm/internal/command" | |
| 10 | + "mm/internal/config" | |
| 11 | + "mm/internal/spinner" | |
| 12 | + "mm/internal/store" | |
| 13 | + "mm/internal/ui" | |
| 14 | + | |
| 15 | + "github.com/firebase/genkit/go/ai" | |
| 16 | +) | |
| 17 | + | |
| 18 | +// asking is what the REPL needs to run one /ask: the pool of agents, where | |
| 19 | +// the session lives, and the two channels a generation also listens to, so | |
| 20 | +// that Ctrl+C, /abort and /quit stop bob exactly as they stop a turn. | |
| 21 | +type asking struct { | |
| 22 | + pool ask.Runner | |
| 23 | + cwd string | |
| 24 | + keep *store.Keeper | |
| 25 | + sigCh <-chan os.Signal | |
| 26 | + inputCh <-chan string | |
| 27 | +} | |
| 28 | + | |
| 29 | +// run asks req.Agent, streaming its answer and tool calls, and returns the | |
| 30 | +// history with the exchange appended — unchanged when the ask failed or was | |
| 31 | +// stopped. quit is true when /quit was typed meanwhile. | |
| 32 | +func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.Message) (out []*ai.Message, quit bool) { | |
| 33 | + dimln(ask.Label(req)) | |
| 34 | + askCtx, cancel := context.WithCancel(ctx) | |
| 35 | + defer cancel() | |
| 36 | + type result struct { | |
| 37 | + exchange []*ai.Message | |
| 38 | + err error | |
| 39 | + } | |
| 40 | + done := make(chan result, 1) | |
| 41 | + go func() { | |
| 42 | + exchange, err := a.pool.Run(askCtx, req, messages, a.cwd, &lineSink{cwd: a.cwd}) | |
| 43 | + done <- result{exchange, err} | |
| 44 | + }() | |
| 45 | + | |
| 46 | + for { | |
| 47 | + select { | |
| 48 | + case res := <-done: | |
| 49 | + return a.settle(askCtx, req, messages, res.exchange, res.err), quit | |
| 50 | + case <-a.sigCh: | |
| 51 | + fmt.Println("\n🛑 Interrupted (Ctrl+C).") | |
| 52 | + cancel() | |
| 53 | + case input := <-a.inputCh: | |
| 54 | + switch { | |
| 55 | + case command.Is(input, command.Abort): | |
| 56 | + fmt.Println("🛑 Aborting…") | |
| 57 | + cancel() | |
| 58 | + case command.Is(input, command.Quit): | |
| 59 | + quit = true | |
| 60 | + cancel() | |
| 61 | + default: | |
| 62 | + fmt.Printf("⚠️ %s is still answering — /abort to stop it.\n", req.Agent) | |
| 63 | + } | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +// settle reports how the ask ended and keeps the exchange when there is one. | |
| 69 | +func (a asking) settle(askCtx context.Context, req command.AskRequest, messages, exchange []*ai.Message, err error) []*ai.Message { | |
| 70 | + fmt.Println() | |
| 71 | + switch { | |
| 72 | + case err != nil && askCtx.Err() != nil: | |
| 73 | + fmt.Println("🛑 Ask aborted — nothing kept.") | |
| 74 | + return messages | |
| 75 | + case err != nil: | |
| 76 | + fmt.Println("[error: " + err.Error() + "]") | |
| 77 | + record(a.keep, messages, store.NewFailure(messages, store.FailAsk, err.Error(), err)) | |
| 78 | + return messages | |
| 79 | + } | |
| 80 | + messages = append(messages, exchange...) | |
| 81 | + save(a.keep, messages) | |
| 82 | + dimln(fmt.Sprintf("🗣 %s's answer is in the history for your next question", req.Agent)) | |
| 83 | + return messages | |
| 84 | +} | |
| 85 | + | |
| 86 | +// lineSink shows the other agent in the line mode: its answer as it streams, | |
| 87 | +// its thoughts dimmed, one line per tool call. Like the line mode's own | |
| 88 | +// tools, its calls are not gated: this front end has no permission prompt. | |
| 89 | +type lineSink struct { | |
| 90 | + cwd string | |
| 91 | +} | |
| 92 | + | |
| 93 | +var _ ui.Sink = (*lineSink)(nil) | |
| 94 | + | |
| 95 | +func (s *lineSink) Text(text string) { | |
| 96 | + spinner.Stop() | |
| 97 | + fmt.Print(text) | |
| 98 | +} | |
| 99 | + | |
| 100 | +func (s *lineSink) Reasoning(text string) { | |
| 101 | + if !config.Cfg.ShowThinking { | |
| 102 | + return | |
| 103 | + } | |
| 104 | + spinner.Stop() | |
| 105 | + if spinner.Styled() { | |
| 106 | + fmt.Printf("\033[2;3m%s\033[0m", text) | |
| 107 | + return | |
| 108 | + } | |
| 109 | + fmt.Print(text) | |
| 110 | +} | |
| 111 | + | |
| 112 | +func (s *lineSink) ToolStart(ev ui.ToolEvent) { | |
| 113 | + spinner.Stop() | |
| 114 | + fmt.Printf("\n🛠️ %s\n", ev.Title) | |
| 115 | +} | |
| 116 | + | |
| 117 | +func (s *lineSink) ToolEnd(_ string, res ui.ToolResult) { | |
| 118 | + if res.Failed { | |
| 119 | + dimln(" │ failed") | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +func (s *lineSink) Allow(context.Context, ui.ToolEvent) bool { return true } | |
| 124 | +func (s *lineSink) ToolRunning(string) {} | |
| 125 | +func (s *lineSink) WorkDir() string { return s.cwd } | |
| new file mode 100644 | |||
| @@ -0,0 +1,125 @@ | |||
| 1 | +package agent | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "os" | ||
| 7 | + | ||
| 8 | + "mm/internal/ask" | ||
| 9 | + "mm/internal/command" | ||
| 10 | + "mm/internal/config" | ||
| 11 | + "mm/internal/spinner" | ||
| 12 | + "mm/internal/store" | ||
| 13 | + "mm/internal/ui" | ||
| 14 | + | ||
| 15 | + "github.com/firebase/genkit/go/ai" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +// asking is what the REPL needs to run one /ask: the pool of agents, where | ||
| 19 | +// the session lives, and the two channels a generation also listens to, so | ||
| 20 | +// that Ctrl+C, /abort and /quit stop bob exactly as they stop a turn. | ||
| 21 | +type asking struct { | ||
| 22 | + pool ask.Runner | ||
| 23 | + cwd string | ||
| 24 | + keep *store.Keeper | ||
| 25 | + sigCh <-chan os.Signal | ||
| 26 | + inputCh <-chan string | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// run asks req.Agent, streaming its answer and tool calls, and returns the | ||
| 30 | +// history with the exchange appended — unchanged when the ask failed or was | ||
| 31 | +// stopped. quit is true when /quit was typed meanwhile. | ||
| 32 | +func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.Message) (out []*ai.Message, quit bool) { | ||
| 33 | + dimln(ask.Label(req)) | ||
| 34 | + askCtx, cancel := context.WithCancel(ctx) | ||
| 35 | + defer cancel() | ||
| 36 | + type result struct { | ||
| 37 | + exchange []*ai.Message | ||
| 38 | + err error | ||
| 39 | + } | ||
| 40 | + done := make(chan result, 1) | ||
| 41 | + go func() { | ||
| 42 | + exchange, err := a.pool.Run(askCtx, req, messages, a.cwd, &lineSink{cwd: a.cwd}) | ||
| 43 | + done <- result{exchange, err} | ||
| 44 | + }() | ||
| 45 | + | ||
| 46 | + for { | ||
| 47 | + select { | ||
| 48 | + case res := <-done: | ||
| 49 | + return a.settle(askCtx, req, messages, res.exchange, res.err), quit | ||
| 50 | + case <-a.sigCh: | ||
| 51 | + fmt.Println("\n🛑 Interrupted (Ctrl+C).") | ||
| 52 | + cancel() | ||
| 53 | + case input := <-a.inputCh: | ||
| 54 | + switch { | ||
| 55 | + case command.Is(input, command.Abort): | ||
| 56 | + fmt.Println("🛑 Aborting…") | ||
| 57 | + cancel() | ||
| 58 | + case command.Is(input, command.Quit): | ||
| 59 | + quit = true | ||
| 60 | + cancel() | ||
| 61 | + default: | ||
| 62 | + fmt.Printf("⚠️ %s is still answering — /abort to stop it.\n", req.Agent) | ||
| 63 | + } | ||
| 64 | + } | ||
| 65 | + } | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +// settle reports how the ask ended and keeps the exchange when there is one. | ||
| 69 | +func (a asking) settle(askCtx context.Context, req command.AskRequest, messages, exchange []*ai.Message, err error) []*ai.Message { | ||
| 70 | + fmt.Println() | ||
| 71 | + switch { | ||
| 72 | + case err != nil && askCtx.Err() != nil: | ||
| 73 | + fmt.Println("🛑 Ask aborted — nothing kept.") | ||
| 74 | + return messages | ||
| 75 | + case err != nil: | ||
| 76 | + fmt.Println("[error: " + err.Error() + "]") | ||
| 77 | + record(a.keep, messages, store.NewFailure(messages, store.FailAsk, err.Error(), err)) | ||
| 78 | + return messages | ||
| 79 | + } | ||
| 80 | + messages = append(messages, exchange...) | ||
| 81 | + save(a.keep, messages) | ||
| 82 | + dimln(fmt.Sprintf("🗣 %s's answer is in the history for your next question", req.Agent)) | ||
| 83 | + return messages | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +// lineSink shows the other agent in the line mode: its answer as it streams, | ||
| 87 | +// its thoughts dimmed, one line per tool call. Like the line mode's own | ||
| 88 | +// tools, its calls are not gated: this front end has no permission prompt. | ||
| 89 | +type lineSink struct { | ||
| 90 | + cwd string | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +var _ ui.Sink = (*lineSink)(nil) | ||
| 94 | + | ||
| 95 | +func (s *lineSink) Text(text string) { | ||
| 96 | + spinner.Stop() | ||
| 97 | + fmt.Print(text) | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +func (s *lineSink) Reasoning(text string) { | ||
| 101 | + if !config.Cfg.ShowThinking { | ||
| 102 | + return | ||
| 103 | + } | ||
| 104 | + spinner.Stop() | ||
| 105 | + if spinner.Styled() { | ||
| 106 | + fmt.Printf("\033[2;3m%s\033[0m", text) | ||
| 107 | + return | ||
| 108 | + } | ||
| 109 | + fmt.Print(text) | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +func (s *lineSink) ToolStart(ev ui.ToolEvent) { | ||
| 113 | + spinner.Stop() | ||
| 114 | + fmt.Printf("\n🛠️ %s\n", ev.Title) | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +func (s *lineSink) ToolEnd(_ string, res ui.ToolResult) { | ||
| 118 | + if res.Failed { | ||
| 119 | + dimln(" │ failed") | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +func (s *lineSink) Allow(context.Context, ui.ToolEvent) bool { return true } | ||
| 124 | +func (s *lineSink) ToolRunning(string) {} | ||
| 125 | +func (s *lineSink) WorkDir() string { return s.cwd } | ||
added
internal/agent/ask_test.go +83 -0 | new file mode 100644 | ||
| @@ -0,0 +1,83 @@ | ||
| 1 | +package agent | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "os" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "mm/internal/command" | |
| 10 | + "mm/internal/config" | |
| 11 | + "mm/internal/store" | |
| 12 | + "mm/internal/ui" | |
| 13 | + | |
| 14 | + "github.com/firebase/genkit/go/ai" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// fakeRunner answers, fails, or waits to be cancelled. | |
| 18 | +type fakeRunner struct { | |
| 19 | + answer string | |
| 20 | + fail error | |
| 21 | + block bool | |
| 22 | +} | |
| 23 | + | |
| 24 | +func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) { | |
| 25 | + if f.block { | |
| 26 | + <-ctx.Done() | |
| 27 | + return nil, ctx.Err() | |
| 28 | + } | |
| 29 | + if f.fail != nil { | |
| 30 | + return nil, f.fail | |
| 31 | + } | |
| 32 | + sink.Text(f.answer) | |
| 33 | + return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + "]"), ai.NewModelTextMessage(f.answer)}, nil | |
| 34 | +} | |
| 35 | + | |
| 36 | +// lineAsk builds an asking with fresh channels and no session file. | |
| 37 | +func lineAsk(t *testing.T, r fakeRunner) (asking, chan os.Signal, chan string) { | |
| 38 | + t.Helper() | |
| 39 | + saved := config.Cfg | |
| 40 | + t.Cleanup(func() { config.Cfg = saved }) | |
| 41 | + config.Cfg.Sessions.Enabled = false | |
| 42 | + config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"} | |
| 43 | + sig, in := make(chan os.Signal, 1), make(chan string, 1) | |
| 44 | + return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in}, sig, in | |
| 45 | +} | |
| 46 | + | |
| 47 | +var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")} | |
| 48 | + | |
| 49 | +// An answered ask appends the exchange. | |
| 50 | +func TestLineAskKeepsTheExchange(t *testing.T) { | |
| 51 | + a, _, _ := lineAsk(t, fakeRunner{answer: "four"}) | |
| 52 | + got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory) | |
| 53 | + if quit || len(got) != 3 || got[2].Text() != "four" { | |
| 54 | + t.Errorf("run = %d messages, quit %v", len(got), quit) | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +// Ctrl+C and /abort stop the ask and keep nothing; /quit also says to leave; | |
| 59 | +// a failure keeps nothing. | |
| 60 | +func TestLineAskStops(t *testing.T) { | |
| 61 | + a, sig, _ := lineAsk(t, fakeRunner{block: true}) | |
| 62 | + sig <- os.Interrupt | |
| 63 | + if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 { | |
| 64 | + t.Errorf("Ctrl+C: %d messages, quit %v", len(got), quit) | |
| 65 | + } | |
| 66 | + | |
| 67 | + a, _, in := lineAsk(t, fakeRunner{block: true}) | |
| 68 | + in <- "/abort" | |
| 69 | + if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 { | |
| 70 | + t.Errorf("/abort: %d messages, quit %v", len(got), quit) | |
| 71 | + } | |
| 72 | + | |
| 73 | + a, _, in = lineAsk(t, fakeRunner{block: true}) | |
| 74 | + in <- "/quit" | |
| 75 | + if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); !quit || len(got) != 1 { | |
| 76 | + t.Errorf("/quit: %d messages, quit %v", len(got), quit) | |
| 77 | + } | |
| 78 | + | |
| 79 | + a, _, _ = lineAsk(t, fakeRunner{fail: errors.New("bob did not start")}) | |
| 80 | + if got, _ := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); len(got) != 1 { | |
| 81 | + t.Errorf("failure: %d messages", len(got)) | |
| 82 | + } | |
| 83 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | +package agent | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "os" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "mm/internal/command" | ||
| 10 | + "mm/internal/config" | ||
| 11 | + "mm/internal/store" | ||
| 12 | + "mm/internal/ui" | ||
| 13 | + | ||
| 14 | + "github.com/firebase/genkit/go/ai" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// fakeRunner answers, fails, or waits to be cancelled. | ||
| 18 | +type fakeRunner struct { | ||
| 19 | + answer string | ||
| 20 | + fail error | ||
| 21 | + block bool | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) { | ||
| 25 | + if f.block { | ||
| 26 | + <-ctx.Done() | ||
| 27 | + return nil, ctx.Err() | ||
| 28 | + } | ||
| 29 | + if f.fail != nil { | ||
| 30 | + return nil, f.fail | ||
| 31 | + } | ||
| 32 | + sink.Text(f.answer) | ||
| 33 | + return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + "]"), ai.NewModelTextMessage(f.answer)}, nil | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// lineAsk builds an asking with fresh channels and no session file. | ||
| 37 | +func lineAsk(t *testing.T, r fakeRunner) (asking, chan os.Signal, chan string) { | ||
| 38 | + t.Helper() | ||
| 39 | + saved := config.Cfg | ||
| 40 | + t.Cleanup(func() { config.Cfg = saved }) | ||
| 41 | + config.Cfg.Sessions.Enabled = false | ||
| 42 | + config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"} | ||
| 43 | + sig, in := make(chan os.Signal, 1), make(chan string, 1) | ||
| 44 | + return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in}, sig, in | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")} | ||
| 48 | + | ||
| 49 | +// An answered ask appends the exchange. | ||
| 50 | +func TestLineAskKeepsTheExchange(t *testing.T) { | ||
| 51 | + a, _, _ := lineAsk(t, fakeRunner{answer: "four"}) | ||
| 52 | + got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory) | ||
| 53 | + if quit || len(got) != 3 || got[2].Text() != "four" { | ||
| 54 | + t.Errorf("run = %d messages, quit %v", len(got), quit) | ||
| 55 | + } | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +// Ctrl+C and /abort stop the ask and keep nothing; /quit also says to leave; | ||
| 59 | +// a failure keeps nothing. | ||
| 60 | +func TestLineAskStops(t *testing.T) { | ||
| 61 | + a, sig, _ := lineAsk(t, fakeRunner{block: true}) | ||
| 62 | + sig <- os.Interrupt | ||
| 63 | + if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 { | ||
| 64 | + t.Errorf("Ctrl+C: %d messages, quit %v", len(got), quit) | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + a, _, in := lineAsk(t, fakeRunner{block: true}) | ||
| 68 | + in <- "/abort" | ||
| 69 | + if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); quit || len(got) != 1 { | ||
| 70 | + t.Errorf("/abort: %d messages, quit %v", len(got), quit) | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + a, _, in = lineAsk(t, fakeRunner{block: true}) | ||
| 74 | + in <- "/quit" | ||
| 75 | + if got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); !quit || len(got) != 1 { | ||
| 76 | + t.Errorf("/quit: %d messages, quit %v", len(got), quit) | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + a, _, _ = lineAsk(t, fakeRunner{fail: errors.New("bob did not start")}) | ||
| 80 | + if got, _ := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, askHistory); len(got) != 1 { | ||
| 81 | + t.Errorf("failure: %d messages", len(got)) | ||
| 82 | + } | ||
| 83 | +} | ||
added
internal/ask/client.go +235 -0 | new file mode 100644 | ||
| @@ -0,0 +1,235 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "strings" | |
| 6 | + "sync" | |
| 7 | + | |
| 8 | + "mm/internal/ui" | |
| 9 | + | |
| 10 | + sdk "github.com/coder/acp-go-sdk" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// client is the ACP client side of one child agent: it receives what the | |
| 14 | +// child streams and turns it into the asking front end's own events, through | |
| 15 | +// the ui.Sink that front end installed — bob's text where the answers go, his | |
| 16 | +// tool calls as tool calls, his permission requests as the asker's own | |
| 17 | +// permission prompt (none in the line mode, the inline prompt in the window, | |
| 18 | +// the editor's dialog in ACP). | |
| 19 | +// | |
| 20 | +// mm's ACP server only ever calls SessionUpdate and RequestPermission; the | |
| 21 | +// file-system and terminal methods are refused, and the capabilities sent in | |
| 22 | +// initialize say they are absent. | |
| 23 | +type client struct { | |
| 24 | + mu sync.Mutex | |
| 25 | + agent string // "bob": prefixes the tool calls shown | |
| 26 | + sid sdk.SessionId // the session of the ask in progress; others are stale | |
| 27 | + sink ui.Sink // where the events go; nil between two asks | |
| 28 | + answer strings.Builder | |
| 29 | + ids map[sdk.ToolCallId]string // the child's call id -> the id shown | |
| 30 | + // started: the calls already shown. The SDK dispatches the child's | |
| 31 | + // notifications and requests concurrently, so a permission request can | |
| 32 | + // overtake the tool_call that announced it (seen in the tests); whichever | |
| 33 | + // comes first shows the call, the other does not show it twice. | |
| 34 | + started map[sdk.ToolCallId]bool | |
| 35 | +} | |
| 36 | + | |
| 37 | +var _ sdk.Client = (*client)(nil) | |
| 38 | + | |
| 39 | +// begin points the client at one ask: its session and its sink. | |
| 40 | +func (c *client) begin(agent string, sid sdk.SessionId, sink ui.Sink) { | |
| 41 | + c.mu.Lock() | |
| 42 | + defer c.mu.Unlock() | |
| 43 | + c.agent, c.sid, c.sink = agent, sid, sink | |
| 44 | + c.answer.Reset() | |
| 45 | + c.ids = map[sdk.ToolCallId]string{} | |
| 46 | + c.started = map[sdk.ToolCallId]bool{} | |
| 47 | +} | |
| 48 | + | |
| 49 | +// end detaches the sink and returns the answer as streamed. | |
| 50 | +func (c *client) end() string { | |
| 51 | + c.mu.Lock() | |
| 52 | + defer c.mu.Unlock() | |
| 53 | + c.sink, c.sid = nil, "" | |
| 54 | + return c.answer.String() | |
| 55 | +} | |
| 56 | + | |
| 57 | +// current returns the sink when sid is the ask in progress, nil otherwise — | |
| 58 | +// a cancelled ask's child may still be talking about its old session. | |
| 59 | +func (c *client) current(sid sdk.SessionId) ui.Sink { | |
| 60 | + c.mu.Lock() | |
| 61 | + defer c.mu.Unlock() | |
| 62 | + if sid != c.sid { | |
| 63 | + return nil | |
| 64 | + } | |
| 65 | + return c.sink | |
| 66 | +} | |
| 67 | + | |
| 68 | +// SessionUpdate routes one streamed update to the sink. | |
| 69 | +func (c *client) SessionUpdate(_ context.Context, n sdk.SessionNotification) error { | |
| 70 | + sink := c.current(n.SessionId) | |
| 71 | + if sink == nil { | |
| 72 | + return nil | |
| 73 | + } | |
| 74 | + u := n.Update | |
| 75 | + switch { | |
| 76 | + case u.AgentMessageChunk != nil && u.AgentMessageChunk.Content.Text != nil: | |
| 77 | + text := u.AgentMessageChunk.Content.Text.Text | |
| 78 | + c.mu.Lock() | |
| 79 | + c.answer.WriteString(text) | |
| 80 | + c.mu.Unlock() | |
| 81 | + sink.Text(text) | |
| 82 | + case u.AgentThoughtChunk != nil && u.AgentThoughtChunk.Content.Text != nil: | |
| 83 | + sink.Reasoning(u.AgentThoughtChunk.Content.Text.Text) // shown as thoughts, never kept | |
| 84 | + case u.ToolCall != nil: | |
| 85 | + c.toolStart(sink, u.ToolCall) | |
| 86 | + case u.ToolCallUpdate != nil: | |
| 87 | + c.toolUpdate(sink, u.ToolCallUpdate) | |
| 88 | + } | |
| 89 | + return nil | |
| 90 | +} | |
| 91 | + | |
| 92 | +// toolStart shows one of the child's tool calls as the asker's own, named | |
| 93 | +// after the child: "bob · bash: ls -1". | |
| 94 | +func (c *client) toolStart(sink ui.Sink, call *sdk.SessionUpdateToolCall) { | |
| 95 | + ev := c.event(call.ToolCallId, call.Title, string(call.Kind), call.RawInput) | |
| 96 | + if c.firstSight(call.ToolCallId) { | |
| 97 | + sink.ToolStart(ev) | |
| 98 | + } | |
| 99 | + if call.Status == sdk.ToolCallStatusInProgress { | |
| 100 | + sink.ToolRunning(ev.ID) | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +// firstSight records a call as shown and reports whether it was not yet. | |
| 105 | +func (c *client) firstSight(id sdk.ToolCallId) bool { | |
| 106 | + c.mu.Lock() | |
| 107 | + defer c.mu.Unlock() | |
| 108 | + if c.started == nil || c.started[id] { | |
| 109 | + return false | |
| 110 | + } | |
| 111 | + c.started[id] = true | |
| 112 | + return true | |
| 113 | +} | |
| 114 | + | |
| 115 | +// toolUpdate follows a call's status: running, then done or failed. | |
| 116 | +func (c *client) toolUpdate(sink ui.Sink, up *sdk.SessionToolCallUpdate) { | |
| 117 | + if up.Status == nil { | |
| 118 | + return | |
| 119 | + } | |
| 120 | + id := c.shownID(up.ToolCallId) | |
| 121 | + switch *up.Status { | |
| 122 | + case sdk.ToolCallStatusInProgress: | |
| 123 | + sink.ToolRunning(id) | |
| 124 | + case sdk.ToolCallStatusCompleted, sdk.ToolCallStatusFailed: | |
| 125 | + sink.ToolEnd(id, ui.ToolResult{Output: contentText(up.Content), Failed: *up.Status == sdk.ToolCallStatusFailed}) | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +// RequestPermission asks the asker's user, through the asker's front end, | |
| 130 | +// whether the child may run a call; the answer is one of the options the | |
| 131 | +// child offered (allow once / reject once), or "cancelled" when it offered | |
| 132 | +// none that fits. | |
| 133 | +func (c *client) RequestPermission(ctx context.Context, p sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) { | |
| 134 | + sink := c.current(p.SessionId) | |
| 135 | + title := "" | |
| 136 | + if p.ToolCall.Title != nil { | |
| 137 | + title = *p.ToolCall.Title | |
| 138 | + } | |
| 139 | + // A stale request — its ask was cancelled — is refused: nobody is there | |
| 140 | + // to answer it. | |
| 141 | + allowed := false | |
| 142 | + if sink != nil { | |
| 143 | + ev := c.event(p.ToolCall.ToolCallId, title, "", p.ToolCall.RawInput) | |
| 144 | + if c.firstSight(p.ToolCall.ToolCallId) { | |
| 145 | + sink.ToolStart(ev) // the request overtook the tool_call: show it first | |
| 146 | + } | |
| 147 | + allowed = sink.Allow(ctx, ev) | |
| 148 | + } | |
| 149 | + want := []sdk.PermissionOptionKind{sdk.PermissionOptionKindRejectOnce, sdk.PermissionOptionKindRejectAlways} | |
| 150 | + if allowed { | |
| 151 | + want = []sdk.PermissionOptionKind{sdk.PermissionOptionKindAllowOnce, sdk.PermissionOptionKindAllowAlways} | |
| 152 | + } | |
| 153 | + for _, kind := range want { | |
| 154 | + for _, o := range p.Options { | |
| 155 | + if o.Kind == kind { | |
| 156 | + return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Selected: &sdk.RequestPermissionOutcomeSelected{OptionId: o.OptionId}}}, nil | |
| 157 | + } | |
| 158 | + } | |
| 159 | + } | |
| 160 | + return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Cancelled: &sdk.RequestPermissionOutcomeCancelled{}}}, nil | |
| 161 | +} | |
| 162 | + | |
| 163 | +// event builds the ui.ToolEvent the asker shows for one of the child's calls. | |
| 164 | +// Tool is "<agent>/<tool>" so that "allow for this session" in the window | |
| 165 | +// grants bob's bash, not the asker's. | |
| 166 | +func (c *client) event(id sdk.ToolCallId, title, kind string, raw any) ui.ToolEvent { | |
| 167 | + tool, _, _ := strings.Cut(title, ":") | |
| 168 | + input, _ := raw.(map[string]any) | |
| 169 | + c.mu.Lock() | |
| 170 | + agent := c.agent | |
| 171 | + c.mu.Unlock() | |
| 172 | + return ui.ToolEvent{ | |
| 173 | + ID: c.shownID(id), | |
| 174 | + Tool: agent + "/" + strings.TrimSpace(tool), | |
| 175 | + Title: agent + " · " + title, | |
| 176 | + Kind: kind, | |
| 177 | + Input: input, | |
| 178 | + } | |
| 179 | +} | |
| 180 | + | |
| 181 | +// shownID maps the child's call id to one unique in this process: two | |
| 182 | +// children both number their calls call_1, call_2… | |
| 183 | +func (c *client) shownID(id sdk.ToolCallId) string { | |
| 184 | + c.mu.Lock() | |
| 185 | + defer c.mu.Unlock() | |
| 186 | + if shown, ok := c.ids[id]; ok { | |
| 187 | + return shown | |
| 188 | + } | |
| 189 | + shown := ui.NextCallID() | |
| 190 | + if c.ids != nil { | |
| 191 | + c.ids[id] = shown | |
| 192 | + } | |
| 193 | + return shown | |
| 194 | +} | |
| 195 | + | |
| 196 | +// contentText joins the text blocks of a tool call's content. | |
| 197 | +func contentText(content []sdk.ToolCallContent) string { | |
| 198 | + var b strings.Builder | |
| 199 | + for _, item := range content { | |
| 200 | + if item.Content != nil && item.Content.Content.Text != nil { | |
| 201 | + b.WriteString(item.Content.Content.Text.Text) | |
| 202 | + } | |
| 203 | + } | |
| 204 | + return b.String() | |
| 205 | +} | |
| 206 | + | |
| 207 | +// --- what mm's ACP server never asks for, refused as absent ----------------- | |
| 208 | + | |
| 209 | +func (c *client) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) { | |
| 210 | + return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile) | |
| 211 | +} | |
| 212 | + | |
| 213 | +func (c *client) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) { | |
| 214 | + return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile) | |
| 215 | +} | |
| 216 | + | |
| 217 | +func (c *client) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) { | |
| 218 | + return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate) | |
| 219 | +} | |
| 220 | + | |
| 221 | +func (c *client) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) { | |
| 222 | + return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill) | |
| 223 | +} | |
| 224 | + | |
| 225 | +func (c *client) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) { | |
| 226 | + return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput) | |
| 227 | +} | |
| 228 | + | |
| 229 | +func (c *client) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) { | |
| 230 | + return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease) | |
| 231 | +} | |
| 232 | + | |
| 233 | +func (c *client) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) { | |
| 234 | + return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit) | |
| 235 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,235 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "strings" | ||
| 6 | + "sync" | ||
| 7 | + | ||
| 8 | + "mm/internal/ui" | ||
| 9 | + | ||
| 10 | + sdk "github.com/coder/acp-go-sdk" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +// client is the ACP client side of one child agent: it receives what the | ||
| 14 | +// child streams and turns it into the asking front end's own events, through | ||
| 15 | +// the ui.Sink that front end installed — bob's text where the answers go, his | ||
| 16 | +// tool calls as tool calls, his permission requests as the asker's own | ||
| 17 | +// permission prompt (none in the line mode, the inline prompt in the window, | ||
| 18 | +// the editor's dialog in ACP). | ||
| 19 | +// | ||
| 20 | +// mm's ACP server only ever calls SessionUpdate and RequestPermission; the | ||
| 21 | +// file-system and terminal methods are refused, and the capabilities sent in | ||
| 22 | +// initialize say they are absent. | ||
| 23 | +type client struct { | ||
| 24 | + mu sync.Mutex | ||
| 25 | + agent string // "bob": prefixes the tool calls shown | ||
| 26 | + sid sdk.SessionId // the session of the ask in progress; others are stale | ||
| 27 | + sink ui.Sink // where the events go; nil between two asks | ||
| 28 | + answer strings.Builder | ||
| 29 | + ids map[sdk.ToolCallId]string // the child's call id -> the id shown | ||
| 30 | + // started: the calls already shown. The SDK dispatches the child's | ||
| 31 | + // notifications and requests concurrently, so a permission request can | ||
| 32 | + // overtake the tool_call that announced it (seen in the tests); whichever | ||
| 33 | + // comes first shows the call, the other does not show it twice. | ||
| 34 | + started map[sdk.ToolCallId]bool | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +var _ sdk.Client = (*client)(nil) | ||
| 38 | + | ||
| 39 | +// begin points the client at one ask: its session and its sink. | ||
| 40 | +func (c *client) begin(agent string, sid sdk.SessionId, sink ui.Sink) { | ||
| 41 | + c.mu.Lock() | ||
| 42 | + defer c.mu.Unlock() | ||
| 43 | + c.agent, c.sid, c.sink = agent, sid, sink | ||
| 44 | + c.answer.Reset() | ||
| 45 | + c.ids = map[sdk.ToolCallId]string{} | ||
| 46 | + c.started = map[sdk.ToolCallId]bool{} | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +// end detaches the sink and returns the answer as streamed. | ||
| 50 | +func (c *client) end() string { | ||
| 51 | + c.mu.Lock() | ||
| 52 | + defer c.mu.Unlock() | ||
| 53 | + c.sink, c.sid = nil, "" | ||
| 54 | + return c.answer.String() | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +// current returns the sink when sid is the ask in progress, nil otherwise — | ||
| 58 | +// a cancelled ask's child may still be talking about its old session. | ||
| 59 | +func (c *client) current(sid sdk.SessionId) ui.Sink { | ||
| 60 | + c.mu.Lock() | ||
| 61 | + defer c.mu.Unlock() | ||
| 62 | + if sid != c.sid { | ||
| 63 | + return nil | ||
| 64 | + } | ||
| 65 | + return c.sink | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +// SessionUpdate routes one streamed update to the sink. | ||
| 69 | +func (c *client) SessionUpdate(_ context.Context, n sdk.SessionNotification) error { | ||
| 70 | + sink := c.current(n.SessionId) | ||
| 71 | + if sink == nil { | ||
| 72 | + return nil | ||
| 73 | + } | ||
| 74 | + u := n.Update | ||
| 75 | + switch { | ||
| 76 | + case u.AgentMessageChunk != nil && u.AgentMessageChunk.Content.Text != nil: | ||
| 77 | + text := u.AgentMessageChunk.Content.Text.Text | ||
| 78 | + c.mu.Lock() | ||
| 79 | + c.answer.WriteString(text) | ||
| 80 | + c.mu.Unlock() | ||
| 81 | + sink.Text(text) | ||
| 82 | + case u.AgentThoughtChunk != nil && u.AgentThoughtChunk.Content.Text != nil: | ||
| 83 | + sink.Reasoning(u.AgentThoughtChunk.Content.Text.Text) // shown as thoughts, never kept | ||
| 84 | + case u.ToolCall != nil: | ||
| 85 | + c.toolStart(sink, u.ToolCall) | ||
| 86 | + case u.ToolCallUpdate != nil: | ||
| 87 | + c.toolUpdate(sink, u.ToolCallUpdate) | ||
| 88 | + } | ||
| 89 | + return nil | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +// toolStart shows one of the child's tool calls as the asker's own, named | ||
| 93 | +// after the child: "bob · bash: ls -1". | ||
| 94 | +func (c *client) toolStart(sink ui.Sink, call *sdk.SessionUpdateToolCall) { | ||
| 95 | + ev := c.event(call.ToolCallId, call.Title, string(call.Kind), call.RawInput) | ||
| 96 | + if c.firstSight(call.ToolCallId) { | ||
| 97 | + sink.ToolStart(ev) | ||
| 98 | + } | ||
| 99 | + if call.Status == sdk.ToolCallStatusInProgress { | ||
| 100 | + sink.ToolRunning(ev.ID) | ||
| 101 | + } | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// firstSight records a call as shown and reports whether it was not yet. | ||
| 105 | +func (c *client) firstSight(id sdk.ToolCallId) bool { | ||
| 106 | + c.mu.Lock() | ||
| 107 | + defer c.mu.Unlock() | ||
| 108 | + if c.started == nil || c.started[id] { | ||
| 109 | + return false | ||
| 110 | + } | ||
| 111 | + c.started[id] = true | ||
| 112 | + return true | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +// toolUpdate follows a call's status: running, then done or failed. | ||
| 116 | +func (c *client) toolUpdate(sink ui.Sink, up *sdk.SessionToolCallUpdate) { | ||
| 117 | + if up.Status == nil { | ||
| 118 | + return | ||
| 119 | + } | ||
| 120 | + id := c.shownID(up.ToolCallId) | ||
| 121 | + switch *up.Status { | ||
| 122 | + case sdk.ToolCallStatusInProgress: | ||
| 123 | + sink.ToolRunning(id) | ||
| 124 | + case sdk.ToolCallStatusCompleted, sdk.ToolCallStatusFailed: | ||
| 125 | + sink.ToolEnd(id, ui.ToolResult{Output: contentText(up.Content), Failed: *up.Status == sdk.ToolCallStatusFailed}) | ||
| 126 | + } | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +// RequestPermission asks the asker's user, through the asker's front end, | ||
| 130 | +// whether the child may run a call; the answer is one of the options the | ||
| 131 | +// child offered (allow once / reject once), or "cancelled" when it offered | ||
| 132 | +// none that fits. | ||
| 133 | +func (c *client) RequestPermission(ctx context.Context, p sdk.RequestPermissionRequest) (sdk.RequestPermissionResponse, error) { | ||
| 134 | + sink := c.current(p.SessionId) | ||
| 135 | + title := "" | ||
| 136 | + if p.ToolCall.Title != nil { | ||
| 137 | + title = *p.ToolCall.Title | ||
| 138 | + } | ||
| 139 | + // A stale request — its ask was cancelled — is refused: nobody is there | ||
| 140 | + // to answer it. | ||
| 141 | + allowed := false | ||
| 142 | + if sink != nil { | ||
| 143 | + ev := c.event(p.ToolCall.ToolCallId, title, "", p.ToolCall.RawInput) | ||
| 144 | + if c.firstSight(p.ToolCall.ToolCallId) { | ||
| 145 | + sink.ToolStart(ev) // the request overtook the tool_call: show it first | ||
| 146 | + } | ||
| 147 | + allowed = sink.Allow(ctx, ev) | ||
| 148 | + } | ||
| 149 | + want := []sdk.PermissionOptionKind{sdk.PermissionOptionKindRejectOnce, sdk.PermissionOptionKindRejectAlways} | ||
| 150 | + if allowed { | ||
| 151 | + want = []sdk.PermissionOptionKind{sdk.PermissionOptionKindAllowOnce, sdk.PermissionOptionKindAllowAlways} | ||
| 152 | + } | ||
| 153 | + for _, kind := range want { | ||
| 154 | + for _, o := range p.Options { | ||
| 155 | + if o.Kind == kind { | ||
| 156 | + return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Selected: &sdk.RequestPermissionOutcomeSelected{OptionId: o.OptionId}}}, nil | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | + } | ||
| 160 | + return sdk.RequestPermissionResponse{Outcome: sdk.RequestPermissionOutcome{Cancelled: &sdk.RequestPermissionOutcomeCancelled{}}}, nil | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +// event builds the ui.ToolEvent the asker shows for one of the child's calls. | ||
| 164 | +// Tool is "<agent>/<tool>" so that "allow for this session" in the window | ||
| 165 | +// grants bob's bash, not the asker's. | ||
| 166 | +func (c *client) event(id sdk.ToolCallId, title, kind string, raw any) ui.ToolEvent { | ||
| 167 | + tool, _, _ := strings.Cut(title, ":") | ||
| 168 | + input, _ := raw.(map[string]any) | ||
| 169 | + c.mu.Lock() | ||
| 170 | + agent := c.agent | ||
| 171 | + c.mu.Unlock() | ||
| 172 | + return ui.ToolEvent{ | ||
| 173 | + ID: c.shownID(id), | ||
| 174 | + Tool: agent + "/" + strings.TrimSpace(tool), | ||
| 175 | + Title: agent + " · " + title, | ||
| 176 | + Kind: kind, | ||
| 177 | + Input: input, | ||
| 178 | + } | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +// shownID maps the child's call id to one unique in this process: two | ||
| 182 | +// children both number their calls call_1, call_2… | ||
| 183 | +func (c *client) shownID(id sdk.ToolCallId) string { | ||
| 184 | + c.mu.Lock() | ||
| 185 | + defer c.mu.Unlock() | ||
| 186 | + if shown, ok := c.ids[id]; ok { | ||
| 187 | + return shown | ||
| 188 | + } | ||
| 189 | + shown := ui.NextCallID() | ||
| 190 | + if c.ids != nil { | ||
| 191 | + c.ids[id] = shown | ||
| 192 | + } | ||
| 193 | + return shown | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +// contentText joins the text blocks of a tool call's content. | ||
| 197 | +func contentText(content []sdk.ToolCallContent) string { | ||
| 198 | + var b strings.Builder | ||
| 199 | + for _, item := range content { | ||
| 200 | + if item.Content != nil && item.Content.Content.Text != nil { | ||
| 201 | + b.WriteString(item.Content.Content.Text.Text) | ||
| 202 | + } | ||
| 203 | + } | ||
| 204 | + return b.String() | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +// --- what mm's ACP server never asks for, refused as absent ----------------- | ||
| 208 | + | ||
| 209 | +func (c *client) ReadTextFile(context.Context, sdk.ReadTextFileRequest) (sdk.ReadTextFileResponse, error) { | ||
| 210 | + return sdk.ReadTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsReadTextFile) | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +func (c *client) WriteTextFile(context.Context, sdk.WriteTextFileRequest) (sdk.WriteTextFileResponse, error) { | ||
| 214 | + return sdk.WriteTextFileResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodFsWriteTextFile) | ||
| 215 | +} | ||
| 216 | + | ||
| 217 | +func (c *client) CreateTerminal(context.Context, sdk.CreateTerminalRequest) (sdk.CreateTerminalResponse, error) { | ||
| 218 | + return sdk.CreateTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalCreate) | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +func (c *client) KillTerminal(context.Context, sdk.KillTerminalRequest) (sdk.KillTerminalResponse, error) { | ||
| 222 | + return sdk.KillTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalKill) | ||
| 223 | +} | ||
| 224 | + | ||
| 225 | +func (c *client) TerminalOutput(context.Context, sdk.TerminalOutputRequest) (sdk.TerminalOutputResponse, error) { | ||
| 226 | + return sdk.TerminalOutputResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalOutput) | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +func (c *client) ReleaseTerminal(context.Context, sdk.ReleaseTerminalRequest) (sdk.ReleaseTerminalResponse, error) { | ||
| 230 | + return sdk.ReleaseTerminalResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalRelease) | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +func (c *client) WaitForTerminalExit(context.Context, sdk.WaitForTerminalExitRequest) (sdk.WaitForTerminalExitResponse, error) { | ||
| 234 | + return sdk.WaitForTerminalExitResponse{}, sdk.NewMethodNotFound(sdk.ClientMethodTerminalWaitForExit) | ||
| 235 | +} | ||
added
internal/ask/context.go +101 -0 | new file mode 100644 | ||
| @@ -0,0 +1,101 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | + | |
| 7 | + "mm/internal/compact" | |
| 8 | + | |
| 9 | + "github.com/firebase/genkit/go/ai" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// Transcript renders the conversation for /ask-with-context: what was asked | |
| 13 | +// and answered, and which tools ran — one line each, with their arguments | |
| 14 | +// shortened — but never the tools' outputs, the system prompt or any | |
| 15 | +// reasoning. The other agent needs to know what happened, not to re-read | |
| 16 | +// every file; and its window is not ours. | |
| 17 | +// | |
| 18 | +// The newest turns are kept first: when the whole history does not fit in | |
| 19 | +// maxChars, the oldest lines go, and a marker says so. | |
| 20 | +// | |
| 21 | +// text := ask.Transcript(messages, config.Cfg.MaxOutput) | |
| 22 | +func Transcript(messages []*ai.Message, maxChars int) string { | |
| 23 | + var lines []string | |
| 24 | + for _, m := range messages { | |
| 25 | + lines = append(lines, transcriptLines(m)...) | |
| 26 | + } | |
| 27 | + return keepNewest(lines, maxChars) | |
| 28 | +} | |
| 29 | + | |
| 30 | +// transcriptLines is one message, as the transcript shows it. | |
| 31 | +func transcriptLines(m *ai.Message) []string { | |
| 32 | + if m == nil { | |
| 33 | + return nil | |
| 34 | + } | |
| 35 | + switch { | |
| 36 | + case compact.IsSummary(m): | |
| 37 | + return []string{"[Summary of the earlier conversation]\n" + strings.TrimSpace(m.Text())} | |
| 38 | + case m.Role == ai.RoleUser: | |
| 39 | + if text := strings.TrimSpace(m.Text()); text != "" { | |
| 40 | + return []string{"User: " + text} | |
| 41 | + } | |
| 42 | + case m.Role == ai.RoleModel: | |
| 43 | + return modelLines(m) | |
| 44 | + } | |
| 45 | + return nil // system prompt, tool outputs | |
| 46 | +} | |
| 47 | + | |
| 48 | +// modelLines is an assistant message: its text, then one line per tool call. | |
| 49 | +func modelLines(m *ai.Message) []string { | |
| 50 | + var lines []string | |
| 51 | + if text := strings.TrimSpace(m.Text()); text != "" { | |
| 52 | + lines = append(lines, "Assistant: "+text) | |
| 53 | + } | |
| 54 | + for _, part := range m.Content { | |
| 55 | + if part.IsToolRequest() && part.ToolRequest != nil { | |
| 56 | + lines = append(lines, fmt.Sprintf("Assistant ran %s: %s", part.ToolRequest.Name, shorten(fmt.Sprint(part.ToolRequest.Input), 160))) | |
| 57 | + } | |
| 58 | + } | |
| 59 | + return lines | |
| 60 | +} | |
| 61 | + | |
| 62 | +// keepNewest joins lines, dropping the oldest ones that do not fit. | |
| 63 | +func keepNewest(lines []string, maxChars int) string { | |
| 64 | + const omitted = "[… earlier conversation omitted …]" | |
| 65 | + size, first := 0, len(lines) | |
| 66 | + for first > 0 { | |
| 67 | + next := len([]rune(lines[first-1])) + 1 | |
| 68 | + if size+next > maxChars-len(omitted)-1 { | |
| 69 | + break | |
| 70 | + } | |
| 71 | + size += next | |
| 72 | + first-- | |
| 73 | + } | |
| 74 | + kept := strings.Join(lines[first:], "\n") | |
| 75 | + if first > 0 { | |
| 76 | + return omitted + "\n" + kept | |
| 77 | + } | |
| 78 | + return kept | |
| 79 | +} | |
| 80 | + | |
| 81 | +// shorten cuts s to n characters on one line. | |
| 82 | +func shorten(s string, n int) string { | |
| 83 | + s = strings.Join(strings.Fields(s), " ") | |
| 84 | + if r := []rune(s); len(r) > n { | |
| 85 | + return string(r[:n]) + "…" | |
| 86 | + } | |
| 87 | + return s | |
| 88 | +} | |
| 89 | + | |
| 90 | +// Prompt is what the other agent receives: the question alone, or — for | |
| 91 | +// /ask-with-context — the transcript first, clearly separated, so it can tell | |
| 92 | +// the conversation it is shown from the question it must answer. | |
| 93 | +// | |
| 94 | +// ask.Prompt("is this safe?", "") // "is this safe?" | |
| 95 | +// ask.Prompt("is this safe?", ask.Transcript(…)) // "[Conversation so far…]\n…\n\n[Question]\nis this safe?" | |
| 96 | +func Prompt(question, transcript string) string { | |
| 97 | + if transcript == "" { | |
| 98 | + return question | |
| 99 | + } | |
| 100 | + return "[Conversation so far, between the user and another assistant]\n" + transcript + "\n\n[Question]\n" + question | |
| 101 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,101 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strings" | ||
| 6 | + | ||
| 7 | + "mm/internal/compact" | ||
| 8 | + | ||
| 9 | + "github.com/firebase/genkit/go/ai" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// Transcript renders the conversation for /ask-with-context: what was asked | ||
| 13 | +// and answered, and which tools ran — one line each, with their arguments | ||
| 14 | +// shortened — but never the tools' outputs, the system prompt or any | ||
| 15 | +// reasoning. The other agent needs to know what happened, not to re-read | ||
| 16 | +// every file; and its window is not ours. | ||
| 17 | +// | ||
| 18 | +// The newest turns are kept first: when the whole history does not fit in | ||
| 19 | +// maxChars, the oldest lines go, and a marker says so. | ||
| 20 | +// | ||
| 21 | +// text := ask.Transcript(messages, config.Cfg.MaxOutput) | ||
| 22 | +func Transcript(messages []*ai.Message, maxChars int) string { | ||
| 23 | + var lines []string | ||
| 24 | + for _, m := range messages { | ||
| 25 | + lines = append(lines, transcriptLines(m)...) | ||
| 26 | + } | ||
| 27 | + return keepNewest(lines, maxChars) | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +// transcriptLines is one message, as the transcript shows it. | ||
| 31 | +func transcriptLines(m *ai.Message) []string { | ||
| 32 | + if m == nil { | ||
| 33 | + return nil | ||
| 34 | + } | ||
| 35 | + switch { | ||
| 36 | + case compact.IsSummary(m): | ||
| 37 | + return []string{"[Summary of the earlier conversation]\n" + strings.TrimSpace(m.Text())} | ||
| 38 | + case m.Role == ai.RoleUser: | ||
| 39 | + if text := strings.TrimSpace(m.Text()); text != "" { | ||
| 40 | + return []string{"User: " + text} | ||
| 41 | + } | ||
| 42 | + case m.Role == ai.RoleModel: | ||
| 43 | + return modelLines(m) | ||
| 44 | + } | ||
| 45 | + return nil // system prompt, tool outputs | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +// modelLines is an assistant message: its text, then one line per tool call. | ||
| 49 | +func modelLines(m *ai.Message) []string { | ||
| 50 | + var lines []string | ||
| 51 | + if text := strings.TrimSpace(m.Text()); text != "" { | ||
| 52 | + lines = append(lines, "Assistant: "+text) | ||
| 53 | + } | ||
| 54 | + for _, part := range m.Content { | ||
| 55 | + if part.IsToolRequest() && part.ToolRequest != nil { | ||
| 56 | + lines = append(lines, fmt.Sprintf("Assistant ran %s: %s", part.ToolRequest.Name, shorten(fmt.Sprint(part.ToolRequest.Input), 160))) | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | + return lines | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// keepNewest joins lines, dropping the oldest ones that do not fit. | ||
| 63 | +func keepNewest(lines []string, maxChars int) string { | ||
| 64 | + const omitted = "[… earlier conversation omitted …]" | ||
| 65 | + size, first := 0, len(lines) | ||
| 66 | + for first > 0 { | ||
| 67 | + next := len([]rune(lines[first-1])) + 1 | ||
| 68 | + if size+next > maxChars-len(omitted)-1 { | ||
| 69 | + break | ||
| 70 | + } | ||
| 71 | + size += next | ||
| 72 | + first-- | ||
| 73 | + } | ||
| 74 | + kept := strings.Join(lines[first:], "\n") | ||
| 75 | + if first > 0 { | ||
| 76 | + return omitted + "\n" + kept | ||
| 77 | + } | ||
| 78 | + return kept | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +// shorten cuts s to n characters on one line. | ||
| 82 | +func shorten(s string, n int) string { | ||
| 83 | + s = strings.Join(strings.Fields(s), " ") | ||
| 84 | + if r := []rune(s); len(r) > n { | ||
| 85 | + return string(r[:n]) + "…" | ||
| 86 | + } | ||
| 87 | + return s | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// Prompt is what the other agent receives: the question alone, or — for | ||
| 91 | +// /ask-with-context — the transcript first, clearly separated, so it can tell | ||
| 92 | +// the conversation it is shown from the question it must answer. | ||
| 93 | +// | ||
| 94 | +// ask.Prompt("is this safe?", "") // "is this safe?" | ||
| 95 | +// ask.Prompt("is this safe?", ask.Transcript(…)) // "[Conversation so far…]\n…\n\n[Question]\nis this safe?" | ||
| 96 | +func Prompt(question, transcript string) string { | ||
| 97 | + if transcript == "" { | ||
| 98 | + return question | ||
| 99 | + } | ||
| 100 | + return "[Conversation so far, between the user and another assistant]\n" + transcript + "\n\n[Question]\n" + question | ||
| 101 | +} | ||
added
internal/ask/context_test.go +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "github.com/firebase/genkit/go/ai" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// conversation is a short history with a system prompt, a tool call and its | |
| 11 | +// output — everything the transcript must keep or drop. | |
| 12 | +func conversation() []*ai.Message { | |
| 13 | + call := ai.NewModelMessage(ai.NewTextPart("Let me look."), ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls -1"}})) | |
| 14 | + out := ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "SECRET-OUTPUT"})) | |
| 15 | + return []*ai.Message{ | |
| 16 | + ai.NewSystemTextMessage("SYSTEM-PROMPT"), | |
| 17 | + ai.NewUserTextMessage("list the files"), | |
| 18 | + call, out, | |
| 19 | + ai.NewModelTextMessage("There are two files."), | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +// The transcript keeps questions, answers and one line per tool call, and | |
| 24 | +// never the system prompt or a tool's output. | |
| 25 | +func TestTranscriptKeepsWhatHappenedNotTheOutputs(t *testing.T) { | |
| 26 | + got := Transcript(conversation(), 10000) | |
| 27 | + for _, want := range []string{"User: list the files", "Assistant: Let me look.", "Assistant ran bash: map[command:ls -1]", "Assistant: There are two files."} { | |
| 28 | + if !strings.Contains(got, want) { | |
| 29 | + t.Errorf("transcript lacks %q:\n%s", want, got) | |
| 30 | + } | |
| 31 | + } | |
| 32 | + for _, never := range []string{"SYSTEM-PROMPT", "SECRET-OUTPUT"} { | |
| 33 | + if strings.Contains(got, never) { | |
| 34 | + t.Errorf("transcript leaks %q:\n%s", never, got) | |
| 35 | + } | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +// Over the cap, the oldest lines go first and a marker says so; the newest | |
| 40 | +// line is always there. | |
| 41 | +func TestTranscriptKeepsTheNewestTurns(t *testing.T) { | |
| 42 | + var msgs []*ai.Message | |
| 43 | + for i := 0; i < 50; i++ { | |
| 44 | + msgs = append(msgs, ai.NewUserTextMessage(strings.Repeat("old ", 10)), ai.NewModelTextMessage("ok")) | |
| 45 | + } | |
| 46 | + msgs = append(msgs, ai.NewUserTextMessage("the latest question")) | |
| 47 | + got := Transcript(msgs, 300) | |
| 48 | + if len([]rune(got)) > 300 { | |
| 49 | + t.Errorf("transcript is %d chars, cap 300", len([]rune(got))) | |
| 50 | + } | |
| 51 | + if !strings.HasPrefix(got, "[… earlier conversation omitted …]") || !strings.HasSuffix(got, "User: the latest question") { | |
| 52 | + t.Errorf("transcript:\n%s", got) | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +// A question alone goes as typed; with a transcript, both are labelled. | |
| 57 | +func TestPrompt(t *testing.T) { | |
| 58 | + if got := Prompt("is it safe?", ""); got != "is it safe?" { | |
| 59 | + t.Errorf("Prompt without context = %q", got) | |
| 60 | + } | |
| 61 | + got := Prompt("is it safe?", "User: hi") | |
| 62 | + want := "[Conversation so far, between the user and another assistant]\nUser: hi\n\n[Question]\nis it safe?" | |
| 63 | + if got != want { | |
| 64 | + t.Errorf("Prompt with context = %q, want %q", got, want) | |
| 65 | + } | |
| 66 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "github.com/firebase/genkit/go/ai" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// conversation is a short history with a system prompt, a tool call and its | ||
| 11 | +// output — everything the transcript must keep or drop. | ||
| 12 | +func conversation() []*ai.Message { | ||
| 13 | + call := ai.NewModelMessage(ai.NewTextPart("Let me look."), ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls -1"}})) | ||
| 14 | + out := ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "SECRET-OUTPUT"})) | ||
| 15 | + return []*ai.Message{ | ||
| 16 | + ai.NewSystemTextMessage("SYSTEM-PROMPT"), | ||
| 17 | + ai.NewUserTextMessage("list the files"), | ||
| 18 | + call, out, | ||
| 19 | + ai.NewModelTextMessage("There are two files."), | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +// The transcript keeps questions, answers and one line per tool call, and | ||
| 24 | +// never the system prompt or a tool's output. | ||
| 25 | +func TestTranscriptKeepsWhatHappenedNotTheOutputs(t *testing.T) { | ||
| 26 | + got := Transcript(conversation(), 10000) | ||
| 27 | + for _, want := range []string{"User: list the files", "Assistant: Let me look.", "Assistant ran bash: map[command:ls -1]", "Assistant: There are two files."} { | ||
| 28 | + if !strings.Contains(got, want) { | ||
| 29 | + t.Errorf("transcript lacks %q:\n%s", want, got) | ||
| 30 | + } | ||
| 31 | + } | ||
| 32 | + for _, never := range []string{"SYSTEM-PROMPT", "SECRET-OUTPUT"} { | ||
| 33 | + if strings.Contains(got, never) { | ||
| 34 | + t.Errorf("transcript leaks %q:\n%s", never, got) | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// Over the cap, the oldest lines go first and a marker says so; the newest | ||
| 40 | +// line is always there. | ||
| 41 | +func TestTranscriptKeepsTheNewestTurns(t *testing.T) { | ||
| 42 | + var msgs []*ai.Message | ||
| 43 | + for i := 0; i < 50; i++ { | ||
| 44 | + msgs = append(msgs, ai.NewUserTextMessage(strings.Repeat("old ", 10)), ai.NewModelTextMessage("ok")) | ||
| 45 | + } | ||
| 46 | + msgs = append(msgs, ai.NewUserTextMessage("the latest question")) | ||
| 47 | + got := Transcript(msgs, 300) | ||
| 48 | + if len([]rune(got)) > 300 { | ||
| 49 | + t.Errorf("transcript is %d chars, cap 300", len([]rune(got))) | ||
| 50 | + } | ||
| 51 | + if !strings.HasPrefix(got, "[… earlier conversation omitted …]") || !strings.HasSuffix(got, "User: the latest question") { | ||
| 52 | + t.Errorf("transcript:\n%s", got) | ||
| 53 | + } | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +// A question alone goes as typed; with a transcript, both are labelled. | ||
| 57 | +func TestPrompt(t *testing.T) { | ||
| 58 | + if got := Prompt("is it safe?", ""); got != "is it safe?" { | ||
| 59 | + t.Errorf("Prompt without context = %q", got) | ||
| 60 | + } | ||
| 61 | + got := Prompt("is it safe?", "User: hi") | ||
| 62 | + want := "[Conversation so far, between the user and another assistant]\nUser: hi\n\n[Question]\nis it safe?" | ||
| 63 | + if got != want { | ||
| 64 | + t.Errorf("Prompt with context = %q, want %q", got, want) | ||
| 65 | + } | ||
| 66 | +} | ||
added
internal/ask/doc.go +22 -0 | new file mode 100644 | ||
| @@ -0,0 +1,22 @@ | ||
| 1 | +// Package ask lets one mm agent put a question to another: `/ask-bob <prompt>` | |
| 2 | +// starts (once) a child `mm -acp -config <bob's yaml>`, talks to it as an | |
| 3 | +// Agent Client Protocol CLIENT, shows what bob does while he does it, and | |
| 4 | +// hands back his answer so the front end can keep it in the history for the | |
| 5 | +// next question. | |
| 6 | +// | |
| 7 | +// Why a child process over ACP, rather than a second engine in this process: | |
| 8 | +// the configuration is one global (config.Cfg) read by the engine, the tools | |
| 9 | +// and the compaction alike, and bob's config differs in all of them — model, | |
| 10 | +// provider, tools, skills, MCP servers. A child keeps each agent whole. Why | |
| 11 | +// ACP rather than `mm -p`: it is structured (text, tool calls and permission | |
| 12 | +// requests arrive as messages, not as a terminal screen to scrape), it can be | |
| 13 | +// cancelled, and mm already speaks it as a server. | |
| 14 | +// | |
| 15 | +// The pieces: | |
| 16 | +// | |
| 17 | +// - context.go — the transcript /ask-with-context sends, and the prompt; | |
| 18 | +// - exchange.go — the two messages the asking agent keeps in its history; | |
| 19 | +// - env.go — the child's environment and the depth limit; | |
| 20 | +// - client.go — the ACP client: events to a ui.Sink, permissions; | |
| 21 | +// - pool.go — one child per agent, started on first use. | |
| 22 | +package ask | |
| new file mode 100644 | |||
| @@ -0,0 +1,22 @@ | |||
| 1 | +// Package ask lets one mm agent put a question to another: `/ask-bob <prompt>` | ||
| 2 | +// starts (once) a child `mm -acp -config <bob's yaml>`, talks to it as an | ||
| 3 | +// Agent Client Protocol CLIENT, shows what bob does while he does it, and | ||
| 4 | +// hands back his answer so the front end can keep it in the history for the | ||
| 5 | +// next question. | ||
| 6 | +// | ||
| 7 | +// Why a child process over ACP, rather than a second engine in this process: | ||
| 8 | +// the configuration is one global (config.Cfg) read by the engine, the tools | ||
| 9 | +// and the compaction alike, and bob's config differs in all of them — model, | ||
| 10 | +// provider, tools, skills, MCP servers. A child keeps each agent whole. Why | ||
| 11 | +// ACP rather than `mm -p`: it is structured (text, tool calls and permission | ||
| 12 | +// requests arrive as messages, not as a terminal screen to scrape), it can be | ||
| 13 | +// cancelled, and mm already speaks it as a server. | ||
| 14 | +// | ||
| 15 | +// The pieces: | ||
| 16 | +// | ||
| 17 | +// - context.go — the transcript /ask-with-context sends, and the prompt; | ||
| 18 | +// - exchange.go — the two messages the asking agent keeps in its history; | ||
| 19 | +// - env.go — the child's environment and the depth limit; | ||
| 20 | +// - client.go — the ACP client: events to a ui.Sink, permissions; | ||
| 21 | +// - pool.go — one child per agent, started on first use. | ||
| 22 | +package ask | ||
added
internal/ask/env.go +55 -0 | new file mode 100644 | ||
| @@ -0,0 +1,55 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "os" | |
| 6 | + "slices" | |
| 7 | + "strconv" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "mm/internal/config" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// DepthEnv carries how deep in a chain of /ask an agent runs: unset for the | |
| 14 | +// one the user started, 1 for the agent it asked, and so on. | |
| 15 | +const DepthEnv = "MM_ASK_DEPTH" | |
| 16 | + | |
| 17 | +// MaxDepth: an agent at this depth may not ask another one. /ask is a USER | |
| 18 | +// command — no model can call it — so a loop needs a person typing /ask into | |
| 19 | +// an agent that was itself asked; the cap keeps even that finite. | |
| 20 | +const MaxDepth = 2 | |
| 21 | + | |
| 22 | +// Depth is this process's place in the chain (0 = started by the user). | |
| 23 | +func Depth() int { | |
| 24 | + n, _ := strconv.Atoi(os.Getenv(DepthEnv)) | |
| 25 | + return max(n, 0) | |
| 26 | +} | |
| 27 | + | |
| 28 | +// CanAsk says whether this agent may ask another one, and why not. | |
| 29 | +func CanAsk() error { | |
| 30 | + if d := Depth(); d >= MaxDepth { | |
| 31 | + return fmt.Errorf("this agent was itself asked through %d level(s) of /ask; it cannot ask further (max %d)", d, MaxDepth) | |
| 32 | + } | |
| 33 | + return nil | |
| 34 | +} | |
| 35 | + | |
| 36 | +// overrides are the variables that would make the child ignore its own | |
| 37 | +// config file: they are meant for ONE agent, the one the user started. | |
| 38 | +var overrides = []string{"AGENT_CONFIG", "AGENT_PROVIDER", "AGENT_MODEL", "AGENT_BASE_URL", config.SessionsEnv, DepthEnv} | |
| 39 | + | |
| 40 | +// ChildEnv is the environment of an agent started by /ask: the parent's, | |
| 41 | +// minus the per-agent overrides, plus no session files (it works in the | |
| 42 | +// parent's project, and its sessions would land in the parent's /sessions) | |
| 43 | +// and its depth. | |
| 44 | +// | |
| 45 | +// cmd.Env = ask.ChildEnv(os.Environ(), ask.Depth()) | |
| 46 | +func ChildEnv(parent []string, depth int) []string { | |
| 47 | + env := make([]string, 0, len(parent)+2) | |
| 48 | + for _, kv := range parent { | |
| 49 | + name, _, _ := strings.Cut(kv, "=") | |
| 50 | + if !slices.Contains(overrides, name) { | |
| 51 | + env = append(env, kv) | |
| 52 | + } | |
| 53 | + } | |
| 54 | + return append(env, config.SessionsEnv+"=off", fmt.Sprintf("%s=%d", DepthEnv, depth+1)) | |
| 55 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "os" | ||
| 6 | + "slices" | ||
| 7 | + "strconv" | ||
| 8 | + "strings" | ||
| 9 | + | ||
| 10 | + "mm/internal/config" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +// DepthEnv carries how deep in a chain of /ask an agent runs: unset for the | ||
| 14 | +// one the user started, 1 for the agent it asked, and so on. | ||
| 15 | +const DepthEnv = "MM_ASK_DEPTH" | ||
| 16 | + | ||
| 17 | +// MaxDepth: an agent at this depth may not ask another one. /ask is a USER | ||
| 18 | +// command — no model can call it — so a loop needs a person typing /ask into | ||
| 19 | +// an agent that was itself asked; the cap keeps even that finite. | ||
| 20 | +const MaxDepth = 2 | ||
| 21 | + | ||
| 22 | +// Depth is this process's place in the chain (0 = started by the user). | ||
| 23 | +func Depth() int { | ||
| 24 | + n, _ := strconv.Atoi(os.Getenv(DepthEnv)) | ||
| 25 | + return max(n, 0) | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +// CanAsk says whether this agent may ask another one, and why not. | ||
| 29 | +func CanAsk() error { | ||
| 30 | + if d := Depth(); d >= MaxDepth { | ||
| 31 | + return fmt.Errorf("this agent was itself asked through %d level(s) of /ask; it cannot ask further (max %d)", d, MaxDepth) | ||
| 32 | + } | ||
| 33 | + return nil | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// overrides are the variables that would make the child ignore its own | ||
| 37 | +// config file: they are meant for ONE agent, the one the user started. | ||
| 38 | +var overrides = []string{"AGENT_CONFIG", "AGENT_PROVIDER", "AGENT_MODEL", "AGENT_BASE_URL", config.SessionsEnv, DepthEnv} | ||
| 39 | + | ||
| 40 | +// ChildEnv is the environment of an agent started by /ask: the parent's, | ||
| 41 | +// minus the per-agent overrides, plus no session files (it works in the | ||
| 42 | +// parent's project, and its sessions would land in the parent's /sessions) | ||
| 43 | +// and its depth. | ||
| 44 | +// | ||
| 45 | +// cmd.Env = ask.ChildEnv(os.Environ(), ask.Depth()) | ||
| 46 | +func ChildEnv(parent []string, depth int) []string { | ||
| 47 | + env := make([]string, 0, len(parent)+2) | ||
| 48 | + for _, kv := range parent { | ||
| 49 | + name, _, _ := strings.Cut(kv, "=") | ||
| 50 | + if !slices.Contains(overrides, name) { | ||
| 51 | + env = append(env, kv) | ||
| 52 | + } | ||
| 53 | + } | ||
| 54 | + return append(env, config.SessionsEnv+"=off", fmt.Sprintf("%s=%d", DepthEnv, depth+1)) | ||
| 55 | +} | ||
added
internal/ask/env_test.go +42 -0 | new file mode 100644 | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "slices" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// The child keeps the parent's environment but none of the variables that | |
| 10 | +// would override ITS config, runs without session files, one level deeper. | |
| 11 | +func TestChildEnv(t *testing.T) { | |
| 12 | + parent := []string{"PATH=/bin", "AGENT_MODEL=parent-model", "AGENT_PROVIDER=dmr", "AGENT_BASE_URL=http://x", "AGENT_CONFIG=p.yaml", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1", "DMR_BASE_URL=http://dmr", "HOME=/h"} | |
| 13 | + got := ChildEnv(parent, 1) | |
| 14 | + for _, want := range []string{"PATH=/bin", "DMR_BASE_URL=http://dmr", "HOME=/h", "AGENT_SESSIONS=off", "MM_ASK_DEPTH=2"} { | |
| 15 | + if !slices.Contains(got, want) { | |
| 16 | + t.Errorf("child env lacks %q: %v", want, got) | |
| 17 | + } | |
| 18 | + } | |
| 19 | + for _, kv := range got { | |
| 20 | + for _, banned := range []string{"AGENT_MODEL=", "AGENT_PROVIDER=", "AGENT_BASE_URL=", "AGENT_CONFIG=", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1"} { | |
| 21 | + if strings.HasPrefix(kv, banned) { | |
| 22 | + t.Errorf("child env keeps %q", kv) | |
| 23 | + } | |
| 24 | + } | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +// The user's agent may ask; an agent asked through MaxDepth levels may not. | |
| 29 | +func TestDepthLimit(t *testing.T) { | |
| 30 | + t.Setenv(DepthEnv, "") | |
| 31 | + if Depth() != 0 || CanAsk() != nil { | |
| 32 | + t.Errorf("top level: Depth %d, CanAsk %v", Depth(), CanAsk()) | |
| 33 | + } | |
| 34 | + t.Setenv(DepthEnv, "1") | |
| 35 | + if CanAsk() != nil { | |
| 36 | + t.Errorf("depth 1: CanAsk %v, want allowed", CanAsk()) | |
| 37 | + } | |
| 38 | + t.Setenv(DepthEnv, "2") | |
| 39 | + if err := CanAsk(); err == nil || !strings.Contains(err.Error(), "cannot ask further") { | |
| 40 | + t.Errorf("depth 2: CanAsk %v, want refused", err) | |
| 41 | + } | |
| 42 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "slices" | ||
| 5 | + "strings" | ||
| 6 | + "testing" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// The child keeps the parent's environment but none of the variables that | ||
| 10 | +// would override ITS config, runs without session files, one level deeper. | ||
| 11 | +func TestChildEnv(t *testing.T) { | ||
| 12 | + parent := []string{"PATH=/bin", "AGENT_MODEL=parent-model", "AGENT_PROVIDER=dmr", "AGENT_BASE_URL=http://x", "AGENT_CONFIG=p.yaml", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1", "DMR_BASE_URL=http://dmr", "HOME=/h"} | ||
| 13 | + got := ChildEnv(parent, 1) | ||
| 14 | + for _, want := range []string{"PATH=/bin", "DMR_BASE_URL=http://dmr", "HOME=/h", "AGENT_SESSIONS=off", "MM_ASK_DEPTH=2"} { | ||
| 15 | + if !slices.Contains(got, want) { | ||
| 16 | + t.Errorf("child env lacks %q: %v", want, got) | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | + for _, kv := range got { | ||
| 20 | + for _, banned := range []string{"AGENT_MODEL=", "AGENT_PROVIDER=", "AGENT_BASE_URL=", "AGENT_CONFIG=", "AGENT_SESSIONS=on", "MM_ASK_DEPTH=1"} { | ||
| 21 | + if strings.HasPrefix(kv, banned) { | ||
| 22 | + t.Errorf("child env keeps %q", kv) | ||
| 23 | + } | ||
| 24 | + } | ||
| 25 | + } | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +// The user's agent may ask; an agent asked through MaxDepth levels may not. | ||
| 29 | +func TestDepthLimit(t *testing.T) { | ||
| 30 | + t.Setenv(DepthEnv, "") | ||
| 31 | + if Depth() != 0 || CanAsk() != nil { | ||
| 32 | + t.Errorf("top level: Depth %d, CanAsk %v", Depth(), CanAsk()) | ||
| 33 | + } | ||
| 34 | + t.Setenv(DepthEnv, "1") | ||
| 35 | + if CanAsk() != nil { | ||
| 36 | + t.Errorf("depth 1: CanAsk %v, want allowed", CanAsk()) | ||
| 37 | + } | ||
| 38 | + t.Setenv(DepthEnv, "2") | ||
| 39 | + if err := CanAsk(); err == nil || !strings.Contains(err.Error(), "cannot ask further") { | ||
| 40 | + t.Errorf("depth 2: CanAsk %v, want refused", err) | ||
| 41 | + } | ||
| 42 | +} | ||
added
internal/ask/exchange.go +63 -0 | new file mode 100644 | ||
| @@ -0,0 +1,63 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "os" | |
| 6 | + | |
| 7 | + "github.com/firebase/genkit/go/ai" | |
| 8 | + "gopkg.in/yaml.v3" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// Exchange is what the asking agent keeps in its history: the question as a | |
| 12 | +// user message and the other agent's answer as the assistant's, each marked | |
| 13 | +// with who was asked. A PAIR, not one message: the next question is a user | |
| 14 | +// message too, and two user messages in a row are refused by chat templates | |
| 15 | +// that require the roles to alternate (Gemma's, Mistral's). | |
| 16 | +// | |
| 17 | +// The answer is capped at maxChars, its beginning and end kept — the same | |
| 18 | +// rule as a tool's output, since it lands in the same window. | |
| 19 | +// | |
| 20 | +// history = append(history, ask.Exchange("bob", "ollama/qwen3.5:2b", false, prompt, answer, config.Cfg.MaxOutput)...) | |
| 21 | +func Exchange(agent, label string, withContext bool, question, answer string, maxChars int) []*ai.Message { | |
| 22 | + how := "" | |
| 23 | + if withContext { | |
| 24 | + how = ", with this conversation as context" | |
| 25 | + } | |
| 26 | + asked := fmt.Sprintf("[asked %s (%s)%s: %s]", agent, label, how, question) | |
| 27 | + answered := fmt.Sprintf("[%s's answer]\n%s", agent, clipMiddle(answer, maxChars)) | |
| 28 | + return []*ai.Message{ai.NewUserTextMessage(asked), ai.NewModelTextMessage(answered)} | |
| 29 | +} | |
| 30 | + | |
| 31 | +// clipMiddle caps s at max characters, keeping the beginning and the end. | |
| 32 | +func clipMiddle(s string, max int) string { | |
| 33 | + r := []rune(s) | |
| 34 | + if max <= 0 || len(r) <= max { | |
| 35 | + return s | |
| 36 | + } | |
| 37 | + tail := min(2000, max/2) | |
| 38 | + head := max - tail | |
| 39 | + return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", string(r[:head]), len(r)-head-tail, string(r[len(r)-tail:])) | |
| 40 | +} | |
| 41 | + | |
| 42 | +// Describe reads "<provider>/<model>" from an agent's config file, for the | |
| 43 | +// marker in the history and the line the user sees. Best effort: the child | |
| 44 | +// validates its own file, and a value it cannot read here is shown as "?". | |
| 45 | +// | |
| 46 | +// ask.Describe("demo-ollama/agent.ollama.yaml") // "ollama/qwen3.5:2b" | |
| 47 | +func Describe(path string) string { | |
| 48 | + data, err := os.ReadFile(path) | |
| 49 | + if err != nil { | |
| 50 | + return "?" | |
| 51 | + } | |
| 52 | + var head struct { | |
| 53 | + Provider string `yaml:"provider"` | |
| 54 | + Model string `yaml:"model"` | |
| 55 | + } | |
| 56 | + if yaml.Unmarshal(data, &head) != nil || head.Model == "" { | |
| 57 | + return "?" | |
| 58 | + } | |
| 59 | + if head.Provider == "" { | |
| 60 | + head.Provider = "dmr" // config.Cfg's default | |
| 61 | + } | |
| 62 | + return head.Provider + "/" + head.Model | |
| 63 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "os" | ||
| 6 | + | ||
| 7 | + "github.com/firebase/genkit/go/ai" | ||
| 8 | + "gopkg.in/yaml.v3" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// Exchange is what the asking agent keeps in its history: the question as a | ||
| 12 | +// user message and the other agent's answer as the assistant's, each marked | ||
| 13 | +// with who was asked. A PAIR, not one message: the next question is a user | ||
| 14 | +// message too, and two user messages in a row are refused by chat templates | ||
| 15 | +// that require the roles to alternate (Gemma's, Mistral's). | ||
| 16 | +// | ||
| 17 | +// The answer is capped at maxChars, its beginning and end kept — the same | ||
| 18 | +// rule as a tool's output, since it lands in the same window. | ||
| 19 | +// | ||
| 20 | +// history = append(history, ask.Exchange("bob", "ollama/qwen3.5:2b", false, prompt, answer, config.Cfg.MaxOutput)...) | ||
| 21 | +func Exchange(agent, label string, withContext bool, question, answer string, maxChars int) []*ai.Message { | ||
| 22 | + how := "" | ||
| 23 | + if withContext { | ||
| 24 | + how = ", with this conversation as context" | ||
| 25 | + } | ||
| 26 | + asked := fmt.Sprintf("[asked %s (%s)%s: %s]", agent, label, how, question) | ||
| 27 | + answered := fmt.Sprintf("[%s's answer]\n%s", agent, clipMiddle(answer, maxChars)) | ||
| 28 | + return []*ai.Message{ai.NewUserTextMessage(asked), ai.NewModelTextMessage(answered)} | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// clipMiddle caps s at max characters, keeping the beginning and the end. | ||
| 32 | +func clipMiddle(s string, max int) string { | ||
| 33 | + r := []rune(s) | ||
| 34 | + if max <= 0 || len(r) <= max { | ||
| 35 | + return s | ||
| 36 | + } | ||
| 37 | + tail := min(2000, max/2) | ||
| 38 | + head := max - tail | ||
| 39 | + return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", string(r[:head]), len(r)-head-tail, string(r[len(r)-tail:])) | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +// Describe reads "<provider>/<model>" from an agent's config file, for the | ||
| 43 | +// marker in the history and the line the user sees. Best effort: the child | ||
| 44 | +// validates its own file, and a value it cannot read here is shown as "?". | ||
| 45 | +// | ||
| 46 | +// ask.Describe("demo-ollama/agent.ollama.yaml") // "ollama/qwen3.5:2b" | ||
| 47 | +func Describe(path string) string { | ||
| 48 | + data, err := os.ReadFile(path) | ||
| 49 | + if err != nil { | ||
| 50 | + return "?" | ||
| 51 | + } | ||
| 52 | + var head struct { | ||
| 53 | + Provider string `yaml:"provider"` | ||
| 54 | + Model string `yaml:"model"` | ||
| 55 | + } | ||
| 56 | + if yaml.Unmarshal(data, &head) != nil || head.Model == "" { | ||
| 57 | + return "?" | ||
| 58 | + } | ||
| 59 | + if head.Provider == "" { | ||
| 60 | + head.Provider = "dmr" // config.Cfg's default | ||
| 61 | + } | ||
| 62 | + return head.Provider + "/" + head.Model | ||
| 63 | +} | ||
added
internal/ask/exchange_test.go +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "github.com/firebase/genkit/go/ai" | |
| 10 | +) | |
| 11 | + | |
| 12 | +// The exchange is a user/model pair (roles must alternate), marked with who | |
| 13 | +// was asked and how, the answer capped with its beginning and end kept. | |
| 14 | +func TestExchangeIsAMarkedPair(t *testing.T) { | |
| 15 | + msgs := Exchange("bob", "ollama/qwen3.5:2b", true, "is it safe?", "Yes.", 100) | |
| 16 | + if len(msgs) != 2 || msgs[0].Role != ai.RoleUser || msgs[1].Role != ai.RoleModel { | |
| 17 | + t.Fatalf("Exchange = %v, want a user then a model message", msgs) | |
| 18 | + } | |
| 19 | + if got := msgs[0].Text(); got != "[asked bob (ollama/qwen3.5:2b), with this conversation as context: is it safe?]" { | |
| 20 | + t.Errorf("question = %q", got) | |
| 21 | + } | |
| 22 | + if got := msgs[1].Text(); got != "[bob's answer]\nYes." { | |
| 23 | + t.Errorf("answer = %q", got) | |
| 24 | + } | |
| 25 | + | |
| 26 | + long := "BEGIN" + strings.Repeat("x", 500) + "END" | |
| 27 | + got := Exchange("bob", "x/y", false, "q", long, 100)[1].Text() | |
| 28 | + if !strings.Contains(got, "BEGIN") || !strings.HasSuffix(got, "END") || !strings.Contains(got, "characters truncated") { | |
| 29 | + t.Errorf("capped answer = %q", got) | |
| 30 | + } | |
| 31 | + if strings.Contains(Exchange("bob", "x/y", false, "q", "a", 100)[0].Text(), "context") { | |
| 32 | + t.Error("a plain /ask says it sent the context") | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +// Describe reads provider/model from the agent's file, defaults the provider | |
| 37 | +// like the config does, and says "?" when it cannot. | |
| 38 | +func TestDescribe(t *testing.T) { | |
| 39 | + dir := t.TempDir() | |
| 40 | + write := func(name, body string) string { | |
| 41 | + p := filepath.Join(dir, name) | |
| 42 | + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { | |
| 43 | + t.Fatal(err) | |
| 44 | + } | |
| 45 | + return p | |
| 46 | + } | |
| 47 | + if got := Describe(write("a.yaml", "provider: ollama\nmodel: qwen3.5:2b\n")); got != "ollama/qwen3.5:2b" { | |
| 48 | + t.Errorf("Describe = %q", got) | |
| 49 | + } | |
| 50 | + if got := Describe(write("b.yaml", "model: ai/gemma\n")); got != "dmr/ai/gemma" { | |
| 51 | + t.Errorf("Describe without provider = %q", got) | |
| 52 | + } | |
| 53 | + if got := Describe(filepath.Join(dir, "missing.yaml")); got != "?" { | |
| 54 | + t.Errorf("Describe(missing) = %q", got) | |
| 55 | + } | |
| 56 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "github.com/firebase/genkit/go/ai" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +// The exchange is a user/model pair (roles must alternate), marked with who | ||
| 13 | +// was asked and how, the answer capped with its beginning and end kept. | ||
| 14 | +func TestExchangeIsAMarkedPair(t *testing.T) { | ||
| 15 | + msgs := Exchange("bob", "ollama/qwen3.5:2b", true, "is it safe?", "Yes.", 100) | ||
| 16 | + if len(msgs) != 2 || msgs[0].Role != ai.RoleUser || msgs[1].Role != ai.RoleModel { | ||
| 17 | + t.Fatalf("Exchange = %v, want a user then a model message", msgs) | ||
| 18 | + } | ||
| 19 | + if got := msgs[0].Text(); got != "[asked bob (ollama/qwen3.5:2b), with this conversation as context: is it safe?]" { | ||
| 20 | + t.Errorf("question = %q", got) | ||
| 21 | + } | ||
| 22 | + if got := msgs[1].Text(); got != "[bob's answer]\nYes." { | ||
| 23 | + t.Errorf("answer = %q", got) | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + long := "BEGIN" + strings.Repeat("x", 500) + "END" | ||
| 27 | + got := Exchange("bob", "x/y", false, "q", long, 100)[1].Text() | ||
| 28 | + if !strings.Contains(got, "BEGIN") || !strings.HasSuffix(got, "END") || !strings.Contains(got, "characters truncated") { | ||
| 29 | + t.Errorf("capped answer = %q", got) | ||
| 30 | + } | ||
| 31 | + if strings.Contains(Exchange("bob", "x/y", false, "q", "a", 100)[0].Text(), "context") { | ||
| 32 | + t.Error("a plain /ask says it sent the context") | ||
| 33 | + } | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// Describe reads provider/model from the agent's file, defaults the provider | ||
| 37 | +// like the config does, and says "?" when it cannot. | ||
| 38 | +func TestDescribe(t *testing.T) { | ||
| 39 | + dir := t.TempDir() | ||
| 40 | + write := func(name, body string) string { | ||
| 41 | + p := filepath.Join(dir, name) | ||
| 42 | + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { | ||
| 43 | + t.Fatal(err) | ||
| 44 | + } | ||
| 45 | + return p | ||
| 46 | + } | ||
| 47 | + if got := Describe(write("a.yaml", "provider: ollama\nmodel: qwen3.5:2b\n")); got != "ollama/qwen3.5:2b" { | ||
| 48 | + t.Errorf("Describe = %q", got) | ||
| 49 | + } | ||
| 50 | + if got := Describe(write("b.yaml", "model: ai/gemma\n")); got != "dmr/ai/gemma" { | ||
| 51 | + t.Errorf("Describe without provider = %q", got) | ||
| 52 | + } | ||
| 53 | + if got := Describe(filepath.Join(dir, "missing.yaml")); got != "?" { | ||
| 54 | + t.Errorf("Describe(missing) = %q", got) | ||
| 55 | + } | ||
| 56 | +} | ||
added
internal/ask/pool.go +253 -0 | new file mode 100644 | ||
| @@ -0,0 +1,253 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "log/slog" | |
| 9 | + "os" | |
| 10 | + "os/exec" | |
| 11 | + "strings" | |
| 12 | + "sync" | |
| 13 | + "time" | |
| 14 | + | |
| 15 | + "mm/internal/ui" | |
| 16 | + | |
| 17 | + sdk "github.com/coder/acp-go-sdk" | |
| 18 | +) | |
| 19 | + | |
| 20 | +// Pool keeps one child agent per name, started on its first /ask and kept for | |
| 21 | +// the life of the asking agent: starting mm costs its engine set-up, its probe | |
| 22 | +// and its MCP servers, and a second question should not pay that again. Each | |
| 23 | +// ask opens a FRESH session in the child — no memory between two asks; the | |
| 24 | +// conversation travels explicitly, with /ask-with-context. | |
| 25 | +// | |
| 26 | +// pool := ask.NewPool() | |
| 27 | +// defer pool.Close() | |
| 28 | +// answer, err := pool.Ask(ctx, "bob", "/path/agent.bob.yaml", cwd, "what is 2+2?", sink) | |
| 29 | +type Pool struct { | |
| 30 | + // command builds the child process for a config file; a test swaps it for | |
| 31 | + // a fake agent. | |
| 32 | + command func(configPath string) *exec.Cmd | |
| 33 | + | |
| 34 | + mu sync.Mutex | |
| 35 | + children map[string]*child | |
| 36 | +} | |
| 37 | + | |
| 38 | +// NewPool returns a pool whose children run this very binary, so every | |
| 39 | +// agent is the same mm build. | |
| 40 | +func NewPool() *Pool { | |
| 41 | + return &Pool{command: childCommand, children: map[string]*child{}} | |
| 42 | +} | |
| 43 | + | |
| 44 | +// childCommand is `<this mm> -acp -config <path>`, in the asker's environment | |
| 45 | +// minus the overrides meant for the asker alone (ChildEnv). | |
| 46 | +func childCommand(configPath string) *exec.Cmd { | |
| 47 | + self, err := os.Executable() | |
| 48 | + if err != nil { | |
| 49 | + self = os.Args[0] | |
| 50 | + } | |
| 51 | + cmd := exec.Command(self, "-acp", "-config", configPath) | |
| 52 | + cmd.Env = ChildEnv(os.Environ(), Depth()) | |
| 53 | + return cmd | |
| 54 | +} | |
| 55 | + | |
| 56 | +// startTimeout bounds the child's start-up: process, initialize, its probe. | |
| 57 | +// Generous — a cold Genkit and a slow MCP discovery (5 s by default) — but | |
| 58 | +// finite, so a child that never speaks ACP fails with its stderr instead of | |
| 59 | +// hanging the asker. | |
| 60 | +var startTimeout = 60 * time.Second | |
| 61 | + | |
| 62 | +// Ask puts prompt to the agent `name` (config file `configPath`), working in | |
| 63 | +// cwd — the asker's project; in an editor, the session's — streaming what it | |
| 64 | +// does to sink, and returns its answer. A cancelled ctx cancels the | |
| 65 | +// child's turn (session/cancel) and returns what was answered so far with | |
| 66 | +// the context's error. A child that died is started again on the next ask. | |
| 67 | +func (p *Pool) Ask(ctx context.Context, name, configPath, cwd, prompt string, sink ui.Sink) (string, error) { | |
| 68 | + if err := CanAsk(); err != nil { | |
| 69 | + return "", err | |
| 70 | + } | |
| 71 | + if _, err := os.Stat(configPath); err != nil { | |
| 72 | + return "", fmt.Errorf("%s: config file %s: %w", name, configPath, errors.Unwrap(err)) | |
| 73 | + } | |
| 74 | + c, err := p.child(ctx, name, configPath) | |
| 75 | + if err != nil { | |
| 76 | + return "", err | |
| 77 | + } | |
| 78 | + return c.ask(ctx, name, cwd, prompt, sink) | |
| 79 | +} | |
| 80 | + | |
| 81 | +// child returns the running child for name, starting it when there is none | |
| 82 | +// or the previous one exited. | |
| 83 | +func (p *Pool) child(ctx context.Context, name, configPath string) (*child, error) { | |
| 84 | + p.mu.Lock() | |
| 85 | + defer p.mu.Unlock() | |
| 86 | + if c := p.children[name]; c != nil && c.alive() { | |
| 87 | + return c, nil | |
| 88 | + } | |
| 89 | + c, err := startChild(ctx, name, p.command(configPath)) | |
| 90 | + if err != nil { | |
| 91 | + return nil, err | |
| 92 | + } | |
| 93 | + p.children[name] = c | |
| 94 | + return c, nil | |
| 95 | +} | |
| 96 | + | |
| 97 | +// Close stops every child: stdin closed (mm -acp exits on EOF), then killed | |
| 98 | +// if it is still there a moment later. | |
| 99 | +func (p *Pool) Close() { | |
| 100 | + p.mu.Lock() | |
| 101 | + defer p.mu.Unlock() | |
| 102 | + for name, c := range p.children { | |
| 103 | + c.stop() | |
| 104 | + delete(p.children, name) | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +// child is one running agent and the ACP connection to it. | |
| 109 | +type child struct { | |
| 110 | + name string | |
| 111 | + cmd *exec.Cmd | |
| 112 | + stdin io.WriteCloser | |
| 113 | + conn *sdk.ClientSideConnection | |
| 114 | + client *client | |
| 115 | + stderr *tail | |
| 116 | + exited chan struct{} | |
| 117 | + | |
| 118 | + askMu sync.Mutex // one ask at a time per child: one sink, one answer | |
| 119 | +} | |
| 120 | + | |
| 121 | +// startChild starts the process and runs the ACP handshake. | |
| 122 | +func startChild(ctx context.Context, name string, cmd *exec.Cmd) (*child, error) { | |
| 123 | + stdin, err := cmd.StdinPipe() | |
| 124 | + if err != nil { | |
| 125 | + return nil, err | |
| 126 | + } | |
| 127 | + stdout, err := cmd.StdoutPipe() | |
| 128 | + if err != nil { | |
| 129 | + return nil, err | |
| 130 | + } | |
| 131 | + c := &child{name: name, cmd: cmd, stdin: stdin, client: &client{}, stderr: &tail{max: 4096}, exited: make(chan struct{})} | |
| 132 | + cmd.Stderr = c.stderr | |
| 133 | + if err := cmd.Start(); err != nil { | |
| 134 | + return nil, fmt.Errorf("%s: cannot start: %w", name, err) | |
| 135 | + } | |
| 136 | + go func() { _ = cmd.Wait(); close(c.exited) }() | |
| 137 | + c.conn = sdk.NewClientSideConnection(c.client, stdin, stdout) | |
| 138 | + // The SDK logs "connection closed" and the like through slog's default, | |
| 139 | + // which is the asker's terminal: the child's life is reported by Ask's | |
| 140 | + // errors, nowhere else. | |
| 141 | + c.conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 142 | + | |
| 143 | + initCtx, cancel := context.WithTimeout(ctx, startTimeout) | |
| 144 | + defer cancel() | |
| 145 | + _, err = c.conn.Initialize(initCtx, sdk.InitializeRequest{ | |
| 146 | + ProtocolVersion: sdk.ProtocolVersionNumber, | |
| 147 | + ClientInfo: &sdk.Implementation{Name: "mm"}, | |
| 148 | + }) | |
| 149 | + if err != nil { | |
| 150 | + c.stop() | |
| 151 | + return nil, c.failure("did not start", err) | |
| 152 | + } | |
| 153 | + return c, nil | |
| 154 | +} | |
| 155 | + | |
| 156 | +// ask runs one prompt in a fresh session. | |
| 157 | +func (c *child) ask(ctx context.Context, name, cwd, prompt string, sink ui.Sink) (string, error) { | |
| 158 | + c.askMu.Lock() | |
| 159 | + defer c.askMu.Unlock() | |
| 160 | + sess, err := c.conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}}) | |
| 161 | + if err != nil { | |
| 162 | + return "", c.failure("could not open a session", err) | |
| 163 | + } | |
| 164 | + c.client.begin(name, sess.SessionId, sink) | |
| 165 | + _, err = c.conn.Prompt(ctx, sdk.PromptRequest{SessionId: sess.SessionId, Prompt: []sdk.ContentBlock{sdk.TextBlock(prompt)}}) | |
| 166 | + answer := c.client.end() | |
| 167 | + switch { | |
| 168 | + case ctx.Err() != nil: | |
| 169 | + return answer, ctx.Err() | |
| 170 | + case err != nil: | |
| 171 | + return answer, c.failure("failed", err) | |
| 172 | + } | |
| 173 | + return answer, nil | |
| 174 | +} | |
| 175 | + | |
| 176 | +// failure is one error line: what went wrong, the protocol's words, and — | |
| 177 | +// when the child wrote anything on stderr — its last line, which is usually | |
| 178 | +// the real cause ("[config error: …]", "[engine error: …]"). | |
| 179 | +func (c *child) failure(what string, err error) error { | |
| 180 | + msg := fmt.Sprintf("%s %s: %v", c.name, what, acpError(err)) | |
| 181 | + if last := c.stderr.lastLine(); last != "" { | |
| 182 | + msg += " — " + last | |
| 183 | + } | |
| 184 | + return errors.New(msg) | |
| 185 | +} | |
| 186 | + | |
| 187 | +// acpError digs the agent's own message out of a JSON-RPC error: mm's ACP | |
| 188 | +// server puts its one-line explanation under data.error. | |
| 189 | +func acpError(err error) string { | |
| 190 | + var re *sdk.RequestError | |
| 191 | + if errors.As(err, &re) { | |
| 192 | + if data, ok := re.Data.(map[string]any); ok { | |
| 193 | + if msg, ok := data["error"].(string); ok && msg != "" { | |
| 194 | + return msg | |
| 195 | + } | |
| 196 | + } | |
| 197 | + return re.Message | |
| 198 | + } | |
| 199 | + return err.Error() | |
| 200 | +} | |
| 201 | + | |
| 202 | +// alive reports whether the process is still running. | |
| 203 | +func (c *child) alive() bool { | |
| 204 | + select { | |
| 205 | + case <-c.exited: | |
| 206 | + return false | |
| 207 | + default: | |
| 208 | + return true | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +// stop ends the child: EOF on its stdin first, then a kill. | |
| 213 | +func (c *child) stop() { | |
| 214 | + _ = c.stdin.Close() | |
| 215 | + select { | |
| 216 | + case <-c.exited: | |
| 217 | + case <-time.After(2 * time.Second): | |
| 218 | + _ = c.cmd.Process.Kill() | |
| 219 | + <-c.exited | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 223 | +// tail keeps the last max bytes written to it: the child's stderr, kept for | |
| 224 | +// the error line and nothing else. | |
| 225 | +type tail struct { | |
| 226 | + mu sync.Mutex | |
| 227 | + buf []byte | |
| 228 | + max int | |
| 229 | +} | |
| 230 | + | |
| 231 | +func (t *tail) Write(p []byte) (int, error) { | |
| 232 | + t.mu.Lock() | |
| 233 | + defer t.mu.Unlock() | |
| 234 | + t.buf = append(t.buf, p...) | |
| 235 | + if len(t.buf) > t.max { | |
| 236 | + t.buf = t.buf[len(t.buf)-t.max:] | |
| 237 | + } | |
| 238 | + return len(p), nil | |
| 239 | +} | |
| 240 | + | |
| 241 | +// lastLine is the last non-empty line written. | |
| 242 | +func (t *tail) lastLine() string { | |
| 243 | + t.mu.Lock() | |
| 244 | + defer t.mu.Unlock() | |
| 245 | + lines := strings.Split(strings.TrimSpace(string(t.buf)), "\n") | |
| 246 | + return strings.TrimSpace(lines[len(lines)-1]) | |
| 247 | +} | |
| 248 | + | |
| 249 | +// exists reports whether path names an existing file. | |
| 250 | +func exists(path string) bool { | |
| 251 | + _, err := os.Stat(path) | |
| 252 | + return err == nil | |
| 253 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,253 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "fmt" | ||
| 7 | + "io" | ||
| 8 | + "log/slog" | ||
| 9 | + "os" | ||
| 10 | + "os/exec" | ||
| 11 | + "strings" | ||
| 12 | + "sync" | ||
| 13 | + "time" | ||
| 14 | + | ||
| 15 | + "mm/internal/ui" | ||
| 16 | + | ||
| 17 | + sdk "github.com/coder/acp-go-sdk" | ||
| 18 | +) | ||
| 19 | + | ||
| 20 | +// Pool keeps one child agent per name, started on its first /ask and kept for | ||
| 21 | +// the life of the asking agent: starting mm costs its engine set-up, its probe | ||
| 22 | +// and its MCP servers, and a second question should not pay that again. Each | ||
| 23 | +// ask opens a FRESH session in the child — no memory between two asks; the | ||
| 24 | +// conversation travels explicitly, with /ask-with-context. | ||
| 25 | +// | ||
| 26 | +// pool := ask.NewPool() | ||
| 27 | +// defer pool.Close() | ||
| 28 | +// answer, err := pool.Ask(ctx, "bob", "/path/agent.bob.yaml", cwd, "what is 2+2?", sink) | ||
| 29 | +type Pool struct { | ||
| 30 | + // command builds the child process for a config file; a test swaps it for | ||
| 31 | + // a fake agent. | ||
| 32 | + command func(configPath string) *exec.Cmd | ||
| 33 | + | ||
| 34 | + mu sync.Mutex | ||
| 35 | + children map[string]*child | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// NewPool returns a pool whose children run this very binary, so every | ||
| 39 | +// agent is the same mm build. | ||
| 40 | +func NewPool() *Pool { | ||
| 41 | + return &Pool{command: childCommand, children: map[string]*child{}} | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// childCommand is `<this mm> -acp -config <path>`, in the asker's environment | ||
| 45 | +// minus the overrides meant for the asker alone (ChildEnv). | ||
| 46 | +func childCommand(configPath string) *exec.Cmd { | ||
| 47 | + self, err := os.Executable() | ||
| 48 | + if err != nil { | ||
| 49 | + self = os.Args[0] | ||
| 50 | + } | ||
| 51 | + cmd := exec.Command(self, "-acp", "-config", configPath) | ||
| 52 | + cmd.Env = ChildEnv(os.Environ(), Depth()) | ||
| 53 | + return cmd | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +// startTimeout bounds the child's start-up: process, initialize, its probe. | ||
| 57 | +// Generous — a cold Genkit and a slow MCP discovery (5 s by default) — but | ||
| 58 | +// finite, so a child that never speaks ACP fails with its stderr instead of | ||
| 59 | +// hanging the asker. | ||
| 60 | +var startTimeout = 60 * time.Second | ||
| 61 | + | ||
| 62 | +// Ask puts prompt to the agent `name` (config file `configPath`), working in | ||
| 63 | +// cwd — the asker's project; in an editor, the session's — streaming what it | ||
| 64 | +// does to sink, and returns its answer. A cancelled ctx cancels the | ||
| 65 | +// child's turn (session/cancel) and returns what was answered so far with | ||
| 66 | +// the context's error. A child that died is started again on the next ask. | ||
| 67 | +func (p *Pool) Ask(ctx context.Context, name, configPath, cwd, prompt string, sink ui.Sink) (string, error) { | ||
| 68 | + if err := CanAsk(); err != nil { | ||
| 69 | + return "", err | ||
| 70 | + } | ||
| 71 | + if _, err := os.Stat(configPath); err != nil { | ||
| 72 | + return "", fmt.Errorf("%s: config file %s: %w", name, configPath, errors.Unwrap(err)) | ||
| 73 | + } | ||
| 74 | + c, err := p.child(ctx, name, configPath) | ||
| 75 | + if err != nil { | ||
| 76 | + return "", err | ||
| 77 | + } | ||
| 78 | + return c.ask(ctx, name, cwd, prompt, sink) | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +// child returns the running child for name, starting it when there is none | ||
| 82 | +// or the previous one exited. | ||
| 83 | +func (p *Pool) child(ctx context.Context, name, configPath string) (*child, error) { | ||
| 84 | + p.mu.Lock() | ||
| 85 | + defer p.mu.Unlock() | ||
| 86 | + if c := p.children[name]; c != nil && c.alive() { | ||
| 87 | + return c, nil | ||
| 88 | + } | ||
| 89 | + c, err := startChild(ctx, name, p.command(configPath)) | ||
| 90 | + if err != nil { | ||
| 91 | + return nil, err | ||
| 92 | + } | ||
| 93 | + p.children[name] = c | ||
| 94 | + return c, nil | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// Close stops every child: stdin closed (mm -acp exits on EOF), then killed | ||
| 98 | +// if it is still there a moment later. | ||
| 99 | +func (p *Pool) Close() { | ||
| 100 | + p.mu.Lock() | ||
| 101 | + defer p.mu.Unlock() | ||
| 102 | + for name, c := range p.children { | ||
| 103 | + c.stop() | ||
| 104 | + delete(p.children, name) | ||
| 105 | + } | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +// child is one running agent and the ACP connection to it. | ||
| 109 | +type child struct { | ||
| 110 | + name string | ||
| 111 | + cmd *exec.Cmd | ||
| 112 | + stdin io.WriteCloser | ||
| 113 | + conn *sdk.ClientSideConnection | ||
| 114 | + client *client | ||
| 115 | + stderr *tail | ||
| 116 | + exited chan struct{} | ||
| 117 | + | ||
| 118 | + askMu sync.Mutex // one ask at a time per child: one sink, one answer | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +// startChild starts the process and runs the ACP handshake. | ||
| 122 | +func startChild(ctx context.Context, name string, cmd *exec.Cmd) (*child, error) { | ||
| 123 | + stdin, err := cmd.StdinPipe() | ||
| 124 | + if err != nil { | ||
| 125 | + return nil, err | ||
| 126 | + } | ||
| 127 | + stdout, err := cmd.StdoutPipe() | ||
| 128 | + if err != nil { | ||
| 129 | + return nil, err | ||
| 130 | + } | ||
| 131 | + c := &child{name: name, cmd: cmd, stdin: stdin, client: &client{}, stderr: &tail{max: 4096}, exited: make(chan struct{})} | ||
| 132 | + cmd.Stderr = c.stderr | ||
| 133 | + if err := cmd.Start(); err != nil { | ||
| 134 | + return nil, fmt.Errorf("%s: cannot start: %w", name, err) | ||
| 135 | + } | ||
| 136 | + go func() { _ = cmd.Wait(); close(c.exited) }() | ||
| 137 | + c.conn = sdk.NewClientSideConnection(c.client, stdin, stdout) | ||
| 138 | + // The SDK logs "connection closed" and the like through slog's default, | ||
| 139 | + // which is the asker's terminal: the child's life is reported by Ask's | ||
| 140 | + // errors, nowhere else. | ||
| 141 | + c.conn.SetLogger(slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 142 | + | ||
| 143 | + initCtx, cancel := context.WithTimeout(ctx, startTimeout) | ||
| 144 | + defer cancel() | ||
| 145 | + _, err = c.conn.Initialize(initCtx, sdk.InitializeRequest{ | ||
| 146 | + ProtocolVersion: sdk.ProtocolVersionNumber, | ||
| 147 | + ClientInfo: &sdk.Implementation{Name: "mm"}, | ||
| 148 | + }) | ||
| 149 | + if err != nil { | ||
| 150 | + c.stop() | ||
| 151 | + return nil, c.failure("did not start", err) | ||
| 152 | + } | ||
| 153 | + return c, nil | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +// ask runs one prompt in a fresh session. | ||
| 157 | +func (c *child) ask(ctx context.Context, name, cwd, prompt string, sink ui.Sink) (string, error) { | ||
| 158 | + c.askMu.Lock() | ||
| 159 | + defer c.askMu.Unlock() | ||
| 160 | + sess, err := c.conn.NewSession(ctx, sdk.NewSessionRequest{Cwd: cwd, McpServers: []sdk.McpServer{}}) | ||
| 161 | + if err != nil { | ||
| 162 | + return "", c.failure("could not open a session", err) | ||
| 163 | + } | ||
| 164 | + c.client.begin(name, sess.SessionId, sink) | ||
| 165 | + _, err = c.conn.Prompt(ctx, sdk.PromptRequest{SessionId: sess.SessionId, Prompt: []sdk.ContentBlock{sdk.TextBlock(prompt)}}) | ||
| 166 | + answer := c.client.end() | ||
| 167 | + switch { | ||
| 168 | + case ctx.Err() != nil: | ||
| 169 | + return answer, ctx.Err() | ||
| 170 | + case err != nil: | ||
| 171 | + return answer, c.failure("failed", err) | ||
| 172 | + } | ||
| 173 | + return answer, nil | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +// failure is one error line: what went wrong, the protocol's words, and — | ||
| 177 | +// when the child wrote anything on stderr — its last line, which is usually | ||
| 178 | +// the real cause ("[config error: …]", "[engine error: …]"). | ||
| 179 | +func (c *child) failure(what string, err error) error { | ||
| 180 | + msg := fmt.Sprintf("%s %s: %v", c.name, what, acpError(err)) | ||
| 181 | + if last := c.stderr.lastLine(); last != "" { | ||
| 182 | + msg += " — " + last | ||
| 183 | + } | ||
| 184 | + return errors.New(msg) | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +// acpError digs the agent's own message out of a JSON-RPC error: mm's ACP | ||
| 188 | +// server puts its one-line explanation under data.error. | ||
| 189 | +func acpError(err error) string { | ||
| 190 | + var re *sdk.RequestError | ||
| 191 | + if errors.As(err, &re) { | ||
| 192 | + if data, ok := re.Data.(map[string]any); ok { | ||
| 193 | + if msg, ok := data["error"].(string); ok && msg != "" { | ||
| 194 | + return msg | ||
| 195 | + } | ||
| 196 | + } | ||
| 197 | + return re.Message | ||
| 198 | + } | ||
| 199 | + return err.Error() | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +// alive reports whether the process is still running. | ||
| 203 | +func (c *child) alive() bool { | ||
| 204 | + select { | ||
| 205 | + case <-c.exited: | ||
| 206 | + return false | ||
| 207 | + default: | ||
| 208 | + return true | ||
| 209 | + } | ||
| 210 | +} | ||
| 211 | + | ||
| 212 | +// stop ends the child: EOF on its stdin first, then a kill. | ||
| 213 | +func (c *child) stop() { | ||
| 214 | + _ = c.stdin.Close() | ||
| 215 | + select { | ||
| 216 | + case <-c.exited: | ||
| 217 | + case <-time.After(2 * time.Second): | ||
| 218 | + _ = c.cmd.Process.Kill() | ||
| 219 | + <-c.exited | ||
| 220 | + } | ||
| 221 | +} | ||
| 222 | + | ||
| 223 | +// tail keeps the last max bytes written to it: the child's stderr, kept for | ||
| 224 | +// the error line and nothing else. | ||
| 225 | +type tail struct { | ||
| 226 | + mu sync.Mutex | ||
| 227 | + buf []byte | ||
| 228 | + max int | ||
| 229 | +} | ||
| 230 | + | ||
| 231 | +func (t *tail) Write(p []byte) (int, error) { | ||
| 232 | + t.mu.Lock() | ||
| 233 | + defer t.mu.Unlock() | ||
| 234 | + t.buf = append(t.buf, p...) | ||
| 235 | + if len(t.buf) > t.max { | ||
| 236 | + t.buf = t.buf[len(t.buf)-t.max:] | ||
| 237 | + } | ||
| 238 | + return len(p), nil | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +// lastLine is the last non-empty line written. | ||
| 242 | +func (t *tail) lastLine() string { | ||
| 243 | + t.mu.Lock() | ||
| 244 | + defer t.mu.Unlock() | ||
| 245 | + lines := strings.Split(strings.TrimSpace(string(t.buf)), "\n") | ||
| 246 | + return strings.TrimSpace(lines[len(lines)-1]) | ||
| 247 | +} | ||
| 248 | + | ||
| 249 | +// exists reports whether path names an existing file. | ||
| 250 | +func exists(path string) bool { | ||
| 251 | + _, err := os.Stat(path) | ||
| 252 | + return err == nil | ||
| 253 | +} | ||
added
internal/ask/pool_test.go +268 -0 | new file mode 100644 | ||
| @@ -0,0 +1,268 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + "os/exec" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | + "sync" | |
| 11 | + "testing" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "mm/internal/ui" | |
| 15 | + | |
| 16 | + sdk "github.com/coder/acp-go-sdk" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// The child agent of these tests is THIS test binary, re-run with | |
| 20 | +// ASK_FAKE_AGENT set: TestFakeAgent then serves ACP on stdin/stdout like | |
| 21 | +// `mm -acp` would, with behaviour picked by the variable's value. No model, | |
| 22 | +// no network, and the real client, pool, pipes and process handling. | |
| 23 | +func TestFakeAgent(t *testing.T) { | |
| 24 | + mode := os.Getenv("ASK_FAKE_AGENT") | |
| 25 | + if mode == "" { | |
| 26 | + t.Skip("helper process only") | |
| 27 | + } | |
| 28 | + if mode == "crash" { | |
| 29 | + fmt.Fprintln(os.Stderr, "[config error: bad.yaml: model must not be empty ]") | |
| 30 | + os.Exit(1) | |
| 31 | + } | |
| 32 | + a := &fakeAgent{mode: mode} | |
| 33 | + a.conn = sdk.NewAgentSideConnection(a, os.Stdout, os.Stdin) | |
| 34 | + <-a.conn.Done() | |
| 35 | + os.Exit(0) | |
| 36 | +} | |
| 37 | + | |
| 38 | +// fakePool is a pool whose children are the fake agent in the given mode. | |
| 39 | +func fakePool(t *testing.T, mode string) *Pool { | |
| 40 | + t.Helper() | |
| 41 | + p := NewPool() | |
| 42 | + p.command = func(string) *exec.Cmd { | |
| 43 | + cmd := exec.Command(os.Args[0], "-test.run=^TestFakeAgent$") | |
| 44 | + cmd.Env = append(os.Environ(), "ASK_FAKE_AGENT="+mode) | |
| 45 | + return cmd | |
| 46 | + } | |
| 47 | + t.Cleanup(p.Close) | |
| 48 | + return p | |
| 49 | +} | |
| 50 | + | |
| 51 | +// configFile is an existing file for Ask's check; its content is not read by | |
| 52 | +// the fake agent. | |
| 53 | +func configFile(t *testing.T) string { | |
| 54 | + t.Helper() | |
| 55 | + path := filepath.Join(t.TempDir(), "agent.bob.yaml") | |
| 56 | + if err := os.WriteFile(path, []byte("model: m\n"), 0o644); err != nil { | |
| 57 | + t.Fatal(err) | |
| 58 | + } | |
| 59 | + return path | |
| 60 | +} | |
| 61 | + | |
| 62 | +// recorder is a ui.Sink that remembers what it was shown and answers | |
| 63 | +// permission requests with `allow`. | |
| 64 | +type recorder struct { | |
| 65 | + mu sync.Mutex | |
| 66 | + text strings.Builder | |
| 67 | + events []string | |
| 68 | + allow bool | |
| 69 | +} | |
| 70 | + | |
| 71 | +func (r *recorder) log(s string) { r.mu.Lock(); r.events = append(r.events, s); r.mu.Unlock() } | |
| 72 | +func (r *recorder) Text(s string) { r.mu.Lock(); r.text.WriteString(s); r.mu.Unlock() } | |
| 73 | +func (r *recorder) Reasoning(s string) { r.log("thought:" + s) } | |
| 74 | +func (r *recorder) ToolStart(ev ui.ToolEvent) { r.log("start:" + ev.Title + ":" + ev.Tool) } | |
| 75 | +func (r *recorder) ToolRunning(string) { r.log("running") } | |
| 76 | +func (r *recorder) ToolEnd(_ string, res ui.ToolResult) { | |
| 77 | + r.log(fmt.Sprintf("end:%s:%v", res.Output, res.Failed)) | |
| 78 | +} | |
| 79 | +func (r *recorder) WorkDir() string { return "" } | |
| 80 | +func (r *recorder) Allow(_ context.Context, ev ui.ToolEvent) bool { | |
| 81 | + r.log("allow?" + ev.Title) | |
| 82 | + return r.allow | |
| 83 | +} | |
| 84 | +func (r *recorder) all() string { | |
| 85 | + r.mu.Lock() | |
| 86 | + defer r.mu.Unlock() | |
| 87 | + return strings.Join(r.events, " | ") | |
| 88 | +} | |
| 89 | + | |
| 90 | +// One ask: the prompt reaches the child, its text streams to the sink and is | |
| 91 | +// returned, its tool call is shown under bob's name with the permission asked | |
| 92 | +// of the asker, and its thoughts are shown as thoughts. | |
| 93 | +func TestAskStreamsTheAnswerAndTheToolCalls(t *testing.T) { | |
| 94 | + p := fakePool(t, "tool") | |
| 95 | + sink := &recorder{allow: true} | |
| 96 | + answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "what is 2+2?", sink) | |
| 97 | + if err != nil { | |
| 98 | + t.Fatal(err) | |
| 99 | + } | |
| 100 | + if answer != "You asked: what is 2+2? (tool allowed)" || sink.text.String() != answer { | |
| 101 | + t.Errorf("answer %q, streamed %q", answer, sink.text.String()) | |
| 102 | + } | |
| 103 | + // The SDK delivers the child's requests (the permission) concurrently with | |
| 104 | + // its notifications (the thought), so the thought's place may vary; the | |
| 105 | + // tool call's own order may not: shown, asked, running, done. | |
| 106 | + got := sink.all() | |
| 107 | + if !strings.Contains(got, "thought:hmm") { | |
| 108 | + t.Errorf("events %s: the thought is missing", got) | |
| 109 | + } | |
| 110 | + tool := strings.ReplaceAll(strings.ReplaceAll(got, "thought:hmm | ", ""), " | thought:hmm", "") | |
| 111 | + if want := "start:bob · bash: ls:bob/bash | allow?bob · bash: ls | running | end:a.txt:false"; tool != want { | |
| 112 | + t.Errorf("tool events:\n got %s\nwant %s", tool, want) | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +// A refusal from the asker's user reaches the child as its reject option. | |
| 117 | +func TestAskForwardsARefusal(t *testing.T) { | |
| 118 | + p := fakePool(t, "tool") | |
| 119 | + answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{allow: false}) | |
| 120 | + if err != nil || !strings.HasSuffix(answer, "(tool rejected)") { | |
| 121 | + t.Errorf("answer %q, err %v, want the child to see the rejection", answer, err) | |
| 122 | + } | |
| 123 | +} | |
| 124 | + | |
| 125 | +// The child is started once and reused, with a fresh session per ask. | |
| 126 | +func TestAskReusesTheChildWithAFreshSession(t *testing.T) { | |
| 127 | + p := fakePool(t, "echo") | |
| 128 | + cfg := configFile(t) | |
| 129 | + first, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "one", &recorder{}) | |
| 130 | + if err != nil { | |
| 131 | + t.Fatal(err) | |
| 132 | + } | |
| 133 | + c := p.children["bob"] | |
| 134 | + second, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "two", &recorder{}) | |
| 135 | + if err != nil { | |
| 136 | + t.Fatal(err) | |
| 137 | + } | |
| 138 | + if p.children["bob"] != c { | |
| 139 | + t.Error("the child was started again") | |
| 140 | + } | |
| 141 | + if first == second || !strings.Contains(second, "session 2") { | |
| 142 | + t.Errorf("answers %q / %q, want two sessions", first, second) | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +// Cancelling the asker's context cancels the child's turn: Ask returns the | |
| 147 | +// context's error and what had streamed, and the child is still usable. | |
| 148 | +func TestAskIsCancellable(t *testing.T) { | |
| 149 | + p := fakePool(t, "slow") | |
| 150 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 151 | + sink := &recorder{} | |
| 152 | + go func() { | |
| 153 | + for !strings.Contains(sink.textNow(), "working") { | |
| 154 | + time.Sleep(10 * time.Millisecond) | |
| 155 | + } | |
| 156 | + cancel() | |
| 157 | + }() | |
| 158 | + answer, err := p.Ask(ctx, "bob", configFile(t), t.TempDir(), "q", sink) | |
| 159 | + if err != context.Canceled || answer != "working" { | |
| 160 | + t.Errorf("answer %q, err %v, want the partial answer and context.Canceled", answer, err) | |
| 161 | + } | |
| 162 | + if !p.children["bob"].alive() { | |
| 163 | + t.Error("the child died with the cancelled ask") | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +func (r *recorder) textNow() string { r.mu.Lock(); defer r.mu.Unlock(); return r.text.String() } | |
| 168 | + | |
| 169 | +// A child that fails to start reports its own last stderr line; a missing | |
| 170 | +// config file is reported before anything starts. | |
| 171 | +func TestAskReportsTheChildFailure(t *testing.T) { | |
| 172 | + p := fakePool(t, "crash") | |
| 173 | + _, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{}) | |
| 174 | + if err == nil || !strings.Contains(err.Error(), "bob did not start") || !strings.Contains(err.Error(), "[config error: bad.yaml: model must not be empty ]") { | |
| 175 | + t.Errorf("err = %v", err) | |
| 176 | + } | |
| 177 | + | |
| 178 | + _, err = fakePool(t, "echo").Ask(context.Background(), "bob", filepath.Join(t.TempDir(), "nope.yaml"), t.TempDir(), "q", &recorder{}) | |
| 179 | + if err == nil || !strings.Contains(err.Error(), "bob: config file") { | |
| 180 | + t.Errorf("missing config: err = %v", err) | |
| 181 | + } | |
| 182 | +} | |
| 183 | + | |
| 184 | +// An agent asked through MaxDepth levels cannot ask further. | |
| 185 | +func TestAskRespectsTheDepthLimit(t *testing.T) { | |
| 186 | + t.Setenv(DepthEnv, fmt.Sprint(MaxDepth)) | |
| 187 | + if _, err := fakePool(t, "echo").Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{}); err == nil || !strings.Contains(err.Error(), "cannot ask further") { | |
| 188 | + t.Errorf("err = %v", err) | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +// --- the fake agent ---------------------------------------------------------- | |
| 193 | + | |
| 194 | +type fakeAgent struct { | |
| 195 | + mode string | |
| 196 | + conn *sdk.AgentSideConnection | |
| 197 | + sessions int | |
| 198 | +} | |
| 199 | + | |
| 200 | +func (a *fakeAgent) send(ctx context.Context, sid sdk.SessionId, u sdk.SessionUpdate) { | |
| 201 | + _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: u}) | |
| 202 | +} | |
| 203 | + | |
| 204 | +func (a *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | |
| 205 | + question := "" | |
| 206 | + if len(p.Prompt) > 0 && p.Prompt[0].Text != nil { | |
| 207 | + question = p.Prompt[0].Text.Text | |
| 208 | + } | |
| 209 | + switch a.mode { | |
| 210 | + case "echo": | |
| 211 | + a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText(fmt.Sprintf("session %d: %s", a.sessions, question))) | |
| 212 | + case "slow": | |
| 213 | + a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("working")) | |
| 214 | + <-ctx.Done() | |
| 215 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil | |
| 216 | + case "tool": | |
| 217 | + a.send(ctx, p.SessionId, sdk.UpdateAgentThoughtText("hmm")) | |
| 218 | + a.send(ctx, p.SessionId, sdk.StartToolCall("call_1", "bash: ls", sdk.WithStartKind(sdk.ToolKindExecute), sdk.WithStartStatus(sdk.ToolCallStatusPending))) | |
| 219 | + resp, err := a.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{ | |
| 220 | + SessionId: p.SessionId, | |
| 221 | + ToolCall: sdk.ToolCallUpdate{ToolCallId: "call_1", Title: sdk.Ptr("bash: ls")}, | |
| 222 | + Options: []sdk.PermissionOption{ | |
| 223 | + {Kind: sdk.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: "allow"}, | |
| 224 | + {Kind: sdk.PermissionOptionKindRejectOnce, Name: "Reject", OptionId: "reject"}, | |
| 225 | + }, | |
| 226 | + }) | |
| 227 | + verdict := "rejected" | |
| 228 | + if err == nil && resp.Outcome.Selected != nil && resp.Outcome.Selected.OptionId == "allow" { | |
| 229 | + verdict = "allowed" | |
| 230 | + a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress))) | |
| 231 | + a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusCompleted), sdk.WithUpdateContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock("a.txt"))}))) | |
| 232 | + } | |
| 233 | + a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("You asked: "+question+" (tool "+verdict+")")) | |
| 234 | + } | |
| 235 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil | |
| 236 | +} | |
| 237 | + | |
| 238 | +func (a *fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | |
| 239 | + a.sessions++ | |
| 240 | + return sdk.NewSessionResponse{SessionId: sdk.SessionId(fmt.Sprintf("s%d", a.sessions))}, nil | |
| 241 | +} | |
| 242 | + | |
| 243 | +func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) { | |
| 244 | + return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber}, nil | |
| 245 | +} | |
| 246 | + | |
| 247 | +func (a *fakeAgent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) { | |
| 248 | + return sdk.AuthenticateResponse{}, nil | |
| 249 | +} | |
| 250 | +func (a *fakeAgent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) { | |
| 251 | + return sdk.LogoutResponse{}, nil | |
| 252 | +} | |
| 253 | +func (a *fakeAgent) Cancel(context.Context, sdk.CancelNotification) error { return nil } | |
| 254 | +func (a *fakeAgent) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) { | |
| 255 | + return sdk.CloseSessionResponse{}, nil | |
| 256 | +} | |
| 257 | +func (a *fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) { | |
| 258 | + return sdk.ListSessionsResponse{}, nil | |
| 259 | +} | |
| 260 | +func (a *fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | |
| 261 | + return sdk.ResumeSessionResponse{}, nil | |
| 262 | +} | |
| 263 | +func (a *fakeAgent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) { | |
| 264 | + return sdk.SetSessionConfigOptionResponse{}, nil | |
| 265 | +} | |
| 266 | +func (a *fakeAgent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) { | |
| 267 | + return sdk.SetSessionModeResponse{}, nil | |
| 268 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,268 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "os" | ||
| 7 | + "os/exec" | ||
| 8 | + "path/filepath" | ||
| 9 | + "strings" | ||
| 10 | + "sync" | ||
| 11 | + "testing" | ||
| 12 | + "time" | ||
| 13 | + | ||
| 14 | + "mm/internal/ui" | ||
| 15 | + | ||
| 16 | + sdk "github.com/coder/acp-go-sdk" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// The child agent of these tests is THIS test binary, re-run with | ||
| 20 | +// ASK_FAKE_AGENT set: TestFakeAgent then serves ACP on stdin/stdout like | ||
| 21 | +// `mm -acp` would, with behaviour picked by the variable's value. No model, | ||
| 22 | +// no network, and the real client, pool, pipes and process handling. | ||
| 23 | +func TestFakeAgent(t *testing.T) { | ||
| 24 | + mode := os.Getenv("ASK_FAKE_AGENT") | ||
| 25 | + if mode == "" { | ||
| 26 | + t.Skip("helper process only") | ||
| 27 | + } | ||
| 28 | + if mode == "crash" { | ||
| 29 | + fmt.Fprintln(os.Stderr, "[config error: bad.yaml: model must not be empty ]") | ||
| 30 | + os.Exit(1) | ||
| 31 | + } | ||
| 32 | + a := &fakeAgent{mode: mode} | ||
| 33 | + a.conn = sdk.NewAgentSideConnection(a, os.Stdout, os.Stdin) | ||
| 34 | + <-a.conn.Done() | ||
| 35 | + os.Exit(0) | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// fakePool is a pool whose children are the fake agent in the given mode. | ||
| 39 | +func fakePool(t *testing.T, mode string) *Pool { | ||
| 40 | + t.Helper() | ||
| 41 | + p := NewPool() | ||
| 42 | + p.command = func(string) *exec.Cmd { | ||
| 43 | + cmd := exec.Command(os.Args[0], "-test.run=^TestFakeAgent$") | ||
| 44 | + cmd.Env = append(os.Environ(), "ASK_FAKE_AGENT="+mode) | ||
| 45 | + return cmd | ||
| 46 | + } | ||
| 47 | + t.Cleanup(p.Close) | ||
| 48 | + return p | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// configFile is an existing file for Ask's check; its content is not read by | ||
| 52 | +// the fake agent. | ||
| 53 | +func configFile(t *testing.T) string { | ||
| 54 | + t.Helper() | ||
| 55 | + path := filepath.Join(t.TempDir(), "agent.bob.yaml") | ||
| 56 | + if err := os.WriteFile(path, []byte("model: m\n"), 0o644); err != nil { | ||
| 57 | + t.Fatal(err) | ||
| 58 | + } | ||
| 59 | + return path | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// recorder is a ui.Sink that remembers what it was shown and answers | ||
| 63 | +// permission requests with `allow`. | ||
| 64 | +type recorder struct { | ||
| 65 | + mu sync.Mutex | ||
| 66 | + text strings.Builder | ||
| 67 | + events []string | ||
| 68 | + allow bool | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +func (r *recorder) log(s string) { r.mu.Lock(); r.events = append(r.events, s); r.mu.Unlock() } | ||
| 72 | +func (r *recorder) Text(s string) { r.mu.Lock(); r.text.WriteString(s); r.mu.Unlock() } | ||
| 73 | +func (r *recorder) Reasoning(s string) { r.log("thought:" + s) } | ||
| 74 | +func (r *recorder) ToolStart(ev ui.ToolEvent) { r.log("start:" + ev.Title + ":" + ev.Tool) } | ||
| 75 | +func (r *recorder) ToolRunning(string) { r.log("running") } | ||
| 76 | +func (r *recorder) ToolEnd(_ string, res ui.ToolResult) { | ||
| 77 | + r.log(fmt.Sprintf("end:%s:%v", res.Output, res.Failed)) | ||
| 78 | +} | ||
| 79 | +func (r *recorder) WorkDir() string { return "" } | ||
| 80 | +func (r *recorder) Allow(_ context.Context, ev ui.ToolEvent) bool { | ||
| 81 | + r.log("allow?" + ev.Title) | ||
| 82 | + return r.allow | ||
| 83 | +} | ||
| 84 | +func (r *recorder) all() string { | ||
| 85 | + r.mu.Lock() | ||
| 86 | + defer r.mu.Unlock() | ||
| 87 | + return strings.Join(r.events, " | ") | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// One ask: the prompt reaches the child, its text streams to the sink and is | ||
| 91 | +// returned, its tool call is shown under bob's name with the permission asked | ||
| 92 | +// of the asker, and its thoughts are shown as thoughts. | ||
| 93 | +func TestAskStreamsTheAnswerAndTheToolCalls(t *testing.T) { | ||
| 94 | + p := fakePool(t, "tool") | ||
| 95 | + sink := &recorder{allow: true} | ||
| 96 | + answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "what is 2+2?", sink) | ||
| 97 | + if err != nil { | ||
| 98 | + t.Fatal(err) | ||
| 99 | + } | ||
| 100 | + if answer != "You asked: what is 2+2? (tool allowed)" || sink.text.String() != answer { | ||
| 101 | + t.Errorf("answer %q, streamed %q", answer, sink.text.String()) | ||
| 102 | + } | ||
| 103 | + // The SDK delivers the child's requests (the permission) concurrently with | ||
| 104 | + // its notifications (the thought), so the thought's place may vary; the | ||
| 105 | + // tool call's own order may not: shown, asked, running, done. | ||
| 106 | + got := sink.all() | ||
| 107 | + if !strings.Contains(got, "thought:hmm") { | ||
| 108 | + t.Errorf("events %s: the thought is missing", got) | ||
| 109 | + } | ||
| 110 | + tool := strings.ReplaceAll(strings.ReplaceAll(got, "thought:hmm | ", ""), " | thought:hmm", "") | ||
| 111 | + if want := "start:bob · bash: ls:bob/bash | allow?bob · bash: ls | running | end:a.txt:false"; tool != want { | ||
| 112 | + t.Errorf("tool events:\n got %s\nwant %s", tool, want) | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +// A refusal from the asker's user reaches the child as its reject option. | ||
| 117 | +func TestAskForwardsARefusal(t *testing.T) { | ||
| 118 | + p := fakePool(t, "tool") | ||
| 119 | + answer, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{allow: false}) | ||
| 120 | + if err != nil || !strings.HasSuffix(answer, "(tool rejected)") { | ||
| 121 | + t.Errorf("answer %q, err %v, want the child to see the rejection", answer, err) | ||
| 122 | + } | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +// The child is started once and reused, with a fresh session per ask. | ||
| 126 | +func TestAskReusesTheChildWithAFreshSession(t *testing.T) { | ||
| 127 | + p := fakePool(t, "echo") | ||
| 128 | + cfg := configFile(t) | ||
| 129 | + first, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "one", &recorder{}) | ||
| 130 | + if err != nil { | ||
| 131 | + t.Fatal(err) | ||
| 132 | + } | ||
| 133 | + c := p.children["bob"] | ||
| 134 | + second, err := p.Ask(context.Background(), "bob", cfg, t.TempDir(), "two", &recorder{}) | ||
| 135 | + if err != nil { | ||
| 136 | + t.Fatal(err) | ||
| 137 | + } | ||
| 138 | + if p.children["bob"] != c { | ||
| 139 | + t.Error("the child was started again") | ||
| 140 | + } | ||
| 141 | + if first == second || !strings.Contains(second, "session 2") { | ||
| 142 | + t.Errorf("answers %q / %q, want two sessions", first, second) | ||
| 143 | + } | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// Cancelling the asker's context cancels the child's turn: Ask returns the | ||
| 147 | +// context's error and what had streamed, and the child is still usable. | ||
| 148 | +func TestAskIsCancellable(t *testing.T) { | ||
| 149 | + p := fakePool(t, "slow") | ||
| 150 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 151 | + sink := &recorder{} | ||
| 152 | + go func() { | ||
| 153 | + for !strings.Contains(sink.textNow(), "working") { | ||
| 154 | + time.Sleep(10 * time.Millisecond) | ||
| 155 | + } | ||
| 156 | + cancel() | ||
| 157 | + }() | ||
| 158 | + answer, err := p.Ask(ctx, "bob", configFile(t), t.TempDir(), "q", sink) | ||
| 159 | + if err != context.Canceled || answer != "working" { | ||
| 160 | + t.Errorf("answer %q, err %v, want the partial answer and context.Canceled", answer, err) | ||
| 161 | + } | ||
| 162 | + if !p.children["bob"].alive() { | ||
| 163 | + t.Error("the child died with the cancelled ask") | ||
| 164 | + } | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +func (r *recorder) textNow() string { r.mu.Lock(); defer r.mu.Unlock(); return r.text.String() } | ||
| 168 | + | ||
| 169 | +// A child that fails to start reports its own last stderr line; a missing | ||
| 170 | +// config file is reported before anything starts. | ||
| 171 | +func TestAskReportsTheChildFailure(t *testing.T) { | ||
| 172 | + p := fakePool(t, "crash") | ||
| 173 | + _, err := p.Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{}) | ||
| 174 | + if err == nil || !strings.Contains(err.Error(), "bob did not start") || !strings.Contains(err.Error(), "[config error: bad.yaml: model must not be empty ]") { | ||
| 175 | + t.Errorf("err = %v", err) | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + _, err = fakePool(t, "echo").Ask(context.Background(), "bob", filepath.Join(t.TempDir(), "nope.yaml"), t.TempDir(), "q", &recorder{}) | ||
| 179 | + if err == nil || !strings.Contains(err.Error(), "bob: config file") { | ||
| 180 | + t.Errorf("missing config: err = %v", err) | ||
| 181 | + } | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +// An agent asked through MaxDepth levels cannot ask further. | ||
| 185 | +func TestAskRespectsTheDepthLimit(t *testing.T) { | ||
| 186 | + t.Setenv(DepthEnv, fmt.Sprint(MaxDepth)) | ||
| 187 | + if _, err := fakePool(t, "echo").Ask(context.Background(), "bob", configFile(t), t.TempDir(), "q", &recorder{}); err == nil || !strings.Contains(err.Error(), "cannot ask further") { | ||
| 188 | + t.Errorf("err = %v", err) | ||
| 189 | + } | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +// --- the fake agent ---------------------------------------------------------- | ||
| 193 | + | ||
| 194 | +type fakeAgent struct { | ||
| 195 | + mode string | ||
| 196 | + conn *sdk.AgentSideConnection | ||
| 197 | + sessions int | ||
| 198 | +} | ||
| 199 | + | ||
| 200 | +func (a *fakeAgent) send(ctx context.Context, sid sdk.SessionId, u sdk.SessionUpdate) { | ||
| 201 | + _ = a.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: sid, Update: u}) | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +func (a *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | ||
| 205 | + question := "" | ||
| 206 | + if len(p.Prompt) > 0 && p.Prompt[0].Text != nil { | ||
| 207 | + question = p.Prompt[0].Text.Text | ||
| 208 | + } | ||
| 209 | + switch a.mode { | ||
| 210 | + case "echo": | ||
| 211 | + a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText(fmt.Sprintf("session %d: %s", a.sessions, question))) | ||
| 212 | + case "slow": | ||
| 213 | + a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("working")) | ||
| 214 | + <-ctx.Done() | ||
| 215 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil | ||
| 216 | + case "tool": | ||
| 217 | + a.send(ctx, p.SessionId, sdk.UpdateAgentThoughtText("hmm")) | ||
| 218 | + a.send(ctx, p.SessionId, sdk.StartToolCall("call_1", "bash: ls", sdk.WithStartKind(sdk.ToolKindExecute), sdk.WithStartStatus(sdk.ToolCallStatusPending))) | ||
| 219 | + resp, err := a.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{ | ||
| 220 | + SessionId: p.SessionId, | ||
| 221 | + ToolCall: sdk.ToolCallUpdate{ToolCallId: "call_1", Title: sdk.Ptr("bash: ls")}, | ||
| 222 | + Options: []sdk.PermissionOption{ | ||
| 223 | + {Kind: sdk.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: "allow"}, | ||
| 224 | + {Kind: sdk.PermissionOptionKindRejectOnce, Name: "Reject", OptionId: "reject"}, | ||
| 225 | + }, | ||
| 226 | + }) | ||
| 227 | + verdict := "rejected" | ||
| 228 | + if err == nil && resp.Outcome.Selected != nil && resp.Outcome.Selected.OptionId == "allow" { | ||
| 229 | + verdict = "allowed" | ||
| 230 | + a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress))) | ||
| 231 | + a.send(ctx, p.SessionId, sdk.UpdateToolCall("call_1", sdk.WithUpdateStatus(sdk.ToolCallStatusCompleted), sdk.WithUpdateContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock("a.txt"))}))) | ||
| 232 | + } | ||
| 233 | + a.send(ctx, p.SessionId, sdk.UpdateAgentMessageText("You asked: "+question+" (tool "+verdict+")")) | ||
| 234 | + } | ||
| 235 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +func (a *fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | ||
| 239 | + a.sessions++ | ||
| 240 | + return sdk.NewSessionResponse{SessionId: sdk.SessionId(fmt.Sprintf("s%d", a.sessions))}, nil | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +func (a *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) { | ||
| 244 | + return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber}, nil | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +func (a *fakeAgent) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) { | ||
| 248 | + return sdk.AuthenticateResponse{}, nil | ||
| 249 | +} | ||
| 250 | +func (a *fakeAgent) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) { | ||
| 251 | + return sdk.LogoutResponse{}, nil | ||
| 252 | +} | ||
| 253 | +func (a *fakeAgent) Cancel(context.Context, sdk.CancelNotification) error { return nil } | ||
| 254 | +func (a *fakeAgent) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) { | ||
| 255 | + return sdk.CloseSessionResponse{}, nil | ||
| 256 | +} | ||
| 257 | +func (a *fakeAgent) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) { | ||
| 258 | + return sdk.ListSessionsResponse{}, nil | ||
| 259 | +} | ||
| 260 | +func (a *fakeAgent) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | ||
| 261 | + return sdk.ResumeSessionResponse{}, nil | ||
| 262 | +} | ||
| 263 | +func (a *fakeAgent) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) { | ||
| 264 | + return sdk.SetSessionConfigOptionResponse{}, nil | ||
| 265 | +} | ||
| 266 | +func (a *fakeAgent) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) { | ||
| 267 | + return sdk.SetSessionModeResponse{}, nil | ||
| 268 | +} | ||
added
internal/ask/run.go +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + | |
| 8 | + "mm/internal/command" | |
| 9 | + "mm/internal/config" | |
| 10 | + "mm/internal/ui" | |
| 11 | + | |
| 12 | + "github.com/firebase/genkit/go/ai" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// Runner is what a front end needs to run /ask: Pool in production, a fake | |
| 16 | +// in the front ends' tests. | |
| 17 | +type Runner interface { | |
| 18 | + Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error) | |
| 19 | +} | |
| 20 | + | |
| 21 | +var _ Runner = (*Pool)(nil) | |
| 22 | + | |
| 23 | +// Run is what the three front ends call for one /ask: it builds what the | |
| 24 | +// other agent receives (the question, and the conversation for | |
| 25 | +// /ask-with-context), asks it, and returns the exchange to append to the | |
| 26 | +// history — or an error, and then nothing to append: a failed or cancelled | |
| 27 | +// ask leaves the history as it was. | |
| 28 | +// | |
| 29 | +// The answer itself has already been shown, as it streamed, through sink. | |
| 30 | +// | |
| 31 | +// req, ok, err := command.ParseAsk(input) | |
| 32 | +// if ok && err == nil { | |
| 33 | +// exchange, err := pool.Run(ctx, req, messages, cwd, sink) | |
| 34 | +// if err == nil { | |
| 35 | +// messages = append(messages, exchange...) | |
| 36 | +// } | |
| 37 | +// } | |
| 38 | +func (p *Pool) Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error) { | |
| 39 | + path, ok := config.Cfg.MMConfigs[req.Agent] | |
| 40 | + if !ok { | |
| 41 | + return nil, fmt.Errorf("unknown agent %q", req.Agent) | |
| 42 | + } | |
| 43 | + transcript := "" | |
| 44 | + if req.WithContext { | |
| 45 | + transcript = Transcript(history, config.Cfg.MaxOutput) | |
| 46 | + } | |
| 47 | + answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Prompt, transcript), sink) | |
| 48 | + if err != nil { | |
| 49 | + return nil, err | |
| 50 | + } | |
| 51 | + if answer == "" { | |
| 52 | + return nil, errors.New(req.Agent + " gave no answer") | |
| 53 | + } | |
| 54 | + return Exchange(req.Agent, Describe(path), req.WithContext, req.Prompt, answer, config.Cfg.MaxOutput), nil | |
| 55 | +} | |
| 56 | + | |
| 57 | +// Label is the one line a front end shows before the answer streams: who is | |
| 58 | +// asked, on what model, and how. | |
| 59 | +// | |
| 60 | +// ask.Label(req) // "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…" | |
| 61 | +func Label(req command.AskRequest) string { | |
| 62 | + line := fmt.Sprintf("🗣 asking %s (%s)", req.Agent, Describe(config.Cfg.MMConfigs[req.Agent])) | |
| 63 | + if req.WithContext { | |
| 64 | + line += ", with the conversation" | |
| 65 | + } | |
| 66 | + return line + "…" | |
| 67 | +} | |
| 68 | + | |
| 69 | +// Missing lists the configured agents whose file does not exist, for the | |
| 70 | +// start-up warnings: the agent starts anyway, and /ask-<name> says it again. | |
| 71 | +func Missing() []string { | |
| 72 | + var warnings []string | |
| 73 | + for _, name := range config.AgentNames() { | |
| 74 | + if path := config.Cfg.MMConfigs[name]; !exists(path) { | |
| 75 | + warnings = append(warnings, fmt.Sprintf("mmConfigs: %s → %s not found — /ask-%s will fail", name, path, name)) | |
| 76 | + } | |
| 77 | + } | |
| 78 | + return warnings | |
| 79 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "fmt" | ||
| 7 | + | ||
| 8 | + "mm/internal/command" | ||
| 9 | + "mm/internal/config" | ||
| 10 | + "mm/internal/ui" | ||
| 11 | + | ||
| 12 | + "github.com/firebase/genkit/go/ai" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// Runner is what a front end needs to run /ask: Pool in production, a fake | ||
| 16 | +// in the front ends' tests. | ||
| 17 | +type Runner interface { | ||
| 18 | + Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error) | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +var _ Runner = (*Pool)(nil) | ||
| 22 | + | ||
| 23 | +// Run is what the three front ends call for one /ask: it builds what the | ||
| 24 | +// other agent receives (the question, and the conversation for | ||
| 25 | +// /ask-with-context), asks it, and returns the exchange to append to the | ||
| 26 | +// history — or an error, and then nothing to append: a failed or cancelled | ||
| 27 | +// ask leaves the history as it was. | ||
| 28 | +// | ||
| 29 | +// The answer itself has already been shown, as it streamed, through sink. | ||
| 30 | +// | ||
| 31 | +// req, ok, err := command.ParseAsk(input) | ||
| 32 | +// if ok && err == nil { | ||
| 33 | +// exchange, err := pool.Run(ctx, req, messages, cwd, sink) | ||
| 34 | +// if err == nil { | ||
| 35 | +// messages = append(messages, exchange...) | ||
| 36 | +// } | ||
| 37 | +// } | ||
| 38 | +func (p *Pool) Run(ctx context.Context, req command.AskRequest, history []*ai.Message, cwd string, sink ui.Sink) ([]*ai.Message, error) { | ||
| 39 | + path, ok := config.Cfg.MMConfigs[req.Agent] | ||
| 40 | + if !ok { | ||
| 41 | + return nil, fmt.Errorf("unknown agent %q", req.Agent) | ||
| 42 | + } | ||
| 43 | + transcript := "" | ||
| 44 | + if req.WithContext { | ||
| 45 | + transcript = Transcript(history, config.Cfg.MaxOutput) | ||
| 46 | + } | ||
| 47 | + answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Prompt, transcript), sink) | ||
| 48 | + if err != nil { | ||
| 49 | + return nil, err | ||
| 50 | + } | ||
| 51 | + if answer == "" { | ||
| 52 | + return nil, errors.New(req.Agent + " gave no answer") | ||
| 53 | + } | ||
| 54 | + return Exchange(req.Agent, Describe(path), req.WithContext, req.Prompt, answer, config.Cfg.MaxOutput), nil | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +// Label is the one line a front end shows before the answer streams: who is | ||
| 58 | +// asked, on what model, and how. | ||
| 59 | +// | ||
| 60 | +// ask.Label(req) // "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…" | ||
| 61 | +func Label(req command.AskRequest) string { | ||
| 62 | + line := fmt.Sprintf("🗣 asking %s (%s)", req.Agent, Describe(config.Cfg.MMConfigs[req.Agent])) | ||
| 63 | + if req.WithContext { | ||
| 64 | + line += ", with the conversation" | ||
| 65 | + } | ||
| 66 | + return line + "…" | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +// Missing lists the configured agents whose file does not exist, for the | ||
| 70 | +// start-up warnings: the agent starts anyway, and /ask-<name> says it again. | ||
| 71 | +func Missing() []string { | ||
| 72 | + var warnings []string | ||
| 73 | + for _, name := range config.AgentNames() { | ||
| 74 | + if path := config.Cfg.MMConfigs[name]; !exists(path) { | ||
| 75 | + warnings = append(warnings, fmt.Sprintf("mmConfigs: %s → %s not found — /ask-%s will fail", name, path, name)) | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + return warnings | ||
| 79 | +} | ||
added
internal/ask/run_test.go +83 -0 | new file mode 100644 | ||
| @@ -0,0 +1,83 @@ | ||
| 1 | +package ask | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + | |
| 10 | + "mm/internal/command" | |
| 11 | + "mm/internal/config" | |
| 12 | + | |
| 13 | + "github.com/firebase/genkit/go/ai" | |
| 14 | +) | |
| 15 | + | |
| 16 | +// withAgent configures one agent, bob, whose file exists. | |
| 17 | +func withAgent(t *testing.T) { | |
| 18 | + t.Helper() | |
| 19 | + saved := config.Cfg | |
| 20 | + t.Cleanup(func() { config.Cfg = saved }) | |
| 21 | + path := filepath.Join(t.TempDir(), "agent.bob.yaml") | |
| 22 | + if err := os.WriteFile(path, []byte("provider: ollama\nmodel: qwen3.5:2b\n"), 0o644); err != nil { | |
| 23 | + t.Fatal(err) | |
| 24 | + } | |
| 25 | + config.Cfg.MMConfigs = map[string]string{"bob": path} | |
| 26 | +} | |
| 27 | + | |
| 28 | +// A plain ask sends the question alone and returns the marked pair. | |
| 29 | +func TestRunReturnsTheExchange(t *testing.T) { | |
| 30 | + withAgent(t) | |
| 31 | + history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")} | |
| 32 | + got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, history, t.TempDir(), &recorder{}) | |
| 33 | + if err != nil { | |
| 34 | + t.Fatal(err) | |
| 35 | + } | |
| 36 | + if len(got) != 2 || got[0].Text() != "[asked bob (ollama/qwen3.5:2b): hi]" || got[1].Text() != "[bob's answer]\nsession 1: hi" { | |
| 37 | + t.Errorf("exchange = %q / %q", got[0].Text(), got[1].Text()) | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +// /ask-with-context sends the transcript before the question. | |
| 42 | +func TestRunWithContextSendsTheConversation(t *testing.T) { | |
| 43 | + withAgent(t) | |
| 44 | + history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")} | |
| 45 | + got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", WithContext: true, Prompt: "hi"}, history, t.TempDir(), &recorder{}) | |
| 46 | + if err != nil { | |
| 47 | + t.Fatal(err) | |
| 48 | + } | |
| 49 | + answer := got[1].Text() | |
| 50 | + if !strings.Contains(answer, "User: PREVIOUS-QUESTION") || !strings.Contains(answer, "[Question]\nhi") { | |
| 51 | + t.Errorf("the child received:\n%s", answer) | |
| 52 | + } | |
| 53 | + if !strings.Contains(got[0].Text(), "with this conversation as context") { | |
| 54 | + t.Errorf("question marker = %q", got[0].Text()) | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +// A failed ask leaves nothing to append; an unknown agent is refused before | |
| 59 | +// anything starts; a missing file is listed for the start-up warnings. | |
| 60 | +func TestRunFailures(t *testing.T) { | |
| 61 | + withAgent(t) | |
| 62 | + if got, err := fakePool(t, "crash").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil || got != nil { | |
| 63 | + t.Errorf("crash: %v, %v — want an error and no exchange", got, err) | |
| 64 | + } | |
| 65 | + if _, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "spock", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil { | |
| 66 | + t.Error("unknown agent accepted") | |
| 67 | + } | |
| 68 | + if len(Missing()) != 0 { | |
| 69 | + t.Errorf("Missing() = %v with an existing file", Missing()) | |
| 70 | + } | |
| 71 | + config.Cfg.MMConfigs["riker"] = filepath.Join(t.TempDir(), "nope.yaml") | |
| 72 | + if m := Missing(); len(m) != 1 || !strings.Contains(m[0], "riker") { | |
| 73 | + t.Errorf("Missing() = %v", m) | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +// The label names the agent, its model, and the context when sent. | |
| 78 | +func TestLabel(t *testing.T) { | |
| 79 | + withAgent(t) | |
| 80 | + if got := Label(command.AskRequest{Agent: "bob", WithContext: true}); got != "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…" { | |
| 81 | + t.Errorf("Label = %q", got) | |
| 82 | + } | |
| 83 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | +package ask | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "os" | ||
| 6 | + "path/filepath" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | + | ||
| 10 | + "mm/internal/command" | ||
| 11 | + "mm/internal/config" | ||
| 12 | + | ||
| 13 | + "github.com/firebase/genkit/go/ai" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +// withAgent configures one agent, bob, whose file exists. | ||
| 17 | +func withAgent(t *testing.T) { | ||
| 18 | + t.Helper() | ||
| 19 | + saved := config.Cfg | ||
| 20 | + t.Cleanup(func() { config.Cfg = saved }) | ||
| 21 | + path := filepath.Join(t.TempDir(), "agent.bob.yaml") | ||
| 22 | + if err := os.WriteFile(path, []byte("provider: ollama\nmodel: qwen3.5:2b\n"), 0o644); err != nil { | ||
| 23 | + t.Fatal(err) | ||
| 24 | + } | ||
| 25 | + config.Cfg.MMConfigs = map[string]string{"bob": path} | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +// A plain ask sends the question alone and returns the marked pair. | ||
| 29 | +func TestRunReturnsTheExchange(t *testing.T) { | ||
| 30 | + withAgent(t) | ||
| 31 | + history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")} | ||
| 32 | + got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, history, t.TempDir(), &recorder{}) | ||
| 33 | + if err != nil { | ||
| 34 | + t.Fatal(err) | ||
| 35 | + } | ||
| 36 | + if len(got) != 2 || got[0].Text() != "[asked bob (ollama/qwen3.5:2b): hi]" || got[1].Text() != "[bob's answer]\nsession 1: hi" { | ||
| 37 | + t.Errorf("exchange = %q / %q", got[0].Text(), got[1].Text()) | ||
| 38 | + } | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +// /ask-with-context sends the transcript before the question. | ||
| 42 | +func TestRunWithContextSendsTheConversation(t *testing.T) { | ||
| 43 | + withAgent(t) | ||
| 44 | + history := []*ai.Message{ai.NewSystemTextMessage("s"), ai.NewUserTextMessage("PREVIOUS-QUESTION")} | ||
| 45 | + got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", WithContext: true, Prompt: "hi"}, history, t.TempDir(), &recorder{}) | ||
| 46 | + if err != nil { | ||
| 47 | + t.Fatal(err) | ||
| 48 | + } | ||
| 49 | + answer := got[1].Text() | ||
| 50 | + if !strings.Contains(answer, "User: PREVIOUS-QUESTION") || !strings.Contains(answer, "[Question]\nhi") { | ||
| 51 | + t.Errorf("the child received:\n%s", answer) | ||
| 52 | + } | ||
| 53 | + if !strings.Contains(got[0].Text(), "with this conversation as context") { | ||
| 54 | + t.Errorf("question marker = %q", got[0].Text()) | ||
| 55 | + } | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +// A failed ask leaves nothing to append; an unknown agent is refused before | ||
| 59 | +// anything starts; a missing file is listed for the start-up warnings. | ||
| 60 | +func TestRunFailures(t *testing.T) { | ||
| 61 | + withAgent(t) | ||
| 62 | + if got, err := fakePool(t, "crash").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil || got != nil { | ||
| 63 | + t.Errorf("crash: %v, %v — want an error and no exchange", got, err) | ||
| 64 | + } | ||
| 65 | + if _, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "spock", Prompt: "hi"}, nil, t.TempDir(), &recorder{}); err == nil { | ||
| 66 | + t.Error("unknown agent accepted") | ||
| 67 | + } | ||
| 68 | + if len(Missing()) != 0 { | ||
| 69 | + t.Errorf("Missing() = %v with an existing file", Missing()) | ||
| 70 | + } | ||
| 71 | + config.Cfg.MMConfigs["riker"] = filepath.Join(t.TempDir(), "nope.yaml") | ||
| 72 | + if m := Missing(); len(m) != 1 || !strings.Contains(m[0], "riker") { | ||
| 73 | + t.Errorf("Missing() = %v", m) | ||
| 74 | + } | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +// The label names the agent, its model, and the context when sent. | ||
| 78 | +func TestLabel(t *testing.T) { | ||
| 79 | + withAgent(t) | ||
| 80 | + if got := Label(command.AskRequest{Agent: "bob", WithContext: true}); got != "🗣 asking bob (ollama/qwen3.5:2b), with the conversation…" { | ||
| 81 | + t.Errorf("Label = %q", got) | ||
| 82 | + } | ||
| 83 | +} | ||
added
internal/command/ask.go +106 -0 | new file mode 100644 | ||
| @@ -0,0 +1,106 @@ | ||
| 1 | +package command | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "slices" | |
| 7 | + "strings" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// The /ask family: ask another mm agent (config `mmConfigs`), with or without | |
| 11 | +// the current conversation as context. Generated by SetAgents, because the | |
| 12 | +// per-agent commands are named after the configured agents: | |
| 13 | +// | |
| 14 | +// /ask <name> <prompt> (terminal and window) | |
| 15 | +// /ask-with-context <name> <prompt> (terminal and window) | |
| 16 | +// /ask-<name> <prompt> (every front end, the editor included) | |
| 17 | +// /ask-with-context-<name> <prompt> (every front end) | |
| 18 | +// | |
| 19 | +// The editor gets the per-agent forms only: its `/` menu completes a command | |
| 20 | +// name, never an argument, so "/ask-bob" is the one way to list the agents | |
| 21 | +// there. | |
| 22 | +const ( | |
| 23 | + Ask = "ask" | |
| 24 | + AskWithContext = "ask-with-context" | |
| 25 | +) | |
| 26 | + | |
| 27 | +// askCommands is the generated part of the table; empty when no agent is | |
| 28 | +// configured, so /help and the menus show no /ask at all. | |
| 29 | +var askCommands []Command | |
| 30 | + | |
| 31 | +// SetAgents generates the /ask family for the given agent names (sorted by | |
| 32 | +// the caller — config.AgentNames). Called once by main after the config is | |
| 33 | +// loaded; nil or empty removes the family. | |
| 34 | +// | |
| 35 | +// command.SetAgents([]string{"bob", "riker"}) | |
| 36 | +// command.For(command.Editor) // … ask-bob, ask-with-context-bob, ask-riker, … | |
| 37 | +func SetAgents(names []string) { | |
| 38 | + askCommands = nil | |
| 39 | + if len(names) == 0 { | |
| 40 | + return | |
| 41 | + } | |
| 42 | + list := strings.Join(names, "|") | |
| 43 | + askCommands = append(askCommands, | |
| 44 | + Command{Name: Ask, Description: "Ask another mm agent (" + list + "); its answer stays in the history", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"}, | |
| 45 | + Command{Name: AskWithContext, Description: "Same, sending the current conversation along", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"}, | |
| 46 | + ) | |
| 47 | + for _, name := range names { | |
| 48 | + askCommands = append(askCommands, | |
| 49 | + Command{Name: Ask + "-" + name, Description: "Ask " + name + " (another mm agent); its answer stays in the history", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"}, | |
| 50 | + Command{Name: AskWithContext + "-" + name, Description: "Ask " + name + ", sending the current conversation along", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"}, | |
| 51 | + ) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +// AskRequest is one /ask, parsed. | |
| 56 | +type AskRequest struct { | |
| 57 | + Agent string // a configured agent name | |
| 58 | + WithContext bool // send the current conversation along | |
| 59 | + Prompt string // what to ask, trimmed, never empty | |
| 60 | +} | |
| 61 | + | |
| 62 | +// ParseAsk recognises the /ask family. ok says the input IS an /ask command | |
| 63 | +// (whatever its validity); err then says what is wrong with it — an unknown | |
| 64 | +// agent, a missing prompt — in words the front end shows as is. | |
| 65 | +// | |
| 66 | +// req, ok, err := command.ParseAsk("/ask-bob what is 2+2?") | |
| 67 | +// // req == AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ok, err == nil | |
| 68 | +func ParseAsk(input string) (req AskRequest, ok bool, err error) { | |
| 69 | + cmd, arg, found := Parse(input) | |
| 70 | + if !found { | |
| 71 | + return AskRequest{}, false, nil | |
| 72 | + } | |
| 73 | + switch { | |
| 74 | + case cmd.Name == Ask || cmd.Name == AskWithContext: | |
| 75 | + req.WithContext = cmd.Name == AskWithContext | |
| 76 | + req.Agent, req.Prompt, _ = strings.Cut(arg, " ") | |
| 77 | + if req.Agent == "" { | |
| 78 | + return req, true, fmt.Errorf("usage: /%s <agent> <prompt> — agents: %s", cmd.Name, strings.Join(agentNames(), ", ")) | |
| 79 | + } | |
| 80 | + if !slices.Contains(agentNames(), req.Agent) { | |
| 81 | + return req, true, fmt.Errorf("unknown agent %q — agents: %s", req.Agent, strings.Join(agentNames(), ", ")) | |
| 82 | + } | |
| 83 | + case strings.HasPrefix(cmd.Name, AskWithContext+"-"): | |
| 84 | + req.WithContext, req.Agent, req.Prompt = true, strings.TrimPrefix(cmd.Name, AskWithContext+"-"), arg | |
| 85 | + case strings.HasPrefix(cmd.Name, Ask+"-"): | |
| 86 | + req.Agent, req.Prompt = strings.TrimPrefix(cmd.Name, Ask+"-"), arg | |
| 87 | + default: | |
| 88 | + return AskRequest{}, false, nil | |
| 89 | + } | |
| 90 | + req.Prompt = strings.TrimSpace(req.Prompt) | |
| 91 | + if req.Prompt == "" { | |
| 92 | + return req, true, errors.New("nothing to ask: type the question after the command") | |
| 93 | + } | |
| 94 | + return req, true, nil | |
| 95 | +} | |
| 96 | + | |
| 97 | +// agentNames lists the agents SetAgents was given, in its order. | |
| 98 | +func agentNames() []string { | |
| 99 | + var names []string | |
| 100 | + for _, c := range askCommands { | |
| 101 | + if name, found := strings.CutPrefix(c.Name, Ask+"-"); found && !strings.HasPrefix(c.Name, AskWithContext) { | |
| 102 | + names = append(names, name) | |
| 103 | + } | |
| 104 | + } | |
| 105 | + return names | |
| 106 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,106 @@ | |||
| 1 | +package command | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "fmt" | ||
| 6 | + "slices" | ||
| 7 | + "strings" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// The /ask family: ask another mm agent (config `mmConfigs`), with or without | ||
| 11 | +// the current conversation as context. Generated by SetAgents, because the | ||
| 12 | +// per-agent commands are named after the configured agents: | ||
| 13 | +// | ||
| 14 | +// /ask <name> <prompt> (terminal and window) | ||
| 15 | +// /ask-with-context <name> <prompt> (terminal and window) | ||
| 16 | +// /ask-<name> <prompt> (every front end, the editor included) | ||
| 17 | +// /ask-with-context-<name> <prompt> (every front end) | ||
| 18 | +// | ||
| 19 | +// The editor gets the per-agent forms only: its `/` menu completes a command | ||
| 20 | +// name, never an argument, so "/ask-bob" is the one way to list the agents | ||
| 21 | +// there. | ||
| 22 | +const ( | ||
| 23 | + Ask = "ask" | ||
| 24 | + AskWithContext = "ask-with-context" | ||
| 25 | +) | ||
| 26 | + | ||
| 27 | +// askCommands is the generated part of the table; empty when no agent is | ||
| 28 | +// configured, so /help and the menus show no /ask at all. | ||
| 29 | +var askCommands []Command | ||
| 30 | + | ||
| 31 | +// SetAgents generates the /ask family for the given agent names (sorted by | ||
| 32 | +// the caller — config.AgentNames). Called once by main after the config is | ||
| 33 | +// loaded; nil or empty removes the family. | ||
| 34 | +// | ||
| 35 | +// command.SetAgents([]string{"bob", "riker"}) | ||
| 36 | +// command.For(command.Editor) // … ask-bob, ask-with-context-bob, ask-riker, … | ||
| 37 | +func SetAgents(names []string) { | ||
| 38 | + askCommands = nil | ||
| 39 | + if len(names) == 0 { | ||
| 40 | + return | ||
| 41 | + } | ||
| 42 | + list := strings.Join(names, "|") | ||
| 43 | + askCommands = append(askCommands, | ||
| 44 | + Command{Name: Ask, Description: "Ask another mm agent (" + list + "); its answer stays in the history", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"}, | ||
| 45 | + Command{Name: AskWithContext, Description: "Same, sending the current conversation along", Fronts: Terminal | Window, TakesArg: true, Hint: "<agent> <prompt>"}, | ||
| 46 | + ) | ||
| 47 | + for _, name := range names { | ||
| 48 | + askCommands = append(askCommands, | ||
| 49 | + Command{Name: Ask + "-" + name, Description: "Ask " + name + " (another mm agent); its answer stays in the history", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"}, | ||
| 50 | + Command{Name: AskWithContext + "-" + name, Description: "Ask " + name + ", sending the current conversation along", Fronts: Terminal | Editor | Window, TakesArg: true, Hint: "<prompt>"}, | ||
| 51 | + ) | ||
| 52 | + } | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +// AskRequest is one /ask, parsed. | ||
| 56 | +type AskRequest struct { | ||
| 57 | + Agent string // a configured agent name | ||
| 58 | + WithContext bool // send the current conversation along | ||
| 59 | + Prompt string // what to ask, trimmed, never empty | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// ParseAsk recognises the /ask family. ok says the input IS an /ask command | ||
| 63 | +// (whatever its validity); err then says what is wrong with it — an unknown | ||
| 64 | +// agent, a missing prompt — in words the front end shows as is. | ||
| 65 | +// | ||
| 66 | +// req, ok, err := command.ParseAsk("/ask-bob what is 2+2?") | ||
| 67 | +// // req == AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ok, err == nil | ||
| 68 | +func ParseAsk(input string) (req AskRequest, ok bool, err error) { | ||
| 69 | + cmd, arg, found := Parse(input) | ||
| 70 | + if !found { | ||
| 71 | + return AskRequest{}, false, nil | ||
| 72 | + } | ||
| 73 | + switch { | ||
| 74 | + case cmd.Name == Ask || cmd.Name == AskWithContext: | ||
| 75 | + req.WithContext = cmd.Name == AskWithContext | ||
| 76 | + req.Agent, req.Prompt, _ = strings.Cut(arg, " ") | ||
| 77 | + if req.Agent == "" { | ||
| 78 | + return req, true, fmt.Errorf("usage: /%s <agent> <prompt> — agents: %s", cmd.Name, strings.Join(agentNames(), ", ")) | ||
| 79 | + } | ||
| 80 | + if !slices.Contains(agentNames(), req.Agent) { | ||
| 81 | + return req, true, fmt.Errorf("unknown agent %q — agents: %s", req.Agent, strings.Join(agentNames(), ", ")) | ||
| 82 | + } | ||
| 83 | + case strings.HasPrefix(cmd.Name, AskWithContext+"-"): | ||
| 84 | + req.WithContext, req.Agent, req.Prompt = true, strings.TrimPrefix(cmd.Name, AskWithContext+"-"), arg | ||
| 85 | + case strings.HasPrefix(cmd.Name, Ask+"-"): | ||
| 86 | + req.Agent, req.Prompt = strings.TrimPrefix(cmd.Name, Ask+"-"), arg | ||
| 87 | + default: | ||
| 88 | + return AskRequest{}, false, nil | ||
| 89 | + } | ||
| 90 | + req.Prompt = strings.TrimSpace(req.Prompt) | ||
| 91 | + if req.Prompt == "" { | ||
| 92 | + return req, true, errors.New("nothing to ask: type the question after the command") | ||
| 93 | + } | ||
| 94 | + return req, true, nil | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// agentNames lists the agents SetAgents was given, in its order. | ||
| 98 | +func agentNames() []string { | ||
| 99 | + var names []string | ||
| 100 | + for _, c := range askCommands { | ||
| 101 | + if name, found := strings.CutPrefix(c.Name, Ask+"-"); found && !strings.HasPrefix(c.Name, AskWithContext) { | ||
| 102 | + names = append(names, name) | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | + return names | ||
| 106 | +} | ||
added
internal/command/ask_test.go +99 -0 | new file mode 100644 | ||
| @@ -0,0 +1,99 @@ | ||
| 1 | +package command | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | +) | |
| 7 | + | |
| 8 | +// withAgents installs an /ask family for one test and removes it afterwards: | |
| 9 | +// the generated commands are package state, shared by every test. | |
| 10 | +func withAgents(t *testing.T, names ...string) { | |
| 11 | + t.Helper() | |
| 12 | + SetAgents(names) | |
| 13 | + t.Cleanup(func() { SetAgents(nil) }) | |
| 14 | +} | |
| 15 | + | |
| 16 | +// No agent configured: no /ask anywhere, so /help and the menus stay as they | |
| 17 | +// were and "/ask bob hi" is a question for the model. | |
| 18 | +func TestNoAgentsNoAskCommands(t *testing.T) { | |
| 19 | + SetAgents(nil) | |
| 20 | + for _, c := range All() { | |
| 21 | + if strings.HasPrefix(c.Name, Ask) { | |
| 22 | + t.Errorf("unexpected %s with no agents", c.Slash()) | |
| 23 | + } | |
| 24 | + } | |
| 25 | + if _, ok, _ := ParseAsk("/ask bob hi"); ok { | |
| 26 | + t.Error("ParseAsk recognised /ask with no agents configured") | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +// The editor gets the per-agent forms only (its menu completes names, not | |
| 31 | +// arguments); the terminal and the window get the generic forms too. | |
| 32 | +func TestAskCommandsPerFront(t *testing.T) { | |
| 33 | + withAgents(t, "bob", "riker") | |
| 34 | + names := func(f Front) []string { | |
| 35 | + var out []string | |
| 36 | + for _, c := range For(f) { | |
| 37 | + if strings.HasPrefix(c.Name, Ask) { | |
| 38 | + out = append(out, c.Name) | |
| 39 | + } | |
| 40 | + } | |
| 41 | + return out | |
| 42 | + } | |
| 43 | + perAgent := "ask-bob ask-with-context-bob ask-riker ask-with-context-riker" | |
| 44 | + if got := strings.Join(names(Editor), " "); got != perAgent { | |
| 45 | + t.Errorf("Editor = %q, want %q", got, perAgent) | |
| 46 | + } | |
| 47 | + want := "ask ask-with-context " + perAgent | |
| 48 | + for _, f := range []Front{Terminal, Window} { | |
| 49 | + if got := strings.Join(names(f), " "); got != want { | |
| 50 | + t.Errorf("front %d = %q, want %q", f, got, want) | |
| 51 | + } | |
| 52 | + } | |
| 53 | + for _, c := range For(Editor) { | |
| 54 | + if strings.HasPrefix(c.Name, Ask) && (c.Hint != "<prompt>" || !c.TakesArg) { | |
| 55 | + t.Errorf("%s: Hint %q TakesArg %v, want <prompt> and true", c.Slash(), c.Hint, c.TakesArg) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + if !strings.Contains(Usage(Terminal), "/ask-bob <prompt>") || !strings.Contains(Usage(Terminal), "/ask <agent> <prompt>") { | |
| 59 | + t.Errorf("Usage does not show the hints:\n%s", Usage(Terminal)) | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +// Every accepted form, and every error the front ends show as is. | |
| 64 | +func TestParseAsk(t *testing.T) { | |
| 65 | + withAgents(t, "bob", "riker") | |
| 66 | + for _, tc := range []struct { | |
| 67 | + in string | |
| 68 | + ok bool | |
| 69 | + want AskRequest | |
| 70 | + wantErr string | |
| 71 | + }{ | |
| 72 | + {"/ask-bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""}, | |
| 73 | + {" /ask-with-context-riker review it ", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""}, | |
| 74 | + {"/ask bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""}, | |
| 75 | + {"/ask-with-context riker review it", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""}, | |
| 76 | + {"/ask-bob", true, AskRequest{Agent: "bob"}, "nothing to ask"}, | |
| 77 | + {"/ask bob", true, AskRequest{Agent: "bob"}, "nothing to ask"}, | |
| 78 | + {"/ask", true, AskRequest{}, "usage: /ask <agent> <prompt> — agents: bob, riker"}, | |
| 79 | + {"/ask spock hi", true, AskRequest{Agent: "spock", Prompt: "hi"}, `unknown agent "spock" — agents: bob, riker`}, | |
| 80 | + {"/ask-spock hi", false, AskRequest{}, ""}, | |
| 81 | + {"/compact", false, AskRequest{}, ""}, | |
| 82 | + {"ask bob hi", false, AskRequest{}, ""}, | |
| 83 | + } { | |
| 84 | + got, ok, err := ParseAsk(tc.in) | |
| 85 | + if ok != tc.ok { | |
| 86 | + t.Errorf("%q: ok = %v, want %v", tc.in, ok, tc.ok) | |
| 87 | + continue | |
| 88 | + } | |
| 89 | + if tc.wantErr == "" && err != nil { | |
| 90 | + t.Errorf("%q: unexpected error %v", tc.in, err) | |
| 91 | + } | |
| 92 | + if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { | |
| 93 | + t.Errorf("%q: error %v, want one containing %q", tc.in, err, tc.wantErr) | |
| 94 | + } | |
| 95 | + if ok && got != tc.want { | |
| 96 | + t.Errorf("%q: %+v, want %+v", tc.in, got, tc.want) | |
| 97 | + } | |
| 98 | + } | |
| 99 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,99 @@ | |||
| 1 | +package command | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +// withAgents installs an /ask family for one test and removes it afterwards: | ||
| 9 | +// the generated commands are package state, shared by every test. | ||
| 10 | +func withAgents(t *testing.T, names ...string) { | ||
| 11 | + t.Helper() | ||
| 12 | + SetAgents(names) | ||
| 13 | + t.Cleanup(func() { SetAgents(nil) }) | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +// No agent configured: no /ask anywhere, so /help and the menus stay as they | ||
| 17 | +// were and "/ask bob hi" is a question for the model. | ||
| 18 | +func TestNoAgentsNoAskCommands(t *testing.T) { | ||
| 19 | + SetAgents(nil) | ||
| 20 | + for _, c := range All() { | ||
| 21 | + if strings.HasPrefix(c.Name, Ask) { | ||
| 22 | + t.Errorf("unexpected %s with no agents", c.Slash()) | ||
| 23 | + } | ||
| 24 | + } | ||
| 25 | + if _, ok, _ := ParseAsk("/ask bob hi"); ok { | ||
| 26 | + t.Error("ParseAsk recognised /ask with no agents configured") | ||
| 27 | + } | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +// The editor gets the per-agent forms only (its menu completes names, not | ||
| 31 | +// arguments); the terminal and the window get the generic forms too. | ||
| 32 | +func TestAskCommandsPerFront(t *testing.T) { | ||
| 33 | + withAgents(t, "bob", "riker") | ||
| 34 | + names := func(f Front) []string { | ||
| 35 | + var out []string | ||
| 36 | + for _, c := range For(f) { | ||
| 37 | + if strings.HasPrefix(c.Name, Ask) { | ||
| 38 | + out = append(out, c.Name) | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | + return out | ||
| 42 | + } | ||
| 43 | + perAgent := "ask-bob ask-with-context-bob ask-riker ask-with-context-riker" | ||
| 44 | + if got := strings.Join(names(Editor), " "); got != perAgent { | ||
| 45 | + t.Errorf("Editor = %q, want %q", got, perAgent) | ||
| 46 | + } | ||
| 47 | + want := "ask ask-with-context " + perAgent | ||
| 48 | + for _, f := range []Front{Terminal, Window} { | ||
| 49 | + if got := strings.Join(names(f), " "); got != want { | ||
| 50 | + t.Errorf("front %d = %q, want %q", f, got, want) | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | + for _, c := range For(Editor) { | ||
| 54 | + if strings.HasPrefix(c.Name, Ask) && (c.Hint != "<prompt>" || !c.TakesArg) { | ||
| 55 | + t.Errorf("%s: Hint %q TakesArg %v, want <prompt> and true", c.Slash(), c.Hint, c.TakesArg) | ||
| 56 | + } | ||
| 57 | + } | ||
| 58 | + if !strings.Contains(Usage(Terminal), "/ask-bob <prompt>") || !strings.Contains(Usage(Terminal), "/ask <agent> <prompt>") { | ||
| 59 | + t.Errorf("Usage does not show the hints:\n%s", Usage(Terminal)) | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// Every accepted form, and every error the front ends show as is. | ||
| 64 | +func TestParseAsk(t *testing.T) { | ||
| 65 | + withAgents(t, "bob", "riker") | ||
| 66 | + for _, tc := range []struct { | ||
| 67 | + in string | ||
| 68 | + ok bool | ||
| 69 | + want AskRequest | ||
| 70 | + wantErr string | ||
| 71 | + }{ | ||
| 72 | + {"/ask-bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""}, | ||
| 73 | + {" /ask-with-context-riker review it ", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""}, | ||
| 74 | + {"/ask bob what is 2+2?", true, AskRequest{Agent: "bob", Prompt: "what is 2+2?"}, ""}, | ||
| 75 | + {"/ask-with-context riker review it", true, AskRequest{Agent: "riker", WithContext: true, Prompt: "review it"}, ""}, | ||
| 76 | + {"/ask-bob", true, AskRequest{Agent: "bob"}, "nothing to ask"}, | ||
| 77 | + {"/ask bob", true, AskRequest{Agent: "bob"}, "nothing to ask"}, | ||
| 78 | + {"/ask", true, AskRequest{}, "usage: /ask <agent> <prompt> — agents: bob, riker"}, | ||
| 79 | + {"/ask spock hi", true, AskRequest{Agent: "spock", Prompt: "hi"}, `unknown agent "spock" — agents: bob, riker`}, | ||
| 80 | + {"/ask-spock hi", false, AskRequest{}, ""}, | ||
| 81 | + {"/compact", false, AskRequest{}, ""}, | ||
| 82 | + {"ask bob hi", false, AskRequest{}, ""}, | ||
| 83 | + } { | ||
| 84 | + got, ok, err := ParseAsk(tc.in) | ||
| 85 | + if ok != tc.ok { | ||
| 86 | + t.Errorf("%q: ok = %v, want %v", tc.in, ok, tc.ok) | ||
| 87 | + continue | ||
| 88 | + } | ||
| 89 | + if tc.wantErr == "" && err != nil { | ||
| 90 | + t.Errorf("%q: unexpected error %v", tc.in, err) | ||
| 91 | + } | ||
| 92 | + if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { | ||
| 93 | + t.Errorf("%q: error %v, want one containing %q", tc.in, err, tc.wantErr) | ||
| 94 | + } | ||
| 95 | + if ok && got != tc.want { | ||
| 96 | + t.Errorf("%q: %+v, want %+v", tc.in, got, tc.want) | ||
| 97 | + } | ||
| 98 | + } | ||
| 99 | +} | ||
modified
internal/command/command.go +11 -4 | @@ -51,6 +51,9 @@ type Command struct { | ||
| 51 | 51 | // TakesArg says "/name something" is still this command ("/resume <id>"); |
| 52 | 52 | // without it, "/compact now" is a question for the model, as it always was. |
| 53 | 53 | TakesArg bool |
| 54 | + // Hint names the argument where a usage line or an editor shows it | |
| 55 | + // ("<prompt>"); "" = the generic "[arg]". | |
| 56 | + Hint string | |
| 54 | 57 | } |
| 55 | 58 | |
| 56 | 59 | // Slash is the command as typed. |
| @@ -71,13 +74,14 @@ var table = []Command{ | ||
| 71 | 74 | {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true}, |
| 72 | 75 | } |
| 73 | 76 | |
| 74 | -// All returns the whole table, in display order. | |
| 75 | -func All() []Command { return append([]Command(nil), table...) } | |
| 77 | +// All returns the whole table, in display order: the fixed commands, then | |
| 78 | +// the /ask family generated from the configured agents (ask.go). | |
| 79 | +func All() []Command { return append(append([]Command(nil), table...), askCommands...) } | |
| 76 | 80 | |
| 77 | 81 | // For returns the commands front f offers, in display order. |
| 78 | 82 | func For(f Front) []Command { |
| 79 | 83 | var out []Command |
| 80 | - for _, c := range table { | |
| 84 | + for _, c := range All() { | |
| 81 | 85 | if c.Offered(f) { |
| 82 | 86 | out = append(out, c) |
| 83 | 87 | } |
| @@ -101,7 +105,7 @@ func Parse(input string) (cmd Command, arg string, ok bool) { | ||
| 101 | 105 | return Command{}, "", false |
| 102 | 106 | } |
| 103 | 107 | name, rest, _ := strings.Cut(input[1:], " ") |
| 104 | - for _, c := range table { | |
| 108 | + for _, c := range All() { | |
| 105 | 109 | if c.Name != name { |
| 106 | 110 | continue |
| 107 | 111 | } |
| @@ -142,6 +146,9 @@ func Usage(f Front) string { | ||
| 142 | 146 | |
| 143 | 147 | // usage is how a command is typed, with its argument when it takes one. |
| 144 | 148 | func usage(c Command) string { |
| 149 | + if c.Hint != "" { | |
| 150 | + return c.Slash() + " " + c.Hint | |
| 151 | + } | |
| 145 | 152 | if c.TakesArg { |
| 146 | 153 | return c.Slash() + " [arg]" |
| 147 | 154 | } |
| @@ -51,6 +51,9 @@ type Command struct { | |||
| 51 | // TakesArg says "/name something" is still this command ("/resume <id>"); | 51 | // TakesArg says "/name something" is still this command ("/resume <id>"); |
| 52 | // without it, "/compact now" is a question for the model, as it always was. | 52 | // without it, "/compact now" is a question for the model, as it always was. |
| 53 | TakesArg bool | 53 | TakesArg bool |
| 54 | + // Hint names the argument where a usage line or an editor shows it | ||
| 55 | + // ("<prompt>"); "" = the generic "[arg]". | ||
| 56 | + Hint string | ||
| 54 | } | 57 | } |
| 55 | 58 | ||
| 56 | // Slash is the command as typed. | 59 | // Slash is the command as typed. |
| @@ -71,13 +74,14 @@ var table = []Command{ | |||
| 71 | {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true}, | 74 | {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true}, |
| 72 | } | 75 | } |
| 73 | 76 | ||
| 74 | -// All returns the whole table, in display order. | 77 | +// All returns the whole table, in display order: the fixed commands, then |
| 75 | -func All() []Command { return append([]Command(nil), table...) } | 78 | +// the /ask family generated from the configured agents (ask.go). |
| 79 | +func All() []Command { return append(append([]Command(nil), table...), askCommands...) } | ||
| 76 | 80 | ||
| 77 | // For returns the commands front f offers, in display order. | 81 | // For returns the commands front f offers, in display order. |
| 78 | func For(f Front) []Command { | 82 | func For(f Front) []Command { |
| 79 | var out []Command | 83 | var out []Command |
| 80 | - for _, c := range table { | 84 | + for _, c := range All() { |
| 81 | if c.Offered(f) { | 85 | if c.Offered(f) { |
| 82 | out = append(out, c) | 86 | out = append(out, c) |
| 83 | } | 87 | } |
| @@ -101,7 +105,7 @@ func Parse(input string) (cmd Command, arg string, ok bool) { | |||
| 101 | return Command{}, "", false | 105 | return Command{}, "", false |
| 102 | } | 106 | } |
| 103 | name, rest, _ := strings.Cut(input[1:], " ") | 107 | name, rest, _ := strings.Cut(input[1:], " ") |
| 104 | - for _, c := range table { | 108 | + for _, c := range All() { |
| 105 | if c.Name != name { | 109 | if c.Name != name { |
| 106 | continue | 110 | continue |
| 107 | } | 111 | } |
| @@ -142,6 +146,9 @@ func Usage(f Front) string { | |||
| 142 | 146 | ||
| 143 | // usage is how a command is typed, with its argument when it takes one. | 147 | // usage is how a command is typed, with its argument when it takes one. |
| 144 | func usage(c Command) string { | 148 | func usage(c Command) string { |
| 149 | + if c.Hint != "" { | ||
| 150 | + return c.Slash() + " " + c.Hint | ||
| 151 | + } | ||
| 145 | if c.TakesArg { | 152 | if c.TakesArg { |
| 146 | return c.Slash() + " [arg]" | 153 | return c.Slash() + " [arg]" |
| 147 | } | 154 | } |
added
internal/config/agents.go +68 -0 | new file mode 100644 | ||
| @@ -0,0 +1,68 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "path/filepath" | |
| 6 | + "regexp" | |
| 7 | + "sort" | |
| 8 | + "strings" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// SessionsEnv names the variable that turns session persistence off for one | |
| 12 | +// run ("off"). The /ask machinery sets it on the agents it starts. | |
| 13 | +const SessionsEnv = "AGENT_SESSIONS" | |
| 14 | + | |
| 15 | +// agentName is the format of an mmConfigs name: it becomes the tail of a | |
| 16 | +// command name (/ask-bob), so it must be typable, unambiguous after a dash, | |
| 17 | +// and safe in an editor's `/` menu. | |
| 18 | +var agentName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) | |
| 19 | + | |
| 20 | +// validateAgents checks the mmConfigs names and that each has a path. The | |
| 21 | +// files themselves are NOT required to exist: a missing one is a start-up | |
| 22 | +// warning (main), and /ask-<name> then says so — one absent sibling config | |
| 23 | +// must not stop the agent from starting. | |
| 24 | +func (c Config) validateAgents() error { | |
| 25 | + for _, name := range AgentNames() { | |
| 26 | + if !agentName.MatchString(name) { | |
| 27 | + return fmt.Errorf("mmConfigs: %q is not a valid name (lowercase letters, digits and '-', starting with a letter or a digit)", name) | |
| 28 | + } | |
| 29 | + // "/ask-with-context-x" must mean one thing: the context form for x. | |
| 30 | + if strings.HasPrefix(name, "with-context") { | |
| 31 | + return fmt.Errorf("mmConfigs: %q is not a valid name (it would read as /ask-with-context-…)", name) | |
| 32 | + } | |
| 33 | + if c.MMConfigs[name] == "" { | |
| 34 | + return fmt.Errorf("mmConfigs.%s: the path must not be empty", name) | |
| 35 | + } | |
| 36 | + } | |
| 37 | + return nil | |
| 38 | +} | |
| 39 | + | |
| 40 | +// resolveAgentPaths makes every relative mmConfigs path absolute against the | |
| 41 | +// directory of the config file that declared it — the one anchor that does | |
| 42 | +// not depend on who started mm (a terminal, an editor, another mm). | |
| 43 | +func resolveAgentPaths(configPath string) { | |
| 44 | + dir := filepath.Dir(configPath) | |
| 45 | + for name, p := range Cfg.MMConfigs { | |
| 46 | + if p == "" || filepath.IsAbs(p) { | |
| 47 | + continue | |
| 48 | + } | |
| 49 | + if abs, err := filepath.Abs(filepath.Join(dir, p)); err == nil { | |
| 50 | + Cfg.MMConfigs[name] = abs | |
| 51 | + } | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +// AgentNames returns the mmConfigs names, sorted: the order of /help, of the | |
| 56 | +// editor's menu and of the window's picker. | |
| 57 | +// | |
| 58 | +// for _, name := range config.AgentNames() { | |
| 59 | +// fmt.Println("/ask-" + name, config.Cfg.MMConfigs[name]) | |
| 60 | +// } | |
| 61 | +func AgentNames() []string { | |
| 62 | + names := make([]string, 0, len(Cfg.MMConfigs)) | |
| 63 | + for name := range Cfg.MMConfigs { | |
| 64 | + names = append(names, name) | |
| 65 | + } | |
| 66 | + sort.Strings(names) | |
| 67 | + return names | |
| 68 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,68 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "path/filepath" | ||
| 6 | + "regexp" | ||
| 7 | + "sort" | ||
| 8 | + "strings" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// SessionsEnv names the variable that turns session persistence off for one | ||
| 12 | +// run ("off"). The /ask machinery sets it on the agents it starts. | ||
| 13 | +const SessionsEnv = "AGENT_SESSIONS" | ||
| 14 | + | ||
| 15 | +// agentName is the format of an mmConfigs name: it becomes the tail of a | ||
| 16 | +// command name (/ask-bob), so it must be typable, unambiguous after a dash, | ||
| 17 | +// and safe in an editor's `/` menu. | ||
| 18 | +var agentName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) | ||
| 19 | + | ||
| 20 | +// validateAgents checks the mmConfigs names and that each has a path. The | ||
| 21 | +// files themselves are NOT required to exist: a missing one is a start-up | ||
| 22 | +// warning (main), and /ask-<name> then says so — one absent sibling config | ||
| 23 | +// must not stop the agent from starting. | ||
| 24 | +func (c Config) validateAgents() error { | ||
| 25 | + for _, name := range AgentNames() { | ||
| 26 | + if !agentName.MatchString(name) { | ||
| 27 | + return fmt.Errorf("mmConfigs: %q is not a valid name (lowercase letters, digits and '-', starting with a letter or a digit)", name) | ||
| 28 | + } | ||
| 29 | + // "/ask-with-context-x" must mean one thing: the context form for x. | ||
| 30 | + if strings.HasPrefix(name, "with-context") { | ||
| 31 | + return fmt.Errorf("mmConfigs: %q is not a valid name (it would read as /ask-with-context-…)", name) | ||
| 32 | + } | ||
| 33 | + if c.MMConfigs[name] == "" { | ||
| 34 | + return fmt.Errorf("mmConfigs.%s: the path must not be empty", name) | ||
| 35 | + } | ||
| 36 | + } | ||
| 37 | + return nil | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +// resolveAgentPaths makes every relative mmConfigs path absolute against the | ||
| 41 | +// directory of the config file that declared it — the one anchor that does | ||
| 42 | +// not depend on who started mm (a terminal, an editor, another mm). | ||
| 43 | +func resolveAgentPaths(configPath string) { | ||
| 44 | + dir := filepath.Dir(configPath) | ||
| 45 | + for name, p := range Cfg.MMConfigs { | ||
| 46 | + if p == "" || filepath.IsAbs(p) { | ||
| 47 | + continue | ||
| 48 | + } | ||
| 49 | + if abs, err := filepath.Abs(filepath.Join(dir, p)); err == nil { | ||
| 50 | + Cfg.MMConfigs[name] = abs | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +// AgentNames returns the mmConfigs names, sorted: the order of /help, of the | ||
| 56 | +// editor's menu and of the window's picker. | ||
| 57 | +// | ||
| 58 | +// for _, name := range config.AgentNames() { | ||
| 59 | +// fmt.Println("/ask-" + name, config.Cfg.MMConfigs[name]) | ||
| 60 | +// } | ||
| 61 | +func AgentNames() []string { | ||
| 62 | + names := make([]string, 0, len(Cfg.MMConfigs)) | ||
| 63 | + for name := range Cfg.MMConfigs { | ||
| 64 | + names = append(names, name) | ||
| 65 | + } | ||
| 66 | + sort.Strings(names) | ||
| 67 | + return names | ||
| 68 | +} | ||
added
internal/config/agents_test.go +96 -0 | new file mode 100644 | ||
| @@ -0,0 +1,96 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "reflect" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// mmConfigs is read, its relative paths are anchored on the config file's | |
| 12 | +// directory (not the current one), absolute paths are kept, and the names | |
| 13 | +// come back sorted. | |
| 14 | +func TestMMConfigsAreResolvedAgainstTheConfigFile(t *testing.T) { | |
| 15 | + defaults := Cfg | |
| 16 | + t.Cleanup(func() { Cfg = defaults }) | |
| 17 | + | |
| 18 | + dir := t.TempDir() | |
| 19 | + path := filepath.Join(dir, "agent.yaml") | |
| 20 | + yaml := "mmConfigs:\n riker: other/agent.riker.yaml\n bob: /abs/agent.bob.yaml\n" | |
| 21 | + if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil { | |
| 22 | + t.Fatal(err) | |
| 23 | + } | |
| 24 | + if _, err := Load(path); err != nil { | |
| 25 | + t.Fatal(err) | |
| 26 | + } | |
| 27 | + if got, want := Cfg.MMConfigs["riker"], filepath.Join(dir, "other", "agent.riker.yaml"); got != want { | |
| 28 | + t.Errorf("riker = %q, want %q", got, want) | |
| 29 | + } | |
| 30 | + if got := Cfg.MMConfigs["bob"]; got != "/abs/agent.bob.yaml" { | |
| 31 | + t.Errorf("bob = %q, want the absolute path unchanged", got) | |
| 32 | + } | |
| 33 | + if got := AgentNames(); !reflect.DeepEqual(got, []string{"bob", "riker"}) { | |
| 34 | + t.Errorf("AgentNames = %v, want [bob riker]", got) | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +// A name that could not be a command name, or an empty path, is a config | |
| 39 | +// error naming the culprit; no mmConfigs at all is fine. | |
| 40 | +func TestMMConfigsValidation(t *testing.T) { | |
| 41 | + defaults := Cfg | |
| 42 | + t.Cleanup(func() { Cfg = defaults }) | |
| 43 | + dir := t.TempDir() | |
| 44 | + | |
| 45 | + for _, tc := range []struct{ yaml, want string }{ | |
| 46 | + {"mmConfigs:\n Bob: a.yaml\n", `"Bob" is not a valid name`}, | |
| 47 | + {"mmConfigs:\n ask bob: a.yaml\n", `"ask bob" is not a valid name`}, | |
| 48 | + {"mmConfigs:\n -bob: a.yaml\n", `"-bob" is not a valid name`}, | |
| 49 | + {"mmConfigs:\n bob: \"\"\n", "mmConfigs.bob: the path must not be empty"}, | |
| 50 | + {"mmConfigs:\n with-context-bob: a.yaml\n", "it would read as /ask-with-context"}, | |
| 51 | + } { | |
| 52 | + Cfg = defaults | |
| 53 | + Cfg.MMConfigs = nil | |
| 54 | + path := filepath.Join(dir, "bad.yaml") | |
| 55 | + if err := os.WriteFile(path, []byte(tc.yaml), 0o644); err != nil { | |
| 56 | + t.Fatal(err) | |
| 57 | + } | |
| 58 | + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), tc.want) { | |
| 59 | + t.Errorf("%q: Load = %v, want an error containing %q", tc.yaml, err, tc.want) | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 63 | + Cfg = defaults | |
| 64 | + Cfg.MMConfigs = nil | |
| 65 | + if len(AgentNames()) != 0 { | |
| 66 | + t.Errorf("no mmConfigs: AgentNames = %v, want none", AgentNames()) | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +// AGENT_SESSIONS=off turns persistence off whatever the file says — what an | |
| 71 | +// agent started by /ask runs with; any other value leaves the file alone. | |
| 72 | +func TestSessionsEnvTurnsPersistenceOff(t *testing.T) { | |
| 73 | + defaults := Cfg | |
| 74 | + t.Cleanup(func() { Cfg = defaults }) | |
| 75 | + path := filepath.Join(t.TempDir(), "agent.yaml") | |
| 76 | + if err := os.WriteFile(path, []byte("sessions:\n enabled: true\n"), 0o644); err != nil { | |
| 77 | + t.Fatal(err) | |
| 78 | + } | |
| 79 | + | |
| 80 | + t.Setenv(SessionsEnv, "off") | |
| 81 | + if _, err := Load(path); err != nil { | |
| 82 | + t.Fatal(err) | |
| 83 | + } | |
| 84 | + if Cfg.Sessions.Enabled { | |
| 85 | + t.Error("AGENT_SESSIONS=off: sessions still enabled") | |
| 86 | + } | |
| 87 | + | |
| 88 | + Cfg = defaults | |
| 89 | + t.Setenv(SessionsEnv, "") | |
| 90 | + if _, err := Load(path); err != nil { | |
| 91 | + t.Fatal(err) | |
| 92 | + } | |
| 93 | + if !Cfg.Sessions.Enabled { | |
| 94 | + t.Error("AGENT_SESSIONS unset: the file's sessions.enabled was ignored") | |
| 95 | + } | |
| 96 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,96 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "reflect" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// mmConfigs is read, its relative paths are anchored on the config file's | ||
| 12 | +// directory (not the current one), absolute paths are kept, and the names | ||
| 13 | +// come back sorted. | ||
| 14 | +func TestMMConfigsAreResolvedAgainstTheConfigFile(t *testing.T) { | ||
| 15 | + defaults := Cfg | ||
| 16 | + t.Cleanup(func() { Cfg = defaults }) | ||
| 17 | + | ||
| 18 | + dir := t.TempDir() | ||
| 19 | + path := filepath.Join(dir, "agent.yaml") | ||
| 20 | + yaml := "mmConfigs:\n riker: other/agent.riker.yaml\n bob: /abs/agent.bob.yaml\n" | ||
| 21 | + if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil { | ||
| 22 | + t.Fatal(err) | ||
| 23 | + } | ||
| 24 | + if _, err := Load(path); err != nil { | ||
| 25 | + t.Fatal(err) | ||
| 26 | + } | ||
| 27 | + if got, want := Cfg.MMConfigs["riker"], filepath.Join(dir, "other", "agent.riker.yaml"); got != want { | ||
| 28 | + t.Errorf("riker = %q, want %q", got, want) | ||
| 29 | + } | ||
| 30 | + if got := Cfg.MMConfigs["bob"]; got != "/abs/agent.bob.yaml" { | ||
| 31 | + t.Errorf("bob = %q, want the absolute path unchanged", got) | ||
| 32 | + } | ||
| 33 | + if got := AgentNames(); !reflect.DeepEqual(got, []string{"bob", "riker"}) { | ||
| 34 | + t.Errorf("AgentNames = %v, want [bob riker]", got) | ||
| 35 | + } | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// A name that could not be a command name, or an empty path, is a config | ||
| 39 | +// error naming the culprit; no mmConfigs at all is fine. | ||
| 40 | +func TestMMConfigsValidation(t *testing.T) { | ||
| 41 | + defaults := Cfg | ||
| 42 | + t.Cleanup(func() { Cfg = defaults }) | ||
| 43 | + dir := t.TempDir() | ||
| 44 | + | ||
| 45 | + for _, tc := range []struct{ yaml, want string }{ | ||
| 46 | + {"mmConfigs:\n Bob: a.yaml\n", `"Bob" is not a valid name`}, | ||
| 47 | + {"mmConfigs:\n ask bob: a.yaml\n", `"ask bob" is not a valid name`}, | ||
| 48 | + {"mmConfigs:\n -bob: a.yaml\n", `"-bob" is not a valid name`}, | ||
| 49 | + {"mmConfigs:\n bob: \"\"\n", "mmConfigs.bob: the path must not be empty"}, | ||
| 50 | + {"mmConfigs:\n with-context-bob: a.yaml\n", "it would read as /ask-with-context"}, | ||
| 51 | + } { | ||
| 52 | + Cfg = defaults | ||
| 53 | + Cfg.MMConfigs = nil | ||
| 54 | + path := filepath.Join(dir, "bad.yaml") | ||
| 55 | + if err := os.WriteFile(path, []byte(tc.yaml), 0o644); err != nil { | ||
| 56 | + t.Fatal(err) | ||
| 57 | + } | ||
| 58 | + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), tc.want) { | ||
| 59 | + t.Errorf("%q: Load = %v, want an error containing %q", tc.yaml, err, tc.want) | ||
| 60 | + } | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + Cfg = defaults | ||
| 64 | + Cfg.MMConfigs = nil | ||
| 65 | + if len(AgentNames()) != 0 { | ||
| 66 | + t.Errorf("no mmConfigs: AgentNames = %v, want none", AgentNames()) | ||
| 67 | + } | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// AGENT_SESSIONS=off turns persistence off whatever the file says — what an | ||
| 71 | +// agent started by /ask runs with; any other value leaves the file alone. | ||
| 72 | +func TestSessionsEnvTurnsPersistenceOff(t *testing.T) { | ||
| 73 | + defaults := Cfg | ||
| 74 | + t.Cleanup(func() { Cfg = defaults }) | ||
| 75 | + path := filepath.Join(t.TempDir(), "agent.yaml") | ||
| 76 | + if err := os.WriteFile(path, []byte("sessions:\n enabled: true\n"), 0o644); err != nil { | ||
| 77 | + t.Fatal(err) | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + t.Setenv(SessionsEnv, "off") | ||
| 81 | + if _, err := Load(path); err != nil { | ||
| 82 | + t.Fatal(err) | ||
| 83 | + } | ||
| 84 | + if Cfg.Sessions.Enabled { | ||
| 85 | + t.Error("AGENT_SESSIONS=off: sessions still enabled") | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + Cfg = defaults | ||
| 89 | + t.Setenv(SessionsEnv, "") | ||
| 90 | + if _, err := Load(path); err != nil { | ||
| 91 | + t.Fatal(err) | ||
| 92 | + } | ||
| 93 | + if !Cfg.Sessions.Enabled { | ||
| 94 | + t.Error("AGENT_SESSIONS unset: the file's sessions.enabled was ignored") | ||
| 95 | + } | ||
| 96 | +} | ||
modified
internal/config/config.go +16 -0 | @@ -157,6 +157,12 @@ type Config struct { | ||
| 157 | 157 | // the terminal always has. Only the window mode reads it today; the |
| 158 | 158 | // editor has its own dialog and the line mode has no gatekeeper. |
| 159 | 159 | Permissions string `yaml:"permissions"` |
| 160 | + | |
| 161 | + // MMConfigs: the other mm agents this one can ask (`/ask-<name>`), name | |
| 162 | + // -> path of that agent's YAML file. A relative path is relative to THIS | |
| 163 | + // file's directory, and Load makes it absolute (agents.go). Each name | |
| 164 | + // becomes a command name, hence its format. | |
| 165 | + MMConfigs map[string]string `yaml:"mmConfigs"` | |
| 160 | 166 | } |
| 161 | 167 | |
| 162 | 168 | // The two values of Permissions. |
| @@ -516,6 +522,7 @@ func Load(cliPath string) (string, error) { | ||
| 516 | 522 | return "", fmt.Errorf("%s: %w", path, err) |
| 517 | 523 | } |
| 518 | 524 | applyEnv() |
| 525 | + resolveAgentPaths(path) | |
| 519 | 526 | if err := Cfg.validate(); err != nil { |
| 520 | 527 | return "", fmt.Errorf("%s: %w", path, err) |
| 521 | 528 | } |
| @@ -533,6 +540,12 @@ func applyEnv() { | ||
| 533 | 540 | if v := os.Getenv("AGENT_MODEL"); v != "" { |
| 534 | 541 | Cfg.Model = v |
| 535 | 542 | } |
| 543 | + // AGENT_SESSIONS=off: an agent started by another one's /ask keeps no | |
| 544 | + // session files — it runs in the asker's project, and its sessions would | |
| 545 | + // show up in the asker's /sessions and /resume. | |
| 546 | + if os.Getenv(SessionsEnv) == "off" { | |
| 547 | + Cfg.Sessions.Enabled = false | |
| 548 | + } | |
| 536 | 549 | } |
| 537 | 550 | |
| 538 | 551 | // validate checks the language servers: every one named and runnable, every |
| @@ -619,5 +632,8 @@ func (c Config) validate() error { | ||
| 619 | 632 | if err := c.MCP.Validate(); err != nil { |
| 620 | 633 | return err |
| 621 | 634 | } |
| 635 | + if err := c.validateAgents(); err != nil { | |
| 636 | + return err | |
| 637 | + } | |
| 622 | 638 | return c.LSP.validate() |
| 623 | 639 | } |
| @@ -157,6 +157,12 @@ type Config struct { | |||
| 157 | // the terminal always has. Only the window mode reads it today; the | 157 | // the terminal always has. Only the window mode reads it today; the |
| 158 | // editor has its own dialog and the line mode has no gatekeeper. | 158 | // editor has its own dialog and the line mode has no gatekeeper. |
| 159 | Permissions string `yaml:"permissions"` | 159 | Permissions string `yaml:"permissions"` |
| 160 | + | ||
| 161 | + // MMConfigs: the other mm agents this one can ask (`/ask-<name>`), name | ||
| 162 | + // -> path of that agent's YAML file. A relative path is relative to THIS | ||
| 163 | + // file's directory, and Load makes it absolute (agents.go). Each name | ||
| 164 | + // becomes a command name, hence its format. | ||
| 165 | + MMConfigs map[string]string `yaml:"mmConfigs"` | ||
| 160 | } | 166 | } |
| 161 | 167 | ||
| 162 | // The two values of Permissions. | 168 | // The two values of Permissions. |
| @@ -516,6 +522,7 @@ func Load(cliPath string) (string, error) { | |||
| 516 | return "", fmt.Errorf("%s: %w", path, err) | 522 | return "", fmt.Errorf("%s: %w", path, err) |
| 517 | } | 523 | } |
| 518 | applyEnv() | 524 | applyEnv() |
| 525 | + resolveAgentPaths(path) | ||
| 519 | if err := Cfg.validate(); err != nil { | 526 | if err := Cfg.validate(); err != nil { |
| 520 | return "", fmt.Errorf("%s: %w", path, err) | 527 | return "", fmt.Errorf("%s: %w", path, err) |
| 521 | } | 528 | } |
| @@ -533,6 +540,12 @@ func applyEnv() { | |||
| 533 | if v := os.Getenv("AGENT_MODEL"); v != "" { | 540 | if v := os.Getenv("AGENT_MODEL"); v != "" { |
| 534 | Cfg.Model = v | 541 | Cfg.Model = v |
| 535 | } | 542 | } |
| 543 | + // AGENT_SESSIONS=off: an agent started by another one's /ask keeps no | ||
| 544 | + // session files — it runs in the asker's project, and its sessions would | ||
| 545 | + // show up in the asker's /sessions and /resume. | ||
| 546 | + if os.Getenv(SessionsEnv) == "off" { | ||
| 547 | + Cfg.Sessions.Enabled = false | ||
| 548 | + } | ||
| 536 | } | 549 | } |
| 537 | 550 | ||
| 538 | // validate checks the language servers: every one named and runnable, every | 551 | // validate checks the language servers: every one named and runnable, every |
| @@ -619,5 +632,8 @@ func (c Config) validate() error { | |||
| 619 | if err := c.MCP.Validate(); err != nil { | 632 | if err := c.MCP.Validate(); err != nil { |
| 620 | return err | 633 | return err |
| 621 | } | 634 | } |
| 635 | + if err := c.validateAgents(); err != nil { | ||
| 636 | + return err | ||
| 637 | + } | ||
| 622 | return c.LSP.validate() | 638 | return c.LSP.validate() |
| 623 | } | 639 | } |
modified
internal/store/store.go +2 -1 | @@ -64,7 +64,7 @@ type Session struct { | ||
| 64 | 64 | type Failure struct { |
| 65 | 65 | At time.Time `json:"at"` |
| 66 | 66 | Turn int `json:"turn"` // user questions in the history when it happened |
| 67 | - Where string `json:"where"` // FailGeneration, FailCompaction or FailResume | |
| 67 | + Where string `json:"where"` // FailGeneration, FailCompaction, FailResume or FailAsk | |
| 68 | 68 | Message string `json:"message"` |
| 69 | 69 | Detail string `json:"detail,omitempty"` |
| 70 | 70 | Question string `json:"question,omitempty"` |
| @@ -78,6 +78,7 @@ const ( | ||
| 78 | 78 | FailGeneration = "generation" // the model's answer to a question (engine.Generate) |
| 79 | 79 | FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact) |
| 80 | 80 | FailResume = "resume" // a /resume that could not load its session |
| 81 | + FailAsk = "ask" // an /ask to another agent (internal/ask) | |
| 81 | 82 | ) |
| 82 | 83 | |
| 83 | 84 | // maxErrors bounds the trail: a session that fails on every turn must not |
| @@ -64,7 +64,7 @@ type Session struct { | |||
| 64 | type Failure struct { | 64 | type Failure struct { |
| 65 | At time.Time `json:"at"` | 65 | At time.Time `json:"at"` |
| 66 | Turn int `json:"turn"` // user questions in the history when it happened | 66 | Turn int `json:"turn"` // user questions in the history when it happened |
| 67 | - Where string `json:"where"` // FailGeneration, FailCompaction or FailResume | 67 | + Where string `json:"where"` // FailGeneration, FailCompaction, FailResume or FailAsk |
| 68 | Message string `json:"message"` | 68 | Message string `json:"message"` |
| 69 | Detail string `json:"detail,omitempty"` | 69 | Detail string `json:"detail,omitempty"` |
| 70 | Question string `json:"question,omitempty"` | 70 | Question string `json:"question,omitempty"` |
| @@ -78,6 +78,7 @@ const ( | |||
| 78 | FailGeneration = "generation" // the model's answer to a question (engine.Generate) | 78 | FailGeneration = "generation" // the model's answer to a question (engine.Generate) |
| 79 | FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact) | 79 | FailCompaction = "compaction" // the summary request of a compaction (automatic or /compact) |
| 80 | FailResume = "resume" // a /resume that could not load its session | 80 | FailResume = "resume" // a /resume that could not load its session |
| 81 | + FailAsk = "ask" // an /ask to another agent (internal/ask) | ||
| 81 | ) | 82 | ) |
| 82 | 83 | ||
| 83 | // maxErrors bounds the trail: a session that fails on every turn must not | 84 | // maxErrors bounds the trail: a session that fails on every turn must not |
added
internal/tui/ask.go +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +// The window's side of /ask: another mm agent answers in the conversation, | |
| 2 | +// its tool calls shown as tool calls, and its answer joins the history for | |
| 3 | +// the next question. Run like a turn — on a goroutine, settled by the loop | |
| 4 | +// goroutine — so Esc and Ctrl-C stop it the same way. | |
| 5 | + | |
| 6 | +package tui | |
| 7 | + | |
| 8 | +import ( | |
| 9 | + "context" | |
| 10 | + "fmt" | |
| 11 | + | |
| 12 | + "mm/internal/ask" | |
| 13 | + "mm/internal/command" | |
| 14 | + "mm/internal/store" | |
| 15 | + | |
| 16 | + "github.com/firebase/genkit/go/ai" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// startAsk shows the command as typed and asks req.Agent on a goroutine. | |
| 20 | +func (r *runner) startAsk(p Prompt, req command.AskRequest) { | |
| 21 | + r.t.AddUser(p.Display()) | |
| 22 | + r.t.Notice(ask.Label(req)) | |
| 23 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 24 | + r.cancel = cancel | |
| 25 | + r.cancelled.Store(false) | |
| 26 | + r.busy.Store("asking " + req.Agent) | |
| 27 | + r.streamed.Store(false) | |
| 28 | + msgs := append([]*ai.Message(nil), r.messages...) | |
| 29 | + go func() { | |
| 30 | + exchange, err := r.opts.Asker.Run(ctx, req, msgs, r.opts.Cwd, &sink{r: r}) | |
| 31 | + r.results <- outcome{kind: "ask", messages: append(msgs, exchange...), err: err, cancelled: ctx.Err() != nil, line: req.Agent} | |
| 32 | + r.wake() | |
| 33 | + }() | |
| 34 | +} | |
| 35 | + | |
| 36 | +// applyAsk folds a finished /ask into the session: the exchange is kept only | |
| 37 | +// when the other agent answered. | |
| 38 | +func (r *runner) applyAsk(res outcome) { | |
| 39 | + r.t.Close() | |
| 40 | + switch { | |
| 41 | + case res.err != nil && res.cancelled: | |
| 42 | + r.t.Notice("🛑 Ask aborted — nothing kept.") | |
| 43 | + case res.err != nil: | |
| 44 | + r.t.Error("[error: " + res.err.Error() + "]") | |
| 45 | + r.record(store.NewFailure(r.messages, store.FailAsk, res.err.Error(), res.err)) | |
| 46 | + default: | |
| 47 | + r.messages = res.messages | |
| 48 | + r.save() | |
| 49 | + r.t.Notice(fmt.Sprintf("🗣 %s's answer is in the history for your next question", res.line)) | |
| 50 | + } | |
| 51 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +// The window's side of /ask: another mm agent answers in the conversation, | ||
| 2 | +// its tool calls shown as tool calls, and its answer joins the history for | ||
| 3 | +// the next question. Run like a turn — on a goroutine, settled by the loop | ||
| 4 | +// goroutine — so Esc and Ctrl-C stop it the same way. | ||
| 5 | + | ||
| 6 | +package tui | ||
| 7 | + | ||
| 8 | +import ( | ||
| 9 | + "context" | ||
| 10 | + "fmt" | ||
| 11 | + | ||
| 12 | + "mm/internal/ask" | ||
| 13 | + "mm/internal/command" | ||
| 14 | + "mm/internal/store" | ||
| 15 | + | ||
| 16 | + "github.com/firebase/genkit/go/ai" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// startAsk shows the command as typed and asks req.Agent on a goroutine. | ||
| 20 | +func (r *runner) startAsk(p Prompt, req command.AskRequest) { | ||
| 21 | + r.t.AddUser(p.Display()) | ||
| 22 | + r.t.Notice(ask.Label(req)) | ||
| 23 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 24 | + r.cancel = cancel | ||
| 25 | + r.cancelled.Store(false) | ||
| 26 | + r.busy.Store("asking " + req.Agent) | ||
| 27 | + r.streamed.Store(false) | ||
| 28 | + msgs := append([]*ai.Message(nil), r.messages...) | ||
| 29 | + go func() { | ||
| 30 | + exchange, err := r.opts.Asker.Run(ctx, req, msgs, r.opts.Cwd, &sink{r: r}) | ||
| 31 | + r.results <- outcome{kind: "ask", messages: append(msgs, exchange...), err: err, cancelled: ctx.Err() != nil, line: req.Agent} | ||
| 32 | + r.wake() | ||
| 33 | + }() | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// applyAsk folds a finished /ask into the session: the exchange is kept only | ||
| 37 | +// when the other agent answered. | ||
| 38 | +func (r *runner) applyAsk(res outcome) { | ||
| 39 | + r.t.Close() | ||
| 40 | + switch { | ||
| 41 | + case res.err != nil && res.cancelled: | ||
| 42 | + r.t.Notice("🛑 Ask aborted — nothing kept.") | ||
| 43 | + case res.err != nil: | ||
| 44 | + r.t.Error("[error: " + res.err.Error() + "]") | ||
| 45 | + r.record(store.NewFailure(r.messages, store.FailAsk, res.err.Error(), res.err)) | ||
| 46 | + default: | ||
| 47 | + r.messages = res.messages | ||
| 48 | + r.save() | ||
| 49 | + r.t.Notice(fmt.Sprintf("🗣 %s's answer is in the history for your next question", res.line)) | ||
| 50 | + } | ||
| 51 | +} | ||
added
internal/tui/ask_test.go +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +package tui | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "mm/internal/command" | |
| 10 | + "mm/internal/config" | |
| 11 | + "mm/internal/ui" | |
| 12 | + | |
| 13 | + "github.com/firebase/genkit/go/ai" | |
| 14 | +) | |
| 15 | + | |
| 16 | +// fakeAsker plays the other agent: it shows one tool call and its answer | |
| 17 | +// through the sink, or fails, or waits to be cancelled. | |
| 18 | +type fakeAsker struct { | |
| 19 | + answer string | |
| 20 | + fail error | |
| 21 | + block bool | |
| 22 | +} | |
| 23 | + | |
| 24 | +func (a *fakeAsker) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) { | |
| 25 | + sink.ToolStart(ui.ToolEvent{ID: "ask-1", Tool: req.Agent + "/bash", Title: req.Agent + " · bash: ls"}) | |
| 26 | + sink.ToolEnd("ask-1", ui.ToolResult{Output: "a.txt"}) | |
| 27 | + if a.block { | |
| 28 | + <-ctx.Done() | |
| 29 | + return nil, ctx.Err() | |
| 30 | + } | |
| 31 | + if a.fail != nil { | |
| 32 | + return nil, a.fail | |
| 33 | + } | |
| 34 | + sink.Text(a.answer) | |
| 35 | + return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil | |
| 36 | +} | |
| 37 | + | |
| 38 | +// askRunner is a test runner with one configured agent, bob. | |
| 39 | +func askRunner(t *testing.T, a *fakeAsker) (*runner, *Transcript) { | |
| 40 | + t.Helper() | |
| 41 | + r, tr := newTestRunner(t, &fakeModel{}) | |
| 42 | + config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"} | |
| 43 | + command.SetAgents([]string{"bob"}) | |
| 44 | + t.Cleanup(func() { command.SetAgents(nil) }) | |
| 45 | + r.opts.Asker = a | |
| 46 | + return r, tr | |
| 47 | +} | |
| 48 | + | |
| 49 | +// /ask-bob runs as a job ("asking bob"), shows bob's tool call and answer, | |
| 50 | +// and keeps the exchange in the history. | |
| 51 | +func TestWindowAskKeepsTheExchange(t *testing.T) { | |
| 52 | + r, tr := askRunner(t, &fakeAsker{answer: "four"}) | |
| 53 | + before := len(r.messages) | |
| 54 | + r.submit(promptOf("/ask-bob what is 2+2?")) | |
| 55 | + if r.busyLabel() != "asking bob" { | |
| 56 | + t.Fatalf("busy %q, want asking bob", r.busyLabel()) | |
| 57 | + } | |
| 58 | + waitIdle(t, r) | |
| 59 | + if len(r.messages) != before+2 || r.messages[len(r.messages)-1].Text() != "four" { | |
| 60 | + t.Errorf("history: %d messages, last %q", len(r.messages), r.messages[len(r.messages)-1].Text()) | |
| 61 | + } | |
| 62 | + var kinds []Kind | |
| 63 | + for _, e := range tr.Entries() { | |
| 64 | + kinds = append(kinds, e.Kind) | |
| 65 | + } | |
| 66 | + if !strings.Contains(notices(tr), "🗣 asking bob") || !strings.Contains(notices(tr), "bob's answer is in the history") || tr.Tools() != 1 { | |
| 67 | + t.Errorf("transcript kinds %v, notices:\n%s", kinds, notices(tr)) | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +// Esc during an ask stops it and keeps nothing; a failure is an error line | |
| 72 | +// and keeps nothing; a bad command is refused without starting anything. | |
| 73 | +func TestWindowAskAbortFailureAndMisuse(t *testing.T) { | |
| 74 | + r, tr := askRunner(t, &fakeAsker{block: true}) | |
| 75 | + before := len(r.messages) | |
| 76 | + r.submit(promptOf("/ask-bob hi")) | |
| 77 | + r.abort() | |
| 78 | + waitIdle(t, r) | |
| 79 | + if len(r.messages) != before || !strings.Contains(notices(tr), "Ask aborted — nothing kept") { | |
| 80 | + t.Errorf("abort: %d messages, notices:\n%s", len(r.messages), notices(tr)) | |
| 81 | + } | |
| 82 | + | |
| 83 | + r, tr = askRunner(t, &fakeAsker{fail: errors.New("bob did not start: boom")}) | |
| 84 | + r.submit(promptOf("/ask bob hi")) | |
| 85 | + waitIdle(t, r) | |
| 86 | + if len(r.messages) != before || !strings.Contains(notices(tr), "[error: bob did not start: boom]") { | |
| 87 | + t.Errorf("failure: notices:\n%s", notices(tr)) | |
| 88 | + } | |
| 89 | + | |
| 90 | + r, tr = askRunner(t, &fakeAsker{}) | |
| 91 | + r.submit(promptOf("/ask spock hi")) | |
| 92 | + if r.isBusy() || !strings.Contains(notices(tr), `unknown agent "spock"`) { | |
| 93 | + t.Errorf("misuse: busy %v, notices:\n%s", r.isBusy(), notices(tr)) | |
| 94 | + } | |
| 95 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +package tui | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "mm/internal/command" | ||
| 10 | + "mm/internal/config" | ||
| 11 | + "mm/internal/ui" | ||
| 12 | + | ||
| 13 | + "github.com/firebase/genkit/go/ai" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +// fakeAsker plays the other agent: it shows one tool call and its answer | ||
| 17 | +// through the sink, or fails, or waits to be cancelled. | ||
| 18 | +type fakeAsker struct { | ||
| 19 | + answer string | ||
| 20 | + fail error | ||
| 21 | + block bool | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +func (a *fakeAsker) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) { | ||
| 25 | + sink.ToolStart(ui.ToolEvent{ID: "ask-1", Tool: req.Agent + "/bash", Title: req.Agent + " · bash: ls"}) | ||
| 26 | + sink.ToolEnd("ask-1", ui.ToolResult{Output: "a.txt"}) | ||
| 27 | + if a.block { | ||
| 28 | + <-ctx.Done() | ||
| 29 | + return nil, ctx.Err() | ||
| 30 | + } | ||
| 31 | + if a.fail != nil { | ||
| 32 | + return nil, a.fail | ||
| 33 | + } | ||
| 34 | + sink.Text(a.answer) | ||
| 35 | + return []*ai.Message{ai.NewUserTextMessage("[asked " + req.Agent + ": " + req.Prompt + "]"), ai.NewModelTextMessage(a.answer)}, nil | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// askRunner is a test runner with one configured agent, bob. | ||
| 39 | +func askRunner(t *testing.T, a *fakeAsker) (*runner, *Transcript) { | ||
| 40 | + t.Helper() | ||
| 41 | + r, tr := newTestRunner(t, &fakeModel{}) | ||
| 42 | + config.Cfg.MMConfigs = map[string]string{"bob": "/nowhere/agent.bob.yaml"} | ||
| 43 | + command.SetAgents([]string{"bob"}) | ||
| 44 | + t.Cleanup(func() { command.SetAgents(nil) }) | ||
| 45 | + r.opts.Asker = a | ||
| 46 | + return r, tr | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +// /ask-bob runs as a job ("asking bob"), shows bob's tool call and answer, | ||
| 50 | +// and keeps the exchange in the history. | ||
| 51 | +func TestWindowAskKeepsTheExchange(t *testing.T) { | ||
| 52 | + r, tr := askRunner(t, &fakeAsker{answer: "four"}) | ||
| 53 | + before := len(r.messages) | ||
| 54 | + r.submit(promptOf("/ask-bob what is 2+2?")) | ||
| 55 | + if r.busyLabel() != "asking bob" { | ||
| 56 | + t.Fatalf("busy %q, want asking bob", r.busyLabel()) | ||
| 57 | + } | ||
| 58 | + waitIdle(t, r) | ||
| 59 | + if len(r.messages) != before+2 || r.messages[len(r.messages)-1].Text() != "four" { | ||
| 60 | + t.Errorf("history: %d messages, last %q", len(r.messages), r.messages[len(r.messages)-1].Text()) | ||
| 61 | + } | ||
| 62 | + var kinds []Kind | ||
| 63 | + for _, e := range tr.Entries() { | ||
| 64 | + kinds = append(kinds, e.Kind) | ||
| 65 | + } | ||
| 66 | + if !strings.Contains(notices(tr), "🗣 asking bob") || !strings.Contains(notices(tr), "bob's answer is in the history") || tr.Tools() != 1 { | ||
| 67 | + t.Errorf("transcript kinds %v, notices:\n%s", kinds, notices(tr)) | ||
| 68 | + } | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// Esc during an ask stops it and keeps nothing; a failure is an error line | ||
| 72 | +// and keeps nothing; a bad command is refused without starting anything. | ||
| 73 | +func TestWindowAskAbortFailureAndMisuse(t *testing.T) { | ||
| 74 | + r, tr := askRunner(t, &fakeAsker{block: true}) | ||
| 75 | + before := len(r.messages) | ||
| 76 | + r.submit(promptOf("/ask-bob hi")) | ||
| 77 | + r.abort() | ||
| 78 | + waitIdle(t, r) | ||
| 79 | + if len(r.messages) != before || !strings.Contains(notices(tr), "Ask aborted — nothing kept") { | ||
| 80 | + t.Errorf("abort: %d messages, notices:\n%s", len(r.messages), notices(tr)) | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + r, tr = askRunner(t, &fakeAsker{fail: errors.New("bob did not start: boom")}) | ||
| 84 | + r.submit(promptOf("/ask bob hi")) | ||
| 85 | + waitIdle(t, r) | ||
| 86 | + if len(r.messages) != before || !strings.Contains(notices(tr), "[error: bob did not start: boom]") { | ||
| 87 | + t.Errorf("failure: notices:\n%s", notices(tr)) | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + r, tr = askRunner(t, &fakeAsker{}) | ||
| 91 | + r.submit(promptOf("/ask spock hi")) | ||
| 92 | + if r.isBusy() || !strings.Contains(notices(tr), `unknown agent "spock"`) { | ||
| 93 | + t.Errorf("misuse: busy %v, notices:\n%s", r.isBusy(), notices(tr)) | ||
| 94 | + } | ||
| 95 | +} | ||
modified
internal/tui/commands.go +8 -0 | @@ -68,6 +68,14 @@ func (r *runner) abort() { | ||
| 68 | 68 | // dispatch runs a slash command, or starts a turn. A command is read from |
| 69 | 69 | // the typed text: a paste token is never part of a command name. |
| 70 | 70 | func (r *runner) dispatch(p Prompt) { |
| 71 | + if req, isAsk, err := command.ParseAsk(p.Typed); isAsk { | |
| 72 | + if err != nil { | |
| 73 | + r.t.Error("⚠️ " + err.Error()) | |
| 74 | + return | |
| 75 | + } | |
| 76 | + r.startAsk(p, req) | |
| 77 | + return | |
| 78 | + } | |
| 71 | 79 | cmd, arg, ok := command.Parse(p.Typed) |
| 72 | 80 | if !ok { |
| 73 | 81 | r.startTurn(p) |
| @@ -68,6 +68,14 @@ func (r *runner) abort() { | |||
| 68 | // dispatch runs a slash command, or starts a turn. A command is read from | 68 | // dispatch runs a slash command, or starts a turn. A command is read from |
| 69 | // the typed text: a paste token is never part of a command name. | 69 | // the typed text: a paste token is never part of a command name. |
| 70 | func (r *runner) dispatch(p Prompt) { | 70 | func (r *runner) dispatch(p Prompt) { |
| 71 | + if req, isAsk, err := command.ParseAsk(p.Typed); isAsk { | ||
| 72 | + if err != nil { | ||
| 73 | + r.t.Error("⚠️ " + err.Error()) | ||
| 74 | + return | ||
| 75 | + } | ||
| 76 | + r.startAsk(p, req) | ||
| 77 | + return | ||
| 78 | + } | ||
| 71 | cmd, arg, ok := command.Parse(p.Typed) | 79 | cmd, arg, ok := command.Parse(p.Typed) |
| 72 | if !ok { | 80 | if !ok { |
| 73 | r.startTurn(p) | 81 | r.startTurn(p) |
modified
internal/tui/turn.go +12 -3 | @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "sync" |
| 8 | 8 | "sync/atomic" |
| 9 | 9 | |
| 10 | + "mm/internal/ask" | |
| 10 | 11 | "mm/internal/compact" |
| 11 | 12 | "mm/internal/config" |
| 12 | 13 | "mm/internal/detector" |
| @@ -55,6 +56,9 @@ type Options struct { | ||
| 55 | 56 | // Banner is the start-up text main printed (or would have); shown as |
| 56 | 57 | // the first notice, since stdout is the window now. |
| 57 | 58 | Banner string |
| 59 | + // Asker runs the /ask commands (another mm agent); nil when none is | |
| 60 | + // configured — command.ParseAsk then recognises none. | |
| 61 | + Asker ask.Runner | |
| 58 | 62 | } |
| 59 | 63 | |
| 60 | 64 | // Copier is what /copy writes to: clipboard.Copier, or a fake in tests. |
| @@ -65,7 +69,7 @@ type Copier interface { | ||
| 65 | 69 | // outcome is what a background job — a turn or a compaction — reports back |
| 66 | 70 | // to the loop goroutine. |
| 67 | 71 | type outcome struct { |
| 68 | - kind string // "turn" or "compact" | |
| 72 | + kind string // "turn", "compact" or "ask" | |
| 69 | 73 | messages []*ai.Message |
| 70 | 74 | before int |
| 71 | 75 | resp *ai.ModelResponse |
| @@ -95,7 +99,7 @@ type runner struct { | ||
| 95 | 99 | clip Copier // set by newApp; nil means /copy reports that it cannot |
| 96 | 100 | |
| 97 | 101 | messages []*ai.Message |
| 98 | - busy atomic.Value // string: "", "thinking", "compressing" | |
| 102 | + busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>" | |
| 99 | 103 | quit atomic.Bool |
| 100 | 104 | cancel context.CancelFunc |
| 101 | 105 | // cancelled says the job in progress has already been told to stop. A |
| @@ -129,7 +133,8 @@ func newRunner(opts Options, t *Transcript, wake func()) *runner { | ||
| 129 | 133 | return r |
| 130 | 134 | } |
| 131 | 135 | |
| 132 | -// busyLabel is what the rule shows: "", "thinking" or "compressing". | |
| 136 | +// busyLabel is what the rule shows: "", "thinking", "compressing" or | |
| 137 | +// "asking <agent>". | |
| 133 | 138 | func (r *runner) busyLabel() string { return r.busy.Load().(string) } |
| 134 | 139 | |
| 135 | 140 | // isBusy reports whether a job is running. |
| @@ -255,6 +260,10 @@ func (r *runner) settle() { | ||
| 255 | 260 | |
| 256 | 261 | // apply folds one outcome into the session and the transcript. |
| 257 | 262 | func (r *runner) apply(res outcome) { |
| 263 | + if res.kind == "ask" { | |
| 264 | + r.applyAsk(res) | |
| 265 | + return | |
| 266 | + } | |
| 258 | 267 | if res.kind == "compact" { |
| 259 | 268 | r.messages = res.messages |
| 260 | 269 | switch { |
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "sync" | 7 | "sync" |
| 8 | "sync/atomic" | 8 | "sync/atomic" |
| 9 | 9 | ||
| 10 | + "mm/internal/ask" | ||
| 10 | "mm/internal/compact" | 11 | "mm/internal/compact" |
| 11 | "mm/internal/config" | 12 | "mm/internal/config" |
| 12 | "mm/internal/detector" | 13 | "mm/internal/detector" |
| @@ -55,6 +56,9 @@ type Options struct { | |||
| 55 | // Banner is the start-up text main printed (or would have); shown as | 56 | // Banner is the start-up text main printed (or would have); shown as |
| 56 | // the first notice, since stdout is the window now. | 57 | // the first notice, since stdout is the window now. |
| 57 | Banner string | 58 | Banner string |
| 59 | + // Asker runs the /ask commands (another mm agent); nil when none is | ||
| 60 | + // configured — command.ParseAsk then recognises none. | ||
| 61 | + Asker ask.Runner | ||
| 58 | } | 62 | } |
| 59 | 63 | ||
| 60 | // Copier is what /copy writes to: clipboard.Copier, or a fake in tests. | 64 | // Copier is what /copy writes to: clipboard.Copier, or a fake in tests. |
| @@ -65,7 +69,7 @@ type Copier interface { | |||
| 65 | // outcome is what a background job — a turn or a compaction — reports back | 69 | // outcome is what a background job — a turn or a compaction — reports back |
| 66 | // to the loop goroutine. | 70 | // to the loop goroutine. |
| 67 | type outcome struct { | 71 | type outcome struct { |
| 68 | - kind string // "turn" or "compact" | 72 | + kind string // "turn", "compact" or "ask" |
| 69 | messages []*ai.Message | 73 | messages []*ai.Message |
| 70 | before int | 74 | before int |
| 71 | resp *ai.ModelResponse | 75 | resp *ai.ModelResponse |
| @@ -95,7 +99,7 @@ type runner struct { | |||
| 95 | clip Copier // set by newApp; nil means /copy reports that it cannot | 99 | clip Copier // set by newApp; nil means /copy reports that it cannot |
| 96 | 100 | ||
| 97 | messages []*ai.Message | 101 | messages []*ai.Message |
| 98 | - busy atomic.Value // string: "", "thinking", "compressing" | 102 | + busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>" |
| 99 | quit atomic.Bool | 103 | quit atomic.Bool |
| 100 | cancel context.CancelFunc | 104 | cancel context.CancelFunc |
| 101 | // cancelled says the job in progress has already been told to stop. A | 105 | // cancelled says the job in progress has already been told to stop. A |
| @@ -129,7 +133,8 @@ func newRunner(opts Options, t *Transcript, wake func()) *runner { | |||
| 129 | return r | 133 | return r |
| 130 | } | 134 | } |
| 131 | 135 | ||
| 132 | -// busyLabel is what the rule shows: "", "thinking" or "compressing". | 136 | +// busyLabel is what the rule shows: "", "thinking", "compressing" or |
| 137 | +// "asking <agent>". | ||
| 133 | func (r *runner) busyLabel() string { return r.busy.Load().(string) } | 138 | func (r *runner) busyLabel() string { return r.busy.Load().(string) } |
| 134 | 139 | ||
| 135 | // isBusy reports whether a job is running. | 140 | // isBusy reports whether a job is running. |
| @@ -255,6 +260,10 @@ func (r *runner) settle() { | |||
| 255 | 260 | ||
| 256 | // apply folds one outcome into the session and the transcript. | 261 | // apply folds one outcome into the session and the transcript. |
| 257 | func (r *runner) apply(res outcome) { | 262 | func (r *runner) apply(res outcome) { |
| 263 | + if res.kind == "ask" { | ||
| 264 | + r.applyAsk(res) | ||
| 265 | + return | ||
| 266 | + } | ||
| 258 | if res.kind == "compact" { | 267 | if res.kind == "compact" { |
| 259 | r.messages = res.messages | 268 | r.messages = res.messages |
| 260 | switch { | 269 | switch { |
modified
internal/tui/view.go +1 -1 | @@ -78,7 +78,7 @@ type View struct { | ||
| 78 | 78 | header string // the header line |
| 79 | 79 | status string // the counters on the right of the status bar |
| 80 | 80 | version string // shown before them while the bar is wide enough |
| 81 | - busy string // "", "thinking", "compressing" | |
| 81 | + busy string // "", "thinking", "compressing", "asking <agent>" | |
| 82 | 82 | ask *permission |
| 83 | 83 | |
| 84 | 84 | // input is what has been typed but not sent, as lines. A long paste is |
| @@ -78,7 +78,7 @@ type View struct { | |||
| 78 | header string // the header line | 78 | header string // the header line |
| 79 | status string // the counters on the right of the status bar | 79 | status string // the counters on the right of the status bar |
| 80 | version string // shown before them while the bar is wide enough | 80 | version string // shown before them while the bar is wide enough |
| 81 | - busy string // "", "thinking", "compressing" | 81 | + busy string // "", "thinking", "compressing", "asking <agent>" |
| 82 | ask *permission | 82 | ask *permission |
| 83 | 83 | ||
| 84 | // input is what has been typed but not sent, as lines. A long paste is | 84 | // input is what has been typed but not sent, as lines. A long paste is |
modified
main.go +15 -5 | @@ -38,6 +38,7 @@ import ( | ||
| 38 | 38 | |
| 39 | 39 | "mm/internal/acp" |
| 40 | 40 | "mm/internal/agent" |
| 41 | + "mm/internal/ask" | |
| 41 | 42 | "mm/internal/command" |
| 42 | 43 | "mm/internal/config" |
| 43 | 44 | "mm/internal/detector" |
| @@ -162,6 +163,13 @@ func main() { | ||
| 162 | 163 | // is made absolute here, so the banner can say exactly where it looked. |
| 163 | 164 | config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode) |
| 164 | 165 | |
| 166 | + // The other agents this one can ask (`mmConfigs`): their /ask-<name> | |
| 167 | + // commands join the shared table before any front end reads it, and their | |
| 168 | + // processes start on the first ask and stop when this one exits. | |
| 169 | + command.SetAgents(config.AgentNames()) | |
| 170 | + asker := ask.NewPool() | |
| 171 | + defer asker.Close() | |
| 172 | + | |
| 165 | 173 | window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode) |
| 166 | 174 | |
| 167 | 175 | e, err := engine.New(ctx) |
| @@ -181,6 +189,7 @@ func main() { | ||
| 181 | 189 | set := assembleTools(e, loopDetector) |
| 182 | 190 | defer set.close() |
| 183 | 191 | agentTools := set.tools |
| 192 | + set.warnings = append(set.warnings, ask.Missing()...) | |
| 184 | 193 | |
| 185 | 194 | if path == "" { |
| 186 | 195 | path = "built-in defaults" |
| @@ -198,19 +207,19 @@ func main() { | ||
| 198 | 207 | // Three front ends, one wiring: everything above — config, engine, tools, |
| 199 | 208 | // detector — is strictly identical whichever loop runs below. |
| 200 | 209 | if *acpMode { |
| 201 | - acp.Run(ctx, e, config.Cfg.System, agentTools) | |
| 210 | + acp.Run(ctx, e, config.Cfg.System, agentTools, asker) | |
| 202 | 211 | return |
| 203 | 212 | } |
| 204 | 213 | if window { |
| 205 | - runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String())) | |
| 214 | + runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()), asker) | |
| 206 | 215 | return |
| 207 | 216 | } |
| 208 | - agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector) | |
| 217 | + agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker) | |
| 209 | 218 | } |
| 210 | 219 | |
| 211 | 220 | // runWindow starts the single-window front end, falling back to the line |
| 212 | 221 | // mode — banner first — if the terminal cannot be opened after all. |
| 213 | -func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string) { | |
| 222 | +func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string, asker ask.Runner) { | |
| 214 | 223 | cwd, _ := os.Getwd() |
| 215 | 224 | err := tui.Run(ctx, tui.Options{ |
| 216 | 225 | Model: e, |
| @@ -226,6 +235,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d | ||
| 226 | 235 | Skills: skills.Declared(), |
| 227 | 236 | Version: version.Short(), |
| 228 | 237 | Banner: banner, |
| 238 | + Asker: asker, | |
| 229 | 239 | }) |
| 230 | 240 | if err == nil { |
| 231 | 241 | return |
| @@ -235,7 +245,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d | ||
| 235 | 245 | say("[tui: %v — running the line mode]", err) |
| 236 | 246 | } |
| 237 | 247 | say("%s", banner) |
| 238 | - agent.Run(ctx, e, config.Cfg.System, agentTools, d) | |
| 248 | + agent.Run(ctx, e, config.Cfg.System, agentTools, d, asker) | |
| 239 | 249 | } |
| 240 | 250 | |
| 241 | 251 | // toolSet is what assembleTools hands main: the tools, their names for the |
| @@ -38,6 +38,7 @@ import ( | |||
| 38 | 38 | ||
| 39 | "mm/internal/acp" | 39 | "mm/internal/acp" |
| 40 | "mm/internal/agent" | 40 | "mm/internal/agent" |
| 41 | + "mm/internal/ask" | ||
| 41 | "mm/internal/command" | 42 | "mm/internal/command" |
| 42 | "mm/internal/config" | 43 | "mm/internal/config" |
| 43 | "mm/internal/detector" | 44 | "mm/internal/detector" |
| @@ -162,6 +163,13 @@ func main() { | |||
| 162 | // is made absolute here, so the banner can say exactly where it looked. | 163 | // is made absolute here, so the banner can say exactly where it looked. |
| 163 | config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode) | 164 | config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode) |
| 164 | 165 | ||
| 166 | + // The other agents this one can ask (`mmConfigs`): their /ask-<name> | ||
| 167 | + // commands join the shared table before any front end reads it, and their | ||
| 168 | + // processes start on the first ask and stop when this one exits. | ||
| 169 | + command.SetAgents(config.AgentNames()) | ||
| 170 | + asker := ask.NewPool() | ||
| 171 | + defer asker.Close() | ||
| 172 | + | ||
| 165 | window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode) | 173 | window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode) |
| 166 | 174 | ||
| 167 | e, err := engine.New(ctx) | 175 | e, err := engine.New(ctx) |
| @@ -181,6 +189,7 @@ func main() { | |||
| 181 | set := assembleTools(e, loopDetector) | 189 | set := assembleTools(e, loopDetector) |
| 182 | defer set.close() | 190 | defer set.close() |
| 183 | agentTools := set.tools | 191 | agentTools := set.tools |
| 192 | + set.warnings = append(set.warnings, ask.Missing()...) | ||
| 184 | 193 | ||
| 185 | if path == "" { | 194 | if path == "" { |
| 186 | path = "built-in defaults" | 195 | path = "built-in defaults" |
| @@ -198,19 +207,19 @@ func main() { | |||
| 198 | // Three front ends, one wiring: everything above — config, engine, tools, | 207 | // Three front ends, one wiring: everything above — config, engine, tools, |
| 199 | // detector — is strictly identical whichever loop runs below. | 208 | // detector — is strictly identical whichever loop runs below. |
| 200 | if *acpMode { | 209 | if *acpMode { |
| 201 | - acp.Run(ctx, e, config.Cfg.System, agentTools) | 210 | + acp.Run(ctx, e, config.Cfg.System, agentTools, asker) |
| 202 | return | 211 | return |
| 203 | } | 212 | } |
| 204 | if window { | 213 | if window { |
| 205 | - runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String())) | 214 | + runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()), asker) |
| 206 | return | 215 | return |
| 207 | } | 216 | } |
| 208 | - agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector) | 217 | + agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker) |
| 209 | } | 218 | } |
| 210 | 219 | ||
| 211 | // runWindow starts the single-window front end, falling back to the line | 220 | // runWindow starts the single-window front end, falling back to the line |
| 212 | // mode — banner first — if the terminal cannot be opened after all. | 221 | // mode — banner first — if the terminal cannot be opened after all. |
| 213 | -func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string) { | 222 | +func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string, asker ask.Runner) { |
| 214 | cwd, _ := os.Getwd() | 223 | cwd, _ := os.Getwd() |
| 215 | err := tui.Run(ctx, tui.Options{ | 224 | err := tui.Run(ctx, tui.Options{ |
| 216 | Model: e, | 225 | Model: e, |
| @@ -226,6 +235,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d | |||
| 226 | Skills: skills.Declared(), | 235 | Skills: skills.Declared(), |
| 227 | Version: version.Short(), | 236 | Version: version.Short(), |
| 228 | Banner: banner, | 237 | Banner: banner, |
| 238 | + Asker: asker, | ||
| 229 | }) | 239 | }) |
| 230 | if err == nil { | 240 | if err == nil { |
| 231 | return | 241 | return |
| @@ -235,7 +245,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d | |||
| 235 | say("[tui: %v — running the line mode]", err) | 245 | say("[tui: %v — running the line mode]", err) |
| 236 | } | 246 | } |
| 237 | say("%s", banner) | 247 | say("%s", banner) |
| 238 | - agent.Run(ctx, e, config.Cfg.System, agentTools, d) | 248 | + agent.Run(ctx, e, config.Cfg.System, agentTools, d, asker) |
| 239 | } | 249 | } |
| 240 | 250 | ||
| 241 | // toolSet is what assembleTools hands main: the tools, their names for the | 251 | // toolSet is what assembleTools hands main: the tools, their names for the |