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

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

Download ZIP

Merge feature/ask-agent-hooks into main

rickub committed 2026-10-16T09:10:07+02:00 Browse files
b0741ef parents: 8eb168b 5b2f986
modified .tickets/issues/0087-hook-for-ask-agents.yaml +62 -2
@@ -6,7 +6,7 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-15T21:55:43.205Z
9-updatedAt: 2026-10-15T22:30:00.000Z
9+updatedAt: 2026-10-15T23:50:00.000Z
1010 labels: []
1111 milestone: v0.5.0
1212 body: |
@@ -101,5 +101,65 @@ body: |
101101 2. **`preAsk` runs after the `ask_agent` consent.** The user approves what the model asked for; the hook then filters or rewrites it.
102102 3. **`mm --ask` runs the hooks** of the file it reads (`AGENT_CONFIG`), with `via: "cli"`.
103103 4. **No rerouting**: `preAsk` cannot change the agent.
104-tasks: []
104+
105+ ## Implemented (2026-10-15, branch `feature/ask-agent-hooks`)
106+
107+ As designed above, with these precisions found while building it:
108+ - **The seam** is `ask.Pool.Delegate(ctx, ask.Request, cwd, sink) (ask.Result, error)` (`internal/ask/delegate.go`); `Pool.Run` (/ask…), `Pool.CLI` (mm --ask) and the `ask_agent` tool all call it. The tool's `AgentAsker` interface is now `Delegate`.
109+ - **Denied on `/ask…`**: shown like any failed /ask (`ask denied by a hook: <reason>`), history unchanged — no dedicated `⛔` display, to avoid touching the three front ends. On `ask_agent` in the line mode, a `🪝 ask_agent [ask denied by a hook: <reason>]` line (found missing in the first live run).
110+ - **`answer` on a failed ask** (not a cancelled one) clears the error: the asking agent reads the replacement as the answer. Any replacement is announced: `🪝 postAsk: <agent>'s answer was replaced by a hook — the asking agent reads the new one`.
111+ - **`asked`** is filled by the hooks runner itself (`config.AskDepth()`, `MM_ASK_DEPTH` moved to `config.AskDepthEnv`), so it reaches every event of an asked agent.
112+ - **`[context added by a hook]`** is now `hooks.ContextLabel` + `hooks.WithContext`, shared by the engine and `ask`.
113+ - Demo: `demos/hooks-ask/` (help desk + expert, Gemma 4 E4B ×2, four Node.js hooks), measured live on the four paths.
114+tasks:
115+ - id: 1
116+ title: "config: preAsk/postAsk events, matcher on the agent name"
117+ state: closed
118+ priority: high
119+ author:
120+ name: k33g
121+ email: ph.charriere@gmail.com
122+ createdAt: 2026-10-15T23:50:00.000Z
123+ updatedAt: 2026-10-15T23:50:00.000Z
124+ closedAt: 2026-10-15T23:50:00.000Z
125+ - id: 2
126+ title: "hooks: AskCall input, transcript/answer replies, PreAsk/PostAsk"
127+ state: closed
128+ priority: high
129+ author:
130+ name: k33g
131+ email: ph.charriere@gmail.com
132+ createdAt: 2026-10-15T23:50:00.000Z
133+ updatedAt: 2026-10-15T23:50:00.000Z
134+ closedAt: 2026-10-15T23:50:00.000Z
135+ - id: 3
136+ title: "ask: Pool.Delegate seam for /ask, mm --ask and ask_agent"
137+ state: closed
138+ priority: high
139+ author:
140+ name: k33g
141+ email: ph.charriere@gmail.com
142+ createdAt: 2026-10-15T23:50:00.000Z
143+ updatedAt: 2026-10-15T23:50:00.000Z
144+ closedAt: 2026-10-15T23:50:00.000Z
145+ - id: 4
146+ title: Demo demos/hooks-ask (EN+FR READMEs, live runs)
147+ state: closed
148+ priority: medium
149+ author:
150+ name: k33g
151+ email: ph.charriere@gmail.com
152+ createdAt: 2026-10-15T23:50:00.000Z
153+ updatedAt: 2026-10-15T23:50:00.000Z
154+ closedAt: 2026-10-15T23:50:00.000Z
155+ - id: 5
156+ title: Docs EN+FR, e-book chapter 14, package diagram
157+ state: closed
158+ priority: medium
159+ author:
160+ name: k33g
161+ email: ph.charriere@gmail.com
162+ createdAt: 2026-10-15T23:50:00.000Z
163+ updatedAt: 2026-10-15T23:50:00.000Z
164+ closedAt: 2026-10-15T23:50:00.000Z
105165 comments: []
@@ -6,7 +6,7 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-15T21:55:43.205Z8 createdAt: 2026-10-15T21:55:43.205Z
9-updatedAt: 2026-10-15T22:30:00.000Z9+updatedAt: 2026-10-15T23:50:00.000Z
10 labels: []10 labels: []
11 milestone: v0.5.011 milestone: v0.5.0
12 body: |12 body: |
@@ -101,5 +101,65 @@ body: |
101 2. **`preAsk` runs after the `ask_agent` consent.** The user approves what the model asked for; the hook then filters or rewrites it.101 2. **`preAsk` runs after the `ask_agent` consent.** The user approves what the model asked for; the hook then filters or rewrites it.
102 3. **`mm --ask` runs the hooks** of the file it reads (`AGENT_CONFIG`), with `via: "cli"`.102 3. **`mm --ask` runs the hooks** of the file it reads (`AGENT_CONFIG`), with `via: "cli"`.
103 4. **No rerouting**: `preAsk` cannot change the agent.103 4. **No rerouting**: `preAsk` cannot change the agent.
104-tasks: []104+
105+ ## Implemented (2026-10-15, branch `feature/ask-agent-hooks`)
106+
107+ As designed above, with these precisions found while building it:
108+ - **The seam** is `ask.Pool.Delegate(ctx, ask.Request, cwd, sink) (ask.Result, error)` (`internal/ask/delegate.go`); `Pool.Run` (/ask…), `Pool.CLI` (mm --ask) and the `ask_agent` tool all call it. The tool's `AgentAsker` interface is now `Delegate`.
109+ - **Denied on `/ask…`**: shown like any failed /ask (`ask denied by a hook: <reason>`), history unchanged — no dedicated `⛔` display, to avoid touching the three front ends. On `ask_agent` in the line mode, a `🪝 ask_agent [ask denied by a hook: <reason>]` line (found missing in the first live run).
110+ - **`answer` on a failed ask** (not a cancelled one) clears the error: the asking agent reads the replacement as the answer. Any replacement is announced: `🪝 postAsk: <agent>'s answer was replaced by a hook — the asking agent reads the new one`.
111+ - **`asked`** is filled by the hooks runner itself (`config.AskDepth()`, `MM_ASK_DEPTH` moved to `config.AskDepthEnv`), so it reaches every event of an asked agent.
112+ - **`[context added by a hook]`** is now `hooks.ContextLabel` + `hooks.WithContext`, shared by the engine and `ask`.
113+ - Demo: `demos/hooks-ask/` (help desk + expert, Gemma 4 E4B ×2, four Node.js hooks), measured live on the four paths.
114+tasks:
115+ - id: 1
116+ title: "config: preAsk/postAsk events, matcher on the agent name"
117+ state: closed
118+ priority: high
119+ author:
120+ name: k33g
121+ email: ph.charriere@gmail.com
122+ createdAt: 2026-10-15T23:50:00.000Z
123+ updatedAt: 2026-10-15T23:50:00.000Z
124+ closedAt: 2026-10-15T23:50:00.000Z
125+ - id: 2
126+ title: "hooks: AskCall input, transcript/answer replies, PreAsk/PostAsk"
127+ state: closed
128+ priority: high
129+ author:
130+ name: k33g
131+ email: ph.charriere@gmail.com
132+ createdAt: 2026-10-15T23:50:00.000Z
133+ updatedAt: 2026-10-15T23:50:00.000Z
134+ closedAt: 2026-10-15T23:50:00.000Z
135+ - id: 3
136+ title: "ask: Pool.Delegate seam for /ask, mm --ask and ask_agent"
137+ state: closed
138+ priority: high
139+ author:
140+ name: k33g
141+ email: ph.charriere@gmail.com
142+ createdAt: 2026-10-15T23:50:00.000Z
143+ updatedAt: 2026-10-15T23:50:00.000Z
144+ closedAt: 2026-10-15T23:50:00.000Z
145+ - id: 4
146+ title: Demo demos/hooks-ask (EN+FR READMEs, live runs)
147+ state: closed
148+ priority: medium
149+ author:
150+ name: k33g
151+ email: ph.charriere@gmail.com
152+ createdAt: 2026-10-15T23:50:00.000Z
153+ updatedAt: 2026-10-15T23:50:00.000Z
154+ closedAt: 2026-10-15T23:50:00.000Z
155+ - id: 5
156+ title: Docs EN+FR, e-book chapter 14, package diagram
157+ state: closed
158+ priority: medium
159+ author:
160+ name: k33g
161+ email: ph.charriere@gmail.com
162+ createdAt: 2026-10-15T23:50:00.000Z
163+ updatedAt: 2026-10-15T23:50:00.000Z
164+ closedAt: 2026-10-15T23:50:00.000Z
105 comments: []165 comments: []
modified README.md +2 -1
@@ -116,7 +116,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
116116 - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md).
117117 - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md).
118118 - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md).
119-- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, and after the turn; they can deny a call, stop or rewrite a question, add context, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md) and `demos/hooks/`.
119+- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, after the turn, and around each hand-off to another agent; they can deny a call, stop or rewrite a question, add context, mask what leaves for another agent, replace what comes back, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md), [Control the hand-offs to other agents with hooks](docs/en/how-to/control-agent-calls-with-hooks.md), `demos/hooks/` and `demos/hooks-ask/`.
120120 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).
121121
122122 ## Demos
@@ -141,6 +141,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
141141 | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results |
142142 | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results |
143143 | `demos/hooks/` | hooks in Node.js around a small shell assistant (Gemma 4 E4B on llmman): a question holding a secret never reaches the model, today's date and the file list are added to every question, `rm -r` / `sudo` / `curl \| sh` are denied, every tool call and turn is logged to `.mm/hooks-audit.jsonl` |
144+| `demos/hooks-ask/` | `preAsk` and `postAsk` hooks in Node.js around a help desk that hands technical questions to an expert agent (both Gemma 4 E4B on llmman): secrets masked before they leave, at most 2 `ask_agent` calls per question, standing rules added for the expert, a failed hand-off turned into an instruction, every hand-off logged to `.mm/ask-audit.jsonl` |
144145 | `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results |
145146 | `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results |
146147
@@ -116,7 +116,7 @@ Anything that speaks ACP can host `mm -acp` the same way: **JetBrains IDEs** (In
116 - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md).116 - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md).
117 - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md).117 - **Sessions**: with `sessions.enabled: true`, `/sessions` lists the conversations of a project and `/resume` continues one. See [Keep sessions and resume them](docs/en/how-to/keep-sessions.md).
118 - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md).118 - **Trace**: `AGENT_TRACE=true mm …` writes every request to the model and every response to `.mm/traces/`. See [See the JSON sent to the model](docs/en/how-to/inspect-model-traffic.md).
119-- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, and after the turn; they can deny a call, stop or rewrite a question, add context, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md) and `demos/hooks/`.119+- **Hooks**: your own commands — in any language — run before a question reaches the model, before and after each tool call, after the turn, and around each hand-off to another agent; they can deny a call, stop or rewrite a question, add context, mask what leaves for another agent, replace what comes back, or log. See [Guard the agent with hooks](docs/en/how-to/guard-the-agent-with-hooks.md), [Control the hand-offs to other agents with hooks](docs/en/how-to/control-agent-calls-with-hooks.md), `demos/hooks/` and `demos/hooks-ask/`.
120 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).120 - **Docker Sandboxes**: the sandbox kit lives in its own repository, [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit); `sbx/` explains how to use it (`sbx run …`), with two mixins (`go-toolchain`, `cli-tools`) and ready-made configurations — see `sbx/README.md` and [Run mini-me in a Docker Sandbox](docs/en/how-to/run-in-a-docker-sandbox.md).
121 121
122 ## Demos122 ## Demos
@@ -141,6 +141,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h
141 | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results |141 | `demos/news-roundup/` | a press-review agent: DuckDuckGo News through a small MCP server (Python standard library), Qwen3.5 9B on llmman, ready-made citations so no article is invented, measured results |
142 | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results |142 | `demos/project-wizard/` | the interactive prompts (`ask_user`) in a small agent: a four-question form (text, two choices, several choices), then a yes/no confirmation carrying the plan before any file is written; Qwen3.5 9B on llmman, measured results |
143 | `demos/hooks/` | hooks in Node.js around a small shell assistant (Gemma 4 E4B on llmman): a question holding a secret never reaches the model, today's date and the file list are added to every question, `rm -r` / `sudo` / `curl \| sh` are denied, every tool call and turn is logged to `.mm/hooks-audit.jsonl` |143 | `demos/hooks/` | hooks in Node.js around a small shell assistant (Gemma 4 E4B on llmman): a question holding a secret never reaches the model, today's date and the file list are added to every question, `rm -r` / `sudo` / `curl \| sh` are denied, every tool call and turn is logged to `.mm/hooks-audit.jsonl` |
144+| `demos/hooks-ask/` | `preAsk` and `postAsk` hooks in Node.js around a help desk that hands technical questions to an expert agent (both Gemma 4 E4B on llmman): secrets masked before they leave, at most 2 `ask_agent` calls per question, standing rules added for the expert, a failed hand-off turned into an instruction, every hand-off logged to `.mm/ask-audit.jsonl` |
144 | `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results |145 | `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results |
145 | `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results |146 | `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results |
146 147
added demos/hooks-ask/README.en.md +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+# Hooks around agent calls — a help desk and its expert
2+
3+*[Version française](README.fr.md)*
4+
5+An agent can hand a question to another one: the model calls the `ask_agent` tool, you type `/ask-<name>`, or a script runs `mm --ask <name>`. Two hook events surround every such hand-off, in the **asking** agent's file: `preAsk` before the question leaves, `postAsk` when the answer comes back. This demo puts four Node.js hooks around a help desk that hands every technical question to an expert agent:
6+
7+| Hook | Event | What it shows |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preAsk` (`onError: fail`) | **rewrite** what leaves: passwords, API keys and tokens are replaced by `[REDACTED]` in the question and in the conversation sent along |
10+| `hooks/budget.js` | `preGeneration` + `preAsk` | **deny** a hand-off: at most 2 `ask_agent` calls per question; the counter is reset by `preGeneration` |
11+| `hooks/house-rules.js` | `preAsk` (`matcher: ^expert$`) | **add context** for the asked agent: answer in the question's language, 120 words, one code block at most |
12+| `hooks/after-ask.js` | `postAsk` | **observe and replace**: one JSON line per hand-off in `.mm/ask-audit.jsonl`; a failed hand-off becomes an instruction for the help desk's model |
13+
14+The expert (`agent.expert.yaml`) has no hooks and knows nothing of these rules.
15+
16+Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 GB) on **llmman**, for both agents — one model, loaded once.
17+
18+## Run
19+
20+Prerequisites: [llmman](https://llmmanorg.github.io/) running, and Node.js on the PATH.
21+
22+```bash
23+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
24+cd demos/hooks-ask
25+mm
26+```
27+
28+The banner lists the hooks:
29+
30+```
31+… | hooks: preGeneration×1 preAsk×3 postAsk×1
32+```
33+
34+## Try it
35+
36+Each case below was run on 2026-10-15 with `mm -p "<question>"` from `demos/hooks-ask` (cases 5 and 6 as shown). Both agents are Gemma 4 E4B; each hand-off took 25–31 s.
37+
38+**1. An ordinary hand-off.** `Qu'est-ce qu'un index dans une base de données ?` — the help desk calls `ask_agent`; the expert's reasoning lists the rules `house-rules.js` added ("Answer in the language of the question (French)", "At most 120 words", "no greetings"); it answers in French; the help desk sums it up in five sentences. `.mm/ask-audit.jsonl`:
39+
40+```json
41+{"at":"…","agent":"expert","via":"tool","prompt":"Qu'est-ce qu'un index dans une base de données ?","answerLength":852,"durationMs":24403}
42+```
43+
44+**2. A secret does not leave.** `psql refuse ma connexion avec password: hunter2 et l'utilisateur admin. Pourquoi ?` — the help desk reads the password (it is its own user's question), the expert receives `password: [REDACTED]`, and so does the audit log:
45+
46+```json
47+{"agent":"expert","via":"tool","prompt":"psql refuse ma connexion avec password: [REDACTED] et l'utilisateur admin. Pourquoi ?",…}
48+```
49+
50+**3. The budget.** `Pose trois questions séparées à l'expert, une par appel : qu'est-ce que TCP ? qu'est-ce que UDP ? qu'est-ce que QUIC ?` — two hand-offs go through; the third is denied before the expert is asked:
51+
52+```
53+🗣 ask_agent: expert (llmman/…) — Qu'est-ce que QUIC ?
54+🪝 ask_agent [ask denied by a hook: the budget of 2 hand-offs per question is spent — answer with what you have]
55+```
56+
57+The help desk answers TCP and UDP from the expert, and for QUIC: "l'expert n'a pas pu répondre à cette question car le budget d'appels … a été atteint". The audit log has two lines: a denied hand-off never reaches `postAsk`.
58+
59+**4. The expert is down.** Run with a copy of the demo whose `agent.expert.yaml` names a model llmman does not have. The ask fails with a server error 500; `after-ask.js` replaces it with an instruction:
60+
61+```
62+🪝 postAsk: expert's answer was replaced by a hook — the asking agent reads the new one
63+🤖 Je suis désolé, mais l'agent expert n'est pas disponible pour le moment.
64+```
65+
66+Without the hook, the model reads `[ask_agent: expert failed: llmman: server error 500 — …]` and decides alone what to do with it.
67+
68+**5. From a script.** `mm --ask expert -p "Explique chmod 755 en une phrase. Mon token: ghp_…"` — stdout holds the answer alone; the audit log records `"via":"cli"` and `Mon token: [REDACTED]`. With a `postAsk` hook declared, `mm --ask` prints the answer once the hooks have run.
69+
70+**6. Typed by you.** In the REPL, `/ask-expert Qu'est-ce qu'un inode ?` — `"via":"command"`: the budget does not count it, and `house-rules.js` does not add "no greetings", since a person reads the answer.
71+
72+## The configuration
73+
74+```yaml
75+mmConfigs:
76+ expert:
77+ path: agent.expert.yaml
78+ description: Technical expert. … For every technical question.
79+ yolo: true
80+
81+hooks:
82+ preGeneration:
83+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # resets the counter
84+ preAsk:
85+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
86+ onError: fail # a guard that cannot run must not let the question out
87+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # counts, denies past 2
88+ - matcher: ^expert$ # a regular expression on the asked agent's name
89+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
90+ postAsk:
91+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
92+```
93+
94+The `preAsk` hooks run in order, each one reading the question as the previous one left it: the rules are added to the question already masked.
95+
96+## Test a hook without mm
97+
98+```bash
99+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"my password: hunter2"}}' \
100+ | node hooks/no-secrets.js
101+# {"prompt":"my password: [REDACTED]","transcript":""}
102+```
103+
104+## Limits
105+
106+- **The answer streams before `postAsk` runs.** The user sees the expert's answer as it is written; `postAsk` changes what the help desk's model reads and keeps. A replacement is announced on the `🪝 postAsk` line.
107+- **The help desk's model still reads the secret** of case 2: `preAsk` guards what leaves for another agent, not what the user sends this one — a `preGeneration` hook does that (see `demos/hooks/`).
108+- **Gemma shows its reasoning**, the expert's included, which makes the transcript long; it is also where the added rules can be seen arriving.
109+
110+## Going further
111+
112+- Every field, decision and limit: [Hooks reference](../../docs/en/reference/hooks.md#preask).
113+- Step by step: [Control the hand-offs to other agents with hooks](../../docs/en/how-to/control-agent-calls-with-hooks.md).
114+- Why these hooks belong to the asking agent: [Hooks — explanation](../../docs/en/explanation/hooks.md#hooks-around-agent-calls).
new file mode 100644
@@ -0,0 +1,114 @@
1+# Hooks around agent calls — a help desk and its expert
2+
3+*[Version française](README.fr.md)*
4+
5+An agent can hand a question to another one: the model calls the `ask_agent` tool, you type `/ask-<name>`, or a script runs `mm --ask <name>`. Two hook events surround every such hand-off, in the **asking** agent's file: `preAsk` before the question leaves, `postAsk` when the answer comes back. This demo puts four Node.js hooks around a help desk that hands every technical question to an expert agent:
6+
7+| Hook | Event | What it shows |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preAsk` (`onError: fail`) | **rewrite** what leaves: passwords, API keys and tokens are replaced by `[REDACTED]` in the question and in the conversation sent along |
10+| `hooks/budget.js` | `preGeneration` + `preAsk` | **deny** a hand-off: at most 2 `ask_agent` calls per question; the counter is reset by `preGeneration` |
11+| `hooks/house-rules.js` | `preAsk` (`matcher: ^expert$`) | **add context** for the asked agent: answer in the question's language, 120 words, one code block at most |
12+| `hooks/after-ask.js` | `postAsk` | **observe and replace**: one JSON line per hand-off in `.mm/ask-audit.jsonl`; a failed hand-off becomes an instruction for the help desk's model |
13+
14+The expert (`agent.expert.yaml`) has no hooks and knows nothing of these rules.
15+
16+Model: **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 GB) on **llmman**, for both agents — one model, loaded once.
17+
18+## Run
19+
20+Prerequisites: [llmman](https://llmmanorg.github.io/) running, and Node.js on the PATH.
21+
22+```bash
23+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
24+cd demos/hooks-ask
25+mm
26+```
27+
28+The banner lists the hooks:
29+
30+```
31+… | hooks: preGeneration×1 preAsk×3 postAsk×1
32+```
33+
34+## Try it
35+
36+Each case below was run on 2026-10-15 with `mm -p "<question>"` from `demos/hooks-ask` (cases 5 and 6 as shown). Both agents are Gemma 4 E4B; each hand-off took 25–31 s.
37+
38+**1. An ordinary hand-off.** `Qu'est-ce qu'un index dans une base de données ?` — the help desk calls `ask_agent`; the expert's reasoning lists the rules `house-rules.js` added ("Answer in the language of the question (French)", "At most 120 words", "no greetings"); it answers in French; the help desk sums it up in five sentences. `.mm/ask-audit.jsonl`:
39+
40+```json
41+{"at":"…","agent":"expert","via":"tool","prompt":"Qu'est-ce qu'un index dans une base de données ?","answerLength":852,"durationMs":24403}
42+```
43+
44+**2. A secret does not leave.** `psql refuse ma connexion avec password: hunter2 et l'utilisateur admin. Pourquoi ?` — the help desk reads the password (it is its own user's question), the expert receives `password: [REDACTED]`, and so does the audit log:
45+
46+```json
47+{"agent":"expert","via":"tool","prompt":"psql refuse ma connexion avec password: [REDACTED] et l'utilisateur admin. Pourquoi ?",…}
48+```
49+
50+**3. The budget.** `Pose trois questions séparées à l'expert, une par appel : qu'est-ce que TCP ? qu'est-ce que UDP ? qu'est-ce que QUIC ?` — two hand-offs go through; the third is denied before the expert is asked:
51+
52+```
53+🗣 ask_agent: expert (llmman/…) — Qu'est-ce que QUIC ?
54+🪝 ask_agent [ask denied by a hook: the budget of 2 hand-offs per question is spent — answer with what you have]
55+```
56+
57+The help desk answers TCP and UDP from the expert, and for QUIC: "l'expert n'a pas pu répondre à cette question car le budget d'appels … a été atteint". The audit log has two lines: a denied hand-off never reaches `postAsk`.
58+
59+**4. The expert is down.** Run with a copy of the demo whose `agent.expert.yaml` names a model llmman does not have. The ask fails with a server error 500; `after-ask.js` replaces it with an instruction:
60+
61+```
62+🪝 postAsk: expert's answer was replaced by a hook — the asking agent reads the new one
63+🤖 Je suis désolé, mais l'agent expert n'est pas disponible pour le moment.
64+```
65+
66+Without the hook, the model reads `[ask_agent: expert failed: llmman: server error 500 — …]` and decides alone what to do with it.
67+
68+**5. From a script.** `mm --ask expert -p "Explique chmod 755 en une phrase. Mon token: ghp_…"` — stdout holds the answer alone; the audit log records `"via":"cli"` and `Mon token: [REDACTED]`. With a `postAsk` hook declared, `mm --ask` prints the answer once the hooks have run.
69+
70+**6. Typed by you.** In the REPL, `/ask-expert Qu'est-ce qu'un inode ?` — `"via":"command"`: the budget does not count it, and `house-rules.js` does not add "no greetings", since a person reads the answer.
71+
72+## The configuration
73+
74+```yaml
75+mmConfigs:
76+ expert:
77+ path: agent.expert.yaml
78+ description: Technical expert. … For every technical question.
79+ yolo: true
80+
81+hooks:
82+ preGeneration:
83+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # resets the counter
84+ preAsk:
85+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
86+ onError: fail # a guard that cannot run must not let the question out
87+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # counts, denies past 2
88+ - matcher: ^expert$ # a regular expression on the asked agent's name
89+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
90+ postAsk:
91+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
92+```
93+
94+The `preAsk` hooks run in order, each one reading the question as the previous one left it: the rules are added to the question already masked.
95+
96+## Test a hook without mm
97+
98+```bash
99+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"my password: hunter2"}}' \
100+ | node hooks/no-secrets.js
101+# {"prompt":"my password: [REDACTED]","transcript":""}
102+```
103+
104+## Limits
105+
106+- **The answer streams before `postAsk` runs.** The user sees the expert's answer as it is written; `postAsk` changes what the help desk's model reads and keeps. A replacement is announced on the `🪝 postAsk` line.
107+- **The help desk's model still reads the secret** of case 2: `preAsk` guards what leaves for another agent, not what the user sends this one — a `preGeneration` hook does that (see `demos/hooks/`).
108+- **Gemma shows its reasoning**, the expert's included, which makes the transcript long; it is also where the added rules can be seen arriving.
109+
110+## Going further
111+
112+- Every field, decision and limit: [Hooks reference](../../docs/en/reference/hooks.md#preask).
113+- Step by step: [Control the hand-offs to other agents with hooks](../../docs/en/how-to/control-agent-calls-with-hooks.md).
114+- Why these hooks belong to the asking agent: [Hooks — explanation](../../docs/en/explanation/hooks.md#hooks-around-agent-calls).
added demos/hooks-ask/README.fr.md +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+# Des hooks autour des appels d'agents — un accueil et son expert
2+
3+*[English version](README.en.md)*
4+
5+Un agent peut confier une question à un autre : le modèle appelle l'outil `ask_agent`, vous tapez `/ask-<nom>`, ou un script lance `mm --ask <nom>`. Deux événements de hooks encadrent chacun de ces relais, dans le fichier de l'agent **qui appelle** : `preAsk` avant que la question parte, `postAsk` quand la réponse revient. Cette démo place quatre hooks Node.js autour d'un accueil (« help desk ») qui confie chaque question technique à un agent expert :
6+
7+| Hook | Événement | Ce qu'il montre |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preAsk` (`onError: fail`) | **réécrire** ce qui part : mots de passe, clés d'API et jetons sont remplacés par `[REDACTED]` dans la question et dans la conversation envoyée |
10+| `hooks/budget.js` | `preGeneration` + `preAsk` | **refuser** un relais : 2 appels `ask_agent` au plus par question ; le compteur est remis à zéro par `preGeneration` |
11+| `hooks/house-rules.js` | `preAsk` (`matcher: ^expert$`) | **ajouter du contexte** pour l'agent appelé : répondre dans la langue de la question, 120 mots, un bloc de code au plus |
12+| `hooks/after-ask.js` | `postAsk` | **observer et remplacer** : une ligne JSON par relais dans `.mm/ask-audit.jsonl` ; un relais en échec devient une consigne pour le modèle de l'accueil |
13+
14+L'expert (`agent.expert.yaml`) n'a aucun hook et ne sait rien de ces règles.
15+
16+Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 Go) sur **llmman**, pour les deux agents — un seul modèle, chargé une fois.
17+
18+## Lancer
19+
20+Prérequis : [llmman](https://llmmanorg.github.io/) démarré, et Node.js dans le PATH.
21+
22+```bash
23+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
24+cd demos/hooks-ask
25+mm
26+```
27+
28+La bannière liste les hooks :
29+
30+```
31+… | hooks: preGeneration×1 preAsk×3 postAsk×1
32+```
33+
34+## Essayer
35+
36+Chaque cas ci-dessous a été exécuté le 2026-10-15 avec `mm -p "<question>"` depuis `demos/hooks-ask` (les cas 5 et 6 comme indiqué). Les deux agents sont sur Gemma 4 E4B ; chaque relais a pris de 25 à 31 s.
37+
38+**1. Un relais ordinaire.** `Qu'est-ce qu'un index dans une base de données ?` — l'accueil appelle `ask_agent` ; le raisonnement de l'expert reprend les règles ajoutées par `house-rules.js` (« Answer in the language of the question (French) », « At most 120 words », « no greetings ») ; il répond en français ; l'accueil résume en cinq phrases. `.mm/ask-audit.jsonl` :
39+
40+```json
41+{"at":"…","agent":"expert","via":"tool","prompt":"Qu'est-ce qu'un index dans une base de données ?","answerLength":852,"durationMs":24403}
42+```
43+
44+**2. Un secret ne sort pas.** `psql refuse ma connexion avec password: hunter2 et l'utilisateur admin. Pourquoi ?` — l'accueil lit le mot de passe (c'est la question de son propre utilisateur), l'expert reçoit `password: [REDACTED]`, et le journal aussi :
45+
46+```json
47+{"agent":"expert","via":"tool","prompt":"psql refuse ma connexion avec password: [REDACTED] et l'utilisateur admin. Pourquoi ?",…}
48+```
49+
50+**3. Le budget.** `Pose trois questions séparées à l'expert, une par appel : qu'est-ce que TCP ? qu'est-ce que UDP ? qu'est-ce que QUIC ?` — deux relais passent ; le troisième est refusé avant que l'expert soit interrogé :
51+
52+```
53+🗣 ask_agent: expert (llmman/…) — Qu'est-ce que QUIC ?
54+🪝 ask_agent [ask denied by a hook: the budget of 2 hand-offs per question is spent — answer with what you have]
55+```
56+
57+L'accueil répond pour TCP et UDP avec les réponses de l'expert, et pour QUIC : « l'expert n'a pas pu répondre à cette question car le budget d'appels … a été atteint ». Le journal a deux lignes : un relais refusé n'atteint jamais `postAsk`.
58+
59+**4. L'expert est en panne.** Avec une copie de la démo dont `agent.expert.yaml` nomme un modèle que llmman n'a pas. L'appel échoue avec une erreur serveur 500 ; `after-ask.js` la remplace par une consigne :
60+
61+```
62+🪝 postAsk: expert's answer was replaced by a hook — the asking agent reads the new one
63+🤖 Je suis désolé, mais l'agent expert n'est pas disponible pour le moment.
64+```
65+
66+Sans le hook, le modèle lit `[ask_agent: expert failed: llmman: server error 500 — …]` et décide seul quoi en faire.
67+
68+**5. Depuis un script.** `mm --ask expert -p "Explique chmod 755 en une phrase. Mon token: ghp_…"` — stdout ne contient que la réponse ; le journal enregistre `"via":"cli"` et `Mon token: [REDACTED]`. Avec un hook `postAsk` déclaré, `mm --ask` écrit la réponse une fois les hooks exécutés.
69+
70+**6. Tapé par vous.** Dans le REPL, `/ask-expert Qu'est-ce qu'un inode ?` — `"via":"command"` : le budget ne le compte pas, et `house-rules.js` n'ajoute pas « no greetings », puisque c'est une personne qui lit la réponse.
71+
72+## La configuration
73+
74+```yaml
75+mmConfigs:
76+ expert:
77+ path: agent.expert.yaml
78+ description: Technical expert. … For every technical question.
79+ yolo: true
80+
81+hooks:
82+ preGeneration:
83+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # remet le compteur à zéro
84+ preAsk:
85+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
86+ onError: fail # un garde-fou qui ne peut pas s'exécuter ne doit pas laisser partir la question
87+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # compte, refuse au-delà de 2
88+ - matcher: ^expert$ # une expression régulière sur le nom de l'agent appelé
89+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
90+ postAsk:
91+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
92+```
93+
94+Les hooks `preAsk` s'exécutent dans l'ordre, chacun lisant la question telle que le précédent l'a laissée : les règles sont ajoutées à la question déjà masquée.
95+
96+## Tester un hook sans mm
97+
98+```bash
99+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"my password: hunter2"}}' \
100+ | node hooks/no-secrets.js
101+# {"prompt":"my password: [REDACTED]","transcript":""}
102+```
103+
104+## Limites
105+
106+- **La réponse s'affiche avant que `postAsk` s'exécute.** L'utilisateur voit la réponse de l'expert au fil de l'écriture ; `postAsk` change ce que le modèle de l'accueil lit et garde. Un remplacement est annoncé par la ligne `🪝 postAsk`.
107+- **Le modèle de l'accueil lit toujours le secret** du cas 2 : `preAsk` protège ce qui part vers un autre agent, pas ce que l'utilisateur envoie à celui-ci — c'est le rôle d'un hook `preGeneration` (voir `demos/hooks/`).
108+- **Gemma affiche son raisonnement**, y compris celui de l'expert, ce qui allonge la conversation ; c'est aussi là qu'on voit arriver les règles ajoutées.
109+
110+## Aller plus loin
111+
112+- Chaque champ, décision et limite : [Référence des hooks](../../docs/fr/reference/hooks.md#preask).
113+- Pas à pas : [Contrôler les relais vers d'autres agents avec des hooks](../../docs/fr/how-to/control-agent-calls-with-hooks.md).
114+- Pourquoi ces hooks appartiennent à l'agent qui appelle : [Les hooks — explication](../../docs/fr/explanation/hooks.md#des-hooks-autour-des-appels-dagents).
new file mode 100644
@@ -0,0 +1,114 @@
1+# Des hooks autour des appels d'agents — un accueil et son expert
2+
3+*[English version](README.en.md)*
4+
5+Un agent peut confier une question à un autre : le modèle appelle l'outil `ask_agent`, vous tapez `/ask-<nom>`, ou un script lance `mm --ask <nom>`. Deux événements de hooks encadrent chacun de ces relais, dans le fichier de l'agent **qui appelle** : `preAsk` avant que la question parte, `postAsk` quand la réponse revient. Cette démo place quatre hooks Node.js autour d'un accueil (« help desk ») qui confie chaque question technique à un agent expert :
6+
7+| Hook | Événement | Ce qu'il montre |
8+|---|---|---|
9+| `hooks/no-secrets.js` | `preAsk` (`onError: fail`) | **réécrire** ce qui part : mots de passe, clés d'API et jetons sont remplacés par `[REDACTED]` dans la question et dans la conversation envoyée |
10+| `hooks/budget.js` | `preGeneration` + `preAsk` | **refuser** un relais : 2 appels `ask_agent` au plus par question ; le compteur est remis à zéro par `preGeneration` |
11+| `hooks/house-rules.js` | `preAsk` (`matcher: ^expert$`) | **ajouter du contexte** pour l'agent appelé : répondre dans la langue de la question, 120 mots, un bloc de code au plus |
12+| `hooks/after-ask.js` | `postAsk` | **observer et remplacer** : une ligne JSON par relais dans `.mm/ask-audit.jsonl` ; un relais en échec devient une consigne pour le modèle de l'accueil |
13+
14+L'expert (`agent.expert.yaml`) n'a aucun hook et ne sait rien de ces règles.
15+
16+Modèle : **Gemma 4 E4B** (`hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M`, ≈ 6 Go) sur **llmman**, pour les deux agents — un seul modèle, chargé une fois.
17+
18+## Lancer
19+
20+Prérequis : [llmman](https://llmmanorg.github.io/) démarré, et Node.js dans le PATH.
21+
22+```bash
23+llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
24+cd demos/hooks-ask
25+mm
26+```
27+
28+La bannière liste les hooks :
29+
30+```
31+… | hooks: preGeneration×1 preAsk×3 postAsk×1
32+```
33+
34+## Essayer
35+
36+Chaque cas ci-dessous a été exécuté le 2026-10-15 avec `mm -p "<question>"` depuis `demos/hooks-ask` (les cas 5 et 6 comme indiqué). Les deux agents sont sur Gemma 4 E4B ; chaque relais a pris de 25 à 31 s.
37+
38+**1. Un relais ordinaire.** `Qu'est-ce qu'un index dans une base de données ?` — l'accueil appelle `ask_agent` ; le raisonnement de l'expert reprend les règles ajoutées par `house-rules.js` (« Answer in the language of the question (French) », « At most 120 words », « no greetings ») ; il répond en français ; l'accueil résume en cinq phrases. `.mm/ask-audit.jsonl` :
39+
40+```json
41+{"at":"…","agent":"expert","via":"tool","prompt":"Qu'est-ce qu'un index dans une base de données ?","answerLength":852,"durationMs":24403}
42+```
43+
44+**2. Un secret ne sort pas.** `psql refuse ma connexion avec password: hunter2 et l'utilisateur admin. Pourquoi ?` — l'accueil lit le mot de passe (c'est la question de son propre utilisateur), l'expert reçoit `password: [REDACTED]`, et le journal aussi :
45+
46+```json
47+{"agent":"expert","via":"tool","prompt":"psql refuse ma connexion avec password: [REDACTED] et l'utilisateur admin. Pourquoi ?",…}
48+```
49+
50+**3. Le budget.** `Pose trois questions séparées à l'expert, une par appel : qu'est-ce que TCP ? qu'est-ce que UDP ? qu'est-ce que QUIC ?` — deux relais passent ; le troisième est refusé avant que l'expert soit interrogé :
51+
52+```
53+🗣 ask_agent: expert (llmman/…) — Qu'est-ce que QUIC ?
54+🪝 ask_agent [ask denied by a hook: the budget of 2 hand-offs per question is spent — answer with what you have]
55+```
56+
57+L'accueil répond pour TCP et UDP avec les réponses de l'expert, et pour QUIC : « l'expert n'a pas pu répondre à cette question car le budget d'appels … a été atteint ». Le journal a deux lignes : un relais refusé n'atteint jamais `postAsk`.
58+
59+**4. L'expert est en panne.** Avec une copie de la démo dont `agent.expert.yaml` nomme un modèle que llmman n'a pas. L'appel échoue avec une erreur serveur 500 ; `after-ask.js` la remplace par une consigne :
60+
61+```
62+🪝 postAsk: expert's answer was replaced by a hook — the asking agent reads the new one
63+🤖 Je suis désolé, mais l'agent expert n'est pas disponible pour le moment.
64+```
65+
66+Sans le hook, le modèle lit `[ask_agent: expert failed: llmman: server error 500 — …]` et décide seul quoi en faire.
67+
68+**5. Depuis un script.** `mm --ask expert -p "Explique chmod 755 en une phrase. Mon token: ghp_…"` — stdout ne contient que la réponse ; le journal enregistre `"via":"cli"` et `Mon token: [REDACTED]`. Avec un hook `postAsk` déclaré, `mm --ask` écrit la réponse une fois les hooks exécutés.
69+
70+**6. Tapé par vous.** Dans le REPL, `/ask-expert Qu'est-ce qu'un inode ?` — `"via":"command"` : le budget ne le compte pas, et `house-rules.js` n'ajoute pas « no greetings », puisque c'est une personne qui lit la réponse.
71+
72+## La configuration
73+
74+```yaml
75+mmConfigs:
76+ expert:
77+ path: agent.expert.yaml
78+ description: Technical expert. … For every technical question.
79+ yolo: true
80+
81+hooks:
82+ preGeneration:
83+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # remet le compteur à zéro
84+ preAsk:
85+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
86+ onError: fail # un garde-fou qui ne peut pas s'exécuter ne doit pas laisser partir la question
87+ - command: node "$MM_CONFIG_DIR/hooks/budget.js" # compte, refuse au-delà de 2
88+ - matcher: ^expert$ # une expression régulière sur le nom de l'agent appelé
89+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
90+ postAsk:
91+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
92+```
93+
94+Les hooks `preAsk` s'exécutent dans l'ordre, chacun lisant la question telle que le précédent l'a laissée : les règles sont ajoutées à la question déjà masquée.
95+
96+## Tester un hook sans mm
97+
98+```bash
99+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"my password: hunter2"}}' \
100+ | node hooks/no-secrets.js
101+# {"prompt":"my password: [REDACTED]","transcript":""}
102+```
103+
104+## Limites
105+
106+- **La réponse s'affiche avant que `postAsk` s'exécute.** L'utilisateur voit la réponse de l'expert au fil de l'écriture ; `postAsk` change ce que le modèle de l'accueil lit et garde. Un remplacement est annoncé par la ligne `🪝 postAsk`.
107+- **Le modèle de l'accueil lit toujours le secret** du cas 2 : `preAsk` protège ce qui part vers un autre agent, pas ce que l'utilisateur envoie à celui-ci — c'est le rôle d'un hook `preGeneration` (voir `demos/hooks/`).
108+- **Gemma affiche son raisonnement**, y compris celui de l'expert, ce qui allonge la conversation ; c'est aussi là qu'on voit arriver les règles ajoutées.
109+
110+## Aller plus loin
111+
112+- Chaque champ, décision et limite : [Référence des hooks](../../docs/fr/reference/hooks.md#preask).
113+- Pas à pas : [Contrôler les relais vers d'autres agents avec des hooks](../../docs/fr/how-to/control-agent-calls-with-hooks.md).
114+- Pourquoi ces hooks appartiennent à l'agent qui appelle : [Les hooks — explication](../../docs/fr/explanation/hooks.md#des-hooks-autour-des-appels-dagents).
added demos/hooks-ask/agent.expert.yaml +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+# The expert asked by the help desk (agent.yaml) through `ask_agent`,
2+# `/ask-expert` or `mm --ask expert` — or used on its own: `mm agent.expert.yaml`.
3+# No tools, no hooks: what changes its questions and its answers are the
4+# help desk's preAsk and postAsk hooks.
5+
6+provider: llmman
7+model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+
11+sampling:
12+ temperature: 0.3
13+ max_tokens: 2048
14+
15+firstTokenTimeout: 3m
16+watchdogTimeout: 3m
17+maxTurns: 2
18+
19+yolo: false
20+bashTool: false
21+editTools: false
22+planTool: false
23+fetchContentTool: false
24+askUserTool: false
25+skillsDir: no-skills
26+
27+agentsMd:
28+ enabled: false
29+
30+sessions:
31+ enabled: false
32+
33+acp:
34+ name: expert
35+ title: 🎓 Technical expert
36+
37+system: |
38+ You are a technical expert. Answer the question you are given clearly and
39+ correctly. Follow every rule written after "[context added by a hook]".
new file mode 100644
@@ -0,0 +1,39 @@
1+# The expert asked by the help desk (agent.yaml) through `ask_agent`,
2+# `/ask-expert` or `mm --ask expert` — or used on its own: `mm agent.expert.yaml`.
3+# No tools, no hooks: what changes its questions and its answers are the
4+# help desk's preAsk and postAsk hooks.
5+
6+provider: llmman
7+model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+
11+sampling:
12+ temperature: 0.3
13+ max_tokens: 2048
14+
15+firstTokenTimeout: 3m
16+watchdogTimeout: 3m
17+maxTurns: 2
18+
19+yolo: false
20+bashTool: false
21+editTools: false
22+planTool: false
23+fetchContentTool: false
24+askUserTool: false
25+skillsDir: no-skills
26+
27+agentsMd:
28+ enabled: false
29+
30+sessions:
31+ enabled: false
32+
33+acp:
34+ name: expert
35+ title: 🎓 Technical expert
36+
37+system: |
38+ You are a technical expert. Answer the question you are given clearly and
39+ correctly. Follow every rule written after "[context added by a hook]".
added demos/hooks-ask/agent.yaml +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+# Hooks around agent calls — a help desk that hands technical questions to
2+# an expert agent, with four Node.js hooks on the hand-off:
3+#
4+# hooks/budget.js preGeneration resets the hand-off counter
5+# preAsk at most 2 hand-offs per question
6+# hooks/no-secrets.js preAsk masks secrets in what leaves
7+# hooks/house-rules.js preAsk adds the expert's standing rules
8+# hooks/after-ask.js postAsk audit log + a failure turned into
9+# an instruction
10+#
11+# The hooks are the HELP DESK's (this file): the expert (agent.expert.yaml)
12+# knows nothing about them.
13+#
14+# llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
15+# cd demos/hooks-ask
16+# mm (reads ./agent.yaml)
17+#
18+# Every key is optional; reference: docs/en/reference/configuration.md.
19+
20+provider: llmman
21+model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
22+baseUrl: http://127.0.0.1:17434/v1
23+fallback: http://host.docker.internal:17434/v1
24+
25+mmConfigs:
26+ expert:
27+ path: agent.expert.yaml
28+ description: >-
29+ Technical expert. Explains programming, networks, databases, Linux and
30+ any other technical subject. For every technical question.
31+ yolo: true
32+
33+sampling:
34+ temperature: 0.2
35+ parallel_tool_calls: false
36+
37+firstTokenTimeout: 3m
38+watchdogTimeout: 5m
39+maxTurns: 4
40+
41+yolo: true
42+bashTool: false
43+editTools: false
44+planTool: false
45+fetchContentTool: false
46+askUserTool: false
47+skillsDir: no-skills
48+
49+agentsMd:
50+ enabled: false
51+
52+sessions:
53+ enabled: false
54+
55+acp:
56+ name: hooks-ask-demo
57+ title: 🪝 Help desk (hooks around agent calls)
58+
59+hooks:
60+ preGeneration:
61+ - command: node "$MM_CONFIG_DIR/hooks/budget.js"
62+ timeout: 5s
63+ preAsk:
64+ # onError: fail — a guard that cannot run must not let the question out.
65+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
66+ timeout: 5s
67+ onError: fail
68+ - command: node "$MM_CONFIG_DIR/hooks/budget.js"
69+ timeout: 5s
70+ - matcher: ^expert$
71+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
72+ timeout: 5s
73+ postAsk:
74+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
75+ timeout: 5s
76+
77+system: |
78+ You are the help desk. You do not answer technical questions yourself.
79+ For any technical question, call the ask_agent tool once with:
80+ - agent: expert
81+ - prompt: the user's question, complete and self-contained.
82+ Then give the user the expert's answer, in the user's language, in at most
83+ five sentences.
84+ If the tool result starts with "[ask denied by a hook", tell the user why
85+ in one sentence and do not call the tool again.
86+ When the user only greets you, greet back.
new file mode 100644
@@ -0,0 +1,86 @@
1+# Hooks around agent calls — a help desk that hands technical questions to
2+# an expert agent, with four Node.js hooks on the hand-off:
3+#
4+# hooks/budget.js preGeneration resets the hand-off counter
5+# preAsk at most 2 hand-offs per question
6+# hooks/no-secrets.js preAsk masks secrets in what leaves
7+# hooks/house-rules.js preAsk adds the expert's standing rules
8+# hooks/after-ask.js postAsk audit log + a failure turned into
9+# an instruction
10+#
11+# The hooks are the HELP DESK's (this file): the expert (agent.expert.yaml)
12+# knows nothing about them.
13+#
14+# llmman pull hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
15+# cd demos/hooks-ask
16+# mm (reads ./agent.yaml)
17+#
18+# Every key is optional; reference: docs/en/reference/configuration.md.
19+
20+provider: llmman
21+model: hf.co/unsloth/gemma-4-E4B-it-GGUF:Q4_K_M
22+baseUrl: http://127.0.0.1:17434/v1
23+fallback: http://host.docker.internal:17434/v1
24+
25+mmConfigs:
26+ expert:
27+ path: agent.expert.yaml
28+ description: >-
29+ Technical expert. Explains programming, networks, databases, Linux and
30+ any other technical subject. For every technical question.
31+ yolo: true
32+
33+sampling:
34+ temperature: 0.2
35+ parallel_tool_calls: false
36+
37+firstTokenTimeout: 3m
38+watchdogTimeout: 5m
39+maxTurns: 4
40+
41+yolo: true
42+bashTool: false
43+editTools: false
44+planTool: false
45+fetchContentTool: false
46+askUserTool: false
47+skillsDir: no-skills
48+
49+agentsMd:
50+ enabled: false
51+
52+sessions:
53+ enabled: false
54+
55+acp:
56+ name: hooks-ask-demo
57+ title: 🪝 Help desk (hooks around agent calls)
58+
59+hooks:
60+ preGeneration:
61+ - command: node "$MM_CONFIG_DIR/hooks/budget.js"
62+ timeout: 5s
63+ preAsk:
64+ # onError: fail — a guard that cannot run must not let the question out.
65+ - command: node "$MM_CONFIG_DIR/hooks/no-secrets.js"
66+ timeout: 5s
67+ onError: fail
68+ - command: node "$MM_CONFIG_DIR/hooks/budget.js"
69+ timeout: 5s
70+ - matcher: ^expert$
71+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
72+ timeout: 5s
73+ postAsk:
74+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
75+ timeout: 5s
76+
77+system: |
78+ You are the help desk. You do not answer technical questions yourself.
79+ For any technical question, call the ask_agent tool once with:
80+ - agent: expert
81+ - prompt: the user's question, complete and self-contained.
82+ Then give the user the expert's answer, in the user's language, in at most
83+ five sentences.
84+ If the tool result starts with "[ask denied by a hook", tell the user why
85+ in one sentence and do not call the tool again.
86+ When the user only greets you, greet back.
added demos/hooks-ask/hooks/after-ask.js +37 -0
new file mode 100644
@@ -0,0 +1,37 @@
1+#!/usr/bin/env node
2+// postAsk hook: runs when the expert's answer comes back to the help desk.
3+//
4+// - writes one JSON line per hand-off to .mm/ask-audit.jsonl: who was
5+// asked, how, the question as sent, the answer's length, how long it
6+// took, the error if any;
7+// - turns a failed hand-off into an instruction for the help desk's model,
8+// by replacing the answer: rather than "[ask_agent: expert failed: …]",
9+// the model reads what to do about it.
10+//
11+// The answer has already streamed to the user: replacing it changes what the
12+// help desk's model reads and keeps, not what was shown.
13+
14+const fs = require("fs");
15+const path = require("path");
16+const input = JSON.parse(fs.readFileSync(0, "utf8"));
17+
18+const line = {
19+ at: new Date().toISOString(),
20+ agent: input.ask.agent,
21+ via: input.ask.via,
22+ prompt: input.ask.prompt,
23+ answerLength: (input.answer || "").length,
24+ durationMs: input.durationMs,
25+};
26+if (input.error) line.error = input.error;
27+
28+const dir = path.join(input.cwd, ".mm");
29+fs.mkdirSync(dir, { recursive: true });
30+fs.appendFileSync(path.join(dir, "ask-audit.jsonl"), JSON.stringify(line) + "\n");
31+
32+if (input.error && !(input.answer || "").trim()) {
33+ console.log(JSON.stringify({
34+ answer: `The ${input.ask.agent} agent is unavailable (${input.error.slice(0, 120)}). ` +
35+ "Tell the user, in one sentence, that the expert cannot be reached right now, and do not answer the question yourself.",
36+ }));
37+}
new file mode 100644
@@ -0,0 +1,37 @@
1+#!/usr/bin/env node
2+// postAsk hook: runs when the expert's answer comes back to the help desk.
3+//
4+// - writes one JSON line per hand-off to .mm/ask-audit.jsonl: who was
5+// asked, how, the question as sent, the answer's length, how long it
6+// took, the error if any;
7+// - turns a failed hand-off into an instruction for the help desk's model,
8+// by replacing the answer: rather than "[ask_agent: expert failed: …]",
9+// the model reads what to do about it.
10+//
11+// The answer has already streamed to the user: replacing it changes what the
12+// help desk's model reads and keeps, not what was shown.
13+
14+const fs = require("fs");
15+const path = require("path");
16+const input = JSON.parse(fs.readFileSync(0, "utf8"));
17+
18+const line = {
19+ at: new Date().toISOString(),
20+ agent: input.ask.agent,
21+ via: input.ask.via,
22+ prompt: input.ask.prompt,
23+ answerLength: (input.answer || "").length,
24+ durationMs: input.durationMs,
25+};
26+if (input.error) line.error = input.error;
27+
28+const dir = path.join(input.cwd, ".mm");
29+fs.mkdirSync(dir, { recursive: true });
30+fs.appendFileSync(path.join(dir, "ask-audit.jsonl"), JSON.stringify(line) + "\n");
31+
32+if (input.error && !(input.answer || "").trim()) {
33+ console.log(JSON.stringify({
34+ answer: `The ${input.ask.agent} agent is unavailable (${input.error.slice(0, 120)}). ` +
35+ "Tell the user, in one sentence, that the expert cannot be reached right now, and do not answer the question yourself.",
36+ }));
37+}
added demos/hooks-ask/hooks/budget.js +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+#!/usr/bin/env node
2+// preGeneration + preAsk hook: at most MAX hand-offs per question.
3+//
4+// A small model may call ask_agent again and again for one question. The
5+// counter lives in .mm/ask-budget.json in the working directory:
6+// - preGeneration (a new question) resets it;
7+// - preAsk counts one hand-off, and denies the hand-off once MAX is reached.
8+// Only the model's hand-offs ("via":"tool", the ask_agent tool) count: one
9+// typed by the user (/ask-expert) or run by a script (mm --ask) is not
10+// limited.
11+
12+const fs = require("fs");
13+const path = require("path");
14+
15+const MAX = 2;
16+const input = JSON.parse(fs.readFileSync(0, "utf8"));
17+const dir = path.join(input.cwd, ".mm");
18+const file = path.join(dir, "ask-budget.json");
19+fs.mkdirSync(dir, { recursive: true });
20+
21+if (input.event === "preGeneration") {
22+ fs.writeFileSync(file, JSON.stringify({ count: 0 }));
23+ process.exit(0);
24+}
25+
26+if (input.ask.via !== "tool") process.exit(0);
27+
28+let count = 0;
29+try {
30+ count = JSON.parse(fs.readFileSync(file, "utf8")).count;
31+} catch {
32+ // no file yet: nothing counted
33+}
34+if (count >= MAX) {
35+ console.log(JSON.stringify({
36+ action: "deny",
37+ reason: `the budget of ${MAX} hand-offs per question is spent — answer with what you have`,
38+ }));
39+ process.exit(0);
40+}
41+fs.writeFileSync(file, JSON.stringify({ count: count + 1 }));
new file mode 100644
@@ -0,0 +1,41 @@
1+#!/usr/bin/env node
2+// preGeneration + preAsk hook: at most MAX hand-offs per question.
3+//
4+// A small model may call ask_agent again and again for one question. The
5+// counter lives in .mm/ask-budget.json in the working directory:
6+// - preGeneration (a new question) resets it;
7+// - preAsk counts one hand-off, and denies the hand-off once MAX is reached.
8+// Only the model's hand-offs ("via":"tool", the ask_agent tool) count: one
9+// typed by the user (/ask-expert) or run by a script (mm --ask) is not
10+// limited.
11+
12+const fs = require("fs");
13+const path = require("path");
14+
15+const MAX = 2;
16+const input = JSON.parse(fs.readFileSync(0, "utf8"));
17+const dir = path.join(input.cwd, ".mm");
18+const file = path.join(dir, "ask-budget.json");
19+fs.mkdirSync(dir, { recursive: true });
20+
21+if (input.event === "preGeneration") {
22+ fs.writeFileSync(file, JSON.stringify({ count: 0 }));
23+ process.exit(0);
24+}
25+
26+if (input.ask.via !== "tool") process.exit(0);
27+
28+let count = 0;
29+try {
30+ count = JSON.parse(fs.readFileSync(file, "utf8")).count;
31+} catch {
32+ // no file yet: nothing counted
33+}
34+if (count >= MAX) {
35+ console.log(JSON.stringify({
36+ action: "deny",
37+ reason: `the budget of ${MAX} hand-offs per question is spent — answer with what you have`,
38+ }));
39+ process.exit(0);
40+}
41+fs.writeFileSync(file, JSON.stringify({ count: count + 1 }));
added demos/hooks-ask/hooks/house-rules.js +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+#!/usr/bin/env node
2+// preAsk hook (matcher: ^expert$): the expert's standing rules.
3+//
4+// The help desk's author cannot always change the expert's own system
5+// prompt — it may be someone else's file. This hook adds the rules to every
6+// question handed to the expert, as `context`: mm appends them after the
7+// question, under "[context added by a hook]".
8+
9+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
10+
11+const rules = [
12+ "Answer in the language of the question.",
13+ "At most 120 words.",
14+ "At most one code block, only if it helps.",
15+];
16+// Through the ask_agent tool, the answer goes to the help desk's model, not
17+// straight to a person.
18+if (input.ask.via === "tool") rules.push("Your answer is read by another assistant, not by a person: no greetings, no questions back.");
19+
20+console.log(JSON.stringify({ context: rules.join("\n") }));
new file mode 100644
@@ -0,0 +1,20 @@
1+#!/usr/bin/env node
2+// preAsk hook (matcher: ^expert$): the expert's standing rules.
3+//
4+// The help desk's author cannot always change the expert's own system
5+// prompt — it may be someone else's file. This hook adds the rules to every
6+// question handed to the expert, as `context`: mm appends them after the
7+// question, under "[context added by a hook]".
8+
9+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
10+
11+const rules = [
12+ "Answer in the language of the question.",
13+ "At most 120 words.",
14+ "At most one code block, only if it helps.",
15+];
16+// Through the ask_agent tool, the answer goes to the help desk's model, not
17+// straight to a person.
18+if (input.ask.via === "tool") rules.push("Your answer is read by another assistant, not by a person: no greetings, no questions back.");
19+
20+console.log(JSON.stringify({ context: rules.join("\n") }));
added demos/hooks-ask/hooks/no-secrets.js +32 -0
new file mode 100644
@@ -0,0 +1,32 @@
1+#!/usr/bin/env node
2+// preAsk hook: masks secrets in what leaves for the other agent.
3+//
4+// The question and, with with_context, the conversation are handed to
5+// another agent — possibly another model, another provider. This hook does
6+// not stop the hand-off: it answers {"prompt": …, "transcript": …} with each
7+// secret replaced by [REDACTED] when it found one, and nothing otherwise
8+// ("continue").
9+
10+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
11+
12+// Each pattern with what replaces it: a password keeps its label.
13+const secrets = [
14+ [/\b(password|passwd|pwd|mot de passe)(\s*[:=]\s*)\S+/gi, "$1$2[REDACTED]"],
15+ [/\bsk-[A-Za-z0-9_-]{20,}/g, "[REDACTED]"],
16+ [/\bgh[pousr]_[A-Za-z0-9]{30,}/g, "[REDACTED]"],
17+ [/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]"],
18+];
19+
20+function mask(text) {
21+ let out = text || "";
22+ for (const [pattern, replacement] of secrets) {
23+ out = out.replace(pattern, replacement);
24+ }
25+ return out;
26+}
27+
28+const prompt = mask(input.ask.prompt);
29+const transcript = mask(input.ask.transcript);
30+if (prompt !== input.ask.prompt || transcript !== (input.ask.transcript || "")) {
31+ console.log(JSON.stringify({ prompt, transcript }));
32+}
new file mode 100644
@@ -0,0 +1,32 @@
1+#!/usr/bin/env node
2+// preAsk hook: masks secrets in what leaves for the other agent.
3+//
4+// The question and, with with_context, the conversation are handed to
5+// another agent — possibly another model, another provider. This hook does
6+// not stop the hand-off: it answers {"prompt": …, "transcript": …} with each
7+// secret replaced by [REDACTED] when it found one, and nothing otherwise
8+// ("continue").
9+
10+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
11+
12+// Each pattern with what replaces it: a password keeps its label.
13+const secrets = [
14+ [/\b(password|passwd|pwd|mot de passe)(\s*[:=]\s*)\S+/gi, "$1$2[REDACTED]"],
15+ [/\bsk-[A-Za-z0-9_-]{20,}/g, "[REDACTED]"],
16+ [/\bgh[pousr]_[A-Za-z0-9]{30,}/g, "[REDACTED]"],
17+ [/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED]"],
18+];
19+
20+function mask(text) {
21+ let out = text || "";
22+ for (const [pattern, replacement] of secrets) {
23+ out = out.replace(pattern, replacement);
24+ }
25+ return out;
26+}
27+
28+const prompt = mask(input.ask.prompt);
29+const transcript = mask(input.ask.transcript);
30+if (prompt !== input.ask.prompt || transcript !== (input.ask.transcript || "")) {
31+ console.log(JSON.stringify({ prompt, transcript }));
32+}
modified docs/diagrams/packages.drawio +1 -1
@@ -1,3 +1,3 @@
11 <mxfile host="app.diagrams.net" modified="2026-10-06T00: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="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" 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="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" 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="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" 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="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" 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="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" 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="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" 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="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" 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="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" 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="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" 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="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" 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="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" 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="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" 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="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" 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="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" 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="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" 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="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" 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="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" 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="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" 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>
22 <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="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" 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="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" 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="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" 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="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" 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="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" 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="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" 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="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" 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="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" 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="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." 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="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" 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="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" 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="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" 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><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><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="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" 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="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" 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="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" 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="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" 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="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" 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="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" 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="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" 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="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" 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="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." 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="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" 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="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" 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="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" 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><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><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-10-06T00: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="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" 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="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" 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="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" 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="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" 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="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" 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="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" 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="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" 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="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" 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="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" 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="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" 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="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" 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="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" 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="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" 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="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" 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="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" 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="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" 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="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" 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="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" 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-10-06T00: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="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" 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="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" 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="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" 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="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" 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="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" 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="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" 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="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" 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="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" 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="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" 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="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" 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="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" 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="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" 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="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" 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="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" 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="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" 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="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" 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="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" 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="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" 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="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" 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="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" 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="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" 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="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" 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="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" 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="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" 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="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" 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="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" 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="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." 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="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" 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="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" 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="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" 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><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><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="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" 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="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" 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="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" 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="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" 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="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" 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="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" 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="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" 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="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" 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="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." 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="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" 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="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" 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="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" 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><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><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 +3 -2
@@ -36,6 +36,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
3636 - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53
3737 - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it
3838 - [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language
39+- [Control the hand-offs to other agents with hooks](how-to/control-agent-calls-with-hooks.md) — refuse, rewrite or mask what an agent sends to another one, check or replace what comes back (`preAsk`, `postAsk`)
3940
4041 ### MCP (Model Context Protocol) How-to
4142 - [Use Context7 for documentation](how-to/mcp-use-context7.md)
@@ -49,7 +50,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
4950 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
5051 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
5152 - [Skill file format](reference/skill-format.md)
52-- [Hooks](reference/hooks.md) — the four events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
53+- [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
5354 - [Agent Client Protocol surface](reference/acp.md)
5455
5556 ### MCP (Model Context Protocol) Reference
@@ -71,7 +72,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
7172 - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer
7273 - [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, `ask_agent` and per-agent `yolo`, the depth limit
7374 - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap
74-- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out
75+- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out, why `preAsk`/`postAsk` belong to the asking agent
7576 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
7677
7778 ### MCP (Model Context Protocol) Explanation
@@ -36,6 +36,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
36 - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 5336 - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53
37 - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it37 - [Fetch a web page as Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — read a page, or save it to a file without showing it
38 - [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language38 - [Guard the agent with hooks](how-to/guard-the-agent-with-hooks.md) — deny a tool call, stop or rewrite a question, add context, log every turn, with a script in any language
39+- [Control the hand-offs to other agents with hooks](how-to/control-agent-calls-with-hooks.md) — refuse, rewrite or mask what an agent sends to another one, check or replace what comes back (`preAsk`, `postAsk`)
39 40
40 ### MCP (Model Context Protocol) How-to41 ### MCP (Model Context Protocol) How-to
41 - [Use Context7 for documentation](how-to/mcp-use-context7.md)42 - [Use Context7 for documentation](how-to/mcp-use-context7.md)
@@ -49,7 +50,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
49 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule50 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
50 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`51 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
51 - [Skill file format](reference/skill-format.md)52 - [Skill file format](reference/skill-format.md)
52-- [Hooks](reference/hooks.md) — the four events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors53+- [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
53 - [Agent Client Protocol surface](reference/acp.md)54 - [Agent Client Protocol surface](reference/acp.md)
54 55
55 ### MCP (Model Context Protocol) Reference56 ### MCP (Model Context Protocol) Reference
@@ -71,7 +72,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
71 - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer72 - [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer
72 - [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, `ask_agent` and per-agent `yolo`, the depth limit73 - [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, `ask_agent` and per-agent `yolo`, the depth limit
73 - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap74 - [Asking the user](explanation/asking-the-user.md) — why `ask_user` asks each front end its own way, how a called agent's questions come up, the loose schema (llama.cpp's alphabetical grammar), the watchdog while a human answers, the ACP SDK gap
74-- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out75+- [Hooks](explanation/hooks.md) — why external commands, why they plug into `engine.Generate` and `WrapTool`, why a stopped question leaves a marker, what ticket 36 left out, why `preAsk`/`postAsk` belong to the asking agent
75 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)76 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
76 77
77 ### MCP (Model Context Protocol) Explanation78 ### MCP (Model Context Protocol) Explanation
modified docs/en/explanation/architecture.md +2 -2
@@ -17,7 +17,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
1717 | `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`. |
1818 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
1919 | `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. |
20-| `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; the same pool serves the `ask_agent` tool and `mm --ask`. See [Asking another agent](asking-another-agent.md). |
20+| `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; the same pool serves the `ask_agent` tool and `mm --ask`, and `Pool.Delegate` — the one path of every hand-off — runs the asking agent's `preAsk` and `postAsk` hooks around it. See [Asking another agent](asking-another-agent.md). |
2121 | `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. |
2222 | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |
2323 | `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. |
@@ -27,7 +27,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
2727 | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |
2828 | `ui` | Where human-facing output goes, and the event sink the front ends share. |
2929 | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). |
30-| `hooks` | The `hooks:` block (ticket 36): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). |
30+| `hooks` | The `hooks:` block (tickets 36 and 87): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`, and `PreAsk`, `PostAsk` for `ask`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). |
3131 | `agent` | The terminal REPL. |
3232 | `acp` | The Agent Client Protocol façade. |
3333 | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |
@@ -17,7 +17,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
17 | `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`. |17 | `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`. |
18 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |18 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
19 | `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. |19 | `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. |
20-| `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; the same pool serves the `ask_agent` tool and `mm --ask`. See [Asking another agent](asking-another-agent.md). |20+| `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; the same pool serves the `ask_agent` tool and `mm --ask`, and `Pool.Delegate` — the one path of every hand-off — runs the asking agent's `preAsk` and `postAsk` hooks around it. See [Asking another agent](asking-another-agent.md). |
21 | `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. |21 | `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. |
22 | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |22 | `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |
23 | `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. |23 | `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. |
@@ -27,7 +27,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
27 | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |27 | `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |
28 | `ui` | Where human-facing output goes, and the event sink the front ends share. |28 | `ui` | Where human-facing output goes, and the event sink the front ends share. |
29 | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). |29 | `consent` | Whether a tool call may run: the `yolo` switch (`Gate`), the four answers (`Answer`), what a session remembers per tool (`Policy`), and the hook through which the line mode asks (`SetPrompter`). The window, the editor façade and `/ask` draw the question their own way and share these rules. See [Yolo mode and permissions](yolo-and-permissions.md). |
30-| `hooks` | The `hooks:` block (ticket 36): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). |30+| `hooks` | The `hooks:` block (tickets 36 and 87): runs each hook with `sh -c`, writes its JSON input, reads its reply, applies the chain order and `onError`, and returns decisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`, and `PreAsk`, `PostAsk` for `ask`). No Genkit, no front end: `engine/hooks.go` applies the decisions around `Generate` and in the `WrapTool` middleware, so every front end gets them. See [Hooks](hooks.md). |
31 | `agent` | The terminal REPL. |31 | `agent` | The terminal REPL. |
32 | `acp` | The Agent Client Protocol façade. |32 | `acp` | The Agent Client Protocol façade. |
33 | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |33 | `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |
modified docs/en/explanation/asking-another-agent.md +1 -0
@@ -42,4 +42,5 @@
4242 - The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side.
4343 - Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools.
4444 - 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.
45+- The asking agent's `preAsk` and `postAsk` [hooks](hooks.md#hooks-around-agent-calls) run around every hand-off — `/ask`, `ask_agent` and `mm --ask` alike: they can refuse it, rewrite what is sent, and replace what comes back. The child runs only the hooks of its own file.
4546 - 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).
@@ -42,4 +42,5 @@
42 - The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side.42 - The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side.
43 - Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools.43 - Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools.
44 - 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.44 - 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.
45+- The asking agent's `preAsk` and `postAsk` [hooks](hooks.md#hooks-around-agent-calls) run around every hand-off — `/ask`, `ask_agent` and `mm --ask` alike: they can refuse it, rewrite what is sent, and replace what comes back. The child runs only the hooks of its own file.
45 - 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).46 - 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).
modified docs/en/explanation/hooks.md +21 -4
@@ -2,9 +2,9 @@
22
33 ## What is this about?
44
5-A hook is a command `mm` runs at four moments of a turn: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), and once the turn is over (`postGeneration`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code.
5+A hook is a command `mm` runs at fixed moments: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), once the turn is over (`postGeneration`), and around each hand-off to another agent (`preAsk`, `postAsk`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code.
66
7-The design started from ticket 36, which explored a much larger system. This page explains what was kept, where the hooks plug in, and what was left out.
7+The design started from ticket 36, which explored a much larger system; ticket 87 added the two hand-off events. This page explains what was kept, where the hooks plug in, and what was left out.
88
99 ## Why external commands
1010
@@ -50,6 +50,23 @@ The package `internal/hooks` runs the commands and turns their replies into deci
5050
5151 **A replaced tool input is not written back into the history.** The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later.
5252
53+## Hooks around agent calls
54+
55+An agent can hand a question to another one: the model calls `ask_agent`, the user types `/ask-<name>`, or a skill runs `mm --ask`. The asked agent is a separate `mm` process with its own file, so it runs its own hooks. That covers what belongs to *that* agent, whoever asks it. It does not cover what belongs to the *hand-off*: what may leave this agent, for whom, and what this agent does with what comes back. Those rules are the asking agent's author's, and the asked agent's file may not even be theirs to change.
56+
57+Before ticket 87, the only handle was `preToolCall`/`postToolCall` with `matcher: ^ask_agent$`, and it fell short in four ways: the `/ask` commands and `mm --ask` do not go through `engine.Generate`, so no hook ran for them at all; `preToolCall` saw the model's arguments but not the conversation `with_context` would add, which is built inside the tool; `postToolCall` could only append to an answer already truncated, never replace it; and a failure reached it as text mixed into the output.
58+
59+`preAsk` and `postAsk` therefore plug in at the one place every hand-off goes through, `ask.Pool.Delegate`: the tool, the commands and the command line all call it. `preAsk` sees the question and the conversation **before** they are joined into the prompt the asked agent receives, so it can rewrite either; `postAsk` sees the whole answer and the error separately, before the answer is truncated into a tool output or kept in the history.
60+
61+Four decisions, taken with the user:
62+
63+- **`preAsk` runs after the `ask_agent` consent prompt.** The user approves what the model asked for; the hook then filters or rewrites it. The other order would have the user approve a question they never see.
64+- **No `retry` action.** A hook that re-asks would start a second hand-off inside the first, with its own consent and its own budget. When an answer is not good enough, `postAsk` says so in `context`, and the asking model decides whether to ask again — through `ask_agent`, where every rule applies once more.
65+- **`mm --ask` runs the hooks** of the file it reads, like the other two paths. With a `postAsk` hook it holds the answer back until the hooks have run, because its stdout is what the calling script reads.
66+- **No rerouting.** `preAsk` cannot change which agent is asked: the consent prompt and the tool call name the agent the model chose, and a hook silently swapping it would make both untrue.
67+
68+One limit follows from how the answer travels: it streams to the user while the asked agent writes it. `postAsk` changes what the asking agent reads and remembers, not what was shown — the same rule as `postToolCall`. A replaced answer is announced on the notice line so the two never differ unseen.
69+
5370 ## Rejected alternatives
5471
5572 Ticket 36 explored more than what was built. What was left out, and why:
@@ -63,7 +80,7 @@ Ticket 36 explored more than what was built. What was left out, and why:
6380 ## How it relates to the rest
6481
6582 - **Yolo and consent.** Hooks and `yolo: false` are complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See [Yolo mode and permissions](yolo-and-permissions.md).
66-- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's.
83+- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's; its hooks see `"asked":{"depth":<n>}`. The asker's `preAsk` and `postAsk` surround the hand-off. See [Asking another agent](asking-another-agent.md).
6784 - **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it.
6885
69-See the [hooks reference](../reference/hooks.md) for every field, and [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one.
86+See the [hooks reference](../reference/hooks.md) for every field, [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one, and [Control the hand-offs to other agents with hooks](../how-to/control-agent-calls-with-hooks.md) for `preAsk` and `postAsk`.
@@ -2,9 +2,9 @@
2 2
3 ## What is this about?3 ## What is this about?
4 4
5-A hook is a command `mm` runs at four moments of a turn: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), and once the turn is over (`postGeneration`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code.5+A hook is a command `mm` runs at fixed moments: before the model reads a question (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`), once the turn is over (`postGeneration`), and around each hand-off to another agent (`preAsk`, `postAsk`). It reads a JSON object describing the moment, and may answer with a decision: rewrite the question, add context, stop the turn, deny a call, replace its arguments. It lets a project put its own rules around the agent — what must never run, what must never be sent, what must be logged — without changing `mm`'s code.
6 6
7-The design started from ticket 36, which explored a much larger system. This page explains what was kept, where the hooks plug in, and what was left out.7+The design started from ticket 36, which explored a much larger system; ticket 87 added the two hand-off events. This page explains what was kept, where the hooks plug in, and what was left out.
8 8
9 ## Why external commands9 ## Why external commands
10 10
@@ -50,6 +50,23 @@ The package `internal/hooks` runs the commands and turns their replies into deci
50 50
51 **A replaced tool input is not written back into the history.** The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later.51 **A replaced tool input is not written back into the history.** The tool runs with the hook's arguments, but the history keeps what the model asked for. The model's own request is the truthful record of what it did; a session file that shows arguments the model never wrote would mislead whoever reads it later.
52 52
53+## Hooks around agent calls
54+
55+An agent can hand a question to another one: the model calls `ask_agent`, the user types `/ask-<name>`, or a skill runs `mm --ask`. The asked agent is a separate `mm` process with its own file, so it runs its own hooks. That covers what belongs to *that* agent, whoever asks it. It does not cover what belongs to the *hand-off*: what may leave this agent, for whom, and what this agent does with what comes back. Those rules are the asking agent's author's, and the asked agent's file may not even be theirs to change.
56+
57+Before ticket 87, the only handle was `preToolCall`/`postToolCall` with `matcher: ^ask_agent$`, and it fell short in four ways: the `/ask` commands and `mm --ask` do not go through `engine.Generate`, so no hook ran for them at all; `preToolCall` saw the model's arguments but not the conversation `with_context` would add, which is built inside the tool; `postToolCall` could only append to an answer already truncated, never replace it; and a failure reached it as text mixed into the output.
58+
59+`preAsk` and `postAsk` therefore plug in at the one place every hand-off goes through, `ask.Pool.Delegate`: the tool, the commands and the command line all call it. `preAsk` sees the question and the conversation **before** they are joined into the prompt the asked agent receives, so it can rewrite either; `postAsk` sees the whole answer and the error separately, before the answer is truncated into a tool output or kept in the history.
60+
61+Four decisions, taken with the user:
62+
63+- **`preAsk` runs after the `ask_agent` consent prompt.** The user approves what the model asked for; the hook then filters or rewrites it. The other order would have the user approve a question they never see.
64+- **No `retry` action.** A hook that re-asks would start a second hand-off inside the first, with its own consent and its own budget. When an answer is not good enough, `postAsk` says so in `context`, and the asking model decides whether to ask again — through `ask_agent`, where every rule applies once more.
65+- **`mm --ask` runs the hooks** of the file it reads, like the other two paths. With a `postAsk` hook it holds the answer back until the hooks have run, because its stdout is what the calling script reads.
66+- **No rerouting.** `preAsk` cannot change which agent is asked: the consent prompt and the tool call name the agent the model chose, and a hook silently swapping it would make both untrue.
67+
68+One limit follows from how the answer travels: it streams to the user while the asked agent writes it. `postAsk` changes what the asking agent reads and remembers, not what was shown — the same rule as `postToolCall`. A replaced answer is announced on the notice line so the two never differ unseen.
69+
53 ## Rejected alternatives70 ## Rejected alternatives
54 71
55 Ticket 36 explored more than what was built. What was left out, and why:72 Ticket 36 explored more than what was built. What was left out, and why:
@@ -63,7 +80,7 @@ Ticket 36 explored more than what was built. What was left out, and why:
63 ## How it relates to the rest80 ## How it relates to the rest
64 81
65 - **Yolo and consent.** Hooks and `yolo: false` are complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See [Yolo mode and permissions](yolo-and-permissions.md).82 - **Yolo and consent.** Hooks and `yolo: false` are complementary: a hook decides by rule, without a human; consent asks a human, case by case. The hook runs first. See [Yolo mode and permissions](yolo-and-permissions.md).
66-- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's.83+- **Asking another agent.** An agent started by `/ask`, `ask_agent` or `mm --ask` is a separate `mm` process with its own configuration file, so it runs **its own** hooks, not its asker's; its hooks see `"asked":{"depth":<n>}`. The asker's `preAsk` and `postAsk` surround the hand-off. See [Asking another agent](asking-another-agent.md).
67 - **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it.84 - **Context compression** asks the model for a summary through another path than `Generate`; the hooks do not run on it.
68 85
69-See the [hooks reference](../reference/hooks.md) for every field, and [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one.86+See the [hooks reference](../reference/hooks.md) for every field, [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) to write one, and [Control the hand-offs to other agents with hooks](../how-to/control-agent-calls-with-hooks.md) for `preAsk` and `postAsk`.
modified docs/en/how-to/ask-another-agent-from-a-skill.md +1 -0
@@ -78,3 +78,4 @@ This guide shows how to make a skill — or any shell script — put a question
7878 - Every flag, stream and exit code: [command-line reference](../reference/cli.md#ask-mode-specifics)
7979 - `bashTimeout` and `mmConfigs`: [configuration reference](../reference/configuration.md)
8080 - Why the agent is a child process, and what `--ask` adds for scripts: [asking another agent — explanation](../explanation/asking-another-agent.md)
81+- `preAsk` and `postAsk` hooks run for `mm --ask` too; with a `postAsk` hook, stdout carries the answer once the hooks have run: [control the hand-offs to other agents with hooks](control-agent-calls-with-hooks.md)
@@ -78,3 +78,4 @@ This guide shows how to make a skill — or any shell script — put a question
78 - Every flag, stream and exit code: [command-line reference](../reference/cli.md#ask-mode-specifics)78 - Every flag, stream and exit code: [command-line reference](../reference/cli.md#ask-mode-specifics)
79 - `bashTimeout` and `mmConfigs`: [configuration reference](../reference/configuration.md)79 - `bashTimeout` and `mmConfigs`: [configuration reference](../reference/configuration.md)
80 - Why the agent is a child process, and what `--ask` adds for scripts: [asking another agent — explanation](../explanation/asking-another-agent.md)80 - Why the agent is a child process, and what `--ask` adds for scripts: [asking another agent — explanation](../explanation/asking-another-agent.md)
81+- `preAsk` and `postAsk` hooks run for `mm --ask` too; with a `postAsk` hook, stdout carries the answer once the hooks have run: [control the hand-offs to other agents with hooks](control-agent-calls-with-hooks.md)
added docs/en/how-to/control-agent-calls-with-hooks.md +104 -0
new file mode 100644
@@ -0,0 +1,104 @@
1+# How to control the hand-offs to other agents with hooks
2+
3+This guide shows how an agent that asks other agents (`ask_agent`, `/ask-<name>`, `mm --ask`) can check, rewrite or refuse each question before it leaves, and check or replace each answer when it comes back. It assumes you have an agent with at least one entry in `mmConfigs` (see [Let the agent call other agents](let-the-agent-call-other-agents.md)), and that you know how to write a hook (see [Guard the agent with hooks](guard-the-agent-with-hooks.md)). The examples use Node.js.
4+
5+The hooks go in the **asking** agent's file. The asked agent does not need to change, or even to know.
6+
7+## Steps
8+
9+### 1. Pick the event
10+
11+| You want to… | Event | Reply |
12+|--------------|-------|-------|
13+| refuse a hand-off (a budget, an agent that must not be asked from here) | `preAsk` | `{"action":"deny","reason":"…"}` |
14+| mask secrets, or cut the conversation sent with `with_context` | `preAsk` | `{"prompt":"…","transcript":"…"}` |
15+| give the asked agent standing rules (language, length, format) | `preAsk` | `{"context":"…"}` |
16+| turn a failure into something the asking model can act on | `postAsk` | `{"answer":"…"}` |
17+| check the answer's format, add a note for the asking model | `postAsk` | `{"context":"…"}` |
18+| log every hand-off | `postAsk` | nothing |
19+
20+### 2. Write the hook
21+
22+This `preAsk` hook adds standing rules to every question sent to the agent `expert`:
23+
24+```js
25+// hooks/house-rules.js
26+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
27+
28+const rules = ["Answer in the language of the question.", "At most 120 words."];
29+// Through ask_agent, the answer is read by the asking model, not a person.
30+if (input.ask.via === "tool") rules.push("No greetings, no questions back.");
31+
32+console.log(JSON.stringify({ context: rules.join("\n") }));
33+```
34+
35+And this `postAsk` hook logs the hand-off and, when the asked agent failed, replaces the error with an instruction for the asking model:
36+
37+```js
38+// hooks/after-ask.js
39+const fs = require("fs");
40+const path = require("path");
41+const input = JSON.parse(fs.readFileSync(0, "utf8"));
42+
43+const line = { agent: input.ask.agent, via: input.ask.via, prompt: input.ask.prompt, durationMs: input.durationMs, error: input.error };
44+fs.mkdirSync(path.join(input.cwd, ".mm"), { recursive: true });
45+fs.appendFileSync(path.join(input.cwd, ".mm", "ask-audit.jsonl"), JSON.stringify(line) + "\n");
46+
47+if (input.error && !(input.answer || "").trim()) {
48+ console.log(JSON.stringify({ answer: `The ${input.ask.agent} agent is unavailable. Tell the user in one sentence; do not answer yourself.` }));
49+}
50+```
51+
52+### 3. Try them without mm
53+
54+```bash
55+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"What is an inode?"}}' \
56+ | node hooks/house-rules.js
57+```
58+
59+You should see:
60+
61+```
62+{"context":"Answer in the language of the question.\nAt most 120 words.\nNo greetings, no questions back."}
63+```
64+
65+### 4. Declare them in the asking agent's file
66+
67+```yaml
68+mmConfigs:
69+ expert:
70+ path: agent.expert.yaml
71+ description: Technical expert. For every technical question.
72+
73+hooks:
74+ preAsk:
75+ - matcher: ^expert$
76+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
77+ postAsk:
78+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
79+```
80+
81+`matcher` is a regular expression on the asked agent's name; without it, the hook runs for every agent.
82+
83+### 5. Start the agent and check
84+
85+```bash
86+mm agent.yaml
87+```
88+
89+The banner ends with `hooks: preAsk×1 postAsk×1`. Ask a question the agent hands off, then look at `.mm/ask-audit.jsonl`: one line per hand-off, with `"via":"tool"`. Type `/ask-expert <question>` and the line says `"via":"command"`.
90+
91+## Variants
92+
93+- **A budget per question.** Declare the same script on `preGeneration` (reset a counter file) and on `preAsk` (count, and answer `deny` past the limit). Count only `"via":"tool"`: a hand-off the user typed, or a script ran, is not the model's doing.
94+- **Mask secrets.** On `preAsk`, rewrite `input.ask.prompt` and `input.ask.transcript` and print both. `"transcript":""` sends no conversation at all.
95+- **A guard.** Set `onError: fail` on a `preAsk` hook that must not be skipped: when it cannot run, the hand-off is denied.
96+- **With `yolo: false`**, the user approves the `ask_agent` call first; `preAsk` runs after that approval, on the question the model wrote.
97+- **`mm --ask` from a skill.** The hooks of the file it reads run too (`"via":"cli"`). With a `postAsk` hook, stdout carries the answer once the hooks have run, so the calling script reads the replacement.
98+- **The asked agent's own hooks.** It runs the hooks of its own file, which see `"asked":{"depth":1}`. Use them for what belongs to that agent whoever asks it; use `preAsk`/`postAsk` for what belongs to this hand-off.
99+
100+## See also
101+
102+- Every field and effect: [Hooks reference](../reference/hooks.md#preask)
103+- Why these hooks belong to the asking agent: [Hooks — explanation](../explanation/hooks.md#hooks-around-agent-calls)
104+- A complete example: `demos/hooks-ask/`
new file mode 100644
@@ -0,0 +1,104 @@
1+# How to control the hand-offs to other agents with hooks
2+
3+This guide shows how an agent that asks other agents (`ask_agent`, `/ask-<name>`, `mm --ask`) can check, rewrite or refuse each question before it leaves, and check or replace each answer when it comes back. It assumes you have an agent with at least one entry in `mmConfigs` (see [Let the agent call other agents](let-the-agent-call-other-agents.md)), and that you know how to write a hook (see [Guard the agent with hooks](guard-the-agent-with-hooks.md)). The examples use Node.js.
4+
5+The hooks go in the **asking** agent's file. The asked agent does not need to change, or even to know.
6+
7+## Steps
8+
9+### 1. Pick the event
10+
11+| You want to… | Event | Reply |
12+|--------------|-------|-------|
13+| refuse a hand-off (a budget, an agent that must not be asked from here) | `preAsk` | `{"action":"deny","reason":"…"}` |
14+| mask secrets, or cut the conversation sent with `with_context` | `preAsk` | `{"prompt":"…","transcript":"…"}` |
15+| give the asked agent standing rules (language, length, format) | `preAsk` | `{"context":"…"}` |
16+| turn a failure into something the asking model can act on | `postAsk` | `{"answer":"…"}` |
17+| check the answer's format, add a note for the asking model | `postAsk` | `{"context":"…"}` |
18+| log every hand-off | `postAsk` | nothing |
19+
20+### 2. Write the hook
21+
22+This `preAsk` hook adds standing rules to every question sent to the agent `expert`:
23+
24+```js
25+// hooks/house-rules.js
26+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
27+
28+const rules = ["Answer in the language of the question.", "At most 120 words."];
29+// Through ask_agent, the answer is read by the asking model, not a person.
30+if (input.ask.via === "tool") rules.push("No greetings, no questions back.");
31+
32+console.log(JSON.stringify({ context: rules.join("\n") }));
33+```
34+
35+And this `postAsk` hook logs the hand-off and, when the asked agent failed, replaces the error with an instruction for the asking model:
36+
37+```js
38+// hooks/after-ask.js
39+const fs = require("fs");
40+const path = require("path");
41+const input = JSON.parse(fs.readFileSync(0, "utf8"));
42+
43+const line = { agent: input.ask.agent, via: input.ask.via, prompt: input.ask.prompt, durationMs: input.durationMs, error: input.error };
44+fs.mkdirSync(path.join(input.cwd, ".mm"), { recursive: true });
45+fs.appendFileSync(path.join(input.cwd, ".mm", "ask-audit.jsonl"), JSON.stringify(line) + "\n");
46+
47+if (input.error && !(input.answer || "").trim()) {
48+ console.log(JSON.stringify({ answer: `The ${input.ask.agent} agent is unavailable. Tell the user in one sentence; do not answer yourself.` }));
49+}
50+```
51+
52+### 3. Try them without mm
53+
54+```bash
55+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"What is an inode?"}}' \
56+ | node hooks/house-rules.js
57+```
58+
59+You should see:
60+
61+```
62+{"context":"Answer in the language of the question.\nAt most 120 words.\nNo greetings, no questions back."}
63+```
64+
65+### 4. Declare them in the asking agent's file
66+
67+```yaml
68+mmConfigs:
69+ expert:
70+ path: agent.expert.yaml
71+ description: Technical expert. For every technical question.
72+
73+hooks:
74+ preAsk:
75+ - matcher: ^expert$
76+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
77+ postAsk:
78+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
79+```
80+
81+`matcher` is a regular expression on the asked agent's name; without it, the hook runs for every agent.
82+
83+### 5. Start the agent and check
84+
85+```bash
86+mm agent.yaml
87+```
88+
89+The banner ends with `hooks: preAsk×1 postAsk×1`. Ask a question the agent hands off, then look at `.mm/ask-audit.jsonl`: one line per hand-off, with `"via":"tool"`. Type `/ask-expert <question>` and the line says `"via":"command"`.
90+
91+## Variants
92+
93+- **A budget per question.** Declare the same script on `preGeneration` (reset a counter file) and on `preAsk` (count, and answer `deny` past the limit). Count only `"via":"tool"`: a hand-off the user typed, or a script ran, is not the model's doing.
94+- **Mask secrets.** On `preAsk`, rewrite `input.ask.prompt` and `input.ask.transcript` and print both. `"transcript":""` sends no conversation at all.
95+- **A guard.** Set `onError: fail` on a `preAsk` hook that must not be skipped: when it cannot run, the hand-off is denied.
96+- **With `yolo: false`**, the user approves the `ask_agent` call first; `preAsk` runs after that approval, on the question the model wrote.
97+- **`mm --ask` from a skill.** The hooks of the file it reads run too (`"via":"cli"`). With a `postAsk` hook, stdout carries the answer once the hooks have run, so the calling script reads the replacement.
98+- **The asked agent's own hooks.** It runs the hooks of its own file, which see `"asked":{"depth":1}`. Use them for what belongs to that agent whoever asks it; use `preAsk`/`postAsk` for what belongs to this hand-off.
99+
100+## See also
101+
102+- Every field and effect: [Hooks reference](../reference/hooks.md#preask)
103+- Why these hooks belong to the asking agent: [Hooks — explanation](../explanation/hooks.md#hooks-around-agent-calls)
104+- A complete example: `demos/hooks-ask/`
modified docs/en/how-to/let-the-agent-call-other-agents.md +1 -0
@@ -71,3 +71,4 @@ This guide shows how to give your agent a team: other mm agents it hands work to
7171 - The tool's fields, display and errors: [tools reference](../reference/tools.md#ask_agent)
7272 - The two forms of `mmConfigs`: [configuration reference](../reference/configuration.md)
7373 - Why it runs inside the turn rather than through a skill: [asking another agent — explanation](../explanation/asking-another-agent.md)
74+- Refuse, rewrite or check the hand-offs: [control the hand-offs to other agents with hooks](control-agent-calls-with-hooks.md)
@@ -71,3 +71,4 @@ This guide shows how to give your agent a team: other mm agents it hands work to
71 - The tool's fields, display and errors: [tools reference](../reference/tools.md#ask_agent)71 - The tool's fields, display and errors: [tools reference](../reference/tools.md#ask_agent)
72 - The two forms of `mmConfigs`: [configuration reference](../reference/configuration.md)72 - The two forms of `mmConfigs`: [configuration reference](../reference/configuration.md)
73 - Why it runs inside the turn rather than through a skill: [asking another agent — explanation](../explanation/asking-another-agent.md)73 - Why it runs inside the turn rather than through a skill: [asking another agent — explanation](../explanation/asking-another-agent.md)
74+- Refuse, rewrite or check the hand-offs: [control the hand-offs to other agents with hooks](control-agent-calls-with-hooks.md)
modified docs/en/reference/acp.md +1 -1
@@ -8,7 +8,7 @@
88 |--------|-------|
99 | Input | stdin, one JSON-RPC message per line. |
1010 | Output | stdout, JSON-RPC messages only. |
11-| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`). |
11+| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`) and the notice of a `postAsk` hook that replaced an answer (`🪝 postAsk: …`). |
1212 | Lifetime | Until the client closes stdin or the process context is cancelled. |
1313
1414 ## `initialize` response
@@ -8,7 +8,7 @@
8 |--------|-------|8 |--------|-------|
9 | Input | stdin, one JSON-RPC message per line. |9 | Input | stdin, one JSON-RPC message per line. |
10 | Output | stdout, JSON-RPC messages only. |10 | Output | stdout, JSON-RPC messages only. |
11-| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`). |11+| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger, the failure lines of [hooks](hooks.md#failures) (`🪝 <event> hook …`) and the notice of a `postAsk` hook that replaced an answer (`🪝 postAsk: …`). |
12 | Lifetime | Until the client closes stdin or the process context is cancelled. |12 | Lifetime | Until the client closes stdin or the process context is cancelled. |
13 13
14 ## `initialize` response14 ## `initialize` response
modified docs/en/reference/configuration.md +1 -1
@@ -34,7 +34,7 @@
3434 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |
3535 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |
3636 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
37-| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) and after the turn (`postGeneration`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool events only), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). |
37+| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). |
3838 | `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`. |
3939 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
4040 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |
@@ -34,7 +34,7 @@
34 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |34 | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. |
35 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |35 | `acp` | map | see below | How `mm -acp` introduces itself to the editor. |
36 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |36 | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. |
37-| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) and after the turn (`postGeneration`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool events only), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). |37+| `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). |
38 | `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`. |38 | `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`. |
39 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |39 | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). |
40 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |40 | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. |
modified docs/en/reference/hooks.md +70 -19
@@ -10,6 +10,8 @@ hooks:
1010 preToolCall: [<hook>, …]
1111 postToolCall: [<hook>, …]
1212 postGeneration: [<hook>, …]
13+ preAsk: [<hook>, …]
14+ postAsk: [<hook>, …]
1315 ```
1416
1517 Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No `hooks:` block = no hook.
@@ -19,7 +21,7 @@ Each event takes a list; the hooks of one event run in the order they are writte
1921 | Key | Type | Default | Description |
2022 |-----|------|---------|-------------|
2123 | `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. |
22-| `matcher` | string (regular expression, Go syntax) | `""` (every tool) | `preToolCall` and `postToolCall` only. The hook runs for the tools whose name matches. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. |
24+| `matcher` | string (regular expression, Go syntax) | `""` (every tool or agent) | `preToolCall` and `postToolCall`: the hook runs for the tools whose name matches. `preAsk` and `postAsk`: for the asked agents whose name (its key in `mmConfigs`) matches. Not allowed on `preGeneration` and `postGeneration`. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. |
2325 | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. |
2426 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). |
2527
@@ -47,8 +49,12 @@ hooks:
4749 | `preToolCall` | before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by `matcher` | deny the call, replace its arguments |
4850 | `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output |
4951 | `postGeneration` | once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only |
52+| `preAsk` | in the asking agent, once per hand-off to another agent — the `ask_agent` tool, `/ask`, `/ask-<name>`, `/ask-with-context-<name>`, `mm --ask` — before the asked agent is started or asked; for `ask_agent`, after the call's consent prompt | the agents of `mmConfigs`, filtered by `matcher` | deny the hand-off, rewrite the question or the conversation sent, add context for the asked agent |
53+| `postAsk` | in the asking agent, once the asked agent's turn is over: answered, failed or cancelled; before the answer becomes `ask_agent`'s output or joins the history | the agents of `mmConfigs`, filtered by `matcher` | replace the answer the asking agent reads, add context after it |
5054
51-All four run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`, and an agent started by `/ask`, `ask_agent` or `mm --ask` (with the hooks of **its own** configuration file). A `preGeneration` hook does not run on a context-compression summary.
55+All six run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`. An agent started by `/ask`, `ask_agent` or `mm --ask` runs the hooks of **its own** configuration file; `preAsk` and `postAsk` are the **asking** agent's. A `preGeneration` hook does not run on a context-compression summary.
56+
57+On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preAsk` → the asked agent (its own hooks) → `postAsk` → `postToolCall`. On `/ask…` and `mm --ask`: `preAsk` → the asked agent → `postAsk`.
5258
5359 ## Execution
5460
@@ -66,7 +72,7 @@ All four run in every front end: the line mode, the window (`-tui`), an editor (
6672
6773 | Variable | Value |
6874 |----------|-------|
69-| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall` or `postGeneration` |
75+| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` or `postAsk` |
7076 | `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook |
7177
7278 ## Input
@@ -80,12 +86,28 @@ The JSON object written on the hook's stdin.
8086 | `model` | string | all | the model reference, `<provider>/<model>` |
8187 | `prompt` | string | `preGeneration`, `postGeneration` | the question as typed — `@path` and `$skill` lines included. In a chain, the question as the previous hook left it. In `postGeneration`: the question the model read, or the marker `[prompt blocked by a hook: <reason>]` when a hook stopped the turn. |
8288 | `tool` | object | `preToolCall`, `postToolCall` | the tool call: `id` (the call's reference, may be empty), `name`, `input` (the arguments, as the model sent them or as the previous hook replaced them) |
89+| `ask` | object | `preAsk`, `postAsk` | the hand-off — see [The `ask` object](#the-ask-object) |
8390 | `output` | string | `postToolCall` | the tool's output, as the model will read it (JSON when the tool returns something other than text) |
84-| `answer` | string | `postGeneration` | the text of the last answer; empty when there is none |
85-| `error` | string | `postGeneration` | the turn's error; absent when the turn succeeded |
91+| `answer` | string | `postGeneration`, `postAsk` | `postGeneration`: the text of the last answer. `postAsk`: the asked agent's whole answer, before any truncation, or as the previous hook replaced it. Empty when there is none. |
92+| `error` | string | `postGeneration`, `postAsk` | the turn's error, or the hand-off's; absent when it succeeded |
93+| `durationMs` | number | `postAsk` | how long the asked agent took, in milliseconds |
94+| `asked` | object | all | present only in an agent that was itself asked by another one: `{"depth": <n>}`, 1 for an agent asked by the one a person started |
8695
8796 Empty fields are omitted.
8897
98+### The `ask` object
99+
100+| Field | Type | Description |
101+|-------|------|-------------|
102+| `agent` | string | the asked agent's name, its key in `mmConfigs` |
103+| `config` | string | the asked agent's configuration file, absolute |
104+| `agentModel` | string | `<provider>/<model>` read from that file; absent when it cannot be read |
105+| `via` | string | `tool` (the `ask_agent` tool), `command` (`/ask`, `/ask-<name>`, `/ask-with-context-<name>`) or `cli` (`mm --ask`, `mm --ask-with-context`) |
106+| `withContext` | boolean | whether the conversation is sent along |
107+| `depth` | number | the asking agent's depth: 0 for the agent a person started |
108+| `prompt` | string | the question. In a `preAsk` chain, as the previous hook left it; in `postAsk`, as it was sent. |
109+| `transcript` | string | the conversation sent along (`withContext` only), in the same way; absent otherwise |
110+
89111 ### Examples
90112
91113 ```json
@@ -96,6 +118,10 @@ Empty fields are omitted.
96118 {"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97119 ```
98120
121+```json
122+{"event":"postAsk","cwd":"/home/me/project","model":"llmman/…","ask":{"agent":"expert","config":"/home/me/project/agent.expert.yaml","agentModel":"llmman/…","via":"tool","withContext":false,"depth":0,"prompt":"What is an inode?"},"answer":"An inode is …","durationMs":25374}
123+```
124+
99125 ## Reply
100126
101127 The JSON object a hook may write on stdout. **An empty stdout (or whitespace only) is `{"action":"continue"}`.** Every field is optional.
@@ -103,10 +129,12 @@ The JSON object a hook may write on stdout. **An empty stdout (or whitespace onl
103129 | Field | Type | Read on | Description |
104130 |-------|------|---------|-------------|
105131 | `action` | string | all | `continue` (default, also when absent), `stop`, `deny` or `error` — see below |
106-| `prompt` | string | `preGeneration` | replaces the question. Absent = unchanged; `""` = an empty question. |
107-| `context` | string | `preGeneration`, `postToolCall` | text for the model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. |
132+| `prompt` | string | `preGeneration`, `preAsk` | replaces the question — the user's, or the one sent to the asked agent. Absent = unchanged; `""` = an empty question. |
133+| `transcript` | string | `preAsk` | replaces the conversation sent to the asked agent. Absent = unchanged; `""` = send none. |
134+| `answer` | string | `postAsk` | replaces the answer the asking agent reads. Absent = unchanged. |
135+| `context` | string | `preGeneration`, `postToolCall`, `preAsk`, `postAsk` | text for a model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. `preAsk`: appended to what the asked agent receives. `postAsk`: appended to the answer the asking agent reads. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. |
108136 | `input` | any JSON value | `preToolCall` | replaces the tool call's arguments. Absent = unchanged. |
109-| `reason` | string | `preGeneration` (`stop`), `preToolCall` (`deny`) | why. |
137+| `reason` | string | `preGeneration` (`stop`), `preToolCall` and `preAsk` (`deny`) | why. |
110138 | `response` | string | `preGeneration` (`stop`) | what the user reads instead of an answer. Absent or blank: `⛔ This request was blocked by a hook: <reason>`. |
111139 | `error` | string | all (`error`) | the hook's own failure message. |
112140
@@ -118,7 +146,7 @@ Fields read on another event are ignored.
118146 |--------|-----------|--------|
119147 | `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied |
120148 | `stop` | `preGeneration` | the turn ends without calling the model; the following hooks of the event do not run |
121-| `deny` | `preToolCall` | the tool does not run; the following hooks of the event do not run |
149+| `deny` | `preToolCall`, `preAsk` | the tool does not run, or the asked agent is not started nor asked; the following hooks of the event do not run |
122150 | `error` | all | a failure, handled according to `onError` |
123151 | anything else, or `stop`/`deny` on another event | — | a failure: `action "<action>" is not valid for <event>` |
124152
@@ -152,6 +180,28 @@ The history keeps the tool call as the model wrote it, even when `input` replace
152180
153181 No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored.
154182
183+### `preAsk`
184+
185+| Decision | The asked agent | The user sees |
186+|----------|-----------------|---------------|
187+| no change | receives the question (preceded by the conversation with `withContext`) | the usual hand-off |
188+| `prompt`, `transcript`, `context` | receives the rewritten question and conversation, then each context | the usual hand-off |
189+| `deny` | is not started nor asked | `ask_agent`: line mode `🪝 ask_agent [ask denied by a hook: <reason>]`, window and editor a failed `ask_agent` call; the model reads `[ask denied by a hook: <reason>]`. `/ask…`: the command fails with `ask denied by a hook: <reason>`, the history is unchanged. `mm --ask`: `[ask: ask denied by a hook: <reason>]` on stderr, exit code 1. |
190+
191+A chain: each hook reads the `prompt` and `transcript` the previous one left; contexts accumulate in hook order. With `/ask…`, the history keeps the question as sent.
192+
193+### `postAsk`
194+
195+| Decision | The asking agent reads | The user sees |
196+|----------|------------------------|---------------|
197+| no change | the answer (`ask_agent`'s output, or the `[<agent>'s answer]` message of `/ask…`) | the answer, as it streamed |
198+| `answer` | the replacement | the answer as it streamed, then the notice `🪝 postAsk: <agent>'s answer was replaced by a hook — the asking agent reads the new one`; `ask_agent`'s call summary reads `<agent> answered; a hook replaced the answer (<n> characters)` |
199+| `context` | the answer, then `[context added by a hook]` and each context | the answer, as it streamed |
200+
201+The answer streams to the user while the asked agent writes it: `postAsk` changes what the asking agent reads and keeps, never what was shown. An `answer` on a failed hand-off (not a cancelled one) replaces the failure: the asking agent reads it as the answer, and `/ask…` keeps the exchange.
202+
203+With at least one `postAsk` hook declared, `mm --ask` holds the answer back and writes it to stdout once the hooks have run (replacement and context included); without one, it streams the answer as before.
204+
155205 ## Failures
156206
157207 A run fails when:
@@ -160,27 +210,27 @@ A run fails when:
160210 |---------|--------|
161211 | the command exits with a non-zero status | `exit status <n>: <stderr>` |
162212 | it runs longer than `timeout` | `timed out after <timeout>` |
163-| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |
213+| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` |
164214 | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` |
165215 | stdout is not JSON | `invalid JSON on stdout: …` |
166216 | `action` is `error` | the reply's `error`, or `the hook reported an error` |
167217 | `action` is unknown or not allowed on the event | `action "<action>" is not valid for <event>` |
168218
169-`postGeneration` is not cancelled with the turn: it runs after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by its `timeout`.
219+`postGeneration` and `postAsk` are not cancelled with the turn: they run after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by their `timeout`.
170220
171221 What follows depends on `onError`:
172222
173-| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |
174-|-----------|-----------------|---------------|----------------------------------|
175-| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call denied (`reason`: `hook failed: …`) | report line, carry on |
223+| `onError` | `preGeneration` | `preToolCall`, `preAsk` | `postToolCall`, `postGeneration`, `postAsk` |
224+|-----------|-----------------|-------------------------|---------------------------------------------|
225+| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call or hand-off denied (`reason`: `hook failed: …`) | report line, carry on |
176226 | `warn` | report line, next hook | report line, next hook | report line, carry on |
177227 | `ignore` | next hook, silently | next hook, silently | carry on, silently |
178228
179-The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP.
229+The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP, on stderr with `mm --ask`.
180230
181231 ## Start-up banner
182232
183-When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`:
233+When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk`, `postAsk`:
184234
185235 ```
186236 mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
@@ -192,9 +242,9 @@ Reported at start-up as `[config error: <path>: <message>]`, exit code 1.
192242
193243 | Message | Cause |
194244 |---------|-------|
195-| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) |
245+| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) |
196246 | `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one |
197-| `hooks.<event>[N]: matcher is only for preToolCall and postToolCall` | a `matcher` on `preGeneration` or `postGeneration` |
247+| `hooks.<event>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | a `matcher` on `preGeneration` or `postGeneration` |
198248 | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile |
199249 | `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` |
200250 | `hooks.<event>[N]: onError must be "fail", "warn" or "ignore"` | any other `onError` |
@@ -203,4 +253,5 @@ Reported at start-up as `[config error: <path>: <message>]`, exit code 1.
203253
204254 - [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md)
205255 - [Hooks — explanation](../explanation/hooks.md)
206-- The demo: `demos/hooks/` (four Node.js hooks)
256+- [Control the hand-offs to other agents with hooks](../how-to/control-agent-calls-with-hooks.md)
257+- The demos: `demos/hooks/` (four Node.js hooks), `demos/hooks-ask/` (`preAsk` and `postAsk` around an expert agent)
@@ -10,6 +10,8 @@ hooks:
10 preToolCall: [<hook>, …]10 preToolCall: [<hook>, …]
11 postToolCall: [<hook>, …]11 postToolCall: [<hook>, …]
12 postGeneration: [<hook>, …]12 postGeneration: [<hook>, …]
13+ preAsk: [<hook>, …]
14+ postAsk: [<hook>, …]
13 ```15 ```
14 16
15 Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No `hooks:` block = no hook.17 Each event takes a list; the hooks of one event run in the order they are written. An event that is absent, or an empty list, runs nothing. No `hooks:` block = no hook.
@@ -19,7 +21,7 @@ Each event takes a list; the hooks of one event run in the order they are writte
19 | Key | Type | Default | Description |21 | Key | Type | Default | Description |
20 |-----|------|---------|-------------|22 |-----|------|---------|-------------|
21 | `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. |23 | `command` | string | — (required) | Run with `sh -c "<command>"`. Must not be empty or blank. |
22-| `matcher` | string (regular expression, Go syntax) | `""` (every tool) | `preToolCall` and `postToolCall` only. The hook runs for the tools whose name matches. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. |24+| `matcher` | string (regular expression, Go syntax) | `""` (every tool or agent) | `preToolCall` and `postToolCall`: the hook runs for the tools whose name matches. `preAsk` and `postAsk`: for the asked agents whose name (its key in `mmConfigs`) matches. Not allowed on `preGeneration` and `postGeneration`. Unanchored: `bash` also matches `my_bash`; `^bash$` matches `bash` alone. |
23 | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. |25 | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. |
24 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). |26 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). |
25 27
@@ -47,8 +49,12 @@ hooks:
47 | `preToolCall` | before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by `matcher` | deny the call, replace its arguments |49 | `preToolCall` | before each tool call, before the tool's own consent prompt | built-in and MCP, filtered by `matcher` | deny the call, replace its arguments |
48 | `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output |50 | `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output |
49 | `postGeneration` | once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only |51 | `postGeneration` | once the turn is over: answered, failed, cancelled or stopped by a hook | — | observe only |
52+| `preAsk` | in the asking agent, once per hand-off to another agent — the `ask_agent` tool, `/ask`, `/ask-<name>`, `/ask-with-context-<name>`, `mm --ask` — before the asked agent is started or asked; for `ask_agent`, after the call's consent prompt | the agents of `mmConfigs`, filtered by `matcher` | deny the hand-off, rewrite the question or the conversation sent, add context for the asked agent |
53+| `postAsk` | in the asking agent, once the asked agent's turn is over: answered, failed or cancelled; before the answer becomes `ask_agent`'s output or joins the history | the agents of `mmConfigs`, filtered by `matcher` | replace the answer the asking agent reads, add context after it |
50 54
51-All four run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`, and an agent started by `/ask`, `ask_agent` or `mm --ask` (with the hooks of **its own** configuration file). A `preGeneration` hook does not run on a context-compression summary.55+All six run in every front end: the line mode, the window (`-tui`), an editor (`-acp`), `-p`. An agent started by `/ask`, `ask_agent` or `mm --ask` runs the hooks of **its own** configuration file; `preAsk` and `postAsk` are the **asking** agent's. A `preGeneration` hook does not run on a context-compression summary.
56+
57+On the `ask_agent` path the order is: `preToolCall` → consent prompt → `preAsk` → the asked agent (its own hooks) → `postAsk` → `postToolCall`. On `/ask…` and `mm --ask`: `preAsk` → the asked agent → `postAsk`.
52 58
53 ## Execution59 ## Execution
54 60
@@ -66,7 +72,7 @@ All four run in every front end: the line mode, the window (`-tui`), an editor (
66 72
67 | Variable | Value |73 | Variable | Value |
68 |----------|-------|74 |----------|-------|
69-| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall` or `postGeneration` |75+| `MM_HOOK_EVENT` | the event: `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` or `postAsk` |
70 | `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook |76 | `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook |
71 77
72 ## Input78 ## Input
@@ -80,12 +86,28 @@ The JSON object written on the hook's stdin.
80 | `model` | string | all | the model reference, `<provider>/<model>` |86 | `model` | string | all | the model reference, `<provider>/<model>` |
81 | `prompt` | string | `preGeneration`, `postGeneration` | the question as typed — `@path` and `$skill` lines included. In a chain, the question as the previous hook left it. In `postGeneration`: the question the model read, or the marker `[prompt blocked by a hook: <reason>]` when a hook stopped the turn. |87 | `prompt` | string | `preGeneration`, `postGeneration` | the question as typed — `@path` and `$skill` lines included. In a chain, the question as the previous hook left it. In `postGeneration`: the question the model read, or the marker `[prompt blocked by a hook: <reason>]` when a hook stopped the turn. |
82 | `tool` | object | `preToolCall`, `postToolCall` | the tool call: `id` (the call's reference, may be empty), `name`, `input` (the arguments, as the model sent them or as the previous hook replaced them) |88 | `tool` | object | `preToolCall`, `postToolCall` | the tool call: `id` (the call's reference, may be empty), `name`, `input` (the arguments, as the model sent them or as the previous hook replaced them) |
89+| `ask` | object | `preAsk`, `postAsk` | the hand-off — see [The `ask` object](#the-ask-object) |
83 | `output` | string | `postToolCall` | the tool's output, as the model will read it (JSON when the tool returns something other than text) |90 | `output` | string | `postToolCall` | the tool's output, as the model will read it (JSON when the tool returns something other than text) |
84-| `answer` | string | `postGeneration` | the text of the last answer; empty when there is none |91+| `answer` | string | `postGeneration`, `postAsk` | `postGeneration`: the text of the last answer. `postAsk`: the asked agent's whole answer, before any truncation, or as the previous hook replaced it. Empty when there is none. |
85-| `error` | string | `postGeneration` | the turn's error; absent when the turn succeeded |92+| `error` | string | `postGeneration`, `postAsk` | the turn's error, or the hand-off's; absent when it succeeded |
93+| `durationMs` | number | `postAsk` | how long the asked agent took, in milliseconds |
94+| `asked` | object | all | present only in an agent that was itself asked by another one: `{"depth": <n>}`, 1 for an agent asked by the one a person started |
86 95
87 Empty fields are omitted.96 Empty fields are omitted.
88 97
98+### The `ask` object
99+
100+| Field | Type | Description |
101+|-------|------|-------------|
102+| `agent` | string | the asked agent's name, its key in `mmConfigs` |
103+| `config` | string | the asked agent's configuration file, absolute |
104+| `agentModel` | string | `<provider>/<model>` read from that file; absent when it cannot be read |
105+| `via` | string | `tool` (the `ask_agent` tool), `command` (`/ask`, `/ask-<name>`, `/ask-with-context-<name>`) or `cli` (`mm --ask`, `mm --ask-with-context`) |
106+| `withContext` | boolean | whether the conversation is sent along |
107+| `depth` | number | the asking agent's depth: 0 for the agent a person started |
108+| `prompt` | string | the question. In a `preAsk` chain, as the previous hook left it; in `postAsk`, as it was sent. |
109+| `transcript` | string | the conversation sent along (`withContext` only), in the same way; absent otherwise |
110+
89 ### Examples111 ### Examples
90 112
91 ```json113 ```json
@@ -96,6 +118,10 @@ Empty fields are omitted.
96 {"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}118 {"event":"preToolCall","cwd":"/home/me/project","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97 ```119 ```
98 120
121+```json
122+{"event":"postAsk","cwd":"/home/me/project","model":"llmman/…","ask":{"agent":"expert","config":"/home/me/project/agent.expert.yaml","agentModel":"llmman/…","via":"tool","withContext":false,"depth":0,"prompt":"What is an inode?"},"answer":"An inode is …","durationMs":25374}
123+```
124+
99 ## Reply125 ## Reply
100 126
101 The JSON object a hook may write on stdout. **An empty stdout (or whitespace only) is `{"action":"continue"}`.** Every field is optional.127 The JSON object a hook may write on stdout. **An empty stdout (or whitespace only) is `{"action":"continue"}`.** Every field is optional.
@@ -103,10 +129,12 @@ The JSON object a hook may write on stdout. **An empty stdout (or whitespace onl
103 | Field | Type | Read on | Description |129 | Field | Type | Read on | Description |
104 |-------|------|---------|-------------|130 |-------|------|---------|-------------|
105 | `action` | string | all | `continue` (default, also when absent), `stop`, `deny` or `error` — see below |131 | `action` | string | all | `continue` (default, also when absent), `stop`, `deny` or `error` — see below |
106-| `prompt` | string | `preGeneration` | replaces the question. Absent = unchanged; `""` = an empty question. |132+| `prompt` | string | `preGeneration`, `preAsk` | replaces the question — the user's, or the one sent to the asked agent. Absent = unchanged; `""` = an empty question. |
107-| `context` | string | `preGeneration`, `postToolCall` | text for the model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. |133+| `transcript` | string | `preAsk` | replaces the conversation sent to the asked agent. Absent = unchanged; `""` = send none. |
134+| `answer` | string | `postAsk` | replaces the answer the asking agent reads. Absent = unchanged. |
135+| `context` | string | `preGeneration`, `postToolCall`, `preAsk`, `postAsk` | text for a model. `preGeneration`: appended to the question. `postToolCall`: appended to the tool's output. `preAsk`: appended to what the asked agent receives. `postAsk`: appended to the answer the asking agent reads. Each one is introduced by the line `[context added by a hook]`. Blank = ignored. |
108 | `input` | any JSON value | `preToolCall` | replaces the tool call's arguments. Absent = unchanged. |136 | `input` | any JSON value | `preToolCall` | replaces the tool call's arguments. Absent = unchanged. |
109-| `reason` | string | `preGeneration` (`stop`), `preToolCall` (`deny`) | why. |137+| `reason` | string | `preGeneration` (`stop`), `preToolCall` and `preAsk` (`deny`) | why. |
110 | `response` | string | `preGeneration` (`stop`) | what the user reads instead of an answer. Absent or blank: `⛔ This request was blocked by a hook: <reason>`. |138 | `response` | string | `preGeneration` (`stop`) | what the user reads instead of an answer. Absent or blank: `⛔ This request was blocked by a hook: <reason>`. |
111 | `error` | string | all (`error`) | the hook's own failure message. |139 | `error` | string | all (`error`) | the hook's own failure message. |
112 140
@@ -118,7 +146,7 @@ Fields read on another event are ignored.
118 |--------|-----------|--------|146 |--------|-----------|--------|
119 | `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied |147 | `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied |
120 | `stop` | `preGeneration` | the turn ends without calling the model; the following hooks of the event do not run |148 | `stop` | `preGeneration` | the turn ends without calling the model; the following hooks of the event do not run |
121-| `deny` | `preToolCall` | the tool does not run; the following hooks of the event do not run |149+| `deny` | `preToolCall`, `preAsk` | the tool does not run, or the asked agent is not started nor asked; the following hooks of the event do not run |
122 | `error` | all | a failure, handled according to `onError` |150 | `error` | all | a failure, handled according to `onError` |
123 | anything else, or `stop`/`deny` on another event | — | a failure: `action "<action>" is not valid for <event>` |151 | anything else, or `stop`/`deny` on another event | — | a failure: `action "<action>" is not valid for <event>` |
124 152
@@ -152,6 +180,28 @@ The history keeps the tool call as the model wrote it, even when `input` replace
152 180
153 No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored.181 No effect on the turn. The reply is decoded (an invalid one is a failure) and otherwise ignored.
154 182
183+### `preAsk`
184+
185+| Decision | The asked agent | The user sees |
186+|----------|-----------------|---------------|
187+| no change | receives the question (preceded by the conversation with `withContext`) | the usual hand-off |
188+| `prompt`, `transcript`, `context` | receives the rewritten question and conversation, then each context | the usual hand-off |
189+| `deny` | is not started nor asked | `ask_agent`: line mode `🪝 ask_agent [ask denied by a hook: <reason>]`, window and editor a failed `ask_agent` call; the model reads `[ask denied by a hook: <reason>]`. `/ask…`: the command fails with `ask denied by a hook: <reason>`, the history is unchanged. `mm --ask`: `[ask: ask denied by a hook: <reason>]` on stderr, exit code 1. |
190+
191+A chain: each hook reads the `prompt` and `transcript` the previous one left; contexts accumulate in hook order. With `/ask…`, the history keeps the question as sent.
192+
193+### `postAsk`
194+
195+| Decision | The asking agent reads | The user sees |
196+|----------|------------------------|---------------|
197+| no change | the answer (`ask_agent`'s output, or the `[<agent>'s answer]` message of `/ask…`) | the answer, as it streamed |
198+| `answer` | the replacement | the answer as it streamed, then the notice `🪝 postAsk: <agent>'s answer was replaced by a hook — the asking agent reads the new one`; `ask_agent`'s call summary reads `<agent> answered; a hook replaced the answer (<n> characters)` |
199+| `context` | the answer, then `[context added by a hook]` and each context | the answer, as it streamed |
200+
201+The answer streams to the user while the asked agent writes it: `postAsk` changes what the asking agent reads and keeps, never what was shown. An `answer` on a failed hand-off (not a cancelled one) replaces the failure: the asking agent reads it as the answer, and `/ask…` keeps the exchange.
202+
203+With at least one `postAsk` hook declared, `mm --ask` holds the answer back and writes it to stdout once the hooks have run (replacement and context included); without one, it streams the answer as before.
204+
155 ## Failures205 ## Failures
156 206
157 A run fails when:207 A run fails when:
@@ -160,27 +210,27 @@ A run fails when:
160 |---------|--------|210 |---------|--------|
161 | the command exits with a non-zero status | `exit status <n>: <stderr>` |211 | the command exits with a non-zero status | `exit status <n>: <stderr>` |
162 | it runs longer than `timeout` | `timed out after <timeout>` |212 | it runs longer than `timeout` | `timed out after <timeout>` |
163-| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |213+| the turn is cancelled while it runs (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` |
164 | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` |214 | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` |
165 | stdout is not JSON | `invalid JSON on stdout: …` |215 | stdout is not JSON | `invalid JSON on stdout: …` |
166 | `action` is `error` | the reply's `error`, or `the hook reported an error` |216 | `action` is `error` | the reply's `error`, or `the hook reported an error` |
167 | `action` is unknown or not allowed on the event | `action "<action>" is not valid for <event>` |217 | `action` is unknown or not allowed on the event | `action "<action>" is not valid for <event>` |
168 218
169-`postGeneration` is not cancelled with the turn: it runs after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by its `timeout`.219+`postGeneration` and `postAsk` are not cancelled with the turn: they run after a cancellation too (Escape, Ctrl-C, `/abort`), bounded by their `timeout`.
170 220
171 What follows depends on `onError`:221 What follows depends on `onError`:
172 222
173-| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |223+| `onError` | `preGeneration` | `preToolCall`, `preAsk` | `postToolCall`, `postGeneration`, `postAsk` |
174-|-----------|-----------------|---------------|----------------------------------|224+|-----------|-----------------|-------------------------|---------------------------------------------|
175-| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call denied (`reason`: `hook failed: …`) | report line, carry on |225+| `fail` | report line, turn stopped (`response`: `⛔ This request was blocked by a hook: hook failed: …`) | report line, call or hand-off denied (`reason`: `hook failed: …`) | report line, carry on |
176 | `warn` | report line, next hook | report line, next hook | report line, carry on |226 | `warn` | report line, next hook | report line, next hook | report line, carry on |
177 | `ignore` | next hook, silently | next hook, silently | carry on, silently |227 | `ignore` | next hook, silently | next hook, silently | carry on, silently |
178 228
179-The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP.229+The report line is `🪝 <event> hook \`<command>\`: <failure>`: on stdout in the line mode, as a dim line of the transcript in the window, on stderr (the editor's agent logs) over ACP, on stderr with `mm --ask`.
180 230
181 ## Start-up banner231 ## Start-up banner
182 232
183-When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`:233+When at least one hook is declared, the banner ends with ` | hooks: <event>×<count> …`, one entry per event that has hooks, in the order `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk`, `postAsk`:
184 234
185 ```235 ```
186 mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1236 mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
@@ -192,9 +242,9 @@ Reported at start-up as `[config error: <path>: <message>]`, exit code 1.
192 242
193 | Message | Cause |243 | Message | Cause |
194 |---------|-------|244 |---------|-------|
195-| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) |245+| `hooks: unknown event "<name>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | a key of `hooks:` that is not an event (a typo: `preToolcall`) |
196 | `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one |246 | `hooks.<event>[N]: command must not be empty` | no `command`, or a blank one |
197-| `hooks.<event>[N]: matcher is only for preToolCall and postToolCall` | a `matcher` on `preGeneration` or `postGeneration` |247+| `hooks.<event>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | a `matcher` on `preGeneration` or `postGeneration` |
198 | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile |248 | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile |
199 | `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` |249 | `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` |
200 | `hooks.<event>[N]: onError must be "fail", "warn" or "ignore"` | any other `onError` |250 | `hooks.<event>[N]: onError must be "fail", "warn" or "ignore"` | any other `onError` |
@@ -203,4 +253,5 @@ Reported at start-up as `[config error: <path>: <message>]`, exit code 1.
203 253
204 - [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md)254 - [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md)
205 - [Hooks — explanation](../explanation/hooks.md)255 - [Hooks — explanation](../explanation/hooks.md)
206-- The demo: `demos/hooks/` (four Node.js hooks)256+- [Control the hand-offs to other agents with hooks](../how-to/control-agent-calls-with-hooks.md)
257+- The demos: `demos/hooks/` (four Node.js hooks), `demos/hooks-ask/` (`preAsk` and `postAsk` around an expert agent)
modified docs/en/reference/tools.md +1 -0
@@ -219,6 +219,7 @@ Declared when `mmConfigs` names at least one agent. Hands a task to one of them
219219 | Display | The asked agent's text, thoughts and tool calls stream through the turn, its tool calls titled `<agent> · <title>`. The window names it while it works: header `mm [<acp.title>] · <model> @ <provider> · asked by <this agent>`, its answer under `‣ mm [<acp.title>]` (its `mmConfigs` name when it has no title). Line mode: `🗣 ask_agent: <agent> (<provider>/<model>) — <first line of the prompt>`, then the agent's output. |
220220 | Watchdog | Each event of the asked agent (text, thought, tool call, answered permission) counts as a sign of life for this agent's watchdog. Silence longer than `watchdogTimeout` — a model loading, a long prompt being read — still ends the turn. |
221221 | Result | The answer, truncated to `maxOutput`. The tool call's own output in the window and the editor is `<agent> answered (N characters)`: the answer has already streamed. Not added to the history as a `/ask` exchange: it is a tool result. |
222+| Hooks | The asking agent's `preAsk` hooks run after the permission, before the agent is asked; its `postAsk` hooks run on the answer before the model reads it. A denied hand-off returns `[ask denied by a hook: <reason>]`; a replaced answer makes the call's output `<agent> answered; a hook replaced the answer (N characters)`; hook context follows the answer after `[context added by a hook]`. See [hooks](hooks.md#preask). |
222223 | Errors | `[ask_agent: unknown agent "x" — agents: a, b]`; `[ask_agent: the prompt is empty — say what <agent> must do]`; `[ask_agent: <agent> failed: <reason>]`; `[ask_agent: <agent> gave no answer]`; a cancelled or broken run returns what was answered, then `[ask_agent: <agent> stopped before finishing: <reason>]`. |
223224 | Depth | An asked agent may itself have `mmConfigs` and call `ask_agent`, up to two levels (`MM_ASK_DEPTH`); beyond, the call fails with the depth message. |
224225
@@ -219,6 +219,7 @@ Declared when `mmConfigs` names at least one agent. Hands a task to one of them
219 | Display | The asked agent's text, thoughts and tool calls stream through the turn, its tool calls titled `<agent> · <title>`. The window names it while it works: header `mm [<acp.title>] · <model> @ <provider> · asked by <this agent>`, its answer under `‣ mm [<acp.title>]` (its `mmConfigs` name when it has no title). Line mode: `🗣 ask_agent: <agent> (<provider>/<model>) — <first line of the prompt>`, then the agent's output. |219 | Display | The asked agent's text, thoughts and tool calls stream through the turn, its tool calls titled `<agent> · <title>`. The window names it while it works: header `mm [<acp.title>] · <model> @ <provider> · asked by <this agent>`, its answer under `‣ mm [<acp.title>]` (its `mmConfigs` name when it has no title). Line mode: `🗣 ask_agent: <agent> (<provider>/<model>) — <first line of the prompt>`, then the agent's output. |
220 | Watchdog | Each event of the asked agent (text, thought, tool call, answered permission) counts as a sign of life for this agent's watchdog. Silence longer than `watchdogTimeout` — a model loading, a long prompt being read — still ends the turn. |220 | Watchdog | Each event of the asked agent (text, thought, tool call, answered permission) counts as a sign of life for this agent's watchdog. Silence longer than `watchdogTimeout` — a model loading, a long prompt being read — still ends the turn. |
221 | Result | The answer, truncated to `maxOutput`. The tool call's own output in the window and the editor is `<agent> answered (N characters)`: the answer has already streamed. Not added to the history as a `/ask` exchange: it is a tool result. |221 | Result | The answer, truncated to `maxOutput`. The tool call's own output in the window and the editor is `<agent> answered (N characters)`: the answer has already streamed. Not added to the history as a `/ask` exchange: it is a tool result. |
222+| Hooks | The asking agent's `preAsk` hooks run after the permission, before the agent is asked; its `postAsk` hooks run on the answer before the model reads it. A denied hand-off returns `[ask denied by a hook: <reason>]`; a replaced answer makes the call's output `<agent> answered; a hook replaced the answer (N characters)`; hook context follows the answer after `[context added by a hook]`. See [hooks](hooks.md#preask). |
222 | Errors | `[ask_agent: unknown agent "x" — agents: a, b]`; `[ask_agent: the prompt is empty — say what <agent> must do]`; `[ask_agent: <agent> failed: <reason>]`; `[ask_agent: <agent> gave no answer]`; a cancelled or broken run returns what was answered, then `[ask_agent: <agent> stopped before finishing: <reason>]`. |223 | Errors | `[ask_agent: unknown agent "x" — agents: a, b]`; `[ask_agent: the prompt is empty — say what <agent> must do]`; `[ask_agent: <agent> failed: <reason>]`; `[ask_agent: <agent> gave no answer]`; a cancelled or broken run returns what was answered, then `[ask_agent: <agent> stopped before finishing: <reason>]`. |
223 | Depth | An asked agent may itself have `mmConfigs` and call `ask_agent`, up to two levels (`MM_ASK_DEPTH`); beyond, the call fails with the depth message. |224 | Depth | An asked agent may itself have `mmConfigs` and call `ask_agent`, up to two levels (`MM_ASK_DEPTH`); beyond, the call fails with the depth message. |
224 225
modified docs/fr/README.md +3 -2
@@ -36,6 +36,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
3636 - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53
3737 - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher
3838 - [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage
39+- [Contrôler les relais vers d'autres agents avec des hooks](how-to/control-agent-calls-with-hooks.md) — refuser, réécrire ou masquer ce qu'un agent envoie à un autre, vérifier ou remplacer ce qui revient (`preAsk`, `postAsk`)
3940
4041 ### Guides pratiques MCP (Model Context Protocol)
4142 - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md)
@@ -49,7 +50,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
4950 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
5051 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
5152 - [Format d'un fichier de skill](reference/skill-format.md)
52-- [Les hooks](reference/hooks.md) — les quatre événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
53+- [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
5354 - [Surface Agent Client Protocol](reference/acp.md)
5455
5556 ### Référence MCP (Model Context Protocol)
@@ -71,7 +72,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
7172 - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse
7273 - [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, `ask_agent` et le `yolo` par agent, la limite de profondeur
7374 - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP
74-- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté
75+- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté, pourquoi `preAsk`/`postAsk` appartiennent à l'agent qui appelle
7576 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
7677
7778 ### Explication MCP (Model Context Protocol)
@@ -36,6 +36,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
36 - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 5336 - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53
37 - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher37 - [Récupérer une page web en Markdown (`fetch_content`)](how-to/fetch-a-web-page.md) — lire une page, ou l'enregistrer dans un fichier sans l'afficher
38 - [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage38 - [Protéger l'agent avec des hooks](how-to/guard-the-agent-with-hooks.md) — refuser un appel d'outil, arrêter ou réécrire une question, ajouter du contexte, journaliser chaque tour, avec un script dans n'importe quel langage
39+- [Contrôler les relais vers d'autres agents avec des hooks](how-to/control-agent-calls-with-hooks.md) — refuser, réécrire ou masquer ce qu'un agent envoie à un autre, vérifier ou remplacer ce qui revient (`preAsk`, `postAsk`)
39 40
40 ### Guides pratiques MCP (Model Context Protocol)41 ### Guides pratiques MCP (Model Context Protocol)
41 - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md)42 - [Utiliser Context7 pour la documentation](how-to/mcp-utiliser-context7.md)
@@ -49,7 +50,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
49 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation50 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
50 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`51 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`
51 - [Format d'un fichier de skill](reference/skill-format.md)52 - [Format d'un fichier de skill](reference/skill-format.md)
52-- [Les hooks](reference/hooks.md) — les quatre événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation53+- [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
53 - [Surface Agent Client Protocol](reference/acp.md)54 - [Surface Agent Client Protocol](reference/acp.md)
54 55
55 ### Référence MCP (Model Context Protocol)56 ### Référence MCP (Model Context Protocol)
@@ -71,7 +72,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
71 - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse72 - [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse
72 - [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, `ask_agent` et le `yolo` par agent, la limite de profondeur73 - [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, `ask_agent` et le `yolo` par agent, la limite de profondeur
73 - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP74 - [Interroger l'utilisateur](explanation/asking-the-user.md) — pourquoi `ask_user` demande à chaque interface à sa façon, comment remontent les questions d'un agent appelé, le schéma souple (la grammaire alphabétique de llama.cpp), le chien de garde pendant qu'un humain répond, le manque du SDK ACP
74-- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté75+- [Les hooks](explanation/hooks.md) — pourquoi des commandes externes, pourquoi ils se branchent sur `engine.Generate` et `WrapTool`, pourquoi une question arrêtée laisse un marqueur, ce que le ticket 36 a laissé de côté, pourquoi `preAsk`/`postAsk` appartiennent à l'agent qui appelle
75 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)76 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
76 77
77 ### Explication MCP (Model Context Protocol)78 ### Explication MCP (Model Context Protocol)
modified docs/fr/explanation/architecture.md +2 -2
@@ -17,7 +17,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
1717 | `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`. |
1818 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
1919 | `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. |
20-| `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 ; le même pool sert l'outil `ask_agent` et `mm --ask`. Voir [Interroger un autre agent](asking-another-agent.md). |
20+| `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 ; le même pool sert l'outil `ask_agent` et `mm --ask`, et `Pool.Delegate` — le seul chemin de tout relais — exécute autour de lui les hooks `preAsk` et `postAsk` de l'agent qui appelle. Voir [Interroger un autre agent](asking-another-agent.md). |
2121 | `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. |
2222 | `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`. |
2323 | `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. |
@@ -27,7 +27,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
2727 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
2828 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
2929 | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). |
30-| `hooks` | Le bloc `hooks:` (ticket 36) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). |
30+| `hooks` | Le bloc `hooks:` (tickets 36 et 87) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`, et `PreAsk`, `PostAsk` pour `ask`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). |
3131 | `agent` | Le REPL du terminal. |
3232 | `acp` | La façade Agent Client Protocol. |
3333 | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |
@@ -17,7 +17,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
17 | `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`. |17 | `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`. |
18 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |18 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
19 | `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. |19 | `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. |
20-| `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 ; le même pool sert l'outil `ask_agent` et `mm --ask`. Voir [Interroger un autre agent](asking-another-agent.md). |20+| `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 ; le même pool sert l'outil `ask_agent` et `mm --ask`, et `Pool.Delegate` — le seul chemin de tout relais — exécute autour de lui les hooks `preAsk` et `postAsk` de l'agent qui appelle. Voir [Interroger un autre agent](asking-another-agent.md). |
21 | `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. |21 | `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. |
22 | `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`. |22 | `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`. |
23 | `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. |23 | `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. |
@@ -27,7 +27,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
27 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |27 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
28 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |28 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
29 | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). |29 | `consent` | Si un appel d'outil peut s'exécuter : l'interrupteur `yolo` (`Gate`), les quatre réponses (`Answer`), ce qu'une session retient par outil (`Policy`), et le point d'accroche par lequel le mode ligne demande (`SetPrompter`). La fenêtre, la façade éditeur et `/ask` dessinent la question à leur manière et partagent ces règles. Voir [Le mode yolo et les permissions](yolo-and-permissions.md). |
30-| `hooks` | Le bloc `hooks:` (ticket 36) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). |30+| `hooks` | Le bloc `hooks:` (tickets 36 et 87) : lance chaque hook avec `sh -c`, écrit son entrée JSON, lit sa réponse, applique l'ordre de la chaîne et `onError`, et renvoie des décisions (`PreGeneration`, `PreToolCall`, `PostToolCall`, `PostGeneration`, et `PreAsk`, `PostAsk` pour `ask`). Ni Genkit ni front end : `engine/hooks.go` applique les décisions autour de `Generate` et dans le middleware `WrapTool`, si bien que tous les front ends en profitent. Voir [Les hooks](hooks.md). |
31 | `agent` | Le REPL du terminal. |31 | `agent` | Le REPL du terminal. |
32 | `acp` | La façade Agent Client Protocol. |32 | `acp` | La façade Agent Client Protocol. |
33 | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |33 | `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |
modified docs/fr/explanation/asking-another-agent.md +1 -0
@@ -42,4 +42,5 @@
4242 - L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté.
4343 - 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.
4444 - 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.
45+- Les [hooks](hooks.md#des-hooks-autour-des-appels-dagents) `preAsk` et `postAsk` de l'agent qui interroge s'exécutent autour de chaque relais — `/ask`, `ask_agent` et `mm --ask` : ils peuvent le refuser, réécrire ce qui part, et remplacer ce qui revient. L'enfant n'exécute que les hooks de son propre fichier.
4546 - 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).
@@ -42,4 +42,5 @@
42 - L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté.42 - L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté.
43 - 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.43 - 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.
44 - 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.44 - 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.
45+- Les [hooks](hooks.md#des-hooks-autour-des-appels-dagents) `preAsk` et `postAsk` de l'agent qui interroge s'exécutent autour de chaque relais — `/ask`, `ask_agent` et `mm --ask` : ils peuvent le refuser, réécrire ce qui part, et remplacer ce qui revient. L'enfant n'exécute que les hooks de son propre fichier.
45 - 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).46 - 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).
modified docs/fr/explanation/hooks.md +21 -4
@@ -2,9 +2,9 @@
22
33 ## De quoi s'agit-il ?
44
5-Un hook est une commande que `mm` lance à quatre moments d'un tour : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), et une fois le tour terminé (`postGeneration`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`.
5+Un hook est une commande que `mm` lance à des moments précis : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), une fois le tour terminé (`postGeneration`), et autour de chaque relais vers un autre agent (`preAsk`, `postAsk`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`.
66
7-La conception part du ticket 36, qui explorait un système bien plus vaste. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté.
7+La conception part du ticket 36, qui explorait un système bien plus vaste ; le ticket 87 a ajouté les deux événements de relais. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté.
88
99 ## Pourquoi des commandes externes
1010
@@ -50,6 +50,23 @@ Le paquet `internal/hooks` lance les commandes et transforme leurs réponses en
5050
5151 **Des arguments d'outil remplacés ne sont pas réécrits dans l'historique.** L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard.
5252
53+## Des hooks autour des appels d'agents
54+
55+Un agent peut confier une question à un autre : le modèle appelle `ask_agent`, l'utilisateur tape `/ask-<nom>`, ou une skill lance `mm --ask`. L'agent appelé est un processus `mm` distinct avec son propre fichier : il exécute ses propres hooks. Cela couvre ce qui appartient à *cet* agent, quel que soit celui qui l'appelle. Cela ne couvre pas ce qui appartient au *relais* : ce qui peut sortir de cet agent, vers qui, et ce que cet agent fait de ce qui revient. Ces règles sont celles de l'auteur de l'agent qui appelle, et le fichier de l'agent appelé n'est peut-être même pas le sien.
56+
57+Avant le ticket 87, la seule prise était `preToolCall`/`postToolCall` avec `matcher: ^ask_agent$`, et elle était insuffisante de quatre façons : les commandes `/ask` et `mm --ask` ne passent pas par `engine.Generate`, donc aucun hook ne s'exécutait pour elles ; `preToolCall` voyait les arguments du modèle mais pas la conversation qu'ajouterait `with_context`, construite à l'intérieur de l'outil ; `postToolCall` ne pouvait qu'ajouter du texte à une réponse déjà tronquée, jamais la remplacer ; et un échec lui arrivait sous forme de texte mêlé à la sortie.
58+
59+`preAsk` et `postAsk` se branchent donc au seul endroit par lequel passe chaque relais, `ask.Pool.Delegate` : l'outil, les commandes et la ligne de commande l'appellent tous. `preAsk` voit la question et la conversation **avant** qu'elles soient réunies dans le prompt que reçoit l'agent appelé, et peut donc réécrire l'une ou l'autre ; `postAsk` voit la réponse entière et l'erreur séparément, avant que la réponse soit tronquée en sortie d'outil ou gardée dans l'historique.
60+
61+Quatre décisions, prises avec l'utilisateur :
62+
63+- **`preAsk` s'exécute après la demande d'autorisation d'`ask_agent`.** L'utilisateur approuve ce que le modèle a demandé ; le hook filtre ou réécrit ensuite. L'ordre inverse ferait approuver à l'utilisateur une question qu'il ne voit jamais.
64+- **Pas d'action `retry`.** Un hook qui relance la question démarrerait un second relais à l'intérieur du premier, avec sa propre autorisation et son propre budget. Quand une réponse ne convient pas, `postAsk` le dit dans `context`, et le modèle qui appelle décide de redemander ou non — via `ask_agent`, où toutes les règles s'appliquent à nouveau.
65+- **`mm --ask` exécute les hooks** du fichier qu'il lit, comme les deux autres chemins. Avec un hook `postAsk`, il retient la réponse jusqu'à ce que les hooks aient tourné, parce que sa sortie standard est ce que lit le script appelant.
66+- **Pas de redirection.** `preAsk` ne peut pas changer l'agent appelé : la demande d'autorisation et l'appel d'outil nomment l'agent choisi par le modèle, et un hook qui le remplacerait en silence rendrait les deux faux.
67+
68+Une limite découle du trajet de la réponse : elle s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit. `postAsk` change ce que l'agent qui appelle lit et retient, pas ce qui a été montré — la même règle que pour `postToolCall`. Une réponse remplacée est annoncée sur la ligne de notice, pour que les deux ne divergent jamais sans qu'on le voie.
69+
5370 ## Les alternatives écartées
5471
5572 Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi :
@@ -63,7 +80,7 @@ Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé
6380 ## Les liens avec le reste
6481
6582 - **Yolo et autorisations.** Les hooks et `yolo: false` se complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir [Le mode yolo et les permissions](yolo-and-permissions.md).
66-- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge.
83+- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge ; ses hooks voient `"asked":{"depth":<n>}`. Les `preAsk` et `postAsk` de l'agent qui interroge encadrent le relais. Voir [Interroger un autre agent](asking-another-agent.md).
6784 - **La compression du contexte** demande un résumé au modèle par un autre chemin que `Generate` ; les hooks ne s'y exécutent pas.
6885
69-Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, et [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un.
86+Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un, et [Contrôler les relais vers d'autres agents avec des hooks](../how-to/control-agent-calls-with-hooks.md) pour `preAsk` et `postAsk`.
@@ -2,9 +2,9 @@
2 2
3 ## De quoi s'agit-il ?3 ## De quoi s'agit-il ?
4 4
5-Un hook est une commande que `mm` lance à quatre moments d'un tour : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), et une fois le tour terminé (`postGeneration`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`.5+Un hook est une commande que `mm` lance à des moments précis : avant que le modèle lise une question (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`), une fois le tour terminé (`postGeneration`), et autour de chaque relais vers un autre agent (`preAsk`, `postAsk`). Il lit un objet JSON qui décrit le moment, et peut répondre par une décision : réécrire la question, ajouter du contexte, arrêter le tour, refuser un appel, remplacer ses arguments. Cela permet à un projet de poser ses propres règles autour de l'agent — ce qui ne doit jamais s'exécuter, ce qui ne doit jamais être envoyé, ce qui doit être journalisé — sans toucher au code de `mm`.
6 6
7-La conception part du ticket 36, qui explorait un système bien plus vaste. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté.7+La conception part du ticket 36, qui explorait un système bien plus vaste ; le ticket 87 a ajouté les deux événements de relais. Cette page explique ce qui a été gardé, où les hooks se branchent, et ce qui a été laissé de côté.
8 8
9 ## Pourquoi des commandes externes9 ## Pourquoi des commandes externes
10 10
@@ -50,6 +50,23 @@ Le paquet `internal/hooks` lance les commandes et transforme leurs réponses en
50 50
51 **Des arguments d'outil remplacés ne sont pas réécrits dans l'historique.** L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard.51 **Des arguments d'outil remplacés ne sont pas réécrits dans l'historique.** L'outil s'exécute avec les arguments du hook, mais l'historique garde ce que le modèle a demandé. La requête du modèle est le compte rendu fidèle de ce qu'il a fait ; un fichier de session qui montrerait des arguments que le modèle n'a jamais écrits tromperait celui qui le lit plus tard.
52 52
53+## Des hooks autour des appels d'agents
54+
55+Un agent peut confier une question à un autre : le modèle appelle `ask_agent`, l'utilisateur tape `/ask-<nom>`, ou une skill lance `mm --ask`. L'agent appelé est un processus `mm` distinct avec son propre fichier : il exécute ses propres hooks. Cela couvre ce qui appartient à *cet* agent, quel que soit celui qui l'appelle. Cela ne couvre pas ce qui appartient au *relais* : ce qui peut sortir de cet agent, vers qui, et ce que cet agent fait de ce qui revient. Ces règles sont celles de l'auteur de l'agent qui appelle, et le fichier de l'agent appelé n'est peut-être même pas le sien.
56+
57+Avant le ticket 87, la seule prise était `preToolCall`/`postToolCall` avec `matcher: ^ask_agent$`, et elle était insuffisante de quatre façons : les commandes `/ask` et `mm --ask` ne passent pas par `engine.Generate`, donc aucun hook ne s'exécutait pour elles ; `preToolCall` voyait les arguments du modèle mais pas la conversation qu'ajouterait `with_context`, construite à l'intérieur de l'outil ; `postToolCall` ne pouvait qu'ajouter du texte à une réponse déjà tronquée, jamais la remplacer ; et un échec lui arrivait sous forme de texte mêlé à la sortie.
58+
59+`preAsk` et `postAsk` se branchent donc au seul endroit par lequel passe chaque relais, `ask.Pool.Delegate` : l'outil, les commandes et la ligne de commande l'appellent tous. `preAsk` voit la question et la conversation **avant** qu'elles soient réunies dans le prompt que reçoit l'agent appelé, et peut donc réécrire l'une ou l'autre ; `postAsk` voit la réponse entière et l'erreur séparément, avant que la réponse soit tronquée en sortie d'outil ou gardée dans l'historique.
60+
61+Quatre décisions, prises avec l'utilisateur :
62+
63+- **`preAsk` s'exécute après la demande d'autorisation d'`ask_agent`.** L'utilisateur approuve ce que le modèle a demandé ; le hook filtre ou réécrit ensuite. L'ordre inverse ferait approuver à l'utilisateur une question qu'il ne voit jamais.
64+- **Pas d'action `retry`.** Un hook qui relance la question démarrerait un second relais à l'intérieur du premier, avec sa propre autorisation et son propre budget. Quand une réponse ne convient pas, `postAsk` le dit dans `context`, et le modèle qui appelle décide de redemander ou non — via `ask_agent`, où toutes les règles s'appliquent à nouveau.
65+- **`mm --ask` exécute les hooks** du fichier qu'il lit, comme les deux autres chemins. Avec un hook `postAsk`, il retient la réponse jusqu'à ce que les hooks aient tourné, parce que sa sortie standard est ce que lit le script appelant.
66+- **Pas de redirection.** `preAsk` ne peut pas changer l'agent appelé : la demande d'autorisation et l'appel d'outil nomment l'agent choisi par le modèle, et un hook qui le remplacerait en silence rendrait les deux faux.
67+
68+Une limite découle du trajet de la réponse : elle s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit. `postAsk` change ce que l'agent qui appelle lit et retient, pas ce qui a été montré — la même règle que pour `postToolCall`. Une réponse remplacée est annoncée sur la ligne de notice, pour que les deux ne divergent jamais sans qu'on le voie.
69+
53 ## Les alternatives écartées70 ## Les alternatives écartées
54 71
55 Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi :72 Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé de côté, et pourquoi :
@@ -63,7 +80,7 @@ Le ticket 36 explorait plus que ce qui a été construit. Ce qui a été laissé
63 ## Les liens avec le reste80 ## Les liens avec le reste
64 81
65 - **Yolo et autorisations.** Les hooks et `yolo: false` se complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir [Le mode yolo et les permissions](yolo-and-permissions.md).82 - **Yolo et autorisations.** Les hooks et `yolo: false` se complètent : un hook décide selon une règle, sans humain ; l'autorisation demande à un humain, au cas par cas. Le hook passe en premier. Voir [Le mode yolo et les permissions](yolo-and-permissions.md).
66-- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge.83+- **Interroger un autre agent.** Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` est un processus `mm` distinct avec son propre fichier de configuration : il exécute **ses propres** hooks, pas ceux de l'agent qui l'interroge ; ses hooks voient `"asked":{"depth":<n>}`. Les `preAsk` et `postAsk` de l'agent qui interroge encadrent le relais. Voir [Interroger un autre agent](asking-another-agent.md).
67 - **La compression du contexte** demande un résumé au modèle par un autre chemin que `Generate` ; les hooks ne s'y exécutent pas.84 - **La compression du contexte** demande un résumé au modèle par un autre chemin que `Generate` ; les hooks ne s'y exécutent pas.
68 85
69-Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, et [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un.86+Voir la [référence des hooks](../reference/hooks.md) pour chaque champ, [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) pour en écrire un, et [Contrôler les relais vers d'autres agents avec des hooks](../how-to/control-agent-calls-with-hooks.md) pour `preAsk` et `postAsk`.
modified docs/fr/how-to/ask-another-agent-from-a-skill.md +1 -0
@@ -78,3 +78,4 @@ Ce guide montre comment faire poser une question à un autre agent mm par une sk
7878 - Toutes les options, flux et codes de sortie : [référence de la ligne de commande](../reference/cli.md#spécificités-du-mode-ask)
7979 - `bashTimeout` et `mmConfigs` : [référence de configuration](../reference/configuration.md)
8080 - Pourquoi l'agent est un processus enfant, et ce que `--ask` ajoute pour les scripts : [interroger un autre agent — explication](../explanation/asking-another-agent.md)
81+- Les hooks `preAsk` et `postAsk` s'exécutent aussi pour `mm --ask` ; avec un hook `postAsk`, stdout porte la réponse une fois les hooks exécutés : [contrôler les relais vers d'autres agents avec des hooks](control-agent-calls-with-hooks.md)
@@ -78,3 +78,4 @@ Ce guide montre comment faire poser une question à un autre agent mm par une sk
78 - Toutes les options, flux et codes de sortie : [référence de la ligne de commande](../reference/cli.md#spécificités-du-mode-ask)78 - Toutes les options, flux et codes de sortie : [référence de la ligne de commande](../reference/cli.md#spécificités-du-mode-ask)
79 - `bashTimeout` et `mmConfigs` : [référence de configuration](../reference/configuration.md)79 - `bashTimeout` et `mmConfigs` : [référence de configuration](../reference/configuration.md)
80 - Pourquoi l'agent est un processus enfant, et ce que `--ask` ajoute pour les scripts : [interroger un autre agent — explication](../explanation/asking-another-agent.md)80 - Pourquoi l'agent est un processus enfant, et ce que `--ask` ajoute pour les scripts : [interroger un autre agent — explication](../explanation/asking-another-agent.md)
81+- Les hooks `preAsk` et `postAsk` s'exécutent aussi pour `mm --ask` ; avec un hook `postAsk`, stdout porte la réponse une fois les hooks exécutés : [contrôler les relais vers d'autres agents avec des hooks](control-agent-calls-with-hooks.md)
added docs/fr/how-to/control-agent-calls-with-hooks.md +104 -0
new file mode 100644
@@ -0,0 +1,104 @@
1+# Contrôler les relais vers d'autres agents avec des hooks
2+
3+Ce guide montre comment un agent qui en appelle d'autres (`ask_agent`, `/ask-<nom>`, `mm --ask`) peut vérifier, réécrire ou refuser chaque question avant qu'elle parte, et vérifier ou remplacer chaque réponse quand elle revient. Il suppose un agent avec au moins une entrée dans `mmConfigs` (voir [Laisser l'agent appeler d'autres agents](let-the-agent-call-other-agents.md)), et que vous savez écrire un hook (voir [Protéger l'agent avec des hooks](guard-the-agent-with-hooks.md)). Les exemples sont en Node.js.
4+
5+Les hooks vont dans le fichier de l'agent **qui appelle**. L'agent appelé n'a pas à changer, ni même à le savoir.
6+
7+## Étapes
8+
9+### 1. Choisir l'événement
10+
11+| Vous voulez… | Événement | Réponse |
12+|--------------|-----------|---------|
13+| refuser un relais (un budget, un agent qu'on ne doit pas appeler d'ici) | `preAsk` | `{"action":"deny","reason":"…"}` |
14+| masquer des secrets, ou couper la conversation envoyée avec `with_context` | `preAsk` | `{"prompt":"…","transcript":"…"}` |
15+| donner des consignes permanentes à l'agent appelé (langue, longueur, format) | `preAsk` | `{"context":"…"}` |
16+| transformer un échec en quelque chose que le modèle qui appelle sait traiter | `postAsk` | `{"answer":"…"}` |
17+| vérifier le format de la réponse, ajouter une note pour le modèle qui appelle | `postAsk` | `{"context":"…"}` |
18+| journaliser chaque relais | `postAsk` | rien |
19+
20+### 2. Écrire le hook
21+
22+Ce hook `preAsk` ajoute des consignes permanentes à chaque question envoyée à l'agent `expert` :
23+
24+```js
25+// hooks/house-rules.js
26+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
27+
28+const rules = ["Answer in the language of the question.", "At most 120 words."];
29+// Via ask_agent, la réponse est lue par le modèle qui appelle, pas par une personne.
30+if (input.ask.via === "tool") rules.push("No greetings, no questions back.");
31+
32+console.log(JSON.stringify({ context: rules.join("\n") }));
33+```
34+
35+Et ce hook `postAsk` journalise le relais et, quand l'agent appelé a échoué, remplace l'erreur par une consigne pour le modèle qui appelle :
36+
37+```js
38+// hooks/after-ask.js
39+const fs = require("fs");
40+const path = require("path");
41+const input = JSON.parse(fs.readFileSync(0, "utf8"));
42+
43+const line = { agent: input.ask.agent, via: input.ask.via, prompt: input.ask.prompt, durationMs: input.durationMs, error: input.error };
44+fs.mkdirSync(path.join(input.cwd, ".mm"), { recursive: true });
45+fs.appendFileSync(path.join(input.cwd, ".mm", "ask-audit.jsonl"), JSON.stringify(line) + "\n");
46+
47+if (input.error && !(input.answer || "").trim()) {
48+ console.log(JSON.stringify({ answer: `The ${input.ask.agent} agent is unavailable. Tell the user in one sentence; do not answer yourself.` }));
49+}
50+```
51+
52+### 3. Les essayer sans mm
53+
54+```bash
55+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"Qu'"'"'est-ce qu'"'"'un inode ?"}}' \
56+ | node hooks/house-rules.js
57+```
58+
59+Vous devriez voir :
60+
61+```
62+{"context":"Answer in the language of the question.\nAt most 120 words.\nNo greetings, no questions back."}
63+```
64+
65+### 4. Les déclarer dans le fichier de l'agent qui appelle
66+
67+```yaml
68+mmConfigs:
69+ expert:
70+ path: agent.expert.yaml
71+ description: Technical expert. For every technical question.
72+
73+hooks:
74+ preAsk:
75+ - matcher: ^expert$
76+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
77+ postAsk:
78+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
79+```
80+
81+`matcher` est une expression régulière sur le nom de l'agent appelé ; sans lui, le hook s'exécute pour tous les agents.
82+
83+### 5. Démarrer l'agent et vérifier
84+
85+```bash
86+mm agent.yaml
87+```
88+
89+La bannière se termine par `hooks: preAsk×1 postAsk×1`. Posez une question que l'agent transmet, puis regardez `.mm/ask-audit.jsonl` : une ligne par relais, avec `"via":"tool"`. Tapez `/ask-expert <question>` et la ligne indique `"via":"command"`.
90+
91+## Variantes
92+
93+- **Un budget par question.** Déclarez le même script sur `preGeneration` (remise à zéro d'un fichier compteur) et sur `preAsk` (comptage, et `deny` au-delà de la limite). Ne comptez que `"via":"tool"` : un relais tapé par l'utilisateur, ou lancé par un script, n'est pas le fait du modèle.
94+- **Masquer des secrets.** Sur `preAsk`, réécrivez `input.ask.prompt` et `input.ask.transcript` et renvoyez les deux. `"transcript":""` n'envoie aucune conversation.
95+- **Un garde-fou.** Mettez `onError: fail` sur un hook `preAsk` qui ne doit pas être sauté : quand il ne peut pas s'exécuter, le relais est refusé.
96+- **Avec `yolo: false`**, l'utilisateur approuve d'abord l'appel `ask_agent` ; `preAsk` s'exécute après cette approbation, sur la question écrite par le modèle.
97+- **`mm --ask` depuis une skill.** Les hooks du fichier qu'il lit s'exécutent aussi (`"via":"cli"`). Avec un hook `postAsk`, stdout porte la réponse une fois les hooks exécutés : le script appelant lit le remplacement.
98+- **Les hooks propres à l'agent appelé.** Il exécute les hooks de son propre fichier, qui voient `"asked":{"depth":1}`. Servez-vous-en pour ce qui appartient à cet agent quel que soit l'appelant ; gardez `preAsk`/`postAsk` pour ce qui appartient à ce relais.
99+
100+## Voir aussi
101+
102+- Tous les champs et leurs effets : [Référence des hooks](../reference/hooks.md#preask)
103+- Pourquoi ces hooks appartiennent à l'agent qui appelle : [Les hooks — explication](../explanation/hooks.md#des-hooks-autour-des-appels-dagents)
104+- Un exemple complet : `demos/hooks-ask/`
new file mode 100644
@@ -0,0 +1,104 @@
1+# Contrôler les relais vers d'autres agents avec des hooks
2+
3+Ce guide montre comment un agent qui en appelle d'autres (`ask_agent`, `/ask-<nom>`, `mm --ask`) peut vérifier, réécrire ou refuser chaque question avant qu'elle parte, et vérifier ou remplacer chaque réponse quand elle revient. Il suppose un agent avec au moins une entrée dans `mmConfigs` (voir [Laisser l'agent appeler d'autres agents](let-the-agent-call-other-agents.md)), et que vous savez écrire un hook (voir [Protéger l'agent avec des hooks](guard-the-agent-with-hooks.md)). Les exemples sont en Node.js.
4+
5+Les hooks vont dans le fichier de l'agent **qui appelle**. L'agent appelé n'a pas à changer, ni même à le savoir.
6+
7+## Étapes
8+
9+### 1. Choisir l'événement
10+
11+| Vous voulez… | Événement | Réponse |
12+|--------------|-----------|---------|
13+| refuser un relais (un budget, un agent qu'on ne doit pas appeler d'ici) | `preAsk` | `{"action":"deny","reason":"…"}` |
14+| masquer des secrets, ou couper la conversation envoyée avec `with_context` | `preAsk` | `{"prompt":"…","transcript":"…"}` |
15+| donner des consignes permanentes à l'agent appelé (langue, longueur, format) | `preAsk` | `{"context":"…"}` |
16+| transformer un échec en quelque chose que le modèle qui appelle sait traiter | `postAsk` | `{"answer":"…"}` |
17+| vérifier le format de la réponse, ajouter une note pour le modèle qui appelle | `postAsk` | `{"context":"…"}` |
18+| journaliser chaque relais | `postAsk` | rien |
19+
20+### 2. Écrire le hook
21+
22+Ce hook `preAsk` ajoute des consignes permanentes à chaque question envoyée à l'agent `expert` :
23+
24+```js
25+// hooks/house-rules.js
26+const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
27+
28+const rules = ["Answer in the language of the question.", "At most 120 words."];
29+// Via ask_agent, la réponse est lue par le modèle qui appelle, pas par une personne.
30+if (input.ask.via === "tool") rules.push("No greetings, no questions back.");
31+
32+console.log(JSON.stringify({ context: rules.join("\n") }));
33+```
34+
35+Et ce hook `postAsk` journalise le relais et, quand l'agent appelé a échoué, remplace l'erreur par une consigne pour le modèle qui appelle :
36+
37+```js
38+// hooks/after-ask.js
39+const fs = require("fs");
40+const path = require("path");
41+const input = JSON.parse(fs.readFileSync(0, "utf8"));
42+
43+const line = { agent: input.ask.agent, via: input.ask.via, prompt: input.ask.prompt, durationMs: input.durationMs, error: input.error };
44+fs.mkdirSync(path.join(input.cwd, ".mm"), { recursive: true });
45+fs.appendFileSync(path.join(input.cwd, ".mm", "ask-audit.jsonl"), JSON.stringify(line) + "\n");
46+
47+if (input.error && !(input.answer || "").trim()) {
48+ console.log(JSON.stringify({ answer: `The ${input.ask.agent} agent is unavailable. Tell the user in one sentence; do not answer yourself.` }));
49+}
50+```
51+
52+### 3. Les essayer sans mm
53+
54+```bash
55+echo '{"event":"preAsk","cwd":"/tmp","ask":{"agent":"expert","via":"tool","prompt":"Qu'"'"'est-ce qu'"'"'un inode ?"}}' \
56+ | node hooks/house-rules.js
57+```
58+
59+Vous devriez voir :
60+
61+```
62+{"context":"Answer in the language of the question.\nAt most 120 words.\nNo greetings, no questions back."}
63+```
64+
65+### 4. Les déclarer dans le fichier de l'agent qui appelle
66+
67+```yaml
68+mmConfigs:
69+ expert:
70+ path: agent.expert.yaml
71+ description: Technical expert. For every technical question.
72+
73+hooks:
74+ preAsk:
75+ - matcher: ^expert$
76+ command: node "$MM_CONFIG_DIR/hooks/house-rules.js"
77+ postAsk:
78+ - command: node "$MM_CONFIG_DIR/hooks/after-ask.js"
79+```
80+
81+`matcher` est une expression régulière sur le nom de l'agent appelé ; sans lui, le hook s'exécute pour tous les agents.
82+
83+### 5. Démarrer l'agent et vérifier
84+
85+```bash
86+mm agent.yaml
87+```
88+
89+La bannière se termine par `hooks: preAsk×1 postAsk×1`. Posez une question que l'agent transmet, puis regardez `.mm/ask-audit.jsonl` : une ligne par relais, avec `"via":"tool"`. Tapez `/ask-expert <question>` et la ligne indique `"via":"command"`.
90+
91+## Variantes
92+
93+- **Un budget par question.** Déclarez le même script sur `preGeneration` (remise à zéro d'un fichier compteur) et sur `preAsk` (comptage, et `deny` au-delà de la limite). Ne comptez que `"via":"tool"` : un relais tapé par l'utilisateur, ou lancé par un script, n'est pas le fait du modèle.
94+- **Masquer des secrets.** Sur `preAsk`, réécrivez `input.ask.prompt` et `input.ask.transcript` et renvoyez les deux. `"transcript":""` n'envoie aucune conversation.
95+- **Un garde-fou.** Mettez `onError: fail` sur un hook `preAsk` qui ne doit pas être sauté : quand il ne peut pas s'exécuter, le relais est refusé.
96+- **Avec `yolo: false`**, l'utilisateur approuve d'abord l'appel `ask_agent` ; `preAsk` s'exécute après cette approbation, sur la question écrite par le modèle.
97+- **`mm --ask` depuis une skill.** Les hooks du fichier qu'il lit s'exécutent aussi (`"via":"cli"`). Avec un hook `postAsk`, stdout porte la réponse une fois les hooks exécutés : le script appelant lit le remplacement.
98+- **Les hooks propres à l'agent appelé.** Il exécute les hooks de son propre fichier, qui voient `"asked":{"depth":1}`. Servez-vous-en pour ce qui appartient à cet agent quel que soit l'appelant ; gardez `preAsk`/`postAsk` pour ce qui appartient à ce relais.
99+
100+## Voir aussi
101+
102+- Tous les champs et leurs effets : [Référence des hooks](../reference/hooks.md#preask)
103+- Pourquoi ces hooks appartiennent à l'agent qui appelle : [Les hooks — explication](../explanation/hooks.md#des-hooks-autour-des-appels-dagents)
104+- Un exemple complet : `demos/hooks-ask/`
modified docs/fr/how-to/let-the-agent-call-other-agents.md +1 -0
@@ -71,3 +71,4 @@ Ce guide montre comment donner une équipe à votre agent : d'autres agents mm a
7171 - Les champs de l'outil, son affichage et ses erreurs : [référence des outils](../reference/tools.md#ask_agent)
7272 - Les deux formes de `mmConfigs` : [référence de la configuration](../reference/configuration.md)
7373 - Pourquoi il tourne dans le tour plutôt que par une skill : [interroger un autre agent — explication](../explanation/asking-another-agent.md)
74+- Refuser, réécrire ou vérifier les relais : [contrôler les relais vers d'autres agents avec des hooks](control-agent-calls-with-hooks.md)
@@ -71,3 +71,4 @@ Ce guide montre comment donner une équipe à votre agent : d'autres agents mm a
71 - Les champs de l'outil, son affichage et ses erreurs : [référence des outils](../reference/tools.md#ask_agent)71 - Les champs de l'outil, son affichage et ses erreurs : [référence des outils](../reference/tools.md#ask_agent)
72 - Les deux formes de `mmConfigs` : [référence de la configuration](../reference/configuration.md)72 - Les deux formes de `mmConfigs` : [référence de la configuration](../reference/configuration.md)
73 - Pourquoi il tourne dans le tour plutôt que par une skill : [interroger un autre agent — explication](../explanation/asking-another-agent.md)73 - Pourquoi il tourne dans le tour plutôt que par une skill : [interroger un autre agent — explication](../explanation/asking-another-agent.md)
74+- Refuser, réécrire ou vérifier les relais : [contrôler les relais vers d'autres agents avec des hooks](control-agent-calls-with-hooks.md)
modified docs/fr/reference/acp.md +1 -1
@@ -8,7 +8,7 @@
88 |--------|--------|
99 | Entrée | stdin, un message JSON-RPC par ligne. |
1010 | Sortie | stdout, messages JSON-RPC uniquement. |
11-| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`). |
11+| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`) et la notice d'un hook `postAsk` qui a remplacé une réponse (`🪝 postAsk: …`). |
1212 | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |
1313
1414 ## Réponse à `initialize`
@@ -8,7 +8,7 @@
8 |--------|--------|8 |--------|--------|
9 | Entrée | stdin, un message JSON-RPC par ligne. |9 | Entrée | stdin, un message JSON-RPC par ligne. |
10 | Sortie | stdout, messages JSON-RPC uniquement. |10 | Sortie | stdout, messages JSON-RPC uniquement. |
11-| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`). |11+| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK, lignes d'échec des [hooks](hooks.md#échecs) (`🪝 <événement> hook …`) et la notice d'un hook `postAsk` qui a remplacé une réponse (`🪝 postAsk: …`). |
12 | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |12 | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. |
13 13
14 ## Réponse à `initialize`14 ## Réponse à `initialize`
modified docs/fr/reference/configuration.md +1 -1
@@ -34,7 +34,7 @@
3434 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |
3535 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |
3636 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
37-| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) et après le tour (`postGeneration`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil seulement), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). |
37+| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). |
3838 | `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`. |
3939 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
4040 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |
@@ -34,7 +34,7 @@
34 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |34 | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. |
35 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |35 | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. |
36 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |36 | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. |
37-| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) et après le tour (`postGeneration`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil seulement), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). |37+| `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). |
38 | `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`. |38 | `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`. |
39 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |39 | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). |
40 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |40 | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. |
modified docs/fr/reference/hooks.md +70 -19
@@ -10,6 +10,8 @@ hooks:
1010 preToolCall: [<hook>, …]
1111 postToolCall: [<hook>, …]
1212 postGeneration: [<hook>, …]
13+ preAsk: [<hook>, …]
14+ postAsk: [<hook>, …]
1315 ```
1416
1517 Chaque événement prend une liste ; les hooks d'un événement s'exécutent dans l'ordre où ils sont écrits. Un événement absent, ou une liste vide, n'exécute rien. Pas de bloc `hooks:` = aucun hook.
@@ -19,7 +21,7 @@ Chaque événement prend une liste ; les hooks d'un événement s'exécutent dan
1921 | Clé | Type | Défaut | Description |
2022 |-----|------|--------|-------------|
2123 | `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. |
22-| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils) | `preToolCall` et `postToolCall` seulement. Le hook s'exécute pour les outils dont le nom correspond. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. |
24+| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils ou agents) | `preToolCall` et `postToolCall` : le hook s'exécute pour les outils dont le nom correspond. `preAsk` et `postAsk` : pour les agents appelés dont le nom (leur clé dans `mmConfigs`) correspond. Interdit sur `preGeneration` et `postGeneration`. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. |
2325 | `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. |
2426 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). |
2527
@@ -47,8 +49,12 @@ hooks:
4749 | `preToolCall` | avant chaque appel d'outil, avant la demande d'autorisation de l'outil | intégrés et MCP, filtrés par `matcher` | refuser l'appel, remplacer ses arguments |
4850 | `postToolCall` | après chaque appel d'outil exécuté | intégrés et MCP, filtrés par `matcher` | ajouter du contexte après la sortie de l'outil |
4951 | `postGeneration` | une fois le tour terminé : répondu, en échec, annulé ou arrêté par un hook | — | observer seulement |
52+| `preAsk` | dans l'agent qui appelle, une fois par passage de relais à un autre agent — l'outil `ask_agent`, `/ask`, `/ask-<nom>`, `/ask-with-context-<nom>`, `mm --ask` — avant que l'agent appelé soit démarré ou interrogé ; pour `ask_agent`, après la demande d'autorisation de l'appel | les agents de `mmConfigs`, filtrés par `matcher` | refuser le relais, réécrire la question ou la conversation envoyée, ajouter du contexte pour l'agent appelé |
53+| `postAsk` | dans l'agent qui appelle, une fois le tour de l'agent appelé terminé : répondu, en échec ou annulé ; avant que la réponse devienne la sortie d'`ask_agent` ou entre dans l'historique | les agents de `mmConfigs`, filtrés par `matcher` | remplacer la réponse que lit l'agent qui appelle, ajouter du contexte après elle |
5054
51-Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`, et un agent démarré par `/ask`, `ask_agent` ou `mm --ask` (avec les hooks de **son propre** fichier de configuration). Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.
55+Les six s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`. Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` exécute les hooks de **son propre** fichier de configuration ; `preAsk` et `postAsk` sont ceux de l'agent **qui appelle**. Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.
56+
57+Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisation → `preAsk` → l'agent appelé (avec ses propres hooks) → `postAsk` → `postToolCall`. Sur `/ask…` et `mm --ask` : `preAsk` → l'agent appelé → `postAsk`.
5258
5359 ## Exécution
5460
@@ -66,7 +72,7 @@ Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`
6672
6773 | Variable | Valeur |
6874 |----------|--------|
69-| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall` ou `postGeneration` |
75+| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` ou `postAsk` |
7076 | `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook |
7177
7278 ## Entrée
@@ -80,12 +86,28 @@ L'objet JSON écrit sur l'entrée standard du hook.
8086 | `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` |
8187 | `prompt` | chaîne | `preGeneration`, `postGeneration` | la question telle que tapée — lignes `@path` et `$skill` comprises. Dans une chaîne, la question telle que le hook précédent l'a laissée. Dans `postGeneration` : la question que le modèle a lue, ou le marqueur `[prompt blocked by a hook: <raison>]` quand un hook a arrêté le tour. |
8288 | `tool` | objet | `preToolCall`, `postToolCall` | l'appel d'outil : `id` (la référence de l'appel, peut être vide), `name`, `input` (les arguments, tels que le modèle les a envoyés ou que le hook précédent les a remplacés) |
89+| `ask` | objet | `preAsk`, `postAsk` | le passage de relais — voir [L'objet `ask`](#lobjet-ask) |
8390 | `output` | chaîne | `postToolCall` | la sortie de l'outil, telle que le modèle la lira (en JSON quand l'outil renvoie autre chose que du texte) |
84-| `answer` | chaîne | `postGeneration` | le texte de la dernière réponse ; vide s'il n'y en a pas |
85-| `error` | chaîne | `postGeneration` | l'erreur du tour ; absent quand le tour a réussi |
91+| `answer` | chaîne | `postGeneration`, `postAsk` | `postGeneration` : le texte de la dernière réponse. `postAsk` : la réponse complète de l'agent appelé, avant toute troncature, ou telle que le hook précédent l'a remplacée. Vide s'il n'y en a pas. |
92+| `error` | chaîne | `postGeneration`, `postAsk` | l'erreur du tour, ou du relais ; absent en cas de succès |
93+| `durationMs` | nombre | `postAsk` | le temps pris par l'agent appelé, en millisecondes |
94+| `asked` | objet | tous | présent seulement dans un agent lui-même appelé par un autre : `{"depth": <n>}`, 1 pour un agent appelé par celui qu'une personne a démarré |
8695
8796 Les champs vides sont omis.
8897
98+### L'objet `ask`
99+
100+| Champ | Type | Description |
101+|-------|------|-------------|
102+| `agent` | chaîne | le nom de l'agent appelé, sa clé dans `mmConfigs` |
103+| `config` | chaîne | le fichier de configuration de l'agent appelé, absolu |
104+| `agentModel` | chaîne | `<fournisseur>/<modèle>` lu dans ce fichier ; absent quand il est illisible |
105+| `via` | chaîne | `tool` (l'outil `ask_agent`), `command` (`/ask`, `/ask-<nom>`, `/ask-with-context-<nom>`) ou `cli` (`mm --ask`, `mm --ask-with-context`) |
106+| `withContext` | booléen | si la conversation est envoyée avec la question |
107+| `depth` | nombre | la profondeur de l'agent qui appelle : 0 pour l'agent qu'une personne a démarré |
108+| `prompt` | chaîne | la question. Dans une chaîne `preAsk`, telle que le hook précédent l'a laissée ; dans `postAsk`, telle qu'elle a été envoyée. |
109+| `transcript` | chaîne | la conversation envoyée (`withContext` seulement), de la même façon ; absent sinon |
110+
89111 ### Exemples
90112
91113 ```json
@@ -96,6 +118,10 @@ Les champs vides sont omis.
96118 {"event":"preToolCall","cwd":"/home/moi/projet","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97119 ```
98120
121+```json
122+{"event":"postAsk","cwd":"/home/moi/projet","model":"llmman/…","ask":{"agent":"expert","config":"/home/moi/projet/agent.expert.yaml","agentModel":"llmman/…","via":"tool","withContext":false,"depth":0,"prompt":"Qu'est-ce qu'un inode ?"},"answer":"Un inode est …","durationMs":25374}
123+```
124+
99125 ## Réponse
100126
101127 L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (ou faite seulement d'espaces) vaut `{"action":"continue"}`.** Tous les champs sont facultatifs.
@@ -103,10 +129,12 @@ L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (
103129 | Champ | Type | Lu sur | Description |
104130 |-------|------|--------|-------------|
105131 | `action` | chaîne | tous | `continue` (par défaut, y compris quand il est absent), `stop`, `deny` ou `error` — voir plus bas |
106-| `prompt` | chaîne | `preGeneration` | remplace la question. Absent = inchangée ; `""` = une question vide. |
107-| `context` | chaîne | `preGeneration`, `postToolCall` | du texte pour le modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. |
132+| `prompt` | chaîne | `preGeneration`, `preAsk` | remplace la question — celle de l'utilisateur, ou celle envoyée à l'agent appelé. Absent = inchangée ; `""` = une question vide. |
133+| `transcript` | chaîne | `preAsk` | remplace la conversation envoyée à l'agent appelé. Absent = inchangée ; `""` = n'en envoyer aucune. |
134+| `answer` | chaîne | `postAsk` | remplace la réponse que lit l'agent qui appelle. Absent = inchangée. |
135+| `context` | chaîne | `preGeneration`, `postToolCall`, `preAsk`, `postAsk` | du texte pour un modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. `preAsk` : ajouté à ce que reçoit l'agent appelé. `postAsk` : ajouté après la réponse que lit l'agent qui appelle. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. |
108136 | `input` | toute valeur JSON | `preToolCall` | remplace les arguments de l'appel d'outil. Absent = inchangés. |
109-| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` (`deny`) | la raison. |
137+| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` et `preAsk` (`deny`) | la raison. |
110138 | `response` | chaîne | `preGeneration` (`stop`) | ce que l'utilisateur lit à la place d'une réponse. Absente ou blanche : `⛔ This request was blocked by a hook: <raison>`. |
111139 | `error` | chaîne | tous (`error`) | le message d'échec du hook lui-même. |
112140
@@ -118,7 +146,7 @@ Les champs lus sur un autre événement sont ignorés.
118146 |--------|-------------|-------|
119147 | `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués |
120148 | `stop` | `preGeneration` | le tour se termine sans appeler le modèle ; les hooks suivants de l'événement ne s'exécutent pas |
121-| `deny` | `preToolCall` | l'outil ne s'exécute pas ; les hooks suivants de l'événement ne s'exécutent pas |
149+| `deny` | `preToolCall`, `preAsk` | l'outil ne s'exécute pas, ou l'agent appelé n'est ni démarré ni interrogé ; les hooks suivants de l'événement ne s'exécutent pas |
122150 | `error` | tous | un échec, traité selon `onError` |
123151 | toute autre valeur, ou `stop`/`deny` sur un autre événement | — | un échec : `action "<action>" is not valid for <événement>` |
124152
@@ -152,6 +180,28 @@ Les `context` de tous les hooks qui correspondent sont réunis et ajoutés à la
152180
153181 Aucun effet sur le tour. La réponse est décodée (une réponse invalide est un échec), puis ignorée.
154182
183+### `preAsk`
184+
185+| Décision | L'agent appelé | L'utilisateur voit |
186+|----------|----------------|--------------------|
187+| aucun changement | reçoit la question (précédée de la conversation avec `withContext`) | le relais habituel |
188+| `prompt`, `transcript`, `context` | reçoit la question et la conversation réécrites, puis chaque contexte | le relais habituel |
189+| `deny` | n'est ni démarré ni interrogé | `ask_agent` : en mode ligne `🪝 ask_agent [ask denied by a hook: <raison>]`, dans la fenêtre et l'éditeur un appel `ask_agent` en échec ; le modèle lit `[ask denied by a hook: <raison>]`. `/ask…` : la commande échoue avec `ask denied by a hook: <raison>`, l'historique ne change pas. `mm --ask` : `[ask: ask denied by a hook: <raison>]` sur stderr, code de sortie 1. |
190+
191+En chaîne : chaque hook lit le `prompt` et le `transcript` laissés par le précédent ; les contextes s'accumulent dans l'ordre des hooks. Avec `/ask…`, l'historique garde la question telle qu'elle a été envoyée.
192+
193+### `postAsk`
194+
195+| Décision | L'agent qui appelle lit | L'utilisateur voit |
196+|----------|-------------------------|--------------------|
197+| aucun changement | la réponse (la sortie d'`ask_agent`, ou le message `[<agent>'s answer]` de `/ask…`) | la réponse, telle qu'elle s'est affichée |
198+| `answer` | le remplacement | la réponse telle qu'elle s'est affichée, puis la notice `🪝 postAsk: <agent>'s answer was replaced by a hook — the asking agent reads the new one` ; le résumé de l'appel `ask_agent` indique `<agent> answered; a hook replaced the answer (<n> characters)` |
199+| `context` | la réponse, puis `[context added by a hook]` et chaque contexte | la réponse, telle qu'elle s'est affichée |
200+
201+La réponse s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit : `postAsk` change ce que l'agent qui appelle lit et garde, jamais ce qui a été montré. Un `answer` sur un relais en échec (pas un relais annulé) remplace l'échec : l'agent qui appelle le lit comme la réponse, et `/ask…` garde l'échange.
202+
203+Avec au moins un hook `postAsk` déclaré, `mm --ask` retient la réponse et ne l'écrit sur stdout qu'une fois les hooks exécutés (remplacement et contexte compris) ; sans hook `postAsk`, il la diffuse au fil de l'eau comme avant.
204+
155205 ## Échecs
156206
157207 Une exécution échoue quand :
@@ -160,27 +210,27 @@ Une exécution échoue quand :
160210 |-------|---------|
161211 | la commande se termine avec un code non nul | `exit status <n>: <stderr>` |
162212 | elle dure plus que `timeout` | `timed out after <timeout>` |
163-| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |
213+| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` |
164214 | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` |
165215 | stdout n'est pas du JSON | `invalid JSON on stdout: …` |
166216 | `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` |
167217 | `action` est inconnue ou non permise sur l'événement | `action "<action>" is not valid for <événement>` |
168218
169-`postGeneration` n'est pas annulé avec le tour : il s'exécute aussi après une annulation (Échap, Ctrl-C, `/abort`), borné par son `timeout`.
219+`postGeneration` et `postAsk` ne sont pas annulés avec le tour : ils s'exécutent aussi après une annulation (Échap, Ctrl-C, `/abort`), bornés par leur `timeout`.
170220
171221 La suite dépend d'`onError` :
172222
173-| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |
174-|-----------|-----------------|---------------|----------------------------------|
175-| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue |
223+| `onError` | `preGeneration` | `preToolCall`, `preAsk` | `postToolCall`, `postGeneration`, `postAsk` |
224+|-----------|-----------------|-------------------------|---------------------------------------------|
225+| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel ou relais refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue |
176226 | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue |
177227 | `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire |
178228
179-La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP.
229+La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP, sur stderr avec `mm --ask`.
180230
181231 ## Bannière de démarrage
182232
183-Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration` :
233+Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk`, `postAsk` :
184234
185235 ```
186236 mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
@@ -192,9 +242,9 @@ Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, co
192242
193243 | Message | Cause |
194244 |---------|-------|
195-| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) |
245+| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) |
196246 | `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche |
197-| `hooks.<événement>[N]: matcher is only for preToolCall and postToolCall` | un `matcher` sur `preGeneration` ou `postGeneration` |
247+| `hooks.<événement>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | un `matcher` sur `preGeneration` ou `postGeneration` |
198248 | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas |
199249 | `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif |
200250 | `hooks.<événement>[N]: onError must be "fail", "warn" or "ignore"` | toute autre valeur d'`onError` |
@@ -203,4 +253,5 @@ Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, co
203253
204254 - [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md)
205255 - [Les hooks — explication](../explanation/hooks.md)
206-- La démo : `demos/hooks/` (quatre hooks Node.js)
256+- [Contrôler les relais vers d'autres agents avec des hooks](../how-to/control-agent-calls-with-hooks.md)
257+- Les démos : `demos/hooks/` (quatre hooks Node.js), `demos/hooks-ask/` (`preAsk` et `postAsk` autour d'un agent expert)
@@ -10,6 +10,8 @@ hooks:
10 preToolCall: [<hook>, …]10 preToolCall: [<hook>, …]
11 postToolCall: [<hook>, …]11 postToolCall: [<hook>, …]
12 postGeneration: [<hook>, …]12 postGeneration: [<hook>, …]
13+ preAsk: [<hook>, …]
14+ postAsk: [<hook>, …]
13 ```15 ```
14 16
15 Chaque événement prend une liste ; les hooks d'un événement s'exécutent dans l'ordre où ils sont écrits. Un événement absent, ou une liste vide, n'exécute rien. Pas de bloc `hooks:` = aucun hook.17 Chaque événement prend une liste ; les hooks d'un événement s'exécutent dans l'ordre où ils sont écrits. Un événement absent, ou une liste vide, n'exécute rien. Pas de bloc `hooks:` = aucun hook.
@@ -19,7 +21,7 @@ Chaque événement prend une liste ; les hooks d'un événement s'exécutent dan
19 | Clé | Type | Défaut | Description |21 | Clé | Type | Défaut | Description |
20 |-----|------|--------|-------------|22 |-----|------|--------|-------------|
21 | `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. |23 | `command` | chaîne | — (obligatoire) | Exécutée par `sh -c "<command>"`. Ne doit pas être vide ni blanche. |
22-| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils) | `preToolCall` et `postToolCall` seulement. Le hook s'exécute pour les outils dont le nom correspond. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. |24+| `matcher` | chaîne (expression régulière, syntaxe Go) | `""` (tous les outils ou agents) | `preToolCall` et `postToolCall` : le hook s'exécute pour les outils dont le nom correspond. `preAsk` et `postAsk` : pour les agents appelés dont le nom (leur clé dans `mmConfigs`) correspond. Interdit sur `preGeneration` et `postGeneration`. Non ancrée : `bash` correspond aussi à `my_bash` ; `^bash$` ne correspond qu'à `bash`. |
23 | `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. |25 | `timeout` | durée | `10s` | Durée maximale d'une exécution avant qu'elle soit tuée. `0` = la valeur par défaut. Doit être ≥ 0. |
24 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). |26 | `onError` | `fail` \| `warn` \| `ignore` | `warn` | Ce que fait une exécution en échec (voir [Échecs](#échecs)). |
25 27
@@ -47,8 +49,12 @@ hooks:
47 | `preToolCall` | avant chaque appel d'outil, avant la demande d'autorisation de l'outil | intégrés et MCP, filtrés par `matcher` | refuser l'appel, remplacer ses arguments |49 | `preToolCall` | avant chaque appel d'outil, avant la demande d'autorisation de l'outil | intégrés et MCP, filtrés par `matcher` | refuser l'appel, remplacer ses arguments |
48 | `postToolCall` | après chaque appel d'outil exécuté | intégrés et MCP, filtrés par `matcher` | ajouter du contexte après la sortie de l'outil |50 | `postToolCall` | après chaque appel d'outil exécuté | intégrés et MCP, filtrés par `matcher` | ajouter du contexte après la sortie de l'outil |
49 | `postGeneration` | une fois le tour terminé : répondu, en échec, annulé ou arrêté par un hook | — | observer seulement |51 | `postGeneration` | une fois le tour terminé : répondu, en échec, annulé ou arrêté par un hook | — | observer seulement |
52+| `preAsk` | dans l'agent qui appelle, une fois par passage de relais à un autre agent — l'outil `ask_agent`, `/ask`, `/ask-<nom>`, `/ask-with-context-<nom>`, `mm --ask` — avant que l'agent appelé soit démarré ou interrogé ; pour `ask_agent`, après la demande d'autorisation de l'appel | les agents de `mmConfigs`, filtrés par `matcher` | refuser le relais, réécrire la question ou la conversation envoyée, ajouter du contexte pour l'agent appelé |
53+| `postAsk` | dans l'agent qui appelle, une fois le tour de l'agent appelé terminé : répondu, en échec ou annulé ; avant que la réponse devienne la sortie d'`ask_agent` ou entre dans l'historique | les agents de `mmConfigs`, filtrés par `matcher` | remplacer la réponse que lit l'agent qui appelle, ajouter du contexte après elle |
50 54
51-Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`, et un agent démarré par `/ask`, `ask_agent` ou `mm --ask` (avec les hooks de **son propre** fichier de configuration). Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.55+Les six s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`-tui`), un éditeur (`-acp`), `-p`. Un agent démarré par `/ask`, `ask_agent` ou `mm --ask` exécute les hooks de **son propre** fichier de configuration ; `preAsk` et `postAsk` sont ceux de l'agent **qui appelle**. Un hook `preGeneration` ne s'exécute pas sur le résumé d'une compression de contexte.
56+
57+Sur le chemin d'`ask_agent`, l'ordre est : `preToolCall` → demande d'autorisation → `preAsk` → l'agent appelé (avec ses propres hooks) → `postAsk` → `postToolCall`. Sur `/ask…` et `mm --ask` : `preAsk` → l'agent appelé → `postAsk`.
52 58
53 ## Exécution59 ## Exécution
54 60
@@ -66,7 +72,7 @@ Les quatre s'exécutent dans tous les front ends : le mode ligne, la fenêtre (`
66 72
67 | Variable | Valeur |73 | Variable | Valeur |
68 |----------|--------|74 |----------|--------|
69-| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall` ou `postGeneration` |75+| `MM_HOOK_EVENT` | l'événement : `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk` ou `postAsk` |
70 | `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook |76 | `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook |
71 77
72 ## Entrée78 ## Entrée
@@ -80,12 +86,28 @@ L'objet JSON écrit sur l'entrée standard du hook.
80 | `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` |86 | `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` |
81 | `prompt` | chaîne | `preGeneration`, `postGeneration` | la question telle que tapée — lignes `@path` et `$skill` comprises. Dans une chaîne, la question telle que le hook précédent l'a laissée. Dans `postGeneration` : la question que le modèle a lue, ou le marqueur `[prompt blocked by a hook: <raison>]` quand un hook a arrêté le tour. |87 | `prompt` | chaîne | `preGeneration`, `postGeneration` | la question telle que tapée — lignes `@path` et `$skill` comprises. Dans une chaîne, la question telle que le hook précédent l'a laissée. Dans `postGeneration` : la question que le modèle a lue, ou le marqueur `[prompt blocked by a hook: <raison>]` quand un hook a arrêté le tour. |
82 | `tool` | objet | `preToolCall`, `postToolCall` | l'appel d'outil : `id` (la référence de l'appel, peut être vide), `name`, `input` (les arguments, tels que le modèle les a envoyés ou que le hook précédent les a remplacés) |88 | `tool` | objet | `preToolCall`, `postToolCall` | l'appel d'outil : `id` (la référence de l'appel, peut être vide), `name`, `input` (les arguments, tels que le modèle les a envoyés ou que le hook précédent les a remplacés) |
89+| `ask` | objet | `preAsk`, `postAsk` | le passage de relais — voir [L'objet `ask`](#lobjet-ask) |
83 | `output` | chaîne | `postToolCall` | la sortie de l'outil, telle que le modèle la lira (en JSON quand l'outil renvoie autre chose que du texte) |90 | `output` | chaîne | `postToolCall` | la sortie de l'outil, telle que le modèle la lira (en JSON quand l'outil renvoie autre chose que du texte) |
84-| `answer` | chaîne | `postGeneration` | le texte de la dernière réponse ; vide s'il n'y en a pas |91+| `answer` | chaîne | `postGeneration`, `postAsk` | `postGeneration` : le texte de la dernière réponse. `postAsk` : la réponse complète de l'agent appelé, avant toute troncature, ou telle que le hook précédent l'a remplacée. Vide s'il n'y en a pas. |
85-| `error` | chaîne | `postGeneration` | l'erreur du tour ; absent quand le tour a réussi |92+| `error` | chaîne | `postGeneration`, `postAsk` | l'erreur du tour, ou du relais ; absent en cas de succès |
93+| `durationMs` | nombre | `postAsk` | le temps pris par l'agent appelé, en millisecondes |
94+| `asked` | objet | tous | présent seulement dans un agent lui-même appelé par un autre : `{"depth": <n>}`, 1 pour un agent appelé par celui qu'une personne a démarré |
86 95
87 Les champs vides sont omis.96 Les champs vides sont omis.
88 97
98+### L'objet `ask`
99+
100+| Champ | Type | Description |
101+|-------|------|-------------|
102+| `agent` | chaîne | le nom de l'agent appelé, sa clé dans `mmConfigs` |
103+| `config` | chaîne | le fichier de configuration de l'agent appelé, absolu |
104+| `agentModel` | chaîne | `<fournisseur>/<modèle>` lu dans ce fichier ; absent quand il est illisible |
105+| `via` | chaîne | `tool` (l'outil `ask_agent`), `command` (`/ask`, `/ask-<nom>`, `/ask-with-context-<nom>`) ou `cli` (`mm --ask`, `mm --ask-with-context`) |
106+| `withContext` | booléen | si la conversation est envoyée avec la question |
107+| `depth` | nombre | la profondeur de l'agent qui appelle : 0 pour l'agent qu'une personne a démarré |
108+| `prompt` | chaîne | la question. Dans une chaîne `preAsk`, telle que le hook précédent l'a laissée ; dans `postAsk`, telle qu'elle a été envoyée. |
109+| `transcript` | chaîne | la conversation envoyée (`withContext` seulement), de la même façon ; absent sinon |
110+
89 ### Exemples111 ### Exemples
90 112
91 ```json113 ```json
@@ -96,6 +118,10 @@ Les champs vides sont omis.
96 {"event":"preToolCall","cwd":"/home/moi/projet","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}118 {"event":"preToolCall","cwd":"/home/moi/projet","model":"llmman/…","tool":{"id":"call_1","name":"bash","input":{"command":"rm -rf build"}}}
97 ```119 ```
98 120
121+```json
122+{"event":"postAsk","cwd":"/home/moi/projet","model":"llmman/…","ask":{"agent":"expert","config":"/home/moi/projet/agent.expert.yaml","agentModel":"llmman/…","via":"tool","withContext":false,"depth":0,"prompt":"Qu'est-ce qu'un inode ?"},"answer":"Un inode est …","durationMs":25374}
123+```
124+
99 ## Réponse125 ## Réponse
100 126
101 L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (ou faite seulement d'espaces) vaut `{"action":"continue"}`.** Tous les champs sont facultatifs.127 L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (ou faite seulement d'espaces) vaut `{"action":"continue"}`.** Tous les champs sont facultatifs.
@@ -103,10 +129,12 @@ L'objet JSON qu'un hook peut écrire sur sa sortie standard. **Une sortie vide (
103 | Champ | Type | Lu sur | Description |129 | Champ | Type | Lu sur | Description |
104 |-------|------|--------|-------------|130 |-------|------|--------|-------------|
105 | `action` | chaîne | tous | `continue` (par défaut, y compris quand il est absent), `stop`, `deny` ou `error` — voir plus bas |131 | `action` | chaîne | tous | `continue` (par défaut, y compris quand il est absent), `stop`, `deny` ou `error` — voir plus bas |
106-| `prompt` | chaîne | `preGeneration` | remplace la question. Absent = inchangée ; `""` = une question vide. |132+| `prompt` | chaîne | `preGeneration`, `preAsk` | remplace la question — celle de l'utilisateur, ou celle envoyée à l'agent appelé. Absent = inchangée ; `""` = une question vide. |
107-| `context` | chaîne | `preGeneration`, `postToolCall` | du texte pour le modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. |133+| `transcript` | chaîne | `preAsk` | remplace la conversation envoyée à l'agent appelé. Absent = inchangée ; `""` = n'en envoyer aucune. |
134+| `answer` | chaîne | `postAsk` | remplace la réponse que lit l'agent qui appelle. Absent = inchangée. |
135+| `context` | chaîne | `preGeneration`, `postToolCall`, `preAsk`, `postAsk` | du texte pour un modèle. `preGeneration` : ajouté après la question. `postToolCall` : ajouté après la sortie de l'outil. `preAsk` : ajouté à ce que reçoit l'agent appelé. `postAsk` : ajouté après la réponse que lit l'agent qui appelle. Chacun est introduit par la ligne `[context added by a hook]`. Blanc = ignoré. |
108 | `input` | toute valeur JSON | `preToolCall` | remplace les arguments de l'appel d'outil. Absent = inchangés. |136 | `input` | toute valeur JSON | `preToolCall` | remplace les arguments de l'appel d'outil. Absent = inchangés. |
109-| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` (`deny`) | la raison. |137+| `reason` | chaîne | `preGeneration` (`stop`), `preToolCall` et `preAsk` (`deny`) | la raison. |
110 | `response` | chaîne | `preGeneration` (`stop`) | ce que l'utilisateur lit à la place d'une réponse. Absente ou blanche : `⛔ This request was blocked by a hook: <raison>`. |138 | `response` | chaîne | `preGeneration` (`stop`) | ce que l'utilisateur lit à la place d'une réponse. Absente ou blanche : `⛔ This request was blocked by a hook: <raison>`. |
111 | `error` | chaîne | tous (`error`) | le message d'échec du hook lui-même. |139 | `error` | chaîne | tous (`error`) | le message d'échec du hook lui-même. |
112 140
@@ -118,7 +146,7 @@ Les champs lus sur un autre événement sont ignorés.
118 |--------|-------------|-------|146 |--------|-------------|-------|
119 | `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués |147 | `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués |
120 | `stop` | `preGeneration` | le tour se termine sans appeler le modèle ; les hooks suivants de l'événement ne s'exécutent pas |148 | `stop` | `preGeneration` | le tour se termine sans appeler le modèle ; les hooks suivants de l'événement ne s'exécutent pas |
121-| `deny` | `preToolCall` | l'outil ne s'exécute pas ; les hooks suivants de l'événement ne s'exécutent pas |149+| `deny` | `preToolCall`, `preAsk` | l'outil ne s'exécute pas, ou l'agent appelé n'est ni démarré ni interrogé ; les hooks suivants de l'événement ne s'exécutent pas |
122 | `error` | tous | un échec, traité selon `onError` |150 | `error` | tous | un échec, traité selon `onError` |
123 | toute autre valeur, ou `stop`/`deny` sur un autre événement | — | un échec : `action "<action>" is not valid for <événement>` |151 | toute autre valeur, ou `stop`/`deny` sur un autre événement | — | un échec : `action "<action>" is not valid for <événement>` |
124 152
@@ -152,6 +180,28 @@ Les `context` de tous les hooks qui correspondent sont réunis et ajoutés à la
152 180
153 Aucun effet sur le tour. La réponse est décodée (une réponse invalide est un échec), puis ignorée.181 Aucun effet sur le tour. La réponse est décodée (une réponse invalide est un échec), puis ignorée.
154 182
183+### `preAsk`
184+
185+| Décision | L'agent appelé | L'utilisateur voit |
186+|----------|----------------|--------------------|
187+| aucun changement | reçoit la question (précédée de la conversation avec `withContext`) | le relais habituel |
188+| `prompt`, `transcript`, `context` | reçoit la question et la conversation réécrites, puis chaque contexte | le relais habituel |
189+| `deny` | n'est ni démarré ni interrogé | `ask_agent` : en mode ligne `🪝 ask_agent [ask denied by a hook: <raison>]`, dans la fenêtre et l'éditeur un appel `ask_agent` en échec ; le modèle lit `[ask denied by a hook: <raison>]`. `/ask…` : la commande échoue avec `ask denied by a hook: <raison>`, l'historique ne change pas. `mm --ask` : `[ask: ask denied by a hook: <raison>]` sur stderr, code de sortie 1. |
190+
191+En chaîne : chaque hook lit le `prompt` et le `transcript` laissés par le précédent ; les contextes s'accumulent dans l'ordre des hooks. Avec `/ask…`, l'historique garde la question telle qu'elle a été envoyée.
192+
193+### `postAsk`
194+
195+| Décision | L'agent qui appelle lit | L'utilisateur voit |
196+|----------|-------------------------|--------------------|
197+| aucun changement | la réponse (la sortie d'`ask_agent`, ou le message `[<agent>'s answer]` de `/ask…`) | la réponse, telle qu'elle s'est affichée |
198+| `answer` | le remplacement | la réponse telle qu'elle s'est affichée, puis la notice `🪝 postAsk: <agent>'s answer was replaced by a hook — the asking agent reads the new one` ; le résumé de l'appel `ask_agent` indique `<agent> answered; a hook replaced the answer (<n> characters)` |
199+| `context` | la réponse, puis `[context added by a hook]` et chaque contexte | la réponse, telle qu'elle s'est affichée |
200+
201+La réponse s'affiche pour l'utilisateur pendant que l'agent appelé l'écrit : `postAsk` change ce que l'agent qui appelle lit et garde, jamais ce qui a été montré. Un `answer` sur un relais en échec (pas un relais annulé) remplace l'échec : l'agent qui appelle le lit comme la réponse, et `/ask…` garde l'échange.
202+
203+Avec au moins un hook `postAsk` déclaré, `mm --ask` retient la réponse et ne l'écrit sur stdout qu'une fois les hooks exécutés (remplacement et contexte compris) ; sans hook `postAsk`, il la diffuse au fil de l'eau comme avant.
204+
155 ## Échecs205 ## Échecs
156 206
157 Une exécution échoue quand :207 Une exécution échoue quand :
@@ -160,27 +210,27 @@ Une exécution échoue quand :
160 |-------|---------|210 |-------|---------|
161 | la commande se termine avec un code non nul | `exit status <n>: <stderr>` |211 | la commande se termine avec un code non nul | `exit status <n>: <stderr>` |
162 | elle dure plus que `timeout` | `timed out after <timeout>` |212 | elle dure plus que `timeout` | `timed out after <timeout>` |
163-| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`) | `cancelled` |213+| le tour est annulé pendant qu'elle s'exécute (`preGeneration`, `preToolCall`, `postToolCall`, `preAsk`) | `cancelled` |
164 | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` |214 | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` |
165 | stdout n'est pas du JSON | `invalid JSON on stdout: …` |215 | stdout n'est pas du JSON | `invalid JSON on stdout: …` |
166 | `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` |216 | `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` |
167 | `action` est inconnue ou non permise sur l'événement | `action "<action>" is not valid for <événement>` |217 | `action` est inconnue ou non permise sur l'événement | `action "<action>" is not valid for <événement>` |
168 218
169-`postGeneration` n'est pas annulé avec le tour : il s'exécute aussi après une annulation (Échap, Ctrl-C, `/abort`), borné par son `timeout`.219+`postGeneration` et `postAsk` ne sont pas annulés avec le tour : ils s'exécutent aussi après une annulation (Échap, Ctrl-C, `/abort`), bornés par leur `timeout`.
170 220
171 La suite dépend d'`onError` :221 La suite dépend d'`onError` :
172 222
173-| `onError` | `preGeneration` | `preToolCall` | `postToolCall`, `postGeneration` |223+| `onError` | `preGeneration` | `preToolCall`, `preAsk` | `postToolCall`, `postGeneration`, `postAsk` |
174-|-----------|-----------------|---------------|----------------------------------|224+|-----------|-----------------|-------------------------|---------------------------------------------|
175-| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue |225+| `fail` | ligne d'échec, tour arrêté (`response` : `⛔ This request was blocked by a hook: hook failed: …`) | ligne d'échec, appel ou relais refusé (`reason` : `hook failed: …`) | ligne d'échec, on continue |
176 | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue |226 | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue |
177 | `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire |227 | `ignore` | hook suivant, sans rien dire | hook suivant, sans rien dire | on continue, sans rien dire |
178 228
179-La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP.229+La ligne d'échec est `🪝 <événement> hook \`<command>\`: <échec>` : sur stdout en mode ligne, en ligne grisée dans la conversation de la fenêtre, sur stderr (les journaux de l'agent dans l'éditeur) en ACP, sur stderr avec `mm --ask`.
180 230
181 ## Bannière de démarrage231 ## Bannière de démarrage
182 232
183-Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration` :233+Quand au moins un hook est déclaré, la bannière se termine par ` | hooks: <événement>×<nombre> …`, une entrée par événement qui a des hooks, dans l'ordre `preGeneration`, `preToolCall`, `postToolCall`, `postGeneration`, `preAsk`, `postAsk` :
184 234
185 ```235 ```
186 mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1236 mm v0.4.0 | config: ../agent.yaml | … | yolo: on | hooks: preGeneration×2 preToolCall×1 postToolCall×1 postGeneration×1
@@ -192,9 +242,9 @@ Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, co
192 242
193 | Message | Cause |243 | Message | Cause |
194 |---------|-------|244 |---------|-------|
195-| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) |245+| `hooks: unknown event "<nom>" (want one of preGeneration, preToolCall, postToolCall, postGeneration, preAsk, postAsk)` | une clé de `hooks:` qui n'est pas un événement (une faute de frappe : `preToolcall`) |
196 | `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche |246 | `hooks.<événement>[N]: command must not be empty` | pas de `command`, ou une commande blanche |
197-| `hooks.<événement>[N]: matcher is only for preToolCall and postToolCall` | un `matcher` sur `preGeneration` ou `postGeneration` |247+| `hooks.<événement>[N]: matcher is only for preToolCall, postToolCall, preAsk and postAsk` | un `matcher` sur `preGeneration` ou `postGeneration` |
198 | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas |248 | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas |
199 | `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif |249 | `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif |
200 | `hooks.<événement>[N]: onError must be "fail", "warn" or "ignore"` | toute autre valeur d'`onError` |250 | `hooks.<événement>[N]: onError must be "fail", "warn" or "ignore"` | toute autre valeur d'`onError` |
@@ -203,4 +253,5 @@ Signalées au démarrage sous la forme `[config error: <chemin>: <message>]`, co
203 253
204 - [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md)254 - [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md)
205 - [Les hooks — explication](../explanation/hooks.md)255 - [Les hooks — explication](../explanation/hooks.md)
206-- La démo : `demos/hooks/` (quatre hooks Node.js)256+- [Contrôler les relais vers d'autres agents avec des hooks](../how-to/control-agent-calls-with-hooks.md)
257+- Les démos : `demos/hooks/` (quatre hooks Node.js), `demos/hooks-ask/` (`preAsk` et `postAsk` autour d'un agent expert)
modified docs/fr/reference/tools.md +1 -0
@@ -219,6 +219,7 @@ Déclaré quand `mmConfigs` nomme au moins un agent. Confie une tâche à l'un d
219219 | Affichage | Le texte, les pensées et les appels d'outils de l'agent interrogé défilent dans le tour, ses appels titrés `<agent> · <titre>`. La fenêtre le nomme pendant qu'il travaille : en-tête `mm [<acp.title>] · <modèle> @ <fournisseur> · asked by <cet agent>`, sa réponse sous `‣ mm [<acp.title>]` (son nom dans `mmConfigs` s'il n'a pas de titre). Mode ligne : `🗣 ask_agent: <agent> (<fournisseur>/<modèle>) — <première ligne du prompt>`, puis la sortie de l'agent. |
220220 | Chien de garde | Chaque événement de l'agent interrogé (texte, pensée, appel d'outil, permission répondue) compte comme un signe de vie pour le chien de garde de cet agent. Un silence plus long que `watchdogTimeout` — un modèle qui se charge, un long prompt en lecture — termine quand même le tour. |
221221 | Résultat | La réponse, tronquée à `maxOutput`. La sortie de l'appel lui-même dans la fenêtre et l'éditeur est `<agent> answered (N characters)` : la réponse a déjà défilé. Pas ajoutée à l'historique comme un échange `/ask` : c'est un résultat d'outil. |
222+| Hooks | Les hooks `preAsk` de cet agent s'exécutent après la permission, avant que l'agent soit interrogé ; ses hooks `postAsk` s'exécutent sur la réponse avant que le modèle la lise. Un relais refusé renvoie `[ask denied by a hook: <raison>]` ; une réponse remplacée donne à l'appel la sortie `<agent> answered; a hook replaced the answer (N characters)` ; le contexte d'un hook suit la réponse après `[context added by a hook]`. Voir [hooks](hooks.md#preask). |
222223 | Erreurs | `[ask_agent: unknown agent "x" — agents: a, b]` ; `[ask_agent: the prompt is empty — say what <agent> must do]` ; `[ask_agent: <agent> failed: <raison>]` ; `[ask_agent: <agent> gave no answer]` ; une exécution annulée ou interrompue renvoie ce qui a été répondu, puis `[ask_agent: <agent> stopped before finishing: <raison>]`. |
223224 | Profondeur | Un agent interrogé peut lui-même avoir des `mmConfigs` et appeler `ask_agent`, jusqu'à deux niveaux (`MM_ASK_DEPTH`) ; au-delà, l'appel échoue avec le message de profondeur. |
224225
@@ -219,6 +219,7 @@ Déclaré quand `mmConfigs` nomme au moins un agent. Confie une tâche à l'un d
219 | Affichage | Le texte, les pensées et les appels d'outils de l'agent interrogé défilent dans le tour, ses appels titrés `<agent> · <titre>`. La fenêtre le nomme pendant qu'il travaille : en-tête `mm [<acp.title>] · <modèle> @ <fournisseur> · asked by <cet agent>`, sa réponse sous `‣ mm [<acp.title>]` (son nom dans `mmConfigs` s'il n'a pas de titre). Mode ligne : `🗣 ask_agent: <agent> (<fournisseur>/<modèle>) — <première ligne du prompt>`, puis la sortie de l'agent. |219 | Affichage | Le texte, les pensées et les appels d'outils de l'agent interrogé défilent dans le tour, ses appels titrés `<agent> · <titre>`. La fenêtre le nomme pendant qu'il travaille : en-tête `mm [<acp.title>] · <modèle> @ <fournisseur> · asked by <cet agent>`, sa réponse sous `‣ mm [<acp.title>]` (son nom dans `mmConfigs` s'il n'a pas de titre). Mode ligne : `🗣 ask_agent: <agent> (<fournisseur>/<modèle>) — <première ligne du prompt>`, puis la sortie de l'agent. |
220 | Chien de garde | Chaque événement de l'agent interrogé (texte, pensée, appel d'outil, permission répondue) compte comme un signe de vie pour le chien de garde de cet agent. Un silence plus long que `watchdogTimeout` — un modèle qui se charge, un long prompt en lecture — termine quand même le tour. |220 | Chien de garde | Chaque événement de l'agent interrogé (texte, pensée, appel d'outil, permission répondue) compte comme un signe de vie pour le chien de garde de cet agent. Un silence plus long que `watchdogTimeout` — un modèle qui se charge, un long prompt en lecture — termine quand même le tour. |
221 | Résultat | La réponse, tronquée à `maxOutput`. La sortie de l'appel lui-même dans la fenêtre et l'éditeur est `<agent> answered (N characters)` : la réponse a déjà défilé. Pas ajoutée à l'historique comme un échange `/ask` : c'est un résultat d'outil. |221 | Résultat | La réponse, tronquée à `maxOutput`. La sortie de l'appel lui-même dans la fenêtre et l'éditeur est `<agent> answered (N characters)` : la réponse a déjà défilé. Pas ajoutée à l'historique comme un échange `/ask` : c'est un résultat d'outil. |
222+| Hooks | Les hooks `preAsk` de cet agent s'exécutent après la permission, avant que l'agent soit interrogé ; ses hooks `postAsk` s'exécutent sur la réponse avant que le modèle la lise. Un relais refusé renvoie `[ask denied by a hook: <raison>]` ; une réponse remplacée donne à l'appel la sortie `<agent> answered; a hook replaced the answer (N characters)` ; le contexte d'un hook suit la réponse après `[context added by a hook]`. Voir [hooks](hooks.md#preask). |
222 | Erreurs | `[ask_agent: unknown agent "x" — agents: a, b]` ; `[ask_agent: the prompt is empty — say what <agent> must do]` ; `[ask_agent: <agent> failed: <raison>]` ; `[ask_agent: <agent> gave no answer]` ; une exécution annulée ou interrompue renvoie ce qui a été répondu, puis `[ask_agent: <agent> stopped before finishing: <raison>]`. |223 | Erreurs | `[ask_agent: unknown agent "x" — agents: a, b]` ; `[ask_agent: the prompt is empty — say what <agent> must do]` ; `[ask_agent: <agent> failed: <raison>]` ; `[ask_agent: <agent> gave no answer]` ; une exécution annulée ou interrompue renvoie ce qui a été répondu, puis `[ask_agent: <agent> stopped before finishing: <raison>]`. |
223 | Profondeur | Un agent interrogé peut lui-même avoir des `mmConfigs` et appeler `ask_agent`, jusqu'à deux niveaux (`MM_ASK_DEPTH`) ; au-delà, l'appel échoue avec le message de profondeur. |224 | Profondeur | Un agent interrogé peut lui-même avoir des `mmConfigs` et appeler `ask_agent`, jusqu'à deux niveaux (`MM_ASK_DEPTH`) ; au-delà, l'appel échoue avec le message de profondeur. |
224 225
modified internal/ask/cli.go +17 -3
@@ -10,6 +10,7 @@ import (
1010
1111 "mm/internal/config"
1212 "mm/internal/consent"
13+ "mm/internal/hooks"
1314 "mm/internal/ui"
1415 )
1516
@@ -89,12 +90,25 @@ func (p *Pool) CLI(ctx context.Context, req CLIRequest, cwd string, stdout, stde
8990 label += ", with the conversation"
9091 }
9192 fmt.Fprintln(stderr, label+"…")
92- sink := &cliSink{out: stdout, log: stderr}
93- answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Question, req.Context), sink)
93+ p.notify = func(line string) { fmt.Fprintln(stderr, line) }
94+ // With postAsk hooks the answer is held back until they have run: what
95+ // stdout carries is what the caller reads, and a hook may replace it.
96+ var held strings.Builder
97+ streamed := len(config.Cfg.Hooks.For(config.HookPostAsk)) == 0
98+ sink := &cliSink{out: &held, log: stderr}
99+ if streamed {
100+ sink.out = stdout
101+ }
102+ res, err := p.Delegate(ctx, Request{Agent: req.Agent, Question: req.Question, Transcript: req.Context, WithContext: req.WithContext, Via: hooks.ViaCLI}, cwd, sink)
103+ answer := res.Answer
104+ if !streamed {
105+ answer = res.Text()
106+ fmt.Fprint(stdout, answer)
107+ }
94108 if answer != "" && !strings.HasSuffix(answer, "\n") {
95109 fmt.Fprintln(stdout)
96110 }
97- if err == nil && answer == "" {
111+ if err == nil && res.Answer == "" {
98112 return errors.New(req.Agent + " gave no answer")
99113 }
100114 return err
@@ -10,6 +10,7 @@ import (
10 10
11 "mm/internal/config"11 "mm/internal/config"
12 "mm/internal/consent"12 "mm/internal/consent"
13+ "mm/internal/hooks"
13 "mm/internal/ui"14 "mm/internal/ui"
14 )15 )
15 16
@@ -89,12 +90,25 @@ func (p *Pool) CLI(ctx context.Context, req CLIRequest, cwd string, stdout, stde
89 label += ", with the conversation"90 label += ", with the conversation"
90 }91 }
91 fmt.Fprintln(stderr, label+"…")92 fmt.Fprintln(stderr, label+"…")
92- sink := &cliSink{out: stdout, log: stderr}93+ p.notify = func(line string) { fmt.Fprintln(stderr, line) }
93- answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Question, req.Context), sink)94+ // With postAsk hooks the answer is held back until they have run: what
95+ // stdout carries is what the caller reads, and a hook may replace it.
96+ var held strings.Builder
97+ streamed := len(config.Cfg.Hooks.For(config.HookPostAsk)) == 0
98+ sink := &cliSink{out: &held, log: stderr}
99+ if streamed {
100+ sink.out = stdout
101+ }
102+ res, err := p.Delegate(ctx, Request{Agent: req.Agent, Question: req.Question, Transcript: req.Context, WithContext: req.WithContext, Via: hooks.ViaCLI}, cwd, sink)
103+ answer := res.Answer
104+ if !streamed {
105+ answer = res.Text()
106+ fmt.Fprint(stdout, answer)
107+ }
94 if answer != "" && !strings.HasSuffix(answer, "\n") {108 if answer != "" && !strings.HasSuffix(answer, "\n") {
95 fmt.Fprintln(stdout)109 fmt.Fprintln(stdout)
96 }110 }
97- if err == nil && answer == "" {111+ if err == nil && res.Answer == "" {
98 return errors.New(req.Agent + " gave no answer")112 return errors.New(req.Agent + " gave no answer")
99 }113 }
100 return err114 return err
added internal/ask/delegate.go +129 -0
new file mode 100644
@@ -0,0 +1,129 @@
1+package ask
2+
3+import (
4+ "context"
5+ "errors"
6+ "time"
7+
8+ "mm/internal/config"
9+ "mm/internal/hooks"
10+ "mm/internal/ui"
11+)
12+
13+// Request is one hand-off to another agent, before the asker's preAsk
14+// hooks have seen it.
15+type Request struct {
16+ // Agent is a key of mmConfigs; the caller has checked it exists.
17+ Agent string
18+ // Question is what the asker wants answered.
19+ Question string
20+ // Transcript is the conversation sent along (WithContext), "" otherwise.
21+ Transcript string
22+ WithContext bool
23+ // Via is hooks.ViaTool, hooks.ViaCommand or hooks.ViaCLI.
24+ Via string
25+}
26+
27+// Result is what comes back to the asker once its postAsk hooks have run.
28+type Result struct {
29+ // Question is the question as sent: the asker's, or a preAsk rewrite.
30+ Question string
31+ // Answer is what the asker reads: the asked agent's, or a postAsk
32+ // replacement. It is NOT what the user saw stream.
33+ Answer string
34+ // Replaced reports that a postAsk hook replaced the answer.
35+ Replaced bool
36+ // Context is what the postAsk hooks added after the answer (see Text).
37+ Context []string
38+}
39+
40+// Text is the answer followed by the context the postAsk hooks added.
41+func (r Result) Text() string { return hooks.WithContext(r.Answer, r.Context) }
42+
43+// DeniedError is a hand-off a preAsk hook refused: the asked agent was not
44+// started nor asked.
45+type DeniedError struct {
46+ Agent, Reason string
47+}
48+
49+func (e *DeniedError) Error() string {
50+ if e.Reason == "" {
51+ return "ask denied by a hook"
52+ }
53+ return "ask denied by a hook: " + e.Reason
54+}
55+
56+// IsDenied reports whether err is a hand-off a preAsk hook refused.
57+func IsDenied(err error) bool {
58+ var denied *DeniedError
59+ return errors.As(err, &denied)
60+}
61+
62+// Delegate is the one way every hand-off goes — the ask_agent tool, the /ask
63+// commands, mm --ask: the asker's preAsk hooks, the prompt the asked agent
64+// receives (Prompt), the ask itself (Ask), then the asker's postAsk hooks.
65+// Without hooks it is exactly Ask on Prompt(Question, Transcript).
66+//
67+// A *DeniedError means a preAsk hook refused. Otherwise the error is the
68+// ask's — unless a postAsk hook replaced the answer of a failed ask: the
69+// asker then reads the replacement as the answer, and the error is dropped.
70+//
71+// res, err := pool.Delegate(ctx, ask.Request{Agent: "go", Question: q, Via: hooks.ViaCommand}, cwd, sink)
72+// if err == nil {
73+// fmt.Println(res.Text())
74+// }
75+func (p *Pool) Delegate(ctx context.Context, req Request, cwd string, sink ui.Sink) (Result, error) {
76+ path := config.Cfg.MMConfigs[req.Agent].Path
77+ runner := hooks.New(config.Cfg.Hooks, p.notifier(sink))
78+ call := &hooks.AskCall{
79+ Agent: req.Agent, Config: path, AgentModel: agentModel(path), Via: req.Via,
80+ WithContext: req.WithContext, Depth: Depth(), Prompt: req.Question, Transcript: req.Transcript,
81+ }
82+ in := hooks.Input{Cwd: cwd, Model: config.Cfg.Provider + "/" + config.Cfg.Model, Ask: call}
83+
84+ d := runner.PreAsk(ctx, in)
85+ if d.Denied {
86+ return Result{Question: req.Question}, &DeniedError{Agent: req.Agent, Reason: d.Reason}
87+ }
88+ call.Prompt, call.Transcript = d.Prompt, d.Transcript
89+ prompt := hooks.WithContext(Prompt(d.Prompt, d.Transcript), d.Context)
90+
91+ start := time.Now()
92+ answer, err := p.Ask(ctx, req.Agent, path, cwd, prompt, sink)
93+ in.Answer, in.DurationMs = answer, time.Since(start).Milliseconds()
94+ if err != nil {
95+ in.Error = err.Error()
96+ }
97+ // Not the ask's context, like postGeneration: an aborted ask is one an
98+ // audit hook must still see. The hook's own timeout bounds it.
99+ a := runner.PostAsk(context.WithoutCancel(ctx), in)
100+ res := Result{Question: d.Prompt, Answer: a.Answer, Replaced: a.Replaced, Context: a.Context}
101+ if a.Replaced {
102+ p.notifier(sink)("🪝 postAsk: " + req.Agent + "'s answer was replaced by a hook — the asking agent reads the new one")
103+ if ctx.Err() == nil {
104+ err = nil
105+ }
106+ }
107+ return res, err
108+}
109+
110+// notifier is where hook reports and notices go: the pool's own (mm --ask:
111+// stderr, stdout being the answer), else the sink's notice line when it has
112+// one, else ui.Notice.
113+func (p *Pool) notifier(sink ui.Sink) func(string) {
114+ if p.notify != nil {
115+ return p.notify
116+ }
117+ if n, ok := sink.(ui.Noticer); ok {
118+ return n.Notice
119+ }
120+ return ui.Notice
121+}
122+
123+// agentModel is "<provider>/<model>" from an agent's file, "" when unreadable.
124+func agentModel(path string) string {
125+ if model := Describe(path); model != "?" {
126+ return model
127+ }
128+ return ""
129+}
new file mode 100644
@@ -0,0 +1,129 @@
1+package ask
2+
3+import (
4+ "context"
5+ "errors"
6+ "time"
7+
8+ "mm/internal/config"
9+ "mm/internal/hooks"
10+ "mm/internal/ui"
11+)
12+
13+// Request is one hand-off to another agent, before the asker's preAsk
14+// hooks have seen it.
15+type Request struct {
16+ // Agent is a key of mmConfigs; the caller has checked it exists.
17+ Agent string
18+ // Question is what the asker wants answered.
19+ Question string
20+ // Transcript is the conversation sent along (WithContext), "" otherwise.
21+ Transcript string
22+ WithContext bool
23+ // Via is hooks.ViaTool, hooks.ViaCommand or hooks.ViaCLI.
24+ Via string
25+}
26+
27+// Result is what comes back to the asker once its postAsk hooks have run.
28+type Result struct {
29+ // Question is the question as sent: the asker's, or a preAsk rewrite.
30+ Question string
31+ // Answer is what the asker reads: the asked agent's, or a postAsk
32+ // replacement. It is NOT what the user saw stream.
33+ Answer string
34+ // Replaced reports that a postAsk hook replaced the answer.
35+ Replaced bool
36+ // Context is what the postAsk hooks added after the answer (see Text).
37+ Context []string
38+}
39+
40+// Text is the answer followed by the context the postAsk hooks added.
41+func (r Result) Text() string { return hooks.WithContext(r.Answer, r.Context) }
42+
43+// DeniedError is a hand-off a preAsk hook refused: the asked agent was not
44+// started nor asked.
45+type DeniedError struct {
46+ Agent, Reason string
47+}
48+
49+func (e *DeniedError) Error() string {
50+ if e.Reason == "" {
51+ return "ask denied by a hook"
52+ }
53+ return "ask denied by a hook: " + e.Reason
54+}
55+
56+// IsDenied reports whether err is a hand-off a preAsk hook refused.
57+func IsDenied(err error) bool {
58+ var denied *DeniedError
59+ return errors.As(err, &denied)
60+}
61+
62+// Delegate is the one way every hand-off goes — the ask_agent tool, the /ask
63+// commands, mm --ask: the asker's preAsk hooks, the prompt the asked agent
64+// receives (Prompt), the ask itself (Ask), then the asker's postAsk hooks.
65+// Without hooks it is exactly Ask on Prompt(Question, Transcript).
66+//
67+// A *DeniedError means a preAsk hook refused. Otherwise the error is the
68+// ask's — unless a postAsk hook replaced the answer of a failed ask: the
69+// asker then reads the replacement as the answer, and the error is dropped.
70+//
71+// res, err := pool.Delegate(ctx, ask.Request{Agent: "go", Question: q, Via: hooks.ViaCommand}, cwd, sink)
72+// if err == nil {
73+// fmt.Println(res.Text())
74+// }
75+func (p *Pool) Delegate(ctx context.Context, req Request, cwd string, sink ui.Sink) (Result, error) {
76+ path := config.Cfg.MMConfigs[req.Agent].Path
77+ runner := hooks.New(config.Cfg.Hooks, p.notifier(sink))
78+ call := &hooks.AskCall{
79+ Agent: req.Agent, Config: path, AgentModel: agentModel(path), Via: req.Via,
80+ WithContext: req.WithContext, Depth: Depth(), Prompt: req.Question, Transcript: req.Transcript,
81+ }
82+ in := hooks.Input{Cwd: cwd, Model: config.Cfg.Provider + "/" + config.Cfg.Model, Ask: call}
83+
84+ d := runner.PreAsk(ctx, in)
85+ if d.Denied {
86+ return Result{Question: req.Question}, &DeniedError{Agent: req.Agent, Reason: d.Reason}
87+ }
88+ call.Prompt, call.Transcript = d.Prompt, d.Transcript
89+ prompt := hooks.WithContext(Prompt(d.Prompt, d.Transcript), d.Context)
90+
91+ start := time.Now()
92+ answer, err := p.Ask(ctx, req.Agent, path, cwd, prompt, sink)
93+ in.Answer, in.DurationMs = answer, time.Since(start).Milliseconds()
94+ if err != nil {
95+ in.Error = err.Error()
96+ }
97+ // Not the ask's context, like postGeneration: an aborted ask is one an
98+ // audit hook must still see. The hook's own timeout bounds it.
99+ a := runner.PostAsk(context.WithoutCancel(ctx), in)
100+ res := Result{Question: d.Prompt, Answer: a.Answer, Replaced: a.Replaced, Context: a.Context}
101+ if a.Replaced {
102+ p.notifier(sink)("🪝 postAsk: " + req.Agent + "'s answer was replaced by a hook — the asking agent reads the new one")
103+ if ctx.Err() == nil {
104+ err = nil
105+ }
106+ }
107+ return res, err
108+}
109+
110+// notifier is where hook reports and notices go: the pool's own (mm --ask:
111+// stderr, stdout being the answer), else the sink's notice line when it has
112+// one, else ui.Notice.
113+func (p *Pool) notifier(sink ui.Sink) func(string) {
114+ if p.notify != nil {
115+ return p.notify
116+ }
117+ if n, ok := sink.(ui.Noticer); ok {
118+ return n.Notice
119+ }
120+ return ui.Notice
121+}
122+
123+// agentModel is "<provider>/<model>" from an agent's file, "" when unreadable.
124+func agentModel(path string) string {
125+ if model := Describe(path); model != "?" {
126+ return model
127+ }
128+ return ""
129+}
added internal/ask/delegate_test.go +175 -0
new file mode 100644
@@ -0,0 +1,175 @@
1+package ask
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "mm/internal/command"
14+ "mm/internal/config"
15+ "mm/internal/hooks"
16+
17+ "github.com/firebase/genkit/go/ai"
18+)
19+
20+// The asker's preAsk/postAsk hooks, run through the real pool and a fake
21+// asked agent: "echo" answers "session N: <the prompt it received>", so what
22+// the hooks changed is visible in the answer.
23+
24+// withHooks declares the asker's hooks for one test (withAgent restores
25+// config.Cfg).
26+func withHooks(t *testing.T, cfg config.HooksConfig) {
27+ t.Helper()
28+ cfg.ConfigDir = t.TempDir()
29+ config.Cfg.Hooks = cfg
30+}
31+
32+// shHook is a hook entry running script, with the defaults Load fills in.
33+func shHook(script string) config.Hook {
34+ return config.Hook{Command: script, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn}
35+}
36+
37+// noticing is a recorder that also has a notice line, like the window.
38+type noticing struct {
39+ recorder
40+ notices []string
41+}
42+
43+func (n *noticing) Notice(s string) { n.notices = append(n.notices, s) }
44+
45+// preAsk rewrites the question, empties the transcript and adds context: the
46+// asked agent receives exactly that, and the history keeps the question as
47+// sent. postAsk's context follows the answer in the history.
48+func TestRunAppliesPreAskAndPostAsk(t *testing.T) {
49+ withAgent(t)
50+ withHooks(t, config.HooksConfig{
51+ PreAsk: []config.Hook{shHook(`echo '{"prompt":"rewritten","transcript":"","context":"answer in French"}'`)},
52+ PostAsk: []config.Hook{shHook(`echo '{"context":"checked by a hook"}'`)},
53+ })
54+ history := []*ai.Message{ai.NewUserTextMessage("SECRET-IN-HISTORY")}
55+ got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", WithContext: true, Prompt: "original"}, history, t.TempDir(), &recorder{})
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if q := got[0].Text(); q != "[asked bob (ollama/qwen3.5:2b), with this conversation as context: rewritten]" {
60+ t.Errorf("question kept = %q", q)
61+ }
62+ answer := got[1].Text()
63+ want := "[bob's answer]\nsession 1: rewritten\n\n" + hooks.ContextLabel + "answer in French\n\n" + hooks.ContextLabel + "checked by a hook"
64+ if answer != want {
65+ t.Errorf("answer kept =\n%q\nwant\n%q", answer, want)
66+ }
67+ if strings.Contains(answer, "SECRET") {
68+ t.Errorf("the emptied transcript was sent: %q", answer)
69+ }
70+}
71+
72+// A deny never starts the asked agent, and says why.
73+func TestPreAskDenyStopsTheHandOff(t *testing.T) {
74+ withAgent(t)
75+ withHooks(t, config.HooksConfig{PreAsk: []config.Hook{shHook(`echo '{"action":"deny","reason":"budget spent"}'`)}})
76+ p := fakePool(t, "echo")
77+ _, err := p.Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, nil, t.TempDir(), &recorder{})
78+ if !IsDenied(err) || err.Error() != "ask denied by a hook: budget spent" {
79+ t.Errorf("err = %v, want a DeniedError", err)
80+ }
81+ if len(p.children) != 0 {
82+ t.Errorf("children = %v, want none started", p.children)
83+ }
84+}
85+
86+// The hooks read the hand-off: who, how, how deep, and — after — the answer
87+// and how long it took. The matcher picks the agent.
88+func TestAskHooksReadTheHandOff(t *testing.T) {
89+ withAgent(t)
90+ dir := t.TempDir()
91+ pre, post := filepath.Join(dir, "pre.json"), filepath.Join(dir, "post.json")
92+ other := shHook(`echo '{"action":"deny"}'`)
93+ other.Matcher = "^alice$"
94+ withHooks(t, config.HooksConfig{
95+ PreAsk: []config.Hook{other, shHook(`cat > "` + pre + `"`)},
96+ PostAsk: []config.Hook{shHook(`cat > "` + post + `"`)},
97+ })
98+ config.Cfg.Provider, config.Cfg.Model = "llmman", "gemma"
99+ cwd := t.TempDir()
100+ if _, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, nil, cwd, &recorder{}); err != nil {
101+ t.Fatal(err)
102+ }
103+ var before, after hooks.Input
104+ readJSON(t, pre, &before)
105+ readJSON(t, post, &after)
106+ a := before.Ask
107+ if before.Event != "preAsk" || before.Cwd != cwd || before.Model != "llmman/gemma" || a.Agent != "bob" || a.AgentModel != "ollama/qwen3.5:2b" || a.Via != hooks.ViaCommand || a.Depth != 0 || a.Prompt != "q" || !strings.HasSuffix(a.Config, "agent.bob.yaml") {
108+ t.Errorf("preAsk read %+v / %+v", before, a)
109+ }
110+ if after.Event != "postAsk" || after.Answer != "session 1: q" || after.Error != "" || after.Ask.Prompt != "q" {
111+ t.Errorf("postAsk read %+v", after)
112+ }
113+}
114+
115+// postAsk replaces the answer of a failed ask: the asker reads it as the
116+// answer, and the user is told on the notice line.
117+func TestPostAskReplacesAFailure(t *testing.T) {
118+ withAgent(t)
119+ withHooks(t, config.HooksConfig{PostAsk: []config.Hook{shHook(`grep -q '"error"' && echo '{"answer":"bob is down: answer yourself"}'`)}})
120+ sink := &noticing{}
121+ got, err := fakePool(t, "crash").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, nil, t.TempDir(), sink)
122+ if err != nil {
123+ t.Fatalf("err = %v, want the failure replaced", err)
124+ }
125+ if a := got[1].Text(); a != "[bob's answer]\nbob is down: answer yourself" {
126+ t.Errorf("answer kept = %q", a)
127+ }
128+ if len(sink.notices) != 1 || !strings.Contains(sink.notices[0], "bob's answer was replaced by a hook") {
129+ t.Errorf("notices = %v", sink.notices)
130+ }
131+}
132+
133+// mm --ask with postAsk hooks: stdout carries the replaced answer, and the
134+// hooks' lines go to stderr.
135+func TestCLIPrintsThePostAskAnswer(t *testing.T) {
136+ withAgent(t)
137+ withHooks(t, config.HooksConfig{PostAsk: []config.Hook{
138+ shHook(`echo '{"answer":"REPLACED","context":"note"}'`),
139+ shHook("exit 4"),
140+ }})
141+ var out, errs bytes.Buffer
142+ if err := fakePool(t, "echo").CLI(context.Background(), CLIRequest{Agent: "bob", Question: "q"}, t.TempDir(), &out, &errs); err != nil {
143+ t.Fatal(err)
144+ }
145+ if got, want := out.String(), "REPLACED\n\n"+hooks.ContextLabel+"note\n"; got != want {
146+ t.Errorf("stdout = %q, want %q", got, want)
147+ }
148+ for _, want := range []string{"postAsk hook `exit 4`: exit status 4", "bob's answer was replaced by a hook"} {
149+ if !strings.Contains(errs.String(), want) {
150+ t.Errorf("stderr lacks %q:\n%s", want, errs.String())
151+ }
152+ }
153+}
154+
155+// mm --ask refused by a preAsk hook fails with the reason, stdout empty.
156+func TestCLIDenied(t *testing.T) {
157+ withAgent(t)
158+ withHooks(t, config.HooksConfig{PreAsk: []config.Hook{shHook(`echo '{"action":"deny","reason":"not from a script"}'`)}})
159+ var out, errs bytes.Buffer
160+ err := fakePool(t, "echo").CLI(context.Background(), CLIRequest{Agent: "bob", Question: "q"}, t.TempDir(), &out, &errs)
161+ if !IsDenied(err) || out.Len() != 0 {
162+ t.Errorf("err %v, stdout %q", err, out.String())
163+ }
164+}
165+
166+func readJSON(t *testing.T, path string, v any) {
167+ t.Helper()
168+ data, err := os.ReadFile(path)
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ if err := json.Unmarshal(data, v); err != nil {
173+ t.Fatalf("%s: %v", data, err)
174+ }
175+}
new file mode 100644
@@ -0,0 +1,175 @@
1+package ask
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "mm/internal/command"
14+ "mm/internal/config"
15+ "mm/internal/hooks"
16+
17+ "github.com/firebase/genkit/go/ai"
18+)
19+
20+// The asker's preAsk/postAsk hooks, run through the real pool and a fake
21+// asked agent: "echo" answers "session N: <the prompt it received>", so what
22+// the hooks changed is visible in the answer.
23+
24+// withHooks declares the asker's hooks for one test (withAgent restores
25+// config.Cfg).
26+func withHooks(t *testing.T, cfg config.HooksConfig) {
27+ t.Helper()
28+ cfg.ConfigDir = t.TempDir()
29+ config.Cfg.Hooks = cfg
30+}
31+
32+// shHook is a hook entry running script, with the defaults Load fills in.
33+func shHook(script string) config.Hook {
34+ return config.Hook{Command: script, Timeout: 5 * time.Second, OnError: config.HookOnErrorWarn}
35+}
36+
37+// noticing is a recorder that also has a notice line, like the window.
38+type noticing struct {
39+ recorder
40+ notices []string
41+}
42+
43+func (n *noticing) Notice(s string) { n.notices = append(n.notices, s) }
44+
45+// preAsk rewrites the question, empties the transcript and adds context: the
46+// asked agent receives exactly that, and the history keeps the question as
47+// sent. postAsk's context follows the answer in the history.
48+func TestRunAppliesPreAskAndPostAsk(t *testing.T) {
49+ withAgent(t)
50+ withHooks(t, config.HooksConfig{
51+ PreAsk: []config.Hook{shHook(`echo '{"prompt":"rewritten","transcript":"","context":"answer in French"}'`)},
52+ PostAsk: []config.Hook{shHook(`echo '{"context":"checked by a hook"}'`)},
53+ })
54+ history := []*ai.Message{ai.NewUserTextMessage("SECRET-IN-HISTORY")}
55+ got, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", WithContext: true, Prompt: "original"}, history, t.TempDir(), &recorder{})
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ if q := got[0].Text(); q != "[asked bob (ollama/qwen3.5:2b), with this conversation as context: rewritten]" {
60+ t.Errorf("question kept = %q", q)
61+ }
62+ answer := got[1].Text()
63+ want := "[bob's answer]\nsession 1: rewritten\n\n" + hooks.ContextLabel + "answer in French\n\n" + hooks.ContextLabel + "checked by a hook"
64+ if answer != want {
65+ t.Errorf("answer kept =\n%q\nwant\n%q", answer, want)
66+ }
67+ if strings.Contains(answer, "SECRET") {
68+ t.Errorf("the emptied transcript was sent: %q", answer)
69+ }
70+}
71+
72+// A deny never starts the asked agent, and says why.
73+func TestPreAskDenyStopsTheHandOff(t *testing.T) {
74+ withAgent(t)
75+ withHooks(t, config.HooksConfig{PreAsk: []config.Hook{shHook(`echo '{"action":"deny","reason":"budget spent"}'`)}})
76+ p := fakePool(t, "echo")
77+ _, err := p.Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, nil, t.TempDir(), &recorder{})
78+ if !IsDenied(err) || err.Error() != "ask denied by a hook: budget spent" {
79+ t.Errorf("err = %v, want a DeniedError", err)
80+ }
81+ if len(p.children) != 0 {
82+ t.Errorf("children = %v, want none started", p.children)
83+ }
84+}
85+
86+// The hooks read the hand-off: who, how, how deep, and — after — the answer
87+// and how long it took. The matcher picks the agent.
88+func TestAskHooksReadTheHandOff(t *testing.T) {
89+ withAgent(t)
90+ dir := t.TempDir()
91+ pre, post := filepath.Join(dir, "pre.json"), filepath.Join(dir, "post.json")
92+ other := shHook(`echo '{"action":"deny"}'`)
93+ other.Matcher = "^alice$"
94+ withHooks(t, config.HooksConfig{
95+ PreAsk: []config.Hook{other, shHook(`cat > "` + pre + `"`)},
96+ PostAsk: []config.Hook{shHook(`cat > "` + post + `"`)},
97+ })
98+ config.Cfg.Provider, config.Cfg.Model = "llmman", "gemma"
99+ cwd := t.TempDir()
100+ if _, err := fakePool(t, "echo").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, nil, cwd, &recorder{}); err != nil {
101+ t.Fatal(err)
102+ }
103+ var before, after hooks.Input
104+ readJSON(t, pre, &before)
105+ readJSON(t, post, &after)
106+ a := before.Ask
107+ if before.Event != "preAsk" || before.Cwd != cwd || before.Model != "llmman/gemma" || a.Agent != "bob" || a.AgentModel != "ollama/qwen3.5:2b" || a.Via != hooks.ViaCommand || a.Depth != 0 || a.Prompt != "q" || !strings.HasSuffix(a.Config, "agent.bob.yaml") {
108+ t.Errorf("preAsk read %+v / %+v", before, a)
109+ }
110+ if after.Event != "postAsk" || after.Answer != "session 1: q" || after.Error != "" || after.Ask.Prompt != "q" {
111+ t.Errorf("postAsk read %+v", after)
112+ }
113+}
114+
115+// postAsk replaces the answer of a failed ask: the asker reads it as the
116+// answer, and the user is told on the notice line.
117+func TestPostAskReplacesAFailure(t *testing.T) {
118+ withAgent(t)
119+ withHooks(t, config.HooksConfig{PostAsk: []config.Hook{shHook(`grep -q '"error"' && echo '{"answer":"bob is down: answer yourself"}'`)}})
120+ sink := &noticing{}
121+ got, err := fakePool(t, "crash").Run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "q"}, nil, t.TempDir(), sink)
122+ if err != nil {
123+ t.Fatalf("err = %v, want the failure replaced", err)
124+ }
125+ if a := got[1].Text(); a != "[bob's answer]\nbob is down: answer yourself" {
126+ t.Errorf("answer kept = %q", a)
127+ }
128+ if len(sink.notices) != 1 || !strings.Contains(sink.notices[0], "bob's answer was replaced by a hook") {
129+ t.Errorf("notices = %v", sink.notices)
130+ }
131+}
132+
133+// mm --ask with postAsk hooks: stdout carries the replaced answer, and the
134+// hooks' lines go to stderr.
135+func TestCLIPrintsThePostAskAnswer(t *testing.T) {
136+ withAgent(t)
137+ withHooks(t, config.HooksConfig{PostAsk: []config.Hook{
138+ shHook(`echo '{"answer":"REPLACED","context":"note"}'`),
139+ shHook("exit 4"),
140+ }})
141+ var out, errs bytes.Buffer
142+ if err := fakePool(t, "echo").CLI(context.Background(), CLIRequest{Agent: "bob", Question: "q"}, t.TempDir(), &out, &errs); err != nil {
143+ t.Fatal(err)
144+ }
145+ if got, want := out.String(), "REPLACED\n\n"+hooks.ContextLabel+"note\n"; got != want {
146+ t.Errorf("stdout = %q, want %q", got, want)
147+ }
148+ for _, want := range []string{"postAsk hook `exit 4`: exit status 4", "bob's answer was replaced by a hook"} {
149+ if !strings.Contains(errs.String(), want) {
150+ t.Errorf("stderr lacks %q:\n%s", want, errs.String())
151+ }
152+ }
153+}
154+
155+// mm --ask refused by a preAsk hook fails with the reason, stdout empty.
156+func TestCLIDenied(t *testing.T) {
157+ withAgent(t)
158+ withHooks(t, config.HooksConfig{PreAsk: []config.Hook{shHook(`echo '{"action":"deny","reason":"not from a script"}'`)}})
159+ var out, errs bytes.Buffer
160+ err := fakePool(t, "echo").CLI(context.Background(), CLIRequest{Agent: "bob", Question: "q"}, t.TempDir(), &out, &errs)
161+ if !IsDenied(err) || out.Len() != 0 {
162+ t.Errorf("err %v, stdout %q", err, out.String())
163+ }
164+}
165+
166+func readJSON(t *testing.T, path string, v any) {
167+ t.Helper()
168+ data, err := os.ReadFile(path)
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ if err := json.Unmarshal(data, v); err != nil {
173+ t.Fatalf("%s: %v", data, err)
174+ }
175+}
modified internal/ask/env.go +2 -6
@@ -5,7 +5,6 @@ import (
55 "os"
66 "os/exec"
77 "slices"
8- "strconv"
98 "strings"
109
1110 "mm/internal/config"
@@ -13,7 +12,7 @@ import (
1312
1413 // DepthEnv carries how deep in a chain of /ask an agent runs: unset for the
1514 // one the user started, 1 for the agent it asked, and so on.
16-const DepthEnv = "MM_ASK_DEPTH"
15+const DepthEnv = config.AskDepthEnv
1716
1817 // MaxDepth: an agent at this depth may not ask another one. /ask is a USER
1918 // command — no model can call it — so a loop needs a person typing /ask into
@@ -21,10 +20,7 @@ const DepthEnv = "MM_ASK_DEPTH"
2120 const MaxDepth = 2
2221
2322 // Depth is this process's place in the chain (0 = started by the user).
24-func Depth() int {
25- n, _ := strconv.Atoi(os.Getenv(DepthEnv))
26- return max(n, 0)
27-}
23+func Depth() int { return config.AskDepth() }
2824
2925 // CanAsk says whether this agent may ask another one, and why not.
3026 func CanAsk() error {
@@ -5,7 +5,6 @@ import (
5 "os"5 "os"
6 "os/exec"6 "os/exec"
7 "slices"7 "slices"
8- "strconv"
9 "strings"8 "strings"
10 9
11 "mm/internal/config"10 "mm/internal/config"
@@ -13,7 +12,7 @@ import (
13 12
14 // DepthEnv carries how deep in a chain of /ask an agent runs: unset for the13 // DepthEnv carries how deep in a chain of /ask an agent runs: unset for the
15 // one the user started, 1 for the agent it asked, and so on.14 // one the user started, 1 for the agent it asked, and so on.
16-const DepthEnv = "MM_ASK_DEPTH"15+const DepthEnv = config.AskDepthEnv
17 16
18 // MaxDepth: an agent at this depth may not ask another one. /ask is a USER17 // MaxDepth: an agent at this depth may not ask another one. /ask is a USER
19 // command — no model can call it — so a loop needs a person typing /ask into18 // command — no model can call it — so a loop needs a person typing /ask into
@@ -21,10 +20,7 @@ const DepthEnv = "MM_ASK_DEPTH"
21 const MaxDepth = 220 const MaxDepth = 2
22 21
23 // Depth is this process's place in the chain (0 = started by the user).22 // Depth is this process's place in the chain (0 = started by the user).
24-func Depth() int {23+func Depth() int { return config.AskDepth() }
25- n, _ := strconv.Atoi(os.Getenv(DepthEnv))
26- return max(n, 0)
27-}
28 24
29 // CanAsk says whether this agent may ask another one, and why not.25 // CanAsk says whether this agent may ask another one, and why not.
30 func CanAsk() error {26 func CanAsk() error {
modified internal/ask/pool.go +3 -0
@@ -30,6 +30,9 @@ type Pool struct {
3030 // command builds the child process for a config file; a test swaps it for
3131 // a fake agent.
3232 command func(configPath string) *exec.Cmd
33+ // notify receives the hooks' reports and notices (Delegate); nil = the
34+ // sink's notice line, else ui.Notice.
35+ notify func(string)
3336
3437 mu sync.Mutex
3538 children map[string]*child
@@ -30,6 +30,9 @@ type Pool struct {
30 // command builds the child process for a config file; a test swaps it for30 // command builds the child process for a config file; a test swaps it for
31 // a fake agent.31 // a fake agent.
32 command func(configPath string) *exec.Cmd32 command func(configPath string) *exec.Cmd
33+ // notify receives the hooks' reports and notices (Delegate); nil = the
34+ // sink's notice line, else ui.Notice.
35+ notify func(string)
33 36
34 mu sync.Mutex37 mu sync.Mutex
35 children map[string]*child38 children map[string]*child
modified internal/ask/run.go +4 -4
@@ -7,6 +7,7 @@ import (
77
88 "mm/internal/command"
99 "mm/internal/config"
10+ "mm/internal/hooks"
1011 "mm/internal/ui"
1112
1213 "github.com/firebase/genkit/go/ai"
@@ -40,19 +41,18 @@ func (p *Pool) Run(ctx context.Context, req command.AskRequest, history []*ai.Me
4041 if !ok {
4142 return nil, fmt.Errorf("unknown agent %q", req.Agent)
4243 }
43- path := ref.Path
4444 transcript := ""
4545 if req.WithContext {
4646 transcript = Transcript(history, config.Cfg.MaxOutput)
4747 }
48- answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Prompt, transcript), sink)
48+ res, err := p.Delegate(ctx, Request{Agent: req.Agent, Question: req.Prompt, Transcript: transcript, WithContext: req.WithContext, Via: hooks.ViaCommand}, cwd, sink)
4949 if err != nil {
5050 return nil, err
5151 }
52- if answer == "" {
52+ if res.Answer == "" {
5353 return nil, errors.New(req.Agent + " gave no answer")
5454 }
55- return Exchange(req.Agent, Describe(path), req.WithContext, req.Prompt, answer, config.Cfg.MaxOutput), nil
55+ return Exchange(req.Agent, Describe(ref.Path), req.WithContext, res.Question, res.Text(), config.Cfg.MaxOutput), nil
5656 }
5757
5858 // Label is the one line a front end shows before the answer streams: who is
@@ -7,6 +7,7 @@ import (
7 7
8 "mm/internal/command"8 "mm/internal/command"
9 "mm/internal/config"9 "mm/internal/config"
10+ "mm/internal/hooks"
10 "mm/internal/ui"11 "mm/internal/ui"
11 12
12 "github.com/firebase/genkit/go/ai"13 "github.com/firebase/genkit/go/ai"
@@ -40,19 +41,18 @@ func (p *Pool) Run(ctx context.Context, req command.AskRequest, history []*ai.Me
40 if !ok {41 if !ok {
41 return nil, fmt.Errorf("unknown agent %q", req.Agent)42 return nil, fmt.Errorf("unknown agent %q", req.Agent)
42 }43 }
43- path := ref.Path
44 transcript := ""44 transcript := ""
45 if req.WithContext {45 if req.WithContext {
46 transcript = Transcript(history, config.Cfg.MaxOutput)46 transcript = Transcript(history, config.Cfg.MaxOutput)
47 }47 }
48- answer, err := p.Ask(ctx, req.Agent, path, cwd, Prompt(req.Prompt, transcript), sink)48+ res, err := p.Delegate(ctx, Request{Agent: req.Agent, Question: req.Prompt, Transcript: transcript, WithContext: req.WithContext, Via: hooks.ViaCommand}, cwd, sink)
49 if err != nil {49 if err != nil {
50 return nil, err50 return nil, err
51 }51 }
52- if answer == "" {52+ if res.Answer == "" {
53 return nil, errors.New(req.Agent + " gave no answer")53 return nil, errors.New(req.Agent + " gave no answer")
54 }54 }
55- return Exchange(req.Agent, Describe(path), req.WithContext, req.Prompt, answer, config.Cfg.MaxOutput), nil55+ return Exchange(req.Agent, Describe(ref.Path), req.WithContext, res.Question, res.Text(), config.Cfg.MaxOutput), nil
56 }56 }
57 57
58 // Label is the one line a front end shows before the answer streams: who is58 // Label is the one line a front end shows before the answer streams: who is
modified internal/config/agents.go +15 -0
@@ -2,9 +2,11 @@ package config
22
33 import (
44 "fmt"
5+ "os"
56 "path/filepath"
67 "regexp"
78 "sort"
9+ "strconv"
810 "strings"
911
1012 "gopkg.in/yaml.v3"
@@ -14,6 +16,19 @@ import (
1416 // run ("off"). The /ask machinery sets it on the agents it starts.
1517 const SessionsEnv = "AGENT_SESSIONS"
1618
19+// AskDepthEnv carries how deep in a chain of asks an agent runs: unset for
20+// the one a person started, 1 for the agent it asked, and so on. The /ask
21+// machinery sets it on the agents it starts; the hooks read it.
22+const AskDepthEnv = "MM_ASK_DEPTH"
23+
24+// AskDepth is this process's place in the chain (0 = started by a person).
25+//
26+// if config.AskDepth() > 0 { /* another agent asked this one */ }
27+func AskDepth() int {
28+ n, _ := strconv.Atoi(os.Getenv(AskDepthEnv))
29+ return max(n, 0)
30+}
31+
1732 // agentName is the format of an mmConfigs name: it becomes the tail of a
1833 // command name (/ask-bob), so it must be typable, unambiguous after a dash,
1934 // and safe in an editor's `/` menu.
@@ -2,9 +2,11 @@ package config
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "os"
5 "path/filepath"6 "path/filepath"
6 "regexp"7 "regexp"
7 "sort"8 "sort"
9+ "strconv"
8 "strings"10 "strings"
9 11
10 "gopkg.in/yaml.v3"12 "gopkg.in/yaml.v3"
@@ -14,6 +16,19 @@ import (
14 // run ("off"). The /ask machinery sets it on the agents it starts.16 // run ("off"). The /ask machinery sets it on the agents it starts.
15 const SessionsEnv = "AGENT_SESSIONS"17 const SessionsEnv = "AGENT_SESSIONS"
16 18
19+// AskDepthEnv carries how deep in a chain of asks an agent runs: unset for
20+// the one a person started, 1 for the agent it asked, and so on. The /ask
21+// machinery sets it on the agents it starts; the hooks read it.
22+const AskDepthEnv = "MM_ASK_DEPTH"
23+
24+// AskDepth is this process's place in the chain (0 = started by a person).
25+//
26+// if config.AskDepth() > 0 { /* another agent asked this one */ }
27+func AskDepth() int {
28+ n, _ := strconv.Atoi(os.Getenv(AskDepthEnv))
29+ return max(n, 0)
30+}
31+
17 // agentName is the format of an mmConfigs name: it becomes the tail of a32 // agentName is the format of an mmConfigs name: it becomes the tail of a
18 // command name (/ask-bob), so it must be typable, unambiguous after a dash,33 // command name (/ask-bob), so it must be typable, unambiguous after a dash,
19 // and safe in an editor's `/` menu.34 // and safe in an editor's `/` menu.
modified internal/config/hooks.go +30 -15
@@ -11,7 +11,7 @@ import (
1111 "gopkg.in/yaml.v3"
1212 )
1313
14-// The four moments a hook can run at (ticket 36). The names are the keys of
14+// The moments a hook can run at (tickets 36 and 87). The names are the keys of
1515 // the `hooks:` block AND the "event" field of the JSON a hook reads.
1616 const (
1717 // HookPreGeneration runs once per user question, before the first call
@@ -26,6 +26,13 @@ const (
2626 // HookPostGeneration runs once the turn is over, success or failure. It
2727 // only observes.
2828 HookPostGeneration = "postGeneration"
29+ // HookPreAsk runs in the asking agent before each hand-off to another
30+ // agent (ask_agent, /ask, mm --ask): it may deny it, rewrite the question
31+ // or the conversation sent, or add context (ticket 87).
32+ HookPreAsk = "preAsk"
33+ // HookPostAsk runs in the asking agent once the asked agent's turn is
34+ // over: it may replace the answer the asker reads, or add context.
35+ HookPostAsk = "postAsk"
2936 )
3037
3138 // The three values of Hook.OnError: what a failing hook (non-zero exit,
@@ -49,7 +56,7 @@ const (
4956 const DefaultHookTimeout = 10 * time.Second
5057
5158 // hookEvents is the order hooks are listed in (banner, errors).
52-var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration}
59+var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration, HookPreAsk, HookPostAsk}
5360
5461 // HooksConfig is the `hooks:` block of the YAML: for each event, the hooks
5562 // to run, in the order they are written.
@@ -64,6 +71,8 @@ type HooksConfig struct {
6471 PreToolCall []Hook `yaml:"preToolCall"`
6572 PostToolCall []Hook `yaml:"postToolCall"`
6673 PostGeneration []Hook `yaml:"postGeneration"`
74+ PreAsk []Hook `yaml:"preAsk"`
75+ PostAsk []Hook `yaml:"postAsk"`
6776
6877 // ConfigDir is the directory of the YAML file that declared the hooks,
6978 // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook
@@ -77,8 +86,9 @@ type Hook struct {
7786 // Command is run with `sh -c`, in the session's working directory.
7887 Command string `yaml:"command"`
7988 // Matcher is a regular expression on the tool name (preToolCall and
80- // postToolCall only). Empty = every tool. Unanchored, like grep:
81- // "bash" also matches "my_bash"; write "^bash$" for the tool alone.
89+ // postToolCall) or on the asked agent's name (preAsk and postAsk).
90+ // Empty = every one. Unanchored, like grep: "bash" also matches
91+ // "my_bash"; write "^bash$" for the tool alone.
8292 Matcher string `yaml:"matcher"`
8393 // Timeout bounds one run of the hook. 0 = DefaultHookTimeout.
8494 Timeout time.Duration `yaml:"timeout"`
@@ -116,6 +126,10 @@ func (h *HooksConfig) slot(event string) *[]Hook {
116126 return &h.PostToolCall
117127 case HookPostGeneration:
118128 return &h.PostGeneration
129+ case HookPreAsk:
130+ return &h.PreAsk
131+ case HookPostAsk:
132+ return &h.PostAsk
119133 }
120134 return nil
121135 }
@@ -146,21 +160,22 @@ func (h HooksConfig) Summary() string {
146160 return strings.Join(parts, " ")
147161 }
148162
149-// Matches reports whether the hook applies to a tool. A hook without a
150-// matcher applies to every tool. Load has rejected a pattern that does not
151-// compile; one that reaches here anyway (a Config built by hand) matches
152-// nothing rather than panicking in the middle of a turn.
153-func (h Hook) Matches(tool string) bool {
163+// Matches reports whether the hook applies to a tool (or, on preAsk and
164+// postAsk, to an agent). A hook without a matcher applies to every one. Load
165+// has rejected a pattern that does not compile; one that reaches here anyway
166+// (a Config built by hand) matches nothing rather than panicking in the
167+// middle of a turn.
168+func (h Hook) Matches(name string) bool {
154169 if h.Matcher == "" {
155170 return true
156171 }
157172 re, err := regexp.Compile(h.Matcher)
158- return err == nil && re.MatchString(tool)
173+ return err == nil && re.MatchString(name)
159174 }
160175
161176 // validate checks every hook entry: a command, a matcher only where a tool
162-// is involved and one that compiles, a timeout that is not negative, and a
163-// known onError.
177+// or an agent is named and one that compiles, a timeout that is not
178+// negative, and a known onError.
164179 func (h HooksConfig) validate() error {
165180 for _, event := range hookEvents {
166181 for i, hook := range h.For(event) {
@@ -173,12 +188,12 @@ func (h HooksConfig) validate() error {
173188 }
174189
175190 func (h Hook) validate(event string) error {
176- toolEvent := event == HookPreToolCall || event == HookPostToolCall
191+ named := event != HookPreGeneration && event != HookPostGeneration
177192 switch {
178193 case strings.TrimSpace(h.Command) == "":
179194 return errors.New("command must not be empty")
180- case h.Matcher != "" && !toolEvent:
181- return fmt.Errorf("matcher is only for %s and %s", HookPreToolCall, HookPostToolCall)
195+ case h.Matcher != "" && !named:
196+ return fmt.Errorf("matcher is only for %s, %s, %s and %s", HookPreToolCall, HookPostToolCall, HookPreAsk, HookPostAsk)
182197 case h.Timeout < 0:
183198 return errors.New("timeout must be >= 0")
184199 }
@@ -11,7 +11,7 @@ import (
11 "gopkg.in/yaml.v3"11 "gopkg.in/yaml.v3"
12 )12 )
13 13
14-// The four moments a hook can run at (ticket 36). The names are the keys of14+// The moments a hook can run at (tickets 36 and 87). The names are the keys of
15 // the `hooks:` block AND the "event" field of the JSON a hook reads.15 // the `hooks:` block AND the "event" field of the JSON a hook reads.
16 const (16 const (
17 // HookPreGeneration runs once per user question, before the first call17 // HookPreGeneration runs once per user question, before the first call
@@ -26,6 +26,13 @@ const (
26 // HookPostGeneration runs once the turn is over, success or failure. It26 // HookPostGeneration runs once the turn is over, success or failure. It
27 // only observes.27 // only observes.
28 HookPostGeneration = "postGeneration"28 HookPostGeneration = "postGeneration"
29+ // HookPreAsk runs in the asking agent before each hand-off to another
30+ // agent (ask_agent, /ask, mm --ask): it may deny it, rewrite the question
31+ // or the conversation sent, or add context (ticket 87).
32+ HookPreAsk = "preAsk"
33+ // HookPostAsk runs in the asking agent once the asked agent's turn is
34+ // over: it may replace the answer the asker reads, or add context.
35+ HookPostAsk = "postAsk"
29 )36 )
30 37
31 // The three values of Hook.OnError: what a failing hook (non-zero exit,38 // The three values of Hook.OnError: what a failing hook (non-zero exit,
@@ -49,7 +56,7 @@ const (
49 const DefaultHookTimeout = 10 * time.Second56 const DefaultHookTimeout = 10 * time.Second
50 57
51 // hookEvents is the order hooks are listed in (banner, errors).58 // hookEvents is the order hooks are listed in (banner, errors).
52-var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration}59+var hookEvents = []string{HookPreGeneration, HookPreToolCall, HookPostToolCall, HookPostGeneration, HookPreAsk, HookPostAsk}
53 60
54 // HooksConfig is the `hooks:` block of the YAML: for each event, the hooks61 // HooksConfig is the `hooks:` block of the YAML: for each event, the hooks
55 // to run, in the order they are written.62 // to run, in the order they are written.
@@ -64,6 +71,8 @@ type HooksConfig struct {
64 PreToolCall []Hook `yaml:"preToolCall"`71 PreToolCall []Hook `yaml:"preToolCall"`
65 PostToolCall []Hook `yaml:"postToolCall"`72 PostToolCall []Hook `yaml:"postToolCall"`
66 PostGeneration []Hook `yaml:"postGeneration"`73 PostGeneration []Hook `yaml:"postGeneration"`
74+ PreAsk []Hook `yaml:"preAsk"`
75+ PostAsk []Hook `yaml:"postAsk"`
67 76
68 // ConfigDir is the directory of the YAML file that declared the hooks,77 // ConfigDir is the directory of the YAML file that declared the hooks,
69 // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook78 // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook
@@ -77,8 +86,9 @@ type Hook struct {
77 // Command is run with `sh -c`, in the session's working directory.86 // Command is run with `sh -c`, in the session's working directory.
78 Command string `yaml:"command"`87 Command string `yaml:"command"`
79 // Matcher is a regular expression on the tool name (preToolCall and88 // Matcher is a regular expression on the tool name (preToolCall and
80- // postToolCall only). Empty = every tool. Unanchored, like grep:89+ // postToolCall) or on the asked agent's name (preAsk and postAsk).
81- // "bash" also matches "my_bash"; write "^bash$" for the tool alone.90+ // Empty = every one. Unanchored, like grep: "bash" also matches
91+ // "my_bash"; write "^bash$" for the tool alone.
82 Matcher string `yaml:"matcher"`92 Matcher string `yaml:"matcher"`
83 // Timeout bounds one run of the hook. 0 = DefaultHookTimeout.93 // Timeout bounds one run of the hook. 0 = DefaultHookTimeout.
84 Timeout time.Duration `yaml:"timeout"`94 Timeout time.Duration `yaml:"timeout"`
@@ -116,6 +126,10 @@ func (h *HooksConfig) slot(event string) *[]Hook {
116 return &h.PostToolCall126 return &h.PostToolCall
117 case HookPostGeneration:127 case HookPostGeneration:
118 return &h.PostGeneration128 return &h.PostGeneration
129+ case HookPreAsk:
130+ return &h.PreAsk
131+ case HookPostAsk:
132+ return &h.PostAsk
119 }133 }
120 return nil134 return nil
121 }135 }
@@ -146,21 +160,22 @@ func (h HooksConfig) Summary() string {
146 return strings.Join(parts, " ")160 return strings.Join(parts, " ")
147 }161 }
148 162
149-// Matches reports whether the hook applies to a tool. A hook without a163+// Matches reports whether the hook applies to a tool (or, on preAsk and
150-// matcher applies to every tool. Load has rejected a pattern that does not164+// postAsk, to an agent). A hook without a matcher applies to every one. Load
151-// compile; one that reaches here anyway (a Config built by hand) matches165+// has rejected a pattern that does not compile; one that reaches here anyway
152-// nothing rather than panicking in the middle of a turn.166+// (a Config built by hand) matches nothing rather than panicking in the
153-func (h Hook) Matches(tool string) bool {167+// middle of a turn.
168+func (h Hook) Matches(name string) bool {
154 if h.Matcher == "" {169 if h.Matcher == "" {
155 return true170 return true
156 }171 }
157 re, err := regexp.Compile(h.Matcher)172 re, err := regexp.Compile(h.Matcher)
158- return err == nil && re.MatchString(tool)173+ return err == nil && re.MatchString(name)
159 }174 }
160 175
161 // validate checks every hook entry: a command, a matcher only where a tool176 // validate checks every hook entry: a command, a matcher only where a tool
162-// is involved and one that compiles, a timeout that is not negative, and a177+// or an agent is named and one that compiles, a timeout that is not
163-// known onError.178+// negative, and a known onError.
164 func (h HooksConfig) validate() error {179 func (h HooksConfig) validate() error {
165 for _, event := range hookEvents {180 for _, event := range hookEvents {
166 for i, hook := range h.For(event) {181 for i, hook := range h.For(event) {
@@ -173,12 +188,12 @@ func (h HooksConfig) validate() error {
173 }188 }
174 189
175 func (h Hook) validate(event string) error {190 func (h Hook) validate(event string) error {
176- toolEvent := event == HookPreToolCall || event == HookPostToolCall191+ named := event != HookPreGeneration && event != HookPostGeneration
177 switch {192 switch {
178 case strings.TrimSpace(h.Command) == "":193 case strings.TrimSpace(h.Command) == "":
179 return errors.New("command must not be empty")194 return errors.New("command must not be empty")
180- case h.Matcher != "" && !toolEvent:195+ case h.Matcher != "" && !named:
181- return fmt.Errorf("matcher is only for %s and %s", HookPreToolCall, HookPostToolCall)196+ return fmt.Errorf("matcher is only for %s, %s, %s and %s", HookPreToolCall, HookPostToolCall, HookPreAsk, HookPostAsk)
182 case h.Timeout < 0:197 case h.Timeout < 0:
183 return errors.New("timeout must be >= 0")198 return errors.New("timeout must be >= 0")
184 }199 }
modified internal/config/hooks_test.go +34 -0
@@ -56,6 +56,36 @@ hooks:
5656 }
5757 }
5858
59+// preAsk and postAsk are read like the others, take a matcher (on the agent's
60+// name), and come last in the banner.
61+func TestAskHooksAreRead(t *testing.T) {
62+ err := loadYAML(t, `
63+hooks:
64+ postAsk:
65+ - command: node log.js
66+ preAsk:
67+ - matcher: ^(go|rust)$
68+ command: node rules.js
69+ onError: fail
70+ preGeneration:
71+ - command: node a.js
72+`)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ h := Cfg.Hooks
77+ if got, want := h.Summary(), "preGeneration×1 preAsk×1 postAsk×1"; got != want {
78+ t.Errorf("Summary() = %q, want %q", got, want)
79+ }
80+ pre := h.For(HookPreAsk)[0]
81+ if pre.Matcher != "^(go|rust)$" || pre.OnError != HookOnErrorFail || !pre.Matches("go") || pre.Matches("python") {
82+ t.Errorf("preAsk[0] = %+v, want matcher on go|rust, onError fail", pre)
83+ }
84+ if post := h.For(HookPostAsk)[0]; post.Timeout != DefaultHookTimeout || post.OnError != HookOnErrorWarn {
85+ t.Errorf("postAsk[0] = %+v, want the defaults", post)
86+ }
87+}
88+
5989 // Each mistake is refused at start-up with the entry it is in, rather than
6090 // discovered when the hook silently does not run.
6191 func TestInvalidHooksAreRejected(t *testing.T) {
@@ -64,6 +94,10 @@ func TestInvalidHooksAreRejected(t *testing.T) {
6494 "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`},
6595 "empty command": {
6696 "hooks:\n preGeneration:\n - command: \" \"\n", "hooks.preGeneration[0]: command must not be empty"},
97+ "matcher on postGeneration": {
98+ "hooks:\n postGeneration:\n - command: x\n matcher: bob\n", "matcher is only for preToolCall, postToolCall, preAsk and postAsk"},
99+ "bad matcher on preAsk": {
100+ "hooks:\n preAsk:\n - command: x\n matcher: \"[\"\n", "hooks.preAsk[0]: matcher:"},
67101 "matcher outside tool events": {
68102 "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"},
69103 "bad matcher": {
@@ -56,6 +56,36 @@ hooks:
56 }56 }
57 }57 }
58 58
59+// preAsk and postAsk are read like the others, take a matcher (on the agent's
60+// name), and come last in the banner.
61+func TestAskHooksAreRead(t *testing.T) {
62+ err := loadYAML(t, `
63+hooks:
64+ postAsk:
65+ - command: node log.js
66+ preAsk:
67+ - matcher: ^(go|rust)$
68+ command: node rules.js
69+ onError: fail
70+ preGeneration:
71+ - command: node a.js
72+`)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ h := Cfg.Hooks
77+ if got, want := h.Summary(), "preGeneration×1 preAsk×1 postAsk×1"; got != want {
78+ t.Errorf("Summary() = %q, want %q", got, want)
79+ }
80+ pre := h.For(HookPreAsk)[0]
81+ if pre.Matcher != "^(go|rust)$" || pre.OnError != HookOnErrorFail || !pre.Matches("go") || pre.Matches("python") {
82+ t.Errorf("preAsk[0] = %+v, want matcher on go|rust, onError fail", pre)
83+ }
84+ if post := h.For(HookPostAsk)[0]; post.Timeout != DefaultHookTimeout || post.OnError != HookOnErrorWarn {
85+ t.Errorf("postAsk[0] = %+v, want the defaults", post)
86+ }
87+}
88+
59 // Each mistake is refused at start-up with the entry it is in, rather than89 // Each mistake is refused at start-up with the entry it is in, rather than
60 // discovered when the hook silently does not run.90 // discovered when the hook silently does not run.
61 func TestInvalidHooksAreRejected(t *testing.T) {91 func TestInvalidHooksAreRejected(t *testing.T) {
@@ -64,6 +94,10 @@ func TestInvalidHooksAreRejected(t *testing.T) {
64 "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`},94 "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`},
65 "empty command": {95 "empty command": {
66 "hooks:\n preGeneration:\n - command: \" \"\n", "hooks.preGeneration[0]: command must not be empty"},96 "hooks:\n preGeneration:\n - command: \" \"\n", "hooks.preGeneration[0]: command must not be empty"},
97+ "matcher on postGeneration": {
98+ "hooks:\n postGeneration:\n - command: x\n matcher: bob\n", "matcher is only for preToolCall, postToolCall, preAsk and postAsk"},
99+ "bad matcher on preAsk": {
100+ "hooks:\n preAsk:\n - command: x\n matcher: \"[\"\n", "hooks.preAsk[0]: matcher:"},
67 "matcher outside tool events": {101 "matcher outside tool events": {
68 "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"},102 "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"},
69 "bad matcher": {103 "bad matcher": {
modified internal/engine/hooks.go +2 -7
@@ -30,11 +30,6 @@ import (
3030 // because it holds a secret must not have it sent on the NEXT turn.
3131 const blockedPrompt = "[prompt blocked by a hook"
3232
33-// hookContextLabel introduces the text a hook adds for the model, so the
34-// model — and whoever reads a session file — can tell it from what the user
35-// typed or what the tool printed.
36-const hookContextLabel = "[context added by a hook]\n"
37-
3833 // generateWithHooks wraps generate with the two generation events. Nothing
3934 // here runs when no hook is declared.
4035 func (e *Engine) generateWithHooks(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
@@ -130,7 +125,7 @@ func rewrittenQuestion(original *ai.Message, d hooks.PromptDecision) *ai.Message
130125 }
131126 parts = append(parts, ai.NewTextPart(d.Prompt))
132127 for _, c := range d.Context {
133- parts = append(parts, ai.NewTextPart("\n\n"+hookContextLabel+c))
128+ parts = append(parts, ai.NewTextPart("\n\n"+hooks.ContextLabel+c))
134129 }
135130 return ai.NewUserMessage(parts...)
136131 }
@@ -186,7 +181,7 @@ func (e *Engine) runToolHooks(ctx context.Context, params *ai.ToolParams, next a
186181 in.Tool.Input = d.Input
187182 in.Output = outputText(resp.Output)
188183 if added := e.hooks.PostToolCall(ctx, in); len(added) > 0 {
189- resp.Output = in.Output + "\n\n" + hookContextLabel + strings.Join(added, "\n\n")
184+ resp.Output = hooks.WithContext(in.Output, added)
190185 }
191186 return resp, nil
192187 }
@@ -30,11 +30,6 @@ import (
30 // because it holds a secret must not have it sent on the NEXT turn.30 // because it holds a secret must not have it sent on the NEXT turn.
31 const blockedPrompt = "[prompt blocked by a hook"31 const blockedPrompt = "[prompt blocked by a hook"
32 32
33-// hookContextLabel introduces the text a hook adds for the model, so the
34-// model — and whoever reads a session file — can tell it from what the user
35-// typed or what the tool printed.
36-const hookContextLabel = "[context added by a hook]\n"
37-
38 // generateWithHooks wraps generate with the two generation events. Nothing33 // generateWithHooks wraps generate with the two generation events. Nothing
39 // here runs when no hook is declared.34 // here runs when no hook is declared.
40 func (e *Engine) generateWithHooks(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {35 func (e *Engine) generateWithHooks(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
@@ -130,7 +125,7 @@ func rewrittenQuestion(original *ai.Message, d hooks.PromptDecision) *ai.Message
130 }125 }
131 parts = append(parts, ai.NewTextPart(d.Prompt))126 parts = append(parts, ai.NewTextPart(d.Prompt))
132 for _, c := range d.Context {127 for _, c := range d.Context {
133- parts = append(parts, ai.NewTextPart("\n\n"+hookContextLabel+c))128+ parts = append(parts, ai.NewTextPart("\n\n"+hooks.ContextLabel+c))
134 }129 }
135 return ai.NewUserMessage(parts...)130 return ai.NewUserMessage(parts...)
136 }131 }
@@ -186,7 +181,7 @@ func (e *Engine) runToolHooks(ctx context.Context, params *ai.ToolParams, next a
186 in.Tool.Input = d.Input181 in.Tool.Input = d.Input
187 in.Output = outputText(resp.Output)182 in.Output = outputText(resp.Output)
188 if added := e.hooks.PostToolCall(ctx, in); len(added) > 0 {183 if added := e.hooks.PostToolCall(ctx, in); len(added) > 0 {
189- resp.Output = in.Output + "\n\n" + hookContextLabel + strings.Join(added, "\n\n")184+ resp.Output = hooks.WithContext(in.Output, added)
190 }185 }
191 return resp, nil186 return resp, nil
192 }187 }
modified internal/engine/hooks_test.go +2 -2
@@ -102,7 +102,7 @@ func TestPreGenerationRewritesWhatTheModelReads(t *testing.T) {
102102 t.Fatal(err)
103103 }
104104 read := (*seen)[0].Messages[0].Text()
105- if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hookContextLabel+"branch: main") {
105+ if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hooks.ContextLabel+"branch: main") {
106106 t.Errorf("the model read %q", read)
107107 }
108108 if full[0].Text() != read {
@@ -207,7 +207,7 @@ func TestPostToolCallAddsContextToTheOutput(t *testing.T) {
207207 if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil {
208208 t.Fatal(err)
209209 }
210- if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hookContextLabel+"tests are flaky on CI"; got != want {
210+ if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hooks.ContextLabel+"tests are flaky on CI"; got != want {
211211 t.Errorf("the model read %q, want %q", got, want)
212212 }
213213 }
@@ -102,7 +102,7 @@ func TestPreGenerationRewritesWhatTheModelReads(t *testing.T) {
102 t.Fatal(err)102 t.Fatal(err)
103 }103 }
104 read := (*seen)[0].Messages[0].Text()104 read := (*seen)[0].Messages[0].Text()
105- if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hookContextLabel+"branch: main") {105+ if !strings.HasPrefix(read, "list the files") || !strings.Contains(read, hooks.ContextLabel+"branch: main") {
106 t.Errorf("the model read %q", read)106 t.Errorf("the model read %q", read)
107 }107 }
108 if full[0].Text() != read {108 if full[0].Text() != read {
@@ -207,7 +207,7 @@ func TestPostToolCallAddsContextToTheOutput(t *testing.T) {
207 if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil {207 if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil {
208 t.Fatal(err)208 t.Fatal(err)
209 }209 }
210- if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hookContextLabel+"tests are flaky on CI"; got != want {210+ if got, want := toolOutput(t, (*seen)[1]), "hello from go test\n\n"+hooks.ContextLabel+"tests are flaky on CI"; got != want {
211 t.Errorf("the model read %q, want %q", got, want)211 t.Errorf("the model read %q, want %q", got, want)
212 }212 }
213 }213 }
added internal/hooks/ask.go +171 -0
new file mode 100644
@@ -0,0 +1,171 @@
1+package hooks
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "mm/internal/config"
8+)
9+
10+// The ways an agent hands a question to another one: AskCall.Via.
11+const (
12+ // ViaTool is the ask_agent tool, called by the model.
13+ ViaTool = "tool"
14+ // ViaCommand is /ask, /ask-<name> or /ask-with-context-<name>, typed by
15+ // the user.
16+ ViaCommand = "command"
17+ // ViaCLI is `mm --ask <name>`, usually run by a skill's bash.
18+ ViaCLI = "cli"
19+)
20+
21+// AskCall describes the hand-off a preAsk or postAsk hook is about.
22+type AskCall struct {
23+ // Agent is the asked agent's name, a key of mmConfigs.
24+ Agent string `json:"agent"`
25+ // Config is the asked agent's configuration file.
26+ Config string `json:"config"`
27+ // AgentModel is "<provider>/<model>" read from that file, empty when it
28+ // cannot be read.
29+ AgentModel string `json:"agentModel,omitempty"`
30+ // Via is ViaTool, ViaCommand or ViaCLI.
31+ Via string `json:"via"`
32+ WithContext bool `json:"withContext"`
33+ // Depth is the asking agent's place in a chain of asks: 0 for the agent
34+ // a person started.
35+ Depth int `json:"depth"`
36+ // Prompt is the question, as the previous hook left it.
37+ Prompt string `json:"prompt"`
38+ // Transcript is the conversation sent along (withContext only), as the
39+ // previous hook left it.
40+ Transcript string `json:"transcript,omitempty"`
41+}
42+
43+// Asked tells the hooks of an agent that it was itself asked by another
44+// agent (Input.Asked), so they can behave differently than when a person
45+// uses it directly.
46+type Asked struct {
47+ Depth int `json:"depth"`
48+}
49+
50+// AskDecision is what the preAsk chain decided.
51+type AskDecision struct {
52+ // Prompt and Transcript are what to send: the asker's, or the last
53+ // rewrite.
54+ Prompt string
55+ Transcript string
56+ // Context holds the text the hooks added for the asked agent, in hook
57+ // order.
58+ Context []string
59+ // Denied means the asked agent must not be asked; Reason says why.
60+ Denied bool
61+ Reason string
62+}
63+
64+// PreAsk runs the preAsk hooks whose matcher accepts in.Ask.Agent. Each one
65+// sees the prompt and the transcript as the previous one left them; the
66+// first deny ends the chain.
67+//
68+// d := runner.PreAsk(ctx, hooks.Input{Cwd: dir, Ask: &hooks.AskCall{Agent: "go", Via: hooks.ViaTool, Prompt: q}})
69+// if d.Denied {
70+// return "[ask denied by a hook: " + d.Reason + "]"
71+// }
72+func (r *Runner) PreAsk(ctx context.Context, in Input) AskDecision {
73+ in.Event = config.HookPreAsk
74+ d := AskDecision{Prompt: in.Ask.Prompt, Transcript: in.Ask.Transcript}
75+ if r == nil {
76+ return d
77+ }
78+ call := *in.Ask
79+ in.Ask = &call
80+ for _, h := range r.cfg.For(config.HookPreAsk) {
81+ if !h.Matches(call.Agent) {
82+ continue
83+ }
84+ call.Prompt, call.Transcript = d.Prompt, d.Transcript
85+ reply, err := r.call(ctx, h, in)
86+ if err != nil {
87+ if r.failed(h, in.Event, err) {
88+ d.Denied, d.Reason = true, "hook failed: "+err.Error()
89+ return d
90+ }
91+ continue
92+ }
93+ if reply.Action == ActionDeny {
94+ d.Denied, d.Reason = true, reply.Reason
95+ return d
96+ }
97+ d.apply(reply)
98+ }
99+ return d
100+}
101+
102+// apply takes a continuing preAsk reply's rewrites and context.
103+func (d *AskDecision) apply(reply Reply) {
104+ if reply.Prompt != nil {
105+ d.Prompt = *reply.Prompt
106+ }
107+ if reply.Transcript != nil {
108+ d.Transcript = *reply.Transcript
109+ }
110+ if c := strings.TrimSpace(reply.Context); c != "" {
111+ d.Context = append(d.Context, c)
112+ }
113+}
114+
115+// AnswerDecision is what the postAsk chain decided.
116+type AnswerDecision struct {
117+ // Answer is what the asker reads: the asked agent's, or the last
118+ // replacement.
119+ Answer string
120+ // Replaced reports that Answer differs from the asked agent's.
121+ Replaced bool
122+ // Context holds the text the hooks added after the answer, in hook
123+ // order.
124+ Context []string
125+}
126+
127+// PostAsk runs the postAsk hooks whose matcher accepts in.Ask.Agent, on
128+// in.Answer and in.Error. Each one sees the answer as the previous one left
129+// it.
130+//
131+// d := runner.PostAsk(context.WithoutCancel(ctx), hooks.Input{Ask: call, Answer: answer})
132+// answer = d.Answer
133+func (r *Runner) PostAsk(ctx context.Context, in Input) AnswerDecision {
134+ in.Event = config.HookPostAsk
135+ original := in.Answer
136+ d := AnswerDecision{Answer: original}
137+ if r == nil {
138+ return d
139+ }
140+ for _, h := range r.cfg.For(config.HookPostAsk) {
141+ if !h.Matches(in.Ask.Agent) {
142+ continue
143+ }
144+ in.Answer = d.Answer
145+ reply, err := r.call(ctx, h, in)
146+ if err != nil {
147+ r.failed(h, in.Event, err)
148+ continue
149+ }
150+ if reply.Answer != nil {
151+ d.Answer = *reply.Answer
152+ }
153+ if c := strings.TrimSpace(reply.Context); c != "" {
154+ d.Context = append(d.Context, c)
155+ }
156+ }
157+ d.Replaced = d.Answer != original
158+ return d
159+}
160+
161+// WithContext appends the context the hooks added to text, each part
162+// introduced by ContextLabel; text alone when there is none.
163+//
164+// hooks.WithContext("the answer", []string{"checked: no code block"})
165+// // "the answer\n\n[context added by a hook]\nchecked: no code block"
166+func WithContext(text string, added []string) string {
167+ if len(added) == 0 {
168+ return text
169+ }
170+ return text + "\n\n" + ContextLabel + strings.Join(added, "\n\n")
171+}
new file mode 100644
@@ -0,0 +1,171 @@
1+package hooks
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "mm/internal/config"
8+)
9+
10+// The ways an agent hands a question to another one: AskCall.Via.
11+const (
12+ // ViaTool is the ask_agent tool, called by the model.
13+ ViaTool = "tool"
14+ // ViaCommand is /ask, /ask-<name> or /ask-with-context-<name>, typed by
15+ // the user.
16+ ViaCommand = "command"
17+ // ViaCLI is `mm --ask <name>`, usually run by a skill's bash.
18+ ViaCLI = "cli"
19+)
20+
21+// AskCall describes the hand-off a preAsk or postAsk hook is about.
22+type AskCall struct {
23+ // Agent is the asked agent's name, a key of mmConfigs.
24+ Agent string `json:"agent"`
25+ // Config is the asked agent's configuration file.
26+ Config string `json:"config"`
27+ // AgentModel is "<provider>/<model>" read from that file, empty when it
28+ // cannot be read.
29+ AgentModel string `json:"agentModel,omitempty"`
30+ // Via is ViaTool, ViaCommand or ViaCLI.
31+ Via string `json:"via"`
32+ WithContext bool `json:"withContext"`
33+ // Depth is the asking agent's place in a chain of asks: 0 for the agent
34+ // a person started.
35+ Depth int `json:"depth"`
36+ // Prompt is the question, as the previous hook left it.
37+ Prompt string `json:"prompt"`
38+ // Transcript is the conversation sent along (withContext only), as the
39+ // previous hook left it.
40+ Transcript string `json:"transcript,omitempty"`
41+}
42+
43+// Asked tells the hooks of an agent that it was itself asked by another
44+// agent (Input.Asked), so they can behave differently than when a person
45+// uses it directly.
46+type Asked struct {
47+ Depth int `json:"depth"`
48+}
49+
50+// AskDecision is what the preAsk chain decided.
51+type AskDecision struct {
52+ // Prompt and Transcript are what to send: the asker's, or the last
53+ // rewrite.
54+ Prompt string
55+ Transcript string
56+ // Context holds the text the hooks added for the asked agent, in hook
57+ // order.
58+ Context []string
59+ // Denied means the asked agent must not be asked; Reason says why.
60+ Denied bool
61+ Reason string
62+}
63+
64+// PreAsk runs the preAsk hooks whose matcher accepts in.Ask.Agent. Each one
65+// sees the prompt and the transcript as the previous one left them; the
66+// first deny ends the chain.
67+//
68+// d := runner.PreAsk(ctx, hooks.Input{Cwd: dir, Ask: &hooks.AskCall{Agent: "go", Via: hooks.ViaTool, Prompt: q}})
69+// if d.Denied {
70+// return "[ask denied by a hook: " + d.Reason + "]"
71+// }
72+func (r *Runner) PreAsk(ctx context.Context, in Input) AskDecision {
73+ in.Event = config.HookPreAsk
74+ d := AskDecision{Prompt: in.Ask.Prompt, Transcript: in.Ask.Transcript}
75+ if r == nil {
76+ return d
77+ }
78+ call := *in.Ask
79+ in.Ask = &call
80+ for _, h := range r.cfg.For(config.HookPreAsk) {
81+ if !h.Matches(call.Agent) {
82+ continue
83+ }
84+ call.Prompt, call.Transcript = d.Prompt, d.Transcript
85+ reply, err := r.call(ctx, h, in)
86+ if err != nil {
87+ if r.failed(h, in.Event, err) {
88+ d.Denied, d.Reason = true, "hook failed: "+err.Error()
89+ return d
90+ }
91+ continue
92+ }
93+ if reply.Action == ActionDeny {
94+ d.Denied, d.Reason = true, reply.Reason
95+ return d
96+ }
97+ d.apply(reply)
98+ }
99+ return d
100+}
101+
102+// apply takes a continuing preAsk reply's rewrites and context.
103+func (d *AskDecision) apply(reply Reply) {
104+ if reply.Prompt != nil {
105+ d.Prompt = *reply.Prompt
106+ }
107+ if reply.Transcript != nil {
108+ d.Transcript = *reply.Transcript
109+ }
110+ if c := strings.TrimSpace(reply.Context); c != "" {
111+ d.Context = append(d.Context, c)
112+ }
113+}
114+
115+// AnswerDecision is what the postAsk chain decided.
116+type AnswerDecision struct {
117+ // Answer is what the asker reads: the asked agent's, or the last
118+ // replacement.
119+ Answer string
120+ // Replaced reports that Answer differs from the asked agent's.
121+ Replaced bool
122+ // Context holds the text the hooks added after the answer, in hook
123+ // order.
124+ Context []string
125+}
126+
127+// PostAsk runs the postAsk hooks whose matcher accepts in.Ask.Agent, on
128+// in.Answer and in.Error. Each one sees the answer as the previous one left
129+// it.
130+//
131+// d := runner.PostAsk(context.WithoutCancel(ctx), hooks.Input{Ask: call, Answer: answer})
132+// answer = d.Answer
133+func (r *Runner) PostAsk(ctx context.Context, in Input) AnswerDecision {
134+ in.Event = config.HookPostAsk
135+ original := in.Answer
136+ d := AnswerDecision{Answer: original}
137+ if r == nil {
138+ return d
139+ }
140+ for _, h := range r.cfg.For(config.HookPostAsk) {
141+ if !h.Matches(in.Ask.Agent) {
142+ continue
143+ }
144+ in.Answer = d.Answer
145+ reply, err := r.call(ctx, h, in)
146+ if err != nil {
147+ r.failed(h, in.Event, err)
148+ continue
149+ }
150+ if reply.Answer != nil {
151+ d.Answer = *reply.Answer
152+ }
153+ if c := strings.TrimSpace(reply.Context); c != "" {
154+ d.Context = append(d.Context, c)
155+ }
156+ }
157+ d.Replaced = d.Answer != original
158+ return d
159+}
160+
161+// WithContext appends the context the hooks added to text, each part
162+// introduced by ContextLabel; text alone when there is none.
163+//
164+// hooks.WithContext("the answer", []string{"checked: no code block"})
165+// // "the answer\n\n[context added by a hook]\nchecked: no code block"
166+func WithContext(text string, added []string) string {
167+ if len(added) == 0 {
168+ return text
169+ }
170+ return text + "\n\n" + ContextLabel + strings.Join(added, "\n\n")
171+}
added internal/hooks/ask_test.go +165 -0
new file mode 100644
@@ -0,0 +1,165 @@
1+package hooks
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "mm/internal/config"
12+)
13+
14+// askInput is a hand-off to the agent "go", as the ask package builds it.
15+func askInput(prompt, transcript string) Input {
16+ return Input{Cwd: os.TempDir(), Ask: &AskCall{Agent: "go", Config: "/cfg/agent.go.yaml", Via: ViaTool, WithContext: transcript != "", Prompt: prompt, Transcript: transcript}}
17+}
18+
19+func TestNoAskHookChangesNothing(t *testing.T) {
20+ r, _ := runner(config.HooksConfig{})
21+ d := r.PreAsk(context.Background(), askInput("q", "t"))
22+ if d.Denied || d.Prompt != "q" || d.Transcript != "t" || len(d.Context) > 0 {
23+ t.Errorf("PreAsk = %+v, want untouched", d)
24+ }
25+ a := r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go"}, Answer: "a"})
26+ if a.Replaced || a.Answer != "a" || len(a.Context) > 0 {
27+ t.Errorf("PostAsk = %+v, want untouched", a)
28+ }
29+}
30+
31+// A preAsk hook reads the hand-off, and the chain passes its rewrites on.
32+func TestPreAskRewritesInOrder(t *testing.T) {
33+ dir := t.TempDir()
34+ seen := filepath.Join(dir, "second.json")
35+ r, notes := runner(config.HooksConfig{PreAsk: []config.Hook{
36+ hook(`echo '{"prompt":"rewritten","transcript":"","context":"answer in French"}'`),
37+ hook(`cat > "` + seen + `"; echo '{"context":"one code block"}'`),
38+ }})
39+ d := r.PreAsk(context.Background(), askInput("original", "user: my key is sk-123"))
40+ if d.Denied || d.Prompt != "rewritten" || d.Transcript != "" {
41+ t.Errorf("PreAsk = %+v, want prompt rewritten and transcript emptied", d)
42+ }
43+ if got := strings.Join(d.Context, " | "); got != "answer in French | one code block" {
44+ t.Errorf("Context = %q", got)
45+ }
46+ var in Input
47+ data, _ := os.ReadFile(seen)
48+ if err := json.Unmarshal(data, &in); err != nil {
49+ t.Fatalf("second hook's input %q: %v", data, err)
50+ }
51+ if in.Event != config.HookPreAsk || in.Ask.Agent != "go" || in.Ask.Prompt != "rewritten" || in.Ask.Transcript != "" || in.Ask.Via != ViaTool {
52+ t.Errorf("second hook read %+v / %+v", in, in.Ask)
53+ }
54+ if len(*notes) > 0 {
55+ t.Errorf("notes = %v", *notes)
56+ }
57+}
58+
59+// deny stops the chain; a matcher on the agent's name picks the hooks.
60+func TestPreAskDenyAndMatcher(t *testing.T) {
61+ other := hook(`echo '{"action":"deny","reason":"not for rust"}'`)
62+ other.Matcher = "^rust$"
63+ mine := hook(`echo '{"action":"deny","reason":"budget spent"}'`)
64+ mine.Matcher = "^go$"
65+ never := hook(`echo '{"prompt":"never"}'`)
66+ r, _ := runner(config.HooksConfig{PreAsk: []config.Hook{other, mine, never}})
67+ d := r.PreAsk(context.Background(), askInput("q", ""))
68+ if !d.Denied || d.Reason != "budget spent" || d.Prompt != "q" {
69+ t.Errorf("PreAsk = %+v, want denied by the go hook only", d)
70+ }
71+}
72+
73+// A failing preAsk hook with onError fail denies the hand-off; with warn it
74+// is reported and skipped.
75+func TestFailingPreAskFollowsOnError(t *testing.T) {
76+ failing := hook("exit 3")
77+ failing.OnError = config.HookOnErrorFail
78+ r, _ := runner(config.HooksConfig{PreAsk: []config.Hook{failing}})
79+ if d := r.PreAsk(context.Background(), askInput("q", "")); !d.Denied || !strings.HasPrefix(d.Reason, "hook failed: exit status 3") {
80+ t.Errorf("fail: %+v", d)
81+ }
82+ r, notes := runner(config.HooksConfig{PreAsk: []config.Hook{hook("exit 3")}})
83+ if d := r.PreAsk(context.Background(), askInput("q", "")); d.Denied {
84+ t.Errorf("warn: %+v, want allowed", d)
85+ }
86+ if len(*notes) != 1 || !strings.Contains((*notes)[0], "preAsk hook") {
87+ t.Errorf("notes = %v", *notes)
88+ }
89+}
90+
91+// stop belongs to preGeneration, deny not to postAsk.
92+func TestAskActionsAreChecked(t *testing.T) {
93+ r, notes := runner(config.HooksConfig{
94+ PreAsk: []config.Hook{hook(`echo '{"action":"stop"}'`)},
95+ PostAsk: []config.Hook{hook(`echo '{"action":"deny"}'`)},
96+ })
97+ if d := r.PreAsk(context.Background(), askInput("q", "")); d.Denied {
98+ t.Errorf("PreAsk = %+v, want an invalid action reported, not a deny", d)
99+ }
100+ r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go"}, Answer: "a"})
101+ want := []string{`action "stop" is not valid for preAsk`, `action "deny" is not valid for postAsk`}
102+ for i, w := range want {
103+ if i >= len(*notes) || !strings.Contains((*notes)[i], w) {
104+ t.Errorf("notes = %v, want %q", *notes, w)
105+ }
106+ }
107+}
108+
109+// postAsk sees the answer, the error and the duration, and may replace the
110+// answer and add context; the next hook sees the replacement.
111+func TestPostAskReplacesTheAnswer(t *testing.T) {
112+ dir := t.TempDir()
113+ first := filepath.Join(dir, "first.json")
114+ second := filepath.Join(dir, "second.json")
115+ r, _ := runner(config.HooksConfig{PostAsk: []config.Hook{
116+ hook(`cat > "` + first + `"; echo '{"answer":"go is unavailable: answer yourself"}'`),
117+ hook(`cat > "` + second + `"; echo '{"context":"checked"}'`),
118+ }})
119+ d := r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go", Prompt: "q"}, Error: "go failed: 500", DurationMs: 1234})
120+ if !d.Replaced || d.Answer != "go is unavailable: answer yourself" || strings.Join(d.Context, "") != "checked" {
121+ t.Errorf("PostAsk = %+v", d)
122+ }
123+ var in Input
124+ data, _ := os.ReadFile(first)
125+ if err := json.Unmarshal(data, &in); err != nil {
126+ t.Fatal(err)
127+ }
128+ if in.Event != config.HookPostAsk || in.Error != "go failed: 500" || in.DurationMs != 1234 || in.Ask.Prompt != "q" {
129+ t.Errorf("first hook read %+v", in)
130+ }
131+ data, _ = os.ReadFile(second)
132+ if !strings.Contains(string(data), `"answer":"go is unavailable: answer yourself"`) {
133+ t.Errorf("second hook read %s, want the replaced answer", data)
134+ }
135+}
136+
137+// A hook that gives the answer back unchanged does not count as a
138+// replacement.
139+func TestPostAskSameAnswerIsNotAReplacement(t *testing.T) {
140+ r, _ := runner(config.HooksConfig{PostAsk: []config.Hook{hook(`echo '{"answer":"same"}'`)}})
141+ if d := r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go"}, Answer: "same"}); d.Replaced {
142+ t.Errorf("PostAsk = %+v, want not replaced", d)
143+ }
144+}
145+
146+// An agent that was itself asked tells its hooks so, on every event.
147+func TestAskedDepthReachesEveryHook(t *testing.T) {
148+ t.Setenv(config.AskDepthEnv, "1")
149+ out := filepath.Join(t.TempDir(), "in.json")
150+ r, _ := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`cat > "` + out + `"`)}})
151+ r.PreGeneration(context.Background(), Input{Prompt: "hi"})
152+ data, _ := os.ReadFile(out)
153+ if !strings.Contains(string(data), `"asked":{"depth":1}`) {
154+ t.Errorf("input = %s, want asked.depth 1", data)
155+ }
156+}
157+
158+func TestWithContext(t *testing.T) {
159+ if got := WithContext("a", nil); got != "a" {
160+ t.Errorf("no context: %q", got)
161+ }
162+ if got, want := WithContext("a", []string{"x", "y"}), "a\n\n"+ContextLabel+"x\n\ny"; got != want {
163+ t.Errorf("WithContext = %q, want %q", got, want)
164+ }
165+}
new file mode 100644
@@ -0,0 +1,165 @@
1+package hooks
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ "mm/internal/config"
12+)
13+
14+// askInput is a hand-off to the agent "go", as the ask package builds it.
15+func askInput(prompt, transcript string) Input {
16+ return Input{Cwd: os.TempDir(), Ask: &AskCall{Agent: "go", Config: "/cfg/agent.go.yaml", Via: ViaTool, WithContext: transcript != "", Prompt: prompt, Transcript: transcript}}
17+}
18+
19+func TestNoAskHookChangesNothing(t *testing.T) {
20+ r, _ := runner(config.HooksConfig{})
21+ d := r.PreAsk(context.Background(), askInput("q", "t"))
22+ if d.Denied || d.Prompt != "q" || d.Transcript != "t" || len(d.Context) > 0 {
23+ t.Errorf("PreAsk = %+v, want untouched", d)
24+ }
25+ a := r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go"}, Answer: "a"})
26+ if a.Replaced || a.Answer != "a" || len(a.Context) > 0 {
27+ t.Errorf("PostAsk = %+v, want untouched", a)
28+ }
29+}
30+
31+// A preAsk hook reads the hand-off, and the chain passes its rewrites on.
32+func TestPreAskRewritesInOrder(t *testing.T) {
33+ dir := t.TempDir()
34+ seen := filepath.Join(dir, "second.json")
35+ r, notes := runner(config.HooksConfig{PreAsk: []config.Hook{
36+ hook(`echo '{"prompt":"rewritten","transcript":"","context":"answer in French"}'`),
37+ hook(`cat > "` + seen + `"; echo '{"context":"one code block"}'`),
38+ }})
39+ d := r.PreAsk(context.Background(), askInput("original", "user: my key is sk-123"))
40+ if d.Denied || d.Prompt != "rewritten" || d.Transcript != "" {
41+ t.Errorf("PreAsk = %+v, want prompt rewritten and transcript emptied", d)
42+ }
43+ if got := strings.Join(d.Context, " | "); got != "answer in French | one code block" {
44+ t.Errorf("Context = %q", got)
45+ }
46+ var in Input
47+ data, _ := os.ReadFile(seen)
48+ if err := json.Unmarshal(data, &in); err != nil {
49+ t.Fatalf("second hook's input %q: %v", data, err)
50+ }
51+ if in.Event != config.HookPreAsk || in.Ask.Agent != "go" || in.Ask.Prompt != "rewritten" || in.Ask.Transcript != "" || in.Ask.Via != ViaTool {
52+ t.Errorf("second hook read %+v / %+v", in, in.Ask)
53+ }
54+ if len(*notes) > 0 {
55+ t.Errorf("notes = %v", *notes)
56+ }
57+}
58+
59+// deny stops the chain; a matcher on the agent's name picks the hooks.
60+func TestPreAskDenyAndMatcher(t *testing.T) {
61+ other := hook(`echo '{"action":"deny","reason":"not for rust"}'`)
62+ other.Matcher = "^rust$"
63+ mine := hook(`echo '{"action":"deny","reason":"budget spent"}'`)
64+ mine.Matcher = "^go$"
65+ never := hook(`echo '{"prompt":"never"}'`)
66+ r, _ := runner(config.HooksConfig{PreAsk: []config.Hook{other, mine, never}})
67+ d := r.PreAsk(context.Background(), askInput("q", ""))
68+ if !d.Denied || d.Reason != "budget spent" || d.Prompt != "q" {
69+ t.Errorf("PreAsk = %+v, want denied by the go hook only", d)
70+ }
71+}
72+
73+// A failing preAsk hook with onError fail denies the hand-off; with warn it
74+// is reported and skipped.
75+func TestFailingPreAskFollowsOnError(t *testing.T) {
76+ failing := hook("exit 3")
77+ failing.OnError = config.HookOnErrorFail
78+ r, _ := runner(config.HooksConfig{PreAsk: []config.Hook{failing}})
79+ if d := r.PreAsk(context.Background(), askInput("q", "")); !d.Denied || !strings.HasPrefix(d.Reason, "hook failed: exit status 3") {
80+ t.Errorf("fail: %+v", d)
81+ }
82+ r, notes := runner(config.HooksConfig{PreAsk: []config.Hook{hook("exit 3")}})
83+ if d := r.PreAsk(context.Background(), askInput("q", "")); d.Denied {
84+ t.Errorf("warn: %+v, want allowed", d)
85+ }
86+ if len(*notes) != 1 || !strings.Contains((*notes)[0], "preAsk hook") {
87+ t.Errorf("notes = %v", *notes)
88+ }
89+}
90+
91+// stop belongs to preGeneration, deny not to postAsk.
92+func TestAskActionsAreChecked(t *testing.T) {
93+ r, notes := runner(config.HooksConfig{
94+ PreAsk: []config.Hook{hook(`echo '{"action":"stop"}'`)},
95+ PostAsk: []config.Hook{hook(`echo '{"action":"deny"}'`)},
96+ })
97+ if d := r.PreAsk(context.Background(), askInput("q", "")); d.Denied {
98+ t.Errorf("PreAsk = %+v, want an invalid action reported, not a deny", d)
99+ }
100+ r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go"}, Answer: "a"})
101+ want := []string{`action "stop" is not valid for preAsk`, `action "deny" is not valid for postAsk`}
102+ for i, w := range want {
103+ if i >= len(*notes) || !strings.Contains((*notes)[i], w) {
104+ t.Errorf("notes = %v, want %q", *notes, w)
105+ }
106+ }
107+}
108+
109+// postAsk sees the answer, the error and the duration, and may replace the
110+// answer and add context; the next hook sees the replacement.
111+func TestPostAskReplacesTheAnswer(t *testing.T) {
112+ dir := t.TempDir()
113+ first := filepath.Join(dir, "first.json")
114+ second := filepath.Join(dir, "second.json")
115+ r, _ := runner(config.HooksConfig{PostAsk: []config.Hook{
116+ hook(`cat > "` + first + `"; echo '{"answer":"go is unavailable: answer yourself"}'`),
117+ hook(`cat > "` + second + `"; echo '{"context":"checked"}'`),
118+ }})
119+ d := r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go", Prompt: "q"}, Error: "go failed: 500", DurationMs: 1234})
120+ if !d.Replaced || d.Answer != "go is unavailable: answer yourself" || strings.Join(d.Context, "") != "checked" {
121+ t.Errorf("PostAsk = %+v", d)
122+ }
123+ var in Input
124+ data, _ := os.ReadFile(first)
125+ if err := json.Unmarshal(data, &in); err != nil {
126+ t.Fatal(err)
127+ }
128+ if in.Event != config.HookPostAsk || in.Error != "go failed: 500" || in.DurationMs != 1234 || in.Ask.Prompt != "q" {
129+ t.Errorf("first hook read %+v", in)
130+ }
131+ data, _ = os.ReadFile(second)
132+ if !strings.Contains(string(data), `"answer":"go is unavailable: answer yourself"`) {
133+ t.Errorf("second hook read %s, want the replaced answer", data)
134+ }
135+}
136+
137+// A hook that gives the answer back unchanged does not count as a
138+// replacement.
139+func TestPostAskSameAnswerIsNotAReplacement(t *testing.T) {
140+ r, _ := runner(config.HooksConfig{PostAsk: []config.Hook{hook(`echo '{"answer":"same"}'`)}})
141+ if d := r.PostAsk(context.Background(), Input{Ask: &AskCall{Agent: "go"}, Answer: "same"}); d.Replaced {
142+ t.Errorf("PostAsk = %+v, want not replaced", d)
143+ }
144+}
145+
146+// An agent that was itself asked tells its hooks so, on every event.
147+func TestAskedDepthReachesEveryHook(t *testing.T) {
148+ t.Setenv(config.AskDepthEnv, "1")
149+ out := filepath.Join(t.TempDir(), "in.json")
150+ r, _ := runner(config.HooksConfig{PreGeneration: []config.Hook{hook(`cat > "` + out + `"`)}})
151+ r.PreGeneration(context.Background(), Input{Prompt: "hi"})
152+ data, _ := os.ReadFile(out)
153+ if !strings.Contains(string(data), `"asked":{"depth":1}`) {
154+ t.Errorf("input = %s, want asked.depth 1", data)
155+ }
156+}
157+
158+func TestWithContext(t *testing.T) {
159+ if got := WithContext("a", nil); got != "a" {
160+ t.Errorf("no context: %q", got)
161+ }
162+ if got, want := WithContext("a", []string{"x", "y"}), "a\n\n"+ContextLabel+"x\n\ny"; got != want {
163+ t.Errorf("WithContext = %q, want %q", got, want)
164+ }
165+}
modified internal/hooks/example_test.go +32 -0
@@ -62,6 +62,38 @@ func ExampleRunner_PostToolCall() {
6262 // Output: [the test suite takes 2 minutes]
6363 }
6464
65+// A preAsk hook, for the agent "go" only, that adds a standing instruction to
66+// every question handed to it.
67+func ExampleRunner_PreAsk() {
68+ cfg := config.HooksConfig{PreAsk: []config.Hook{{
69+ Matcher: "^go$",
70+ Command: `echo '{"context":"Answer in French, with one code block."}'`,
71+ Timeout: 5 * time.Second,
72+ OnError: config.HookOnErrorWarn,
73+ }}}
74+ runner := hooks.New(cfg, nil)
75+
76+ call := &hooks.AskCall{Agent: "go", Via: hooks.ViaTool, Prompt: "How do I read a file?"}
77+ d := runner.PreAsk(context.Background(), hooks.Input{Ask: call})
78+ fmt.Println(d.Denied, d.Prompt, d.Context)
79+ // Output: false How do I read a file? [Answer in French, with one code block.]
80+}
81+
82+// A postAsk hook that turns a failed hand-off into an instruction for the
83+// asking agent's model.
84+func ExampleRunner_PostAsk() {
85+ cfg := config.HooksConfig{PostAsk: []config.Hook{{
86+ Command: `grep -q '"error"' && echo '{"answer":"go is unavailable: answer yourself and say so."}'; true`,
87+ Timeout: 5 * time.Second,
88+ OnError: config.HookOnErrorWarn,
89+ }}}
90+ runner := hooks.New(cfg, nil)
91+
92+ d := runner.PostAsk(context.Background(), hooks.Input{Ask: &hooks.AskCall{Agent: "go"}, Error: "go failed: 500"})
93+ fmt.Println(d.Replaced, d.Answer)
94+ // Output: true go is unavailable: answer yourself and say so.
95+}
96+
6597 // A failing hook with onError warn is reported through the notify function
6698 // and the turn goes on.
6799 func ExampleNew() {
@@ -62,6 +62,38 @@ func ExampleRunner_PostToolCall() {
62 // Output: [the test suite takes 2 minutes]62 // Output: [the test suite takes 2 minutes]
63 }63 }
64 64
65+// A preAsk hook, for the agent "go" only, that adds a standing instruction to
66+// every question handed to it.
67+func ExampleRunner_PreAsk() {
68+ cfg := config.HooksConfig{PreAsk: []config.Hook{{
69+ Matcher: "^go$",
70+ Command: `echo '{"context":"Answer in French, with one code block."}'`,
71+ Timeout: 5 * time.Second,
72+ OnError: config.HookOnErrorWarn,
73+ }}}
74+ runner := hooks.New(cfg, nil)
75+
76+ call := &hooks.AskCall{Agent: "go", Via: hooks.ViaTool, Prompt: "How do I read a file?"}
77+ d := runner.PreAsk(context.Background(), hooks.Input{Ask: call})
78+ fmt.Println(d.Denied, d.Prompt, d.Context)
79+ // Output: false How do I read a file? [Answer in French, with one code block.]
80+}
81+
82+// A postAsk hook that turns a failed hand-off into an instruction for the
83+// asking agent's model.
84+func ExampleRunner_PostAsk() {
85+ cfg := config.HooksConfig{PostAsk: []config.Hook{{
86+ Command: `grep -q '"error"' && echo '{"answer":"go is unavailable: answer yourself and say so."}'; true`,
87+ Timeout: 5 * time.Second,
88+ OnError: config.HookOnErrorWarn,
89+ }}}
90+ runner := hooks.New(cfg, nil)
91+
92+ d := runner.PostAsk(context.Background(), hooks.Input{Ask: &hooks.AskCall{Agent: "go"}, Error: "go failed: 500"})
93+ fmt.Println(d.Replaced, d.Answer)
94+ // Output: true go is unavailable: answer yourself and say so.
95+}
96+
65 // A failing hook with onError warn is reported through the notify function97 // A failing hook with onError warn is reported through the notify function
66 // and the turn goes on.98 // and the turn goes on.
67 func ExampleNew() {99 func ExampleNew() {
modified internal/hooks/exec.go +4 -1
@@ -46,6 +46,9 @@ type execFunc func(ctx context.Context, h config.Hook, env []string, dir string,
4646 // "continue". Every failure — the hook's exit, its timeout, an unreadable
4747 // reply, an unknown action, an explicit "error" — comes back as an error.
4848 func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) {
49+ if depth := config.AskDepth(); depth > 0 {
50+ in.Asked = &Asked{Depth: depth}
51+ }
4952 stdin, err := encode(in)
5053 if err != nil {
5154 return Reply{}, fmt.Errorf("cannot encode the input: %w", err)
@@ -109,7 +112,7 @@ func checkAction(reply Reply, event string) error {
109112 return nil
110113 }
111114 case ActionDeny:
112- if event == config.HookPreToolCall {
115+ if event == config.HookPreToolCall || event == config.HookPreAsk {
113116 return nil
114117 }
115118 }
@@ -46,6 +46,9 @@ type execFunc func(ctx context.Context, h config.Hook, env []string, dir string,
46 // "continue". Every failure — the hook's exit, its timeout, an unreadable46 // "continue". Every failure — the hook's exit, its timeout, an unreadable
47 // reply, an unknown action, an explicit "error" — comes back as an error.47 // reply, an unknown action, an explicit "error" — comes back as an error.
48 func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) {48 func (r *Runner) call(ctx context.Context, h config.Hook, in Input) (Reply, error) {
49+ if depth := config.AskDepth(); depth > 0 {
50+ in.Asked = &Asked{Depth: depth}
51+ }
49 stdin, err := encode(in)52 stdin, err := encode(in)
50 if err != nil {53 if err != nil {
51 return Reply{}, fmt.Errorf("cannot encode the input: %w", err)54 return Reply{}, fmt.Errorf("cannot encode the input: %w", err)
@@ -109,7 +112,7 @@ func checkAction(reply Reply, event string) error {
109 return nil112 return nil
110 }113 }
111 case ActionDeny:114 case ActionDeny:
112- if event == config.HookPreToolCall {115+ if event == config.HookPreToolCall || event == config.HookPreAsk {
113 return nil116 return nil
114 }117 }
115 }118 }
modified internal/hooks/hooks.go +41 -19
@@ -1,5 +1,6 @@
1-// Package hooks runs the external commands of the `hooks:` block (ticket 36)
2-// at four moments of a turn, and turns what they answer into decisions the
1+// Package hooks runs the external commands of the `hooks:` block (tickets 36
2+// and 87) at six moments — around a turn, a tool call, and a hand-off to
3+// another agent — and turns what they answer into decisions the
34 // engine applies.
45 //
56 // A hook is any program: it reads ONE JSON object on stdin (Input) and may
@@ -30,14 +31,20 @@ const (
3031 ActionContinue = "continue"
3132 // ActionStop ends the turn before the model is called (preGeneration).
3233 ActionStop = "stop"
33- // ActionDeny refuses one tool call (preToolCall); the model reads the
34- // reason as the tool's output.
34+ // ActionDeny refuses one tool call (preToolCall) or one hand-off to
35+ // another agent (preAsk); the model reads the reason as the tool's
36+ // output.
3537 ActionDeny = "deny"
3638 // ActionError is the hook saying it failed: handled like a crash,
3739 // according to its onError.
3840 ActionError = "error"
3941 )
4042
43+// ContextLabel introduces the text a hook adds for a model, so the model —
44+// and whoever reads a session file — can tell it from what the user typed,
45+// what a tool printed or what another agent answered.
46+const ContextLabel = "[context added by a hook]\n"
47+
4148 // Input is the JSON object a hook reads on stdin. Which fields are filled
4249 // depends on the event:
4350 //
@@ -45,15 +52,22 @@ const (
4552 // preToolCall event, cwd, model, tool
4653 // postToolCall event, cwd, model, tool, output
4754 // postGeneration event, cwd, model, prompt, answer, error
55+// preAsk event, cwd, model, ask
56+// postAsk event, cwd, model, ask, answer, error, durationMs
57+//
58+// Asked is set on every event when this agent was itself asked by another.
4859 type Input struct {
49- Event string `json:"event"`
50- Cwd string `json:"cwd"`
51- Model string `json:"model,omitempty"`
52- Prompt string `json:"prompt,omitempty"`
53- Tool *ToolCall `json:"tool,omitempty"`
54- Output string `json:"output,omitempty"`
55- Answer string `json:"answer,omitempty"`
56- Error string `json:"error,omitempty"`
60+ Event string `json:"event"`
61+ Cwd string `json:"cwd"`
62+ Model string `json:"model,omitempty"`
63+ Prompt string `json:"prompt,omitempty"`
64+ Tool *ToolCall `json:"tool,omitempty"`
65+ Ask *AskCall `json:"ask,omitempty"`
66+ Output string `json:"output,omitempty"`
67+ Answer string `json:"answer,omitempty"`
68+ Error string `json:"error,omitempty"`
69+ DurationMs int64 `json:"durationMs,omitempty"`
70+ Asked *Asked `json:"asked,omitempty"`
5771 }
5872
5973 // ToolCall describes the tool call a preToolCall or postToolCall hook is
@@ -68,10 +82,18 @@ type ToolCall struct {
6882 // optional; which ones are read depends on the event (see the decisions).
6983 type Reply struct {
7084 Action string `json:"action,omitempty"`
71- // Prompt replaces the user's question (preGeneration). Absent = kept.
85+ // Prompt replaces the user's question (preGeneration) or the question
86+ // sent to another agent (preAsk). Absent = kept.
7287 Prompt *string `json:"prompt,omitempty"`
73- // Context is text added for the model: after the question
74- // (preGeneration), after the tool's output (postToolCall).
88+ // Transcript replaces the conversation sent to another agent (preAsk).
89+ // Absent = kept; "" = send none.
90+ Transcript *string `json:"transcript,omitempty"`
91+ // Answer replaces the answer the asking agent reads (postAsk). Absent =
92+ // kept.
93+ Answer *string `json:"answer,omitempty"`
94+ // Context is text added for a model: after the question
95+ // (preGeneration), after the tool's output (postToolCall), after the
96+ // question sent to another agent (preAsk), after its answer (postAsk).
7597 Context string `json:"context,omitempty"`
7698 // Input replaces the tool call's arguments (preToolCall). Absent = kept.
7799 Input json.RawMessage `json:"input,omitempty"`
@@ -255,14 +277,14 @@ func (r *Runner) PostGeneration(ctx context.Context, in Input) {
255277 }
256278
257279 // failed reports a hook's failure according to its onError, and says whether
258-// the turn (or the call) must stop: only for fail, and only on the pre*
259-// events — after the fact there is nothing left to stop, so fail is
260-// reported like warn there.
280+// the turn (or the call, or the hand-off) must stop: only for fail, and only
281+// on the pre* events — after the fact there is nothing left to stop, so fail
282+// is reported like warn there.
261283 func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) {
262284 if h.OnError == config.HookOnErrorIgnore {
263285 return false
264286 }
265287 r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error())
266- pre := event == config.HookPreGeneration || event == config.HookPreToolCall
288+ pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk
267289 return pre && h.OnError == config.HookOnErrorFail
268290 }
@@ -1,5 +1,6 @@
1-// Package hooks runs the external commands of the `hooks:` block (ticket 36)1+// Package hooks runs the external commands of the `hooks:` block (tickets 36
2-// at four moments of a turn, and turns what they answer into decisions the2+// and 87) at six moments — around a turn, a tool call, and a hand-off to
3+// another agent — and turns what they answer into decisions the
3 // engine applies.4 // engine applies.
4 //5 //
5 // A hook is any program: it reads ONE JSON object on stdin (Input) and may6 // A hook is any program: it reads ONE JSON object on stdin (Input) and may
@@ -30,14 +31,20 @@ const (
30 ActionContinue = "continue"31 ActionContinue = "continue"
31 // ActionStop ends the turn before the model is called (preGeneration).32 // ActionStop ends the turn before the model is called (preGeneration).
32 ActionStop = "stop"33 ActionStop = "stop"
33- // ActionDeny refuses one tool call (preToolCall); the model reads the34+ // ActionDeny refuses one tool call (preToolCall) or one hand-off to
34- // reason as the tool's output.35+ // another agent (preAsk); the model reads the reason as the tool's
36+ // output.
35 ActionDeny = "deny"37 ActionDeny = "deny"
36 // ActionError is the hook saying it failed: handled like a crash,38 // ActionError is the hook saying it failed: handled like a crash,
37 // according to its onError.39 // according to its onError.
38 ActionError = "error"40 ActionError = "error"
39 )41 )
40 42
43+// ContextLabel introduces the text a hook adds for a model, so the model —
44+// and whoever reads a session file — can tell it from what the user typed,
45+// what a tool printed or what another agent answered.
46+const ContextLabel = "[context added by a hook]\n"
47+
41 // Input is the JSON object a hook reads on stdin. Which fields are filled48 // Input is the JSON object a hook reads on stdin. Which fields are filled
42 // depends on the event:49 // depends on the event:
43 //50 //
@@ -45,15 +52,22 @@ const (
45 // preToolCall event, cwd, model, tool52 // preToolCall event, cwd, model, tool
46 // postToolCall event, cwd, model, tool, output53 // postToolCall event, cwd, model, tool, output
47 // postGeneration event, cwd, model, prompt, answer, error54 // postGeneration event, cwd, model, prompt, answer, error
55+// preAsk event, cwd, model, ask
56+// postAsk event, cwd, model, ask, answer, error, durationMs
57+//
58+// Asked is set on every event when this agent was itself asked by another.
48 type Input struct {59 type Input struct {
49- Event string `json:"event"`60+ Event string `json:"event"`
50- Cwd string `json:"cwd"`61+ Cwd string `json:"cwd"`
51- Model string `json:"model,omitempty"`62+ Model string `json:"model,omitempty"`
52- Prompt string `json:"prompt,omitempty"`63+ Prompt string `json:"prompt,omitempty"`
53- Tool *ToolCall `json:"tool,omitempty"`64+ Tool *ToolCall `json:"tool,omitempty"`
54- Output string `json:"output,omitempty"`65+ Ask *AskCall `json:"ask,omitempty"`
55- Answer string `json:"answer,omitempty"`66+ Output string `json:"output,omitempty"`
56- Error string `json:"error,omitempty"`67+ Answer string `json:"answer,omitempty"`
68+ Error string `json:"error,omitempty"`
69+ DurationMs int64 `json:"durationMs,omitempty"`
70+ Asked *Asked `json:"asked,omitempty"`
57 }71 }
58 72
59 // ToolCall describes the tool call a preToolCall or postToolCall hook is73 // ToolCall describes the tool call a preToolCall or postToolCall hook is
@@ -68,10 +82,18 @@ type ToolCall struct {
68 // optional; which ones are read depends on the event (see the decisions).82 // optional; which ones are read depends on the event (see the decisions).
69 type Reply struct {83 type Reply struct {
70 Action string `json:"action,omitempty"`84 Action string `json:"action,omitempty"`
71- // Prompt replaces the user's question (preGeneration). Absent = kept.85+ // Prompt replaces the user's question (preGeneration) or the question
86+ // sent to another agent (preAsk). Absent = kept.
72 Prompt *string `json:"prompt,omitempty"`87 Prompt *string `json:"prompt,omitempty"`
73- // Context is text added for the model: after the question88+ // Transcript replaces the conversation sent to another agent (preAsk).
74- // (preGeneration), after the tool's output (postToolCall).89+ // Absent = kept; "" = send none.
90+ Transcript *string `json:"transcript,omitempty"`
91+ // Answer replaces the answer the asking agent reads (postAsk). Absent =
92+ // kept.
93+ Answer *string `json:"answer,omitempty"`
94+ // Context is text added for a model: after the question
95+ // (preGeneration), after the tool's output (postToolCall), after the
96+ // question sent to another agent (preAsk), after its answer (postAsk).
75 Context string `json:"context,omitempty"`97 Context string `json:"context,omitempty"`
76 // Input replaces the tool call's arguments (preToolCall). Absent = kept.98 // Input replaces the tool call's arguments (preToolCall). Absent = kept.
77 Input json.RawMessage `json:"input,omitempty"`99 Input json.RawMessage `json:"input,omitempty"`
@@ -255,14 +277,14 @@ func (r *Runner) PostGeneration(ctx context.Context, in Input) {
255 }277 }
256 278
257 // failed reports a hook's failure according to its onError, and says whether279 // failed reports a hook's failure according to its onError, and says whether
258-// the turn (or the call) must stop: only for fail, and only on the pre*280+// the turn (or the call, or the hand-off) must stop: only for fail, and only
259-// events — after the fact there is nothing left to stop, so fail is281+// on the pre* events — after the fact there is nothing left to stop, so fail
260-// reported like warn there.282+// is reported like warn there.
261 func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) {283 func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) {
262 if h.OnError == config.HookOnErrorIgnore {284 if h.OnError == config.HookOnErrorIgnore {
263 return false285 return false
264 }286 }
265 r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error())287 r.notify("🪝 " + event + " hook `" + h.Command + "`: " + err.Error())
266- pre := event == config.HookPreGeneration || event == config.HookPreToolCall288+ pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk
267 return pre && h.OnError == config.HookOnErrorFail289 return pre && h.OnError == config.HookOnErrorFail
268 }290 }
modified internal/tools/askagent.go +30 -11
@@ -12,6 +12,7 @@ import (
1212 "mm/internal/detector"
1313 "mm/internal/elicit"
1414 "mm/internal/engine"
15+ "mm/internal/hooks"
1516 "mm/internal/spinner"
1617 "mm/internal/ui"
1718
@@ -37,9 +38,10 @@ type askAgentInput struct {
3738 WithContext bool `json:"with_context,omitempty" jsonschema_description:"Optional. true sends this conversation (questions, answers, the commands run) along with the prompt."`
3839 }
3940
40-// AgentAsker is what ask_agent needs from ask.Pool; tests give it a fake.
41+// AgentAsker is what ask_agent needs from ask.Pool — Delegate runs the
42+// asker's preAsk and postAsk hooks around the ask; tests give it a fake.
4143 type AgentAsker interface {
42- Ask(ctx context.Context, name, configPath, cwd, prompt string, sink ui.Sink) (string, error)
44+ Delegate(ctx context.Context, req ask.Request, cwd string, sink ui.Sink) (ask.Result, error)
4345 }
4446
4547 // AskAgent declares the `ask_agent` tool over the agents of mmConfigs. The
@@ -100,11 +102,11 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
100102 if in.WithContext {
101103 transcript = ask.Transcript(engine.Conversation(ctx), config.Cfg.MaxOutput)
102104 }
103- prompt := ask.Prompt(in.Prompt, transcript)
105+ req := ask.Request{Agent: in.Agent, Question: in.Prompt, Transcript: transcript, WithContext: in.WithContext, Via: hooks.ViaTool}
104106 id := ask.Identify(ref.Path)
105107 title := fmt.Sprintf("ask_agent: %s (%s)", in.Agent, ask.Describe(ref.Path))
106108 if s := ui.ActiveSink(); s != nil {
107- return call.evented(ctx, s, in, ref.Path, prompt, title, id)
109+ return call.evented(ctx, s, in, req, title, id)
108110 }
109111
110112 announce("🗣 " + title + " — " + firstLine(in.Prompt))
@@ -113,17 +115,21 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
113115 }
114116 spinner.Stop()
115117 cwd, _ := os.Getwd()
116- answer, err := call.asker.Ask(ctx, in.Agent, ref.Path, cwd, prompt, beating(ctx, &ask.LineSink{Cwd: cwd}))
118+ res, err := call.asker.Delegate(ctx, req, cwd, beating(ctx, &ask.LineSink{Cwd: cwd}))
119+ out := handOffResult(in.Agent, res, err)
120+ if ask.IsDenied(err) {
121+ ui.Notice("🪝 ask_agent " + out)
122+ }
117123 fmt.Println()
118124 spinner.Start("Thinking")
119- return record(call.detector, "ask_agent", key, askAgentResult(in.Agent, answer, err))
125+ return record(call.detector, "ask_agent", key, out)
120126 }
121127
122128 // evented is ask_agent for an event-consuming front end (the window, an
123129 // editor): one tool call for the hand-off, the asked agent's own events
124130 // inside it, and — for a sink that is a ui.Speaker — the asked agent named
125131 // while it answers.
126-func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInput, path, prompt, title string, id ask.Identity) (string, error) {
132+func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInput, req ask.Request, title string, id ask.Identity) (string, error) {
127133 key := in.Agent + ": " + in.Prompt
128134 ev := ui.ToolEvent{
129135 ID: ui.NextCallID(),
@@ -142,16 +148,19 @@ func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInpu
142148 if sp, ok := s.(ui.Speaker); ok {
143149 sp.Speaking(in.Agent, id.Title, id.Model, id.Provider)
144150 }
145- answer, err := call.asker.Ask(ctx, in.Agent, path, s.WorkDir(), prompt, beating(ctx, s))
151+ res, err := call.asker.Delegate(ctx, req, s.WorkDir(), beating(ctx, s))
146152 if sp, ok := s.(ui.Speaker); ok {
147153 sp.Spoken()
148154 }
149- out := askAgentResult(in.Agent, answer, err)
155+ out := handOffResult(in.Agent, res, err)
150156 // The answer has streamed already: the call's own output only says how
151157 // it ended, not the answer a second time.
152- summary := fmt.Sprintf("%s answered (%d characters)", in.Agent, len([]rune(answer)))
153- if err != nil || strings.TrimSpace(answer) == "" {
158+ summary := fmt.Sprintf("%s answered (%d characters)", in.Agent, len([]rune(res.Answer)))
159+ switch {
160+ case err != nil || strings.TrimSpace(res.Answer) == "":
154161 summary = out
162+ case res.Replaced:
163+ summary = fmt.Sprintf("%s answered; a hook replaced the answer (%d characters)", in.Agent, len([]rune(res.Answer)))
155164 }
156165 s.ToolEnd(ev.ID, ui.ToolResult{Output: summary, Failed: err != nil})
157166 return record(call.detector, "ask_agent", key, out)
@@ -198,6 +207,16 @@ func (b beatingSink) Elicit(ctx context.Context, ev ui.ToolEvent, f elicit.Form)
198207 return whileWaiting(b.beat, func() elicit.Result { return asker.Elicit(ctx, ev, f) })
199208 }
200209
210+// handOffResult is what the model reads after Delegate: why a preAsk hook
211+// refused, or the answer (askAgentResult) followed by what the postAsk hooks
212+// added.
213+func handOffResult(agent string, res ask.Result, err error) string {
214+ if ask.IsDenied(err) {
215+ return "[" + err.Error() + "]"
216+ }
217+ return hooks.WithContext(askAgentResult(agent, res.Answer, err), res.Context)
218+}
219+
201220 // askAgentResult is what the model reads: the answer (capped like any tool
202221 // output), or why there is none.
203222 func askAgentResult(agent, answer string, err error) string {
@@ -12,6 +12,7 @@ import (
12 "mm/internal/detector"12 "mm/internal/detector"
13 "mm/internal/elicit"13 "mm/internal/elicit"
14 "mm/internal/engine"14 "mm/internal/engine"
15+ "mm/internal/hooks"
15 "mm/internal/spinner"16 "mm/internal/spinner"
16 "mm/internal/ui"17 "mm/internal/ui"
17 18
@@ -37,9 +38,10 @@ type askAgentInput struct {
37 WithContext bool `json:"with_context,omitempty" jsonschema_description:"Optional. true sends this conversation (questions, answers, the commands run) along with the prompt."`38 WithContext bool `json:"with_context,omitempty" jsonschema_description:"Optional. true sends this conversation (questions, answers, the commands run) along with the prompt."`
38 }39 }
39 40
40-// AgentAsker is what ask_agent needs from ask.Pool; tests give it a fake.41+// AgentAsker is what ask_agent needs from ask.Pool — Delegate runs the
42+// asker's preAsk and postAsk hooks around the ask; tests give it a fake.
41 type AgentAsker interface {43 type AgentAsker interface {
42- Ask(ctx context.Context, name, configPath, cwd, prompt string, sink ui.Sink) (string, error)44+ Delegate(ctx context.Context, req ask.Request, cwd string, sink ui.Sink) (ask.Result, error)
43 }45 }
44 46
45 // AskAgent declares the `ask_agent` tool over the agents of mmConfigs. The47 // AskAgent declares the `ask_agent` tool over the agents of mmConfigs. The
@@ -100,11 +102,11 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
100 if in.WithContext {102 if in.WithContext {
101 transcript = ask.Transcript(engine.Conversation(ctx), config.Cfg.MaxOutput)103 transcript = ask.Transcript(engine.Conversation(ctx), config.Cfg.MaxOutput)
102 }104 }
103- prompt := ask.Prompt(in.Prompt, transcript)105+ req := ask.Request{Agent: in.Agent, Question: in.Prompt, Transcript: transcript, WithContext: in.WithContext, Via: hooks.ViaTool}
104 id := ask.Identify(ref.Path)106 id := ask.Identify(ref.Path)
105 title := fmt.Sprintf("ask_agent: %s (%s)", in.Agent, ask.Describe(ref.Path))107 title := fmt.Sprintf("ask_agent: %s (%s)", in.Agent, ask.Describe(ref.Path))
106 if s := ui.ActiveSink(); s != nil {108 if s := ui.ActiveSink(); s != nil {
107- return call.evented(ctx, s, in, ref.Path, prompt, title, id)109+ return call.evented(ctx, s, in, req, title, id)
108 }110 }
109 111
110 announce("🗣 " + title + " — " + firstLine(in.Prompt))112 announce("🗣 " + title + " — " + firstLine(in.Prompt))
@@ -113,17 +115,21 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err
113 }115 }
114 spinner.Stop()116 spinner.Stop()
115 cwd, _ := os.Getwd()117 cwd, _ := os.Getwd()
116- answer, err := call.asker.Ask(ctx, in.Agent, ref.Path, cwd, prompt, beating(ctx, &ask.LineSink{Cwd: cwd}))118+ res, err := call.asker.Delegate(ctx, req, cwd, beating(ctx, &ask.LineSink{Cwd: cwd}))
119+ out := handOffResult(in.Agent, res, err)
120+ if ask.IsDenied(err) {
121+ ui.Notice("🪝 ask_agent " + out)
122+ }
117 fmt.Println()123 fmt.Println()
118 spinner.Start("Thinking")124 spinner.Start("Thinking")
119- return record(call.detector, "ask_agent", key, askAgentResult(in.Agent, answer, err))125+ return record(call.detector, "ask_agent", key, out)
120 }126 }
121 127
122 // evented is ask_agent for an event-consuming front end (the window, an128 // evented is ask_agent for an event-consuming front end (the window, an
123 // editor): one tool call for the hand-off, the asked agent's own events129 // editor): one tool call for the hand-off, the asked agent's own events
124 // inside it, and — for a sink that is a ui.Speaker — the asked agent named130 // inside it, and — for a sink that is a ui.Speaker — the asked agent named
125 // while it answers.131 // while it answers.
126-func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInput, path, prompt, title string, id ask.Identity) (string, error) {132+func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInput, req ask.Request, title string, id ask.Identity) (string, error) {
127 key := in.Agent + ": " + in.Prompt133 key := in.Agent + ": " + in.Prompt
128 ev := ui.ToolEvent{134 ev := ui.ToolEvent{
129 ID: ui.NextCallID(),135 ID: ui.NextCallID(),
@@ -142,16 +148,19 @@ func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInpu
142 if sp, ok := s.(ui.Speaker); ok {148 if sp, ok := s.(ui.Speaker); ok {
143 sp.Speaking(in.Agent, id.Title, id.Model, id.Provider)149 sp.Speaking(in.Agent, id.Title, id.Model, id.Provider)
144 }150 }
145- answer, err := call.asker.Ask(ctx, in.Agent, path, s.WorkDir(), prompt, beating(ctx, s))151+ res, err := call.asker.Delegate(ctx, req, s.WorkDir(), beating(ctx, s))
146 if sp, ok := s.(ui.Speaker); ok {152 if sp, ok := s.(ui.Speaker); ok {
147 sp.Spoken()153 sp.Spoken()
148 }154 }
149- out := askAgentResult(in.Agent, answer, err)155+ out := handOffResult(in.Agent, res, err)
150 // The answer has streamed already: the call's own output only says how156 // The answer has streamed already: the call's own output only says how
151 // it ended, not the answer a second time.157 // it ended, not the answer a second time.
152- summary := fmt.Sprintf("%s answered (%d characters)", in.Agent, len([]rune(answer)))158+ summary := fmt.Sprintf("%s answered (%d characters)", in.Agent, len([]rune(res.Answer)))
153- if err != nil || strings.TrimSpace(answer) == "" {159+ switch {
160+ case err != nil || strings.TrimSpace(res.Answer) == "":
154 summary = out161 summary = out
162+ case res.Replaced:
163+ summary = fmt.Sprintf("%s answered; a hook replaced the answer (%d characters)", in.Agent, len([]rune(res.Answer)))
155 }164 }
156 s.ToolEnd(ev.ID, ui.ToolResult{Output: summary, Failed: err != nil})165 s.ToolEnd(ev.ID, ui.ToolResult{Output: summary, Failed: err != nil})
157 return record(call.detector, "ask_agent", key, out)166 return record(call.detector, "ask_agent", key, out)
@@ -198,6 +207,16 @@ func (b beatingSink) Elicit(ctx context.Context, ev ui.ToolEvent, f elicit.Form)
198 return whileWaiting(b.beat, func() elicit.Result { return asker.Elicit(ctx, ev, f) })207 return whileWaiting(b.beat, func() elicit.Result { return asker.Elicit(ctx, ev, f) })
199 }208 }
200 209
210+// handOffResult is what the model reads after Delegate: why a preAsk hook
211+// refused, or the answer (askAgentResult) followed by what the postAsk hooks
212+// added.
213+func handOffResult(agent string, res ask.Result, err error) string {
214+ if ask.IsDenied(err) {
215+ return "[" + err.Error() + "]"
216+ }
217+ return hooks.WithContext(askAgentResult(agent, res.Answer, err), res.Context)
218+}
219+
201 // askAgentResult is what the model reads: the answer (capped like any tool220 // askAgentResult is what the model reads: the answer (capped like any tool
202 // output), or why there is none.221 // output), or why there is none.
203 func askAgentResult(agent, answer string, err error) string {222 func askAgentResult(agent, answer string, err error) string {
modified internal/tools/askagent_test.go +67 -9
@@ -10,25 +10,34 @@ import (
1010 "mm/internal/config"
1111 "mm/internal/detector"
1212 "mm/internal/engine"
13+ "mm/internal/hooks"
1314 "mm/internal/ui"
1415
1516 "github.com/firebase/genkit/go/ai"
1617 )
1718
18-// fakeAgentAsker plays the asked agent: it records what it was given and
19-// streams its answer through the sink.
19+// fakeAgentAsker plays the pool and the asked agent: it records what it was
20+// given, streams its answer through the sink, and returns what the asker's
21+// hooks would have made of it (result, when set).
2022 type fakeAgentAsker struct {
2123 answer string
2224 err error
23- got struct{ name, path, cwd, prompt string }
24- calls int
25+ result *ask.Result
26+ got struct {
27+ name, cwd, prompt string
28+ req ask.Request
29+ }
30+ calls int
2531 }
2632
27-func (a *fakeAgentAsker) Ask(_ context.Context, name, path, cwd, prompt string, sink ui.Sink) (string, error) {
33+func (a *fakeAgentAsker) Delegate(_ context.Context, req ask.Request, cwd string, sink ui.Sink) (ask.Result, error) {
2834 a.calls++
29- a.got.name, a.got.path, a.got.cwd, a.got.prompt = name, path, cwd, prompt
35+ a.got.name, a.got.cwd, a.got.prompt, a.got.req = req.Agent, cwd, ask.Prompt(req.Question, req.Transcript), req
36+ if a.result != nil {
37+ return *a.result, a.err
38+ }
3039 sink.Text(a.answer)
31- return a.answer, a.err
40+ return ask.Result{Question: req.Question, Answer: a.answer}, a.err
3241 }
3342
3443 // speakingSink is a recordingSink that also records who it was told speaks.
@@ -77,7 +86,7 @@ func TestAskAgentEvented(t *testing.T) {
7786 a := &fakeAgentAsker{answer: "Implemented R1 and R2."}
7887 sink := &speakingSink{recordingSink: recordingSink{dir: "/proj"}}
7988
80- out, err := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "Implement specs/x.spec.md"}, "/nowhere/homer.yaml", "Implement specs/x.spec.md", "ask_agent: homer (?)", askIdentity("Homer (coder)"))
89+ out, err := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "Implement specs/x.spec.md"}, ask.Request{Agent: "homer", Question: "Implement specs/x.spec.md", Via: hooks.ViaTool}, "ask_agent: homer (?)", askIdentity("Homer (coder)"))
8190 if err != nil || out != "Implemented R1 and R2." {
8291 t.Fatalf("result %q, err %v", out, err)
8392 }
@@ -98,7 +107,7 @@ func TestAskAgentRefused(t *testing.T) {
98107 withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
99108 a := &fakeAgentAsker{answer: "never"}
100109 sink := &speakingSink{recordingSink: recordingSink{allow: false}}
101- out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, "x", "p", "t", askIdentity(""))
110+ out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, ask.Request{Agent: "homer", Question: "p"}, "t", askIdentity(""))
102111 if a.calls != 0 || out != "[ask_agent rejected by the user]" || sink.asked != 1 || len(sink.spoke) != 0 {
103112 t.Errorf("calls %d, out %q, asked %d, spoke %v", a.calls, out, sink.asked, sink.spoke)
104113 }
@@ -152,6 +161,55 @@ func TestAskAgentWithContext(t *testing.T) {
152161 }
153162 }
154163
164+// The hand-off goes through Delegate as a tool hand-off; a preAsk deny is
165+// what the model reads, and a failed tool call for whoever watches.
166+func TestAskAgentDeniedByAHook(t *testing.T) {
167+ yoloOn(t)
168+ withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
169+ a := &fakeAgentAsker{result: &ask.Result{Question: "p"}, err: &ask.DeniedError{Agent: "homer", Reason: "budget spent"}}
170+ sink := &speakingSink{}
171+ out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, ask.Request{Agent: "homer", Question: "p", Via: hooks.ViaTool}, "t", askIdentity(""))
172+ if out != "[ask denied by a hook: budget spent]" || !sink.ended.Failed || sink.ended.Output != out {
173+ t.Errorf("out %q, ended %+v", out, sink.ended)
174+ }
175+ if a.got.req.Via != hooks.ViaTool {
176+ t.Errorf("via = %q", a.got.req.Via)
177+ }
178+}
179+
180+// In the line mode a refused hand-off is shown on its own line, like a
181+// refused tool call: the model is not the only one to know.
182+func TestAskAgentDeniedInTheLineMode(t *testing.T) {
183+ yoloOn(t)
184+ withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
185+ ui.SetActive(nil)
186+ var shown strings.Builder
187+ saved := ui.Out
188+ ui.Out = &shown
189+ t.Cleanup(func() { ui.Out = saved })
190+ a := &fakeAgentAsker{result: &ask.Result{}, err: &ask.DeniedError{Agent: "homer", Reason: "budget spent"}}
191+ out, _ := newAskAgentCall(a).run(context.Background(), askAgentInput{Agent: "homer", Prompt: "p"})
192+ if out != "[ask denied by a hook: budget spent]" || !strings.Contains(shown.String(), "🪝 ask_agent [ask denied by a hook: budget spent]") {
193+ t.Errorf("out %q, shown %q", out, shown.String())
194+ }
195+}
196+
197+// A postAsk replacement and its context are what the model reads; the call's
198+// summary says the answer was replaced.
199+func TestAskAgentReadsThePostAskResult(t *testing.T) {
200+ yoloOn(t)
201+ withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
202+ a := &fakeAgentAsker{result: &ask.Result{Question: "p", Answer: "short", Replaced: true, Context: []string{"no code block"}}}
203+ sink := &speakingSink{}
204+ out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, ask.Request{Agent: "homer", Question: "p"}, "t", askIdentity(""))
205+ if want := "short\n\n" + hooks.ContextLabel + "no code block"; out != want {
206+ t.Errorf("out = %q, want %q", out, want)
207+ }
208+ if sink.ended.Output != "homer answered; a hook replaced the answer (5 characters)" {
209+ t.Errorf("summary = %q", sink.ended.Output)
210+ }
211+}
212+
155213 // yoloOn sets yolo on for one test.
156214 func yoloOn(t *testing.T) {
157215 t.Helper()
@@ -10,25 +10,34 @@ import (
10 "mm/internal/config"10 "mm/internal/config"
11 "mm/internal/detector"11 "mm/internal/detector"
12 "mm/internal/engine"12 "mm/internal/engine"
13+ "mm/internal/hooks"
13 "mm/internal/ui"14 "mm/internal/ui"
14 15
15 "github.com/firebase/genkit/go/ai"16 "github.com/firebase/genkit/go/ai"
16 )17 )
17 18
18-// fakeAgentAsker plays the asked agent: it records what it was given and19+// fakeAgentAsker plays the pool and the asked agent: it records what it was
19-// streams its answer through the sink.20+// given, streams its answer through the sink, and returns what the asker's
21+// hooks would have made of it (result, when set).
20 type fakeAgentAsker struct {22 type fakeAgentAsker struct {
21 answer string23 answer string
22 err error24 err error
23- got struct{ name, path, cwd, prompt string }25+ result *ask.Result
24- calls int26+ got struct {
27+ name, cwd, prompt string
28+ req ask.Request
29+ }
30+ calls int
25 }31 }
26 32
27-func (a *fakeAgentAsker) Ask(_ context.Context, name, path, cwd, prompt string, sink ui.Sink) (string, error) {33+func (a *fakeAgentAsker) Delegate(_ context.Context, req ask.Request, cwd string, sink ui.Sink) (ask.Result, error) {
28 a.calls++34 a.calls++
29- a.got.name, a.got.path, a.got.cwd, a.got.prompt = name, path, cwd, prompt35+ a.got.name, a.got.cwd, a.got.prompt, a.got.req = req.Agent, cwd, ask.Prompt(req.Question, req.Transcript), req
36+ if a.result != nil {
37+ return *a.result, a.err
38+ }
30 sink.Text(a.answer)39 sink.Text(a.answer)
31- return a.answer, a.err40+ return ask.Result{Question: req.Question, Answer: a.answer}, a.err
32 }41 }
33 42
34 // speakingSink is a recordingSink that also records who it was told speaks.43 // speakingSink is a recordingSink that also records who it was told speaks.
@@ -77,7 +86,7 @@ func TestAskAgentEvented(t *testing.T) {
77 a := &fakeAgentAsker{answer: "Implemented R1 and R2."}86 a := &fakeAgentAsker{answer: "Implemented R1 and R2."}
78 sink := &speakingSink{recordingSink: recordingSink{dir: "/proj"}}87 sink := &speakingSink{recordingSink: recordingSink{dir: "/proj"}}
79 88
80- out, err := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "Implement specs/x.spec.md"}, "/nowhere/homer.yaml", "Implement specs/x.spec.md", "ask_agent: homer (?)", askIdentity("Homer (coder)"))89+ out, err := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "Implement specs/x.spec.md"}, ask.Request{Agent: "homer", Question: "Implement specs/x.spec.md", Via: hooks.ViaTool}, "ask_agent: homer (?)", askIdentity("Homer (coder)"))
81 if err != nil || out != "Implemented R1 and R2." {90 if err != nil || out != "Implemented R1 and R2." {
82 t.Fatalf("result %q, err %v", out, err)91 t.Fatalf("result %q, err %v", out, err)
83 }92 }
@@ -98,7 +107,7 @@ func TestAskAgentRefused(t *testing.T) {
98 withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})107 withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
99 a := &fakeAgentAsker{answer: "never"}108 a := &fakeAgentAsker{answer: "never"}
100 sink := &speakingSink{recordingSink: recordingSink{allow: false}}109 sink := &speakingSink{recordingSink: recordingSink{allow: false}}
101- out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, "x", "p", "t", askIdentity(""))110+ out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, ask.Request{Agent: "homer", Question: "p"}, "t", askIdentity(""))
102 if a.calls != 0 || out != "[ask_agent rejected by the user]" || sink.asked != 1 || len(sink.spoke) != 0 {111 if a.calls != 0 || out != "[ask_agent rejected by the user]" || sink.asked != 1 || len(sink.spoke) != 0 {
103 t.Errorf("calls %d, out %q, asked %d, spoke %v", a.calls, out, sink.asked, sink.spoke)112 t.Errorf("calls %d, out %q, asked %d, spoke %v", a.calls, out, sink.asked, sink.spoke)
104 }113 }
@@ -152,6 +161,55 @@ func TestAskAgentWithContext(t *testing.T) {
152 }161 }
153 }162 }
154 163
164+// The hand-off goes through Delegate as a tool hand-off; a preAsk deny is
165+// what the model reads, and a failed tool call for whoever watches.
166+func TestAskAgentDeniedByAHook(t *testing.T) {
167+ yoloOn(t)
168+ withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
169+ a := &fakeAgentAsker{result: &ask.Result{Question: "p"}, err: &ask.DeniedError{Agent: "homer", Reason: "budget spent"}}
170+ sink := &speakingSink{}
171+ out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, ask.Request{Agent: "homer", Question: "p", Via: hooks.ViaTool}, "t", askIdentity(""))
172+ if out != "[ask denied by a hook: budget spent]" || !sink.ended.Failed || sink.ended.Output != out {
173+ t.Errorf("out %q, ended %+v", out, sink.ended)
174+ }
175+ if a.got.req.Via != hooks.ViaTool {
176+ t.Errorf("via = %q", a.got.req.Via)
177+ }
178+}
179+
180+// In the line mode a refused hand-off is shown on its own line, like a
181+// refused tool call: the model is not the only one to know.
182+func TestAskAgentDeniedInTheLineMode(t *testing.T) {
183+ yoloOn(t)
184+ withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
185+ ui.SetActive(nil)
186+ var shown strings.Builder
187+ saved := ui.Out
188+ ui.Out = &shown
189+ t.Cleanup(func() { ui.Out = saved })
190+ a := &fakeAgentAsker{result: &ask.Result{}, err: &ask.DeniedError{Agent: "homer", Reason: "budget spent"}}
191+ out, _ := newAskAgentCall(a).run(context.Background(), askAgentInput{Agent: "homer", Prompt: "p"})
192+ if out != "[ask denied by a hook: budget spent]" || !strings.Contains(shown.String(), "🪝 ask_agent [ask denied by a hook: budget spent]") {
193+ t.Errorf("out %q, shown %q", out, shown.String())
194+ }
195+}
196+
197+// A postAsk replacement and its context are what the model reads; the call's
198+// summary says the answer was replaced.
199+func TestAskAgentReadsThePostAskResult(t *testing.T) {
200+ yoloOn(t)
201+ withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}})
202+ a := &fakeAgentAsker{result: &ask.Result{Question: "p", Answer: "short", Replaced: true, Context: []string{"no code block"}}}
203+ sink := &speakingSink{}
204+ out, _ := newAskAgentCall(a).evented(context.Background(), sink, askAgentInput{Agent: "homer", Prompt: "p"}, ask.Request{Agent: "homer", Question: "p"}, "t", askIdentity(""))
205+ if want := "short\n\n" + hooks.ContextLabel + "no code block"; out != want {
206+ t.Errorf("out = %q, want %q", out, want)
207+ }
208+ if sink.ended.Output != "homer answered; a hook replaced the answer (5 characters)" {
209+ t.Errorf("summary = %q", sink.ended.Output)
210+ }
211+}
212+
155 // yoloOn sets yolo on for one test.213 // yoloOn sets yolo on for one test.
156 func yoloOn(t *testing.T) {214 func yoloOn(t *testing.T) {
157 t.Helper()215 t.Helper()