🛟 Updated.
5b2f986 parent: 8eb168b modified
.tickets/issues/0087-hook-for-ask-agents.yaml +62 -2 | @@ -6,7 +6,7 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-15T21:55:43.205Z |
| 9 | -updatedAt: 2026-10-15T22:30:00.000Z | |
| 9 | +updatedAt: 2026-10-15T23:50:00.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.5.0 |
| 12 | 12 | body: | |
| @@ -101,5 +101,65 @@ body: | | ||
| 101 | 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 | 102 | 3. **`mm --ask` runs the hooks** of the file it reads (`AGENT_CONFIG`), with `via: "cli"`. |
| 103 | 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 | 165 | comments: [] |
| @@ -6,7 +6,7 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-15T21:55:43.205Z | 8 | createdAt: 2026-10-15T21:55:43.205Z |
| 9 | -updatedAt: 2026-10-15T22:30:00.000Z | 9 | +updatedAt: 2026-10-15T23:50:00.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.5.0 | 11 | 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 | ||
| 116 | 116 | - **MCP servers**: their tools, wrapped as mm tools. See the [MCP tutorial](docs/en/tutorials/mcp-getting-started.md). |
| 117 | 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 | 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 | 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 | 122 | ## Demos |
| @@ -141,6 +141,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | ||
| 141 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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 | ## Demos | 122 | ## 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 @@ | ||
| 1 | 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | 1 | <mxfile host="app.diagrams.net" modified="2026-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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" 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="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" 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="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" 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="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" 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="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" 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="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" 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) | ||
| 36 | 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 53 |
| 37 | 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 it |
| 38 | 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 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 | 41 | ### MCP (Model Context Protocol) How-to |
| 41 | 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 | 50 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule |
| 50 | 51 | - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` |
| 51 | 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 errors | |
| 53 | +- [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors | |
| 53 | 54 | - [Agent Client Protocol surface](reference/acp.md) |
| 54 | 55 | |
| 55 | 56 | ### MCP (Model Context Protocol) Reference |
| @@ -71,7 +72,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 71 | 72 | - [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 | 73 | - [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 | 74 | - [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 | |
| 75 | 76 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 76 | 77 | |
| 77 | 78 | ### 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 53 | 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 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 it | 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 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 language | 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 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-to | 41 | ### 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 rule | 50 | - [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 errors | 53 | +- [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) Reference | 56 | ### 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 answer | 72 | - [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 limit | 73 | - [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 gap | 74 | - [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 |
| 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) Explanation | 78 | ### 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 | ||
| 17 | 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 | 18 | | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. | |
| 19 | 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 | 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 | 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 | 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 | 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 | 28 | | `ui` | Where human-facing output goes, and the event sink the front ends share. | |
| 29 | 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 | 31 | | `agent` | The terminal REPL. | |
| 32 | 32 | | `acp` | The Agent Client Protocol façade. | |
| 33 | 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. | |
| @@ -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 @@ | ||
| 42 | 42 | - The child is an ordinary [ACP agent](../reference/acp.md); the parent reuses the protocol from the other side. |
| 43 | 43 | - Its tool calls and permissions go through the same event interface the three front ends implement for your own agent's tools. |
| 44 | 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 | 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). |
| @@ -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 @@ | ||
| 2 | 2 | |
| 3 | 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 | 9 | ## 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 | 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 | 70 | ## Rejected alternatives |
| 54 | 71 | |
| 55 | 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 | 80 | ## How it relates to the rest |
| 64 | 81 | |
| 65 | 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 | 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`. | |
| @@ -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 commands | 9 | ## 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 alternatives | 70 | ## 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 rest | 80 | ## 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 | ||
| 78 | 78 | - Every flag, stream and exit code: [command-line reference](../reference/cli.md#ask-mode-specifics) |
| 79 | 79 | - `bashTimeout` and `mmConfigs`: [configuration reference](../reference/configuration.md) |
| 80 | 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) | |
| @@ -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 | ||
| 71 | 71 | - The tool's fields, display and errors: [tools reference](../reference/tools.md#ask_agent) |
| 72 | 72 | - The two forms of `mmConfigs`: [configuration reference](../reference/configuration.md) |
| 73 | 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) | |
| @@ -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 @@ | ||
| 8 | 8 | |--------|-------| |
| 9 | 9 | | Input | stdin, one JSON-RPC message per line. | |
| 10 | 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 | 12 | | Lifetime | Until the client closes stdin or the process context is cancelled. | |
| 13 | 13 | |
| 14 | 14 | ## `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` response | 14 | ## `initialize` response |
modified
docs/en/reference/configuration.md +1 -1 | @@ -34,7 +34,7 @@ | ||
| 34 | 34 | | `agentsMd` | map | see below | Append the project's `AGENTS.md` to the system prompt of every conversation. On by default. | |
| 35 | 35 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| 36 | 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 | 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 | 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 | 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`. | |
| @@ -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: | ||
| 10 | 10 | preToolCall: [<hook>, …] |
| 11 | 11 | postToolCall: [<hook>, …] |
| 12 | 12 | postGeneration: [<hook>, …] |
| 13 | + preAsk: [<hook>, …] | |
| 14 | + postAsk: [<hook>, …] | |
| 13 | 15 | ``` |
| 14 | 16 | |
| 15 | 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 | 21 | | Key | Type | Default | Description | |
| 20 | 22 | |-----|------|---------|-------------| |
| 21 | 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 | 25 | | `timeout` | duration | `10s` | How long one run may last before it is killed. `0` = the default. Must be ≥ 0. | |
| 24 | 26 | | `onError` | `fail` \| `warn` \| `ignore` | `warn` | What a failing run does (see [Failures](#failures)). | |
| 25 | 27 | |
| @@ -47,8 +49,12 @@ hooks: | ||
| 47 | 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 | 50 | | `postToolCall` | after each tool call that ran | built-in and MCP, filtered by `matcher` | add context after the tool's output | |
| 49 | 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 | 59 | ## 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 | 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 | 76 | | `MM_CONFIG_DIR` | absolute directory of the configuration file that declared the hook | |
| 71 | 77 | |
| 72 | 78 | ## Input |
| @@ -80,12 +86,28 @@ The JSON object written on the hook's stdin. | ||
| 80 | 86 | | `model` | string | all | the model reference, `<provider>/<model>` | |
| 81 | 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 | 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 | 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 | | |
| 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 | | |
| 86 | 95 | |
| 87 | 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 | 111 | ### Examples |
| 90 | 112 | |
| 91 | 113 | ```json |
| @@ -96,6 +118,10 @@ Empty fields are omitted. | ||
| 96 | 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 | 125 | ## Reply |
| 100 | 126 | |
| 101 | 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 | 129 | | Field | Type | Read on | Description | |
| 104 | 130 | |-------|------|---------|-------------| |
| 105 | 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. | | |
| 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. | | |
| 108 | 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 | 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 | 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 | 147 | | `continue` | all | the turn goes on, with this hook's `prompt`, `context` and `input` applied | |
| 120 | 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 | 150 | | `error` | all | a failure, handled according to `onError` | |
| 123 | 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 | 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 | 205 | ## Failures |
| 156 | 206 | |
| 157 | 207 | A run fails when: |
| @@ -160,27 +210,27 @@ A run fails when: | ||
| 160 | 210 | |---------|--------| |
| 161 | 211 | | the command exits with a non-zero status | `exit status <n>: <stderr>` | |
| 162 | 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 | 214 | | stdout is larger than 1 MiB | `stdout larger than 1048576 bytes` | |
| 165 | 215 | | stdout is not JSON | `invalid JSON on stdout: …` | |
| 166 | 216 | | `action` is `error` | the reply's `error`, or `the hook reported an error` | |
| 167 | 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 | 221 | What follows depends on `onError`: |
| 172 | 222 | |
| 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 | | |
| 176 | 226 | | `warn` | report line, next hook | report line, next hook | report line, carry on | |
| 177 | 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 | 231 | ## 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 | 236 | 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 | 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 | 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 | 248 | | `hooks.<event>[N]: matcher: error parsing regexp: …` | a `matcher` that does not compile | |
| 199 | 249 | | `hooks.<event>[N]: timeout must be >= 0` | a negative `timeout` | |
| 200 | 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 | 254 | - [Guard the agent with hooks](../how-to/guard-the-agent-with-hooks.md) |
| 205 | 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) | |
| @@ -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 | ## Execution | 59 | ## 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 | ## Input | 78 | ## 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 | ### Examples | 111 | ### Examples |
| 90 | 112 | ||
| 91 | ```json | 113 | ```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 | ## Reply | 125 | ## 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 | ## Failures | 205 | ## 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 banner | 231 | ## 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×1 | 236 | 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 | ||
| 219 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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: | ||
| 36 | 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 53 |
| 37 | 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'afficher |
| 38 | 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 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 | 41 | ### Guides pratiques MCP (Model Context Protocol) |
| 41 | 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 | 50 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation |
| 50 | 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 | 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 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 | |
| 53 | 54 | - [Surface Agent Client Protocol](reference/acp.md) |
| 54 | 55 | |
| 55 | 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 | 72 | - [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 | 73 | - [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 | 74 | - [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 | 76 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 76 | 77 | |
| 77 | 78 | ### 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 53 | 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 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'afficher | 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'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 langage | 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 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 validation | 50 | - [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 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 |
| 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éponse | 72 | - [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 profondeur | 73 | - [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 ACP | 74 | - [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 | ||
| 17 | 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 | 18 | | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. | |
| 19 | 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 | 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 | 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 | 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 | 27 | | `spinner` | L'indicateur « toujours en cours » sur un terminal. | |
| 28 | 28 | | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. | |
| 29 | 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 | 31 | | `agent` | Le REPL du terminal. | |
| 32 | 32 | | `acp` | La façade Agent Client Protocol. | |
| 33 | 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. | |
| @@ -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 @@ | ||
| 42 | 42 | - L'enfant est un [agent ACP](../reference/acp.md) ordinaire ; le parent réutilise le protocole de l'autre côté. |
| 43 | 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 | 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 | 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). |
| @@ -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 @@ | ||
| 2 | 2 | |
| 3 | 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 | 9 | ## 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 | 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 | 70 | ## Les alternatives écartées |
| 54 | 71 | |
| 55 | 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 | 80 | ## Les liens avec le reste |
| 64 | 81 | |
| 65 | 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 | 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`. | |
| @@ -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 externes | 9 | ## 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ées | 70 | ## 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 reste | 80 | ## 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 | ||
| 78 | 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 | 79 | - `bashTimeout` et `mmConfigs` : [référence de configuration](../reference/configuration.md) |
| 80 | 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) | |
| @@ -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 | ||
| 71 | 71 | - Les champs de l'outil, son affichage et ses erreurs : [référence des outils](../reference/tools.md#ask_agent) |
| 72 | 72 | - Les deux formes de `mmConfigs` : [référence de la configuration](../reference/configuration.md) |
| 73 | 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) | |
| @@ -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 @@ | ||
| 8 | 8 | |--------|--------| |
| 9 | 9 | | Entrée | stdin, un message JSON-RPC par ligne. | |
| 10 | 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 | 12 | | Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. | |
| 13 | 13 | |
| 14 | 14 | ## 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 @@ | ||
| 34 | 34 | | `trace` | dictionnaire | voir ci-dessous | Écrit chaque requête au modèle et chaque réponse dans des fichiers. | |
| 35 | 35 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| 36 | 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 | 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 | 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 | 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`. | |
| @@ -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: | ||
| 10 | 10 | preToolCall: [<hook>, …] |
| 11 | 11 | postToolCall: [<hook>, …] |
| 12 | 12 | postGeneration: [<hook>, …] |
| 13 | + preAsk: [<hook>, …] | |
| 14 | + postAsk: [<hook>, …] | |
| 13 | 15 | ``` |
| 14 | 16 | |
| 15 | 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 | 21 | | Clé | Type | Défaut | Description | |
| 20 | 22 | |-----|------|--------|-------------| |
| 21 | 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 | 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 | 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 | 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 | 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 | 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 | 59 | ## 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 | 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 | 76 | | `MM_CONFIG_DIR` | dossier absolu du fichier de configuration qui a déclaré le hook | |
| 71 | 77 | |
| 72 | 78 | ## Entrée |
| @@ -80,12 +86,28 @@ L'objet JSON écrit sur l'entrée standard du hook. | ||
| 80 | 86 | | `model` | chaîne | tous | la référence du modèle, `<fournisseur>/<modèle>` | |
| 81 | 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 | 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 | 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 | | |
| 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é | | |
| 86 | 95 | |
| 87 | 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 | 111 | ### Exemples |
| 90 | 112 | |
| 91 | 113 | ```json |
| @@ -96,6 +118,10 @@ Les champs vides sont omis. | ||
| 96 | 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 | 125 | ## Réponse |
| 100 | 126 | |
| 101 | 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 | 129 | | Champ | Type | Lu sur | Description | |
| 104 | 130 | |-------|------|--------|-------------| |
| 105 | 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. | | |
| 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é. | | |
| 108 | 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 | 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 | 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 | 147 | | `continue` | tous | le tour continue, avec le `prompt`, le `context` et l'`input` de ce hook appliqués | |
| 120 | 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 | 150 | | `error` | tous | un échec, traité selon `onError` | |
| 123 | 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 | 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 | 205 | ## Échecs |
| 156 | 206 | |
| 157 | 207 | Une exécution échoue quand : |
| @@ -160,27 +210,27 @@ Une exécution échoue quand : | ||
| 160 | 210 | |-------|---------| |
| 161 | 211 | | la commande se termine avec un code non nul | `exit status <n>: <stderr>` | |
| 162 | 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 | 214 | | stdout dépasse 1 Mio | `stdout larger than 1048576 bytes` | |
| 165 | 215 | | stdout n'est pas du JSON | `invalid JSON on stdout: …` | |
| 166 | 216 | | `action` vaut `error` | l'`error` de la réponse, ou `the hook reported an error` | |
| 167 | 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 | 221 | La suite dépend d'`onError` : |
| 172 | 222 | |
| 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 | | |
| 176 | 226 | | `warn` | ligne d'échec, hook suivant | ligne d'échec, hook suivant | ligne d'échec, on continue | |
| 177 | 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 | 231 | ## 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 | 236 | 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 | 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 | 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 | 248 | | `hooks.<événement>[N]: matcher: error parsing regexp: …` | un `matcher` qui ne compile pas | |
| 199 | 249 | | `hooks.<événement>[N]: timeout must be >= 0` | un `timeout` négatif | |
| 200 | 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 | 254 | - [Protéger l'agent avec des hooks](../how-to/guard-the-agent-with-hooks.md) |
| 205 | 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) | |
| @@ -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écution | 59 | ## 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ée | 78 | ## 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 | ### Exemples | 111 | ### Exemples |
| 90 | 112 | ||
| 91 | ```json | 113 | ```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éponse | 125 | ## 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 | ## Échecs | 205 | ## É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émarrage | 231 | ## 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×1 | 236 | 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 | ||
| 219 | 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 | 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 | 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 | 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 | 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 | |
| @@ -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 ( | ||
| 10 | 10 | |
| 11 | 11 | "mm/internal/config" |
| 12 | 12 | "mm/internal/consent" |
| 13 | + "mm/internal/hooks" | |
| 13 | 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 | 90 | label += ", with the conversation" |
| 90 | 91 | } |
| 91 | 92 | 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 | + } | |
| 94 | 108 | if answer != "" && !strings.HasSuffix(answer, "\n") { |
| 95 | 109 | fmt.Fprintln(stdout) |
| 96 | 110 | } |
| 97 | - if err == nil && answer == "" { | |
| 111 | + if err == nil && res.Answer == "" { | |
| 98 | 112 | return errors.New(req.Agent + " gave no answer") |
| 99 | 113 | } |
| 100 | 114 | 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 err | 114 | 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 := ¬icing{} | |
| 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 := ¬icing{} | ||
| 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 ( | ||
| 5 | 5 | "os" |
| 6 | 6 | "os/exec" |
| 7 | 7 | "slices" |
| 8 | - "strconv" | |
| 9 | 8 | "strings" |
| 10 | 9 | |
| 11 | 10 | "mm/internal/config" |
| @@ -13,7 +12,7 @@ import ( | ||
| 13 | 12 | |
| 14 | 13 | // DepthEnv carries how deep in a chain of /ask an agent runs: unset for the |
| 15 | 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 | 17 | // MaxDepth: an agent at this depth may not ask another one. /ask is a USER |
| 19 | 18 | // 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 | 20 | const MaxDepth = 2 |
| 22 | 21 | |
| 23 | 22 | // 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() } | |
| 28 | 24 | |
| 29 | 25 | // CanAsk says whether this agent may ask another one, and why not. |
| 30 | 26 | 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 the | 13 | // 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 USER | 17 | // 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 into | 18 | // 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 = 2 | 20 | 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 { | ||
| 30 | 30 | // command builds the child process for a config file; a test swaps it for |
| 31 | 31 | // a fake agent. |
| 32 | 32 | 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 | 37 | mu sync.Mutex |
| 35 | 38 | 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 for | 30 | // 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.Cmd | 32 | 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.Mutex | 37 | mu sync.Mutex |
| 35 | children map[string]*child | 38 | children map[string]*child |
modified
internal/ask/run.go +4 -4 | @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | |
| 8 | 8 | "mm/internal/command" |
| 9 | 9 | "mm/internal/config" |
| 10 | + "mm/internal/hooks" | |
| 10 | 11 | "mm/internal/ui" |
| 11 | 12 | |
| 12 | 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 | 41 | if !ok { |
| 41 | 42 | return nil, fmt.Errorf("unknown agent %q", req.Agent) |
| 42 | 43 | } |
| 43 | - path := ref.Path | |
| 44 | 44 | transcript := "" |
| 45 | 45 | if req.WithContext { |
| 46 | 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 | 49 | if err != nil { |
| 50 | 50 | return nil, err |
| 51 | 51 | } |
| 52 | - if answer == "" { | |
| 52 | + if res.Answer == "" { | |
| 53 | 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), nil | |
| 55 | + return Exchange(req.Agent, Describe(ref.Path), req.WithContext, res.Question, res.Text(), config.Cfg.MaxOutput), nil | |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | // 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, err | 50 | 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), nil | 55 | + 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 is | 58 | // 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 | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "fmt" |
| 5 | + "os" | |
| 5 | 6 | "path/filepath" |
| 6 | 7 | "regexp" |
| 7 | 8 | "sort" |
| 9 | + "strconv" | |
| 8 | 10 | "strings" |
| 9 | 11 | |
| 10 | 12 | "gopkg.in/yaml.v3" |
| @@ -14,6 +16,19 @@ import ( | ||
| 14 | 16 | // run ("off"). The /ask machinery sets it on the agents it starts. |
| 15 | 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 | 32 | // agentName is the format of an mmConfigs name: it becomes the tail of a |
| 18 | 33 | // command name (/ask-bob), so it must be typable, unambiguous after a dash, |
| 19 | 34 | // 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 a | 32 | // 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 ( | ||
| 11 | 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 of | |
| 14 | +// The moments a hook can run at (tickets 36 and 87). The names are the keys of | |
| 15 | 15 | // the `hooks:` block AND the "event" field of the JSON a hook reads. |
| 16 | 16 | const ( |
| 17 | 17 | // HookPreGeneration runs once per user question, before the first call |
| @@ -26,6 +26,13 @@ const ( | ||
| 26 | 26 | // HookPostGeneration runs once the turn is over, success or failure. It |
| 27 | 27 | // only observes. |
| 28 | 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 | 38 | // The three values of Hook.OnError: what a failing hook (non-zero exit, |
| @@ -49,7 +56,7 @@ const ( | ||
| 49 | 56 | const DefaultHookTimeout = 10 * time.Second |
| 50 | 57 | |
| 51 | 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 | 61 | // HooksConfig is the `hooks:` block of the YAML: for each event, the hooks |
| 55 | 62 | // to run, in the order they are written. |
| @@ -64,6 +71,8 @@ type HooksConfig struct { | ||
| 64 | 71 | PreToolCall []Hook `yaml:"preToolCall"` |
| 65 | 72 | PostToolCall []Hook `yaml:"postToolCall"` |
| 66 | 73 | PostGeneration []Hook `yaml:"postGeneration"` |
| 74 | + PreAsk []Hook `yaml:"preAsk"` | |
| 75 | + PostAsk []Hook `yaml:"postAsk"` | |
| 67 | 76 | |
| 68 | 77 | // ConfigDir is the directory of the YAML file that declared the hooks, |
| 69 | 78 | // absolute, set by Load. The hooks read it as MM_CONFIG_DIR: a hook |
| @@ -77,8 +86,9 @@ type Hook struct { | ||
| 77 | 86 | // Command is run with `sh -c`, in the session's working directory. |
| 78 | 87 | Command string `yaml:"command"` |
| 79 | 88 | // 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. | |
| 82 | 92 | Matcher string `yaml:"matcher"` |
| 83 | 93 | // Timeout bounds one run of the hook. 0 = DefaultHookTimeout. |
| 84 | 94 | Timeout time.Duration `yaml:"timeout"` |
| @@ -116,6 +126,10 @@ func (h *HooksConfig) slot(event string) *[]Hook { | ||
| 116 | 126 | return &h.PostToolCall |
| 117 | 127 | case HookPostGeneration: |
| 118 | 128 | return &h.PostGeneration |
| 129 | + case HookPreAsk: | |
| 130 | + return &h.PreAsk | |
| 131 | + case HookPostAsk: | |
| 132 | + return &h.PostAsk | |
| 119 | 133 | } |
| 120 | 134 | return nil |
| 121 | 135 | } |
| @@ -146,21 +160,22 @@ func (h HooksConfig) Summary() string { | ||
| 146 | 160 | return strings.Join(parts, " ") |
| 147 | 161 | } |
| 148 | 162 | |
| 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 { | |
| 154 | 169 | if h.Matcher == "" { |
| 155 | 170 | return true |
| 156 | 171 | } |
| 157 | 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 | 176 | // 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. | |
| 164 | 179 | func (h HooksConfig) validate() error { |
| 165 | 180 | for _, event := range hookEvents { |
| 166 | 181 | for i, hook := range h.For(event) { |
| @@ -173,12 +188,12 @@ func (h HooksConfig) validate() error { | ||
| 173 | 188 | } |
| 174 | 189 | |
| 175 | 190 | func (h Hook) validate(event string) error { |
| 176 | - toolEvent := event == HookPreToolCall || event == HookPostToolCall | |
| 191 | + named := event != HookPreGeneration && event != HookPostGeneration | |
| 177 | 192 | switch { |
| 178 | 193 | case strings.TrimSpace(h.Command) == "": |
| 179 | 194 | 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) | |
| 182 | 197 | case h.Timeout < 0: |
| 183 | 198 | return errors.New("timeout must be >= 0") |
| 184 | 199 | } |
| @@ -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 of | 14 | +// 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 call | 17 | // 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. It | 26 | // 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.Second | 56 | 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 hooks | 61 | // 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 hook | 78 | // 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 and | 88 | // 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.PostToolCall | 126 | return &h.PostToolCall |
| 117 | case HookPostGeneration: | 127 | case HookPostGeneration: |
| 118 | return &h.PostGeneration | 128 | return &h.PostGeneration |
| 129 | + case HookPreAsk: | ||
| 130 | + return &h.PreAsk | ||
| 131 | + case HookPostAsk: | ||
| 132 | + return &h.PostAsk | ||
| 119 | } | 133 | } |
| 120 | return nil | 134 | 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 a | 163 | +// 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 not | 164 | +// 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) matches | 165 | +// 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 true | 170 | 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 tool | 176 | // 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 | 177 | +// 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 == HookPostToolCall | 191 | + 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: | ||
| 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 | 89 | // Each mistake is refused at start-up with the entry it is in, rather than |
| 60 | 90 | // discovered when the hook silently does not run. |
| 61 | 91 | func TestInvalidHooksAreRejected(t *testing.T) { |
| @@ -64,6 +94,10 @@ func TestInvalidHooksAreRejected(t *testing.T) { | ||
| 64 | 94 | "hooks:\n preToolcall:\n - command: x\n", `unknown event "preToolcall"`}, |
| 65 | 95 | "empty command": { |
| 66 | 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 | 101 | "matcher outside tool events": { |
| 68 | 102 | "hooks:\n preGeneration:\n - command: x\n matcher: bash\n", "matcher is only for"}, |
| 69 | 103 | "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 than | 89 | // 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 ( | ||
| 30 | 30 | // because it holds a secret must not have it sent on the NEXT turn. |
| 31 | 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 | 33 | // generateWithHooks wraps generate with the two generation events. Nothing |
| 39 | 34 | // here runs when no hook is declared. |
| 40 | 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 | 126 | parts = append(parts, ai.NewTextPart(d.Prompt)) |
| 132 | 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 | 130 | return ai.NewUserMessage(parts...) |
| 136 | 131 | } |
| @@ -186,7 +181,7 @@ func (e *Engine) runToolHooks(ctx context.Context, params *ai.ToolParams, next a | ||
| 186 | 181 | in.Tool.Input = d.Input |
| 187 | 182 | in.Output = outputText(resp.Output) |
| 188 | 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 | 186 | return resp, nil |
| 192 | 187 | } |
| @@ -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. Nothing | 33 | // 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.Input | 181 | 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, nil | 186 | return resp, nil |
| 192 | } | 187 | } |
modified
internal/engine/hooks_test.go +2 -2 | @@ -102,7 +102,7 @@ func TestPreGenerationRewritesWhatTheModelReads(t *testing.T) { | ||
| 102 | 102 | t.Fatal(err) |
| 103 | 103 | } |
| 104 | 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 | 106 | t.Errorf("the model read %q", read) |
| 107 | 107 | } |
| 108 | 108 | if full[0].Text() != read { |
| @@ -207,7 +207,7 @@ func TestPostToolCallAddsContextToTheOutput(t *testing.T) { | ||
| 207 | 207 | if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("test")}, []ai.ToolRef{ai.ToolName("bash")}); err != nil { |
| 208 | 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 | 211 | t.Errorf("the model read %q, want %q", got, want) |
| 212 | 212 | } |
| 213 | 213 | } |
| @@ -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() { | ||
| 62 | 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 | 97 | // A failing hook with onError warn is reported through the notify function |
| 66 | 98 | // and the turn goes on. |
| 67 | 99 | 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 function | 97 | // 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, | ||
| 46 | 46 | // "continue". Every failure — the hook's exit, its timeout, an unreadable |
| 47 | 47 | // reply, an unknown action, an explicit "error" — comes back as an error. |
| 48 | 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 | 52 | stdin, err := encode(in) |
| 50 | 53 | if err != nil { |
| 51 | 54 | return Reply{}, fmt.Errorf("cannot encode the input: %w", err) |
| @@ -109,7 +112,7 @@ func checkAction(reply Reply, event string) error { | ||
| 109 | 112 | return nil |
| 110 | 113 | } |
| 111 | 114 | case ActionDeny: |
| 112 | - if event == config.HookPreToolCall { | |
| 115 | + if event == config.HookPreToolCall || event == config.HookPreAsk { | |
| 113 | 116 | return nil |
| 114 | 117 | } |
| 115 | 118 | } |
| @@ -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 unreadable | 46 | // "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 nil | 112 | 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 nil | 116 | 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 | |
| 3 | 4 | // engine applies. |
| 4 | 5 | // |
| 5 | 6 | // A hook is any program: it reads ONE JSON object on stdin (Input) and may |
| @@ -30,14 +31,20 @@ const ( | ||
| 30 | 31 | ActionContinue = "continue" |
| 31 | 32 | // ActionStop ends the turn before the model is called (preGeneration). |
| 32 | 33 | 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. | |
| 35 | 37 | ActionDeny = "deny" |
| 36 | 38 | // ActionError is the hook saying it failed: handled like a crash, |
| 37 | 39 | // according to its onError. |
| 38 | 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 | 48 | // Input is the JSON object a hook reads on stdin. Which fields are filled |
| 42 | 49 | // depends on the event: |
| 43 | 50 | // |
| @@ -45,15 +52,22 @@ const ( | ||
| 45 | 52 | // preToolCall event, cwd, model, tool |
| 46 | 53 | // postToolCall event, cwd, model, tool, output |
| 47 | 54 | // 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 | 59 | 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"` | |
| 57 | 71 | } |
| 58 | 72 | |
| 59 | 73 | // ToolCall describes the tool call a preToolCall or postToolCall hook is |
| @@ -68,10 +82,18 @@ type ToolCall struct { | ||
| 68 | 82 | // optional; which ones are read depends on the event (see the decisions). |
| 69 | 83 | type Reply struct { |
| 70 | 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 | 87 | 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). | |
| 75 | 97 | Context string `json:"context,omitempty"` |
| 76 | 98 | // Input replaces the tool call's arguments (preToolCall). Absent = kept. |
| 77 | 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 | 279 | // 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. | |
| 261 | 283 | func (r *Runner) failed(h config.Hook, event string, err error) (stop bool) { |
| 262 | 284 | if h.OnError == config.HookOnErrorIgnore { |
| 263 | 285 | return false |
| 264 | 286 | } |
| 265 | 287 | 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 | |
| 267 | 289 | return pre && h.OnError == config.HookOnErrorFail |
| 268 | 290 | } |
| @@ -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 the | 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 | ||
| 3 | // engine applies. | 4 | // engine applies. |
| 4 | // | 5 | // |
| 5 | // A hook is any program: it reads ONE JSON object on stdin (Input) and may | 6 | // 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 the | 34 | + // 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 filled | 48 | // 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, tool | 52 | // preToolCall event, cwd, model, tool |
| 46 | // postToolCall event, cwd, model, tool, output | 53 | // postToolCall event, cwd, model, tool, output |
| 47 | // postGeneration event, cwd, model, prompt, answer, error | 54 | // 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 is | 73 | // 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 question | 88 | + // 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 whether | 279 | // 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 is | 281 | +// 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 false | 285 | 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.HookPreToolCall | 288 | + pre := event == config.HookPreGeneration || event == config.HookPreToolCall || event == config.HookPreAsk |
| 267 | return pre && h.OnError == config.HookOnErrorFail | 289 | return pre && h.OnError == config.HookOnErrorFail |
| 268 | } | 290 | } |
modified
internal/tools/askagent.go +30 -11 | @@ -12,6 +12,7 @@ import ( | ||
| 12 | 12 | "mm/internal/detector" |
| 13 | 13 | "mm/internal/elicit" |
| 14 | 14 | "mm/internal/engine" |
| 15 | + "mm/internal/hooks" | |
| 15 | 16 | "mm/internal/spinner" |
| 16 | 17 | "mm/internal/ui" |
| 17 | 18 | |
| @@ -37,9 +38,10 @@ type askAgentInput struct { | ||
| 37 | 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 | 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 | 47 | // 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 | 102 | if in.WithContext { |
| 101 | 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 | 106 | id := ask.Identify(ref.Path) |
| 105 | 107 | title := fmt.Sprintf("ask_agent: %s (%s)", in.Agent, ask.Describe(ref.Path)) |
| 106 | 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 | 112 | announce("🗣 " + title + " — " + firstLine(in.Prompt)) |
| @@ -113,17 +115,21 @@ func (call askAgentCall) run(ctx context.Context, in askAgentInput) (string, err | ||
| 113 | 115 | } |
| 114 | 116 | spinner.Stop() |
| 115 | 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 | 123 | fmt.Println() |
| 118 | 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 | 128 | // evented is ask_agent for an event-consuming front end (the window, an |
| 123 | 129 | // editor): one tool call for the hand-off, the asked agent's own events |
| 124 | 130 | // inside it, and — for a sink that is a ui.Speaker — the asked agent named |
| 125 | 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 | 133 | key := in.Agent + ": " + in.Prompt |
| 128 | 134 | ev := ui.ToolEvent{ |
| 129 | 135 | ID: ui.NextCallID(), |
| @@ -142,16 +148,19 @@ func (call askAgentCall) evented(ctx context.Context, s ui.Sink, in askAgentInpu | ||
| 142 | 148 | if sp, ok := s.(ui.Speaker); ok { |
| 143 | 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 | 152 | if sp, ok := s.(ui.Speaker); ok { |
| 147 | 153 | sp.Spoken() |
| 148 | 154 | } |
| 149 | - out := askAgentResult(in.Agent, answer, err) | |
| 155 | + out := handOffResult(in.Agent, res, err) | |
| 150 | 156 | // The answer has streamed already: the call's own output only says how |
| 151 | 157 | // 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) == "": | |
| 154 | 161 | 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 | 165 | s.ToolEnd(ev.ID, ui.ToolResult{Output: summary, Failed: err != nil}) |
| 157 | 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 | 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 | 220 | // askAgentResult is what the model reads: the answer (capped like any tool |
| 202 | 221 | // output), or why there is none. |
| 203 | 222 | 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. The | 47 | // 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, an | 128 | // 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 events | 129 | // 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 named | 130 | // 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.Prompt | 133 | 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 how | 156 | // 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 = out | 161 | 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 tool | 220 | // 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 ( | ||
| 10 | 10 | "mm/internal/config" |
| 11 | 11 | "mm/internal/detector" |
| 12 | 12 | "mm/internal/engine" |
| 13 | + "mm/internal/hooks" | |
| 13 | 14 | "mm/internal/ui" |
| 14 | 15 | |
| 15 | 16 | "github.com/firebase/genkit/go/ai" |
| 16 | 17 | ) |
| 17 | 18 | |
| 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). | |
| 20 | 22 | type fakeAgentAsker struct { |
| 21 | 23 | answer string |
| 22 | 24 | 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 | |
| 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 | 34 | 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 | + } | |
| 30 | 39 | sink.Text(a.answer) |
| 31 | - return a.answer, a.err | |
| 40 | + return ask.Result{Question: req.Question, Answer: a.answer}, a.err | |
| 32 | 41 | } |
| 33 | 42 | |
| 34 | 43 | // speakingSink is a recordingSink that also records who it was told speaks. |
| @@ -77,7 +86,7 @@ func TestAskAgentEvented(t *testing.T) { | ||
| 77 | 86 | a := &fakeAgentAsker{answer: "Implemented R1 and R2."} |
| 78 | 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 | 90 | if err != nil || out != "Implemented R1 and R2." { |
| 82 | 91 | t.Fatalf("result %q, err %v", out, err) |
| 83 | 92 | } |
| @@ -98,7 +107,7 @@ func TestAskAgentRefused(t *testing.T) { | ||
| 98 | 107 | withAgents(t, map[string]config.AgentRef{"homer": {Path: "/nowhere/homer.yaml"}}) |
| 99 | 108 | a := &fakeAgentAsker{answer: "never"} |
| 100 | 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 | 111 | if a.calls != 0 || out != "[ask_agent rejected by the user]" || sink.asked != 1 || len(sink.spoke) != 0 { |
| 103 | 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 | 213 | // yoloOn sets yolo on for one test. |
| 156 | 214 | func yoloOn(t *testing.T) { |
| 157 | 215 | 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 and | 19 | +// 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 string | 23 | answer string |
| 22 | err error | 24 | err error |
| 23 | - got struct{ name, path, cwd, prompt string } | 25 | + result *ask.Result |
| 24 | - calls int | 26 | + 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, 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 | + } | ||
| 30 | sink.Text(a.answer) | 39 | sink.Text(a.answer) |
| 31 | - return a.answer, a.err | 40 | + 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() |