added
.tickets/issues/0200-demo-an-agent-that-uses-the-forgejo-api.yaml +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 200 | |
| 3 | +title: "Demo: an agent that uses the Forgejo API" | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-11-10T12:58:06.000Z | |
| 9 | +updatedAt: 2026-11-10T12:58:06.000Z | |
| 10 | +labels: | |
| 11 | + - kind::feature | |
| 12 | +body: | | |
| 13 | + A demo of a Mini Me agent that works with a [Forgejo](https://forgejo.org/) | |
| 14 | + instance through its REST API: list and read issues, comment on them, label | |
| 15 | + them, read a pull request, etc. | |
| 16 | + | |
| 17 | + Everything runs locally: Forgejo in Docker, a local model, `mm`. | |
| 18 | + | |
| 19 | + ## Leads | |
| 20 | + | |
| 21 | + - **Forgejo locally**: the official Docker image | |
| 22 | + (`codeberg.org/forgejo/forgejo`), one admin user, one bot user, one demo | |
| 23 | + repository with a few issues. | |
| 24 | + - **The API**: `http://localhost:3000/api/v1/...`, token authentication | |
| 25 | + (`Authorization: token <TOKEN>`), Swagger at `/api/swagger`. | |
| 26 | + - **How the agent calls it**: small skills (one per action: list issues, | |
| 27 | + read an issue, comment, add a label) each with one `curl` command, in the | |
| 28 | + style of `blog/demo-decision-skills`. Also check whether a Forgejo MCP | |
| 29 | + server exists and is usable with `mcp:` (to compare with the skills). | |
| 30 | + - **The token**: read from an environment variable, never written in a | |
| 31 | + skill or in the config. In `sbx`, it can be injected by the proxy like | |
| 32 | + `CONTEXT7_API_KEY` (see `blog/20261108-mm-secure.fr.md`). | |
| 33 | + | |
| 34 | + ## Expected outcome | |
| 35 | + | |
| 36 | + A demo folder (`compose.yaml` for Forgejo, a setup script that creates the | |
| 37 | + users, token and repository, the agent config, the skills) with a README | |
| 38 | + EN + FR and measured results on a small local model. It is the base of the | |
| 39 | + webhook bot ticket. | |
| 40 | +tasks: | |
| 41 | + - id: 1 | |
| 42 | + title: Run Forgejo locally with Docker Compose | |
| 43 | + state: open | |
| 44 | + priority: medium | |
| 45 | + author: | |
| 46 | + name: k33g | |
| 47 | + email: ph.charriere@gmail.com | |
| 48 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 49 | + - id: 2 | |
| 50 | + title: Setup script (admin, bot user, token, demo repository and issues) | |
| 51 | + state: open | |
| 52 | + priority: medium | |
| 53 | + author: | |
| 54 | + name: k33g | |
| 55 | + email: ph.charriere@gmail.com | |
| 56 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 57 | + - id: 3 | |
| 58 | + title: Write the Forgejo skills (curl on the REST API) | |
| 59 | + state: open | |
| 60 | + priority: high | |
| 61 | + author: | |
| 62 | + name: k33g | |
| 63 | + email: ph.charriere@gmail.com | |
| 64 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 65 | + - id: 4 | |
| 66 | + title: Compare with a Forgejo MCP server, if one exists | |
| 67 | + state: open | |
| 68 | + priority: low | |
| 69 | + author: | |
| 70 | + name: k33g | |
| 71 | + email: ph.charriere@gmail.com | |
| 72 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 73 | + - id: 5 | |
| 74 | + title: Measure the agent on a few tasks with a small local model | |
| 75 | + state: open | |
| 76 | + priority: medium | |
| 77 | + author: | |
| 78 | + name: k33g | |
| 79 | + email: ph.charriere@gmail.com | |
| 80 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 81 | + - id: 6 | |
| 82 | + title: Document the demo (README EN + FR) | |
| 83 | + state: open | |
| 84 | + priority: low | |
| 85 | + author: | |
| 86 | + name: k33g | |
| 87 | + email: ph.charriere@gmail.com | |
| 88 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 89 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 200 | ||
| 3 | +title: "Demo: an agent that uses the Forgejo API" | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-11-10T12:58:06.000Z | ||
| 9 | +updatedAt: 2026-11-10T12:58:06.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::feature | ||
| 12 | +body: | | ||
| 13 | + A demo of a Mini Me agent that works with a [Forgejo](https://forgejo.org/) | ||
| 14 | + instance through its REST API: list and read issues, comment on them, label | ||
| 15 | + them, read a pull request, etc. | ||
| 16 | + | ||
| 17 | + Everything runs locally: Forgejo in Docker, a local model, `mm`. | ||
| 18 | + | ||
| 19 | + ## Leads | ||
| 20 | + | ||
| 21 | + - **Forgejo locally**: the official Docker image | ||
| 22 | + (`codeberg.org/forgejo/forgejo`), one admin user, one bot user, one demo | ||
| 23 | + repository with a few issues. | ||
| 24 | + - **The API**: `http://localhost:3000/api/v1/...`, token authentication | ||
| 25 | + (`Authorization: token <TOKEN>`), Swagger at `/api/swagger`. | ||
| 26 | + - **How the agent calls it**: small skills (one per action: list issues, | ||
| 27 | + read an issue, comment, add a label) each with one `curl` command, in the | ||
| 28 | + style of `blog/demo-decision-skills`. Also check whether a Forgejo MCP | ||
| 29 | + server exists and is usable with `mcp:` (to compare with the skills). | ||
| 30 | + - **The token**: read from an environment variable, never written in a | ||
| 31 | + skill or in the config. In `sbx`, it can be injected by the proxy like | ||
| 32 | + `CONTEXT7_API_KEY` (see `blog/20261108-mm-secure.fr.md`). | ||
| 33 | + | ||
| 34 | + ## Expected outcome | ||
| 35 | + | ||
| 36 | + A demo folder (`compose.yaml` for Forgejo, a setup script that creates the | ||
| 37 | + users, token and repository, the agent config, the skills) with a README | ||
| 38 | + EN + FR and measured results on a small local model. It is the base of the | ||
| 39 | + webhook bot ticket. | ||
| 40 | +tasks: | ||
| 41 | + - id: 1 | ||
| 42 | + title: Run Forgejo locally with Docker Compose | ||
| 43 | + state: open | ||
| 44 | + priority: medium | ||
| 45 | + author: | ||
| 46 | + name: k33g | ||
| 47 | + email: ph.charriere@gmail.com | ||
| 48 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 49 | + - id: 2 | ||
| 50 | + title: Setup script (admin, bot user, token, demo repository and issues) | ||
| 51 | + state: open | ||
| 52 | + priority: medium | ||
| 53 | + author: | ||
| 54 | + name: k33g | ||
| 55 | + email: ph.charriere@gmail.com | ||
| 56 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 57 | + - id: 3 | ||
| 58 | + title: Write the Forgejo skills (curl on the REST API) | ||
| 59 | + state: open | ||
| 60 | + priority: high | ||
| 61 | + author: | ||
| 62 | + name: k33g | ||
| 63 | + email: ph.charriere@gmail.com | ||
| 64 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 65 | + - id: 4 | ||
| 66 | + title: Compare with a Forgejo MCP server, if one exists | ||
| 67 | + state: open | ||
| 68 | + priority: low | ||
| 69 | + author: | ||
| 70 | + name: k33g | ||
| 71 | + email: ph.charriere@gmail.com | ||
| 72 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 73 | + - id: 5 | ||
| 74 | + title: Measure the agent on a few tasks with a small local model | ||
| 75 | + state: open | ||
| 76 | + priority: medium | ||
| 77 | + author: | ||
| 78 | + name: k33g | ||
| 79 | + email: ph.charriere@gmail.com | ||
| 80 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 81 | + - id: 6 | ||
| 82 | + title: Document the demo (README EN + FR) | ||
| 83 | + state: open | ||
| 84 | + priority: low | ||
| 85 | + author: | ||
| 86 | + name: k33g | ||
| 87 | + email: ph.charriere@gmail.com | ||
| 88 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 89 | +comments: [] | ||
added
.tickets/issues/0201-a-forgejo-bot-triggered-by-webhooks.yaml +102 -0 | new file mode 100644 | ||
| @@ -0,0 +1,102 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 201 | |
| 3 | +title: A Forgejo bot triggered by webhooks | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-11-10T12:58:06.000Z | |
| 9 | +updatedAt: 2026-11-10T12:58:06.000Z | |
| 10 | +labels: | |
| 11 | + - kind::feature | |
| 12 | +related: | |
| 13 | + - 200 | |
| 14 | +body: | | |
| 15 | + A Mini Me bot driven by [Forgejo](https://forgejo.org/) webhooks: when | |
| 16 | + something happens in a repository (an issue is opened, a comment mentions | |
| 17 | + the bot, a pull request is opened), Forgejo calls a small local receiver, | |
| 18 | + the receiver hands the event to `mm`, and the agent **answers through the | |
| 19 | + Forgejo API** (comment, label...). | |
| 20 | + | |
| 21 | + Builds on [#200 — Demo: an agent that uses the Forgejo API](.tickets/issues/0200-demo-an-agent-that-uses-the-forgejo-api.yaml): | |
| 22 | + same local Forgejo, same bot user, same skills. | |
| 23 | + | |
| 24 | + The demo runs **locally**. | |
| 25 | + | |
| 26 | + ## Flow | |
| 27 | + | |
| 28 | + 1. Forgejo sends the webhook (JSON payload) to the receiver. | |
| 29 | + 2. The receiver checks the signature, filters the event, and builds the | |
| 30 | + prompt (repository, issue number, text). | |
| 31 | + 3. `mm` runs (one process per event, or an ACP session per issue). | |
| 32 | + 4. The agent reads the issue and answers with the API skills of #200. | |
| 33 | + | |
| 34 | + ## Points to check | |
| 35 | + | |
| 36 | + - **Local webhooks**: by default Forgejo may refuse to deliver webhooks to | |
| 37 | + local or private addresses; check the `[webhook] ALLOWED_HOST_LIST` | |
| 38 | + setting. Forgejo in Docker reaches the receiver on the host through | |
| 39 | + `host.docker.internal` (or put both in the same Compose network). | |
| 40 | + - **Signature**: verify the HMAC of the payload with the webhook secret | |
| 41 | + (check the header name Forgejo uses). | |
| 42 | + - **Loops**: the bot must ignore events it caused itself (its own comments). | |
| 43 | + - **Slow answers**: Forgejo does not wait for the agent; answer the webhook | |
| 44 | + at once and run the agent in the background. | |
| 45 | + - **Safety**: the bot reacts to text written by other people: restricted | |
| 46 | + tools (no edit tools, no free `bash`, only the API skills), a token with | |
| 47 | + the smallest scope, and possibly `sbx`. | |
| 48 | + | |
| 49 | + ## Expected outcome | |
| 50 | + | |
| 51 | + A demo folder (receiver, Forgejo webhook setup, agent config) with a README | |
| 52 | + EN + FR, and possibly a blog post. | |
| 53 | +tasks: | |
| 54 | + - id: 1 | |
| 55 | + title: Write the webhook receiver (signature, event filter, prompt) | |
| 56 | + state: open | |
| 57 | + priority: high | |
| 58 | + author: | |
| 59 | + name: k33g | |
| 60 | + email: ph.charriere@gmail.com | |
| 61 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 62 | + - id: 2 | |
| 63 | + title: Configure the webhook in Forgejo (allowed hosts, secret, events) | |
| 64 | + state: open | |
| 65 | + priority: medium | |
| 66 | + author: | |
| 67 | + name: k33g | |
| 68 | + email: ph.charriere@gmail.com | |
| 69 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 70 | + - id: 3 | |
| 71 | + title: Run mm on each event and answer through the API | |
| 72 | + state: open | |
| 73 | + priority: high | |
| 74 | + author: | |
| 75 | + name: k33g | |
| 76 | + email: ph.charriere@gmail.com | |
| 77 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 78 | + - id: 4 | |
| 79 | + title: Avoid loops (ignore the bot's own events) | |
| 80 | + state: open | |
| 81 | + priority: medium | |
| 82 | + author: | |
| 83 | + name: k33g | |
| 84 | + email: ph.charriere@gmail.com | |
| 85 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 86 | + - id: 5 | |
| 87 | + title: Lock it down (restricted tools, token scope, sbx) | |
| 88 | + state: open | |
| 89 | + priority: high | |
| 90 | + author: | |
| 91 | + name: k33g | |
| 92 | + email: ph.charriere@gmail.com | |
| 93 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 94 | + - id: 6 | |
| 95 | + title: Document the demo (README EN + FR) | |
| 96 | + state: open | |
| 97 | + priority: low | |
| 98 | + author: | |
| 99 | + name: k33g | |
| 100 | + email: ph.charriere@gmail.com | |
| 101 | + createdAt: 2026-11-10T12:58:06.000Z | |
| 102 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,102 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 201 | ||
| 3 | +title: A Forgejo bot triggered by webhooks | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-11-10T12:58:06.000Z | ||
| 9 | +updatedAt: 2026-11-10T12:58:06.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::feature | ||
| 12 | +related: | ||
| 13 | + - 200 | ||
| 14 | +body: | | ||
| 15 | + A Mini Me bot driven by [Forgejo](https://forgejo.org/) webhooks: when | ||
| 16 | + something happens in a repository (an issue is opened, a comment mentions | ||
| 17 | + the bot, a pull request is opened), Forgejo calls a small local receiver, | ||
| 18 | + the receiver hands the event to `mm`, and the agent **answers through the | ||
| 19 | + Forgejo API** (comment, label...). | ||
| 20 | + | ||
| 21 | + Builds on [#200 — Demo: an agent that uses the Forgejo API](.tickets/issues/0200-demo-an-agent-that-uses-the-forgejo-api.yaml): | ||
| 22 | + same local Forgejo, same bot user, same skills. | ||
| 23 | + | ||
| 24 | + The demo runs **locally**. | ||
| 25 | + | ||
| 26 | + ## Flow | ||
| 27 | + | ||
| 28 | + 1. Forgejo sends the webhook (JSON payload) to the receiver. | ||
| 29 | + 2. The receiver checks the signature, filters the event, and builds the | ||
| 30 | + prompt (repository, issue number, text). | ||
| 31 | + 3. `mm` runs (one process per event, or an ACP session per issue). | ||
| 32 | + 4. The agent reads the issue and answers with the API skills of #200. | ||
| 33 | + | ||
| 34 | + ## Points to check | ||
| 35 | + | ||
| 36 | + - **Local webhooks**: by default Forgejo may refuse to deliver webhooks to | ||
| 37 | + local or private addresses; check the `[webhook] ALLOWED_HOST_LIST` | ||
| 38 | + setting. Forgejo in Docker reaches the receiver on the host through | ||
| 39 | + `host.docker.internal` (or put both in the same Compose network). | ||
| 40 | + - **Signature**: verify the HMAC of the payload with the webhook secret | ||
| 41 | + (check the header name Forgejo uses). | ||
| 42 | + - **Loops**: the bot must ignore events it caused itself (its own comments). | ||
| 43 | + - **Slow answers**: Forgejo does not wait for the agent; answer the webhook | ||
| 44 | + at once and run the agent in the background. | ||
| 45 | + - **Safety**: the bot reacts to text written by other people: restricted | ||
| 46 | + tools (no edit tools, no free `bash`, only the API skills), a token with | ||
| 47 | + the smallest scope, and possibly `sbx`. | ||
| 48 | + | ||
| 49 | + ## Expected outcome | ||
| 50 | + | ||
| 51 | + A demo folder (receiver, Forgejo webhook setup, agent config) with a README | ||
| 52 | + EN + FR, and possibly a blog post. | ||
| 53 | +tasks: | ||
| 54 | + - id: 1 | ||
| 55 | + title: Write the webhook receiver (signature, event filter, prompt) | ||
| 56 | + state: open | ||
| 57 | + priority: high | ||
| 58 | + author: | ||
| 59 | + name: k33g | ||
| 60 | + email: ph.charriere@gmail.com | ||
| 61 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 62 | + - id: 2 | ||
| 63 | + title: Configure the webhook in Forgejo (allowed hosts, secret, events) | ||
| 64 | + state: open | ||
| 65 | + priority: medium | ||
| 66 | + author: | ||
| 67 | + name: k33g | ||
| 68 | + email: ph.charriere@gmail.com | ||
| 69 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 70 | + - id: 3 | ||
| 71 | + title: Run mm on each event and answer through the API | ||
| 72 | + state: open | ||
| 73 | + priority: high | ||
| 74 | + author: | ||
| 75 | + name: k33g | ||
| 76 | + email: ph.charriere@gmail.com | ||
| 77 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 78 | + - id: 4 | ||
| 79 | + title: Avoid loops (ignore the bot's own events) | ||
| 80 | + state: open | ||
| 81 | + priority: medium | ||
| 82 | + author: | ||
| 83 | + name: k33g | ||
| 84 | + email: ph.charriere@gmail.com | ||
| 85 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 86 | + - id: 5 | ||
| 87 | + title: Lock it down (restricted tools, token scope, sbx) | ||
| 88 | + state: open | ||
| 89 | + priority: high | ||
| 90 | + author: | ||
| 91 | + name: k33g | ||
| 92 | + email: ph.charriere@gmail.com | ||
| 93 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 94 | + - id: 6 | ||
| 95 | + title: Document the demo (README EN + FR) | ||
| 96 | + state: open | ||
| 97 | + priority: low | ||
| 98 | + author: | ||
| 99 | + name: k33g | ||
| 100 | + email: ph.charriere@gmail.com | ||
| 101 | + createdAt: 2026-11-10T12:58:06.000Z | ||
| 102 | +comments: [] | ||
added
.tickets/issues/0202-acp-custom-extensions-exposed-by-the-client-as-a.yaml +145 -0 | new file mode 100644 | ||
| @@ -0,0 +1,145 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 202 | |
| 3 | +title: "ACP: custom extensions exposed by the client as agent tools" | |
| 4 | +state: closed | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-11-10T21:56:01.000Z | |
| 9 | +updatedAt: 2026-11-10T23:30:00.000Z | |
| 10 | +labels: | |
| 11 | + - kind::feature | |
| 12 | + - tools | |
| 13 | +milestone: v1.0.6 | |
| 14 | +body: | | |
| 15 | + In ACP mode (`mm -acp`), the agent must **discover** the custom extensions | |
| 16 | + the ACP client exposes, and **call them like any other tool**. Discovery | |
| 17 | + and tool creation are **dynamic**: nothing is declared in `agent.yaml`, the | |
| 18 | + tools come from the client. Only in ACP mode: the REPL and the window are | |
| 19 | + not concerned. | |
| 20 | + | |
| 21 | + ## What ACP says ([Extensibility](https://agentclientprotocol.com/protocol/extensibility)) | |
| 22 | + | |
| 23 | + - A custom method name starts with `_`, with a reverse-DNS prefix: | |
| 24 | + `_com.example/workspace/buffers`. | |
| 25 | + - A custom request has an `id` and expects an answer; an unknown method | |
| 26 | + answers `-32601 Method not found`. | |
| 27 | + - Extensions are advertised in the `_meta` object of the capabilities | |
| 28 | + exchanged at `initialize` (`clientCapabilities._meta`, keys of the form | |
| 29 | + `com.example/<name>`). | |
| 30 | + - **ACP defines no way to describe the parameters of a custom method.** | |
| 31 | + mm has to define its own convention for the description (name, | |
| 32 | + description, JSON schema of the arguments). | |
| 33 | + | |
| 34 | + ## Proposal (to validate) | |
| 35 | + | |
| 36 | + 1. **Discovery**, two options to choose from: | |
| 37 | + - a. the client lists its tools in `clientCapabilities._meta` under a | |
| 38 | + mini-me key, e.g. `"org.mini-me/tools": [{ "name", "method", | |
| 39 | + "description", "inputSchema" }]`; | |
| 40 | + - b. the client only advertises `"org.mini-me/tools": true`, and mm | |
| 41 | + asks for the list with a custom request (`_org.mini-me/tools/list`, | |
| 42 | + MCP-like) at `session/new` (and `session/load`), so the list can | |
| 43 | + change between sessions. | |
| 44 | + 2. **Tool creation**: each entry becomes a tool of the session (Genkit | |
| 45 | + tool built at run time), named with a prefix to avoid collisions with | |
| 46 | + the built-in tools and MCP tools (e.g. `client_<name>`), and passed | |
| 47 | + through the same sorting, `toolsets` / allow-list rules and decision | |
| 48 | + model tool routing as the other tools. | |
| 49 | + 3. **Call**: the tool sends the custom request `method` to the client | |
| 50 | + with the model's arguments as `params`, and returns the result (JSON | |
| 51 | + or text) to the model. Errors (`-32601`, timeout, cancellation of the | |
| 52 | + turn) come back to the model as tool errors, never as a crash. | |
| 53 | + 4. **Permissions**: same consent rules as the other tools (`yolo`, | |
| 54 | + permission request to the client when not allowed). | |
| 55 | + | |
| 56 | + ## Points to check | |
| 57 | + | |
| 58 | + - acp-go-sdk v0.13.5: `ClientCapabilities.Meta` exists, and the generic | |
| 59 | + `SendRequest` exists on `Connection`, but `AgentSideConnection` keeps | |
| 60 | + its connection unexported: check how the agent side can send a custom | |
| 61 | + request (exported API, SDK upgrade, or a small wrapper). | |
| 62 | + - Robustness against a bad description (missing name, invalid schema, | |
| 63 | + duplicate names): skip the entry with a warning, never fail the session. | |
| 64 | + - Context size: many client tools = a lot of tool definitions for a small | |
| 65 | + model (tool routing helps). | |
| 66 | + | |
| 67 | + ## Acceptance criteria | |
| 68 | + | |
| 69 | + - A client that advertises a custom tool sees mm call it during a turn, | |
| 70 | + with the right arguments, and the model uses the answer. | |
| 71 | + - A client that advertises nothing: no change in behaviour. | |
| 72 | + - Unit tests with a fake ACP client (no model), like the existing ACP | |
| 73 | + tests. | |
| 74 | + - A demo client in `demo.acp.sdk/` that exposes one extension. | |
| 75 | + - Docs EN + FR (ACP reference: the convention, an example). | |
| 76 | +tasks: | |
| 77 | + - id: 1 | |
| 78 | + title: Choose the discovery convention (_meta list or a list request) | |
| 79 | + state: closed | |
| 80 | + priority: high | |
| 81 | + author: | |
| 82 | + name: k33g | |
| 83 | + email: ph.charriere@gmail.com | |
| 84 | + createdAt: 2026-11-10T21:56:01.000Z | |
| 85 | + updatedAt: 2026-11-10T23:30:00.000Z | |
| 86 | + closedAt: 2026-11-10T23:30:00.000Z | |
| 87 | + - id: 2 | |
| 88 | + title: Check how acp-go-sdk lets the agent side send a custom request | |
| 89 | + state: closed | |
| 90 | + priority: high | |
| 91 | + author: | |
| 92 | + name: k33g | |
| 93 | + email: ph.charriere@gmail.com | |
| 94 | + createdAt: 2026-11-10T21:56:01.000Z | |
| 95 | + updatedAt: 2026-11-10T23:30:00.000Z | |
| 96 | + closedAt: 2026-11-10T23:30:00.000Z | |
| 97 | + - id: 3 | |
| 98 | + title: Build the tools dynamically per session (prefix, schema, consent) | |
| 99 | + state: closed | |
| 100 | + priority: high | |
| 101 | + author: | |
| 102 | + name: k33g | |
| 103 | + email: ph.charriere@gmail.com | |
| 104 | + createdAt: 2026-11-10T21:56:01.000Z | |
| 105 | + updatedAt: 2026-11-10T23:30:00.000Z | |
| 106 | + closedAt: 2026-11-10T23:30:00.000Z | |
| 107 | + - id: 4 | |
| 108 | + title: Unit tests with a fake ACP client | |
| 109 | + state: closed | |
| 110 | + priority: medium | |
| 111 | + author: | |
| 112 | + name: k33g | |
| 113 | + email: ph.charriere@gmail.com | |
| 114 | + createdAt: 2026-11-10T21:56:01.000Z | |
| 115 | + updatedAt: 2026-11-10T23:30:00.000Z | |
| 116 | + closedAt: 2026-11-10T23:30:00.000Z | |
| 117 | + - id: 5 | |
| 118 | + title: Demo client exposing one extension (demo.acp.sdk) | |
| 119 | + state: closed | |
| 120 | + priority: medium | |
| 121 | + author: | |
| 122 | + name: k33g | |
| 123 | + email: ph.charriere@gmail.com | |
| 124 | + createdAt: 2026-11-10T21:56:01.000Z | |
| 125 | + updatedAt: 2026-11-10T23:30:00.000Z | |
| 126 | + closedAt: 2026-11-10T23:30:00.000Z | |
| 127 | + - id: 6 | |
| 128 | + title: Document it (EN + FR) | |
| 129 | + state: closed | |
| 130 | + priority: medium | |
| 131 | + author: | |
| 132 | + name: k33g | |
| 133 | + email: ph.charriere@gmail.com | |
| 134 | + createdAt: 2026-11-10T21:56:01.000Z | |
| 135 | + updatedAt: 2026-11-10T23:30:00.000Z | |
| 136 | + closedAt: 2026-11-10T23:30:00.000Z | |
| 137 | +comments: | |
| 138 | + - id: 1 | |
| 139 | + author: | |
| 140 | + name: k33g | |
| 141 | + email: ph.charriere@gmail.com | |
| 142 | + createdAt: 2026-11-10T23:30:00.000Z | |
| 143 | + body: | | |
| 144 | + Done on branch `feature/acp-client-tools`. Discovery `meta` is the default, `request` is selectable (`acp.clientTools.discovery`). The SDK was not a blocker: `AgentSideConnection.CallExtension` exists in acp-go-sdk v0.13.5. | |
| 145 | + Client specification: [docs/en/reference/acp-client-tools.md](docs/en/reference/acp-client-tools.md). Go reference client: [demo.acp.sdk/go/tools.go](demo.acp.sdk/go/tools.go), example `examples/clienttools`. | |
| new file mode 100644 | |||
| @@ -0,0 +1,145 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 202 | ||
| 3 | +title: "ACP: custom extensions exposed by the client as agent tools" | ||
| 4 | +state: closed | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-11-10T21:56:01.000Z | ||
| 9 | +updatedAt: 2026-11-10T23:30:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::feature | ||
| 12 | + - tools | ||
| 13 | +milestone: v1.0.6 | ||
| 14 | +body: | | ||
| 15 | + In ACP mode (`mm -acp`), the agent must **discover** the custom extensions | ||
| 16 | + the ACP client exposes, and **call them like any other tool**. Discovery | ||
| 17 | + and tool creation are **dynamic**: nothing is declared in `agent.yaml`, the | ||
| 18 | + tools come from the client. Only in ACP mode: the REPL and the window are | ||
| 19 | + not concerned. | ||
| 20 | + | ||
| 21 | + ## What ACP says ([Extensibility](https://agentclientprotocol.com/protocol/extensibility)) | ||
| 22 | + | ||
| 23 | + - A custom method name starts with `_`, with a reverse-DNS prefix: | ||
| 24 | + `_com.example/workspace/buffers`. | ||
| 25 | + - A custom request has an `id` and expects an answer; an unknown method | ||
| 26 | + answers `-32601 Method not found`. | ||
| 27 | + - Extensions are advertised in the `_meta` object of the capabilities | ||
| 28 | + exchanged at `initialize` (`clientCapabilities._meta`, keys of the form | ||
| 29 | + `com.example/<name>`). | ||
| 30 | + - **ACP defines no way to describe the parameters of a custom method.** | ||
| 31 | + mm has to define its own convention for the description (name, | ||
| 32 | + description, JSON schema of the arguments). | ||
| 33 | + | ||
| 34 | + ## Proposal (to validate) | ||
| 35 | + | ||
| 36 | + 1. **Discovery**, two options to choose from: | ||
| 37 | + - a. the client lists its tools in `clientCapabilities._meta` under a | ||
| 38 | + mini-me key, e.g. `"org.mini-me/tools": [{ "name", "method", | ||
| 39 | + "description", "inputSchema" }]`; | ||
| 40 | + - b. the client only advertises `"org.mini-me/tools": true`, and mm | ||
| 41 | + asks for the list with a custom request (`_org.mini-me/tools/list`, | ||
| 42 | + MCP-like) at `session/new` (and `session/load`), so the list can | ||
| 43 | + change between sessions. | ||
| 44 | + 2. **Tool creation**: each entry becomes a tool of the session (Genkit | ||
| 45 | + tool built at run time), named with a prefix to avoid collisions with | ||
| 46 | + the built-in tools and MCP tools (e.g. `client_<name>`), and passed | ||
| 47 | + through the same sorting, `toolsets` / allow-list rules and decision | ||
| 48 | + model tool routing as the other tools. | ||
| 49 | + 3. **Call**: the tool sends the custom request `method` to the client | ||
| 50 | + with the model's arguments as `params`, and returns the result (JSON | ||
| 51 | + or text) to the model. Errors (`-32601`, timeout, cancellation of the | ||
| 52 | + turn) come back to the model as tool errors, never as a crash. | ||
| 53 | + 4. **Permissions**: same consent rules as the other tools (`yolo`, | ||
| 54 | + permission request to the client when not allowed). | ||
| 55 | + | ||
| 56 | + ## Points to check | ||
| 57 | + | ||
| 58 | + - acp-go-sdk v0.13.5: `ClientCapabilities.Meta` exists, and the generic | ||
| 59 | + `SendRequest` exists on `Connection`, but `AgentSideConnection` keeps | ||
| 60 | + its connection unexported: check how the agent side can send a custom | ||
| 61 | + request (exported API, SDK upgrade, or a small wrapper). | ||
| 62 | + - Robustness against a bad description (missing name, invalid schema, | ||
| 63 | + duplicate names): skip the entry with a warning, never fail the session. | ||
| 64 | + - Context size: many client tools = a lot of tool definitions for a small | ||
| 65 | + model (tool routing helps). | ||
| 66 | + | ||
| 67 | + ## Acceptance criteria | ||
| 68 | + | ||
| 69 | + - A client that advertises a custom tool sees mm call it during a turn, | ||
| 70 | + with the right arguments, and the model uses the answer. | ||
| 71 | + - A client that advertises nothing: no change in behaviour. | ||
| 72 | + - Unit tests with a fake ACP client (no model), like the existing ACP | ||
| 73 | + tests. | ||
| 74 | + - A demo client in `demo.acp.sdk/` that exposes one extension. | ||
| 75 | + - Docs EN + FR (ACP reference: the convention, an example). | ||
| 76 | +tasks: | ||
| 77 | + - id: 1 | ||
| 78 | + title: Choose the discovery convention (_meta list or a list request) | ||
| 79 | + state: closed | ||
| 80 | + priority: high | ||
| 81 | + author: | ||
| 82 | + name: k33g | ||
| 83 | + email: ph.charriere@gmail.com | ||
| 84 | + createdAt: 2026-11-10T21:56:01.000Z | ||
| 85 | + updatedAt: 2026-11-10T23:30:00.000Z | ||
| 86 | + closedAt: 2026-11-10T23:30:00.000Z | ||
| 87 | + - id: 2 | ||
| 88 | + title: Check how acp-go-sdk lets the agent side send a custom request | ||
| 89 | + state: closed | ||
| 90 | + priority: high | ||
| 91 | + author: | ||
| 92 | + name: k33g | ||
| 93 | + email: ph.charriere@gmail.com | ||
| 94 | + createdAt: 2026-11-10T21:56:01.000Z | ||
| 95 | + updatedAt: 2026-11-10T23:30:00.000Z | ||
| 96 | + closedAt: 2026-11-10T23:30:00.000Z | ||
| 97 | + - id: 3 | ||
| 98 | + title: Build the tools dynamically per session (prefix, schema, consent) | ||
| 99 | + state: closed | ||
| 100 | + priority: high | ||
| 101 | + author: | ||
| 102 | + name: k33g | ||
| 103 | + email: ph.charriere@gmail.com | ||
| 104 | + createdAt: 2026-11-10T21:56:01.000Z | ||
| 105 | + updatedAt: 2026-11-10T23:30:00.000Z | ||
| 106 | + closedAt: 2026-11-10T23:30:00.000Z | ||
| 107 | + - id: 4 | ||
| 108 | + title: Unit tests with a fake ACP client | ||
| 109 | + state: closed | ||
| 110 | + priority: medium | ||
| 111 | + author: | ||
| 112 | + name: k33g | ||
| 113 | + email: ph.charriere@gmail.com | ||
| 114 | + createdAt: 2026-11-10T21:56:01.000Z | ||
| 115 | + updatedAt: 2026-11-10T23:30:00.000Z | ||
| 116 | + closedAt: 2026-11-10T23:30:00.000Z | ||
| 117 | + - id: 5 | ||
| 118 | + title: Demo client exposing one extension (demo.acp.sdk) | ||
| 119 | + state: closed | ||
| 120 | + priority: medium | ||
| 121 | + author: | ||
| 122 | + name: k33g | ||
| 123 | + email: ph.charriere@gmail.com | ||
| 124 | + createdAt: 2026-11-10T21:56:01.000Z | ||
| 125 | + updatedAt: 2026-11-10T23:30:00.000Z | ||
| 126 | + closedAt: 2026-11-10T23:30:00.000Z | ||
| 127 | + - id: 6 | ||
| 128 | + title: Document it (EN + FR) | ||
| 129 | + state: closed | ||
| 130 | + priority: medium | ||
| 131 | + author: | ||
| 132 | + name: k33g | ||
| 133 | + email: ph.charriere@gmail.com | ||
| 134 | + createdAt: 2026-11-10T21:56:01.000Z | ||
| 135 | + updatedAt: 2026-11-10T23:30:00.000Z | ||
| 136 | + closedAt: 2026-11-10T23:30:00.000Z | ||
| 137 | +comments: | ||
| 138 | + - id: 1 | ||
| 139 | + author: | ||
| 140 | + name: k33g | ||
| 141 | + email: ph.charriere@gmail.com | ||
| 142 | + createdAt: 2026-11-10T23:30:00.000Z | ||
| 143 | + body: | | ||
| 144 | + Done on branch `feature/acp-client-tools`. Discovery `meta` is the default, `request` is selectable (`acp.clientTools.discovery`). The SDK was not a blocker: `AgentSideConnection.CallExtension` exists in acp-go-sdk v0.13.5. | ||
| 145 | + Client specification: [docs/en/reference/acp-client-tools.md](docs/en/reference/acp-client-tools.md). Go reference client: [demo.acp.sdk/go/tools.go](demo.acp.sdk/go/tools.go), example `examples/clienttools`. | ||
modified
.tickets/milestones.yaml +1 -1 | @@ -52,7 +52,7 @@ milestones: | ||
| 52 | 52 | description: Security update (golang.org/x/net v0.62.0), published 2026-11-02 |
| 53 | 53 | dueOn: 2026-10-31 |
| 54 | 54 | - title: v1.0.5 |
| 55 | - state: open | |
| 55 | + state: closed | |
| 56 | 56 | dueOn: 2026-10-31 |
| 57 | 57 | - title: v1.0.6 |
| 58 | 58 | state: open |
| @@ -52,7 +52,7 @@ milestones: | |||
| 52 | description: Security update (golang.org/x/net v0.62.0), published 2026-11-02 | 52 | description: Security update (golang.org/x/net v0.62.0), published 2026-11-02 |
| 53 | dueOn: 2026-10-31 | 53 | dueOn: 2026-10-31 |
| 54 | - title: v1.0.5 | 54 | - title: v1.0.5 |
| 55 | - state: open | 55 | + state: closed |
| 56 | dueOn: 2026-10-31 | 56 | dueOn: 2026-10-31 |
| 57 | - title: v1.0.6 | 57 | - title: v1.0.6 |
| 58 | state: open | 58 | state: open |
modified
demo.acp.sdk/README.en.md +2 -0 | @@ -10,6 +10,7 @@ Programs in other languages use mini-me as a library by driving `mm -acp`: the s | ||
| 10 | 10 | | [`nodejs-sdk/`](nodejs-sdk/README.en.md) | Node.js, official ACP TypeScript SDK (ticket 188) | done: same API as `nodejs/`, on `@agentclientprotocol/sdk`, four examples, tests | |
| 11 | 11 | | [`go/`](go/README.en.md) | Go (ticket 168) | done: package `minime`, four examples, tests | |
| 12 | 12 | | [`kotlin/`](kotlin/README.en.md) | Kotlin (ticket 187) | done: `minime.Agent` on the official ACP Kotlin SDK, four examples, tests | |
| 13 | +| [`client-tools-nodejs/`](client-tools-nodejs/README.en.md) | Node.js, no dependency (ticket 202) | demo: a client that offers mm one of its own tools (`host_info`), both discovery modes | | |
| 13 | 14 | | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | not started | |
| 14 | 15 | |
| 15 | 16 | What every client does: |
| @@ -22,5 +23,6 @@ What every client does: | ||
| 22 | 23 | | not streamed | join the text chunks until `session/prompt` answers with its `stopReason` | |
| 23 | 24 | | cancel | the `session/cancel` notification; the prompt then answers `stopReason: "cancelled"` | |
| 24 | 25 | | confirm a tool (`yolo: false`) | answer mm's `session/request_permission` request | |
| 26 | +| offer the agent a tool of the client | `clientCapabilities._meta["org.mini-me/tools"]` in `initialize`, then answer mm's extension requests ([client tools](../docs/en/reference/acp-client-tools.md)) | | |
| 25 | 27 | |
| 26 | 28 | Limits shared by all: the `mm` binary must be installed, it runs locally (stdio), and the agent is a file (`mm -acp -config <file>`). For a Go program without the binary, see ticket 139 (an in-process SDK); [`go/`](go/README.en.md) says when to choose which. |
| @@ -10,6 +10,7 @@ Programs in other languages use mini-me as a library by driving `mm -acp`: the s | |||
| 10 | | [`nodejs-sdk/`](nodejs-sdk/README.en.md) | Node.js, official ACP TypeScript SDK (ticket 188) | done: same API as `nodejs/`, on `@agentclientprotocol/sdk`, four examples, tests | | 10 | | [`nodejs-sdk/`](nodejs-sdk/README.en.md) | Node.js, official ACP TypeScript SDK (ticket 188) | done: same API as `nodejs/`, on `@agentclientprotocol/sdk`, four examples, tests | |
| 11 | | [`go/`](go/README.en.md) | Go (ticket 168) | done: package `minime`, four examples, tests | | 11 | | [`go/`](go/README.en.md) | Go (ticket 168) | done: package `minime`, four examples, tests | |
| 12 | | [`kotlin/`](kotlin/README.en.md) | Kotlin (ticket 187) | done: `minime.Agent` on the official ACP Kotlin SDK, four examples, tests | | 12 | | [`kotlin/`](kotlin/README.en.md) | Kotlin (ticket 187) | done: `minime.Agent` on the official ACP Kotlin SDK, four examples, tests | |
| 13 | +| [`client-tools-nodejs/`](client-tools-nodejs/README.en.md) | Node.js, no dependency (ticket 202) | demo: a client that offers mm one of its own tools (`host_info`), both discovery modes | | ||
| 13 | | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | not started | | 14 | | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | not started | |
| 14 | 15 | ||
| 15 | What every client does: | 16 | What every client does: |
| @@ -22,5 +23,6 @@ What every client does: | |||
| 22 | | not streamed | join the text chunks until `session/prompt` answers with its `stopReason` | | 23 | | not streamed | join the text chunks until `session/prompt` answers with its `stopReason` | |
| 23 | | cancel | the `session/cancel` notification; the prompt then answers `stopReason: "cancelled"` | | 24 | | cancel | the `session/cancel` notification; the prompt then answers `stopReason: "cancelled"` | |
| 24 | | confirm a tool (`yolo: false`) | answer mm's `session/request_permission` request | | 25 | | confirm a tool (`yolo: false`) | answer mm's `session/request_permission` request | |
| 26 | +| offer the agent a tool of the client | `clientCapabilities._meta["org.mini-me/tools"]` in `initialize`, then answer mm's extension requests ([client tools](../docs/en/reference/acp-client-tools.md)) | | ||
| 25 | 27 | ||
| 26 | Limits shared by all: the `mm` binary must be installed, it runs locally (stdio), and the agent is a file (`mm -acp -config <file>`). For a Go program without the binary, see ticket 139 (an in-process SDK); [`go/`](go/README.en.md) says when to choose which. | 28 | Limits shared by all: the `mm` binary must be installed, it runs locally (stdio), and the agent is a file (`mm -acp -config <file>`). For a Go program without the binary, see ticket 139 (an in-process SDK); [`go/`](go/README.en.md) says when to choose which. |
modified
demo.acp.sdk/README.fr.md +2 -0 | @@ -10,6 +10,7 @@ Des programmes écrits dans d'autres langages utilisent mini-me comme bibliothè | ||
| 10 | 10 | | [`nodejs-sdk/`](nodejs-sdk/README.fr.md) | Node.js, SDK ACP officiel pour TypeScript (ticket 188) | fait : même API que `nodejs/`, sur `@agentclientprotocol/sdk`, quatre exemples, des tests | |
| 11 | 11 | | [`go/`](go/README.fr.md) | Go (ticket 168) | fait : paquet `minime`, quatre exemples, des tests | |
| 12 | 12 | | [`kotlin/`](kotlin/README.fr.md) | Kotlin (ticket 187) | fait : `minime.Agent` sur le SDK ACP officiel pour Kotlin, quatre exemples, des tests | |
| 13 | +| [`client-tools-nodejs/`](client-tools-nodejs/README.fr.md) | Node.js, sans dépendance (ticket 202) | démo : un client qui propose à mm un de ses propres outils (`host_info`), les deux modes de découverte | | |
| 13 | 14 | | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | pas commencés | |
| 14 | 15 | |
| 15 | 16 | Ce que fait chaque client : |
| @@ -22,5 +23,6 @@ Ce que fait chaque client : | ||
| 22 | 23 | | sans stream | concaténer les morceaux de texte jusqu'à la réponse de `session/prompt` et son `stopReason` | |
| 23 | 24 | | annuler | la notification `session/cancel` ; le prompt répond alors `stopReason: "cancelled"` | |
| 24 | 25 | | confirmer un outil (`yolo: false`) | répondre à la requête `session/request_permission` de mm | |
| 26 | +| proposer à l'agent un outil du client | `clientCapabilities._meta["org.mini-me/tools"]` dans `initialize`, puis répondre aux requêtes d'extension de mm ([outils du client](../docs/fr/reference/acp-client-tools.md)) | | |
| 25 | 27 | |
| 26 | 28 | Limites communes : le binaire `mm` doit être installé, tout tourne en local (stdio), et l'agent est un fichier (`mm -acp -config <fichier>`). Pour un programme Go sans le binaire, voir le ticket 139 (un SDK en mémoire) ; [`go/`](go/README.fr.md) explique quand choisir l'un ou l'autre. |
| @@ -10,6 +10,7 @@ Des programmes écrits dans d'autres langages utilisent mini-me comme bibliothè | |||
| 10 | | [`nodejs-sdk/`](nodejs-sdk/README.fr.md) | Node.js, SDK ACP officiel pour TypeScript (ticket 188) | fait : même API que `nodejs/`, sur `@agentclientprotocol/sdk`, quatre exemples, des tests | | 10 | | [`nodejs-sdk/`](nodejs-sdk/README.fr.md) | Node.js, SDK ACP officiel pour TypeScript (ticket 188) | fait : même API que `nodejs/`, sur `@agentclientprotocol/sdk`, quatre exemples, des tests | |
| 11 | | [`go/`](go/README.fr.md) | Go (ticket 168) | fait : paquet `minime`, quatre exemples, des tests | | 11 | | [`go/`](go/README.fr.md) | Go (ticket 168) | fait : paquet `minime`, quatre exemples, des tests | |
| 12 | | [`kotlin/`](kotlin/README.fr.md) | Kotlin (ticket 187) | fait : `minime.Agent` sur le SDK ACP officiel pour Kotlin, quatre exemples, des tests | | 12 | | [`kotlin/`](kotlin/README.fr.md) | Kotlin (ticket 187) | fait : `minime.Agent` sur le SDK ACP officiel pour Kotlin, quatre exemples, des tests | |
| 13 | +| [`client-tools-nodejs/`](client-tools-nodejs/README.fr.md) | Node.js, sans dépendance (ticket 202) | démo : un client qui propose à mm un de ses propres outils (`host_info`), les deux modes de découverte | | ||
| 13 | | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | pas commencés | | 14 | | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | pas commencés | |
| 14 | 15 | ||
| 15 | Ce que fait chaque client : | 16 | Ce que fait chaque client : |
| @@ -22,5 +23,6 @@ Ce que fait chaque client : | |||
| 22 | | sans stream | concaténer les morceaux de texte jusqu'à la réponse de `session/prompt` et son `stopReason` | | 23 | | sans stream | concaténer les morceaux de texte jusqu'à la réponse de `session/prompt` et son `stopReason` | |
| 23 | | annuler | la notification `session/cancel` ; le prompt répond alors `stopReason: "cancelled"` | | 24 | | annuler | la notification `session/cancel` ; le prompt répond alors `stopReason: "cancelled"` | |
| 24 | | confirmer un outil (`yolo: false`) | répondre à la requête `session/request_permission` de mm | | 25 | | confirmer un outil (`yolo: false`) | répondre à la requête `session/request_permission` de mm | |
| 26 | +| proposer à l'agent un outil du client | `clientCapabilities._meta["org.mini-me/tools"]` dans `initialize`, puis répondre aux requêtes d'extension de mm ([outils du client](../docs/fr/reference/acp-client-tools.md)) | | ||
| 25 | 27 | ||
| 26 | Limites communes : le binaire `mm` doit être installé, tout tourne en local (stdio), et l'agent est un fichier (`mm -acp -config <fichier>`). Pour un programme Go sans le binaire, voir le ticket 139 (un SDK en mémoire) ; [`go/`](go/README.fr.md) explique quand choisir l'un ou l'autre. | 28 | Limites communes : le binaire `mm` doit être installé, tout tourne en local (stdio), et l'agent est un fichier (`mm -acp -config <fichier>`). Pour un programme Go sans le binaire, voir le ticket 139 (un SDK en mémoire) ; [`go/`](go/README.fr.md) explique quand choisir l'un ou l'autre. |
added
demo.acp.sdk/client-tools-nodejs/README.en.md +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +# Client tools from Node.js: a client that lends mm a tool | |
| 2 | + | |
| 3 | +*[Version française](README.fr.md)* | |
| 4 | + | |
| 5 | +`client.js` is an ACP client of `mm -acp` that offers the agent one of its own tools, `host_info`: a JavaScript function that reports the operating system, CPU, memory or uptime of the machine the client runs on. The agent has no shell (`bashTool: false`): the client's tool is its only way to know anything about the machine. mm discovers the tool over ACP and the model calls it like its own, as `client_host_info` (ticket 202, the protocol is in [ACP client tools](../../docs/en/reference/acp-client-tools.md)). | |
| 6 | + | |
| 7 | +No dependency: Node.js 20 or later, `node:child_process`, `node:readline`, `node:os`. | |
| 8 | + | |
| 9 | +## Files | |
| 10 | + | |
| 11 | +| File | | | |
| 12 | +|---|---| | |
| 13 | +| `client.js` | the client: starts mm, advertises the tool, asks one question, answers the tool calls, prints the answer | | |
| 14 | +| `agent.yaml` | the agent: Qwen3.5 4B on llmman, no bash, `yolo: true`, `acp.clientTools.discovery: meta` | | |
| 15 | +| `agent-request.yaml` | the same agent with `acp.clientTools.discovery: request` | | |
| 16 | + | |
| 17 | +## Run it | |
| 18 | + | |
| 19 | +Needs an mm built with ticket 202 (`acp.clientTools`), and llmman with the model: | |
| 20 | + | |
| 21 | +```bash | |
| 22 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 23 | +llmman serve | |
| 24 | + | |
| 25 | +cd demo.acp.sdk/client-tools-nodejs | |
| 26 | +node client.js # default question, discovery "meta" | |
| 27 | +node client.js "How much memory is free on my machine?" # your question | |
| 28 | +node client.js --request "Which CPU does my machine have?" # discovery "request" | |
| 29 | +``` | |
| 30 | + | |
| 31 | +`mm` must be on the PATH, or named by `MM_BIN` (`MM_BIN=/path/to/mm node client.js`). | |
| 32 | + | |
| 33 | +Expected output (the values are your machine's): | |
| 34 | + | |
| 35 | +``` | |
| 36 | +[acp] client tools (meta): client_host_info | |
| 37 | +[client] discovery: meta, session 20261110-230430-6df8f9dd | |
| 38 | +❓ What operating system does my machine run, and how much memory does it have? | |
| 39 | + | |
| 40 | +🛠️ client: host_info | |
| 41 | +🛠️ client: host_info | |
| 42 | +[client] host_info {"topic":"os"} (session 20261110-230430-6df8f9dd) | |
| 43 | +[client] host_info {"topic":"memory"} (session 20261110-230430-6df8f9dd) | |
| 44 | + │ Linux 7.0.14 (linux/arm64), host name claude-mini-me | |
| 45 | + │ 15.0 GiB free of 15.8 GiB | |
| 46 | +Your machine runs Linux (version 7.0.14 on ARM64) and has 15.8 GiB of total memory, with 15.0 GiB currently free. | |
| 47 | + | |
| 48 | +[end_turn] | |
| 49 | +``` | |
| 50 | + | |
| 51 | +- `[acp] …` is mm's own stderr line: it saw the tool. In `--request` mode it is preceded by `[client] list requested for session …`: mm asked for the list while it opened the session. | |
| 52 | +- `[client] host_info …` is printed by the client when mm calls the tool: the model's arguments, and the session from `_meta`. | |
| 53 | +- `🛠️` and `│` are mm's `session/update` notifications: the tool call and its result, as an editor would show them. | |
| 54 | + | |
| 55 | +## How the client does it | |
| 56 | + | |
| 57 | +| Step | In `client.js` | | |
| 58 | +|---|---| | |
| 59 | +| describe the tool | `hostInfoTool`: `name`, `description`, `method` (`_demo.minime/host_info`, an ACP extension method), `inputSchema` | | |
| 60 | +| advertise it | `initialize` with `clientCapabilities._meta["org.mini-me/tools"]`: the list (`meta`) or `true` (`request`) | | |
| 61 | +| give the list (`request` only) | answer `_org.mini-me/tools/list` with `{tools: [hostInfoTool]}` | | |
| 62 | +| run a call | answer the request `_demo.minime/host_info`: the arguments are at the top level of `params`, the session in `params._meta["org.mini-me/sessionId"]`; the answer is `{content: [{type: "text", text}]}`, or `isError: true` with a message the model can read | | |
| 63 | +| confirm a call (`yolo: false`) | answer `session/request_permission`: allow the titles that start with `client: `, refuse the rest | | |
| 64 | +| anything else mm asks | JSON-RPC error `-32601` (method not found) | | |
| 65 | + | |
| 66 | +## Things to try | |
| 67 | + | |
| 68 | +- Set `yolo: false` in `agent.yaml`: each call goes through `session/request_permission` first, and the client prints `[client] permission for "client: host_info": allowed`. | |
| 69 | +- Mix the modes up: set `discovery: request` in `agent-request.yaml` back to `meta` and run `node client.js --request`. The client advertises only `true`, mm expects the list itself: it prints `[acp] client tools: "org.mini-me/tools" is not a list of tools; …` and the model gets no tool. | |
| 70 | +- Add a second tool: one more entry in the list, one more `case` in `onRequest`. | |
| 71 | + | |
| 72 | +## Measured (2026-11-10, in the development sandbox, Linux arm64, llmman with Qwen3.5 4B, mm built from branch `feature/acp-client-tools`) | |
| 73 | + | |
| 74 | +| Run | Result | | |
| 75 | +|---|---| | |
| 76 | +| `node client.js` | two calls (`os`, `memory`), answer built from both | | |
| 77 | +| `node client.js --request "Which CPU does my machine have, and since when is it up?"` | list requested at `session/new`, two calls (`uptime`, `cpu`), answer built from both | | |
| 78 | +| `yolo: false` | permission asked and allowed, then the call, then the answer | | |
| 79 | + | |
| 80 | +Also run by the author on macOS (2026-11-10), with mm rebuilt from the branch: works. | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +# Client tools from Node.js: a client that lends mm a tool | ||
| 2 | + | ||
| 3 | +*[Version française](README.fr.md)* | ||
| 4 | + | ||
| 5 | +`client.js` is an ACP client of `mm -acp` that offers the agent one of its own tools, `host_info`: a JavaScript function that reports the operating system, CPU, memory or uptime of the machine the client runs on. The agent has no shell (`bashTool: false`): the client's tool is its only way to know anything about the machine. mm discovers the tool over ACP and the model calls it like its own, as `client_host_info` (ticket 202, the protocol is in [ACP client tools](../../docs/en/reference/acp-client-tools.md)). | ||
| 6 | + | ||
| 7 | +No dependency: Node.js 20 or later, `node:child_process`, `node:readline`, `node:os`. | ||
| 8 | + | ||
| 9 | +## Files | ||
| 10 | + | ||
| 11 | +| File | | | ||
| 12 | +|---|---| | ||
| 13 | +| `client.js` | the client: starts mm, advertises the tool, asks one question, answers the tool calls, prints the answer | | ||
| 14 | +| `agent.yaml` | the agent: Qwen3.5 4B on llmman, no bash, `yolo: true`, `acp.clientTools.discovery: meta` | | ||
| 15 | +| `agent-request.yaml` | the same agent with `acp.clientTools.discovery: request` | | ||
| 16 | + | ||
| 17 | +## Run it | ||
| 18 | + | ||
| 19 | +Needs an mm built with ticket 202 (`acp.clientTools`), and llmman with the model: | ||
| 20 | + | ||
| 21 | +```bash | ||
| 22 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 23 | +llmman serve | ||
| 24 | + | ||
| 25 | +cd demo.acp.sdk/client-tools-nodejs | ||
| 26 | +node client.js # default question, discovery "meta" | ||
| 27 | +node client.js "How much memory is free on my machine?" # your question | ||
| 28 | +node client.js --request "Which CPU does my machine have?" # discovery "request" | ||
| 29 | +``` | ||
| 30 | + | ||
| 31 | +`mm` must be on the PATH, or named by `MM_BIN` (`MM_BIN=/path/to/mm node client.js`). | ||
| 32 | + | ||
| 33 | +Expected output (the values are your machine's): | ||
| 34 | + | ||
| 35 | +``` | ||
| 36 | +[acp] client tools (meta): client_host_info | ||
| 37 | +[client] discovery: meta, session 20261110-230430-6df8f9dd | ||
| 38 | +❓ What operating system does my machine run, and how much memory does it have? | ||
| 39 | + | ||
| 40 | +🛠️ client: host_info | ||
| 41 | +🛠️ client: host_info | ||
| 42 | +[client] host_info {"topic":"os"} (session 20261110-230430-6df8f9dd) | ||
| 43 | +[client] host_info {"topic":"memory"} (session 20261110-230430-6df8f9dd) | ||
| 44 | + │ Linux 7.0.14 (linux/arm64), host name claude-mini-me | ||
| 45 | + │ 15.0 GiB free of 15.8 GiB | ||
| 46 | +Your machine runs Linux (version 7.0.14 on ARM64) and has 15.8 GiB of total memory, with 15.0 GiB currently free. | ||
| 47 | + | ||
| 48 | +[end_turn] | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +- `[acp] …` is mm's own stderr line: it saw the tool. In `--request` mode it is preceded by `[client] list requested for session …`: mm asked for the list while it opened the session. | ||
| 52 | +- `[client] host_info …` is printed by the client when mm calls the tool: the model's arguments, and the session from `_meta`. | ||
| 53 | +- `🛠️` and `│` are mm's `session/update` notifications: the tool call and its result, as an editor would show them. | ||
| 54 | + | ||
| 55 | +## How the client does it | ||
| 56 | + | ||
| 57 | +| Step | In `client.js` | | ||
| 58 | +|---|---| | ||
| 59 | +| describe the tool | `hostInfoTool`: `name`, `description`, `method` (`_demo.minime/host_info`, an ACP extension method), `inputSchema` | | ||
| 60 | +| advertise it | `initialize` with `clientCapabilities._meta["org.mini-me/tools"]`: the list (`meta`) or `true` (`request`) | | ||
| 61 | +| give the list (`request` only) | answer `_org.mini-me/tools/list` with `{tools: [hostInfoTool]}` | | ||
| 62 | +| run a call | answer the request `_demo.minime/host_info`: the arguments are at the top level of `params`, the session in `params._meta["org.mini-me/sessionId"]`; the answer is `{content: [{type: "text", text}]}`, or `isError: true` with a message the model can read | | ||
| 63 | +| confirm a call (`yolo: false`) | answer `session/request_permission`: allow the titles that start with `client: `, refuse the rest | | ||
| 64 | +| anything else mm asks | JSON-RPC error `-32601` (method not found) | | ||
| 65 | + | ||
| 66 | +## Things to try | ||
| 67 | + | ||
| 68 | +- Set `yolo: false` in `agent.yaml`: each call goes through `session/request_permission` first, and the client prints `[client] permission for "client: host_info": allowed`. | ||
| 69 | +- Mix the modes up: set `discovery: request` in `agent-request.yaml` back to `meta` and run `node client.js --request`. The client advertises only `true`, mm expects the list itself: it prints `[acp] client tools: "org.mini-me/tools" is not a list of tools; …` and the model gets no tool. | ||
| 70 | +- Add a second tool: one more entry in the list, one more `case` in `onRequest`. | ||
| 71 | + | ||
| 72 | +## Measured (2026-11-10, in the development sandbox, Linux arm64, llmman with Qwen3.5 4B, mm built from branch `feature/acp-client-tools`) | ||
| 73 | + | ||
| 74 | +| Run | Result | | ||
| 75 | +|---|---| | ||
| 76 | +| `node client.js` | two calls (`os`, `memory`), answer built from both | | ||
| 77 | +| `node client.js --request "Which CPU does my machine have, and since when is it up?"` | list requested at `session/new`, two calls (`uptime`, `cpu`), answer built from both | | ||
| 78 | +| `yolo: false` | permission asked and allowed, then the call, then the answer | | ||
| 79 | + | ||
| 80 | +Also run by the author on macOS (2026-11-10), with mm rebuilt from the branch: works. | ||
added
demo.acp.sdk/client-tools-nodejs/README.fr.md +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +# Outils du client en Node.js : un client qui prête un outil à mm | |
| 2 | + | |
| 3 | +*[English version](README.en.md)* | |
| 4 | + | |
| 5 | +`client.js` est un client ACP de `mm -acp` qui propose à l'agent un de ses propres outils, `host_info` : une fonction JavaScript qui donne le système d'exploitation, le CPU, la mémoire ou la durée de fonctionnement de la machine où tourne le client. L'agent n'a pas de shell (`bashTool: false`) : l'outil du client est son seul moyen de savoir quoi que ce soit sur la machine. mm découvre l'outil par ACP et le modèle l'appelle comme les siens, sous le nom `client_host_info` (ticket 202, le protocole est dans [Outils du client ACP](../../docs/fr/reference/acp-client-tools.md)). | |
| 6 | + | |
| 7 | +Aucune dépendance : Node.js 20 ou plus, `node:child_process`, `node:readline`, `node:os`. | |
| 8 | + | |
| 9 | +## Fichiers | |
| 10 | + | |
| 11 | +| Fichier | | | |
| 12 | +|---|---| | |
| 13 | +| `client.js` | le client : lance mm, annonce l'outil, pose une question, répond aux appels de l'outil, affiche la réponse | | |
| 14 | +| `agent.yaml` | l'agent : Qwen3.5 4B sur llmman, pas de bash, `yolo: true`, `acp.clientTools.discovery: meta` | | |
| 15 | +| `agent-request.yaml` | le même agent avec `acp.clientTools.discovery: request` | | |
| 16 | + | |
| 17 | +## Le lancer | |
| 18 | + | |
| 19 | +Il faut un mm compilé avec le ticket 202 (`acp.clientTools`), et llmman avec le modèle : | |
| 20 | + | |
| 21 | +```bash | |
| 22 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 23 | +llmman serve | |
| 24 | + | |
| 25 | +cd demo.acp.sdk/client-tools-nodejs | |
| 26 | +node client.js # question par défaut, découverte "meta" | |
| 27 | +node client.js "How much memory is free on my machine?" # votre question | |
| 28 | +node client.js --request "Which CPU does my machine have?" # découverte "request" | |
| 29 | +``` | |
| 30 | + | |
| 31 | +`mm` doit être dans le PATH, ou désigné par `MM_BIN` (`MM_BIN=/chemin/vers/mm node client.js`). | |
| 32 | + | |
| 33 | +Sortie attendue (les valeurs sont celles de votre machine) : | |
| 34 | + | |
| 35 | +``` | |
| 36 | +[acp] client tools (meta): client_host_info | |
| 37 | +[client] discovery: meta, session 20261110-230430-6df8f9dd | |
| 38 | +❓ What operating system does my machine run, and how much memory does it have? | |
| 39 | + | |
| 40 | +🛠️ client: host_info | |
| 41 | +🛠️ client: host_info | |
| 42 | +[client] host_info {"topic":"os"} (session 20261110-230430-6df8f9dd) | |
| 43 | +[client] host_info {"topic":"memory"} (session 20261110-230430-6df8f9dd) | |
| 44 | + │ Linux 7.0.14 (linux/arm64), host name claude-mini-me | |
| 45 | + │ 15.0 GiB free of 15.8 GiB | |
| 46 | +Your machine runs Linux (version 7.0.14 on ARM64) and has 15.8 GiB of total memory, with 15.0 GiB currently free. | |
| 47 | + | |
| 48 | +[end_turn] | |
| 49 | +``` | |
| 50 | + | |
| 51 | +- `[acp] …` est la ligne stderr de mm lui-même : il a vu l'outil. En mode `--request`, elle est précédée de `[client] list requested for session …` : mm a demandé la liste pendant qu'il ouvrait la session. | |
| 52 | +- `[client] host_info …` est affiché par le client quand mm appelle l'outil : les arguments du modèle, et la session lue dans `_meta`. | |
| 53 | +- `🛠️` et `│` sont les notifications `session/update` de mm : l'appel d'outil et son résultat, comme un éditeur les afficherait. | |
| 54 | + | |
| 55 | +## Comment fait le client | |
| 56 | + | |
| 57 | +| Étape | Dans `client.js` | | |
| 58 | +|---|---| | |
| 59 | +| décrire l'outil | `hostInfoTool` : `name`, `description`, `method` (`_demo.minime/host_info`, une méthode d'extension ACP), `inputSchema` | | |
| 60 | +| l'annoncer | `initialize` avec `clientCapabilities._meta["org.mini-me/tools"]` : la liste (`meta`) ou `true` (`request`) | | |
| 61 | +| donner la liste (`request` seulement) | répondre à `_org.mini-me/tools/list` avec `{tools: [hostInfoTool]}` | | |
| 62 | +| exécuter un appel | répondre à la requête `_demo.minime/host_info` : les arguments sont au premier niveau de `params`, la session dans `params._meta["org.mini-me/sessionId"]` ; la réponse est `{content: [{type: "text", text}]}`, ou `isError: true` avec un message que le modèle peut lire | | |
| 63 | +| confirmer un appel (`yolo: false`) | répondre à `session/request_permission` : autoriser les titres qui commencent par `client: `, refuser le reste | | |
| 64 | +| tout ce que mm demande d'autre | erreur JSON-RPC `-32601` (method not found) | | |
| 65 | + | |
| 66 | +## À essayer | |
| 67 | + | |
| 68 | +- Mettre `yolo: false` dans `agent.yaml` : chaque appel passe d'abord par `session/request_permission`, et le client affiche `[client] permission for "client: host_info": allowed`. | |
| 69 | +- Croiser les modes : remettre `discovery: meta` dans `agent-request.yaml` et lancer `node client.js --request`. Le client n'annonce que `true`, mm attend la liste elle-même : il affiche `[acp] client tools: "org.mini-me/tools" is not a list of tools; …` et le modèle n'a pas d'outil. | |
| 70 | +- Ajouter un second outil : une entrée de plus dans la liste, un `case` de plus dans `onRequest`. | |
| 71 | + | |
| 72 | +## Mesuré (2026-11-10, dans le sandbox de développement, Linux arm64, llmman avec Qwen3.5 4B, mm compilé depuis la branche `feature/acp-client-tools`) | |
| 73 | + | |
| 74 | +| Lancement | Résultat | | |
| 75 | +|---|---| | |
| 76 | +| `node client.js` | deux appels (`os`, `memory`), réponse construite à partir des deux | | |
| 77 | +| `node client.js --request "Which CPU does my machine have, and since when is it up?"` | liste demandée à `session/new`, deux appels (`uptime`, `cpu`), réponse construite à partir des deux | | |
| 78 | +| `yolo: false` | permission demandée et accordée, puis l'appel, puis la réponse | | |
| 79 | + | |
| 80 | +Lancé aussi par l'auteur sur macOS (2026-11-10), avec mm recompilé depuis la branche : ça fonctionne. | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +# Outils du client en Node.js : un client qui prête un outil à mm | ||
| 2 | + | ||
| 3 | +*[English version](README.en.md)* | ||
| 4 | + | ||
| 5 | +`client.js` est un client ACP de `mm -acp` qui propose à l'agent un de ses propres outils, `host_info` : une fonction JavaScript qui donne le système d'exploitation, le CPU, la mémoire ou la durée de fonctionnement de la machine où tourne le client. L'agent n'a pas de shell (`bashTool: false`) : l'outil du client est son seul moyen de savoir quoi que ce soit sur la machine. mm découvre l'outil par ACP et le modèle l'appelle comme les siens, sous le nom `client_host_info` (ticket 202, le protocole est dans [Outils du client ACP](../../docs/fr/reference/acp-client-tools.md)). | ||
| 6 | + | ||
| 7 | +Aucune dépendance : Node.js 20 ou plus, `node:child_process`, `node:readline`, `node:os`. | ||
| 8 | + | ||
| 9 | +## Fichiers | ||
| 10 | + | ||
| 11 | +| Fichier | | | ||
| 12 | +|---|---| | ||
| 13 | +| `client.js` | le client : lance mm, annonce l'outil, pose une question, répond aux appels de l'outil, affiche la réponse | | ||
| 14 | +| `agent.yaml` | l'agent : Qwen3.5 4B sur llmman, pas de bash, `yolo: true`, `acp.clientTools.discovery: meta` | | ||
| 15 | +| `agent-request.yaml` | le même agent avec `acp.clientTools.discovery: request` | | ||
| 16 | + | ||
| 17 | +## Le lancer | ||
| 18 | + | ||
| 19 | +Il faut un mm compilé avec le ticket 202 (`acp.clientTools`), et llmman avec le modèle : | ||
| 20 | + | ||
| 21 | +```bash | ||
| 22 | +llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 23 | +llmman serve | ||
| 24 | + | ||
| 25 | +cd demo.acp.sdk/client-tools-nodejs | ||
| 26 | +node client.js # question par défaut, découverte "meta" | ||
| 27 | +node client.js "How much memory is free on my machine?" # votre question | ||
| 28 | +node client.js --request "Which CPU does my machine have?" # découverte "request" | ||
| 29 | +``` | ||
| 30 | + | ||
| 31 | +`mm` doit être dans le PATH, ou désigné par `MM_BIN` (`MM_BIN=/chemin/vers/mm node client.js`). | ||
| 32 | + | ||
| 33 | +Sortie attendue (les valeurs sont celles de votre machine) : | ||
| 34 | + | ||
| 35 | +``` | ||
| 36 | +[acp] client tools (meta): client_host_info | ||
| 37 | +[client] discovery: meta, session 20261110-230430-6df8f9dd | ||
| 38 | +❓ What operating system does my machine run, and how much memory does it have? | ||
| 39 | + | ||
| 40 | +🛠️ client: host_info | ||
| 41 | +🛠️ client: host_info | ||
| 42 | +[client] host_info {"topic":"os"} (session 20261110-230430-6df8f9dd) | ||
| 43 | +[client] host_info {"topic":"memory"} (session 20261110-230430-6df8f9dd) | ||
| 44 | + │ Linux 7.0.14 (linux/arm64), host name claude-mini-me | ||
| 45 | + │ 15.0 GiB free of 15.8 GiB | ||
| 46 | +Your machine runs Linux (version 7.0.14 on ARM64) and has 15.8 GiB of total memory, with 15.0 GiB currently free. | ||
| 47 | + | ||
| 48 | +[end_turn] | ||
| 49 | +``` | ||
| 50 | + | ||
| 51 | +- `[acp] …` est la ligne stderr de mm lui-même : il a vu l'outil. En mode `--request`, elle est précédée de `[client] list requested for session …` : mm a demandé la liste pendant qu'il ouvrait la session. | ||
| 52 | +- `[client] host_info …` est affiché par le client quand mm appelle l'outil : les arguments du modèle, et la session lue dans `_meta`. | ||
| 53 | +- `🛠️` et `│` sont les notifications `session/update` de mm : l'appel d'outil et son résultat, comme un éditeur les afficherait. | ||
| 54 | + | ||
| 55 | +## Comment fait le client | ||
| 56 | + | ||
| 57 | +| Étape | Dans `client.js` | | ||
| 58 | +|---|---| | ||
| 59 | +| décrire l'outil | `hostInfoTool` : `name`, `description`, `method` (`_demo.minime/host_info`, une méthode d'extension ACP), `inputSchema` | | ||
| 60 | +| l'annoncer | `initialize` avec `clientCapabilities._meta["org.mini-me/tools"]` : la liste (`meta`) ou `true` (`request`) | | ||
| 61 | +| donner la liste (`request` seulement) | répondre à `_org.mini-me/tools/list` avec `{tools: [hostInfoTool]}` | | ||
| 62 | +| exécuter un appel | répondre à la requête `_demo.minime/host_info` : les arguments sont au premier niveau de `params`, la session dans `params._meta["org.mini-me/sessionId"]` ; la réponse est `{content: [{type: "text", text}]}`, ou `isError: true` avec un message que le modèle peut lire | | ||
| 63 | +| confirmer un appel (`yolo: false`) | répondre à `session/request_permission` : autoriser les titres qui commencent par `client: `, refuser le reste | | ||
| 64 | +| tout ce que mm demande d'autre | erreur JSON-RPC `-32601` (method not found) | | ||
| 65 | + | ||
| 66 | +## À essayer | ||
| 67 | + | ||
| 68 | +- Mettre `yolo: false` dans `agent.yaml` : chaque appel passe d'abord par `session/request_permission`, et le client affiche `[client] permission for "client: host_info": allowed`. | ||
| 69 | +- Croiser les modes : remettre `discovery: meta` dans `agent-request.yaml` et lancer `node client.js --request`. Le client n'annonce que `true`, mm attend la liste elle-même : il affiche `[acp] client tools: "org.mini-me/tools" is not a list of tools; …` et le modèle n'a pas d'outil. | ||
| 70 | +- Ajouter un second outil : une entrée de plus dans la liste, un `case` de plus dans `onRequest`. | ||
| 71 | + | ||
| 72 | +## Mesuré (2026-11-10, dans le sandbox de développement, Linux arm64, llmman avec Qwen3.5 4B, mm compilé depuis la branche `feature/acp-client-tools`) | ||
| 73 | + | ||
| 74 | +| Lancement | Résultat | | ||
| 75 | +|---|---| | ||
| 76 | +| `node client.js` | deux appels (`os`, `memory`), réponse construite à partir des deux | | ||
| 77 | +| `node client.js --request "Which CPU does my machine have, and since when is it up?"` | liste demandée à `session/new`, deux appels (`uptime`, `cpu`), réponse construite à partir des deux | | ||
| 78 | +| `yolo: false` | permission demandée et accordée, puis l'appel, puis la réponse | | ||
| 79 | + | ||
| 80 | +Lancé aussi par l'auteur sur macOS (2026-11-10), avec mm recompilé depuis la branche : ça fonctionne. | ||
added
demo.acp.sdk/client-tools-nodejs/agent-request.yaml +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +# Same agent as agent.yaml, with `acp.clientTools.discovery: request` | |
| 2 | +# (`node client.js --request`). | |
| 3 | +# client.js starts `mm -acp -config <this file>` and offers it one tool, | |
| 4 | +# `host_info`, that runs inside the Node.js process. | |
| 5 | +# | |
| 6 | +# Qwen3.5 4B on llmman. No bash, no file tools: the only way for the model | |
| 7 | +# to learn something about the machine is the client's tool. | |
| 8 | + | |
| 9 | +provider: llmman | |
| 10 | +model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 11 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 12 | +fallback: http://host.docker.internal:17434/v1 | |
| 13 | +contextWindow: 32768 | |
| 14 | + | |
| 15 | +sampling: | |
| 16 | + temperature: 0.0 | |
| 17 | + max_tokens: 1024 | |
| 18 | + chat_template_kwargs: | |
| 19 | + enable_thinking: false | |
| 20 | + | |
| 21 | +maxTurns: 6 | |
| 22 | +yolo: true | |
| 23 | +bashTool: false | |
| 24 | +editTools: false | |
| 25 | +planTool: false | |
| 26 | +askUserTool: false | |
| 27 | +fetchContentTool: false | |
| 28 | +skillsDir: no-skills | |
| 29 | + | |
| 30 | +agentsMd: | |
| 31 | + enabled: false | |
| 32 | + | |
| 33 | +sessions: | |
| 34 | + enabled: false | |
| 35 | + | |
| 36 | +acp: | |
| 37 | + name: client-tools-demo | |
| 38 | + title: Client tools demo agent | |
| 39 | + clientTools: | |
| 40 | + enabled: true | |
| 41 | + # request: the client only says `true`; mm asks for the list at session/new. | |
| 42 | + discovery: request | |
| 43 | + permissions: auto | |
| 44 | + timeout: 30s | |
| 45 | + | |
| 46 | +system: | | |
| 47 | + You are a helpful assistant. You have no shell. To learn anything about the | |
| 48 | + user's machine, use the tools you are given. Answer briefly. | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +# Same agent as agent.yaml, with `acp.clientTools.discovery: request` | ||
| 2 | +# (`node client.js --request`). | ||
| 3 | +# client.js starts `mm -acp -config <this file>` and offers it one tool, | ||
| 4 | +# `host_info`, that runs inside the Node.js process. | ||
| 5 | +# | ||
| 6 | +# Qwen3.5 4B on llmman. No bash, no file tools: the only way for the model | ||
| 7 | +# to learn something about the machine is the client's tool. | ||
| 8 | + | ||
| 9 | +provider: llmman | ||
| 10 | +model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 11 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 12 | +fallback: http://host.docker.internal:17434/v1 | ||
| 13 | +contextWindow: 32768 | ||
| 14 | + | ||
| 15 | +sampling: | ||
| 16 | + temperature: 0.0 | ||
| 17 | + max_tokens: 1024 | ||
| 18 | + chat_template_kwargs: | ||
| 19 | + enable_thinking: false | ||
| 20 | + | ||
| 21 | +maxTurns: 6 | ||
| 22 | +yolo: true | ||
| 23 | +bashTool: false | ||
| 24 | +editTools: false | ||
| 25 | +planTool: false | ||
| 26 | +askUserTool: false | ||
| 27 | +fetchContentTool: false | ||
| 28 | +skillsDir: no-skills | ||
| 29 | + | ||
| 30 | +agentsMd: | ||
| 31 | + enabled: false | ||
| 32 | + | ||
| 33 | +sessions: | ||
| 34 | + enabled: false | ||
| 35 | + | ||
| 36 | +acp: | ||
| 37 | + name: client-tools-demo | ||
| 38 | + title: Client tools demo agent | ||
| 39 | + clientTools: | ||
| 40 | + enabled: true | ||
| 41 | + # request: the client only says `true`; mm asks for the list at session/new. | ||
| 42 | + discovery: request | ||
| 43 | + permissions: auto | ||
| 44 | + timeout: 30s | ||
| 45 | + | ||
| 46 | +system: | | ||
| 47 | + You are a helpful assistant. You have no shell. To learn anything about the | ||
| 48 | + user's machine, use the tools you are given. Answer briefly. | ||
added
demo.acp.sdk/client-tools-nodejs/agent.yaml +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +# The agent of the client tools demo (ticket 202): an ordinary mm agent file. | |
| 2 | +# client.js starts `mm -acp -config <this file>` and offers it one tool, | |
| 3 | +# `host_info`, that runs inside the Node.js process. | |
| 4 | +# | |
| 5 | +# Qwen3.5 4B on llmman. No bash, no file tools: the only way for the model | |
| 6 | +# to learn something about the machine is the client's tool. | |
| 7 | + | |
| 8 | +provider: llmman | |
| 9 | +model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | |
| 10 | +baseUrl: http://127.0.0.1:17434/v1 | |
| 11 | +fallback: http://host.docker.internal:17434/v1 | |
| 12 | +contextWindow: 32768 | |
| 13 | + | |
| 14 | +sampling: | |
| 15 | + temperature: 0.0 | |
| 16 | + max_tokens: 1024 | |
| 17 | + chat_template_kwargs: | |
| 18 | + enable_thinking: false | |
| 19 | + | |
| 20 | +maxTurns: 6 | |
| 21 | +yolo: true | |
| 22 | +bashTool: false | |
| 23 | +editTools: false | |
| 24 | +planTool: false | |
| 25 | +askUserTool: false | |
| 26 | +fetchContentTool: false | |
| 27 | +skillsDir: no-skills | |
| 28 | + | |
| 29 | +agentsMd: | |
| 30 | + enabled: false | |
| 31 | + | |
| 32 | +sessions: | |
| 33 | + enabled: false | |
| 34 | + | |
| 35 | +acp: | |
| 36 | + name: client-tools-demo | |
| 37 | + title: Client tools demo agent | |
| 38 | + clientTools: | |
| 39 | + enabled: true | |
| 40 | + # meta: the client lists its tools in the initialize request. | |
| 41 | + discovery: meta | |
| 42 | + permissions: auto | |
| 43 | + timeout: 30s | |
| 44 | + | |
| 45 | +system: | | |
| 46 | + You are a helpful assistant. You have no shell. To learn anything about the | |
| 47 | + user's machine, use the tools you are given. Answer briefly. | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +# The agent of the client tools demo (ticket 202): an ordinary mm agent file. | ||
| 2 | +# client.js starts `mm -acp -config <this file>` and offers it one tool, | ||
| 3 | +# `host_info`, that runs inside the Node.js process. | ||
| 4 | +# | ||
| 5 | +# Qwen3.5 4B on llmman. No bash, no file tools: the only way for the model | ||
| 6 | +# to learn something about the machine is the client's tool. | ||
| 7 | + | ||
| 8 | +provider: llmman | ||
| 9 | +model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M | ||
| 10 | +baseUrl: http://127.0.0.1:17434/v1 | ||
| 11 | +fallback: http://host.docker.internal:17434/v1 | ||
| 12 | +contextWindow: 32768 | ||
| 13 | + | ||
| 14 | +sampling: | ||
| 15 | + temperature: 0.0 | ||
| 16 | + max_tokens: 1024 | ||
| 17 | + chat_template_kwargs: | ||
| 18 | + enable_thinking: false | ||
| 19 | + | ||
| 20 | +maxTurns: 6 | ||
| 21 | +yolo: true | ||
| 22 | +bashTool: false | ||
| 23 | +editTools: false | ||
| 24 | +planTool: false | ||
| 25 | +askUserTool: false | ||
| 26 | +fetchContentTool: false | ||
| 27 | +skillsDir: no-skills | ||
| 28 | + | ||
| 29 | +agentsMd: | ||
| 30 | + enabled: false | ||
| 31 | + | ||
| 32 | +sessions: | ||
| 33 | + enabled: false | ||
| 34 | + | ||
| 35 | +acp: | ||
| 36 | + name: client-tools-demo | ||
| 37 | + title: Client tools demo agent | ||
| 38 | + clientTools: | ||
| 39 | + enabled: true | ||
| 40 | + # meta: the client lists its tools in the initialize request. | ||
| 41 | + discovery: meta | ||
| 42 | + permissions: auto | ||
| 43 | + timeout: 30s | ||
| 44 | + | ||
| 45 | +system: | | ||
| 46 | + You are a helpful assistant. You have no shell. To learn anything about the | ||
| 47 | + user's machine, use the tools you are given. Answer briefly. | ||
added
demo.acp.sdk/client-tools-nodejs/client.js +180 -0 | new file mode 100644 | ||
| @@ -0,0 +1,180 @@ | ||
| 1 | +// client.js — an ACP client that offers mini-me one of its own tools. | |
| 2 | +// | |
| 3 | +// It starts `mm -acp`, advertises the tool `host_info` (mm's "client tools", | |
| 4 | +// ticket 202, docs/en/reference/acp-client-tools.md), asks one question, and | |
| 5 | +// answers mm's calls of the tool by running a JavaScript function — here, in | |
| 6 | +// this Node.js process, on this machine. No dependency: node:child_process, | |
| 7 | +// node:readline, node:os. | |
| 8 | +// | |
| 9 | +// node client.js # default question, discovery "meta" | |
| 10 | +// node client.js "How much memory is free?" # your question | |
| 11 | +// node client.js --request "Which CPU is this?" # discovery "request" (agent-request.yaml) | |
| 12 | +// | |
| 13 | +// mm is $MM_BIN, else `mm` on the PATH. | |
| 14 | + | |
| 15 | +import { spawn } from "node:child_process"; | |
| 16 | +import { createInterface } from "node:readline"; | |
| 17 | +import os from "node:os"; | |
| 18 | +import path from "node:path"; | |
| 19 | +import { fileURLToPath } from "node:url"; | |
| 20 | + | |
| 21 | +const here = path.dirname(fileURLToPath(import.meta.url)); | |
| 22 | + | |
| 23 | +// --- the tool ------------------------------------------------------------------- | |
| 24 | + | |
| 25 | +// The description mm reads (the specification's "tool description"). The model | |
| 26 | +// will see it as `client_host_info`. | |
| 27 | +const hostInfoTool = { | |
| 28 | + name: "host_info", | |
| 29 | + description: "Information about the user's machine: operating system, CPU, memory or uptime.", | |
| 30 | + method: "_demo.minime/host_info", | |
| 31 | + inputSchema: { | |
| 32 | + type: "object", | |
| 33 | + properties: { | |
| 34 | + topic: { type: "string", enum: ["os", "cpu", "memory", "uptime"], description: "What to report." }, | |
| 35 | + }, | |
| 36 | + required: ["topic"], | |
| 37 | + }, | |
| 38 | +}; | |
| 39 | + | |
| 40 | +// What the tool does: a plain function of the arguments the model chose. | |
| 41 | +function hostInfo({ topic }) { | |
| 42 | + const gib = (bytes) => `${(bytes / 2 ** 30).toFixed(1)} GiB`; | |
| 43 | + switch (topic) { | |
| 44 | + case "os": | |
| 45 | + return `${os.type()} ${os.release()} (${os.platform()}/${os.arch()}), host name ${os.hostname()}`; | |
| 46 | + case "cpu": { | |
| 47 | + const cpus = os.cpus(); | |
| 48 | + return `${cpus.length} cores, ${cpus[0]?.model || "unknown model"}`; | |
| 49 | + } | |
| 50 | + case "memory": | |
| 51 | + return `${gib(os.freemem())} free of ${gib(os.totalmem())}`; | |
| 52 | + case "uptime": | |
| 53 | + return `up for ${(os.uptime() / 3600).toFixed(1)} hours`; | |
| 54 | + default: | |
| 55 | + throw new Error(`unknown topic ${JSON.stringify(topic)}: use os, cpu, memory or uptime`); | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +// --- the command line ----------------------------------------------------------- | |
| 60 | + | |
| 61 | +const args = process.argv.slice(2); | |
| 62 | +const discovery = args[0] === "--request" ? "request" : "meta"; | |
| 63 | +if (discovery === "request") args.shift(); | |
| 64 | +const question = args.join(" ") || "What operating system does my machine run, and how much memory does it have?"; | |
| 65 | +const config = path.join(here, discovery === "request" ? "agent-request.yaml" : "agent.yaml"); | |
| 66 | +const mm = process.env.MM_BIN || "mm"; | |
| 67 | + | |
| 68 | +// --- a minimal ACP client: JSON-RPC, one message per line -------------------------- | |
| 69 | + | |
| 70 | +const child = spawn(mm, ["-acp", "-config", config], { cwd: here, stdio: ["pipe", "pipe", "pipe"] }); | |
| 71 | +child.on("error", (err) => { | |
| 72 | + console.error(`cannot start ${mm}: ${err.message} (set MM_BIN?)`); | |
| 73 | + process.exit(1); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +let nextId = 1; | |
| 77 | +const pending = new Map(); // our request id → { resolve, reject } | |
| 78 | + | |
| 79 | +function send(message) { | |
| 80 | + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); | |
| 81 | +} | |
| 82 | + | |
| 83 | +function request(method, params) { | |
| 84 | + const id = nextId++; | |
| 85 | + send({ id, method, params }); | |
| 86 | + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); | |
| 87 | +} | |
| 88 | + | |
| 89 | +// mm's stderr: only the lines about the client tools, to see the discovery. | |
| 90 | +createInterface({ input: child.stderr }).on("line", (line) => { | |
| 91 | + if (line.includes("client tools")) console.log(`\x1b[2m${line}\x1b[0m`); | |
| 92 | +}); | |
| 93 | + | |
| 94 | +// mm's stdout: responses to our requests, requests from mm, notifications. | |
| 95 | +createInterface({ input: child.stdout }).on("line", async (line) => { | |
| 96 | + const msg = JSON.parse(line); | |
| 97 | + if (msg.method === undefined) { | |
| 98 | + // A response to one of our requests. | |
| 99 | + const p = pending.get(msg.id); | |
| 100 | + pending.delete(msg.id); | |
| 101 | + if (msg.error) p?.reject(new Error(`${msg.error.message} ${JSON.stringify(msg.error.data ?? "")}`)); | |
| 102 | + else p?.resolve(msg.result); | |
| 103 | + return; | |
| 104 | + } | |
| 105 | + if (msg.id === undefined) { | |
| 106 | + onNotification(msg); | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + // A request from mm: answer it, whatever happens. | |
| 110 | + try { | |
| 111 | + send({ id: msg.id, result: await onRequest(msg.method, msg.params ?? {}) }); | |
| 112 | + } catch (err) { | |
| 113 | + send({ id: msg.id, error: err.code ? err : { code: -32603, message: err.message } }); | |
| 114 | + } | |
| 115 | +}); | |
| 116 | + | |
| 117 | +// The requests mm sends us. | |
| 118 | +async function onRequest(method, params) { | |
| 119 | + switch (method) { | |
| 120 | + // Discovery "request": mm asks for the list while it opens the session. | |
| 121 | + case "_org.mini-me/tools/list": | |
| 122 | + console.log(`\x1b[2m[client] list requested for session ${params.sessionId}\x1b[0m`); | |
| 123 | + return { tools: [hostInfoTool] }; | |
| 124 | + | |
| 125 | + // A call of our tool: the model's arguments, plus the session in _meta. | |
| 126 | + case hostInfoTool.method: { | |
| 127 | + const { _meta, ...toolArgs } = params; | |
| 128 | + console.log(`\x1b[36m[client] host_info ${JSON.stringify(toolArgs)} (session ${_meta?.["org.mini-me/sessionId"]})\x1b[0m`); | |
| 129 | + try { | |
| 130 | + return { content: [{ type: "text", text: hostInfo(toolArgs) }] }; | |
| 131 | + } catch (err) { | |
| 132 | + return { content: [{ type: "text", text: err.message }], isError: true }; | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + // With `yolo: false` (or permissions: ask), mm asks before each call: | |
| 137 | + // allow our own tools, refuse anything else. | |
| 138 | + case "session/request_permission": { | |
| 139 | + const ours = params.toolCall?.title?.startsWith("client: "); | |
| 140 | + const kind = ours ? "allow_once" : "reject_once"; | |
| 141 | + const option = params.options.find((o) => o.kind === kind); | |
| 142 | + console.log(`\x1b[2m[client] permission for "${params.toolCall?.title}": ${ours ? "allowed" : "refused"}\x1b[0m`); | |
| 143 | + return { outcome: option ? { outcome: "selected", optionId: option.optionId } : { outcome: "cancelled" } }; | |
| 144 | + } | |
| 145 | + | |
| 146 | + default: | |
| 147 | + throw { code: -32601, message: `Method not found: ${method}` }; | |
| 148 | + } | |
| 149 | +} | |
| 150 | + | |
| 151 | +// The notifications mm sends us: the answer and the tool calls, as they come. | |
| 152 | +function onNotification(msg) { | |
| 153 | + if (msg.method !== "session/update") return; | |
| 154 | + const u = msg.params.update; | |
| 155 | + if (u.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") process.stdout.write(u.content.text); | |
| 156 | + if (u.sessionUpdate === "tool_call") console.log(`🛠️ ${u.title}`); | |
| 157 | + if (u.sessionUpdate === "tool_call_update" && u.rawOutput?.output !== undefined) { | |
| 158 | + console.log(` │ ${String(u.rawOutput.output).replaceAll("\n", "\n │ ")}`); | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +// --- the conversation --------------------------------------------------------------- | |
| 163 | + | |
| 164 | +try { | |
| 165 | + // Discovery "meta": the list itself travels in initialize. "request": only `true`. | |
| 166 | + await request("initialize", { | |
| 167 | + protocolVersion: 1, | |
| 168 | + clientInfo: { name: "client-tools-nodejs", version: "0.1.0" }, | |
| 169 | + clientCapabilities: { _meta: { "org.mini-me/tools": discovery === "meta" ? [hostInfoTool] : true } }, | |
| 170 | + }); | |
| 171 | + const { sessionId } = await request("session/new", { cwd: here, mcpServers: [] }); | |
| 172 | + console.log(`\x1b[2m[client] discovery: ${discovery}, session ${sessionId}\x1b[0m\n❓ ${question}\n`); | |
| 173 | + const { stopReason } = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: question }] }); | |
| 174 | + console.log(`\n\n[${stopReason}]`); | |
| 175 | +} catch (err) { | |
| 176 | + console.error(`\nfailed: ${err.message}`); | |
| 177 | + process.exitCode = 1; | |
| 178 | +} finally { | |
| 179 | + child.stdin.end(); | |
| 180 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,180 @@ | |||
| 1 | +// client.js — an ACP client that offers mini-me one of its own tools. | ||
| 2 | +// | ||
| 3 | +// It starts `mm -acp`, advertises the tool `host_info` (mm's "client tools", | ||
| 4 | +// ticket 202, docs/en/reference/acp-client-tools.md), asks one question, and | ||
| 5 | +// answers mm's calls of the tool by running a JavaScript function — here, in | ||
| 6 | +// this Node.js process, on this machine. No dependency: node:child_process, | ||
| 7 | +// node:readline, node:os. | ||
| 8 | +// | ||
| 9 | +// node client.js # default question, discovery "meta" | ||
| 10 | +// node client.js "How much memory is free?" # your question | ||
| 11 | +// node client.js --request "Which CPU is this?" # discovery "request" (agent-request.yaml) | ||
| 12 | +// | ||
| 13 | +// mm is $MM_BIN, else `mm` on the PATH. | ||
| 14 | + | ||
| 15 | +import { spawn } from "node:child_process"; | ||
| 16 | +import { createInterface } from "node:readline"; | ||
| 17 | +import os from "node:os"; | ||
| 18 | +import path from "node:path"; | ||
| 19 | +import { fileURLToPath } from "node:url"; | ||
| 20 | + | ||
| 21 | +const here = path.dirname(fileURLToPath(import.meta.url)); | ||
| 22 | + | ||
| 23 | +// --- the tool ------------------------------------------------------------------- | ||
| 24 | + | ||
| 25 | +// The description mm reads (the specification's "tool description"). The model | ||
| 26 | +// will see it as `client_host_info`. | ||
| 27 | +const hostInfoTool = { | ||
| 28 | + name: "host_info", | ||
| 29 | + description: "Information about the user's machine: operating system, CPU, memory or uptime.", | ||
| 30 | + method: "_demo.minime/host_info", | ||
| 31 | + inputSchema: { | ||
| 32 | + type: "object", | ||
| 33 | + properties: { | ||
| 34 | + topic: { type: "string", enum: ["os", "cpu", "memory", "uptime"], description: "What to report." }, | ||
| 35 | + }, | ||
| 36 | + required: ["topic"], | ||
| 37 | + }, | ||
| 38 | +}; | ||
| 39 | + | ||
| 40 | +// What the tool does: a plain function of the arguments the model chose. | ||
| 41 | +function hostInfo({ topic }) { | ||
| 42 | + const gib = (bytes) => `${(bytes / 2 ** 30).toFixed(1)} GiB`; | ||
| 43 | + switch (topic) { | ||
| 44 | + case "os": | ||
| 45 | + return `${os.type()} ${os.release()} (${os.platform()}/${os.arch()}), host name ${os.hostname()}`; | ||
| 46 | + case "cpu": { | ||
| 47 | + const cpus = os.cpus(); | ||
| 48 | + return `${cpus.length} cores, ${cpus[0]?.model || "unknown model"}`; | ||
| 49 | + } | ||
| 50 | + case "memory": | ||
| 51 | + return `${gib(os.freemem())} free of ${gib(os.totalmem())}`; | ||
| 52 | + case "uptime": | ||
| 53 | + return `up for ${(os.uptime() / 3600).toFixed(1)} hours`; | ||
| 54 | + default: | ||
| 55 | + throw new Error(`unknown topic ${JSON.stringify(topic)}: use os, cpu, memory or uptime`); | ||
| 56 | + } | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// --- the command line ----------------------------------------------------------- | ||
| 60 | + | ||
| 61 | +const args = process.argv.slice(2); | ||
| 62 | +const discovery = args[0] === "--request" ? "request" : "meta"; | ||
| 63 | +if (discovery === "request") args.shift(); | ||
| 64 | +const question = args.join(" ") || "What operating system does my machine run, and how much memory does it have?"; | ||
| 65 | +const config = path.join(here, discovery === "request" ? "agent-request.yaml" : "agent.yaml"); | ||
| 66 | +const mm = process.env.MM_BIN || "mm"; | ||
| 67 | + | ||
| 68 | +// --- a minimal ACP client: JSON-RPC, one message per line -------------------------- | ||
| 69 | + | ||
| 70 | +const child = spawn(mm, ["-acp", "-config", config], { cwd: here, stdio: ["pipe", "pipe", "pipe"] }); | ||
| 71 | +child.on("error", (err) => { | ||
| 72 | + console.error(`cannot start ${mm}: ${err.message} (set MM_BIN?)`); | ||
| 73 | + process.exit(1); | ||
| 74 | +}); | ||
| 75 | + | ||
| 76 | +let nextId = 1; | ||
| 77 | +const pending = new Map(); // our request id → { resolve, reject } | ||
| 78 | + | ||
| 79 | +function send(message) { | ||
| 80 | + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +function request(method, params) { | ||
| 84 | + const id = nextId++; | ||
| 85 | + send({ id, method, params }); | ||
| 86 | + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// mm's stderr: only the lines about the client tools, to see the discovery. | ||
| 90 | +createInterface({ input: child.stderr }).on("line", (line) => { | ||
| 91 | + if (line.includes("client tools")) console.log(`\x1b[2m${line}\x1b[0m`); | ||
| 92 | +}); | ||
| 93 | + | ||
| 94 | +// mm's stdout: responses to our requests, requests from mm, notifications. | ||
| 95 | +createInterface({ input: child.stdout }).on("line", async (line) => { | ||
| 96 | + const msg = JSON.parse(line); | ||
| 97 | + if (msg.method === undefined) { | ||
| 98 | + // A response to one of our requests. | ||
| 99 | + const p = pending.get(msg.id); | ||
| 100 | + pending.delete(msg.id); | ||
| 101 | + if (msg.error) p?.reject(new Error(`${msg.error.message} ${JSON.stringify(msg.error.data ?? "")}`)); | ||
| 102 | + else p?.resolve(msg.result); | ||
| 103 | + return; | ||
| 104 | + } | ||
| 105 | + if (msg.id === undefined) { | ||
| 106 | + onNotification(msg); | ||
| 107 | + return; | ||
| 108 | + } | ||
| 109 | + // A request from mm: answer it, whatever happens. | ||
| 110 | + try { | ||
| 111 | + send({ id: msg.id, result: await onRequest(msg.method, msg.params ?? {}) }); | ||
| 112 | + } catch (err) { | ||
| 113 | + send({ id: msg.id, error: err.code ? err : { code: -32603, message: err.message } }); | ||
| 114 | + } | ||
| 115 | +}); | ||
| 116 | + | ||
| 117 | +// The requests mm sends us. | ||
| 118 | +async function onRequest(method, params) { | ||
| 119 | + switch (method) { | ||
| 120 | + // Discovery "request": mm asks for the list while it opens the session. | ||
| 121 | + case "_org.mini-me/tools/list": | ||
| 122 | + console.log(`\x1b[2m[client] list requested for session ${params.sessionId}\x1b[0m`); | ||
| 123 | + return { tools: [hostInfoTool] }; | ||
| 124 | + | ||
| 125 | + // A call of our tool: the model's arguments, plus the session in _meta. | ||
| 126 | + case hostInfoTool.method: { | ||
| 127 | + const { _meta, ...toolArgs } = params; | ||
| 128 | + console.log(`\x1b[36m[client] host_info ${JSON.stringify(toolArgs)} (session ${_meta?.["org.mini-me/sessionId"]})\x1b[0m`); | ||
| 129 | + try { | ||
| 130 | + return { content: [{ type: "text", text: hostInfo(toolArgs) }] }; | ||
| 131 | + } catch (err) { | ||
| 132 | + return { content: [{ type: "text", text: err.message }], isError: true }; | ||
| 133 | + } | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + // With `yolo: false` (or permissions: ask), mm asks before each call: | ||
| 137 | + // allow our own tools, refuse anything else. | ||
| 138 | + case "session/request_permission": { | ||
| 139 | + const ours = params.toolCall?.title?.startsWith("client: "); | ||
| 140 | + const kind = ours ? "allow_once" : "reject_once"; | ||
| 141 | + const option = params.options.find((o) => o.kind === kind); | ||
| 142 | + console.log(`\x1b[2m[client] permission for "${params.toolCall?.title}": ${ours ? "allowed" : "refused"}\x1b[0m`); | ||
| 143 | + return { outcome: option ? { outcome: "selected", optionId: option.optionId } : { outcome: "cancelled" } }; | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + default: | ||
| 147 | + throw { code: -32601, message: `Method not found: ${method}` }; | ||
| 148 | + } | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +// The notifications mm sends us: the answer and the tool calls, as they come. | ||
| 152 | +function onNotification(msg) { | ||
| 153 | + if (msg.method !== "session/update") return; | ||
| 154 | + const u = msg.params.update; | ||
| 155 | + if (u.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") process.stdout.write(u.content.text); | ||
| 156 | + if (u.sessionUpdate === "tool_call") console.log(`🛠️ ${u.title}`); | ||
| 157 | + if (u.sessionUpdate === "tool_call_update" && u.rawOutput?.output !== undefined) { | ||
| 158 | + console.log(` │ ${String(u.rawOutput.output).replaceAll("\n", "\n │ ")}`); | ||
| 159 | + } | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +// --- the conversation --------------------------------------------------------------- | ||
| 163 | + | ||
| 164 | +try { | ||
| 165 | + // Discovery "meta": the list itself travels in initialize. "request": only `true`. | ||
| 166 | + await request("initialize", { | ||
| 167 | + protocolVersion: 1, | ||
| 168 | + clientInfo: { name: "client-tools-nodejs", version: "0.1.0" }, | ||
| 169 | + clientCapabilities: { _meta: { "org.mini-me/tools": discovery === "meta" ? [hostInfoTool] : true } }, | ||
| 170 | + }); | ||
| 171 | + const { sessionId } = await request("session/new", { cwd: here, mcpServers: [] }); | ||
| 172 | + console.log(`\x1b[2m[client] discovery: ${discovery}, session ${sessionId}\x1b[0m\n❓ ${question}\n`); | ||
| 173 | + const { stopReason } = await request("session/prompt", { sessionId, prompt: [{ type: "text", text: question }] }); | ||
| 174 | + console.log(`\n\n[${stopReason}]`); | ||
| 175 | +} catch (err) { | ||
| 176 | + console.error(`\nfailed: ${err.message}`); | ||
| 177 | + process.exitCode = 1; | ||
| 178 | +} finally { | ||
| 179 | + child.stdin.end(); | ||
| 180 | +} | ||
modified
demo.acp.sdk/go/README.en.md +24 -3 | @@ -38,6 +38,7 @@ go run ./examples/complete # not streamed | ||
| 38 | 38 | go run ./examples/stream # streamed, with the tool calls |
| 39 | 39 | go run ./examples/cancel # cancelled after 3 seconds |
| 40 | 40 | go run ./examples/permission # a command refused, then allowed |
| 41 | +go run ./examples/clienttools # a to-do list in this program, offered to the agent as two tools | |
| 41 | 42 | ``` |
| 42 | 43 | |
| 43 | 44 | `mm` must be on the PATH, or named by `MM_BIN`. The examples use `agent.yaml` (Qwen3.5 4B on llmman, bash only, `yolo: false`); any agent file works. Go 1.24 or later (range-over-func). |
| @@ -58,6 +59,8 @@ go mod tidy | ||
| 58 | 59 | | `Options.Cwd` | the session's working directory, where the tools run. Default: the current directory. | |
| 59 | 60 | | `Options.MM` | the mm binary. Default: `$MM_BIN`, then `mm` on the PATH. | |
| 60 | 61 | | `Options.OnPermission func(ctx, ToolCall) Decision` | asked when the agent wants to run a tool that needs a confirmation (`yolo: false`). **Without it, every such call is refused.** | |
| 62 | +| `Options.Tools []Tool` | Go functions offered to the agent as tools (mm's [ACP client tools](../../docs/en/reference/acp-client-tools.md), `acp.clientTools`, on by default). The model sees `client_<Name>`; each call runs `Handle`. `Start` refuses a tool with no `Name`, no `Handle`, a duplicate name, or a `Method` that does not start with `_`. | | |
| 63 | +| `Options.ToolDiscovery` | `minime.DiscoveryMeta` (default: the tools travel in `initialize`) or `minime.DiscoveryRequest` (only `true` travels; the list is answered to `_org.mini-me/tools/list`). Must match `acp.clientTools.discovery` in the agent file. | | |
| 61 | 64 | | `Options.OnStderr func(line string)` | mm's stderr, line by line (banner, warnings, `[acp]` lines). Otherwise it is read and dropped; its last line is added to errors. | |
| 62 | 65 | | `agent.Complete(ctx, prompt) (Result, error)` | one turn; returns `Result{Text, StopReason, Usage}` once it is over. | |
| 63 | 66 | | `agent.Stream(ctx, prompt) iter.Seq2[Event, error]` | one turn as a range-over-func sequence; the last event is `KindDone`. Leaving the loop early cancels the turn. | |
| @@ -92,16 +95,28 @@ Rules worth knowing: | ||
| 92 | 95 | - **One Agent = one mm process = one conversation.** Calls continue it, as in an editor. One turn at a time per Agent (`ErrBusy`); start several Agents to work in parallel. |
| 93 | 96 | - **A turn mm ends with an error returns it** with mm's own words and the last line of its stderr, e.g. `minime: turn failed: Internal error (-32603): the model repeated the same bash call 6 times…`. If mm crashed, the error says `mm exited: exit status N`. |
| 94 | 97 | - **A cancelled question is dropped from the conversation by mm**: the next question does not know about it. |
| 95 | -- The client advertises no file-system, terminal or form capability: mm needs none, and its `ask_user` tool is not offered. | |
| 98 | +- The client advertises no file-system, terminal or form capability: mm needs none, and its `ask_user` tool is not offered. Its only capability is `_meta["org.mini-me/tools"]`, when `Options.Tools` has some. | |
| 99 | + | |
| 100 | +`Tool`, an entry of `Options.Tools`: | |
| 101 | + | |
| 102 | +| Field | | | |
| 103 | +|---|---| | |
| 104 | +| `Name` | required, unique; the model sees `client_<Name>`. | | |
| 105 | +| `Description` | what the model reads to decide when to call it. | | |
| 106 | +| `Method` | the ACP extension method mm calls; must start with `_`. Default: `_minime.go/tools/<Name>`. | | |
| 107 | +| `InputSchema map[string]any` | JSON schema of the arguments (`type: object`); optional. | | |
| 108 | +| `Handle func(ctx, ToolCallRequest) (string, error)` | required. `ToolCallRequest{SessionID, Arguments}`. The text goes back to the model; an error (or a panic) goes back as a failed call (`isError: true`) and the client lives on. Calls may run concurrently. | | |
| 109 | + | |
| 110 | +With `yolo: false` in the agent file, each call is first submitted to `OnPermission`, titled `client: <Name>`. | |
| 96 | 111 | |
| 97 | 112 | ## Tests |
| 98 | 113 | |
| 99 | 114 | ```bash |
| 100 | 115 | cd demo.acp.sdk/go |
| 101 | -go test ./... # 40 tests and subtests, no model, no mm, ~1 s | |
| 116 | +go test ./... # 47 tests and subtests, no model, no mm, ~1 s | |
| 102 | 117 | ``` |
| 103 | 118 | |
| 104 | -The test binary plays a fake mm (`fakemm_test.go`): started again with `MINIME_FAKE_MM=1`, it answers over ACP with mm's message shapes (four permission options, `rawOutput.output`, usage on the prompt's answer): answers, a thought, a tool call with its permission, a slow turn to cancel, a turn deaf to the cancel, an error, a crash. `go test -race` needs cgo and a C compiler. | |
| 119 | +The test binary plays a fake mm (`fakemm_test.go`): started again with `MINIME_FAKE_MM=1`, it answers over ACP with mm's message shapes (four permission options, `rawOutput.output`, usage on the prompt's answer): answers, a thought, a tool call with its permission, a slow turn to cancel, a turn deaf to the cancel, an error, a crash; and, like mm, it learns the client tools (from `initialize`, or with `_org.mini-me/tools/list` at `session/new`) and calls one. `go test -race` needs cgo and a C compiler. | |
| 105 | 120 | |
| 106 | 121 | ## Measured (2026-11-01, llmman on the author's host, mm v1.0.3 built from the working tree) |
| 107 | 122 | |
| @@ -112,6 +127,12 @@ The test binary plays a fake mm (`fakemm_test.go`): started again with `MINIME_F | ||
| 112 | 127 | | `cancel` | story cut after **3.0 s** (`cancelled`, 394 characters kept); the next question on the same Agent answered at once, without knowing the story, which mm dropped | |
| 113 | 128 | | `permission` | `pwd` refused: the model **invented** an answer (`/root`); allowed: the real directory. A refusal does not stop a small model from guessing. | |
| 114 | 129 | |
| 130 | +Added on 2026-11-10 (same host and model, mm built from branch `feature/acp-client-tools`): | |
| 131 | + | |
| 132 | +| Example | Result | | |
| 133 | +|---|---| | |
| 134 | +| `clienttools` | `client_add_todo` called twice (`buy milk`, `write the blog post`), `client_list_todos` twice; the answer lists both items, and the program's own list holds them. Same result with `ToolDiscovery: DiscoveryRequest` and `acp.clientTools.discovery: request`. | | |
| 135 | + | |
| 115 | 136 | ## When to choose this, and when not |
| 116 | 137 | |
| 117 | 138 | - **This client** needs the `mm` binary, but works today with no change to mm, and isolates each agent in its own process (mm's global state is no problem). |
| @@ -38,6 +38,7 @@ go run ./examples/complete # not streamed | |||
| 38 | go run ./examples/stream # streamed, with the tool calls | 38 | go run ./examples/stream # streamed, with the tool calls |
| 39 | go run ./examples/cancel # cancelled after 3 seconds | 39 | go run ./examples/cancel # cancelled after 3 seconds |
| 40 | go run ./examples/permission # a command refused, then allowed | 40 | go run ./examples/permission # a command refused, then allowed |
| 41 | +go run ./examples/clienttools # a to-do list in this program, offered to the agent as two tools | ||
| 41 | ``` | 42 | ``` |
| 42 | 43 | ||
| 43 | `mm` must be on the PATH, or named by `MM_BIN`. The examples use `agent.yaml` (Qwen3.5 4B on llmman, bash only, `yolo: false`); any agent file works. Go 1.24 or later (range-over-func). | 44 | `mm` must be on the PATH, or named by `MM_BIN`. The examples use `agent.yaml` (Qwen3.5 4B on llmman, bash only, `yolo: false`); any agent file works. Go 1.24 or later (range-over-func). |
| @@ -58,6 +59,8 @@ go mod tidy | |||
| 58 | | `Options.Cwd` | the session's working directory, where the tools run. Default: the current directory. | | 59 | | `Options.Cwd` | the session's working directory, where the tools run. Default: the current directory. | |
| 59 | | `Options.MM` | the mm binary. Default: `$MM_BIN`, then `mm` on the PATH. | | 60 | | `Options.MM` | the mm binary. Default: `$MM_BIN`, then `mm` on the PATH. | |
| 60 | | `Options.OnPermission func(ctx, ToolCall) Decision` | asked when the agent wants to run a tool that needs a confirmation (`yolo: false`). **Without it, every such call is refused.** | | 61 | | `Options.OnPermission func(ctx, ToolCall) Decision` | asked when the agent wants to run a tool that needs a confirmation (`yolo: false`). **Without it, every such call is refused.** | |
| 62 | +| `Options.Tools []Tool` | Go functions offered to the agent as tools (mm's [ACP client tools](../../docs/en/reference/acp-client-tools.md), `acp.clientTools`, on by default). The model sees `client_<Name>`; each call runs `Handle`. `Start` refuses a tool with no `Name`, no `Handle`, a duplicate name, or a `Method` that does not start with `_`. | | ||
| 63 | +| `Options.ToolDiscovery` | `minime.DiscoveryMeta` (default: the tools travel in `initialize`) or `minime.DiscoveryRequest` (only `true` travels; the list is answered to `_org.mini-me/tools/list`). Must match `acp.clientTools.discovery` in the agent file. | | ||
| 61 | | `Options.OnStderr func(line string)` | mm's stderr, line by line (banner, warnings, `[acp]` lines). Otherwise it is read and dropped; its last line is added to errors. | | 64 | | `Options.OnStderr func(line string)` | mm's stderr, line by line (banner, warnings, `[acp]` lines). Otherwise it is read and dropped; its last line is added to errors. | |
| 62 | | `agent.Complete(ctx, prompt) (Result, error)` | one turn; returns `Result{Text, StopReason, Usage}` once it is over. | | 65 | | `agent.Complete(ctx, prompt) (Result, error)` | one turn; returns `Result{Text, StopReason, Usage}` once it is over. | |
| 63 | | `agent.Stream(ctx, prompt) iter.Seq2[Event, error]` | one turn as a range-over-func sequence; the last event is `KindDone`. Leaving the loop early cancels the turn. | | 66 | | `agent.Stream(ctx, prompt) iter.Seq2[Event, error]` | one turn as a range-over-func sequence; the last event is `KindDone`. Leaving the loop early cancels the turn. | |
| @@ -92,16 +95,28 @@ Rules worth knowing: | |||
| 92 | - **One Agent = one mm process = one conversation.** Calls continue it, as in an editor. One turn at a time per Agent (`ErrBusy`); start several Agents to work in parallel. | 95 | - **One Agent = one mm process = one conversation.** Calls continue it, as in an editor. One turn at a time per Agent (`ErrBusy`); start several Agents to work in parallel. |
| 93 | - **A turn mm ends with an error returns it** with mm's own words and the last line of its stderr, e.g. `minime: turn failed: Internal error (-32603): the model repeated the same bash call 6 times…`. If mm crashed, the error says `mm exited: exit status N`. | 96 | - **A turn mm ends with an error returns it** with mm's own words and the last line of its stderr, e.g. `minime: turn failed: Internal error (-32603): the model repeated the same bash call 6 times…`. If mm crashed, the error says `mm exited: exit status N`. |
| 94 | - **A cancelled question is dropped from the conversation by mm**: the next question does not know about it. | 97 | - **A cancelled question is dropped from the conversation by mm**: the next question does not know about it. |
| 95 | -- The client advertises no file-system, terminal or form capability: mm needs none, and its `ask_user` tool is not offered. | 98 | +- The client advertises no file-system, terminal or form capability: mm needs none, and its `ask_user` tool is not offered. Its only capability is `_meta["org.mini-me/tools"]`, when `Options.Tools` has some. |
| 99 | + | ||
| 100 | +`Tool`, an entry of `Options.Tools`: | ||
| 101 | + | ||
| 102 | +| Field | | | ||
| 103 | +|---|---| | ||
| 104 | +| `Name` | required, unique; the model sees `client_<Name>`. | | ||
| 105 | +| `Description` | what the model reads to decide when to call it. | | ||
| 106 | +| `Method` | the ACP extension method mm calls; must start with `_`. Default: `_minime.go/tools/<Name>`. | | ||
| 107 | +| `InputSchema map[string]any` | JSON schema of the arguments (`type: object`); optional. | | ||
| 108 | +| `Handle func(ctx, ToolCallRequest) (string, error)` | required. `ToolCallRequest{SessionID, Arguments}`. The text goes back to the model; an error (or a panic) goes back as a failed call (`isError: true`) and the client lives on. Calls may run concurrently. | | ||
| 109 | + | ||
| 110 | +With `yolo: false` in the agent file, each call is first submitted to `OnPermission`, titled `client: <Name>`. | ||
| 96 | 111 | ||
| 97 | ## Tests | 112 | ## Tests |
| 98 | 113 | ||
| 99 | ```bash | 114 | ```bash |
| 100 | cd demo.acp.sdk/go | 115 | cd demo.acp.sdk/go |
| 101 | -go test ./... # 40 tests and subtests, no model, no mm, ~1 s | 116 | +go test ./... # 47 tests and subtests, no model, no mm, ~1 s |
| 102 | ``` | 117 | ``` |
| 103 | 118 | ||
| 104 | -The test binary plays a fake mm (`fakemm_test.go`): started again with `MINIME_FAKE_MM=1`, it answers over ACP with mm's message shapes (four permission options, `rawOutput.output`, usage on the prompt's answer): answers, a thought, a tool call with its permission, a slow turn to cancel, a turn deaf to the cancel, an error, a crash. `go test -race` needs cgo and a C compiler. | 119 | +The test binary plays a fake mm (`fakemm_test.go`): started again with `MINIME_FAKE_MM=1`, it answers over ACP with mm's message shapes (four permission options, `rawOutput.output`, usage on the prompt's answer): answers, a thought, a tool call with its permission, a slow turn to cancel, a turn deaf to the cancel, an error, a crash; and, like mm, it learns the client tools (from `initialize`, or with `_org.mini-me/tools/list` at `session/new`) and calls one. `go test -race` needs cgo and a C compiler. |
| 105 | 120 | ||
| 106 | ## Measured (2026-11-01, llmman on the author's host, mm v1.0.3 built from the working tree) | 121 | ## Measured (2026-11-01, llmman on the author's host, mm v1.0.3 built from the working tree) |
| 107 | 122 | ||
| @@ -112,6 +127,12 @@ The test binary plays a fake mm (`fakemm_test.go`): started again with `MINIME_F | |||
| 112 | | `cancel` | story cut after **3.0 s** (`cancelled`, 394 characters kept); the next question on the same Agent answered at once, without knowing the story, which mm dropped | | 127 | | `cancel` | story cut after **3.0 s** (`cancelled`, 394 characters kept); the next question on the same Agent answered at once, without knowing the story, which mm dropped | |
| 113 | | `permission` | `pwd` refused: the model **invented** an answer (`/root`); allowed: the real directory. A refusal does not stop a small model from guessing. | | 128 | | `permission` | `pwd` refused: the model **invented** an answer (`/root`); allowed: the real directory. A refusal does not stop a small model from guessing. | |
| 114 | 129 | ||
| 130 | +Added on 2026-11-10 (same host and model, mm built from branch `feature/acp-client-tools`): | ||
| 131 | + | ||
| 132 | +| Example | Result | | ||
| 133 | +|---|---| | ||
| 134 | +| `clienttools` | `client_add_todo` called twice (`buy milk`, `write the blog post`), `client_list_todos` twice; the answer lists both items, and the program's own list holds them. Same result with `ToolDiscovery: DiscoveryRequest` and `acp.clientTools.discovery: request`. | | ||
| 135 | + | ||
| 115 | ## When to choose this, and when not | 136 | ## When to choose this, and when not |
| 116 | 137 | ||
| 117 | - **This client** needs the `mm` binary, but works today with no change to mm, and isolates each agent in its own process (mm's global state is no problem). | 138 | - **This client** needs the `mm` binary, but works today with no change to mm, and isolates each agent in its own process (mm's global state is no problem). |
modified
demo.acp.sdk/go/README.fr.md +24 -3 | @@ -38,6 +38,7 @@ go run ./examples/complete # sans stream | ||
| 38 | 38 | go run ./examples/stream # en stream, avec les appels d'outils |
| 39 | 39 | go run ./examples/cancel # annulé au bout de 3 secondes |
| 40 | 40 | go run ./examples/permission # une commande refusée, puis autorisée |
| 41 | +go run ./examples/clienttools # une liste de tâches dans ce programme, proposée à l'agent sous forme de deux outils | |
| 41 | 42 | ``` |
| 42 | 43 | |
| 43 | 44 | `mm` doit être dans le PATH, ou désigné par `MM_BIN`. Les exemples utilisent `agent.yaml` (Qwen3.5 4B sur llmman, bash seulement, `yolo: false`) ; tout fichier d'agent fonctionne. Go 1.24 ou plus récent (range-over-func). |
| @@ -58,6 +59,8 @@ go mod tidy | ||
| 58 | 59 | | `Options.Cwd` | le répertoire de travail de la session, là où les outils s'exécutent. Par défaut : le répertoire courant. | |
| 59 | 60 | | `Options.MM` | le binaire mm. Par défaut : `$MM_BIN`, puis `mm` dans le PATH. | |
| 60 | 61 | | `Options.OnPermission func(ctx, ToolCall) Decision` | appelé quand l'agent veut lancer un outil qui demande une confirmation (`yolo: false`). **Sans lui, chacun de ces appels est refusé.** | |
| 62 | +| `Options.Tools []Tool` | des fonctions Go proposées à l'agent comme outils (les [outils du client ACP](../../docs/fr/reference/acp-client-tools.md) de mm, `acp.clientTools`, activés par défaut). Le modèle voit `client_<Name>` ; chaque appel exécute `Handle`. `Start` refuse un outil sans `Name`, sans `Handle`, un nom en double, ou une `Method` qui ne commence pas par `_`. | | |
| 63 | +| `Options.ToolDiscovery` | `minime.DiscoveryMeta` (par défaut : les outils voyagent dans `initialize`) ou `minime.DiscoveryRequest` (seul `true` voyage ; la liste est donnée en réponse à `_org.mini-me/tools/list`). Doit correspondre à `acp.clientTools.discovery` dans le fichier d'agent. | | |
| 61 | 64 | | `Options.OnStderr func(line string)` | le stderr de mm, ligne par ligne (bannière, avertissements, lignes `[acp]`). Sinon il est lu puis jeté ; sa dernière ligne est ajoutée aux erreurs. | |
| 62 | 65 | | `agent.Complete(ctx, prompt) (Result, error)` | un tour ; renvoie `Result{Text, StopReason, Usage}` une fois terminé. | |
| 63 | 66 | | `agent.Stream(ctx, prompt) iter.Seq2[Event, error]` | un tour sous forme de séquence range-over-func ; le dernier événement est `KindDone`. Sortir de la boucle avant la fin annule le tour. | |
| @@ -92,16 +95,28 @@ Les règles à connaître : | ||
| 92 | 95 | - **Un Agent = un processus mm = une conversation.** Chaque appel la continue, comme dans un éditeur. Un seul tour à la fois par Agent (`ErrBusy`) ; lancez plusieurs Agents pour travailler en parallèle. |
| 93 | 96 | - **Un tour que mm termine par une erreur la renvoie**, avec les mots de mm et la dernière ligne de son stderr, par exemple `minime: turn failed: Internal error (-32603): the model repeated the same bash call 6 times…`. Si mm a planté, l'erreur dit `mm exited: exit status N`. |
| 94 | 97 | - **mm retire de la conversation une question annulée** : la question suivante n'en sait rien. |
| 95 | -- Le client n'annonce aucune capacité fichiers, terminal ou formulaire : mm n'en a pas besoin, et son outil `ask_user` n'est pas proposé. | |
| 98 | +- Le client n'annonce aucune capacité fichiers, terminal ou formulaire : mm n'en a pas besoin, et son outil `ask_user` n'est pas proposé. Sa seule capacité est `_meta["org.mini-me/tools"]`, quand `Options.Tools` en contient. | |
| 99 | + | |
| 100 | +`Tool`, une entrée de `Options.Tools` : | |
| 101 | + | |
| 102 | +| Champ | | | |
| 103 | +|---|---| | |
| 104 | +| `Name` | obligatoire, unique ; le modèle voit `client_<Name>`. | | |
| 105 | +| `Description` | ce que le modèle lit pour décider quand l'appeler. | | |
| 106 | +| `Method` | la méthode d'extension ACP qu'appelle mm ; doit commencer par `_`. Par défaut : `_minime.go/tools/<Name>`. | | |
| 107 | +| `InputSchema map[string]any` | schéma JSON des arguments (`type: object`) ; optionnel. | | |
| 108 | +| `Handle func(ctx, ToolCallRequest) (string, error)` | obligatoire. `ToolCallRequest{SessionID, Arguments}`. Le texte retourne au modèle ; une erreur (ou un panic) retourne comme un appel en échec (`isError: true`) et le client continue. Les appels peuvent s'exécuter en parallèle. | | |
| 109 | + | |
| 110 | +Avec `yolo: false` dans le fichier d'agent, chaque appel passe d'abord par `OnPermission`, avec le titre `client: <Name>`. | |
| 96 | 111 | |
| 97 | 112 | ## Tests |
| 98 | 113 | |
| 99 | 114 | ```bash |
| 100 | 115 | cd demo.acp.sdk/go |
| 101 | -go test ./... # 40 tests et sous-tests, sans modèle ni mm, ~1 s | |
| 116 | +go test ./... # 47 tests et sous-tests, sans modèle ni mm, ~1 s | |
| 102 | 117 | ``` |
| 103 | 118 | |
| 104 | -Le binaire de test joue un faux mm (`fakemm_test.go`) : relancé avec `MINIME_FAKE_MM=1`, il répond en ACP avec les formats de messages de mm (quatre options de permission, `rawOutput.output`, usage dans la réponse au prompt) : des réponses, une réflexion, un appel d'outil avec sa permission, un tour lent à annuler, un tour sourd à l'annulation, une erreur, un plantage. `go test -race` demande cgo et un compilateur C. | |
| 119 | +Le binaire de test joue un faux mm (`fakemm_test.go`) : relancé avec `MINIME_FAKE_MM=1`, il répond en ACP avec les formats de messages de mm (quatre options de permission, `rawOutput.output`, usage dans la réponse au prompt) : des réponses, une réflexion, un appel d'outil avec sa permission, un tour lent à annuler, un tour sourd à l'annulation, une erreur, un plantage ; et, comme mm, il découvre les outils du client (dans `initialize`, ou par `_org.mini-me/tools/list` à `session/new`) et en appelle un. `go test -race` demande cgo et un compilateur C. | |
| 105 | 120 | |
| 106 | 121 | ## Mesures (2026-11-01, llmman sur la machine de l'auteur, mm v1.0.3 compilé depuis l'arbre de travail) |
| 107 | 122 | |
| @@ -112,6 +127,12 @@ Le binaire de test joue un faux mm (`fakemm_test.go`) : relancé avec `MINIME_FA | ||
| 112 | 127 | | `cancel` | histoire coupée au bout de **3,0 s** (`cancelled`, 394 caractères gardés) ; la question suivante sur le même Agent a eu sa réponse aussitôt, sans connaître l'histoire, que mm a retirée | |
| 113 | 128 | | `permission` | `pwd` refusé : le modèle a **inventé** une réponse (`/root`) ; autorisé : le vrai répertoire. Un refus n'empêche pas un petit modèle de deviner. | |
| 114 | 129 | |
| 130 | +Ajouté le 2026-11-10 (même machine et même modèle, mm compilé depuis la branche `feature/acp-client-tools`) : | |
| 131 | + | |
| 132 | +| Exemple | Résultat | | |
| 133 | +|---|---| | |
| 134 | +| `clienttools` | `client_add_todo` appelé deux fois (`buy milk`, `write the blog post`), `client_list_todos` deux fois ; la réponse liste les deux éléments, et la liste du programme les contient. Même résultat avec `ToolDiscovery: DiscoveryRequest` et `acp.clientTools.discovery: request`. | | |
| 135 | + | |
| 115 | 136 | ## Quand le choisir, et quand ne pas le choisir |
| 116 | 137 | |
| 117 | 138 | - **Ce client** a besoin du binaire `mm`, mais il marche dès aujourd'hui sans rien changer à mm, et il isole chaque agent dans son propre processus (l'état global de mm ne pose aucun problème). |
| @@ -38,6 +38,7 @@ go run ./examples/complete # sans stream | |||
| 38 | go run ./examples/stream # en stream, avec les appels d'outils | 38 | go run ./examples/stream # en stream, avec les appels d'outils |
| 39 | go run ./examples/cancel # annulé au bout de 3 secondes | 39 | go run ./examples/cancel # annulé au bout de 3 secondes |
| 40 | go run ./examples/permission # une commande refusée, puis autorisée | 40 | go run ./examples/permission # une commande refusée, puis autorisée |
| 41 | +go run ./examples/clienttools # une liste de tâches dans ce programme, proposée à l'agent sous forme de deux outils | ||
| 41 | ``` | 42 | ``` |
| 42 | 43 | ||
| 43 | `mm` doit être dans le PATH, ou désigné par `MM_BIN`. Les exemples utilisent `agent.yaml` (Qwen3.5 4B sur llmman, bash seulement, `yolo: false`) ; tout fichier d'agent fonctionne. Go 1.24 ou plus récent (range-over-func). | 44 | `mm` doit être dans le PATH, ou désigné par `MM_BIN`. Les exemples utilisent `agent.yaml` (Qwen3.5 4B sur llmman, bash seulement, `yolo: false`) ; tout fichier d'agent fonctionne. Go 1.24 ou plus récent (range-over-func). |
| @@ -58,6 +59,8 @@ go mod tidy | |||
| 58 | | `Options.Cwd` | le répertoire de travail de la session, là où les outils s'exécutent. Par défaut : le répertoire courant. | | 59 | | `Options.Cwd` | le répertoire de travail de la session, là où les outils s'exécutent. Par défaut : le répertoire courant. | |
| 59 | | `Options.MM` | le binaire mm. Par défaut : `$MM_BIN`, puis `mm` dans le PATH. | | 60 | | `Options.MM` | le binaire mm. Par défaut : `$MM_BIN`, puis `mm` dans le PATH. | |
| 60 | | `Options.OnPermission func(ctx, ToolCall) Decision` | appelé quand l'agent veut lancer un outil qui demande une confirmation (`yolo: false`). **Sans lui, chacun de ces appels est refusé.** | | 61 | | `Options.OnPermission func(ctx, ToolCall) Decision` | appelé quand l'agent veut lancer un outil qui demande une confirmation (`yolo: false`). **Sans lui, chacun de ces appels est refusé.** | |
| 62 | +| `Options.Tools []Tool` | des fonctions Go proposées à l'agent comme outils (les [outils du client ACP](../../docs/fr/reference/acp-client-tools.md) de mm, `acp.clientTools`, activés par défaut). Le modèle voit `client_<Name>` ; chaque appel exécute `Handle`. `Start` refuse un outil sans `Name`, sans `Handle`, un nom en double, ou une `Method` qui ne commence pas par `_`. | | ||
| 63 | +| `Options.ToolDiscovery` | `minime.DiscoveryMeta` (par défaut : les outils voyagent dans `initialize`) ou `minime.DiscoveryRequest` (seul `true` voyage ; la liste est donnée en réponse à `_org.mini-me/tools/list`). Doit correspondre à `acp.clientTools.discovery` dans le fichier d'agent. | | ||
| 61 | | `Options.OnStderr func(line string)` | le stderr de mm, ligne par ligne (bannière, avertissements, lignes `[acp]`). Sinon il est lu puis jeté ; sa dernière ligne est ajoutée aux erreurs. | | 64 | | `Options.OnStderr func(line string)` | le stderr de mm, ligne par ligne (bannière, avertissements, lignes `[acp]`). Sinon il est lu puis jeté ; sa dernière ligne est ajoutée aux erreurs. | |
| 62 | | `agent.Complete(ctx, prompt) (Result, error)` | un tour ; renvoie `Result{Text, StopReason, Usage}` une fois terminé. | | 65 | | `agent.Complete(ctx, prompt) (Result, error)` | un tour ; renvoie `Result{Text, StopReason, Usage}` une fois terminé. | |
| 63 | | `agent.Stream(ctx, prompt) iter.Seq2[Event, error]` | un tour sous forme de séquence range-over-func ; le dernier événement est `KindDone`. Sortir de la boucle avant la fin annule le tour. | | 66 | | `agent.Stream(ctx, prompt) iter.Seq2[Event, error]` | un tour sous forme de séquence range-over-func ; le dernier événement est `KindDone`. Sortir de la boucle avant la fin annule le tour. | |
| @@ -92,16 +95,28 @@ Les règles à connaître : | |||
| 92 | - **Un Agent = un processus mm = une conversation.** Chaque appel la continue, comme dans un éditeur. Un seul tour à la fois par Agent (`ErrBusy`) ; lancez plusieurs Agents pour travailler en parallèle. | 95 | - **Un Agent = un processus mm = une conversation.** Chaque appel la continue, comme dans un éditeur. Un seul tour à la fois par Agent (`ErrBusy`) ; lancez plusieurs Agents pour travailler en parallèle. |
| 93 | - **Un tour que mm termine par une erreur la renvoie**, avec les mots de mm et la dernière ligne de son stderr, par exemple `minime: turn failed: Internal error (-32603): the model repeated the same bash call 6 times…`. Si mm a planté, l'erreur dit `mm exited: exit status N`. | 96 | - **Un tour que mm termine par une erreur la renvoie**, avec les mots de mm et la dernière ligne de son stderr, par exemple `minime: turn failed: Internal error (-32603): the model repeated the same bash call 6 times…`. Si mm a planté, l'erreur dit `mm exited: exit status N`. |
| 94 | - **mm retire de la conversation une question annulée** : la question suivante n'en sait rien. | 97 | - **mm retire de la conversation une question annulée** : la question suivante n'en sait rien. |
| 95 | -- Le client n'annonce aucune capacité fichiers, terminal ou formulaire : mm n'en a pas besoin, et son outil `ask_user` n'est pas proposé. | 98 | +- Le client n'annonce aucune capacité fichiers, terminal ou formulaire : mm n'en a pas besoin, et son outil `ask_user` n'est pas proposé. Sa seule capacité est `_meta["org.mini-me/tools"]`, quand `Options.Tools` en contient. |
| 99 | + | ||
| 100 | +`Tool`, une entrée de `Options.Tools` : | ||
| 101 | + | ||
| 102 | +| Champ | | | ||
| 103 | +|---|---| | ||
| 104 | +| `Name` | obligatoire, unique ; le modèle voit `client_<Name>`. | | ||
| 105 | +| `Description` | ce que le modèle lit pour décider quand l'appeler. | | ||
| 106 | +| `Method` | la méthode d'extension ACP qu'appelle mm ; doit commencer par `_`. Par défaut : `_minime.go/tools/<Name>`. | | ||
| 107 | +| `InputSchema map[string]any` | schéma JSON des arguments (`type: object`) ; optionnel. | | ||
| 108 | +| `Handle func(ctx, ToolCallRequest) (string, error)` | obligatoire. `ToolCallRequest{SessionID, Arguments}`. Le texte retourne au modèle ; une erreur (ou un panic) retourne comme un appel en échec (`isError: true`) et le client continue. Les appels peuvent s'exécuter en parallèle. | | ||
| 109 | + | ||
| 110 | +Avec `yolo: false` dans le fichier d'agent, chaque appel passe d'abord par `OnPermission`, avec le titre `client: <Name>`. | ||
| 96 | 111 | ||
| 97 | ## Tests | 112 | ## Tests |
| 98 | 113 | ||
| 99 | ```bash | 114 | ```bash |
| 100 | cd demo.acp.sdk/go | 115 | cd demo.acp.sdk/go |
| 101 | -go test ./... # 40 tests et sous-tests, sans modèle ni mm, ~1 s | 116 | +go test ./... # 47 tests et sous-tests, sans modèle ni mm, ~1 s |
| 102 | ``` | 117 | ``` |
| 103 | 118 | ||
| 104 | -Le binaire de test joue un faux mm (`fakemm_test.go`) : relancé avec `MINIME_FAKE_MM=1`, il répond en ACP avec les formats de messages de mm (quatre options de permission, `rawOutput.output`, usage dans la réponse au prompt) : des réponses, une réflexion, un appel d'outil avec sa permission, un tour lent à annuler, un tour sourd à l'annulation, une erreur, un plantage. `go test -race` demande cgo et un compilateur C. | 119 | +Le binaire de test joue un faux mm (`fakemm_test.go`) : relancé avec `MINIME_FAKE_MM=1`, il répond en ACP avec les formats de messages de mm (quatre options de permission, `rawOutput.output`, usage dans la réponse au prompt) : des réponses, une réflexion, un appel d'outil avec sa permission, un tour lent à annuler, un tour sourd à l'annulation, une erreur, un plantage ; et, comme mm, il découvre les outils du client (dans `initialize`, ou par `_org.mini-me/tools/list` à `session/new`) et en appelle un. `go test -race` demande cgo et un compilateur C. |
| 105 | 120 | ||
| 106 | ## Mesures (2026-11-01, llmman sur la machine de l'auteur, mm v1.0.3 compilé depuis l'arbre de travail) | 121 | ## Mesures (2026-11-01, llmman sur la machine de l'auteur, mm v1.0.3 compilé depuis l'arbre de travail) |
| 107 | 122 | ||
| @@ -112,6 +127,12 @@ Le binaire de test joue un faux mm (`fakemm_test.go`) : relancé avec `MINIME_FA | |||
| 112 | | `cancel` | histoire coupée au bout de **3,0 s** (`cancelled`, 394 caractères gardés) ; la question suivante sur le même Agent a eu sa réponse aussitôt, sans connaître l'histoire, que mm a retirée | | 127 | | `cancel` | histoire coupée au bout de **3,0 s** (`cancelled`, 394 caractères gardés) ; la question suivante sur le même Agent a eu sa réponse aussitôt, sans connaître l'histoire, que mm a retirée | |
| 113 | | `permission` | `pwd` refusé : le modèle a **inventé** une réponse (`/root`) ; autorisé : le vrai répertoire. Un refus n'empêche pas un petit modèle de deviner. | | 128 | | `permission` | `pwd` refusé : le modèle a **inventé** une réponse (`/root`) ; autorisé : le vrai répertoire. Un refus n'empêche pas un petit modèle de deviner. | |
| 114 | 129 | ||
| 130 | +Ajouté le 2026-11-10 (même machine et même modèle, mm compilé depuis la branche `feature/acp-client-tools`) : | ||
| 131 | + | ||
| 132 | +| Exemple | Résultat | | ||
| 133 | +|---|---| | ||
| 134 | +| `clienttools` | `client_add_todo` appelé deux fois (`buy milk`, `write the blog post`), `client_list_todos` deux fois ; la réponse liste les deux éléments, et la liste du programme les contient. Même résultat avec `ToolDiscovery: DiscoveryRequest` et `acp.clientTools.discovery: request`. | | ||
| 135 | + | ||
| 115 | ## Quand le choisir, et quand ne pas le choisir | 136 | ## Quand le choisir, et quand ne pas le choisir |
| 116 | 137 | ||
| 117 | - **Ce client** a besoin du binaire `mm`, mais il marche dès aujourd'hui sans rien changer à mm, et il isole chaque agent dans son propre processus (l'état global de mm ne pose aucun problème). | 138 | - **Ce client** a besoin du binaire `mm`, mais il marche dès aujourd'hui sans rien changer à mm, et il isole chaque agent dans son propre processus (l'état global de mm ne pose aucun problème). |
modified
demo.acp.sdk/go/agent.go +11 -5 | @@ -65,6 +65,10 @@ func Start(ctx context.Context, opts Options) (*Agent, error) { | ||
| 65 | 65 | if err != nil { |
| 66 | 66 | return nil, err |
| 67 | 67 | } |
| 68 | + tools, err := newToolbox(opts.Tools, opts.ToolDiscovery) | |
| 69 | + if err != nil { | |
| 70 | + return nil, err | |
| 71 | + } | |
| 68 | 72 | stdin, err := cmd.StdinPipe() |
| 69 | 73 | if err != nil { |
| 70 | 74 | return nil, fmt.Errorf("minime: %w", err) |
| @@ -84,7 +88,7 @@ func Start(ctx context.Context, opts Options) (*Agent, error) { | ||
| 84 | 88 | a := &Agent{ |
| 85 | 89 | cmd: cmd, |
| 86 | 90 | stdin: stdin, |
| 87 | - client: &client{onPermission: opts.OnPermission}, | |
| 91 | + client: &client{onPermission: opts.OnPermission, tools: tools}, | |
| 88 | 92 | cwd: cwd, |
| 89 | 93 | stderr: &stderrTail{}, |
| 90 | 94 | exited: make(chan struct{}), |
| @@ -137,11 +141,13 @@ func command(opts Options) (*exec.Cmd, string, error) { | ||
| 137 | 141 | func (a *Agent) open(ctx context.Context) error { |
| 138 | 142 | ctx, cancel := context.WithTimeout(ctx, startTimeout) |
| 139 | 143 | defer cancel() |
| 140 | - // No client capabilities: no file system or terminal for mm to call back | |
| 141 | - // (mm does not use them), and no form support, so ask_user is not offered. | |
| 144 | + // No file system or terminal for mm to call back (mm does not use them), | |
| 145 | + // and no form support, so ask_user is not offered. The only capability | |
| 146 | + // is the client tools, in _meta, when Options.Tools has some. | |
| 142 | 147 | _, err := a.conn.Initialize(ctx, sdk.InitializeRequest{ |
| 143 | - ProtocolVersion: sdk.ProtocolVersionNumber, | |
| 144 | - ClientInfo: &sdk.Implementation{Name: "minime-go"}, | |
| 148 | + ProtocolVersion: sdk.ProtocolVersionNumber, | |
| 149 | + ClientInfo: &sdk.Implementation{Name: "minime-go"}, | |
| 150 | + ClientCapabilities: sdk.ClientCapabilities{Meta: a.client.tools.capabilities()}, | |
| 145 | 151 | }) |
| 146 | 152 | if err != nil { |
| 147 | 153 | return a.failure("did not start", err) |
| @@ -65,6 +65,10 @@ func Start(ctx context.Context, opts Options) (*Agent, error) { | |||
| 65 | if err != nil { | 65 | if err != nil { |
| 66 | return nil, err | 66 | return nil, err |
| 67 | } | 67 | } |
| 68 | + tools, err := newToolbox(opts.Tools, opts.ToolDiscovery) | ||
| 69 | + if err != nil { | ||
| 70 | + return nil, err | ||
| 71 | + } | ||
| 68 | stdin, err := cmd.StdinPipe() | 72 | stdin, err := cmd.StdinPipe() |
| 69 | if err != nil { | 73 | if err != nil { |
| 70 | return nil, fmt.Errorf("minime: %w", err) | 74 | return nil, fmt.Errorf("minime: %w", err) |
| @@ -84,7 +88,7 @@ func Start(ctx context.Context, opts Options) (*Agent, error) { | |||
| 84 | a := &Agent{ | 88 | a := &Agent{ |
| 85 | cmd: cmd, | 89 | cmd: cmd, |
| 86 | stdin: stdin, | 90 | stdin: stdin, |
| 87 | - client: &client{onPermission: opts.OnPermission}, | 91 | + client: &client{onPermission: opts.OnPermission, tools: tools}, |
| 88 | cwd: cwd, | 92 | cwd: cwd, |
| 89 | stderr: &stderrTail{}, | 93 | stderr: &stderrTail{}, |
| 90 | exited: make(chan struct{}), | 94 | exited: make(chan struct{}), |
| @@ -137,11 +141,13 @@ func command(opts Options) (*exec.Cmd, string, error) { | |||
| 137 | func (a *Agent) open(ctx context.Context) error { | 141 | func (a *Agent) open(ctx context.Context) error { |
| 138 | ctx, cancel := context.WithTimeout(ctx, startTimeout) | 142 | ctx, cancel := context.WithTimeout(ctx, startTimeout) |
| 139 | defer cancel() | 143 | defer cancel() |
| 140 | - // No client capabilities: no file system or terminal for mm to call back | 144 | + // No file system or terminal for mm to call back (mm does not use them), |
| 141 | - // (mm does not use them), and no form support, so ask_user is not offered. | 145 | + // and no form support, so ask_user is not offered. The only capability |
| 146 | + // is the client tools, in _meta, when Options.Tools has some. | ||
| 142 | _, err := a.conn.Initialize(ctx, sdk.InitializeRequest{ | 147 | _, err := a.conn.Initialize(ctx, sdk.InitializeRequest{ |
| 143 | - ProtocolVersion: sdk.ProtocolVersionNumber, | 148 | + ProtocolVersion: sdk.ProtocolVersionNumber, |
| 144 | - ClientInfo: &sdk.Implementation{Name: "minime-go"}, | 149 | + ClientInfo: &sdk.Implementation{Name: "minime-go"}, |
| 150 | + ClientCapabilities: sdk.ClientCapabilities{Meta: a.client.tools.capabilities()}, | ||
| 145 | }) | 151 | }) |
| 146 | if err != nil { | 152 | if err != nil { |
| 147 | return a.failure("did not start", err) | 153 | return a.failure("did not start", err) |
modified
demo.acp.sdk/go/client.go +13 -1 | @@ -2,6 +2,7 @@ package minime | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | + "encoding/json" | |
| 5 | 6 | "strings" |
| 6 | 7 | "sync" |
| 7 | 8 | "sync/atomic" |
| @@ -62,12 +63,23 @@ func (t *turn) answer() string { | ||
| 62 | 63 | // and its permission requests. It implements sdk.Client. |
| 63 | 64 | type client struct { |
| 64 | 65 | onPermission func(ctx context.Context, call ToolCall) Decision |
| 66 | + tools *toolbox // the Options.Tools, answered in HandleExtensionMethod | |
| 65 | 67 | |
| 66 | 68 | mu sync.Mutex |
| 67 | 69 | current *turn // nil between turns |
| 68 | 70 | } |
| 69 | 71 | |
| 70 | -var _ sdk.Client = (*client)(nil) | |
| 72 | +var ( | |
| 73 | + _ sdk.Client = (*client)(nil) | |
| 74 | + _ sdk.ExtensionMethodHandler = (*client)(nil) | |
| 75 | +) | |
| 76 | + | |
| 77 | +// HandleExtensionMethod answers mm's extension requests: the tools list and | |
| 78 | +// the tool calls (tools.go). The SDK runs each request in its own goroutine, | |
| 79 | +// so a list request arriving while session/new waits is answered. | |
| 80 | +func (c *client) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { | |
| 81 | + return c.tools.handle(ctx, method, params) | |
| 82 | +} | |
| 71 | 83 | |
| 72 | 84 | func (c *client) begin(t *turn) { |
| 73 | 85 | c.mu.Lock() |
| @@ -2,6 +2,7 @@ package minime | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | + "encoding/json" | ||
| 5 | "strings" | 6 | "strings" |
| 6 | "sync" | 7 | "sync" |
| 7 | "sync/atomic" | 8 | "sync/atomic" |
| @@ -62,12 +63,23 @@ func (t *turn) answer() string { | |||
| 62 | // and its permission requests. It implements sdk.Client. | 63 | // and its permission requests. It implements sdk.Client. |
| 63 | type client struct { | 64 | type client struct { |
| 64 | onPermission func(ctx context.Context, call ToolCall) Decision | 65 | onPermission func(ctx context.Context, call ToolCall) Decision |
| 66 | + tools *toolbox // the Options.Tools, answered in HandleExtensionMethod | ||
| 65 | 67 | ||
| 66 | mu sync.Mutex | 68 | mu sync.Mutex |
| 67 | current *turn // nil between turns | 69 | current *turn // nil between turns |
| 68 | } | 70 | } |
| 69 | 71 | ||
| 70 | -var _ sdk.Client = (*client)(nil) | 72 | +var ( |
| 73 | + _ sdk.Client = (*client)(nil) | ||
| 74 | + _ sdk.ExtensionMethodHandler = (*client)(nil) | ||
| 75 | +) | ||
| 76 | + | ||
| 77 | +// HandleExtensionMethod answers mm's extension requests: the tools list and | ||
| 78 | +// the tool calls (tools.go). The SDK runs each request in its own goroutine, | ||
| 79 | +// so a list request arriving while session/new waits is answered. | ||
| 80 | +func (c *client) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { | ||
| 81 | + return c.tools.handle(ctx, method, params) | ||
| 82 | +} | ||
| 71 | 83 | ||
| 72 | func (c *client) begin(t *turn) { | 84 | func (c *client) begin(t *turn) { |
| 73 | c.mu.Lock() | 85 | c.mu.Lock() |
added
demo.acp.sdk/go/examples/clienttools/main.go +100 -0 | new file mode 100644 | ||
| @@ -0,0 +1,100 @@ | ||
| 1 | +// Client tools: a to-do list that lives in THIS program, offered to the agent | |
| 2 | +// as two tools. mm discovers them at initialize (acp.clientTools, on by | |
| 3 | +// default) and the model calls them like its own; each call runs the Go | |
| 4 | +// function below. The list printed at the end is the program's own state. | |
| 5 | +// | |
| 6 | +// agent.yaml says `yolo: false`, so each call is first submitted to | |
| 7 | +// OnPermission: the client tools (client_*) are allowed, anything else is not. | |
| 8 | +// | |
| 9 | +// go run ./examples/clienttools (from demo.acp.sdk/go/) | |
| 10 | +package main | |
| 11 | + | |
| 12 | +import ( | |
| 13 | + "context" | |
| 14 | + "fmt" | |
| 15 | + "log" | |
| 16 | + "strings" | |
| 17 | + "sync" | |
| 18 | + | |
| 19 | + "minime" | |
| 20 | +) | |
| 21 | + | |
| 22 | +// todoList is the client's state: the agent can only reach it through the | |
| 23 | +// two tools. | |
| 24 | +type todoList struct { | |
| 25 | + mu sync.Mutex | |
| 26 | + items []string | |
| 27 | +} | |
| 28 | + | |
| 29 | +func (l *todoList) tools() []minime.Tool { | |
| 30 | + return []minime.Tool{ | |
| 31 | + { | |
| 32 | + Name: "add_todo", | |
| 33 | + Description: "Add one item to the user's to-do list.", | |
| 34 | + InputSchema: map[string]any{ | |
| 35 | + "type": "object", | |
| 36 | + "properties": map[string]any{"item": map[string]any{"type": "string", "description": "The item to add."}}, | |
| 37 | + "required": []any{"item"}, | |
| 38 | + }, | |
| 39 | + Handle: func(_ context.Context, call minime.ToolCallRequest) (string, error) { | |
| 40 | + item, _ := call.Arguments["item"].(string) | |
| 41 | + if strings.TrimSpace(item) == "" { | |
| 42 | + return "", fmt.Errorf("item is empty") | |
| 43 | + } | |
| 44 | + l.mu.Lock() | |
| 45 | + defer l.mu.Unlock() | |
| 46 | + l.items = append(l.items, item) | |
| 47 | + return fmt.Sprintf("added %q (%d item(s) in the list)", item, len(l.items)), nil | |
| 48 | + }, | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + Name: "list_todos", | |
| 52 | + Description: "Show the user's to-do list.", | |
| 53 | + Handle: func(context.Context, minime.ToolCallRequest) (string, error) { | |
| 54 | + l.mu.Lock() | |
| 55 | + defer l.mu.Unlock() | |
| 56 | + if len(l.items) == 0 { | |
| 57 | + return "the list is empty", nil | |
| 58 | + } | |
| 59 | + return "- " + strings.Join(l.items, "\n- "), nil | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +func main() { | |
| 66 | + ctx := context.Background() | |
| 67 | + todos := &todoList{} | |
| 68 | + agent, err := minime.Start(ctx, minime.Options{ | |
| 69 | + Config: "agent.yaml", | |
| 70 | + Tools: todos.tools(), | |
| 71 | + OnPermission: func(_ context.Context, call minime.ToolCall) minime.Decision { | |
| 72 | + if strings.HasPrefix(call.Title, "client: ") { | |
| 73 | + return minime.Allow | |
| 74 | + } | |
| 75 | + return minime.Reject | |
| 76 | + }, | |
| 77 | + }) | |
| 78 | + if err != nil { | |
| 79 | + log.Fatal(err) | |
| 80 | + } | |
| 81 | + defer agent.Close() | |
| 82 | + | |
| 83 | + prompt := "Add 'buy milk' and 'write the blog post' to my to-do list, then show me the list." | |
| 84 | + for ev, err := range agent.Stream(ctx, prompt) { | |
| 85 | + if err != nil { | |
| 86 | + log.Fatal(err) | |
| 87 | + } | |
| 88 | + switch ev.Kind { | |
| 89 | + case minime.KindToolCall: | |
| 90 | + fmt.Printf("🛠️ %s %v\n", ev.Call.Title, ev.Call.Input) | |
| 91 | + case minime.KindToolResult: | |
| 92 | + fmt.Printf(" │ %s\n", strings.ReplaceAll(ev.Output, "\n", "\n │ ")) | |
| 93 | + case minime.KindText: | |
| 94 | + fmt.Print(ev.Text) | |
| 95 | + } | |
| 96 | + } | |
| 97 | + todos.mu.Lock() | |
| 98 | + fmt.Printf("\n\n[the program's list: %q]\n", todos.items) | |
| 99 | + todos.mu.Unlock() | |
| 100 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | +// Client tools: a to-do list that lives in THIS program, offered to the agent | ||
| 2 | +// as two tools. mm discovers them at initialize (acp.clientTools, on by | ||
| 3 | +// default) and the model calls them like its own; each call runs the Go | ||
| 4 | +// function below. The list printed at the end is the program's own state. | ||
| 5 | +// | ||
| 6 | +// agent.yaml says `yolo: false`, so each call is first submitted to | ||
| 7 | +// OnPermission: the client tools (client_*) are allowed, anything else is not. | ||
| 8 | +// | ||
| 9 | +// go run ./examples/clienttools (from demo.acp.sdk/go/) | ||
| 10 | +package main | ||
| 11 | + | ||
| 12 | +import ( | ||
| 13 | + "context" | ||
| 14 | + "fmt" | ||
| 15 | + "log" | ||
| 16 | + "strings" | ||
| 17 | + "sync" | ||
| 18 | + | ||
| 19 | + "minime" | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | +// todoList is the client's state: the agent can only reach it through the | ||
| 23 | +// two tools. | ||
| 24 | +type todoList struct { | ||
| 25 | + mu sync.Mutex | ||
| 26 | + items []string | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +func (l *todoList) tools() []minime.Tool { | ||
| 30 | + return []minime.Tool{ | ||
| 31 | + { | ||
| 32 | + Name: "add_todo", | ||
| 33 | + Description: "Add one item to the user's to-do list.", | ||
| 34 | + InputSchema: map[string]any{ | ||
| 35 | + "type": "object", | ||
| 36 | + "properties": map[string]any{"item": map[string]any{"type": "string", "description": "The item to add."}}, | ||
| 37 | + "required": []any{"item"}, | ||
| 38 | + }, | ||
| 39 | + Handle: func(_ context.Context, call minime.ToolCallRequest) (string, error) { | ||
| 40 | + item, _ := call.Arguments["item"].(string) | ||
| 41 | + if strings.TrimSpace(item) == "" { | ||
| 42 | + return "", fmt.Errorf("item is empty") | ||
| 43 | + } | ||
| 44 | + l.mu.Lock() | ||
| 45 | + defer l.mu.Unlock() | ||
| 46 | + l.items = append(l.items, item) | ||
| 47 | + return fmt.Sprintf("added %q (%d item(s) in the list)", item, len(l.items)), nil | ||
| 48 | + }, | ||
| 49 | + }, | ||
| 50 | + { | ||
| 51 | + Name: "list_todos", | ||
| 52 | + Description: "Show the user's to-do list.", | ||
| 53 | + Handle: func(context.Context, minime.ToolCallRequest) (string, error) { | ||
| 54 | + l.mu.Lock() | ||
| 55 | + defer l.mu.Unlock() | ||
| 56 | + if len(l.items) == 0 { | ||
| 57 | + return "the list is empty", nil | ||
| 58 | + } | ||
| 59 | + return "- " + strings.Join(l.items, "\n- "), nil | ||
| 60 | + }, | ||
| 61 | + }, | ||
| 62 | + } | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func main() { | ||
| 66 | + ctx := context.Background() | ||
| 67 | + todos := &todoList{} | ||
| 68 | + agent, err := minime.Start(ctx, minime.Options{ | ||
| 69 | + Config: "agent.yaml", | ||
| 70 | + Tools: todos.tools(), | ||
| 71 | + OnPermission: func(_ context.Context, call minime.ToolCall) minime.Decision { | ||
| 72 | + if strings.HasPrefix(call.Title, "client: ") { | ||
| 73 | + return minime.Allow | ||
| 74 | + } | ||
| 75 | + return minime.Reject | ||
| 76 | + }, | ||
| 77 | + }) | ||
| 78 | + if err != nil { | ||
| 79 | + log.Fatal(err) | ||
| 80 | + } | ||
| 81 | + defer agent.Close() | ||
| 82 | + | ||
| 83 | + prompt := "Add 'buy milk' and 'write the blog post' to my to-do list, then show me the list." | ||
| 84 | + for ev, err := range agent.Stream(ctx, prompt) { | ||
| 85 | + if err != nil { | ||
| 86 | + log.Fatal(err) | ||
| 87 | + } | ||
| 88 | + switch ev.Kind { | ||
| 89 | + case minime.KindToolCall: | ||
| 90 | + fmt.Printf("🛠️ %s %v\n", ev.Call.Title, ev.Call.Input) | ||
| 91 | + case minime.KindToolResult: | ||
| 92 | + fmt.Printf(" │ %s\n", strings.ReplaceAll(ev.Output, "\n", "\n │ ")) | ||
| 93 | + case minime.KindText: | ||
| 94 | + fmt.Print(ev.Text) | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + todos.mu.Lock() | ||
| 98 | + fmt.Printf("\n\n[the program's list: %q]\n", todos.items) | ||
| 99 | + todos.mu.Unlock() | ||
| 100 | +} | ||
modified
demo.acp.sdk/go/fakemm_test.go +71 -4 | @@ -2,6 +2,7 @@ package minime | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | + "encoding/json" | |
| 5 | 6 | "fmt" |
| 6 | 7 | "io" |
| 7 | 8 | "log/slog" |
| @@ -62,22 +63,81 @@ func runFakeMM() { | ||
| 62 | 63 | // deaf "Once", then ignores session/cancel for 3 s |
| 63 | 64 | // fail a JSON-RPC error carrying mm's explanation under data.error |
| 64 | 65 | // crash a line on stderr, then exit 3 |
| 66 | +// tools the names of the client tools it learnt, "none" when none | |
| 67 | +// call a call of the first client tool, with {"text": <rest of the | |
| 68 | +// prompt>}; the answer is the client's raw answer | |
| 69 | +// | |
| 70 | +// Like mm, it learns the client tools from clientCapabilities._meta at | |
| 71 | +// initialize — the list, or `true`, and then it asks for the list with | |
| 72 | +// _org.mini-me/tools/list at session/new. | |
| 65 | 73 | type fakeAgent struct { |
| 66 | 74 | conn *sdk.AgentSideConnection |
| 67 | 75 | mu sync.Mutex |
| 68 | 76 | sessions int |
| 69 | 77 | turns map[sdk.SessionId]int |
| 78 | + | |
| 79 | + advertised any // clientCapabilities._meta["org.mini-me/tools"] | |
| 80 | + clientTools []json.RawMessage // the specs, from _meta or from the list request | |
| 70 | 81 | } |
| 71 | 82 | |
| 72 | -func (f *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) { | |
| 83 | +func (f *fakeAgent) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) { | |
| 84 | + f.advertised = p.ClientCapabilities.Meta[toolsMetaKey] | |
| 85 | + if list, ok := f.advertised.([]any); ok { | |
| 86 | + raw, _ := json.Marshal(list) | |
| 87 | + _ = json.Unmarshal(raw, &f.clientTools) | |
| 88 | + } | |
| 73 | 89 | return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber, AgentInfo: &sdk.Implementation{Name: "fake-mm"}}, nil |
| 74 | 90 | } |
| 75 | 91 | |
| 76 | -func (f *fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | |
| 92 | +func (f *fakeAgent) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | |
| 77 | 93 | f.mu.Lock() |
| 78 | - defer f.mu.Unlock() | |
| 79 | 94 | f.sessions++ |
| 80 | - return sdk.NewSessionResponse{SessionId: sdk.SessionId(fmt.Sprintf("fake-%d", f.sessions))}, nil | |
| 95 | + sid := sdk.SessionId(fmt.Sprintf("fake-%d", f.sessions)) | |
| 96 | + f.mu.Unlock() | |
| 97 | + if f.advertised == true { | |
| 98 | + raw, err := f.conn.CallExtension(ctx, toolsListMethod, map[string]any{"sessionId": sid, "cwd": p.Cwd}) | |
| 99 | + if err != nil { | |
| 100 | + return sdk.NewSessionResponse{}, err | |
| 101 | + } | |
| 102 | + var list struct { | |
| 103 | + Tools []json.RawMessage `json:"tools"` | |
| 104 | + } | |
| 105 | + _ = json.Unmarshal(raw, &list) | |
| 106 | + f.clientTools = list.Tools | |
| 107 | + } | |
| 108 | + return sdk.NewSessionResponse{SessionId: sid}, nil | |
| 109 | +} | |
| 110 | + | |
| 111 | +// toolNames lists the names of the client tools learnt. | |
| 112 | +func (f *fakeAgent) toolNames() string { | |
| 113 | + var names []string | |
| 114 | + for _, raw := range f.clientTools { | |
| 115 | + var spec toolSpec | |
| 116 | + _ = json.Unmarshal(raw, &spec) | |
| 117 | + names = append(names, spec.Name) | |
| 118 | + } | |
| 119 | + if len(names) == 0 { | |
| 120 | + return "none" | |
| 121 | + } | |
| 122 | + return strings.Join(names, ",") | |
| 123 | +} | |
| 124 | + | |
| 125 | +// callFirstTool calls the first client tool the way mm does: its method, | |
| 126 | +// the arguments as params, the session id in _meta. | |
| 127 | +func (f *fakeAgent) callFirstTool(ctx context.Context, sid sdk.SessionId, text string) string { | |
| 128 | + if len(f.clientTools) == 0 { | |
| 129 | + return "no tool" | |
| 130 | + } | |
| 131 | + var spec toolSpec | |
| 132 | + _ = json.Unmarshal(f.clientTools[0], &spec) | |
| 133 | + raw, err := f.conn.CallExtension(ctx, spec.Method, map[string]any{ | |
| 134 | + "text": text, | |
| 135 | + "_meta": map[string]any{sessionIDMetaKey: string(sid)}, | |
| 136 | + }) | |
| 137 | + if err != nil { | |
| 138 | + return "error: " + err.Error() | |
| 139 | + } | |
| 140 | + return string(raw) | |
| 81 | 141 | } |
| 82 | 142 | |
| 83 | 143 | func (f *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { |
| @@ -111,6 +171,13 @@ func (f *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.Prompt | ||
| 111 | 171 | case "crash": |
| 112 | 172 | fmt.Fprintln(os.Stderr, "fake mm: boom") |
| 113 | 173 | os.Exit(3) |
| 174 | + case "tools": | |
| 175 | + say(sdk.UpdateAgentMessageText(f.toolNames())) | |
| 176 | + return endTurn, nil | |
| 177 | + case "call": | |
| 178 | + rest := strings.TrimSpace(strings.TrimPrefix(p.Prompt[0].Text.Text, "call")) | |
| 179 | + say(sdk.UpdateAgentMessageText(f.callFirstTool(ctx, p.SessionId, rest))) | |
| 180 | + return endTurn, nil | |
| 114 | 181 | } |
| 115 | 182 | say(sdk.UpdateAgentMessageText("?")) |
| 116 | 183 | return endTurn, nil |
| @@ -2,6 +2,7 @@ package minime | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | + "encoding/json" | ||
| 5 | "fmt" | 6 | "fmt" |
| 6 | "io" | 7 | "io" |
| 7 | "log/slog" | 8 | "log/slog" |
| @@ -62,22 +63,81 @@ func runFakeMM() { | |||
| 62 | // deaf "Once", then ignores session/cancel for 3 s | 63 | // deaf "Once", then ignores session/cancel for 3 s |
| 63 | // fail a JSON-RPC error carrying mm's explanation under data.error | 64 | // fail a JSON-RPC error carrying mm's explanation under data.error |
| 64 | // crash a line on stderr, then exit 3 | 65 | // crash a line on stderr, then exit 3 |
| 66 | +// tools the names of the client tools it learnt, "none" when none | ||
| 67 | +// call a call of the first client tool, with {"text": <rest of the | ||
| 68 | +// prompt>}; the answer is the client's raw answer | ||
| 69 | +// | ||
| 70 | +// Like mm, it learns the client tools from clientCapabilities._meta at | ||
| 71 | +// initialize — the list, or `true`, and then it asks for the list with | ||
| 72 | +// _org.mini-me/tools/list at session/new. | ||
| 65 | type fakeAgent struct { | 73 | type fakeAgent struct { |
| 66 | conn *sdk.AgentSideConnection | 74 | conn *sdk.AgentSideConnection |
| 67 | mu sync.Mutex | 75 | mu sync.Mutex |
| 68 | sessions int | 76 | sessions int |
| 69 | turns map[sdk.SessionId]int | 77 | turns map[sdk.SessionId]int |
| 78 | + | ||
| 79 | + advertised any // clientCapabilities._meta["org.mini-me/tools"] | ||
| 80 | + clientTools []json.RawMessage // the specs, from _meta or from the list request | ||
| 70 | } | 81 | } |
| 71 | 82 | ||
| 72 | -func (f *fakeAgent) Initialize(context.Context, sdk.InitializeRequest) (sdk.InitializeResponse, error) { | 83 | +func (f *fakeAgent) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) { |
| 84 | + f.advertised = p.ClientCapabilities.Meta[toolsMetaKey] | ||
| 85 | + if list, ok := f.advertised.([]any); ok { | ||
| 86 | + raw, _ := json.Marshal(list) | ||
| 87 | + _ = json.Unmarshal(raw, &f.clientTools) | ||
| 88 | + } | ||
| 73 | return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber, AgentInfo: &sdk.Implementation{Name: "fake-mm"}}, nil | 89 | return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber, AgentInfo: &sdk.Implementation{Name: "fake-mm"}}, nil |
| 74 | } | 90 | } |
| 75 | 91 | ||
| 76 | -func (f *fakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | 92 | +func (f *fakeAgent) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { |
| 77 | f.mu.Lock() | 93 | f.mu.Lock() |
| 78 | - defer f.mu.Unlock() | ||
| 79 | f.sessions++ | 94 | f.sessions++ |
| 80 | - return sdk.NewSessionResponse{SessionId: sdk.SessionId(fmt.Sprintf("fake-%d", f.sessions))}, nil | 95 | + sid := sdk.SessionId(fmt.Sprintf("fake-%d", f.sessions)) |
| 96 | + f.mu.Unlock() | ||
| 97 | + if f.advertised == true { | ||
| 98 | + raw, err := f.conn.CallExtension(ctx, toolsListMethod, map[string]any{"sessionId": sid, "cwd": p.Cwd}) | ||
| 99 | + if err != nil { | ||
| 100 | + return sdk.NewSessionResponse{}, err | ||
| 101 | + } | ||
| 102 | + var list struct { | ||
| 103 | + Tools []json.RawMessage `json:"tools"` | ||
| 104 | + } | ||
| 105 | + _ = json.Unmarshal(raw, &list) | ||
| 106 | + f.clientTools = list.Tools | ||
| 107 | + } | ||
| 108 | + return sdk.NewSessionResponse{SessionId: sid}, nil | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +// toolNames lists the names of the client tools learnt. | ||
| 112 | +func (f *fakeAgent) toolNames() string { | ||
| 113 | + var names []string | ||
| 114 | + for _, raw := range f.clientTools { | ||
| 115 | + var spec toolSpec | ||
| 116 | + _ = json.Unmarshal(raw, &spec) | ||
| 117 | + names = append(names, spec.Name) | ||
| 118 | + } | ||
| 119 | + if len(names) == 0 { | ||
| 120 | + return "none" | ||
| 121 | + } | ||
| 122 | + return strings.Join(names, ",") | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +// callFirstTool calls the first client tool the way mm does: its method, | ||
| 126 | +// the arguments as params, the session id in _meta. | ||
| 127 | +func (f *fakeAgent) callFirstTool(ctx context.Context, sid sdk.SessionId, text string) string { | ||
| 128 | + if len(f.clientTools) == 0 { | ||
| 129 | + return "no tool" | ||
| 130 | + } | ||
| 131 | + var spec toolSpec | ||
| 132 | + _ = json.Unmarshal(f.clientTools[0], &spec) | ||
| 133 | + raw, err := f.conn.CallExtension(ctx, spec.Method, map[string]any{ | ||
| 134 | + "text": text, | ||
| 135 | + "_meta": map[string]any{sessionIDMetaKey: string(sid)}, | ||
| 136 | + }) | ||
| 137 | + if err != nil { | ||
| 138 | + return "error: " + err.Error() | ||
| 139 | + } | ||
| 140 | + return string(raw) | ||
| 81 | } | 141 | } |
| 82 | 142 | ||
| 83 | func (f *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | 143 | func (f *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { |
| @@ -111,6 +171,13 @@ func (f *fakeAgent) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.Prompt | |||
| 111 | case "crash": | 171 | case "crash": |
| 112 | fmt.Fprintln(os.Stderr, "fake mm: boom") | 172 | fmt.Fprintln(os.Stderr, "fake mm: boom") |
| 113 | os.Exit(3) | 173 | os.Exit(3) |
| 174 | + case "tools": | ||
| 175 | + say(sdk.UpdateAgentMessageText(f.toolNames())) | ||
| 176 | + return endTurn, nil | ||
| 177 | + case "call": | ||
| 178 | + rest := strings.TrimSpace(strings.TrimPrefix(p.Prompt[0].Text.Text, "call")) | ||
| 179 | + say(sdk.UpdateAgentMessageText(f.callFirstTool(ctx, p.SessionId, rest))) | ||
| 180 | + return endTurn, nil | ||
| 114 | } | 181 | } |
| 115 | say(sdk.UpdateAgentMessageText("?")) | 182 | say(sdk.UpdateAgentMessageText("?")) |
| 116 | return endTurn, nil | 183 | return endTurn, nil |
modified
demo.acp.sdk/go/minime.go +10 -0 | @@ -55,6 +55,16 @@ type Options struct { | ||
| 55 | 55 | // lines). Without it the lines are read and dropped; the last one is |
| 56 | 56 | // added to the error when mm exits. |
| 57 | 57 | OnStderr func(line string) |
| 58 | + | |
| 59 | + // Tools are Go functions offered to the agent: mm discovers them and the | |
| 60 | + // model calls them like its own tools (tools.go). Requires an mm with | |
| 61 | + // `acp.clientTools` (on by default). | |
| 62 | + Tools []Tool | |
| 63 | + | |
| 64 | + // ToolDiscovery is how mm learns the Tools: DiscoveryMeta (default) or | |
| 65 | + // DiscoveryRequest. It must match `acp.clientTools.discovery` in the | |
| 66 | + // agent file. | |
| 67 | + ToolDiscovery string | |
| 58 | 68 | } |
| 59 | 69 | |
| 60 | 70 | // EventKind says what an Event carries. |
| @@ -55,6 +55,16 @@ type Options struct { | |||
| 55 | // lines). Without it the lines are read and dropped; the last one is | 55 | // lines). Without it the lines are read and dropped; the last one is |
| 56 | // added to the error when mm exits. | 56 | // added to the error when mm exits. |
| 57 | OnStderr func(line string) | 57 | OnStderr func(line string) |
| 58 | + | ||
| 59 | + // Tools are Go functions offered to the agent: mm discovers them and the | ||
| 60 | + // model calls them like its own tools (tools.go). Requires an mm with | ||
| 61 | + // `acp.clientTools` (on by default). | ||
| 62 | + Tools []Tool | ||
| 63 | + | ||
| 64 | + // ToolDiscovery is how mm learns the Tools: DiscoveryMeta (default) or | ||
| 65 | + // DiscoveryRequest. It must match `acp.clientTools.discovery` in the | ||
| 66 | + // agent file. | ||
| 67 | + ToolDiscovery string | ||
| 58 | } | 68 | } |
| 59 | 69 | ||
| 60 | // EventKind says what an Event carries. | 70 | // EventKind says what an Event carries. |
added
demo.acp.sdk/go/tools.go +189 -0 | new file mode 100644 | ||
| @@ -0,0 +1,189 @@ | ||
| 1 | +package minime | |
| 2 | + | |
| 3 | +// Client tools (mm ticket 202): Go functions this client offers to the | |
| 4 | +// agent. mm discovers them over ACP and calls them like any other tool; each | |
| 5 | +// call is an ACP extension request (a method whose name starts with "_") | |
| 6 | +// that this client answers by running the tool's Handle. | |
| 7 | +// | |
| 8 | +// The protocol is specified in mm's docs/en/reference/acp-client-tools.md. | |
| 9 | + | |
| 10 | +import ( | |
| 11 | + "context" | |
| 12 | + "encoding/json" | |
| 13 | + "errors" | |
| 14 | + "fmt" | |
| 15 | + "strings" | |
| 16 | + | |
| 17 | + sdk "github.com/coder/acp-go-sdk" | |
| 18 | +) | |
| 19 | + | |
| 20 | +// The two discovery modes; Options.ToolDiscovery must match the | |
| 21 | +// `acp.clientTools.discovery` of the agent file. | |
| 22 | +const ( | |
| 23 | + // DiscoveryMeta (default): the tools are listed at initialize, in | |
| 24 | + // clientCapabilities._meta["org.mini-me/tools"]. | |
| 25 | + DiscoveryMeta = "meta" | |
| 26 | + // DiscoveryRequest: the capability only says `true`, and mm asks for the | |
| 27 | + // list with `_org.mini-me/tools/list` when a session opens. | |
| 28 | + DiscoveryRequest = "request" | |
| 29 | +) | |
| 30 | + | |
| 31 | +const ( | |
| 32 | + toolsMetaKey = "org.mini-me/tools" | |
| 33 | + toolsListMethod = "_org.mini-me/tools/list" | |
| 34 | + sessionIDMetaKey = "org.mini-me/sessionId" | |
| 35 | + defaultToolPrefix = "_minime.go/tools/" | |
| 36 | +) | |
| 37 | + | |
| 38 | +// Tool is a tool this client offers to the agent. The model sees it as | |
| 39 | +// "client_<Name>". | |
| 40 | +// | |
| 41 | +// minime.Tool{ | |
| 42 | +// Name: "weather", | |
| 43 | +// Description: "Current weather of a city.", | |
| 44 | +// InputSchema: map[string]any{ | |
| 45 | +// "type": "object", | |
| 46 | +// "properties": map[string]any{"city": map[string]any{"type": "string"}}, | |
| 47 | +// "required": []any{"city"}, | |
| 48 | +// }, | |
| 49 | +// Handle: func(ctx context.Context, call minime.ToolCallRequest) (string, error) { | |
| 50 | +// return "sunny in " + call.Arguments["city"].(string), nil | |
| 51 | +// }, | |
| 52 | +// } | |
| 53 | +type Tool struct { | |
| 54 | + // Name identifies the tool; required, unique. | |
| 55 | + Name string | |
| 56 | + // Description is what the model reads to decide when to call the tool. | |
| 57 | + Description string | |
| 58 | + // Method is the ACP extension method mm calls; it must start with "_". | |
| 59 | + // Default: "_minime.go/tools/<Name>". | |
| 60 | + Method string | |
| 61 | + // InputSchema is the JSON schema of the arguments (type "object"). | |
| 62 | + // Optional: without it the model sees a tool with no parameter. | |
| 63 | + InputSchema map[string]any | |
| 64 | + // Handle runs the tool. Its text goes back to the model; an error goes | |
| 65 | + // back too, as a failed call the model can read. Required. | |
| 66 | + Handle func(ctx context.Context, call ToolCallRequest) (string, error) | |
| 67 | +} | |
| 68 | + | |
| 69 | +// ToolCallRequest is one call of a Tool by the agent. | |
| 70 | +type ToolCallRequest struct { | |
| 71 | + // SessionID is the ACP session the call belongs to. | |
| 72 | + SessionID string | |
| 73 | + // Arguments are the model's arguments, as decoded from JSON. | |
| 74 | + Arguments map[string]any | |
| 75 | +} | |
| 76 | + | |
| 77 | +// toolSpec is a Tool as it travels: the description mm reads. | |
| 78 | +type toolSpec struct { | |
| 79 | + Name string `json:"name"` | |
| 80 | + Description string `json:"description,omitempty"` | |
| 81 | + Method string `json:"method"` | |
| 82 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | |
| 83 | +} | |
| 84 | + | |
| 85 | +// toolText and toolResult are the answer to a call, in the shape mm reads | |
| 86 | +// first (MCP's content list). | |
| 87 | +type toolText struct { | |
| 88 | + Type string `json:"type"` | |
| 89 | + Text string `json:"text"` | |
| 90 | +} | |
| 91 | + | |
| 92 | +type toolResult struct { | |
| 93 | + Content []toolText `json:"content"` | |
| 94 | + IsError bool `json:"isError,omitempty"` | |
| 95 | +} | |
| 96 | + | |
| 97 | +// toolbox holds the tools of one Agent, by method. | |
| 98 | +type toolbox struct { | |
| 99 | + discovery string | |
| 100 | + specs []toolSpec | |
| 101 | + byMethod map[string]Tool | |
| 102 | +} | |
| 103 | + | |
| 104 | +// newToolbox validates the tools and the discovery mode of the options. No | |
| 105 | +// tools gives an empty toolbox: nothing is advertised. | |
| 106 | +func newToolbox(tools []Tool, discovery string) (*toolbox, error) { | |
| 107 | + if discovery == "" { | |
| 108 | + discovery = DiscoveryMeta | |
| 109 | + } | |
| 110 | + if discovery != DiscoveryMeta && discovery != DiscoveryRequest { | |
| 111 | + return nil, fmt.Errorf("minime: Options.ToolDiscovery must be %q or %q, got %q", DiscoveryMeta, DiscoveryRequest, discovery) | |
| 112 | + } | |
| 113 | + box := &toolbox{discovery: discovery, byMethod: map[string]Tool{}} | |
| 114 | + names := map[string]bool{} | |
| 115 | + for _, t := range tools { | |
| 116 | + if t.Method == "" { | |
| 117 | + t.Method = defaultToolPrefix + t.Name | |
| 118 | + } | |
| 119 | + switch { | |
| 120 | + case strings.TrimSpace(t.Name) == "": | |
| 121 | + return nil, errors.New("minime: a Tool has no Name") | |
| 122 | + case names[t.Name]: | |
| 123 | + return nil, fmt.Errorf("minime: two tools are named %q", t.Name) | |
| 124 | + case !strings.HasPrefix(t.Method, "_"): | |
| 125 | + return nil, fmt.Errorf("minime: tool %q: Method %q must start with \"_\"", t.Name, t.Method) | |
| 126 | + case t.Method == toolsListMethod || box.byMethod[t.Method].Handle != nil: | |
| 127 | + return nil, fmt.Errorf("minime: tool %q: Method %q is already taken", t.Name, t.Method) | |
| 128 | + case t.Handle == nil: | |
| 129 | + return nil, fmt.Errorf("minime: tool %q has no Handle", t.Name) | |
| 130 | + } | |
| 131 | + names[t.Name] = true | |
| 132 | + box.byMethod[t.Method] = t | |
| 133 | + box.specs = append(box.specs, toolSpec{Name: t.Name, Description: t.Description, Method: t.Method, InputSchema: t.InputSchema}) | |
| 134 | + } | |
| 135 | + return box, nil | |
| 136 | +} | |
| 137 | + | |
| 138 | +// capabilities is the clientCapabilities._meta sent at initialize: the list | |
| 139 | +// itself (meta), `true` (request), or nothing when there is no tool. | |
| 140 | +func (b *toolbox) capabilities() map[string]any { | |
| 141 | + if len(b.specs) == 0 { | |
| 142 | + return nil | |
| 143 | + } | |
| 144 | + if b.discovery == DiscoveryRequest { | |
| 145 | + return map[string]any{toolsMetaKey: true} | |
| 146 | + } | |
| 147 | + return map[string]any{toolsMetaKey: b.specs} | |
| 148 | +} | |
| 149 | + | |
| 150 | +// handle answers an extension request from mm: the list request, or a call. | |
| 151 | +// Anything else is "method not found", as ACP requires. | |
| 152 | +func (b *toolbox) handle(ctx context.Context, method string, params json.RawMessage) (any, error) { | |
| 153 | + if method == toolsListMethod && b.discovery == DiscoveryRequest { | |
| 154 | + return map[string]any{"tools": b.specs}, nil | |
| 155 | + } | |
| 156 | + tool, ok := b.byMethod[method] | |
| 157 | + if !ok { | |
| 158 | + return nil, sdk.NewMethodNotFound(method) | |
| 159 | + } | |
| 160 | + var args map[string]any | |
| 161 | + if len(params) > 0 && string(params) != "null" { | |
| 162 | + if err := json.Unmarshal(params, &args); err != nil { | |
| 163 | + return nil, sdk.NewInvalidParams(map[string]any{"error": err.Error()}) | |
| 164 | + } | |
| 165 | + } | |
| 166 | + if args == nil { | |
| 167 | + args = map[string]any{} | |
| 168 | + } | |
| 169 | + call := ToolCallRequest{Arguments: args} | |
| 170 | + if meta, ok := args["_meta"].(map[string]any); ok { | |
| 171 | + call.SessionID, _ = meta[sessionIDMetaKey].(string) | |
| 172 | + delete(args, "_meta") | |
| 173 | + } | |
| 174 | + text, err := runTool(ctx, tool, call) | |
| 175 | + if err != nil { | |
| 176 | + return toolResult{Content: []toolText{{Type: "text", Text: err.Error()}}, IsError: true}, nil | |
| 177 | + } | |
| 178 | + return toolResult{Content: []toolText{{Type: "text", Text: text}}}, nil | |
| 179 | +} | |
| 180 | + | |
| 181 | +// runTool runs Handle; a panic is an error, not a dead client. | |
| 182 | +func runTool(ctx context.Context, tool Tool, call ToolCallRequest) (text string, err error) { | |
| 183 | + defer func() { | |
| 184 | + if r := recover(); r != nil { | |
| 185 | + err = fmt.Errorf("tool %s panicked: %v", tool.Name, r) | |
| 186 | + } | |
| 187 | + }() | |
| 188 | + return tool.Handle(ctx, call) | |
| 189 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,189 @@ | |||
| 1 | +package minime | ||
| 2 | + | ||
| 3 | +// Client tools (mm ticket 202): Go functions this client offers to the | ||
| 4 | +// agent. mm discovers them over ACP and calls them like any other tool; each | ||
| 5 | +// call is an ACP extension request (a method whose name starts with "_") | ||
| 6 | +// that this client answers by running the tool's Handle. | ||
| 7 | +// | ||
| 8 | +// The protocol is specified in mm's docs/en/reference/acp-client-tools.md. | ||
| 9 | + | ||
| 10 | +import ( | ||
| 11 | + "context" | ||
| 12 | + "encoding/json" | ||
| 13 | + "errors" | ||
| 14 | + "fmt" | ||
| 15 | + "strings" | ||
| 16 | + | ||
| 17 | + sdk "github.com/coder/acp-go-sdk" | ||
| 18 | +) | ||
| 19 | + | ||
| 20 | +// The two discovery modes; Options.ToolDiscovery must match the | ||
| 21 | +// `acp.clientTools.discovery` of the agent file. | ||
| 22 | +const ( | ||
| 23 | + // DiscoveryMeta (default): the tools are listed at initialize, in | ||
| 24 | + // clientCapabilities._meta["org.mini-me/tools"]. | ||
| 25 | + DiscoveryMeta = "meta" | ||
| 26 | + // DiscoveryRequest: the capability only says `true`, and mm asks for the | ||
| 27 | + // list with `_org.mini-me/tools/list` when a session opens. | ||
| 28 | + DiscoveryRequest = "request" | ||
| 29 | +) | ||
| 30 | + | ||
| 31 | +const ( | ||
| 32 | + toolsMetaKey = "org.mini-me/tools" | ||
| 33 | + toolsListMethod = "_org.mini-me/tools/list" | ||
| 34 | + sessionIDMetaKey = "org.mini-me/sessionId" | ||
| 35 | + defaultToolPrefix = "_minime.go/tools/" | ||
| 36 | +) | ||
| 37 | + | ||
| 38 | +// Tool is a tool this client offers to the agent. The model sees it as | ||
| 39 | +// "client_<Name>". | ||
| 40 | +// | ||
| 41 | +// minime.Tool{ | ||
| 42 | +// Name: "weather", | ||
| 43 | +// Description: "Current weather of a city.", | ||
| 44 | +// InputSchema: map[string]any{ | ||
| 45 | +// "type": "object", | ||
| 46 | +// "properties": map[string]any{"city": map[string]any{"type": "string"}}, | ||
| 47 | +// "required": []any{"city"}, | ||
| 48 | +// }, | ||
| 49 | +// Handle: func(ctx context.Context, call minime.ToolCallRequest) (string, error) { | ||
| 50 | +// return "sunny in " + call.Arguments["city"].(string), nil | ||
| 51 | +// }, | ||
| 52 | +// } | ||
| 53 | +type Tool struct { | ||
| 54 | + // Name identifies the tool; required, unique. | ||
| 55 | + Name string | ||
| 56 | + // Description is what the model reads to decide when to call the tool. | ||
| 57 | + Description string | ||
| 58 | + // Method is the ACP extension method mm calls; it must start with "_". | ||
| 59 | + // Default: "_minime.go/tools/<Name>". | ||
| 60 | + Method string | ||
| 61 | + // InputSchema is the JSON schema of the arguments (type "object"). | ||
| 62 | + // Optional: without it the model sees a tool with no parameter. | ||
| 63 | + InputSchema map[string]any | ||
| 64 | + // Handle runs the tool. Its text goes back to the model; an error goes | ||
| 65 | + // back too, as a failed call the model can read. Required. | ||
| 66 | + Handle func(ctx context.Context, call ToolCallRequest) (string, error) | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +// ToolCallRequest is one call of a Tool by the agent. | ||
| 70 | +type ToolCallRequest struct { | ||
| 71 | + // SessionID is the ACP session the call belongs to. | ||
| 72 | + SessionID string | ||
| 73 | + // Arguments are the model's arguments, as decoded from JSON. | ||
| 74 | + Arguments map[string]any | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +// toolSpec is a Tool as it travels: the description mm reads. | ||
| 78 | +type toolSpec struct { | ||
| 79 | + Name string `json:"name"` | ||
| 80 | + Description string `json:"description,omitempty"` | ||
| 81 | + Method string `json:"method"` | ||
| 82 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +// toolText and toolResult are the answer to a call, in the shape mm reads | ||
| 86 | +// first (MCP's content list). | ||
| 87 | +type toolText struct { | ||
| 88 | + Type string `json:"type"` | ||
| 89 | + Text string `json:"text"` | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +type toolResult struct { | ||
| 93 | + Content []toolText `json:"content"` | ||
| 94 | + IsError bool `json:"isError,omitempty"` | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// toolbox holds the tools of one Agent, by method. | ||
| 98 | +type toolbox struct { | ||
| 99 | + discovery string | ||
| 100 | + specs []toolSpec | ||
| 101 | + byMethod map[string]Tool | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// newToolbox validates the tools and the discovery mode of the options. No | ||
| 105 | +// tools gives an empty toolbox: nothing is advertised. | ||
| 106 | +func newToolbox(tools []Tool, discovery string) (*toolbox, error) { | ||
| 107 | + if discovery == "" { | ||
| 108 | + discovery = DiscoveryMeta | ||
| 109 | + } | ||
| 110 | + if discovery != DiscoveryMeta && discovery != DiscoveryRequest { | ||
| 111 | + return nil, fmt.Errorf("minime: Options.ToolDiscovery must be %q or %q, got %q", DiscoveryMeta, DiscoveryRequest, discovery) | ||
| 112 | + } | ||
| 113 | + box := &toolbox{discovery: discovery, byMethod: map[string]Tool{}} | ||
| 114 | + names := map[string]bool{} | ||
| 115 | + for _, t := range tools { | ||
| 116 | + if t.Method == "" { | ||
| 117 | + t.Method = defaultToolPrefix + t.Name | ||
| 118 | + } | ||
| 119 | + switch { | ||
| 120 | + case strings.TrimSpace(t.Name) == "": | ||
| 121 | + return nil, errors.New("minime: a Tool has no Name") | ||
| 122 | + case names[t.Name]: | ||
| 123 | + return nil, fmt.Errorf("minime: two tools are named %q", t.Name) | ||
| 124 | + case !strings.HasPrefix(t.Method, "_"): | ||
| 125 | + return nil, fmt.Errorf("minime: tool %q: Method %q must start with \"_\"", t.Name, t.Method) | ||
| 126 | + case t.Method == toolsListMethod || box.byMethod[t.Method].Handle != nil: | ||
| 127 | + return nil, fmt.Errorf("minime: tool %q: Method %q is already taken", t.Name, t.Method) | ||
| 128 | + case t.Handle == nil: | ||
| 129 | + return nil, fmt.Errorf("minime: tool %q has no Handle", t.Name) | ||
| 130 | + } | ||
| 131 | + names[t.Name] = true | ||
| 132 | + box.byMethod[t.Method] = t | ||
| 133 | + box.specs = append(box.specs, toolSpec{Name: t.Name, Description: t.Description, Method: t.Method, InputSchema: t.InputSchema}) | ||
| 134 | + } | ||
| 135 | + return box, nil | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +// capabilities is the clientCapabilities._meta sent at initialize: the list | ||
| 139 | +// itself (meta), `true` (request), or nothing when there is no tool. | ||
| 140 | +func (b *toolbox) capabilities() map[string]any { | ||
| 141 | + if len(b.specs) == 0 { | ||
| 142 | + return nil | ||
| 143 | + } | ||
| 144 | + if b.discovery == DiscoveryRequest { | ||
| 145 | + return map[string]any{toolsMetaKey: true} | ||
| 146 | + } | ||
| 147 | + return map[string]any{toolsMetaKey: b.specs} | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +// handle answers an extension request from mm: the list request, or a call. | ||
| 151 | +// Anything else is "method not found", as ACP requires. | ||
| 152 | +func (b *toolbox) handle(ctx context.Context, method string, params json.RawMessage) (any, error) { | ||
| 153 | + if method == toolsListMethod && b.discovery == DiscoveryRequest { | ||
| 154 | + return map[string]any{"tools": b.specs}, nil | ||
| 155 | + } | ||
| 156 | + tool, ok := b.byMethod[method] | ||
| 157 | + if !ok { | ||
| 158 | + return nil, sdk.NewMethodNotFound(method) | ||
| 159 | + } | ||
| 160 | + var args map[string]any | ||
| 161 | + if len(params) > 0 && string(params) != "null" { | ||
| 162 | + if err := json.Unmarshal(params, &args); err != nil { | ||
| 163 | + return nil, sdk.NewInvalidParams(map[string]any{"error": err.Error()}) | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + if args == nil { | ||
| 167 | + args = map[string]any{} | ||
| 168 | + } | ||
| 169 | + call := ToolCallRequest{Arguments: args} | ||
| 170 | + if meta, ok := args["_meta"].(map[string]any); ok { | ||
| 171 | + call.SessionID, _ = meta[sessionIDMetaKey].(string) | ||
| 172 | + delete(args, "_meta") | ||
| 173 | + } | ||
| 174 | + text, err := runTool(ctx, tool, call) | ||
| 175 | + if err != nil { | ||
| 176 | + return toolResult{Content: []toolText{{Type: "text", Text: err.Error()}}, IsError: true}, nil | ||
| 177 | + } | ||
| 178 | + return toolResult{Content: []toolText{{Type: "text", Text: text}}}, nil | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +// runTool runs Handle; a panic is an error, not a dead client. | ||
| 182 | +func runTool(ctx context.Context, tool Tool, call ToolCallRequest) (text string, err error) { | ||
| 183 | + defer func() { | ||
| 184 | + if r := recover(); r != nil { | ||
| 185 | + err = fmt.Errorf("tool %s panicked: %v", tool.Name, r) | ||
| 186 | + } | ||
| 187 | + }() | ||
| 188 | + return tool.Handle(ctx, call) | ||
| 189 | +} | ||
added
demo.acp.sdk/go/tools_test.go +103 -0 | new file mode 100644 | ||
| @@ -0,0 +1,103 @@ | ||
| 1 | +package minime | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// echoTool answers with its argument and the session it was called for. | |
| 11 | +func echoTool() Tool { | |
| 12 | + return Tool{ | |
| 13 | + Name: "echo", | |
| 14 | + Description: "Repeat the text.", | |
| 15 | + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}}, | |
| 16 | + Handle: func(_ context.Context, call ToolCallRequest) (string, error) { | |
| 17 | + return call.Arguments["text"].(string) + " @" + call.SessionID, nil | |
| 18 | + }, | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +func answerOf(t *testing.T, a *Agent, prompt string) string { | |
| 23 | + t.Helper() | |
| 24 | + res, err := a.Complete(context.Background(), prompt) | |
| 25 | + if err != nil { | |
| 26 | + t.Fatalf("Complete(%q): %v", prompt, err) | |
| 27 | + } | |
| 28 | + return res.Text | |
| 29 | +} | |
| 30 | + | |
| 31 | +// Discovery "meta" (the default): the tools travel in initialize, and a call | |
| 32 | +// reaches Handle with the arguments, without _meta, and the session id; the | |
| 33 | +// answer is MCP's content list. | |
| 34 | +func TestToolsAdvertisedAtInitializeAreCalled(t *testing.T) { | |
| 35 | + a := startFake(t, Options{Tools: []Tool{echoTool()}}) | |
| 36 | + if got := answerOf(t, a, "tools"); got != "echo" { | |
| 37 | + t.Errorf("tools learnt = %q", got) | |
| 38 | + } | |
| 39 | + want := `{"content":[{"type":"text","text":"hi @` + a.SessionID() + `"}]}` | |
| 40 | + if got := answerOf(t, a, "call hi"); got != want { | |
| 41 | + t.Errorf("call answered %s, want %s", got, want) | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +// Discovery "request": initialize only says true; the list is answered when | |
| 46 | +// mm asks for it, during session/new. | |
| 47 | +func TestToolsListedOnRequest(t *testing.T) { | |
| 48 | + a := startFake(t, Options{Tools: []Tool{echoTool()}, ToolDiscovery: DiscoveryRequest}) | |
| 49 | + if got := answerOf(t, a, "tools"); got != "echo" { | |
| 50 | + t.Errorf("tools learnt = %q", got) | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +// No tools: nothing is advertised, nothing is learnt. | |
| 55 | +func TestNoToolsNothingAdvertised(t *testing.T) { | |
| 56 | + a := startFake(t, Options{}) | |
| 57 | + if got := answerOf(t, a, "tools"); got != "none" { | |
| 58 | + t.Errorf("tools learnt = %q", got) | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +// An error from Handle, or a panic, is an answer with isError: the model | |
| 63 | +// reads it, the client lives on. | |
| 64 | +func TestToolFailuresAreAnswersWithIsError(t *testing.T) { | |
| 65 | + for name, handle := range map[string]func(context.Context, ToolCallRequest) (string, error){ | |
| 66 | + "error": func(context.Context, ToolCallRequest) (string, error) { return "", errors.New("no such city") }, | |
| 67 | + "panic": func(context.Context, ToolCallRequest) (string, error) { panic("boom") }, | |
| 68 | + } { | |
| 69 | + t.Run(name, func(t *testing.T) { | |
| 70 | + tool := echoTool() | |
| 71 | + tool.Handle = handle | |
| 72 | + a := startFake(t, Options{Tools: []Tool{tool}}) | |
| 73 | + if got := answerOf(t, a, "call x"); !strings.Contains(got, `"isError":true`) { | |
| 74 | + t.Errorf("answer = %s", got) | |
| 75 | + } | |
| 76 | + if got := answerOf(t, a, "hello"); got != "Hello" { | |
| 77 | + t.Errorf("after the failure: %q", got) | |
| 78 | + } | |
| 79 | + }) | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +// Bad tools are refused by Start, before mm is launched. | |
| 84 | +func TestStartRefusesBadTools(t *testing.T) { | |
| 85 | + noHandle := echoTool() | |
| 86 | + noHandle.Handle = nil | |
| 87 | + badMethod := echoTool() | |
| 88 | + badMethod.Method = "tools/echo" | |
| 89 | + cases := map[string]Options{ | |
| 90 | + "no name": {Tools: []Tool{{Handle: echoTool().Handle}}}, | |
| 91 | + "duplicate": {Tools: []Tool{echoTool(), echoTool()}}, | |
| 92 | + "no handle": {Tools: []Tool{noHandle}}, | |
| 93 | + "bad method": {Tools: []Tool{badMethod}}, | |
| 94 | + "bad discovery": {Tools: []Tool{echoTool()}, ToolDiscovery: "magic"}, | |
| 95 | + } | |
| 96 | + for name, opts := range cases { | |
| 97 | + opts.Config = "agent.yaml" | |
| 98 | + opts.MM = "/nonexistent/mm" | |
| 99 | + if _, err := Start(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "minime:") || strings.Contains(err.Error(), "cannot start") { | |
| 100 | + t.Errorf("%s: err = %v, want a refusal before mm starts", name, err) | |
| 101 | + } | |
| 102 | + } | |
| 103 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | +package minime | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "errors" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// echoTool answers with its argument and the session it was called for. | ||
| 11 | +func echoTool() Tool { | ||
| 12 | + return Tool{ | ||
| 13 | + Name: "echo", | ||
| 14 | + Description: "Repeat the text.", | ||
| 15 | + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}}, | ||
| 16 | + Handle: func(_ context.Context, call ToolCallRequest) (string, error) { | ||
| 17 | + return call.Arguments["text"].(string) + " @" + call.SessionID, nil | ||
| 18 | + }, | ||
| 19 | + } | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +func answerOf(t *testing.T, a *Agent, prompt string) string { | ||
| 23 | + t.Helper() | ||
| 24 | + res, err := a.Complete(context.Background(), prompt) | ||
| 25 | + if err != nil { | ||
| 26 | + t.Fatalf("Complete(%q): %v", prompt, err) | ||
| 27 | + } | ||
| 28 | + return res.Text | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// Discovery "meta" (the default): the tools travel in initialize, and a call | ||
| 32 | +// reaches Handle with the arguments, without _meta, and the session id; the | ||
| 33 | +// answer is MCP's content list. | ||
| 34 | +func TestToolsAdvertisedAtInitializeAreCalled(t *testing.T) { | ||
| 35 | + a := startFake(t, Options{Tools: []Tool{echoTool()}}) | ||
| 36 | + if got := answerOf(t, a, "tools"); got != "echo" { | ||
| 37 | + t.Errorf("tools learnt = %q", got) | ||
| 38 | + } | ||
| 39 | + want := `{"content":[{"type":"text","text":"hi @` + a.SessionID() + `"}]}` | ||
| 40 | + if got := answerOf(t, a, "call hi"); got != want { | ||
| 41 | + t.Errorf("call answered %s, want %s", got, want) | ||
| 42 | + } | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +// Discovery "request": initialize only says true; the list is answered when | ||
| 46 | +// mm asks for it, during session/new. | ||
| 47 | +func TestToolsListedOnRequest(t *testing.T) { | ||
| 48 | + a := startFake(t, Options{Tools: []Tool{echoTool()}, ToolDiscovery: DiscoveryRequest}) | ||
| 49 | + if got := answerOf(t, a, "tools"); got != "echo" { | ||
| 50 | + t.Errorf("tools learnt = %q", got) | ||
| 51 | + } | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +// No tools: nothing is advertised, nothing is learnt. | ||
| 55 | +func TestNoToolsNothingAdvertised(t *testing.T) { | ||
| 56 | + a := startFake(t, Options{}) | ||
| 57 | + if got := answerOf(t, a, "tools"); got != "none" { | ||
| 58 | + t.Errorf("tools learnt = %q", got) | ||
| 59 | + } | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// An error from Handle, or a panic, is an answer with isError: the model | ||
| 63 | +// reads it, the client lives on. | ||
| 64 | +func TestToolFailuresAreAnswersWithIsError(t *testing.T) { | ||
| 65 | + for name, handle := range map[string]func(context.Context, ToolCallRequest) (string, error){ | ||
| 66 | + "error": func(context.Context, ToolCallRequest) (string, error) { return "", errors.New("no such city") }, | ||
| 67 | + "panic": func(context.Context, ToolCallRequest) (string, error) { panic("boom") }, | ||
| 68 | + } { | ||
| 69 | + t.Run(name, func(t *testing.T) { | ||
| 70 | + tool := echoTool() | ||
| 71 | + tool.Handle = handle | ||
| 72 | + a := startFake(t, Options{Tools: []Tool{tool}}) | ||
| 73 | + if got := answerOf(t, a, "call x"); !strings.Contains(got, `"isError":true`) { | ||
| 74 | + t.Errorf("answer = %s", got) | ||
| 75 | + } | ||
| 76 | + if got := answerOf(t, a, "hello"); got != "Hello" { | ||
| 77 | + t.Errorf("after the failure: %q", got) | ||
| 78 | + } | ||
| 79 | + }) | ||
| 80 | + } | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +// Bad tools are refused by Start, before mm is launched. | ||
| 84 | +func TestStartRefusesBadTools(t *testing.T) { | ||
| 85 | + noHandle := echoTool() | ||
| 86 | + noHandle.Handle = nil | ||
| 87 | + badMethod := echoTool() | ||
| 88 | + badMethod.Method = "tools/echo" | ||
| 89 | + cases := map[string]Options{ | ||
| 90 | + "no name": {Tools: []Tool{{Handle: echoTool().Handle}}}, | ||
| 91 | + "duplicate": {Tools: []Tool{echoTool(), echoTool()}}, | ||
| 92 | + "no handle": {Tools: []Tool{noHandle}}, | ||
| 93 | + "bad method": {Tools: []Tool{badMethod}}, | ||
| 94 | + "bad discovery": {Tools: []Tool{echoTool()}, ToolDiscovery: "magic"}, | ||
| 95 | + } | ||
| 96 | + for name, opts := range cases { | ||
| 97 | + opts.Config = "agent.yaml" | ||
| 98 | + opts.MM = "/nonexistent/mm" | ||
| 99 | + if _, err := Start(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "minime:") || strings.Contains(err.Error(), "cannot start") { | ||
| 100 | + t.Errorf("%s: err = %v, want a refusal before mm starts", name, err) | ||
| 101 | + } | ||
| 102 | + } | ||
| 103 | +} | ||
modified
docs/diagrams/packages.drawio +1 -1 | @@ -1,4 +1,4 @@ | ||
| 1 | 1 | <mxfile host="app.diagrams.net" modified="2026-10-22T00: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; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<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 | 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), the questions ↑ recalls, /memory and search_memory</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="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><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, a hook's one-call approval</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="e70consent" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" 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> |
| 4 | - <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| 4 | + <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_mcp" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_detector" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | <mxfile host="app.diagrams.net" modified="2026-10-22T00: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; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<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-22T00: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; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<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), the questions ↑ recalls, /memory and search_memory</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="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><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, a hook's one-call approval</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="e70consent" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" 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> | 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), the questions ↑ recalls, /memory and search_memory</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="saved" value="<b>internal/saved</b><br/><font style="font-size:10px">an answer to a new file: /save, save_answer</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e87" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e88" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="saved"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e89" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="saved"><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, a hook's one-call approval</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="e70consent" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" 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> |
| 4 | - <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 4 | + <mxCell id="e90" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="spinner"><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_main_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" 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><mxCell id="e_store_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_session" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_store" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="docsearch" value="<b>internal/docsearch</b><br/><font style="font-size:10px">BM25 over the text files of folders: /docs, search_docs</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="textfold" value="<b>internal/textfold</b><br/><font style="font-size:10px">case and accents folded, rune for rune</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_docsearch_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_docsearch_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="docsearch" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_store_textfold" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="store" target="textfold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_textfold_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="textfold" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_docsearch" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="docsearch"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_tools" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentinit" value="<b>internal/agentinit</b><br/><font style="font-size:10px">mm init: the agent.yaml wizard, presets.yaml, the server's model facts</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="660" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_elicit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agentinit_engine" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentinit" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_agentinit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="wasmtool" value="<b>internal/wasmtool</b><br/><font style="font-size:10px">runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1640" y="360" width="200" height="60" as="geometry" /></mxCell><mxCell id="wazero" value="<b>github.com/tetratelabs/wazero</b><br/><font style="font-size:10px">WebAssembly runtime in pure Go, WASI preview 1</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1640" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_tools_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_wasmtool_wazero" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="wasmtool" target="wazero"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_wasmtool" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="wasmtool"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="decide" value="<b>internal/decide</b><br/><font style="font-size:10px">decision models: /v1/systemone client, skill and tool routing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1140" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="ed0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="decide"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ed6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="decide" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="dockerinit" value="<b>internal/dockerinit</b><br/><font style="font-size:10px">mm dockerinit: writes the Dockerfile and compose.yaml</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="860" width="200" height="60" as="geometry" /></mxCell><mxCell id="e91" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="dockerinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="sbxinit" value="<b>internal/sbxinit</b><br/><font style="font-size:10px">mm sbxinit: writes sbxenv.yaml with the latest kit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="960" width="200" height="60" as="geometry" /></mxCell><mxCell id="scaffold" value="<b>internal/scaffold</b><br/><font style="font-size:10px">what mm dockerinit and mm sbxinit share: the agent name, writing without destroying</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="1640" y="1060" width="200" height="60" as="geometry" /></mxCell><mxCell id="e92" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="dockerinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e93" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="sbxinit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e94" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="scaffold"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e95" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="sbxinit" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e96" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e97" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="scaffold" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_mcp" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_detector" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
docs/en/README.md +2 -0 | @@ -23,6 +23,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 23 | 23 | - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo` |
| 24 | 24 | - [Ask another agent from a skill or a script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context` |
| 25 | 25 | - [Drive mini-me from a program](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js: `mm -acp` as a library: `Start`/`Complete`/`Stream`, cancel, permission callback |
| 26 | +- [Offer tools from your ACP client](how-to/offer-tools-from-an-acp-client.md) — your program's functions as agent tools: `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools` | |
| 26 | 27 | - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) |
| 27 | 28 | - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server |
| 28 | 29 | - [Confirm tool calls before they run](how-to/confirm-tool-calls.md) — `yolo: false`, the four answers, `AGENT_YOLO`, who decides with `mmConfigs` |
| @@ -72,6 +73,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 72 | 73 | - [Skill file format](reference/skill-format.md) |
| 73 | 74 | - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors |
| 74 | 75 | - [Agent Client Protocol surface](reference/acp.md) |
| 76 | +- [ACP client tools](reference/acp-client-tools.md) — the specification a client implements to offer its tools: discovery, calls, answers, errors | |
| 75 | 77 | |
| 76 | 78 | ### MCP (Model Context Protocol) Reference |
| 77 | 79 | - [MCP Configuration](reference/mcp-configuration.md) — All MCP-specific configuration options |
| @@ -23,6 +23,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 23 | - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo` | 23 | - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo` |
| 24 | - [Ask another agent from a skill or a script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context` | 24 | - [Ask another agent from a skill or a script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context` |
| 25 | - [Drive mini-me from a program](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js: `mm -acp` as a library: `Start`/`Complete`/`Stream`, cancel, permission callback | 25 | - [Drive mini-me from a program](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js: `mm -acp` as a library: `Start`/`Complete`/`Stream`, cancel, permission callback |
| 26 | +- [Offer tools from your ACP client](how-to/offer-tools-from-an-acp-client.md) — your program's functions as agent tools: `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools` | ||
| 26 | - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) | 27 | - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) |
| 27 | - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server | 28 | - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server |
| 28 | - [Confirm tool calls before they run](how-to/confirm-tool-calls.md) — `yolo: false`, the four answers, `AGENT_YOLO`, who decides with `mmConfigs` | 29 | - [Confirm tool calls before they run](how-to/confirm-tool-calls.md) — `yolo: false`, the four answers, `AGENT_YOLO`, who decides with `mmConfigs` |
| @@ -72,6 +73,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 72 | - [Skill file format](reference/skill-format.md) | 73 | - [Skill file format](reference/skill-format.md) |
| 73 | - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors | 74 | - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors |
| 74 | - [Agent Client Protocol surface](reference/acp.md) | 75 | - [Agent Client Protocol surface](reference/acp.md) |
| 76 | +- [ACP client tools](reference/acp-client-tools.md) — the specification a client implements to offer its tools: discovery, calls, answers, errors | ||
| 75 | 77 | ||
| 76 | ### MCP (Model Context Protocol) Reference | 78 | ### MCP (Model Context Protocol) Reference |
| 77 | - [MCP Configuration](reference/mcp-configuration.md) — All MCP-specific configuration options | 79 | - [MCP Configuration](reference/mcp-configuration.md) — All MCP-specific configuration options |
added
docs/en/how-to/offer-tools-from-an-acp-client.md +115 -0 | new file mode 100644 | ||
| @@ -0,0 +1,115 @@ | ||
| 1 | +# How to offer tools from your ACP client | |
| 2 | + | |
| 3 | +This guide shows how a Go program that drives `mm -acp` gives the agent its own tools: functions of your program that the model calls like its built-in tools (an editor's open buffers, an application's data, a to-do list…). It assumes your program is already an ACP client of mm, written with [`github.com/coder/acp-go-sdk`](https://github.com/coder/acp-go-sdk) (v0.13.5, the version mm uses). The exact protocol is in the [client tools specification](../reference/acp-client-tools.md). | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +### 1. Describe your tools | |
| 8 | + | |
| 9 | +One description per tool: a name, a description for the model, the extension method mm will call (it must start with `_`), and the JSON schema of the arguments. | |
| 10 | + | |
| 11 | +```go | |
| 12 | +type toolSpec struct { | |
| 13 | + Name string `json:"name"` | |
| 14 | + Description string `json:"description,omitempty"` | |
| 15 | + Method string `json:"method"` | |
| 16 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | |
| 17 | +} | |
| 18 | + | |
| 19 | +var tools = []toolSpec{{ | |
| 20 | + Name: "add_todo", | |
| 21 | + Description: "Add one item to the user's to-do list.", | |
| 22 | + Method: "_com.example/todo/add", | |
| 23 | + InputSchema: map[string]any{ | |
| 24 | + "type": "object", | |
| 25 | + "properties": map[string]any{"item": map[string]any{"type": "string"}}, | |
| 26 | + "required": []any{"item"}, | |
| 27 | + }, | |
| 28 | +}} | |
| 29 | +``` | |
| 30 | + | |
| 31 | +The model will see the tool as `client_add_todo`. | |
| 32 | + | |
| 33 | +### 2. Advertise them in `initialize` | |
| 34 | + | |
| 35 | +Put the list in `clientCapabilities._meta`, under `org.mini-me/tools`: | |
| 36 | + | |
| 37 | +```go | |
| 38 | +_, err := conn.Initialize(ctx, sdk.InitializeRequest{ | |
| 39 | + ProtocolVersion: sdk.ProtocolVersionNumber, | |
| 40 | + ClientInfo: &sdk.Implementation{Name: "my-client", Version: "1.0.0"}, | |
| 41 | + ClientCapabilities: sdk.ClientCapabilities{ | |
| 42 | + Meta: map[string]any{"org.mini-me/tools": tools}, | |
| 43 | + }, | |
| 44 | +}) | |
| 45 | +``` | |
| 46 | + | |
| 47 | +That is all mm needs with the default configuration (`acp.clientTools.discovery: meta`): every session opened afterwards has the tools. | |
| 48 | + | |
| 49 | +### 3. Answer the calls | |
| 50 | + | |
| 51 | +Your client type (the one passed to `sdk.NewClientSideConnection`) implements `sdk.ExtensionMethodHandler`. The SDK hands it every request whose method starts with `_`: | |
| 52 | + | |
| 53 | +```go | |
| 54 | +var _ sdk.ExtensionMethodHandler = (*myClient)(nil) | |
| 55 | + | |
| 56 | +func (c *myClient) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { | |
| 57 | + switch method { | |
| 58 | + case "_com.example/todo/add": | |
| 59 | + var args struct { | |
| 60 | + Item string `json:"item"` | |
| 61 | + Meta struct { | |
| 62 | + SessionID string `json:"org.mini-me/sessionId"` | |
| 63 | + } `json:"_meta"` | |
| 64 | + } | |
| 65 | + if err := json.Unmarshal(params, &args); err != nil { | |
| 66 | + return nil, sdk.NewInvalidParams(map[string]any{"error": err.Error()}) | |
| 67 | + } | |
| 68 | + c.todos = append(c.todos, args.Item) | |
| 69 | + return textResult(fmt.Sprintf("added %q", args.Item), false), nil | |
| 70 | + } | |
| 71 | + return nil, sdk.NewMethodNotFound(method) | |
| 72 | +} | |
| 73 | + | |
| 74 | +// textResult is the answer shape mm reads first. | |
| 75 | +func textResult(text string, isError bool) map[string]any { | |
| 76 | + return map[string]any{ | |
| 77 | + "content": []map[string]any{{"type": "text", "text": text}}, | |
| 78 | + "isError": isError, | |
| 79 | + } | |
| 80 | +} | |
| 81 | +``` | |
| 82 | + | |
| 83 | +- The model's arguments are at the top level of `params`; `_meta["org.mini-me/sessionId"]` says which session called. | |
| 84 | +- Answer a failure with `isError: true` and a text the model can act on, rather than a JSON-RPC error: both reach the model, but the text is yours. | |
| 85 | +- Keep the handler safe for concurrent use: the SDK runs each request in its own goroutine. | |
| 86 | + | |
| 87 | +### 4. Check that mm sees them | |
| 88 | + | |
| 89 | +Start mm and read its stderr. After `initialize` it prints: | |
| 90 | + | |
| 91 | +``` | |
| 92 | +[acp] client tools (meta): client_add_todo | |
| 93 | +``` | |
| 94 | + | |
| 95 | +An entry it could not use is reported on its own line (`[acp] client tools: entry 0 skipped: …`), with the reason. | |
| 96 | + | |
| 97 | +## Variants | |
| 98 | + | |
| 99 | +- **The list depends on the session** (its `cwd`, a user): set `discovery: request` in the agent file, advertise only `"org.mini-me/tools": true`, and answer `_org.mini-me/tools/list` in `HandleExtensionMethod` with `map[string]any{"tools": tools}`. mm sends it during `session/new`, `session/load` and `session/resume`, before answering them; the SDK lets you answer while your `NewSession` call waits. | |
| 100 | + | |
| 101 | + ```yaml | |
| 102 | + acp: | |
| 103 | + clientTools: | |
| 104 | + discovery: request | |
| 105 | + ``` | |
| 106 | + | |
| 107 | +- **Every call must be confirmed**: set `acp.clientTools.permissions: ask`. mm then sends `session/request_permission` before each call, titled `client: <name>`; answer it in your `RequestPermission` method. With the default `auto`, this happens only when the agent file says `yolo: false`. | |
| 108 | +- **Slow tools**: raise `acp.clientTools.timeout` (default `30s`). Past it, the model reads an error and the turn goes on. | |
| 109 | +- **Using the `minime` package** of `demo.acp.sdk/go`: steps 1 to 3 are `minime.Options{Tools: []minime.Tool{…}}`, each `Tool` with a `Handle` function; `ToolDiscovery: minime.DiscoveryRequest` selects the second mode. `go run ./examples/clienttools` runs a complete example. | |
| 110 | + | |
| 111 | +## See also | |
| 112 | + | |
| 113 | +- [Client tools specification](../reference/acp-client-tools.md) — the exact JSON, the validation of a list, the errors | |
| 114 | +- [Configuration file — `acp` keys](../reference/configuration.md#acp-keys) | |
| 115 | +- [How to drive mini-me from a program](drive-mini-me-from-a-program.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,115 @@ | |||
| 1 | +# How to offer tools from your ACP client | ||
| 2 | + | ||
| 3 | +This guide shows how a Go program that drives `mm -acp` gives the agent its own tools: functions of your program that the model calls like its built-in tools (an editor's open buffers, an application's data, a to-do list…). It assumes your program is already an ACP client of mm, written with [`github.com/coder/acp-go-sdk`](https://github.com/coder/acp-go-sdk) (v0.13.5, the version mm uses). The exact protocol is in the [client tools specification](../reference/acp-client-tools.md). | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +### 1. Describe your tools | ||
| 8 | + | ||
| 9 | +One description per tool: a name, a description for the model, the extension method mm will call (it must start with `_`), and the JSON schema of the arguments. | ||
| 10 | + | ||
| 11 | +```go | ||
| 12 | +type toolSpec struct { | ||
| 13 | + Name string `json:"name"` | ||
| 14 | + Description string `json:"description,omitempty"` | ||
| 15 | + Method string `json:"method"` | ||
| 16 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +var tools = []toolSpec{{ | ||
| 20 | + Name: "add_todo", | ||
| 21 | + Description: "Add one item to the user's to-do list.", | ||
| 22 | + Method: "_com.example/todo/add", | ||
| 23 | + InputSchema: map[string]any{ | ||
| 24 | + "type": "object", | ||
| 25 | + "properties": map[string]any{"item": map[string]any{"type": "string"}}, | ||
| 26 | + "required": []any{"item"}, | ||
| 27 | + }, | ||
| 28 | +}} | ||
| 29 | +``` | ||
| 30 | + | ||
| 31 | +The model will see the tool as `client_add_todo`. | ||
| 32 | + | ||
| 33 | +### 2. Advertise them in `initialize` | ||
| 34 | + | ||
| 35 | +Put the list in `clientCapabilities._meta`, under `org.mini-me/tools`: | ||
| 36 | + | ||
| 37 | +```go | ||
| 38 | +_, err := conn.Initialize(ctx, sdk.InitializeRequest{ | ||
| 39 | + ProtocolVersion: sdk.ProtocolVersionNumber, | ||
| 40 | + ClientInfo: &sdk.Implementation{Name: "my-client", Version: "1.0.0"}, | ||
| 41 | + ClientCapabilities: sdk.ClientCapabilities{ | ||
| 42 | + Meta: map[string]any{"org.mini-me/tools": tools}, | ||
| 43 | + }, | ||
| 44 | +}) | ||
| 45 | +``` | ||
| 46 | + | ||
| 47 | +That is all mm needs with the default configuration (`acp.clientTools.discovery: meta`): every session opened afterwards has the tools. | ||
| 48 | + | ||
| 49 | +### 3. Answer the calls | ||
| 50 | + | ||
| 51 | +Your client type (the one passed to `sdk.NewClientSideConnection`) implements `sdk.ExtensionMethodHandler`. The SDK hands it every request whose method starts with `_`: | ||
| 52 | + | ||
| 53 | +```go | ||
| 54 | +var _ sdk.ExtensionMethodHandler = (*myClient)(nil) | ||
| 55 | + | ||
| 56 | +func (c *myClient) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { | ||
| 57 | + switch method { | ||
| 58 | + case "_com.example/todo/add": | ||
| 59 | + var args struct { | ||
| 60 | + Item string `json:"item"` | ||
| 61 | + Meta struct { | ||
| 62 | + SessionID string `json:"org.mini-me/sessionId"` | ||
| 63 | + } `json:"_meta"` | ||
| 64 | + } | ||
| 65 | + if err := json.Unmarshal(params, &args); err != nil { | ||
| 66 | + return nil, sdk.NewInvalidParams(map[string]any{"error": err.Error()}) | ||
| 67 | + } | ||
| 68 | + c.todos = append(c.todos, args.Item) | ||
| 69 | + return textResult(fmt.Sprintf("added %q", args.Item), false), nil | ||
| 70 | + } | ||
| 71 | + return nil, sdk.NewMethodNotFound(method) | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +// textResult is the answer shape mm reads first. | ||
| 75 | +func textResult(text string, isError bool) map[string]any { | ||
| 76 | + return map[string]any{ | ||
| 77 | + "content": []map[string]any{{"type": "text", "text": text}}, | ||
| 78 | + "isError": isError, | ||
| 79 | + } | ||
| 80 | +} | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +- The model's arguments are at the top level of `params`; `_meta["org.mini-me/sessionId"]` says which session called. | ||
| 84 | +- Answer a failure with `isError: true` and a text the model can act on, rather than a JSON-RPC error: both reach the model, but the text is yours. | ||
| 85 | +- Keep the handler safe for concurrent use: the SDK runs each request in its own goroutine. | ||
| 86 | + | ||
| 87 | +### 4. Check that mm sees them | ||
| 88 | + | ||
| 89 | +Start mm and read its stderr. After `initialize` it prints: | ||
| 90 | + | ||
| 91 | +``` | ||
| 92 | +[acp] client tools (meta): client_add_todo | ||
| 93 | +``` | ||
| 94 | + | ||
| 95 | +An entry it could not use is reported on its own line (`[acp] client tools: entry 0 skipped: …`), with the reason. | ||
| 96 | + | ||
| 97 | +## Variants | ||
| 98 | + | ||
| 99 | +- **The list depends on the session** (its `cwd`, a user): set `discovery: request` in the agent file, advertise only `"org.mini-me/tools": true`, and answer `_org.mini-me/tools/list` in `HandleExtensionMethod` with `map[string]any{"tools": tools}`. mm sends it during `session/new`, `session/load` and `session/resume`, before answering them; the SDK lets you answer while your `NewSession` call waits. | ||
| 100 | + | ||
| 101 | + ```yaml | ||
| 102 | + acp: | ||
| 103 | + clientTools: | ||
| 104 | + discovery: request | ||
| 105 | + ``` | ||
| 106 | + | ||
| 107 | +- **Every call must be confirmed**: set `acp.clientTools.permissions: ask`. mm then sends `session/request_permission` before each call, titled `client: <name>`; answer it in your `RequestPermission` method. With the default `auto`, this happens only when the agent file says `yolo: false`. | ||
| 108 | +- **Slow tools**: raise `acp.clientTools.timeout` (default `30s`). Past it, the model reads an error and the turn goes on. | ||
| 109 | +- **Using the `minime` package** of `demo.acp.sdk/go`: steps 1 to 3 are `minime.Options{Tools: []minime.Tool{…}}`, each `Tool` with a `Handle` function; `ToolDiscovery: minime.DiscoveryRequest` selects the second mode. `go run ./examples/clienttools` runs a complete example. | ||
| 110 | + | ||
| 111 | +## See also | ||
| 112 | + | ||
| 113 | +- [Client tools specification](../reference/acp-client-tools.md) — the exact JSON, the validation of a list, the errors | ||
| 114 | +- [Configuration file — `acp` keys](../reference/configuration.md#acp-keys) | ||
| 115 | +- [How to drive mini-me from a program](drive-mini-me-from-a-program.md) | ||
added
docs/en/reference/acp-client-tools.md +205 -0 | new file mode 100644 | ||
| @@ -0,0 +1,205 @@ | ||
| 1 | +# Reference: ACP client tools (specification) | |
| 2 | + | |
| 3 | +> The protocol an ACP client follows to offer its own tools to mini-me. In ACP mode (`mm -acp`), mm discovers the tools a client exposes as custom extension methods and the model calls them like its built-in tools. This page is the specification a client implements; the Go steps are in [How to offer tools from your ACP client](../how-to/offer-tools-from-an-acp-client.md). | |
| 4 | + | |
| 5 | +## Scope | |
| 6 | + | |
| 7 | +| Item | Value | | |
| 8 | +|------|-------| | |
| 9 | +| Front end | ACP mode only (`mm -acp`). The terminal, the window (`-tui`) and `-p` have no client: no client tool exists there. | | |
| 10 | +| Configuration | [`acp.clientTools`](configuration.md#acp-keys) in the agent file. | | |
| 11 | +| Namespace | `org.mini-me` | | |
| 12 | +| ACP mechanism | [Extensibility](https://agentclientprotocol.com/protocol/extensibility): methods whose name starts with `_`, capabilities in `_meta`. ACP defines no way to describe a method's parameters; this page defines mm's. | | |
| 13 | +| Who calls whom | mm (the agent) sends the requests; the client answers them. | | |
| 14 | + | |
| 15 | +## Identifiers | |
| 16 | + | |
| 17 | +| Identifier | Kind | Where | | |
| 18 | +|------------|------|-------| | |
| 19 | +| `org.mini-me/tools` | `_meta` key | `clientCapabilities._meta` of the `initialize` request. | | |
| 20 | +| `_org.mini-me/tools/list` | request method, agent → client | Discovery `request` only. | | |
| 21 | +| `org.mini-me/sessionId` | `_meta` key | `params._meta` of every tool call. | | |
| 22 | +| `client_<name>` | tool name | The name the model sees for the tool `<name>`. | | |
| 23 | + | |
| 24 | +## Tool description | |
| 25 | + | |
| 26 | +One JSON object per tool. The same object is used by both discovery modes. | |
| 27 | + | |
| 28 | +| Field | Type | Required | Description | | |
| 29 | +|-------|------|----------|-------------| | |
| 30 | +| `name` | string | yes | The tool's name, unique in the list. The model sees `client_<name>`, where every character outside `[A-Za-z0-9]` becomes `_`, runs of `_` are collapsed, and leading/trailing `_` are removed (`open buffers` → `client_open_buffers`). | | |
| 31 | +| `description` | string | no | What the model reads to decide when to call the tool. Absent: `Tool provided by the editor (<method>).` | | |
| 32 | +| `method` | string | yes | The extension method mm calls for this tool. Must start with `_`. Recommended form: `_<reverse-dns>/<path>`, for example `_com.example/workspace/buffers`. | | |
| 33 | +| `inputSchema` | object | no | JSON Schema of the arguments. When present, `type` must be `"object"` (or absent). Absent: the tool takes no parameter. | | |
| 34 | + | |
| 35 | +### Schema as the model sees it | |
| 36 | + | |
| 37 | +The schema is passed to the model with these changes (the same as for MCP tools): | |
| 38 | + | |
| 39 | +| In `inputSchema` | Declared to the model | | |
| 40 | +|------------------|-----------------------| | |
| 41 | +| `required` | Removed from the schema; the names are appended to the description as `Required parameters: a, b`. mm checks them before the call. | | |
| 42 | +| `enum` | Removed; the values are appended to the property's description as `One of: x, y.` | | |
| 43 | +| `additionalProperties: false`, `$schema` | Removed. | | |
| 44 | +| `type`, `properties` | Always present: `type: "object"`, `properties: {}` when absent. | | |
| 45 | + | |
| 46 | +### Validation of a list | |
| 47 | + | |
| 48 | +Each entry is validated on its own. An invalid entry is skipped and a line is written on mm's stderr (`[acp] client tools: entry <i> skipped: <reason>`); the other entries are kept, and the session never fails because of the list. | |
| 49 | + | |
| 50 | +| Reason | Cause | | |
| 51 | +|--------|-------| | |
| 52 | +| `not a tool description: …` | The entry is not a JSON object of the shape above. | | |
| 53 | +| `no name` | `name` is absent or blank. | | |
| 54 | +| `method "…" must start with "_" (an ACP extension method)` | `method` is absent or not an extension method. | | |
| 55 | +| `inputSchema.type must be "object", got …` | The schema describes something other than an object. | | |
| 56 | +| `duplicate tool name "client_…"` | Two entries give the same model name; the first one is kept. | | |
| 57 | + | |
| 58 | +The tools are sorted by model name. | |
| 59 | + | |
| 60 | +## Discovery | |
| 61 | + | |
| 62 | +The mode is set by `acp.clientTools.discovery` in the agent file. The client must use the mode the agent file names. | |
| 63 | + | |
| 64 | +### Mode `meta` (default) | |
| 65 | + | |
| 66 | +The client puts the list in its `initialize` request. The list applies to every session of the connection. | |
| 67 | + | |
| 68 | +```json | |
| 69 | +{"jsonrpc":"2.0","id":0,"method":"initialize","params":{ | |
| 70 | + "protocolVersion":1, | |
| 71 | + "clientInfo":{"name":"my-client","version":"1.0.0"}, | |
| 72 | + "clientCapabilities":{ | |
| 73 | + "_meta":{ | |
| 74 | + "org.mini-me/tools":[ | |
| 75 | + {"name":"open_buffers", | |
| 76 | + "description":"List the files open in the editor.", | |
| 77 | + "method":"_com.example/workspace/buffers", | |
| 78 | + "inputSchema":{"type":"object", | |
| 79 | + "properties":{"language":{"type":"string","description":"Only the buffers of this language."}}, | |
| 80 | + "required":["language"]}} | |
| 81 | + ] | |
| 82 | + } | |
| 83 | + } | |
| 84 | +}} | |
| 85 | +``` | |
| 86 | + | |
| 87 | +In mode `meta`, the value `true` instead of a list gives no tool and the stderr line `"org.mini-me/tools" is not a list of tools; a client that answers _org.mini-me/tools/list needs acp.clientTools.discovery: request`. | |
| 88 | + | |
| 89 | +### Mode `request` | |
| 90 | + | |
| 91 | +The client only advertises the capability: | |
| 92 | + | |
| 93 | +```json | |
| 94 | +"clientCapabilities":{"_meta":{"org.mini-me/tools":true}} | |
| 95 | +``` | |
| 96 | + | |
| 97 | +mm then sends `_org.mini-me/tools/list` while it handles `session/new`, `session/load` and `session/resume`, **before** it answers them. The client must therefore answer this request while it waits for its own `session/new` response (the ACP SDKs handle each incoming request concurrently). | |
| 98 | + | |
| 99 | +Request: | |
| 100 | + | |
| 101 | +```json | |
| 102 | +{"jsonrpc":"2.0","id":7,"method":"_org.mini-me/tools/list", | |
| 103 | + "params":{"sessionId":"20261110-224507-1f3a9c2e","cwd":"/home/me/project"}} | |
| 104 | +``` | |
| 105 | + | |
| 106 | +| Param | Type | Description | | |
| 107 | +|-------|------|-------------| | |
| 108 | +| `sessionId` | string | The id of the session being opened; the `session/new` response that carries it has not been sent yet. | | |
| 109 | +| `cwd` | string | The session's working directory, as the client sent it. | | |
| 110 | + | |
| 111 | +Response: | |
| 112 | + | |
| 113 | +```json | |
| 114 | +{"jsonrpc":"2.0","id":7,"result":{"tools":[ | |
| 115 | + {"name":"open_buffers","method":"_com.example/workspace/buffers"} | |
| 116 | +]}} | |
| 117 | +``` | |
| 118 | + | |
| 119 | +| Outcome | Effect | | |
| 120 | +|---------|--------| | |
| 121 | +| A `tools` array | Those tools, validated as above, for this session only. | | |
| 122 | +| An error, no answer within `acp.clientTools.timeout`, or no `tools` array | No client tool for this session; one stderr line; the session opens normally. | | |
| 123 | + | |
| 124 | +Each session asks again: two sessions of the same connection may have different tools. | |
| 125 | + | |
| 126 | +## Tool call | |
| 127 | + | |
| 128 | +When the model calls `client_<name>`, mm sends a request to the tool's `method`. | |
| 129 | + | |
| 130 | +### Before the request | |
| 131 | + | |
| 132 | +| Check | Result when it fails | | |
| 133 | +|-------|----------------------| | |
| 134 | +| Every parameter of `required` is present in the model's arguments. | No request is sent; the model reads `[client tool error: missing required parameters: a, b]`. | | |
| 135 | +| Permission: with `acp.clientTools.permissions: ask`, or with `auto` when `yolo` is `false`, mm sends `session/request_permission` to the client first (see [`session/request_permission` requests sent](acp.md#sessionrequest_permission-requests-sent)). The tool call's title is `client: <name>`, its kind `other`. | The model reads `[client_<name> rejected by user]`. | | |
| 136 | + | |
| 137 | +### Request | |
| 138 | + | |
| 139 | +```json | |
| 140 | +{"jsonrpc":"2.0","id":12,"method":"_com.example/workspace/buffers", | |
| 141 | + "params":{"language":"go", | |
| 142 | + "_meta":{"org.mini-me/sessionId":"20261110-224507-1f3a9c2e"}}} | |
| 143 | +``` | |
| 144 | + | |
| 145 | +| Param | Description | | |
| 146 | +|-------|-------------| | |
| 147 | +| the model's arguments | Copied as the model sent them, at the top level of `params`. | | |
| 148 | +| `_meta["org.mini-me/sessionId"]` | The session the call belongs to. A `_meta` the model may have put in its arguments is replaced. | | |
| 149 | + | |
| 150 | +### Response | |
| 151 | + | |
| 152 | +Two shapes are accepted. | |
| 153 | + | |
| 154 | +Content list (the shape of MCP's `tools/call` result): | |
| 155 | + | |
| 156 | +```json | |
| 157 | +{"jsonrpc":"2.0","id":12,"result":{ | |
| 158 | + "content":[{"type":"text","text":"main.go\nutil.go"}], | |
| 159 | + "isError":false}} | |
| 160 | +``` | |
| 161 | + | |
| 162 | +| Field | Type | Effect | | |
| 163 | +|-------|------|--------| | |
| 164 | +| `content` | array | The `text` of every block of type `text`, joined with no separator, is what the model reads. A block of another type is replaced by `[client tool: non-text content of type <type> skipped]`. | | |
| 165 | +| `isError` | boolean, optional | `true`: the call is a failed call; the model reads `[client tool error: <the text>]`. | | |
| 166 | + | |
| 167 | +Any other JSON value: a JSON string is given to the model unquoted; any other value (object, number, array without `content`) is given as its JSON text. | |
| 168 | + | |
| 169 | +### Errors | |
| 170 | + | |
| 171 | +| Situation | What the model reads | Turn | | |
| 172 | +|-----------|----------------------|------| | |
| 173 | +| JSON-RPC error (for example `-32601` Method not found) | `[client tool error: {"code":-32601,"message":"Method not found"}]` | Goes on. | | |
| 174 | +| No answer within `acp.clientTools.timeout` | `[client tool error: …]`, with the SDK's error for the expired deadline. mm sends `$/cancel_request` for the request. | Goes on. | | |
| 175 | +| The turn is cancelled (`session/cancel`) | mm sends `$/cancel_request` for the request. | Ends as cancelled. | | |
| 176 | +| `isError: true` | `[client tool error: <text>]` | Goes on. | | |
| 177 | + | |
| 178 | +Every result, failed or not, is cut to `maxOutput` characters (head and tail kept), reported to the editor as the end of the tool call (`session/update`, `tool_call_update`), and counted by the loop detector like any tool call: the same call with the same answer repeated is flagged, then stops the turn. | |
| 179 | + | |
| 180 | +## Configuration | |
| 181 | + | |
| 182 | +| Key | Default | Effect on this protocol | | |
| 183 | +|-----|---------|-------------------------| | |
| 184 | +| `acp.clientTools.enabled` | `true` | `false`: `org.mini-me/tools` is ignored; no list request, no client tool. | | |
| 185 | +| `acp.clientTools.discovery` | `meta` | `meta` or `request`, as described above. | | |
| 186 | +| `acp.clientTools.permissions` | `auto` | `auto`: a call asks the client for permission only when `yolo` is `false`. `ask`: every call asks. | | |
| 187 | +| `acp.clientTools.timeout` | `30s` | Bound of one tool call, and of the list request. | | |
| 188 | + | |
| 189 | +## Log lines (mm's stderr) | |
| 190 | + | |
| 191 | +| Line | When | | |
| 192 | +|------|------| | |
| 193 | +| `[acp] client tools (meta): client_a, client_b` | After `initialize`, mode `meta`, when the client advertised a list. | | |
| 194 | +| `[acp] session <id> client tools (request): client_a` | After a list request answered. | | |
| 195 | +| `[acp] client tools: …` | A skipped entry, a failed list request, a bare `true` in mode `meta`. | | |
| 196 | + | |
| 197 | +## Implementations | |
| 198 | + | |
| 199 | +- [`demo.acp.sdk/go`](../../../demo.acp.sdk/go/README.en.md): `minime.Options.Tools` and `ToolDiscovery` implement the client side of this page in Go; `examples/clienttools` is a to-do list offered as two tools. | |
| 200 | + | |
| 201 | +## See also | |
| 202 | + | |
| 203 | +- [How to offer tools from your ACP client](../how-to/offer-tools-from-an-acp-client.md) | |
| 204 | +- [Agent Client Protocol surface](acp.md) | |
| 205 | +- [Configuration file — `acp` keys](configuration.md#acp-keys) | |
| new file mode 100644 | |||
| @@ -0,0 +1,205 @@ | |||
| 1 | +# Reference: ACP client tools (specification) | ||
| 2 | + | ||
| 3 | +> The protocol an ACP client follows to offer its own tools to mini-me. In ACP mode (`mm -acp`), mm discovers the tools a client exposes as custom extension methods and the model calls them like its built-in tools. This page is the specification a client implements; the Go steps are in [How to offer tools from your ACP client](../how-to/offer-tools-from-an-acp-client.md). | ||
| 4 | + | ||
| 5 | +## Scope | ||
| 6 | + | ||
| 7 | +| Item | Value | | ||
| 8 | +|------|-------| | ||
| 9 | +| Front end | ACP mode only (`mm -acp`). The terminal, the window (`-tui`) and `-p` have no client: no client tool exists there. | | ||
| 10 | +| Configuration | [`acp.clientTools`](configuration.md#acp-keys) in the agent file. | | ||
| 11 | +| Namespace | `org.mini-me` | | ||
| 12 | +| ACP mechanism | [Extensibility](https://agentclientprotocol.com/protocol/extensibility): methods whose name starts with `_`, capabilities in `_meta`. ACP defines no way to describe a method's parameters; this page defines mm's. | | ||
| 13 | +| Who calls whom | mm (the agent) sends the requests; the client answers them. | | ||
| 14 | + | ||
| 15 | +## Identifiers | ||
| 16 | + | ||
| 17 | +| Identifier | Kind | Where | | ||
| 18 | +|------------|------|-------| | ||
| 19 | +| `org.mini-me/tools` | `_meta` key | `clientCapabilities._meta` of the `initialize` request. | | ||
| 20 | +| `_org.mini-me/tools/list` | request method, agent → client | Discovery `request` only. | | ||
| 21 | +| `org.mini-me/sessionId` | `_meta` key | `params._meta` of every tool call. | | ||
| 22 | +| `client_<name>` | tool name | The name the model sees for the tool `<name>`. | | ||
| 23 | + | ||
| 24 | +## Tool description | ||
| 25 | + | ||
| 26 | +One JSON object per tool. The same object is used by both discovery modes. | ||
| 27 | + | ||
| 28 | +| Field | Type | Required | Description | | ||
| 29 | +|-------|------|----------|-------------| | ||
| 30 | +| `name` | string | yes | The tool's name, unique in the list. The model sees `client_<name>`, where every character outside `[A-Za-z0-9]` becomes `_`, runs of `_` are collapsed, and leading/trailing `_` are removed (`open buffers` → `client_open_buffers`). | | ||
| 31 | +| `description` | string | no | What the model reads to decide when to call the tool. Absent: `Tool provided by the editor (<method>).` | | ||
| 32 | +| `method` | string | yes | The extension method mm calls for this tool. Must start with `_`. Recommended form: `_<reverse-dns>/<path>`, for example `_com.example/workspace/buffers`. | | ||
| 33 | +| `inputSchema` | object | no | JSON Schema of the arguments. When present, `type` must be `"object"` (or absent). Absent: the tool takes no parameter. | | ||
| 34 | + | ||
| 35 | +### Schema as the model sees it | ||
| 36 | + | ||
| 37 | +The schema is passed to the model with these changes (the same as for MCP tools): | ||
| 38 | + | ||
| 39 | +| In `inputSchema` | Declared to the model | | ||
| 40 | +|------------------|-----------------------| | ||
| 41 | +| `required` | Removed from the schema; the names are appended to the description as `Required parameters: a, b`. mm checks them before the call. | | ||
| 42 | +| `enum` | Removed; the values are appended to the property's description as `One of: x, y.` | | ||
| 43 | +| `additionalProperties: false`, `$schema` | Removed. | | ||
| 44 | +| `type`, `properties` | Always present: `type: "object"`, `properties: {}` when absent. | | ||
| 45 | + | ||
| 46 | +### Validation of a list | ||
| 47 | + | ||
| 48 | +Each entry is validated on its own. An invalid entry is skipped and a line is written on mm's stderr (`[acp] client tools: entry <i> skipped: <reason>`); the other entries are kept, and the session never fails because of the list. | ||
| 49 | + | ||
| 50 | +| Reason | Cause | | ||
| 51 | +|--------|-------| | ||
| 52 | +| `not a tool description: …` | The entry is not a JSON object of the shape above. | | ||
| 53 | +| `no name` | `name` is absent or blank. | | ||
| 54 | +| `method "…" must start with "_" (an ACP extension method)` | `method` is absent or not an extension method. | | ||
| 55 | +| `inputSchema.type must be "object", got …` | The schema describes something other than an object. | | ||
| 56 | +| `duplicate tool name "client_…"` | Two entries give the same model name; the first one is kept. | | ||
| 57 | + | ||
| 58 | +The tools are sorted by model name. | ||
| 59 | + | ||
| 60 | +## Discovery | ||
| 61 | + | ||
| 62 | +The mode is set by `acp.clientTools.discovery` in the agent file. The client must use the mode the agent file names. | ||
| 63 | + | ||
| 64 | +### Mode `meta` (default) | ||
| 65 | + | ||
| 66 | +The client puts the list in its `initialize` request. The list applies to every session of the connection. | ||
| 67 | + | ||
| 68 | +```json | ||
| 69 | +{"jsonrpc":"2.0","id":0,"method":"initialize","params":{ | ||
| 70 | + "protocolVersion":1, | ||
| 71 | + "clientInfo":{"name":"my-client","version":"1.0.0"}, | ||
| 72 | + "clientCapabilities":{ | ||
| 73 | + "_meta":{ | ||
| 74 | + "org.mini-me/tools":[ | ||
| 75 | + {"name":"open_buffers", | ||
| 76 | + "description":"List the files open in the editor.", | ||
| 77 | + "method":"_com.example/workspace/buffers", | ||
| 78 | + "inputSchema":{"type":"object", | ||
| 79 | + "properties":{"language":{"type":"string","description":"Only the buffers of this language."}}, | ||
| 80 | + "required":["language"]}} | ||
| 81 | + ] | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | +}} | ||
| 85 | +``` | ||
| 86 | + | ||
| 87 | +In mode `meta`, the value `true` instead of a list gives no tool and the stderr line `"org.mini-me/tools" is not a list of tools; a client that answers _org.mini-me/tools/list needs acp.clientTools.discovery: request`. | ||
| 88 | + | ||
| 89 | +### Mode `request` | ||
| 90 | + | ||
| 91 | +The client only advertises the capability: | ||
| 92 | + | ||
| 93 | +```json | ||
| 94 | +"clientCapabilities":{"_meta":{"org.mini-me/tools":true}} | ||
| 95 | +``` | ||
| 96 | + | ||
| 97 | +mm then sends `_org.mini-me/tools/list` while it handles `session/new`, `session/load` and `session/resume`, **before** it answers them. The client must therefore answer this request while it waits for its own `session/new` response (the ACP SDKs handle each incoming request concurrently). | ||
| 98 | + | ||
| 99 | +Request: | ||
| 100 | + | ||
| 101 | +```json | ||
| 102 | +{"jsonrpc":"2.0","id":7,"method":"_org.mini-me/tools/list", | ||
| 103 | + "params":{"sessionId":"20261110-224507-1f3a9c2e","cwd":"/home/me/project"}} | ||
| 104 | +``` | ||
| 105 | + | ||
| 106 | +| Param | Type | Description | | ||
| 107 | +|-------|------|-------------| | ||
| 108 | +| `sessionId` | string | The id of the session being opened; the `session/new` response that carries it has not been sent yet. | | ||
| 109 | +| `cwd` | string | The session's working directory, as the client sent it. | | ||
| 110 | + | ||
| 111 | +Response: | ||
| 112 | + | ||
| 113 | +```json | ||
| 114 | +{"jsonrpc":"2.0","id":7,"result":{"tools":[ | ||
| 115 | + {"name":"open_buffers","method":"_com.example/workspace/buffers"} | ||
| 116 | +]}} | ||
| 117 | +``` | ||
| 118 | + | ||
| 119 | +| Outcome | Effect | | ||
| 120 | +|---------|--------| | ||
| 121 | +| A `tools` array | Those tools, validated as above, for this session only. | | ||
| 122 | +| An error, no answer within `acp.clientTools.timeout`, or no `tools` array | No client tool for this session; one stderr line; the session opens normally. | | ||
| 123 | + | ||
| 124 | +Each session asks again: two sessions of the same connection may have different tools. | ||
| 125 | + | ||
| 126 | +## Tool call | ||
| 127 | + | ||
| 128 | +When the model calls `client_<name>`, mm sends a request to the tool's `method`. | ||
| 129 | + | ||
| 130 | +### Before the request | ||
| 131 | + | ||
| 132 | +| Check | Result when it fails | | ||
| 133 | +|-------|----------------------| | ||
| 134 | +| Every parameter of `required` is present in the model's arguments. | No request is sent; the model reads `[client tool error: missing required parameters: a, b]`. | | ||
| 135 | +| Permission: with `acp.clientTools.permissions: ask`, or with `auto` when `yolo` is `false`, mm sends `session/request_permission` to the client first (see [`session/request_permission` requests sent](acp.md#sessionrequest_permission-requests-sent)). The tool call's title is `client: <name>`, its kind `other`. | The model reads `[client_<name> rejected by user]`. | | ||
| 136 | + | ||
| 137 | +### Request | ||
| 138 | + | ||
| 139 | +```json | ||
| 140 | +{"jsonrpc":"2.0","id":12,"method":"_com.example/workspace/buffers", | ||
| 141 | + "params":{"language":"go", | ||
| 142 | + "_meta":{"org.mini-me/sessionId":"20261110-224507-1f3a9c2e"}}} | ||
| 143 | +``` | ||
| 144 | + | ||
| 145 | +| Param | Description | | ||
| 146 | +|-------|-------------| | ||
| 147 | +| the model's arguments | Copied as the model sent them, at the top level of `params`. | | ||
| 148 | +| `_meta["org.mini-me/sessionId"]` | The session the call belongs to. A `_meta` the model may have put in its arguments is replaced. | | ||
| 149 | + | ||
| 150 | +### Response | ||
| 151 | + | ||
| 152 | +Two shapes are accepted. | ||
| 153 | + | ||
| 154 | +Content list (the shape of MCP's `tools/call` result): | ||
| 155 | + | ||
| 156 | +```json | ||
| 157 | +{"jsonrpc":"2.0","id":12,"result":{ | ||
| 158 | + "content":[{"type":"text","text":"main.go\nutil.go"}], | ||
| 159 | + "isError":false}} | ||
| 160 | +``` | ||
| 161 | + | ||
| 162 | +| Field | Type | Effect | | ||
| 163 | +|-------|------|--------| | ||
| 164 | +| `content` | array | The `text` of every block of type `text`, joined with no separator, is what the model reads. A block of another type is replaced by `[client tool: non-text content of type <type> skipped]`. | | ||
| 165 | +| `isError` | boolean, optional | `true`: the call is a failed call; the model reads `[client tool error: <the text>]`. | | ||
| 166 | + | ||
| 167 | +Any other JSON value: a JSON string is given to the model unquoted; any other value (object, number, array without `content`) is given as its JSON text. | ||
| 168 | + | ||
| 169 | +### Errors | ||
| 170 | + | ||
| 171 | +| Situation | What the model reads | Turn | | ||
| 172 | +|-----------|----------------------|------| | ||
| 173 | +| JSON-RPC error (for example `-32601` Method not found) | `[client tool error: {"code":-32601,"message":"Method not found"}]` | Goes on. | | ||
| 174 | +| No answer within `acp.clientTools.timeout` | `[client tool error: …]`, with the SDK's error for the expired deadline. mm sends `$/cancel_request` for the request. | Goes on. | | ||
| 175 | +| The turn is cancelled (`session/cancel`) | mm sends `$/cancel_request` for the request. | Ends as cancelled. | | ||
| 176 | +| `isError: true` | `[client tool error: <text>]` | Goes on. | | ||
| 177 | + | ||
| 178 | +Every result, failed or not, is cut to `maxOutput` characters (head and tail kept), reported to the editor as the end of the tool call (`session/update`, `tool_call_update`), and counted by the loop detector like any tool call: the same call with the same answer repeated is flagged, then stops the turn. | ||
| 179 | + | ||
| 180 | +## Configuration | ||
| 181 | + | ||
| 182 | +| Key | Default | Effect on this protocol | | ||
| 183 | +|-----|---------|-------------------------| | ||
| 184 | +| `acp.clientTools.enabled` | `true` | `false`: `org.mini-me/tools` is ignored; no list request, no client tool. | | ||
| 185 | +| `acp.clientTools.discovery` | `meta` | `meta` or `request`, as described above. | | ||
| 186 | +| `acp.clientTools.permissions` | `auto` | `auto`: a call asks the client for permission only when `yolo` is `false`. `ask`: every call asks. | | ||
| 187 | +| `acp.clientTools.timeout` | `30s` | Bound of one tool call, and of the list request. | | ||
| 188 | + | ||
| 189 | +## Log lines (mm's stderr) | ||
| 190 | + | ||
| 191 | +| Line | When | | ||
| 192 | +|------|------| | ||
| 193 | +| `[acp] client tools (meta): client_a, client_b` | After `initialize`, mode `meta`, when the client advertised a list. | | ||
| 194 | +| `[acp] session <id> client tools (request): client_a` | After a list request answered. | | ||
| 195 | +| `[acp] client tools: …` | A skipped entry, a failed list request, a bare `true` in mode `meta`. | | ||
| 196 | + | ||
| 197 | +## Implementations | ||
| 198 | + | ||
| 199 | +- [`demo.acp.sdk/go`](../../../demo.acp.sdk/go/README.en.md): `minime.Options.Tools` and `ToolDiscovery` implement the client side of this page in Go; `examples/clienttools` is a to-do list offered as two tools. | ||
| 200 | + | ||
| 201 | +## See also | ||
| 202 | + | ||
| 203 | +- [How to offer tools from your ACP client](../how-to/offer-tools-from-an-acp-client.md) | ||
| 204 | +- [Agent Client Protocol surface](acp.md) | ||
| 205 | +- [Configuration file — `acp` keys](configuration.md#acp-keys) | ||
modified
docs/en/reference/acp.md +16 -5 | @@ -26,14 +26,14 @@ | ||
| 26 | 26 | |
| 27 | 27 | | Method | Behaviour | |
| 28 | 28 | |--------|-----------| |
| 29 | -| `initialize` | Reads `clientCapabilities.elicitation.form` (present and non-null = form support), logs the client name, protocol version and `elicitation(form)=true\|false` on stderr; returns the values above. | | |
| 29 | +| `initialize` | Reads `clientCapabilities.elicitation.form` (present and non-null = form support), logs the client name, protocol version and `elicitation(form)=true\|false` on stderr; returns the values above. Also reads `clientCapabilities._meta["org.mini-me/tools"]`, the client tools (see [Client tools](#client-tools)). | | |
| 30 | 30 | | `authenticate` | Returns an empty response. | |
| 31 | -| `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. | | |
| 31 | +| `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. | | |
| 32 | 32 | | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | |
| 33 | 33 | | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. | |
| 34 | -| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. | | |
| 34 | +| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | | |
| 35 | 35 | | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. | |
| 36 | -| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. | | |
| 36 | +| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | | |
| 37 | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | |
| 39 | 39 | ## Skills as commands |
| @@ -151,6 +151,17 @@ Without form support, a form whose questions are all `choice` or `confirm` goes | ||
| 151 | 151 | |
| 152 | 152 | When mm is the **client** — an agent asking another one through `/ask`, `ask_agent` or `mm --ask` — its `initialize` request advertises `clientCapabilities.elicitation.form`, and the asked agent's `elicitation/create` requests are put to the asking agent's user through its own front end. A front end that cannot ask (`mm --ask`) answers with an error. |
| 153 | 153 | |
| 154 | +## Client tools | |
| 155 | + | |
| 156 | +A client may offer its own tools as ACP extension methods (`acp.clientTools`, on by default). mm then sends these requests to the client: | |
| 157 | + | |
| 158 | +| Request | When | | |
| 159 | +|---------|------| | |
| 160 | +| `_org.mini-me/tools/list` | `session/new`, `session/load`, `session/resume`, before answering, with `acp.clientTools.discovery: request` and a client that advertised `"org.mini-me/tools": true`. | | |
| 161 | +| the tool's own `method` (starts with `_`) | Each call of a client tool by the model. | | |
| 162 | + | |
| 163 | +Extension requests sent BY the client to mm are answered `method not found`. The complete protocol is in [ACP client tools](acp-client-tools.md). | |
| 164 | + | |
| 154 | 165 | ## Tool kinds |
| 155 | 166 | |
| 156 | 167 | | `mm` kind | ACP `ToolKind` | Tools | |
| @@ -160,7 +171,7 @@ When mm is the **client** — an agent asking another one through `/ask`, `ask_a | ||
| 160 | 171 | | `edit` | `edit` | `write_file`, `edit_file` | |
| 161 | 172 | | `fetch` | `fetch` | `fetch_content` | |
| 162 | 173 | | `search` | `search` | `search_memory`, `search_docs` | |
| 163 | -| `other` | `other` | `ask_agent`, `ask_user` | | |
| 174 | +| `other` | `other` | `ask_agent`, `ask_user`, client tools (`client_<name>`) | | |
| 164 | 175 | |
| 165 | 176 | ## Tool call identifiers |
| 166 | 177 | |
| @@ -26,14 +26,14 @@ | |||
| 26 | 26 | ||
| 27 | | Method | Behaviour | | 27 | | Method | Behaviour | |
| 28 | |--------|-----------| | 28 | |--------|-----------| |
| 29 | -| `initialize` | Reads `clientCapabilities.elicitation.form` (present and non-null = form support), logs the client name, protocol version and `elicitation(form)=true\|false` on stderr; returns the values above. | | 29 | +| `initialize` | Reads `clientCapabilities.elicitation.form` (present and non-null = form support), logs the client name, protocol version and `elicitation(form)=true\|false` on stderr; returns the values above. Also reads `clientCapabilities._meta["org.mini-me/tools"]`, the client tools (see [Client tools](#client-tools)). | |
| 30 | | `authenticate` | Returns an empty response. | | 30 | | `authenticate` | Returns an empty response. | |
| 31 | -| `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. | | 31 | +| `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. Before answering, gives the session its client tools: the list read at `initialize`, or, with `acp.clientTools.discovery: request`, the answer to a `_org.mini-me/tools/list` request. | |
| 32 | | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | | 32 | | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | |
| 33 | | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. | | 33 | | `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. | |
| 34 | -| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. | | 34 | +| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | |
| 35 | | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. | | 35 | | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. | |
| 36 | -| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. | | 36 | +| `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. The session's client tools are set as for `session/new`. | |
| 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | ||
| 39 | ## Skills as commands | 39 | ## Skills as commands |
| @@ -151,6 +151,17 @@ Without form support, a form whose questions are all `choice` or `confirm` goes | |||
| 151 | 151 | ||
| 152 | When mm is the **client** — an agent asking another one through `/ask`, `ask_agent` or `mm --ask` — its `initialize` request advertises `clientCapabilities.elicitation.form`, and the asked agent's `elicitation/create` requests are put to the asking agent's user through its own front end. A front end that cannot ask (`mm --ask`) answers with an error. | 152 | When mm is the **client** — an agent asking another one through `/ask`, `ask_agent` or `mm --ask` — its `initialize` request advertises `clientCapabilities.elicitation.form`, and the asked agent's `elicitation/create` requests are put to the asking agent's user through its own front end. A front end that cannot ask (`mm --ask`) answers with an error. |
| 153 | 153 | ||
| 154 | +## Client tools | ||
| 155 | + | ||
| 156 | +A client may offer its own tools as ACP extension methods (`acp.clientTools`, on by default). mm then sends these requests to the client: | ||
| 157 | + | ||
| 158 | +| Request | When | | ||
| 159 | +|---------|------| | ||
| 160 | +| `_org.mini-me/tools/list` | `session/new`, `session/load`, `session/resume`, before answering, with `acp.clientTools.discovery: request` and a client that advertised `"org.mini-me/tools": true`. | | ||
| 161 | +| the tool's own `method` (starts with `_`) | Each call of a client tool by the model. | | ||
| 162 | + | ||
| 163 | +Extension requests sent BY the client to mm are answered `method not found`. The complete protocol is in [ACP client tools](acp-client-tools.md). | ||
| 164 | + | ||
| 154 | ## Tool kinds | 165 | ## Tool kinds |
| 155 | 166 | ||
| 156 | | `mm` kind | ACP `ToolKind` | Tools | | 167 | | `mm` kind | ACP `ToolKind` | Tools | |
| @@ -160,7 +171,7 @@ When mm is the **client** — an agent asking another one through `/ask`, `ask_a | |||
| 160 | | `edit` | `edit` | `write_file`, `edit_file` | | 171 | | `edit` | `edit` | `write_file`, `edit_file` | |
| 161 | | `fetch` | `fetch` | `fetch_content` | | 172 | | `fetch` | `fetch` | `fetch_content` | |
| 162 | | `search` | `search` | `search_memory`, `search_docs` | | 173 | | `search` | `search` | `search_memory`, `search_docs` | |
| 163 | -| `other` | `other` | `ask_agent`, `ask_user` | | 174 | +| `other` | `other` | `ask_agent`, `ask_user`, client tools (`client_<name>`) | |
| 164 | 175 | ||
| 165 | ## Tool call identifiers | 176 | ## Tool call identifiers |
| 166 | 177 | ||
modified
docs/en/reference/configuration.md +4 -0 | @@ -120,6 +120,10 @@ Not read: a missing file, a file of blanks only, a file above `maxChars`, any fi | ||
| 120 | 120 | |-----|------|---------|-------------| |
| 121 | 121 | | `acp.name` | string | `bob` | `agentInfo.name` in the `initialize` response. Must not be empty. | |
| 122 | 122 | | `acp.title` | string | `Bob (bash-first agent)` | `agentInfo.title`, the name the editor may display. Empty: not sent. Also shown by the window mode (`-tui`) next to `mm`, in its header and above each answer — `‣ mm [Homer (coder)]` — unless it is empty or the built-in default. During an `/ask`, the window shows the ASKED agent instead: its `acp.title` (or its `mmConfigs` name) above its answer, and in the header with its model and `asked by <this agent>`, until it has answered. | |
| 123 | +| `acp.clientTools.enabled` | bool | `true` | ACP mode only: the tools an ACP client advertises (`org.mini-me/tools` in its capabilities' `_meta`) become agent tools named `client_<name>`, called through the client's extension methods. `false`: whatever the client advertises is ignored. No effect when the client advertises nothing. See [ACP client tools](acp-client-tools.md). | | |
| 124 | +| `acp.clientTools.discovery` | string | `meta` | `meta`: the client lists its tools in the `initialize` request, one list for the connection. `request`: the client only advertises `true`, and mm asks for the list (`_org.mini-me/tools/list`) at each `session/new`, `session/load` and `session/resume`. Any other value is refused: `acp.clientTools.discovery must be "meta" or "request"`. | | |
| 125 | +| `acp.clientTools.permissions` | string | `auto` | `auto`: a call of a client tool asks the client for permission only when `yolo` is `false`. `ask`: every call asks. Any other value is refused. | | |
| 126 | +| `acp.clientTools.timeout` | duration | `30s` | Bound of one call of a client tool, and of the list request. Must be > 0. | | |
| 123 | 127 | |
| 124 | 128 | The version is not configurable: `agentInfo.version` is always the binary's, as printed by `mm --version`. |
| 125 | 129 | |
| @@ -120,6 +120,10 @@ Not read: a missing file, a file of blanks only, a file above `maxChars`, any fi | |||
| 120 | |-----|------|---------|-------------| | 120 | |-----|------|---------|-------------| |
| 121 | | `acp.name` | string | `bob` | `agentInfo.name` in the `initialize` response. Must not be empty. | | 121 | | `acp.name` | string | `bob` | `agentInfo.name` in the `initialize` response. Must not be empty. | |
| 122 | | `acp.title` | string | `Bob (bash-first agent)` | `agentInfo.title`, the name the editor may display. Empty: not sent. Also shown by the window mode (`-tui`) next to `mm`, in its header and above each answer — `‣ mm [Homer (coder)]` — unless it is empty or the built-in default. During an `/ask`, the window shows the ASKED agent instead: its `acp.title` (or its `mmConfigs` name) above its answer, and in the header with its model and `asked by <this agent>`, until it has answered. | | 122 | | `acp.title` | string | `Bob (bash-first agent)` | `agentInfo.title`, the name the editor may display. Empty: not sent. Also shown by the window mode (`-tui`) next to `mm`, in its header and above each answer — `‣ mm [Homer (coder)]` — unless it is empty or the built-in default. During an `/ask`, the window shows the ASKED agent instead: its `acp.title` (or its `mmConfigs` name) above its answer, and in the header with its model and `asked by <this agent>`, until it has answered. | |
| 123 | +| `acp.clientTools.enabled` | bool | `true` | ACP mode only: the tools an ACP client advertises (`org.mini-me/tools` in its capabilities' `_meta`) become agent tools named `client_<name>`, called through the client's extension methods. `false`: whatever the client advertises is ignored. No effect when the client advertises nothing. See [ACP client tools](acp-client-tools.md). | | ||
| 124 | +| `acp.clientTools.discovery` | string | `meta` | `meta`: the client lists its tools in the `initialize` request, one list for the connection. `request`: the client only advertises `true`, and mm asks for the list (`_org.mini-me/tools/list`) at each `session/new`, `session/load` and `session/resume`. Any other value is refused: `acp.clientTools.discovery must be "meta" or "request"`. | | ||
| 125 | +| `acp.clientTools.permissions` | string | `auto` | `auto`: a call of a client tool asks the client for permission only when `yolo` is `false`. `ask`: every call asks. Any other value is refused. | | ||
| 126 | +| `acp.clientTools.timeout` | duration | `30s` | Bound of one call of a client tool, and of the list request. Must be > 0. | | ||
| 123 | 127 | ||
| 124 | The version is not configurable: `agentInfo.version` is always the binary's, as printed by `mm --version`. | 128 | The version is not configurable: `agentInfo.version` is always the binary's, as printed by `mm --version`. |
| 125 | 129 | ||
modified
docs/fr/README.md +2 -0 | @@ -23,6 +23,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 23 | 23 | - [Laisser votre agent appeler d'autres agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, descriptions dans `mmConfigs`, `yolo` par agent |
| 24 | 24 | - [Interroger un autre agent depuis une skill ou un script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context` |
| 25 | 25 | - [Piloter mini-me depuis un programme](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js : `mm -acp` comme bibliothèque : `Start`/`Complete`/`Stream`, annulation, callback de permission |
| 26 | +- [Proposer des outils depuis votre client ACP](how-to/offer-tools-from-an-acp-client.md) — les fonctions de votre programme comme outils de l'agent : `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools` | |
| 26 | 27 | - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) |
| 27 | 28 | - [Couper le raisonnement d'un modèle](how-to/turn-reasoning-off.md) — `chat_template_kwargs` ou `reasoning_effort` dans `sampling`, selon le serveur |
| 28 | 29 | - [Confirmer les appels d'outils avant leur exécution](how-to/confirm-tool-calls.md) — `yolo: false`, les quatre réponses, `AGENT_YOLO`, qui décide avec `mmConfigs` |
| @@ -72,6 +73,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 72 | 73 | - [Format d'un fichier de skill](reference/skill-format.md) |
| 73 | 74 | - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation |
| 74 | 75 | - [Surface Agent Client Protocol](reference/acp.md) |
| 76 | +- [Outils du client ACP](reference/acp-client-tools.md) — la spécification qu'un client implémente pour proposer ses outils : découverte, appels, réponses, erreurs | |
| 75 | 77 | |
| 76 | 78 | ### Référence MCP (Model Context Protocol) |
| 77 | 79 | - [Configuration MCP](reference/mcp-configuration.md) — Toutes les options de configuration spécifiques à MCP |
| @@ -23,6 +23,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 23 | - [Laisser votre agent appeler d'autres agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, descriptions dans `mmConfigs`, `yolo` par agent | 23 | - [Laisser votre agent appeler d'autres agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, descriptions dans `mmConfigs`, `yolo` par agent |
| 24 | - [Interroger un autre agent depuis une skill ou un script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context` | 24 | - [Interroger un autre agent depuis une skill ou un script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context` |
| 25 | - [Piloter mini-me depuis un programme](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js : `mm -acp` comme bibliothèque : `Start`/`Complete`/`Stream`, annulation, callback de permission | 25 | - [Piloter mini-me depuis un programme](how-to/drive-mini-me-from-a-program.md) — Go, Kotlin, Node.js : `mm -acp` comme bibliothèque : `Start`/`Complete`/`Stream`, annulation, callback de permission |
| 26 | +- [Proposer des outils depuis votre client ACP](how-to/offer-tools-from-an-acp-client.md) — les fonctions de votre programme comme outils de l'agent : `org.mini-me/tools`, `HandleExtensionMethod`, `acp.clientTools` | ||
| 26 | - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) | 27 | - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) |
| 27 | - [Couper le raisonnement d'un modèle](how-to/turn-reasoning-off.md) — `chat_template_kwargs` ou `reasoning_effort` dans `sampling`, selon le serveur | 28 | - [Couper le raisonnement d'un modèle](how-to/turn-reasoning-off.md) — `chat_template_kwargs` ou `reasoning_effort` dans `sampling`, selon le serveur |
| 28 | - [Confirmer les appels d'outils avant leur exécution](how-to/confirm-tool-calls.md) — `yolo: false`, les quatre réponses, `AGENT_YOLO`, qui décide avec `mmConfigs` | 29 | - [Confirmer les appels d'outils avant leur exécution](how-to/confirm-tool-calls.md) — `yolo: false`, les quatre réponses, `AGENT_YOLO`, qui décide avec `mmConfigs` |
| @@ -72,6 +73,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 72 | - [Format d'un fichier de skill](reference/skill-format.md) | 73 | - [Format d'un fichier de skill](reference/skill-format.md) |
| 73 | - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation | 74 | - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation |
| 74 | - [Surface Agent Client Protocol](reference/acp.md) | 75 | - [Surface Agent Client Protocol](reference/acp.md) |
| 76 | +- [Outils du client ACP](reference/acp-client-tools.md) — la spécification qu'un client implémente pour proposer ses outils : découverte, appels, réponses, erreurs | ||
| 75 | 77 | ||
| 76 | ### Référence MCP (Model Context Protocol) | 78 | ### Référence MCP (Model Context Protocol) |
| 77 | - [Configuration MCP](reference/mcp-configuration.md) — Toutes les options de configuration spécifiques à MCP | 79 | - [Configuration MCP](reference/mcp-configuration.md) — Toutes les options de configuration spécifiques à MCP |
added
docs/fr/how-to/offer-tools-from-an-acp-client.md +115 -0 | new file mode 100644 | ||
| @@ -0,0 +1,115 @@ | ||
| 1 | +# Proposer des outils depuis votre client ACP | |
| 2 | + | |
| 3 | +Ce guide montre comment un programme Go qui pilote `mm -acp` donne à l'agent ses propres outils : des fonctions de votre programme que le modèle appelle comme ses outils intégrés (les buffers ouverts d'un éditeur, les données d'une application, une liste de tâches…). Il suppose que votre programme est déjà un client ACP de mm, écrit avec [`github.com/coder/acp-go-sdk`](https://github.com/coder/acp-go-sdk) (v0.13.5, la version qu'utilise mm). Le protocole exact est dans la [spécification des outils du client](../reference/acp-client-tools.md). | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +### 1. Décrire vos outils | |
| 8 | + | |
| 9 | +Une description par outil : un nom, une description pour le modèle, la méthode d'extension que mm appellera (elle doit commencer par `_`), et le schéma JSON des arguments. | |
| 10 | + | |
| 11 | +```go | |
| 12 | +type toolSpec struct { | |
| 13 | + Name string `json:"name"` | |
| 14 | + Description string `json:"description,omitempty"` | |
| 15 | + Method string `json:"method"` | |
| 16 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | |
| 17 | +} | |
| 18 | + | |
| 19 | +var tools = []toolSpec{{ | |
| 20 | + Name: "add_todo", | |
| 21 | + Description: "Add one item to the user's to-do list.", | |
| 22 | + Method: "_com.example/todo/add", | |
| 23 | + InputSchema: map[string]any{ | |
| 24 | + "type": "object", | |
| 25 | + "properties": map[string]any{"item": map[string]any{"type": "string"}}, | |
| 26 | + "required": []any{"item"}, | |
| 27 | + }, | |
| 28 | +}} | |
| 29 | +``` | |
| 30 | + | |
| 31 | +Le modèle verra l'outil sous le nom `client_add_todo`. | |
| 32 | + | |
| 33 | +### 2. Les annoncer dans `initialize` | |
| 34 | + | |
| 35 | +Mettez la liste dans `clientCapabilities._meta`, sous `org.mini-me/tools` : | |
| 36 | + | |
| 37 | +```go | |
| 38 | +_, err := conn.Initialize(ctx, sdk.InitializeRequest{ | |
| 39 | + ProtocolVersion: sdk.ProtocolVersionNumber, | |
| 40 | + ClientInfo: &sdk.Implementation{Name: "my-client", Version: "1.0.0"}, | |
| 41 | + ClientCapabilities: sdk.ClientCapabilities{ | |
| 42 | + Meta: map[string]any{"org.mini-me/tools": tools}, | |
| 43 | + }, | |
| 44 | +}) | |
| 45 | +``` | |
| 46 | + | |
| 47 | +C'est tout ce dont mm a besoin avec la configuration par défaut (`acp.clientTools.discovery: meta`) : chaque session ouverte ensuite a les outils. | |
| 48 | + | |
| 49 | +### 3. Répondre aux appels | |
| 50 | + | |
| 51 | +Votre type client (celui passé à `sdk.NewClientSideConnection`) implémente `sdk.ExtensionMethodHandler`. Le SDK lui passe chaque requête dont la méthode commence par `_` : | |
| 52 | + | |
| 53 | +```go | |
| 54 | +var _ sdk.ExtensionMethodHandler = (*myClient)(nil) | |
| 55 | + | |
| 56 | +func (c *myClient) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { | |
| 57 | + switch method { | |
| 58 | + case "_com.example/todo/add": | |
| 59 | + var args struct { | |
| 60 | + Item string `json:"item"` | |
| 61 | + Meta struct { | |
| 62 | + SessionID string `json:"org.mini-me/sessionId"` | |
| 63 | + } `json:"_meta"` | |
| 64 | + } | |
| 65 | + if err := json.Unmarshal(params, &args); err != nil { | |
| 66 | + return nil, sdk.NewInvalidParams(map[string]any{"error": err.Error()}) | |
| 67 | + } | |
| 68 | + c.todos = append(c.todos, args.Item) | |
| 69 | + return textResult(fmt.Sprintf("added %q", args.Item), false), nil | |
| 70 | + } | |
| 71 | + return nil, sdk.NewMethodNotFound(method) | |
| 72 | +} | |
| 73 | + | |
| 74 | +// textResult is the answer shape mm reads first. | |
| 75 | +func textResult(text string, isError bool) map[string]any { | |
| 76 | + return map[string]any{ | |
| 77 | + "content": []map[string]any{{"type": "text", "text": text}}, | |
| 78 | + "isError": isError, | |
| 79 | + } | |
| 80 | +} | |
| 81 | +``` | |
| 82 | + | |
| 83 | +- Les arguments du modèle sont au premier niveau de `params` ; `_meta["org.mini-me/sessionId"]` indique quelle session a appelé. | |
| 84 | +- Répondez à un échec par `isError: true` et un texte dont le modèle peut se servir, plutôt que par une erreur JSON-RPC : les deux arrivent au modèle, mais le texte, c'est vous qui l'écrivez. | |
| 85 | +- Gardez le handler sûr en accès concurrent : le SDK traite chaque requête dans sa propre goroutine. | |
| 86 | + | |
| 87 | +### 4. Vérifier que mm les voit | |
| 88 | + | |
| 89 | +Lancez mm et lisez son stderr. Après `initialize`, il affiche : | |
| 90 | + | |
| 91 | +``` | |
| 92 | +[acp] client tools (meta): client_add_todo | |
| 93 | +``` | |
| 94 | + | |
| 95 | +Une entrée qu'il n'a pas pu utiliser est signalée sur sa propre ligne (`[acp] client tools: entry 0 skipped: …`), avec la raison. | |
| 96 | + | |
| 97 | +## Variantes | |
| 98 | + | |
| 99 | +- **La liste dépend de la session** (son `cwd`, un utilisateur) : mettez `discovery: request` dans le fichier d'agent, annoncez seulement `"org.mini-me/tools": true`, et répondez à `_org.mini-me/tools/list` dans `HandleExtensionMethod` avec `map[string]any{"tools": tools}`. mm l'envoie pendant `session/new`, `session/load` et `session/resume`, avant d'y répondre ; le SDK vous permet de répondre pendant que votre appel à `NewSession` attend. | |
| 100 | + | |
| 101 | + ```yaml | |
| 102 | + acp: | |
| 103 | + clientTools: | |
| 104 | + discovery: request | |
| 105 | + ``` | |
| 106 | + | |
| 107 | +- **Chaque appel doit être confirmé** : mettez `acp.clientTools.permissions: ask`. mm envoie alors `session/request_permission` avant chaque appel, avec le titre `client: <name>` ; répondez-y dans votre méthode `RequestPermission`. Avec la valeur par défaut `auto`, cela n'arrive que si le fichier d'agent dit `yolo: false`. | |
| 108 | +- **Outils lents** : augmentez `acp.clientTools.timeout` (`30s` par défaut). Au-delà, le modèle lit une erreur et le tour continue. | |
| 109 | +- **Avec le paquet `minime`** de `demo.acp.sdk/go` : les étapes 1 à 3 se résument à `minime.Options{Tools: []minime.Tool{…}}`, chaque `Tool` ayant une fonction `Handle` ; `ToolDiscovery: minime.DiscoveryRequest` choisit le second mode. `go run ./examples/clienttools` lance un exemple complet. | |
| 110 | + | |
| 111 | +## Voir aussi | |
| 112 | + | |
| 113 | +- [Spécification des outils du client](../reference/acp-client-tools.md) — le JSON exact, la validation d'une liste, les erreurs | |
| 114 | +- [Fichier de configuration — clés de `acp`](../reference/configuration.md#clés-de-acp) | |
| 115 | +- [Piloter mini-me depuis un programme](drive-mini-me-from-a-program.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,115 @@ | |||
| 1 | +# Proposer des outils depuis votre client ACP | ||
| 2 | + | ||
| 3 | +Ce guide montre comment un programme Go qui pilote `mm -acp` donne à l'agent ses propres outils : des fonctions de votre programme que le modèle appelle comme ses outils intégrés (les buffers ouverts d'un éditeur, les données d'une application, une liste de tâches…). Il suppose que votre programme est déjà un client ACP de mm, écrit avec [`github.com/coder/acp-go-sdk`](https://github.com/coder/acp-go-sdk) (v0.13.5, la version qu'utilise mm). Le protocole exact est dans la [spécification des outils du client](../reference/acp-client-tools.md). | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +### 1. Décrire vos outils | ||
| 8 | + | ||
| 9 | +Une description par outil : un nom, une description pour le modèle, la méthode d'extension que mm appellera (elle doit commencer par `_`), et le schéma JSON des arguments. | ||
| 10 | + | ||
| 11 | +```go | ||
| 12 | +type toolSpec struct { | ||
| 13 | + Name string `json:"name"` | ||
| 14 | + Description string `json:"description,omitempty"` | ||
| 15 | + Method string `json:"method"` | ||
| 16 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +var tools = []toolSpec{{ | ||
| 20 | + Name: "add_todo", | ||
| 21 | + Description: "Add one item to the user's to-do list.", | ||
| 22 | + Method: "_com.example/todo/add", | ||
| 23 | + InputSchema: map[string]any{ | ||
| 24 | + "type": "object", | ||
| 25 | + "properties": map[string]any{"item": map[string]any{"type": "string"}}, | ||
| 26 | + "required": []any{"item"}, | ||
| 27 | + }, | ||
| 28 | +}} | ||
| 29 | +``` | ||
| 30 | + | ||
| 31 | +Le modèle verra l'outil sous le nom `client_add_todo`. | ||
| 32 | + | ||
| 33 | +### 2. Les annoncer dans `initialize` | ||
| 34 | + | ||
| 35 | +Mettez la liste dans `clientCapabilities._meta`, sous `org.mini-me/tools` : | ||
| 36 | + | ||
| 37 | +```go | ||
| 38 | +_, err := conn.Initialize(ctx, sdk.InitializeRequest{ | ||
| 39 | + ProtocolVersion: sdk.ProtocolVersionNumber, | ||
| 40 | + ClientInfo: &sdk.Implementation{Name: "my-client", Version: "1.0.0"}, | ||
| 41 | + ClientCapabilities: sdk.ClientCapabilities{ | ||
| 42 | + Meta: map[string]any{"org.mini-me/tools": tools}, | ||
| 43 | + }, | ||
| 44 | +}) | ||
| 45 | +``` | ||
| 46 | + | ||
| 47 | +C'est tout ce dont mm a besoin avec la configuration par défaut (`acp.clientTools.discovery: meta`) : chaque session ouverte ensuite a les outils. | ||
| 48 | + | ||
| 49 | +### 3. Répondre aux appels | ||
| 50 | + | ||
| 51 | +Votre type client (celui passé à `sdk.NewClientSideConnection`) implémente `sdk.ExtensionMethodHandler`. Le SDK lui passe chaque requête dont la méthode commence par `_` : | ||
| 52 | + | ||
| 53 | +```go | ||
| 54 | +var _ sdk.ExtensionMethodHandler = (*myClient)(nil) | ||
| 55 | + | ||
| 56 | +func (c *myClient) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) { | ||
| 57 | + switch method { | ||
| 58 | + case "_com.example/todo/add": | ||
| 59 | + var args struct { | ||
| 60 | + Item string `json:"item"` | ||
| 61 | + Meta struct { | ||
| 62 | + SessionID string `json:"org.mini-me/sessionId"` | ||
| 63 | + } `json:"_meta"` | ||
| 64 | + } | ||
| 65 | + if err := json.Unmarshal(params, &args); err != nil { | ||
| 66 | + return nil, sdk.NewInvalidParams(map[string]any{"error": err.Error()}) | ||
| 67 | + } | ||
| 68 | + c.todos = append(c.todos, args.Item) | ||
| 69 | + return textResult(fmt.Sprintf("added %q", args.Item), false), nil | ||
| 70 | + } | ||
| 71 | + return nil, sdk.NewMethodNotFound(method) | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +// textResult is the answer shape mm reads first. | ||
| 75 | +func textResult(text string, isError bool) map[string]any { | ||
| 76 | + return map[string]any{ | ||
| 77 | + "content": []map[string]any{{"type": "text", "text": text}}, | ||
| 78 | + "isError": isError, | ||
| 79 | + } | ||
| 80 | +} | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +- Les arguments du modèle sont au premier niveau de `params` ; `_meta["org.mini-me/sessionId"]` indique quelle session a appelé. | ||
| 84 | +- Répondez à un échec par `isError: true` et un texte dont le modèle peut se servir, plutôt que par une erreur JSON-RPC : les deux arrivent au modèle, mais le texte, c'est vous qui l'écrivez. | ||
| 85 | +- Gardez le handler sûr en accès concurrent : le SDK traite chaque requête dans sa propre goroutine. | ||
| 86 | + | ||
| 87 | +### 4. Vérifier que mm les voit | ||
| 88 | + | ||
| 89 | +Lancez mm et lisez son stderr. Après `initialize`, il affiche : | ||
| 90 | + | ||
| 91 | +``` | ||
| 92 | +[acp] client tools (meta): client_add_todo | ||
| 93 | +``` | ||
| 94 | + | ||
| 95 | +Une entrée qu'il n'a pas pu utiliser est signalée sur sa propre ligne (`[acp] client tools: entry 0 skipped: …`), avec la raison. | ||
| 96 | + | ||
| 97 | +## Variantes | ||
| 98 | + | ||
| 99 | +- **La liste dépend de la session** (son `cwd`, un utilisateur) : mettez `discovery: request` dans le fichier d'agent, annoncez seulement `"org.mini-me/tools": true`, et répondez à `_org.mini-me/tools/list` dans `HandleExtensionMethod` avec `map[string]any{"tools": tools}`. mm l'envoie pendant `session/new`, `session/load` et `session/resume`, avant d'y répondre ; le SDK vous permet de répondre pendant que votre appel à `NewSession` attend. | ||
| 100 | + | ||
| 101 | + ```yaml | ||
| 102 | + acp: | ||
| 103 | + clientTools: | ||
| 104 | + discovery: request | ||
| 105 | + ``` | ||
| 106 | + | ||
| 107 | +- **Chaque appel doit être confirmé** : mettez `acp.clientTools.permissions: ask`. mm envoie alors `session/request_permission` avant chaque appel, avec le titre `client: <name>` ; répondez-y dans votre méthode `RequestPermission`. Avec la valeur par défaut `auto`, cela n'arrive que si le fichier d'agent dit `yolo: false`. | ||
| 108 | +- **Outils lents** : augmentez `acp.clientTools.timeout` (`30s` par défaut). Au-delà, le modèle lit une erreur et le tour continue. | ||
| 109 | +- **Avec le paquet `minime`** de `demo.acp.sdk/go` : les étapes 1 à 3 se résument à `minime.Options{Tools: []minime.Tool{…}}`, chaque `Tool` ayant une fonction `Handle` ; `ToolDiscovery: minime.DiscoveryRequest` choisit le second mode. `go run ./examples/clienttools` lance un exemple complet. | ||
| 110 | + | ||
| 111 | +## Voir aussi | ||
| 112 | + | ||
| 113 | +- [Spécification des outils du client](../reference/acp-client-tools.md) — le JSON exact, la validation d'une liste, les erreurs | ||
| 114 | +- [Fichier de configuration — clés de `acp`](../reference/configuration.md#clés-de-acp) | ||
| 115 | +- [Piloter mini-me depuis un programme](drive-mini-me-from-a-program.md) | ||
added
docs/fr/reference/acp-client-tools.md +205 -0 | new file mode 100644 | ||
| @@ -0,0 +1,205 @@ | ||
| 1 | +# Référence : outils du client ACP (spécification) | |
| 2 | + | |
| 3 | +> Le protocole qu'un client ACP suit pour proposer ses propres outils à mini-me. En mode ACP (`mm -acp`), mm découvre les outils qu'un client expose sous forme de méthodes d'extension, et le modèle les appelle comme ses outils intégrés. Cette page est la spécification qu'un client implémente ; les étapes en Go sont dans [Proposer des outils depuis votre client ACP](../how-to/offer-tools-from-an-acp-client.md). | |
| 4 | + | |
| 5 | +## Périmètre | |
| 6 | + | |
| 7 | +| Élément | Valeur | | |
| 8 | +|---------|--------| | |
| 9 | +| Front end | Mode ACP seulement (`mm -acp`). Le terminal, la fenêtre (`-tui`) et `-p` n'ont pas de client : aucun outil client n'y existe. | | |
| 10 | +| Configuration | [`acp.clientTools`](configuration.md#clés-de-acp) dans le fichier d'agent. | | |
| 11 | +| Espace de noms | `org.mini-me` | | |
| 12 | +| Mécanisme ACP | [Extensibility](https://agentclientprotocol.com/protocol/extensibility) : méthodes dont le nom commence par `_`, capacités dans `_meta`. ACP ne définit aucun moyen de décrire les paramètres d'une méthode ; cette page définit celui de mm. | | |
| 13 | +| Qui appelle qui | mm (l'agent) envoie les requêtes ; le client y répond. | | |
| 14 | + | |
| 15 | +## Identifiants | |
| 16 | + | |
| 17 | +| Identifiant | Nature | Où | | |
| 18 | +|-------------|--------|----| | |
| 19 | +| `org.mini-me/tools` | clé de `_meta` | `clientCapabilities._meta` de la requête `initialize`. | | |
| 20 | +| `_org.mini-me/tools/list` | méthode de requête, agent → client | Découverte `request` seulement. | | |
| 21 | +| `org.mini-me/sessionId` | clé de `_meta` | `params._meta` de chaque appel d'outil. | | |
| 22 | +| `client_<name>` | nom d'outil | Le nom que voit le modèle pour l'outil `<name>`. | | |
| 23 | + | |
| 24 | +## Description d'un outil | |
| 25 | + | |
| 26 | +Un objet JSON par outil. Le même objet sert dans les deux modes de découverte. | |
| 27 | + | |
| 28 | +| Champ | Type | Obligatoire | Description | | |
| 29 | +|-------|------|-------------|-------------| | |
| 30 | +| `name` | chaîne | oui | Le nom de l'outil, unique dans la liste. Le modèle voit `client_<name>`, où chaque caractère hors de `[A-Za-z0-9]` devient `_`, les suites de `_` sont réduites à un seul, et les `_` en début et en fin sont retirés (`open buffers` → `client_open_buffers`). | | |
| 31 | +| `description` | chaîne | non | Ce que le modèle lit pour décider quand appeler l'outil. Absente : `Tool provided by the editor (<method>).` | | |
| 32 | +| `method` | chaîne | oui | La méthode d'extension que mm appelle pour cet outil. Doit commencer par `_`. Forme conseillée : `_<dns-inversé>/<chemin>`, par exemple `_com.example/workspace/buffers`. | | |
| 33 | +| `inputSchema` | objet | non | Schéma JSON des arguments. S'il est présent, `type` doit valoir `"object"` (ou être absent). Absent : l'outil n'a pas de paramètre. | | |
| 34 | + | |
| 35 | +### Le schéma tel que le voit le modèle | |
| 36 | + | |
| 37 | +Le schéma est transmis au modèle avec ces modifications (les mêmes que pour les outils MCP) : | |
| 38 | + | |
| 39 | +| Dans `inputSchema` | Déclaré au modèle | | |
| 40 | +|--------------------|-------------------| | |
| 41 | +| `required` | Retiré du schéma ; les noms sont ajoutés à la description sous la forme `Required parameters: a, b`. mm les vérifie avant l'appel. | | |
| 42 | +| `enum` | Retiré ; les valeurs sont ajoutées à la description de la propriété sous la forme `One of: x, y.` | | |
| 43 | +| `additionalProperties: false`, `$schema` | Retirés. | | |
| 44 | +| `type`, `properties` | Toujours présents : `type: "object"`, `properties: {}` s'ils manquent. | | |
| 45 | + | |
| 46 | +### Validation d'une liste | |
| 47 | + | |
| 48 | +Chaque entrée est validée séparément. Une entrée invalide est ignorée et une ligne est écrite sur le stderr de mm (`[acp] client tools: entry <i> skipped: <raison>`) ; les autres entrées sont gardées, et la session n'échoue jamais à cause de la liste. | |
| 49 | + | |
| 50 | +| Raison | Cause | | |
| 51 | +|--------|-------| | |
| 52 | +| `not a tool description: …` | L'entrée n'est pas un objet JSON de la forme ci-dessus. | | |
| 53 | +| `no name` | `name` est absent ou vide. | | |
| 54 | +| `method "…" must start with "_" (an ACP extension method)` | `method` est absente ou n'est pas une méthode d'extension. | | |
| 55 | +| `inputSchema.type must be "object", got …` | Le schéma décrit autre chose qu'un objet. | | |
| 56 | +| `duplicate tool name "client_…"` | Deux entrées donnent le même nom côté modèle ; la première est gardée. | | |
| 57 | + | |
| 58 | +Les outils sont triés par nom côté modèle. | |
| 59 | + | |
| 60 | +## Découverte | |
| 61 | + | |
| 62 | +Le mode est fixé par `acp.clientTools.discovery` dans le fichier d'agent. Le client doit utiliser le mode que nomme le fichier d'agent. | |
| 63 | + | |
| 64 | +### Mode `meta` (par défaut) | |
| 65 | + | |
| 66 | +Le client met la liste dans sa requête `initialize`. La liste vaut pour toutes les sessions de la connexion. | |
| 67 | + | |
| 68 | +```json | |
| 69 | +{"jsonrpc":"2.0","id":0,"method":"initialize","params":{ | |
| 70 | + "protocolVersion":1, | |
| 71 | + "clientInfo":{"name":"my-client","version":"1.0.0"}, | |
| 72 | + "clientCapabilities":{ | |
| 73 | + "_meta":{ | |
| 74 | + "org.mini-me/tools":[ | |
| 75 | + {"name":"open_buffers", | |
| 76 | + "description":"List the files open in the editor.", | |
| 77 | + "method":"_com.example/workspace/buffers", | |
| 78 | + "inputSchema":{"type":"object", | |
| 79 | + "properties":{"language":{"type":"string","description":"Only the buffers of this language."}}, | |
| 80 | + "required":["language"]}} | |
| 81 | + ] | |
| 82 | + } | |
| 83 | + } | |
| 84 | +}} | |
| 85 | +``` | |
| 86 | + | |
| 87 | +En mode `meta`, la valeur `true` à la place d'une liste ne donne aucun outil, et la ligne stderr `"org.mini-me/tools" is not a list of tools; a client that answers _org.mini-me/tools/list needs acp.clientTools.discovery: request`. | |
| 88 | + | |
| 89 | +### Mode `request` | |
| 90 | + | |
| 91 | +Le client annonce seulement la capacité : | |
| 92 | + | |
| 93 | +```json | |
| 94 | +"clientCapabilities":{"_meta":{"org.mini-me/tools":true}} | |
| 95 | +``` | |
| 96 | + | |
| 97 | +mm envoie alors `_org.mini-me/tools/list` pendant qu'il traite `session/new`, `session/load` et `session/resume`, **avant** d'y répondre. Le client doit donc répondre à cette requête pendant qu'il attend sa propre réponse à `session/new` (les SDK ACP traitent chaque requête entrante en parallèle). | |
| 98 | + | |
| 99 | +Requête : | |
| 100 | + | |
| 101 | +```json | |
| 102 | +{"jsonrpc":"2.0","id":7,"method":"_org.mini-me/tools/list", | |
| 103 | + "params":{"sessionId":"20261110-224507-1f3a9c2e","cwd":"/home/me/project"}} | |
| 104 | +``` | |
| 105 | + | |
| 106 | +| Paramètre | Type | Description | | |
| 107 | +|-----------|------|-------------| | |
| 108 | +| `sessionId` | chaîne | L'id de la session en cours d'ouverture ; la réponse à `session/new` qui le porte n'est pas encore envoyée. | | |
| 109 | +| `cwd` | chaîne | Le répertoire de travail de la session, tel que le client l'a envoyé. | | |
| 110 | + | |
| 111 | +Réponse : | |
| 112 | + | |
| 113 | +```json | |
| 114 | +{"jsonrpc":"2.0","id":7,"result":{"tools":[ | |
| 115 | + {"name":"open_buffers","method":"_com.example/workspace/buffers"} | |
| 116 | +]}} | |
| 117 | +``` | |
| 118 | + | |
| 119 | +| Résultat | Effet | | |
| 120 | +|----------|-------| | |
| 121 | +| Un tableau `tools` | Ces outils, validés comme ci-dessus, pour cette session seulement. | | |
| 122 | +| Une erreur, pas de réponse dans le délai `acp.clientTools.timeout`, ou pas de tableau `tools` | Aucun outil client pour cette session ; une ligne sur stderr ; la session s'ouvre normalement. | | |
| 123 | + | |
| 124 | +Chaque session redemande la liste : deux sessions d'une même connexion peuvent avoir des outils différents. | |
| 125 | + | |
| 126 | +## Appel d'un outil | |
| 127 | + | |
| 128 | +Quand le modèle appelle `client_<name>`, mm envoie une requête à la `method` de l'outil. | |
| 129 | + | |
| 130 | +### Avant la requête | |
| 131 | + | |
| 132 | +| Vérification | Résultat en cas d'échec | | |
| 133 | +|--------------|-------------------------| | |
| 134 | +| Chaque paramètre de `required` est présent dans les arguments du modèle. | Aucune requête n'est envoyée ; le modèle lit `[client tool error: missing required parameters: a, b]`. | | |
| 135 | +| Permission : avec `acp.clientTools.permissions: ask`, ou avec `auto` quand `yolo` vaut `false`, mm envoie d'abord `session/request_permission` au client (voir [Requêtes `session/request_permission` envoyées](acp.md#requêtes-sessionrequest_permission-envoyées)). Le titre de l'appel d'outil est `client: <name>`, son type `other`. | Le modèle lit `[client_<name> rejected by user]`. | | |
| 136 | + | |
| 137 | +### Requête | |
| 138 | + | |
| 139 | +```json | |
| 140 | +{"jsonrpc":"2.0","id":12,"method":"_com.example/workspace/buffers", | |
| 141 | + "params":{"language":"go", | |
| 142 | + "_meta":{"org.mini-me/sessionId":"20261110-224507-1f3a9c2e"}}} | |
| 143 | +``` | |
| 144 | + | |
| 145 | +| Paramètre | Description | | |
| 146 | +|-----------|-------------| | |
| 147 | +| les arguments du modèle | Copiés tels que le modèle les a envoyés, au premier niveau de `params`. | | |
| 148 | +| `_meta["org.mini-me/sessionId"]` | La session à laquelle appartient l'appel. Un `_meta` que le modèle aurait mis dans ses arguments est remplacé. | | |
| 149 | + | |
| 150 | +### Réponse | |
| 151 | + | |
| 152 | +Deux formes sont acceptées. | |
| 153 | + | |
| 154 | +Liste de contenus (la forme du résultat de `tools/call` dans MCP) : | |
| 155 | + | |
| 156 | +```json | |
| 157 | +{"jsonrpc":"2.0","id":12,"result":{ | |
| 158 | + "content":[{"type":"text","text":"main.go\nutil.go"}], | |
| 159 | + "isError":false}} | |
| 160 | +``` | |
| 161 | + | |
| 162 | +| Champ | Type | Effet | | |
| 163 | +|-------|------|-------| | |
| 164 | +| `content` | tableau | Le `text` de chaque bloc de type `text`, concaténés sans séparateur, est ce que lit le modèle. Un bloc d'un autre type est remplacé par `[client tool: non-text content of type <type> skipped]`. | | |
| 165 | +| `isError` | booléen, optionnel | `true` : l'appel est un appel en échec ; le modèle lit `[client tool error: <le texte>]`. | | |
| 166 | + | |
| 167 | +Toute autre valeur JSON : une chaîne JSON est donnée au modèle sans les guillemets ; toute autre valeur (objet, nombre, tableau sans `content`) est donnée sous forme de texte JSON. | |
| 168 | + | |
| 169 | +### Erreurs | |
| 170 | + | |
| 171 | +| Situation | Ce que lit le modèle | Tour | | |
| 172 | +|-----------|----------------------|------| | |
| 173 | +| Erreur JSON-RPC (par exemple `-32601` Method not found) | `[client tool error: {"code":-32601,"message":"Method not found"}]` | Continue. | | |
| 174 | +| Pas de réponse dans le délai `acp.clientTools.timeout` | `[client tool error: …]`, avec l'erreur du SDK pour le délai dépassé. mm envoie `$/cancel_request` pour la requête. | Continue. | | |
| 175 | +| Le tour est annulé (`session/cancel`) | mm envoie `$/cancel_request` pour la requête. | Se termine comme annulé. | | |
| 176 | +| `isError: true` | `[client tool error: <texte>]` | Continue. | | |
| 177 | + | |
| 178 | +Chaque résultat, en échec ou non, est coupé à `maxOutput` caractères (début et fin conservés), signalé à l'éditeur comme fin de l'appel d'outil (`session/update`, `tool_call_update`), et compté par le détecteur de boucles comme tout appel d'outil : le même appel répété avec la même réponse est signalé, puis arrête le tour. | |
| 179 | + | |
| 180 | +## Configuration | |
| 181 | + | |
| 182 | +| Clé | Défaut | Effet sur ce protocole | | |
| 183 | +|-----|--------|------------------------| | |
| 184 | +| `acp.clientTools.enabled` | `true` | `false` : `org.mini-me/tools` est ignoré ; pas de requête de liste, pas d'outil client. | | |
| 185 | +| `acp.clientTools.discovery` | `meta` | `meta` ou `request`, comme décrit plus haut. | | |
| 186 | +| `acp.clientTools.permissions` | `auto` | `auto` : un appel ne demande la permission au client que si `yolo` vaut `false`. `ask` : chaque appel la demande. | | |
| 187 | +| `acp.clientTools.timeout` | `30s` | Limite d'un appel d'outil, et de la requête de liste. | | |
| 188 | + | |
| 189 | +## Lignes de journal (stderr de mm) | |
| 190 | + | |
| 191 | +| Ligne | Quand | | |
| 192 | +|-------|-------| | |
| 193 | +| `[acp] client tools (meta): client_a, client_b` | Après `initialize`, en mode `meta`, quand le client a annoncé une liste. | | |
| 194 | +| `[acp] session <id> client tools (request): client_a` | Après une réponse à la requête de liste. | | |
| 195 | +| `[acp] client tools: …` | Une entrée ignorée, une requête de liste en échec, un `true` seul en mode `meta`. | | |
| 196 | + | |
| 197 | +## Implémentations | |
| 198 | + | |
| 199 | +- [`demo.acp.sdk/go`](../../../demo.acp.sdk/go/README.fr.md) : `minime.Options.Tools` et `ToolDiscovery` implémentent en Go le côté client de cette page ; `examples/clienttools` est une liste de tâches proposée sous forme de deux outils. | |
| 200 | + | |
| 201 | +## Voir aussi | |
| 202 | + | |
| 203 | +- [Proposer des outils depuis votre client ACP](../how-to/offer-tools-from-an-acp-client.md) | |
| 204 | +- [Surface Agent Client Protocol](acp.md) | |
| 205 | +- [Fichier de configuration — clés de `acp`](configuration.md#clés-de-acp) | |
| new file mode 100644 | |||
| @@ -0,0 +1,205 @@ | |||
| 1 | +# Référence : outils du client ACP (spécification) | ||
| 2 | + | ||
| 3 | +> Le protocole qu'un client ACP suit pour proposer ses propres outils à mini-me. En mode ACP (`mm -acp`), mm découvre les outils qu'un client expose sous forme de méthodes d'extension, et le modèle les appelle comme ses outils intégrés. Cette page est la spécification qu'un client implémente ; les étapes en Go sont dans [Proposer des outils depuis votre client ACP](../how-to/offer-tools-from-an-acp-client.md). | ||
| 4 | + | ||
| 5 | +## Périmètre | ||
| 6 | + | ||
| 7 | +| Élément | Valeur | | ||
| 8 | +|---------|--------| | ||
| 9 | +| Front end | Mode ACP seulement (`mm -acp`). Le terminal, la fenêtre (`-tui`) et `-p` n'ont pas de client : aucun outil client n'y existe. | | ||
| 10 | +| Configuration | [`acp.clientTools`](configuration.md#clés-de-acp) dans le fichier d'agent. | | ||
| 11 | +| Espace de noms | `org.mini-me` | | ||
| 12 | +| Mécanisme ACP | [Extensibility](https://agentclientprotocol.com/protocol/extensibility) : méthodes dont le nom commence par `_`, capacités dans `_meta`. ACP ne définit aucun moyen de décrire les paramètres d'une méthode ; cette page définit celui de mm. | | ||
| 13 | +| Qui appelle qui | mm (l'agent) envoie les requêtes ; le client y répond. | | ||
| 14 | + | ||
| 15 | +## Identifiants | ||
| 16 | + | ||
| 17 | +| Identifiant | Nature | Où | | ||
| 18 | +|-------------|--------|----| | ||
| 19 | +| `org.mini-me/tools` | clé de `_meta` | `clientCapabilities._meta` de la requête `initialize`. | | ||
| 20 | +| `_org.mini-me/tools/list` | méthode de requête, agent → client | Découverte `request` seulement. | | ||
| 21 | +| `org.mini-me/sessionId` | clé de `_meta` | `params._meta` de chaque appel d'outil. | | ||
| 22 | +| `client_<name>` | nom d'outil | Le nom que voit le modèle pour l'outil `<name>`. | | ||
| 23 | + | ||
| 24 | +## Description d'un outil | ||
| 25 | + | ||
| 26 | +Un objet JSON par outil. Le même objet sert dans les deux modes de découverte. | ||
| 27 | + | ||
| 28 | +| Champ | Type | Obligatoire | Description | | ||
| 29 | +|-------|------|-------------|-------------| | ||
| 30 | +| `name` | chaîne | oui | Le nom de l'outil, unique dans la liste. Le modèle voit `client_<name>`, où chaque caractère hors de `[A-Za-z0-9]` devient `_`, les suites de `_` sont réduites à un seul, et les `_` en début et en fin sont retirés (`open buffers` → `client_open_buffers`). | | ||
| 31 | +| `description` | chaîne | non | Ce que le modèle lit pour décider quand appeler l'outil. Absente : `Tool provided by the editor (<method>).` | | ||
| 32 | +| `method` | chaîne | oui | La méthode d'extension que mm appelle pour cet outil. Doit commencer par `_`. Forme conseillée : `_<dns-inversé>/<chemin>`, par exemple `_com.example/workspace/buffers`. | | ||
| 33 | +| `inputSchema` | objet | non | Schéma JSON des arguments. S'il est présent, `type` doit valoir `"object"` (ou être absent). Absent : l'outil n'a pas de paramètre. | | ||
| 34 | + | ||
| 35 | +### Le schéma tel que le voit le modèle | ||
| 36 | + | ||
| 37 | +Le schéma est transmis au modèle avec ces modifications (les mêmes que pour les outils MCP) : | ||
| 38 | + | ||
| 39 | +| Dans `inputSchema` | Déclaré au modèle | | ||
| 40 | +|--------------------|-------------------| | ||
| 41 | +| `required` | Retiré du schéma ; les noms sont ajoutés à la description sous la forme `Required parameters: a, b`. mm les vérifie avant l'appel. | | ||
| 42 | +| `enum` | Retiré ; les valeurs sont ajoutées à la description de la propriété sous la forme `One of: x, y.` | | ||
| 43 | +| `additionalProperties: false`, `$schema` | Retirés. | | ||
| 44 | +| `type`, `properties` | Toujours présents : `type: "object"`, `properties: {}` s'ils manquent. | | ||
| 45 | + | ||
| 46 | +### Validation d'une liste | ||
| 47 | + | ||
| 48 | +Chaque entrée est validée séparément. Une entrée invalide est ignorée et une ligne est écrite sur le stderr de mm (`[acp] client tools: entry <i> skipped: <raison>`) ; les autres entrées sont gardées, et la session n'échoue jamais à cause de la liste. | ||
| 49 | + | ||
| 50 | +| Raison | Cause | | ||
| 51 | +|--------|-------| | ||
| 52 | +| `not a tool description: …` | L'entrée n'est pas un objet JSON de la forme ci-dessus. | | ||
| 53 | +| `no name` | `name` est absent ou vide. | | ||
| 54 | +| `method "…" must start with "_" (an ACP extension method)` | `method` est absente ou n'est pas une méthode d'extension. | | ||
| 55 | +| `inputSchema.type must be "object", got …` | Le schéma décrit autre chose qu'un objet. | | ||
| 56 | +| `duplicate tool name "client_…"` | Deux entrées donnent le même nom côté modèle ; la première est gardée. | | ||
| 57 | + | ||
| 58 | +Les outils sont triés par nom côté modèle. | ||
| 59 | + | ||
| 60 | +## Découverte | ||
| 61 | + | ||
| 62 | +Le mode est fixé par `acp.clientTools.discovery` dans le fichier d'agent. Le client doit utiliser le mode que nomme le fichier d'agent. | ||
| 63 | + | ||
| 64 | +### Mode `meta` (par défaut) | ||
| 65 | + | ||
| 66 | +Le client met la liste dans sa requête `initialize`. La liste vaut pour toutes les sessions de la connexion. | ||
| 67 | + | ||
| 68 | +```json | ||
| 69 | +{"jsonrpc":"2.0","id":0,"method":"initialize","params":{ | ||
| 70 | + "protocolVersion":1, | ||
| 71 | + "clientInfo":{"name":"my-client","version":"1.0.0"}, | ||
| 72 | + "clientCapabilities":{ | ||
| 73 | + "_meta":{ | ||
| 74 | + "org.mini-me/tools":[ | ||
| 75 | + {"name":"open_buffers", | ||
| 76 | + "description":"List the files open in the editor.", | ||
| 77 | + "method":"_com.example/workspace/buffers", | ||
| 78 | + "inputSchema":{"type":"object", | ||
| 79 | + "properties":{"language":{"type":"string","description":"Only the buffers of this language."}}, | ||
| 80 | + "required":["language"]}} | ||
| 81 | + ] | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | +}} | ||
| 85 | +``` | ||
| 86 | + | ||
| 87 | +En mode `meta`, la valeur `true` à la place d'une liste ne donne aucun outil, et la ligne stderr `"org.mini-me/tools" is not a list of tools; a client that answers _org.mini-me/tools/list needs acp.clientTools.discovery: request`. | ||
| 88 | + | ||
| 89 | +### Mode `request` | ||
| 90 | + | ||
| 91 | +Le client annonce seulement la capacité : | ||
| 92 | + | ||
| 93 | +```json | ||
| 94 | +"clientCapabilities":{"_meta":{"org.mini-me/tools":true}} | ||
| 95 | +``` | ||
| 96 | + | ||
| 97 | +mm envoie alors `_org.mini-me/tools/list` pendant qu'il traite `session/new`, `session/load` et `session/resume`, **avant** d'y répondre. Le client doit donc répondre à cette requête pendant qu'il attend sa propre réponse à `session/new` (les SDK ACP traitent chaque requête entrante en parallèle). | ||
| 98 | + | ||
| 99 | +Requête : | ||
| 100 | + | ||
| 101 | +```json | ||
| 102 | +{"jsonrpc":"2.0","id":7,"method":"_org.mini-me/tools/list", | ||
| 103 | + "params":{"sessionId":"20261110-224507-1f3a9c2e","cwd":"/home/me/project"}} | ||
| 104 | +``` | ||
| 105 | + | ||
| 106 | +| Paramètre | Type | Description | | ||
| 107 | +|-----------|------|-------------| | ||
| 108 | +| `sessionId` | chaîne | L'id de la session en cours d'ouverture ; la réponse à `session/new` qui le porte n'est pas encore envoyée. | | ||
| 109 | +| `cwd` | chaîne | Le répertoire de travail de la session, tel que le client l'a envoyé. | | ||
| 110 | + | ||
| 111 | +Réponse : | ||
| 112 | + | ||
| 113 | +```json | ||
| 114 | +{"jsonrpc":"2.0","id":7,"result":{"tools":[ | ||
| 115 | + {"name":"open_buffers","method":"_com.example/workspace/buffers"} | ||
| 116 | +]}} | ||
| 117 | +``` | ||
| 118 | + | ||
| 119 | +| Résultat | Effet | | ||
| 120 | +|----------|-------| | ||
| 121 | +| Un tableau `tools` | Ces outils, validés comme ci-dessus, pour cette session seulement. | | ||
| 122 | +| Une erreur, pas de réponse dans le délai `acp.clientTools.timeout`, ou pas de tableau `tools` | Aucun outil client pour cette session ; une ligne sur stderr ; la session s'ouvre normalement. | | ||
| 123 | + | ||
| 124 | +Chaque session redemande la liste : deux sessions d'une même connexion peuvent avoir des outils différents. | ||
| 125 | + | ||
| 126 | +## Appel d'un outil | ||
| 127 | + | ||
| 128 | +Quand le modèle appelle `client_<name>`, mm envoie une requête à la `method` de l'outil. | ||
| 129 | + | ||
| 130 | +### Avant la requête | ||
| 131 | + | ||
| 132 | +| Vérification | Résultat en cas d'échec | | ||
| 133 | +|--------------|-------------------------| | ||
| 134 | +| Chaque paramètre de `required` est présent dans les arguments du modèle. | Aucune requête n'est envoyée ; le modèle lit `[client tool error: missing required parameters: a, b]`. | | ||
| 135 | +| Permission : avec `acp.clientTools.permissions: ask`, ou avec `auto` quand `yolo` vaut `false`, mm envoie d'abord `session/request_permission` au client (voir [Requêtes `session/request_permission` envoyées](acp.md#requêtes-sessionrequest_permission-envoyées)). Le titre de l'appel d'outil est `client: <name>`, son type `other`. | Le modèle lit `[client_<name> rejected by user]`. | | ||
| 136 | + | ||
| 137 | +### Requête | ||
| 138 | + | ||
| 139 | +```json | ||
| 140 | +{"jsonrpc":"2.0","id":12,"method":"_com.example/workspace/buffers", | ||
| 141 | + "params":{"language":"go", | ||
| 142 | + "_meta":{"org.mini-me/sessionId":"20261110-224507-1f3a9c2e"}}} | ||
| 143 | +``` | ||
| 144 | + | ||
| 145 | +| Paramètre | Description | | ||
| 146 | +|-----------|-------------| | ||
| 147 | +| les arguments du modèle | Copiés tels que le modèle les a envoyés, au premier niveau de `params`. | | ||
| 148 | +| `_meta["org.mini-me/sessionId"]` | La session à laquelle appartient l'appel. Un `_meta` que le modèle aurait mis dans ses arguments est remplacé. | | ||
| 149 | + | ||
| 150 | +### Réponse | ||
| 151 | + | ||
| 152 | +Deux formes sont acceptées. | ||
| 153 | + | ||
| 154 | +Liste de contenus (la forme du résultat de `tools/call` dans MCP) : | ||
| 155 | + | ||
| 156 | +```json | ||
| 157 | +{"jsonrpc":"2.0","id":12,"result":{ | ||
| 158 | + "content":[{"type":"text","text":"main.go\nutil.go"}], | ||
| 159 | + "isError":false}} | ||
| 160 | +``` | ||
| 161 | + | ||
| 162 | +| Champ | Type | Effet | | ||
| 163 | +|-------|------|-------| | ||
| 164 | +| `content` | tableau | Le `text` de chaque bloc de type `text`, concaténés sans séparateur, est ce que lit le modèle. Un bloc d'un autre type est remplacé par `[client tool: non-text content of type <type> skipped]`. | | ||
| 165 | +| `isError` | booléen, optionnel | `true` : l'appel est un appel en échec ; le modèle lit `[client tool error: <le texte>]`. | | ||
| 166 | + | ||
| 167 | +Toute autre valeur JSON : une chaîne JSON est donnée au modèle sans les guillemets ; toute autre valeur (objet, nombre, tableau sans `content`) est donnée sous forme de texte JSON. | ||
| 168 | + | ||
| 169 | +### Erreurs | ||
| 170 | + | ||
| 171 | +| Situation | Ce que lit le modèle | Tour | | ||
| 172 | +|-----------|----------------------|------| | ||
| 173 | +| Erreur JSON-RPC (par exemple `-32601` Method not found) | `[client tool error: {"code":-32601,"message":"Method not found"}]` | Continue. | | ||
| 174 | +| Pas de réponse dans le délai `acp.clientTools.timeout` | `[client tool error: …]`, avec l'erreur du SDK pour le délai dépassé. mm envoie `$/cancel_request` pour la requête. | Continue. | | ||
| 175 | +| Le tour est annulé (`session/cancel`) | mm envoie `$/cancel_request` pour la requête. | Se termine comme annulé. | | ||
| 176 | +| `isError: true` | `[client tool error: <texte>]` | Continue. | | ||
| 177 | + | ||
| 178 | +Chaque résultat, en échec ou non, est coupé à `maxOutput` caractères (début et fin conservés), signalé à l'éditeur comme fin de l'appel d'outil (`session/update`, `tool_call_update`), et compté par le détecteur de boucles comme tout appel d'outil : le même appel répété avec la même réponse est signalé, puis arrête le tour. | ||
| 179 | + | ||
| 180 | +## Configuration | ||
| 181 | + | ||
| 182 | +| Clé | Défaut | Effet sur ce protocole | | ||
| 183 | +|-----|--------|------------------------| | ||
| 184 | +| `acp.clientTools.enabled` | `true` | `false` : `org.mini-me/tools` est ignoré ; pas de requête de liste, pas d'outil client. | | ||
| 185 | +| `acp.clientTools.discovery` | `meta` | `meta` ou `request`, comme décrit plus haut. | | ||
| 186 | +| `acp.clientTools.permissions` | `auto` | `auto` : un appel ne demande la permission au client que si `yolo` vaut `false`. `ask` : chaque appel la demande. | | ||
| 187 | +| `acp.clientTools.timeout` | `30s` | Limite d'un appel d'outil, et de la requête de liste. | | ||
| 188 | + | ||
| 189 | +## Lignes de journal (stderr de mm) | ||
| 190 | + | ||
| 191 | +| Ligne | Quand | | ||
| 192 | +|-------|-------| | ||
| 193 | +| `[acp] client tools (meta): client_a, client_b` | Après `initialize`, en mode `meta`, quand le client a annoncé une liste. | | ||
| 194 | +| `[acp] session <id> client tools (request): client_a` | Après une réponse à la requête de liste. | | ||
| 195 | +| `[acp] client tools: …` | Une entrée ignorée, une requête de liste en échec, un `true` seul en mode `meta`. | | ||
| 196 | + | ||
| 197 | +## Implémentations | ||
| 198 | + | ||
| 199 | +- [`demo.acp.sdk/go`](../../../demo.acp.sdk/go/README.fr.md) : `minime.Options.Tools` et `ToolDiscovery` implémentent en Go le côté client de cette page ; `examples/clienttools` est une liste de tâches proposée sous forme de deux outils. | ||
| 200 | + | ||
| 201 | +## Voir aussi | ||
| 202 | + | ||
| 203 | +- [Proposer des outils depuis votre client ACP](../how-to/offer-tools-from-an-acp-client.md) | ||
| 204 | +- [Surface Agent Client Protocol](acp.md) | ||
| 205 | +- [Fichier de configuration — clés de `acp`](configuration.md#clés-de-acp) | ||
modified
docs/fr/reference/acp.md +16 -5 | @@ -26,14 +26,14 @@ | ||
| 26 | 26 | |
| 27 | 27 | | Méthode | Comportement | |
| 28 | 28 | |---------|--------------| |
| 29 | -| `initialize` | Lit `clientCapabilities.elicitation.form` (présent et non nul = formulaires pris en charge), écrit sur stderr le nom du client, la version du protocole et `elicitation(form)=true\|false` ; renvoie les valeurs ci-dessus. | | |
| 29 | +| `initialize` | Lit `clientCapabilities.elicitation.form` (présent et non nul = formulaires pris en charge), écrit sur stderr le nom du client, la version du protocole et `elicitation(form)=true\|false` ; renvoie les valeurs ci-dessus. Lit aussi `clientCapabilities._meta["org.mini-me/tools"]`, les outils du client (voir [Outils du client](#outils-du-client)). | | |
| 30 | 30 | | `authenticate` | Renvoie une réponse vide. | |
| 31 | -| `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. | | |
| 31 | +| `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. | | |
| 32 | 32 | | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | |
| 33 | 33 | | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. | |
| 34 | -| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. | | |
| 34 | +| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | | |
| 35 | 35 | | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. | |
| 36 | -| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. | | |
| 36 | +| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | | |
| 37 | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | |
| 39 | 39 | ## Les skills comme commandes |
| @@ -151,6 +151,17 @@ Sans prise en charge des formulaires, un formulaire dont toutes les questions so | ||
| 151 | 151 | |
| 152 | 152 | Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `ask_agent` ou `mm --ask` —, sa requête `initialize` annonce `clientCapabilities.elicitation.form`, et les requêtes `elicitation/create` de l'agent interrogé sont posées à l'utilisateur de l'agent qui interroge, par sa propre interface. Une interface qui ne peut pas interroger (`mm --ask`) répond par une erreur. |
| 153 | 153 | |
| 154 | +## Outils du client | |
| 155 | + | |
| 156 | +Un client peut proposer ses propres outils sous forme de méthodes d'extension ACP (`acp.clientTools`, activé par défaut). mm envoie alors ces requêtes au client : | |
| 157 | + | |
| 158 | +| Requête | Quand | | |
| 159 | +|---------|-------| | |
| 160 | +| `_org.mini-me/tools/list` | `session/new`, `session/load`, `session/resume`, avant d'y répondre, avec `acp.clientTools.discovery: request` et un client qui a annoncé `"org.mini-me/tools": true`. | | |
| 161 | +| la `method` propre à l'outil (commence par `_`) | Chaque appel d'un outil client par le modèle. | | |
| 162 | + | |
| 163 | +Les requêtes d'extension envoyées PAR le client à mm reçoivent `method not found`. Le protocole complet est dans [Outils du client ACP](acp-client-tools.md). | |
| 164 | + | |
| 154 | 165 | ## Genres d'outils |
| 155 | 166 | |
| 156 | 167 | | Genre `mm` | `ToolKind` ACP | Outils | |
| @@ -160,7 +171,7 @@ Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `a | ||
| 160 | 171 | | `edit` | `edit` | `write_file`, `edit_file` | |
| 161 | 172 | | `fetch` | `fetch` | `fetch_content` | |
| 162 | 173 | | `search` | `search` | `search_memory`, `search_docs` | |
| 163 | -| `other` | `other` | `ask_agent`, `ask_user` | | |
| 174 | +| `other` | `other` | `ask_agent`, `ask_user`, outils client (`client_<name>`) | | |
| 164 | 175 | |
| 165 | 176 | ## Identifiants d'appel d'outil |
| 166 | 177 | |
| @@ -26,14 +26,14 @@ | |||
| 26 | 26 | ||
| 27 | | Méthode | Comportement | | 27 | | Méthode | Comportement | |
| 28 | |---------|--------------| | 28 | |---------|--------------| |
| 29 | -| `initialize` | Lit `clientCapabilities.elicitation.form` (présent et non nul = formulaires pris en charge), écrit sur stderr le nom du client, la version du protocole et `elicitation(form)=true\|false` ; renvoie les valeurs ci-dessus. | | 29 | +| `initialize` | Lit `clientCapabilities.elicitation.form` (présent et non nul = formulaires pris en charge), écrit sur stderr le nom du client, la version du protocole et `elicitation(form)=true\|false` ; renvoie les valeurs ci-dessus. Lit aussi `clientCapabilities._meta["org.mini-me/tools"]`, les outils du client (voir [Outils du client](#outils-du-client)). | |
| 30 | | `authenticate` | Renvoie une réponse vide. | | 30 | | `authenticate` | Renvoie une réponse vide. | |
| 31 | -| `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. | | 31 | +| `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. Avant de répondre, donne à la session ses outils client : la liste lue à `initialize`, ou, avec `acp.clientTools.discovery: request`, la réponse à une requête `_org.mini-me/tools/list`. | |
| 32 | | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | | 32 | | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | |
| 33 | | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. | | 33 | | `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. | |
| 34 | -| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. | | 34 | +| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | |
| 35 | | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. | | 35 | | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. | |
| 36 | -| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. | | 36 | +| `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. Les outils client de la session sont fixés comme pour `session/new`. | |
| 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | | 37 | | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. | |
| 38 | 38 | ||
| 39 | ## Les skills comme commandes | 39 | ## Les skills comme commandes |
| @@ -151,6 +151,17 @@ Sans prise en charge des formulaires, un formulaire dont toutes les questions so | |||
| 151 | 151 | ||
| 152 | Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `ask_agent` ou `mm --ask` —, sa requête `initialize` annonce `clientCapabilities.elicitation.form`, et les requêtes `elicitation/create` de l'agent interrogé sont posées à l'utilisateur de l'agent qui interroge, par sa propre interface. Une interface qui ne peut pas interroger (`mm --ask`) répond par une erreur. | 152 | Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `ask_agent` ou `mm --ask` —, sa requête `initialize` annonce `clientCapabilities.elicitation.form`, et les requêtes `elicitation/create` de l'agent interrogé sont posées à l'utilisateur de l'agent qui interroge, par sa propre interface. Une interface qui ne peut pas interroger (`mm --ask`) répond par une erreur. |
| 153 | 153 | ||
| 154 | +## Outils du client | ||
| 155 | + | ||
| 156 | +Un client peut proposer ses propres outils sous forme de méthodes d'extension ACP (`acp.clientTools`, activé par défaut). mm envoie alors ces requêtes au client : | ||
| 157 | + | ||
| 158 | +| Requête | Quand | | ||
| 159 | +|---------|-------| | ||
| 160 | +| `_org.mini-me/tools/list` | `session/new`, `session/load`, `session/resume`, avant d'y répondre, avec `acp.clientTools.discovery: request` et un client qui a annoncé `"org.mini-me/tools": true`. | | ||
| 161 | +| la `method` propre à l'outil (commence par `_`) | Chaque appel d'un outil client par le modèle. | | ||
| 162 | + | ||
| 163 | +Les requêtes d'extension envoyées PAR le client à mm reçoivent `method not found`. Le protocole complet est dans [Outils du client ACP](acp-client-tools.md). | ||
| 164 | + | ||
| 154 | ## Genres d'outils | 165 | ## Genres d'outils |
| 155 | 166 | ||
| 156 | | Genre `mm` | `ToolKind` ACP | Outils | | 167 | | Genre `mm` | `ToolKind` ACP | Outils | |
| @@ -160,7 +171,7 @@ Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `a | |||
| 160 | | `edit` | `edit` | `write_file`, `edit_file` | | 171 | | `edit` | `edit` | `write_file`, `edit_file` | |
| 161 | | `fetch` | `fetch` | `fetch_content` | | 172 | | `fetch` | `fetch` | `fetch_content` | |
| 162 | | `search` | `search` | `search_memory`, `search_docs` | | 173 | | `search` | `search` | `search_memory`, `search_docs` | |
| 163 | -| `other` | `other` | `ask_agent`, `ask_user` | | 174 | +| `other` | `other` | `ask_agent`, `ask_user`, outils client (`client_<name>`) | |
| 164 | 175 | ||
| 165 | ## Identifiants d'appel d'outil | 176 | ## Identifiants d'appel d'outil |
| 166 | 177 | ||
modified
docs/fr/reference/configuration.md +4 -0 | @@ -120,6 +120,10 @@ Non lu : un fichier absent, un fichier qui ne contient que des blancs, un fichie | ||
| 120 | 120 | |-----|------|--------|-------------| |
| 121 | 121 | | `acp.name` | chaîne | `bob` | `agentInfo.name` dans la réponse à `initialize`. Ne doit pas être vide. | |
| 122 | 122 | | `acp.title` | chaîne | `Bob (bash-first agent)` | `agentInfo.title`, le nom que l'éditeur peut afficher. Vide : non envoyé. Affiché aussi par le mode fenêtre (`-tui`) à côté de `mm`, dans son en-tête et au-dessus de chaque réponse — `‣ mm [Homer (coder)]` — sauf s'il est vide ou égal à la valeur intégrée. Pendant un `/ask`, la fenêtre montre à la place l'agent INTERROGÉ : son `acp.title` (ou son nom dans `mmConfigs`) au-dessus de sa réponse, et dans l'en-tête avec son modèle et `asked by <cet agent>`, jusqu'à ce qu'il ait répondu. | |
| 123 | +| `acp.clientTools.enabled` | booléen | `true` | Mode ACP seulement : les outils qu'un client ACP annonce (`org.mini-me/tools` dans le `_meta` de ses capacités) deviennent des outils de l'agent nommés `client_<name>`, appelés via les méthodes d'extension du client. `false` : ce que le client annonce est ignoré. Sans effet quand le client n'annonce rien. Voir [Outils du client ACP](acp-client-tools.md). | | |
| 124 | +| `acp.clientTools.discovery` | chaîne | `meta` | `meta` : le client liste ses outils dans la requête `initialize`, une liste pour la connexion. `request` : le client annonce seulement `true`, et mm demande la liste (`_org.mini-me/tools/list`) à chaque `session/new`, `session/load` et `session/resume`. Toute autre valeur est refusée : `acp.clientTools.discovery must be "meta" or "request"`. | | |
| 125 | +| `acp.clientTools.permissions` | chaîne | `auto` | `auto` : un appel d'outil client ne demande la permission au client que si `yolo` vaut `false`. `ask` : chaque appel la demande. Toute autre valeur est refusée. | | |
| 126 | +| `acp.clientTools.timeout` | durée | `30s` | Limite d'un appel d'outil client, et de la requête de liste. Doit être > 0. | | |
| 123 | 127 | |
| 124 | 128 | La version n'est pas paramétrable : `agentInfo.version` est toujours celle du binaire, telle qu'affichée par `mm --version`. |
| 125 | 129 | |
| @@ -120,6 +120,10 @@ Non lu : un fichier absent, un fichier qui ne contient que des blancs, un fichie | |||
| 120 | |-----|------|--------|-------------| | 120 | |-----|------|--------|-------------| |
| 121 | | `acp.name` | chaîne | `bob` | `agentInfo.name` dans la réponse à `initialize`. Ne doit pas être vide. | | 121 | | `acp.name` | chaîne | `bob` | `agentInfo.name` dans la réponse à `initialize`. Ne doit pas être vide. | |
| 122 | | `acp.title` | chaîne | `Bob (bash-first agent)` | `agentInfo.title`, le nom que l'éditeur peut afficher. Vide : non envoyé. Affiché aussi par le mode fenêtre (`-tui`) à côté de `mm`, dans son en-tête et au-dessus de chaque réponse — `‣ mm [Homer (coder)]` — sauf s'il est vide ou égal à la valeur intégrée. Pendant un `/ask`, la fenêtre montre à la place l'agent INTERROGÉ : son `acp.title` (ou son nom dans `mmConfigs`) au-dessus de sa réponse, et dans l'en-tête avec son modèle et `asked by <cet agent>`, jusqu'à ce qu'il ait répondu. | | 122 | | `acp.title` | chaîne | `Bob (bash-first agent)` | `agentInfo.title`, le nom que l'éditeur peut afficher. Vide : non envoyé. Affiché aussi par le mode fenêtre (`-tui`) à côté de `mm`, dans son en-tête et au-dessus de chaque réponse — `‣ mm [Homer (coder)]` — sauf s'il est vide ou égal à la valeur intégrée. Pendant un `/ask`, la fenêtre montre à la place l'agent INTERROGÉ : son `acp.title` (ou son nom dans `mmConfigs`) au-dessus de sa réponse, et dans l'en-tête avec son modèle et `asked by <cet agent>`, jusqu'à ce qu'il ait répondu. | |
| 123 | +| `acp.clientTools.enabled` | booléen | `true` | Mode ACP seulement : les outils qu'un client ACP annonce (`org.mini-me/tools` dans le `_meta` de ses capacités) deviennent des outils de l'agent nommés `client_<name>`, appelés via les méthodes d'extension du client. `false` : ce que le client annonce est ignoré. Sans effet quand le client n'annonce rien. Voir [Outils du client ACP](acp-client-tools.md). | | ||
| 124 | +| `acp.clientTools.discovery` | chaîne | `meta` | `meta` : le client liste ses outils dans la requête `initialize`, une liste pour la connexion. `request` : le client annonce seulement `true`, et mm demande la liste (`_org.mini-me/tools/list`) à chaque `session/new`, `session/load` et `session/resume`. Toute autre valeur est refusée : `acp.clientTools.discovery must be "meta" or "request"`. | | ||
| 125 | +| `acp.clientTools.permissions` | chaîne | `auto` | `auto` : un appel d'outil client ne demande la permission au client que si `yolo` vaut `false`. `ask` : chaque appel la demande. Toute autre valeur est refusée. | | ||
| 126 | +| `acp.clientTools.timeout` | durée | `30s` | Limite d'un appel d'outil client, et de la requête de liste. Doit être > 0. | | ||
| 123 | 127 | ||
| 124 | La version n'est pas paramétrable : `agentInfo.version` est toujours celle du binaire, telle qu'affichée par `mm --version`. | 128 | La version n'est pas paramétrable : `agentInfo.version` est toujours celle du binaire, telle qu'affichée par `mm --version`. |
| 125 | 129 | ||
modified
internal/acp/acp.go +25 -3 | @@ -36,6 +36,7 @@ import ( | ||
| 36 | 36 | "mm/internal/config" |
| 37 | 37 | "mm/internal/consent" |
| 38 | 38 | "mm/internal/decide" |
| 39 | + "mm/internal/detector" | |
| 39 | 40 | "mm/internal/docsearch" |
| 40 | 41 | "mm/internal/engine" |
| 41 | 42 | "mm/internal/mention" |
| @@ -68,6 +69,9 @@ type session struct { | ||
| 68 | 69 | // usage is the token accounting: totals for the session/prompt response, |
| 69 | 70 | // the last measure for the context meter (usage.go). |
| 70 | 71 | usage usage |
| 72 | + // clientTools are the tools the client exposes for this session | |
| 73 | + // (clienttools.go, ticket 202), added to the agent's own at each turn. | |
| 74 | + clientTools []ai.ToolRef | |
| 71 | 75 | } |
| 72 | 76 | |
| 73 | 77 | type front struct { |
| @@ -89,6 +93,17 @@ type front struct { | ||
| 89 | 93 | // initialize: the ask_user tool may send it whole forms (elicit.go). |
| 90 | 94 | elicitForm bool |
| 91 | 95 | |
| 96 | + // connClientTools is the list of client tools the client sent at | |
| 97 | + // initialize (discovery "meta"), shared by all its sessions; | |
| 98 | + // clientToolsAdvertised says it offered the list request (discovery | |
| 99 | + // "request"). See clienttools.go. | |
| 100 | + connClientTools []clientTool | |
| 101 | + clientToolsAdvertised bool | |
| 102 | + | |
| 103 | + // detector is the agent's loop detector, which the client tools report | |
| 104 | + // to like every other tool; nil in tests that do not need one. | |
| 105 | + detector *detector.LoopDetector | |
| 106 | + | |
| 92 | 107 | mu sync.Mutex // guards sessions |
| 93 | 108 | sessions map[sdk.SessionId]*session |
| 94 | 109 | |
| @@ -105,10 +120,13 @@ var _ sdk.Agent = (*front)(nil) | ||
| 105 | 120 | // client closes it. Same arguments as agent.Run: main.go's wiring — engine, |
| 106 | 121 | // system prompt, tool list — is identical for the two front ends. |
| 107 | 122 | // |
| 108 | -// asker runs the /ask-<agent> commands; nil when no agent is configured. | |
| 109 | -func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, asker ask.Runner) { | |
| 123 | +// d is the loop detector the built-in tools report to; the client tools | |
| 124 | +// (clienttools.go) report to it too. asker runs the /ask-<agent> commands; | |
| 125 | +// nil when no agent is configured. | |
| 126 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector, asker ask.Runner) { | |
| 110 | 127 | f := newFront(e, system, tools, os.Stdout, os.Stdin) |
| 111 | 128 | f.asker = asker |
| 129 | + f.detector = d | |
| 112 | 130 | select { |
| 113 | 131 | case <-f.conn.Done(): // the editor closed our stdin: we are done |
| 114 | 132 | case <-ctx.Done(): |
| @@ -206,6 +224,7 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init | ||
| 206 | 224 | if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil { |
| 207 | 225 | f.elicitForm = true |
| 208 | 226 | } |
| 227 | + f.readClientToolsCapability(p.ClientCapabilities.Meta) | |
| 209 | 228 | fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d elicitation(form)=%v\n", |
| 210 | 229 | clientName(p.ClientInfo), p.ProtocolVersion, f.elicitForm) |
| 211 | 230 | return sdk.InitializeResponse{ |
| @@ -264,6 +283,9 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | ||
| 264 | 283 | // The AGENTS.md of THIS session's project: each session has its own cwd. |
| 265 | 284 | messages: history.Fresh(agentsmd.System(f.system, p.Cwd)), |
| 266 | 285 | } |
| 286 | + // The client's tools, before the session is visible: in "request" mode | |
| 287 | + // the client answers the list request while it waits for this response. | |
| 288 | + s.clientTools = f.sessionClientTools(ctx, sid, p.Cwd) | |
| 267 | 289 | f.persistNew(sid, s) |
| 268 | 290 | f.mu.Lock() |
| 269 | 291 | f.sessions[sid] = s |
| @@ -448,7 +470,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 448 | 470 | } |
| 449 | 471 | } |
| 450 | 472 | |
| 451 | - resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools) | |
| 473 | + resp, full, err := f.e.Generate(turnCtx, s.messages, f.turnTools(s)) | |
| 452 | 474 | |
| 453 | 475 | // Same rule as the REPL, for the same reason: keep the FULL history even |
| 454 | 476 | // when the turn failed or was cancelled — the commands that already ran are |
| @@ -36,6 +36,7 @@ import ( | |||
| 36 | "mm/internal/config" | 36 | "mm/internal/config" |
| 37 | "mm/internal/consent" | 37 | "mm/internal/consent" |
| 38 | "mm/internal/decide" | 38 | "mm/internal/decide" |
| 39 | + "mm/internal/detector" | ||
| 39 | "mm/internal/docsearch" | 40 | "mm/internal/docsearch" |
| 40 | "mm/internal/engine" | 41 | "mm/internal/engine" |
| 41 | "mm/internal/mention" | 42 | "mm/internal/mention" |
| @@ -68,6 +69,9 @@ type session struct { | |||
| 68 | // usage is the token accounting: totals for the session/prompt response, | 69 | // usage is the token accounting: totals for the session/prompt response, |
| 69 | // the last measure for the context meter (usage.go). | 70 | // the last measure for the context meter (usage.go). |
| 70 | usage usage | 71 | usage usage |
| 72 | + // clientTools are the tools the client exposes for this session | ||
| 73 | + // (clienttools.go, ticket 202), added to the agent's own at each turn. | ||
| 74 | + clientTools []ai.ToolRef | ||
| 71 | } | 75 | } |
| 72 | 76 | ||
| 73 | type front struct { | 77 | type front struct { |
| @@ -89,6 +93,17 @@ type front struct { | |||
| 89 | // initialize: the ask_user tool may send it whole forms (elicit.go). | 93 | // initialize: the ask_user tool may send it whole forms (elicit.go). |
| 90 | elicitForm bool | 94 | elicitForm bool |
| 91 | 95 | ||
| 96 | + // connClientTools is the list of client tools the client sent at | ||
| 97 | + // initialize (discovery "meta"), shared by all its sessions; | ||
| 98 | + // clientToolsAdvertised says it offered the list request (discovery | ||
| 99 | + // "request"). See clienttools.go. | ||
| 100 | + connClientTools []clientTool | ||
| 101 | + clientToolsAdvertised bool | ||
| 102 | + | ||
| 103 | + // detector is the agent's loop detector, which the client tools report | ||
| 104 | + // to like every other tool; nil in tests that do not need one. | ||
| 105 | + detector *detector.LoopDetector | ||
| 106 | + | ||
| 92 | mu sync.Mutex // guards sessions | 107 | mu sync.Mutex // guards sessions |
| 93 | sessions map[sdk.SessionId]*session | 108 | sessions map[sdk.SessionId]*session |
| 94 | 109 | ||
| @@ -105,10 +120,13 @@ var _ sdk.Agent = (*front)(nil) | |||
| 105 | // client closes it. Same arguments as agent.Run: main.go's wiring — engine, | 120 | // client closes it. Same arguments as agent.Run: main.go's wiring — engine, |
| 106 | // system prompt, tool list — is identical for the two front ends. | 121 | // system prompt, tool list — is identical for the two front ends. |
| 107 | // | 122 | // |
| 108 | -// asker runs the /ask-<agent> commands; nil when no agent is configured. | 123 | +// d is the loop detector the built-in tools report to; the client tools |
| 109 | -func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, asker ask.Runner) { | 124 | +// (clienttools.go) report to it too. asker runs the /ask-<agent> commands; |
| 125 | +// nil when no agent is configured. | ||
| 126 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector, asker ask.Runner) { | ||
| 110 | f := newFront(e, system, tools, os.Stdout, os.Stdin) | 127 | f := newFront(e, system, tools, os.Stdout, os.Stdin) |
| 111 | f.asker = asker | 128 | f.asker = asker |
| 129 | + f.detector = d | ||
| 112 | select { | 130 | select { |
| 113 | case <-f.conn.Done(): // the editor closed our stdin: we are done | 131 | case <-f.conn.Done(): // the editor closed our stdin: we are done |
| 114 | case <-ctx.Done(): | 132 | case <-ctx.Done(): |
| @@ -206,6 +224,7 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init | |||
| 206 | if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil { | 224 | if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil { |
| 207 | f.elicitForm = true | 225 | f.elicitForm = true |
| 208 | } | 226 | } |
| 227 | + f.readClientToolsCapability(p.ClientCapabilities.Meta) | ||
| 209 | fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d elicitation(form)=%v\n", | 228 | fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d elicitation(form)=%v\n", |
| 210 | clientName(p.ClientInfo), p.ProtocolVersion, f.elicitForm) | 229 | clientName(p.ClientInfo), p.ProtocolVersion, f.elicitForm) |
| 211 | return sdk.InitializeResponse{ | 230 | return sdk.InitializeResponse{ |
| @@ -264,6 +283,9 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne | |||
| 264 | // The AGENTS.md of THIS session's project: each session has its own cwd. | 283 | // The AGENTS.md of THIS session's project: each session has its own cwd. |
| 265 | messages: history.Fresh(agentsmd.System(f.system, p.Cwd)), | 284 | messages: history.Fresh(agentsmd.System(f.system, p.Cwd)), |
| 266 | } | 285 | } |
| 286 | + // The client's tools, before the session is visible: in "request" mode | ||
| 287 | + // the client answers the list request while it waits for this response. | ||
| 288 | + s.clientTools = f.sessionClientTools(ctx, sid, p.Cwd) | ||
| 267 | f.persistNew(sid, s) | 289 | f.persistNew(sid, s) |
| 268 | f.mu.Lock() | 290 | f.mu.Lock() |
| 269 | f.sessions[sid] = s | 291 | f.sessions[sid] = s |
| @@ -448,7 +470,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | |||
| 448 | } | 470 | } |
| 449 | } | 471 | } |
| 450 | 472 | ||
| 451 | - resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools) | 473 | + resp, full, err := f.e.Generate(turnCtx, s.messages, f.turnTools(s)) |
| 452 | 474 | ||
| 453 | // Same rule as the REPL, for the same reason: keep the FULL history even | 475 | // Same rule as the REPL, for the same reason: keep the FULL history even |
| 454 | // when the turn failed or was cancelled — the commands that already ran are | 476 | // when the turn failed or was cancelled — the commands that already ran are |
added
internal/acp/clienttools.go +408 -0 | new file mode 100644 | ||
| @@ -0,0 +1,408 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +// Client tools (ticket 202): the tools an ACP client exposes as custom | |
| 4 | +// extension methods become agent tools, discovered at run time. | |
| 5 | +// | |
| 6 | +// ACP lets either side define methods whose name starts with "_" and lets a | |
| 7 | +// client advertise them in the `_meta` of its capabilities, but it defines no | |
| 8 | +// way to describe their parameters. mm's convention, under the org.mini-me | |
| 9 | +// namespace, fills that gap (docs/en/reference/acp-client-tools.md): | |
| 10 | +// | |
| 11 | +// - discovery "meta" (default): the client lists its tools at initialize, | |
| 12 | +// in clientCapabilities._meta["org.mini-me/tools"]; one list for every | |
| 13 | +// session of the connection. | |
| 14 | +// - discovery "request": the client only advertises | |
| 15 | +// "org.mini-me/tools": true, and mm asks for the list with the | |
| 16 | +// `_org.mini-me/tools/list` request when a session is created, loaded or | |
| 17 | +// resumed; one list per session. | |
| 18 | +// - a call is a request to the tool's own `method`, whose params are the | |
| 19 | +// model's arguments plus `_meta: {"org.mini-me/sessionId": "<id>"}`. | |
| 20 | +// | |
| 21 | +// The tools only exist in ACP mode: the REPL and the window have no client. | |
| 22 | + | |
| 23 | +import ( | |
| 24 | + "context" | |
| 25 | + "encoding/json" | |
| 26 | + "errors" | |
| 27 | + "fmt" | |
| 28 | + "os" | |
| 29 | + "sort" | |
| 30 | + "strings" | |
| 31 | + | |
| 32 | + "mm/internal/config" | |
| 33 | + "mm/internal/consent" | |
| 34 | + "mm/internal/detector" | |
| 35 | + "mm/internal/mcp" | |
| 36 | + "mm/internal/ui" | |
| 37 | + | |
| 38 | + sdk "github.com/coder/acp-go-sdk" | |
| 39 | + "github.com/firebase/genkit/go/ai" | |
| 40 | +) | |
| 41 | + | |
| 42 | +const ( | |
| 43 | + // clientToolsMetaKey is the key of clientCapabilities._meta that carries | |
| 44 | + // the list of tools (discovery "meta") or `true` (discovery "request"). | |
| 45 | + clientToolsMetaKey = "org.mini-me/tools" | |
| 46 | + // clientToolsListMethod is the request mm sends for the list when the | |
| 47 | + // discovery is "request". | |
| 48 | + clientToolsListMethod = "_org.mini-me/tools/list" | |
| 49 | + // sessionIDMetaKey is the key of a call's params._meta that names the | |
| 50 | + // session the call belongs to. | |
| 51 | + sessionIDMetaKey = "org.mini-me/sessionId" | |
| 52 | + // clientToolPrefix is the prefix of the name the model sees: a client | |
| 53 | + // tool cannot shadow a built-in tool or an MCP tool. | |
| 54 | + clientToolPrefix = "client" | |
| 55 | +) | |
| 56 | + | |
| 57 | +// clientToolSpec is one entry of the list a client sends: the description of | |
| 58 | +// one of its extension methods, as a tool. | |
| 59 | +type clientToolSpec struct { | |
| 60 | + // Name is the client's name for the tool; the model sees "client_<Name>". | |
| 61 | + Name string `json:"name"` | |
| 62 | + // Description is what the model reads to decide when to call the tool. | |
| 63 | + Description string `json:"description"` | |
| 64 | + // Method is the extension method mm calls; it must start with "_". | |
| 65 | + Method string `json:"method"` | |
| 66 | + // InputSchema is the JSON schema of the arguments; optional. | |
| 67 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | |
| 68 | +} | |
| 69 | + | |
| 70 | +// clientTool is a validated spec and the name the model sees. | |
| 71 | +type clientTool struct { | |
| 72 | + spec clientToolSpec | |
| 73 | + modelName string | |
| 74 | +} | |
| 75 | + | |
| 76 | +// clientToolsList is the result of the `_org.mini-me/tools/list` request. | |
| 77 | +type clientToolsList struct { | |
| 78 | + Tools json.RawMessage `json:"tools"` | |
| 79 | +} | |
| 80 | + | |
| 81 | +// parseClientTools validates a list of specs, given as the JSON value the | |
| 82 | +// client sent (an array). An invalid entry is skipped with a warning, never | |
| 83 | +// fatal: a client's mistake must not cost the user the session. The result | |
| 84 | +// is sorted by model name, so the model sees the same order every time. | |
| 85 | +// | |
| 86 | +// tools, warnings := parseClientTools(json.RawMessage(`[{"name":"buffers","method":"_com.example/buffers"}]`)) | |
| 87 | +// // tools[0].modelName == "client_buffers", warnings == nil | |
| 88 | +func parseClientTools(raw json.RawMessage) ([]clientTool, []string) { | |
| 89 | + var entries []json.RawMessage | |
| 90 | + if err := json.Unmarshal(raw, &entries); err != nil { | |
| 91 | + return nil, []string{fmt.Sprintf("client tools: the list is not a JSON array: %v", err)} | |
| 92 | + } | |
| 93 | + var tools []clientTool | |
| 94 | + var warnings []string | |
| 95 | + seen := map[string]bool{} | |
| 96 | + for i, entry := range entries { | |
| 97 | + tool, err := parseClientTool(entry) | |
| 98 | + if err == nil && seen[tool.modelName] { | |
| 99 | + err = fmt.Errorf("duplicate tool name %q", tool.modelName) | |
| 100 | + } | |
| 101 | + if err != nil { | |
| 102 | + warnings = append(warnings, fmt.Sprintf("client tools: entry %d skipped: %v", i, err)) | |
| 103 | + continue | |
| 104 | + } | |
| 105 | + seen[tool.modelName] = true | |
| 106 | + tools = append(tools, tool) | |
| 107 | + } | |
| 108 | + sort.Slice(tools, func(i, j int) bool { return tools[i].modelName < tools[j].modelName }) | |
| 109 | + return tools, warnings | |
| 110 | +} | |
| 111 | + | |
| 112 | +// parseClientTool validates one entry of the list. | |
| 113 | +func parseClientTool(entry json.RawMessage) (clientTool, error) { | |
| 114 | + var spec clientToolSpec | |
| 115 | + if err := json.Unmarshal(entry, &spec); err != nil { | |
| 116 | + return clientTool{}, fmt.Errorf("not a tool description: %v", err) | |
| 117 | + } | |
| 118 | + if strings.TrimSpace(spec.Name) == "" { | |
| 119 | + return clientTool{}, errors.New("no name") | |
| 120 | + } | |
| 121 | + if !strings.HasPrefix(spec.Method, "_") { | |
| 122 | + return clientTool{}, fmt.Errorf("tool %q: method %q must start with \"_\" (an ACP extension method)", spec.Name, spec.Method) | |
| 123 | + } | |
| 124 | + if t, ok := spec.InputSchema["type"]; ok && t != "object" { | |
| 125 | + return clientTool{}, fmt.Errorf("tool %q: inputSchema.type must be \"object\", got %v", spec.Name, t) | |
| 126 | + } | |
| 127 | + modelName, err := mcp.SanitizeToolName(clientToolPrefix, spec.Name) | |
| 128 | + if err != nil { | |
| 129 | + return clientTool{}, err | |
| 130 | + } | |
| 131 | + return clientTool{spec: spec, modelName: modelName}, nil | |
| 132 | +} | |
| 133 | + | |
| 134 | +// --- discovery ----------------------------------------------------------------- | |
| 135 | + | |
| 136 | +// readClientToolsCapability reads what the client advertised at initialize, | |
| 137 | +// according to the configured discovery. In "meta" mode the list is parsed | |
| 138 | +// once, for every session of the connection; in "request" mode only the | |
| 139 | +// advertisement is kept, and each session asks for its own list. | |
| 140 | +func (f *front) readClientToolsCapability(meta map[string]any) { | |
| 141 | + cfg := config.Cfg.ACP.ClientTools | |
| 142 | + value, advertised := meta[clientToolsMetaKey] | |
| 143 | + if !cfg.Enabled || !advertised { | |
| 144 | + return | |
| 145 | + } | |
| 146 | + if cfg.Discovery == config.DiscoveryRequest { | |
| 147 | + f.clientToolsAdvertised = value != false && value != nil | |
| 148 | + return | |
| 149 | + } | |
| 150 | + raw, err := json.Marshal(value) | |
| 151 | + if err != nil || value == true { | |
| 152 | + warnClientTools([]string{fmt.Sprintf( | |
| 153 | + "client tools: %q is not a list of tools; a client that answers %s needs acp.clientTools.discovery: %s", | |
| 154 | + clientToolsMetaKey, clientToolsListMethod, config.DiscoveryRequest)}) | |
| 155 | + return | |
| 156 | + } | |
| 157 | + tools, warnings := parseClientTools(raw) | |
| 158 | + warnClientTools(warnings) | |
| 159 | + f.connClientTools = tools | |
| 160 | + fmt.Fprintf(os.Stderr, "[acp] client tools (meta): %s\n", clientToolNames(tools)) | |
| 161 | +} | |
| 162 | + | |
| 163 | +// sessionClientTools returns the tools of a session being created, loaded or | |
| 164 | +// resumed: the connection's list in "meta" mode, the answer to the list | |
| 165 | +// request in "request" mode. A client that advertised nothing gets none. | |
| 166 | +func (f *front) sessionClientTools(ctx context.Context, sid sdk.SessionId, cwd string) []ai.ToolRef { | |
| 167 | + tools := f.connClientTools | |
| 168 | + if config.Cfg.ACP.ClientTools.Discovery == config.DiscoveryRequest { | |
| 169 | + tools = f.requestClientTools(ctx, sid, cwd) | |
| 170 | + } | |
| 171 | + refs := make([]ai.ToolRef, 0, len(tools)) | |
| 172 | + for _, t := range tools { | |
| 173 | + refs = append(refs, f.defineClientTool(sid, t)) | |
| 174 | + } | |
| 175 | + return refs | |
| 176 | +} | |
| 177 | + | |
| 178 | +// requestClientTools sends `_org.mini-me/tools/list` and parses the answer. | |
| 179 | +// It is called while the client waits for its session/new (load, resume) | |
| 180 | +// response, so the client must answer requests concurrently — the ACP SDKs | |
| 181 | +// do. An error leaves the session without client tools, with a warning. | |
| 182 | +func (f *front) requestClientTools(ctx context.Context, sid sdk.SessionId, cwd string) []clientTool { | |
| 183 | + if !f.clientToolsAdvertised || f.conn == nil { | |
| 184 | + return nil | |
| 185 | + } | |
| 186 | + ctx, cancel := context.WithTimeout(ctx, config.Cfg.ACP.ClientTools.Timeout) | |
| 187 | + defer cancel() | |
| 188 | + raw, err := f.conn.CallExtension(ctx, clientToolsListMethod, map[string]any{"sessionId": sid, "cwd": cwd}) | |
| 189 | + if err != nil { | |
| 190 | + warnClientTools([]string{fmt.Sprintf("client tools: %s failed: %v", clientToolsListMethod, err)}) | |
| 191 | + return nil | |
| 192 | + } | |
| 193 | + var list clientToolsList | |
| 194 | + if err := json.Unmarshal(raw, &list); err != nil || len(list.Tools) == 0 { | |
| 195 | + warnClientTools([]string{fmt.Sprintf("client tools: %s answered no \"tools\" list", clientToolsListMethod)}) | |
| 196 | + return nil | |
| 197 | + } | |
| 198 | + tools, warnings := parseClientTools(list.Tools) | |
| 199 | + warnClientTools(warnings) | |
| 200 | + fmt.Fprintf(os.Stderr, "[acp] session %s client tools (request): %s\n", sid, clientToolNames(tools)) | |
| 201 | + return tools | |
| 202 | +} | |
| 203 | + | |
| 204 | +// warnClientTools prints the warnings on stderr, where an editor's ACP log | |
| 205 | +// shows them: the user configured the client, the user reads the log. | |
| 206 | +func warnClientTools(warnings []string) { | |
| 207 | + for _, w := range warnings { | |
| 208 | + fmt.Fprintf(os.Stderr, "[acp] %s\n", w) | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +// clientToolNames lists the model names, for the log. | |
| 213 | +func clientToolNames(tools []clientTool) string { | |
| 214 | + if len(tools) == 0 { | |
| 215 | + return "none" | |
| 216 | + } | |
| 217 | + names := make([]string, len(tools)) | |
| 218 | + for i, t := range tools { | |
| 219 | + names[i] = t.modelName | |
| 220 | + } | |
| 221 | + return strings.Join(names, ", ") | |
| 222 | +} | |
| 223 | + | |
| 224 | +// --- the tool ------------------------------------------------------------------ | |
| 225 | + | |
| 226 | +// defineClientTool builds the Genkit tool of one client tool, bound to the | |
| 227 | +// session it belongs to. The tool is not registered: Genkit registers a | |
| 228 | +// dynamic tool for the duration of the call it is passed to, so two sessions | |
| 229 | +// may hold tools of the same name with different lists. | |
| 230 | +func (f *front) defineClientTool(sid sdk.SessionId, t clientTool) ai.ToolRef { | |
| 231 | + return ai.NewTool(t.modelName, clientToolDescription(t.spec), | |
| 232 | + func(tctx *ai.ToolContext, raw any) (string, error) { | |
| 233 | + in, _ := raw.(map[string]any) | |
| 234 | + if in == nil { | |
| 235 | + in = map[string]any{} | |
| 236 | + } | |
| 237 | + return f.runClientTool(tctx, sid, t, in) | |
| 238 | + }, | |
| 239 | + ai.WithInputSchema(mcp.ModelSchema(t.spec.InputSchema))) | |
| 240 | +} | |
| 241 | + | |
| 242 | +// clientToolDescription is the description the model reads: the client's, | |
| 243 | +// plus the required parameters, which mcp.ModelSchema takes out of the | |
| 244 | +// schema (a call missing one is answered by runClientTool instead). | |
| 245 | +func clientToolDescription(spec clientToolSpec) string { | |
| 246 | + description := spec.Description | |
| 247 | + if description == "" { | |
| 248 | + description = "Tool provided by the editor (" + spec.Method + ")." | |
| 249 | + } | |
| 250 | + if required := requiredParams(spec.InputSchema); len(required) > 0 { | |
| 251 | + description += "\nRequired parameters: " + strings.Join(required, ", ") | |
| 252 | + } | |
| 253 | + return description | |
| 254 | +} | |
| 255 | + | |
| 256 | +// requiredParams reads the `required` list of a JSON schema. | |
| 257 | +func requiredParams(schema map[string]any) []string { | |
| 258 | + list, _ := schema["required"].([]any) | |
| 259 | + var names []string | |
| 260 | + for _, v := range list { | |
| 261 | + if name, ok := v.(string); ok { | |
| 262 | + names = append(names, name) | |
| 263 | + } | |
| 264 | + } | |
| 265 | + return names | |
| 266 | +} | |
| 267 | + | |
| 268 | +// runClientTool is one call of a client tool, on the event path every ACP | |
| 269 | +// tool takes: the editor sees the call start, may be asked for permission, | |
| 270 | +// and sees the result. Whatever happens, the model gets an answer it can | |
| 271 | +// read; only a loop the detector stops ends the turn. | |
| 272 | +func (f *front) runClientTool(ctx context.Context, sid sdk.SessionId, t clientTool, in map[string]any) (string, error) { | |
| 273 | + ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: t.modelName, Title: "client: " + t.spec.Name, Kind: "other", Input: in} | |
| 274 | + sink := ui.ActiveSink() | |
| 275 | + if sink != nil { | |
| 276 | + sink.ToolStart(ev) | |
| 277 | + } | |
| 278 | + if mustAskClientTool() && (sink == nil || !sink.Allow(ctx, ev)) { | |
| 279 | + out := fmt.Sprintf("[%s rejected by user]", t.modelName) | |
| 280 | + if sink != nil { | |
| 281 | + sink.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 282 | + } | |
| 283 | + return f.recordClientTool(t.modelName, in, out, true) | |
| 284 | + } | |
| 285 | + if sink != nil { | |
| 286 | + sink.ToolRunning(ev.ID) | |
| 287 | + } | |
| 288 | + out, err := f.callClientTool(ctx, sid, t, in) | |
| 289 | + failed := err != nil | |
| 290 | + if failed { | |
| 291 | + out = fmt.Sprintf("[client tool error: %v]", err) | |
| 292 | + } | |
| 293 | + out = mcp.Truncate(out) | |
| 294 | + if sink != nil { | |
| 295 | + sink.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) | |
| 296 | + } | |
| 297 | + return f.recordClientTool(t.modelName, in, out, failed) | |
| 298 | +} | |
| 299 | + | |
| 300 | +// mustAskClientTool says whether a call waits for the user: always with | |
| 301 | +// permissions "ask", and with "auto" when yolo is off — the MCP rule. | |
| 302 | +func mustAskClientTool() bool { | |
| 303 | + return config.Cfg.ACP.ClientTools.Permissions == config.PermissionsAsk || consent.Required() | |
| 304 | +} | |
| 305 | + | |
| 306 | +// callClientTool sends the extension request and reads the answer. | |
| 307 | +func (f *front) callClientTool(ctx context.Context, sid sdk.SessionId, t clientTool, in map[string]any) (string, error) { | |
| 308 | + if missing := missingParams(requiredParams(t.spec.InputSchema), in); len(missing) > 0 { | |
| 309 | + return "", fmt.Errorf("missing required parameters: %s", strings.Join(missing, ", ")) | |
| 310 | + } | |
| 311 | + ctx, cancel := context.WithTimeout(ctx, config.Cfg.ACP.ClientTools.Timeout) | |
| 312 | + defer cancel() | |
| 313 | + raw, err := f.conn.CallExtension(ctx, t.spec.Method, callParams(sid, in)) | |
| 314 | + if err != nil { | |
| 315 | + return "", err | |
| 316 | + } | |
| 317 | + return clientToolOutput(raw) | |
| 318 | +} | |
| 319 | + | |
| 320 | +// missingParams lists the required parameters the model left out. | |
| 321 | +func missingParams(required []string, in map[string]any) []string { | |
| 322 | + var missing []string | |
| 323 | + for _, name := range required { | |
| 324 | + if _, ok := in[name]; !ok { | |
| 325 | + missing = append(missing, name) | |
| 326 | + } | |
| 327 | + } | |
| 328 | + return missing | |
| 329 | +} | |
| 330 | + | |
| 331 | +// callParams are the params of a call: the model's arguments, plus the | |
| 332 | +// session id in `_meta` — ACP's slot for data that is not an argument. The | |
| 333 | +// model's own `_meta`, if it invented one, is replaced. | |
| 334 | +// | |
| 335 | +// callParams("s1", map[string]any{"q": "x"}) | |
| 336 | +// // {"q": "x", "_meta": {"org.mini-me/sessionId": "s1"}} | |
| 337 | +func callParams(sid sdk.SessionId, in map[string]any) map[string]any { | |
| 338 | + params := make(map[string]any, len(in)+1) | |
| 339 | + for k, v := range in { | |
| 340 | + params[k] = v | |
| 341 | + } | |
| 342 | + params["_meta"] = map[string]any{sessionIDMetaKey: string(sid)} | |
| 343 | + return params | |
| 344 | +} | |
| 345 | + | |
| 346 | +// clientToolResult is the answer shape mm reads first: MCP's content list. | |
| 347 | +type clientToolResult struct { | |
| 348 | + Content []struct { | |
| 349 | + Type string `json:"type"` | |
| 350 | + Text string `json:"text"` | |
| 351 | + } `json:"content"` | |
| 352 | + IsError bool `json:"isError"` | |
| 353 | +} | |
| 354 | + | |
| 355 | +// clientToolOutput turns the client's answer into the text the model reads. | |
| 356 | +// Two shapes are accepted: MCP's `{"content":[{"type":"text","text":…}], | |
| 357 | +// "isError":…}` — the text blocks joined, an error when isError — and any | |
| 358 | +// other JSON value, given to the model as is (a JSON string unquoted). | |
| 359 | +func clientToolOutput(raw json.RawMessage) (string, error) { | |
| 360 | + var result clientToolResult | |
| 361 | + if err := json.Unmarshal(raw, &result); err == nil && result.Content != nil { | |
| 362 | + var text strings.Builder | |
| 363 | + for _, block := range result.Content { | |
| 364 | + if block.Type == "text" { | |
| 365 | + text.WriteString(block.Text) | |
| 366 | + } else { | |
| 367 | + fmt.Fprintf(&text, "[client tool: non-text content of type %s skipped]", block.Type) | |
| 368 | + } | |
| 369 | + } | |
| 370 | + if result.IsError { | |
| 371 | + return "", errors.New(text.String()) | |
| 372 | + } | |
| 373 | + return text.String(), nil | |
| 374 | + } | |
| 375 | + var text string | |
| 376 | + if json.Unmarshal(raw, &text) == nil { | |
| 377 | + return text, nil | |
| 378 | + } | |
| 379 | + return string(raw), nil | |
| 380 | +} | |
| 381 | + | |
| 382 | +// recordClientTool tells the loop detector about the call, like every other | |
| 383 | +// tool does: the same call repeated is flagged, then stops the turn. | |
| 384 | +func (f *front) recordClientTool(name string, in map[string]any, out string, failed bool) (string, error) { | |
| 385 | + d := f.detector | |
| 386 | + if d == nil { | |
| 387 | + return out, nil | |
| 388 | + } | |
| 389 | + input, _ := json.Marshal(in) | |
| 390 | + if !d.Record(detector.Action{ToolName: name, Input: string(input), Output: out, Failed: failed}) { | |
| 391 | + return out, nil | |
| 392 | + } | |
| 393 | + if d.Stuck() { | |
| 394 | + return "", d.StuckError(name) | |
| 395 | + } | |
| 396 | + return out + "\n\n" + d.LoopError(), nil | |
| 397 | +} | |
| 398 | + | |
| 399 | +// turnTools are the tools of one turn: the agent's own, then the session's | |
| 400 | +// client tools. A new slice, so f.tools is never appended to in place. | |
| 401 | +// | |
| 402 | +// tools := f.turnTools(s) // len(f.tools) + len(s.clientTools) | |
| 403 | +func (f *front) turnTools(s *session) []ai.ToolRef { | |
| 404 | + if len(s.clientTools) == 0 { | |
| 405 | + return f.tools | |
| 406 | + } | |
| 407 | + return append(append(make([]ai.ToolRef, 0, len(f.tools)+len(s.clientTools)), f.tools...), s.clientTools...) | |
| 408 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,408 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +// Client tools (ticket 202): the tools an ACP client exposes as custom | ||
| 4 | +// extension methods become agent tools, discovered at run time. | ||
| 5 | +// | ||
| 6 | +// ACP lets either side define methods whose name starts with "_" and lets a | ||
| 7 | +// client advertise them in the `_meta` of its capabilities, but it defines no | ||
| 8 | +// way to describe their parameters. mm's convention, under the org.mini-me | ||
| 9 | +// namespace, fills that gap (docs/en/reference/acp-client-tools.md): | ||
| 10 | +// | ||
| 11 | +// - discovery "meta" (default): the client lists its tools at initialize, | ||
| 12 | +// in clientCapabilities._meta["org.mini-me/tools"]; one list for every | ||
| 13 | +// session of the connection. | ||
| 14 | +// - discovery "request": the client only advertises | ||
| 15 | +// "org.mini-me/tools": true, and mm asks for the list with the | ||
| 16 | +// `_org.mini-me/tools/list` request when a session is created, loaded or | ||
| 17 | +// resumed; one list per session. | ||
| 18 | +// - a call is a request to the tool's own `method`, whose params are the | ||
| 19 | +// model's arguments plus `_meta: {"org.mini-me/sessionId": "<id>"}`. | ||
| 20 | +// | ||
| 21 | +// The tools only exist in ACP mode: the REPL and the window have no client. | ||
| 22 | + | ||
| 23 | +import ( | ||
| 24 | + "context" | ||
| 25 | + "encoding/json" | ||
| 26 | + "errors" | ||
| 27 | + "fmt" | ||
| 28 | + "os" | ||
| 29 | + "sort" | ||
| 30 | + "strings" | ||
| 31 | + | ||
| 32 | + "mm/internal/config" | ||
| 33 | + "mm/internal/consent" | ||
| 34 | + "mm/internal/detector" | ||
| 35 | + "mm/internal/mcp" | ||
| 36 | + "mm/internal/ui" | ||
| 37 | + | ||
| 38 | + sdk "github.com/coder/acp-go-sdk" | ||
| 39 | + "github.com/firebase/genkit/go/ai" | ||
| 40 | +) | ||
| 41 | + | ||
| 42 | +const ( | ||
| 43 | + // clientToolsMetaKey is the key of clientCapabilities._meta that carries | ||
| 44 | + // the list of tools (discovery "meta") or `true` (discovery "request"). | ||
| 45 | + clientToolsMetaKey = "org.mini-me/tools" | ||
| 46 | + // clientToolsListMethod is the request mm sends for the list when the | ||
| 47 | + // discovery is "request". | ||
| 48 | + clientToolsListMethod = "_org.mini-me/tools/list" | ||
| 49 | + // sessionIDMetaKey is the key of a call's params._meta that names the | ||
| 50 | + // session the call belongs to. | ||
| 51 | + sessionIDMetaKey = "org.mini-me/sessionId" | ||
| 52 | + // clientToolPrefix is the prefix of the name the model sees: a client | ||
| 53 | + // tool cannot shadow a built-in tool or an MCP tool. | ||
| 54 | + clientToolPrefix = "client" | ||
| 55 | +) | ||
| 56 | + | ||
| 57 | +// clientToolSpec is one entry of the list a client sends: the description of | ||
| 58 | +// one of its extension methods, as a tool. | ||
| 59 | +type clientToolSpec struct { | ||
| 60 | + // Name is the client's name for the tool; the model sees "client_<Name>". | ||
| 61 | + Name string `json:"name"` | ||
| 62 | + // Description is what the model reads to decide when to call the tool. | ||
| 63 | + Description string `json:"description"` | ||
| 64 | + // Method is the extension method mm calls; it must start with "_". | ||
| 65 | + Method string `json:"method"` | ||
| 66 | + // InputSchema is the JSON schema of the arguments; optional. | ||
| 67 | + InputSchema map[string]any `json:"inputSchema,omitempty"` | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// clientTool is a validated spec and the name the model sees. | ||
| 71 | +type clientTool struct { | ||
| 72 | + spec clientToolSpec | ||
| 73 | + modelName string | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +// clientToolsList is the result of the `_org.mini-me/tools/list` request. | ||
| 77 | +type clientToolsList struct { | ||
| 78 | + Tools json.RawMessage `json:"tools"` | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +// parseClientTools validates a list of specs, given as the JSON value the | ||
| 82 | +// client sent (an array). An invalid entry is skipped with a warning, never | ||
| 83 | +// fatal: a client's mistake must not cost the user the session. The result | ||
| 84 | +// is sorted by model name, so the model sees the same order every time. | ||
| 85 | +// | ||
| 86 | +// tools, warnings := parseClientTools(json.RawMessage(`[{"name":"buffers","method":"_com.example/buffers"}]`)) | ||
| 87 | +// // tools[0].modelName == "client_buffers", warnings == nil | ||
| 88 | +func parseClientTools(raw json.RawMessage) ([]clientTool, []string) { | ||
| 89 | + var entries []json.RawMessage | ||
| 90 | + if err := json.Unmarshal(raw, &entries); err != nil { | ||
| 91 | + return nil, []string{fmt.Sprintf("client tools: the list is not a JSON array: %v", err)} | ||
| 92 | + } | ||
| 93 | + var tools []clientTool | ||
| 94 | + var warnings []string | ||
| 95 | + seen := map[string]bool{} | ||
| 96 | + for i, entry := range entries { | ||
| 97 | + tool, err := parseClientTool(entry) | ||
| 98 | + if err == nil && seen[tool.modelName] { | ||
| 99 | + err = fmt.Errorf("duplicate tool name %q", tool.modelName) | ||
| 100 | + } | ||
| 101 | + if err != nil { | ||
| 102 | + warnings = append(warnings, fmt.Sprintf("client tools: entry %d skipped: %v", i, err)) | ||
| 103 | + continue | ||
| 104 | + } | ||
| 105 | + seen[tool.modelName] = true | ||
| 106 | + tools = append(tools, tool) | ||
| 107 | + } | ||
| 108 | + sort.Slice(tools, func(i, j int) bool { return tools[i].modelName < tools[j].modelName }) | ||
| 109 | + return tools, warnings | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +// parseClientTool validates one entry of the list. | ||
| 113 | +func parseClientTool(entry json.RawMessage) (clientTool, error) { | ||
| 114 | + var spec clientToolSpec | ||
| 115 | + if err := json.Unmarshal(entry, &spec); err != nil { | ||
| 116 | + return clientTool{}, fmt.Errorf("not a tool description: %v", err) | ||
| 117 | + } | ||
| 118 | + if strings.TrimSpace(spec.Name) == "" { | ||
| 119 | + return clientTool{}, errors.New("no name") | ||
| 120 | + } | ||
| 121 | + if !strings.HasPrefix(spec.Method, "_") { | ||
| 122 | + return clientTool{}, fmt.Errorf("tool %q: method %q must start with \"_\" (an ACP extension method)", spec.Name, spec.Method) | ||
| 123 | + } | ||
| 124 | + if t, ok := spec.InputSchema["type"]; ok && t != "object" { | ||
| 125 | + return clientTool{}, fmt.Errorf("tool %q: inputSchema.type must be \"object\", got %v", spec.Name, t) | ||
| 126 | + } | ||
| 127 | + modelName, err := mcp.SanitizeToolName(clientToolPrefix, spec.Name) | ||
| 128 | + if err != nil { | ||
| 129 | + return clientTool{}, err | ||
| 130 | + } | ||
| 131 | + return clientTool{spec: spec, modelName: modelName}, nil | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +// --- discovery ----------------------------------------------------------------- | ||
| 135 | + | ||
| 136 | +// readClientToolsCapability reads what the client advertised at initialize, | ||
| 137 | +// according to the configured discovery. In "meta" mode the list is parsed | ||
| 138 | +// once, for every session of the connection; in "request" mode only the | ||
| 139 | +// advertisement is kept, and each session asks for its own list. | ||
| 140 | +func (f *front) readClientToolsCapability(meta map[string]any) { | ||
| 141 | + cfg := config.Cfg.ACP.ClientTools | ||
| 142 | + value, advertised := meta[clientToolsMetaKey] | ||
| 143 | + if !cfg.Enabled || !advertised { | ||
| 144 | + return | ||
| 145 | + } | ||
| 146 | + if cfg.Discovery == config.DiscoveryRequest { | ||
| 147 | + f.clientToolsAdvertised = value != false && value != nil | ||
| 148 | + return | ||
| 149 | + } | ||
| 150 | + raw, err := json.Marshal(value) | ||
| 151 | + if err != nil || value == true { | ||
| 152 | + warnClientTools([]string{fmt.Sprintf( | ||
| 153 | + "client tools: %q is not a list of tools; a client that answers %s needs acp.clientTools.discovery: %s", | ||
| 154 | + clientToolsMetaKey, clientToolsListMethod, config.DiscoveryRequest)}) | ||
| 155 | + return | ||
| 156 | + } | ||
| 157 | + tools, warnings := parseClientTools(raw) | ||
| 158 | + warnClientTools(warnings) | ||
| 159 | + f.connClientTools = tools | ||
| 160 | + fmt.Fprintf(os.Stderr, "[acp] client tools (meta): %s\n", clientToolNames(tools)) | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +// sessionClientTools returns the tools of a session being created, loaded or | ||
| 164 | +// resumed: the connection's list in "meta" mode, the answer to the list | ||
| 165 | +// request in "request" mode. A client that advertised nothing gets none. | ||
| 166 | +func (f *front) sessionClientTools(ctx context.Context, sid sdk.SessionId, cwd string) []ai.ToolRef { | ||
| 167 | + tools := f.connClientTools | ||
| 168 | + if config.Cfg.ACP.ClientTools.Discovery == config.DiscoveryRequest { | ||
| 169 | + tools = f.requestClientTools(ctx, sid, cwd) | ||
| 170 | + } | ||
| 171 | + refs := make([]ai.ToolRef, 0, len(tools)) | ||
| 172 | + for _, t := range tools { | ||
| 173 | + refs = append(refs, f.defineClientTool(sid, t)) | ||
| 174 | + } | ||
| 175 | + return refs | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +// requestClientTools sends `_org.mini-me/tools/list` and parses the answer. | ||
| 179 | +// It is called while the client waits for its session/new (load, resume) | ||
| 180 | +// response, so the client must answer requests concurrently — the ACP SDKs | ||
| 181 | +// do. An error leaves the session without client tools, with a warning. | ||
| 182 | +func (f *front) requestClientTools(ctx context.Context, sid sdk.SessionId, cwd string) []clientTool { | ||
| 183 | + if !f.clientToolsAdvertised || f.conn == nil { | ||
| 184 | + return nil | ||
| 185 | + } | ||
| 186 | + ctx, cancel := context.WithTimeout(ctx, config.Cfg.ACP.ClientTools.Timeout) | ||
| 187 | + defer cancel() | ||
| 188 | + raw, err := f.conn.CallExtension(ctx, clientToolsListMethod, map[string]any{"sessionId": sid, "cwd": cwd}) | ||
| 189 | + if err != nil { | ||
| 190 | + warnClientTools([]string{fmt.Sprintf("client tools: %s failed: %v", clientToolsListMethod, err)}) | ||
| 191 | + return nil | ||
| 192 | + } | ||
| 193 | + var list clientToolsList | ||
| 194 | + if err := json.Unmarshal(raw, &list); err != nil || len(list.Tools) == 0 { | ||
| 195 | + warnClientTools([]string{fmt.Sprintf("client tools: %s answered no \"tools\" list", clientToolsListMethod)}) | ||
| 196 | + return nil | ||
| 197 | + } | ||
| 198 | + tools, warnings := parseClientTools(list.Tools) | ||
| 199 | + warnClientTools(warnings) | ||
| 200 | + fmt.Fprintf(os.Stderr, "[acp] session %s client tools (request): %s\n", sid, clientToolNames(tools)) | ||
| 201 | + return tools | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +// warnClientTools prints the warnings on stderr, where an editor's ACP log | ||
| 205 | +// shows them: the user configured the client, the user reads the log. | ||
| 206 | +func warnClientTools(warnings []string) { | ||
| 207 | + for _, w := range warnings { | ||
| 208 | + fmt.Fprintf(os.Stderr, "[acp] %s\n", w) | ||
| 209 | + } | ||
| 210 | +} | ||
| 211 | + | ||
| 212 | +// clientToolNames lists the model names, for the log. | ||
| 213 | +func clientToolNames(tools []clientTool) string { | ||
| 214 | + if len(tools) == 0 { | ||
| 215 | + return "none" | ||
| 216 | + } | ||
| 217 | + names := make([]string, len(tools)) | ||
| 218 | + for i, t := range tools { | ||
| 219 | + names[i] = t.modelName | ||
| 220 | + } | ||
| 221 | + return strings.Join(names, ", ") | ||
| 222 | +} | ||
| 223 | + | ||
| 224 | +// --- the tool ------------------------------------------------------------------ | ||
| 225 | + | ||
| 226 | +// defineClientTool builds the Genkit tool of one client tool, bound to the | ||
| 227 | +// session it belongs to. The tool is not registered: Genkit registers a | ||
| 228 | +// dynamic tool for the duration of the call it is passed to, so two sessions | ||
| 229 | +// may hold tools of the same name with different lists. | ||
| 230 | +func (f *front) defineClientTool(sid sdk.SessionId, t clientTool) ai.ToolRef { | ||
| 231 | + return ai.NewTool(t.modelName, clientToolDescription(t.spec), | ||
| 232 | + func(tctx *ai.ToolContext, raw any) (string, error) { | ||
| 233 | + in, _ := raw.(map[string]any) | ||
| 234 | + if in == nil { | ||
| 235 | + in = map[string]any{} | ||
| 236 | + } | ||
| 237 | + return f.runClientTool(tctx, sid, t, in) | ||
| 238 | + }, | ||
| 239 | + ai.WithInputSchema(mcp.ModelSchema(t.spec.InputSchema))) | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +// clientToolDescription is the description the model reads: the client's, | ||
| 243 | +// plus the required parameters, which mcp.ModelSchema takes out of the | ||
| 244 | +// schema (a call missing one is answered by runClientTool instead). | ||
| 245 | +func clientToolDescription(spec clientToolSpec) string { | ||
| 246 | + description := spec.Description | ||
| 247 | + if description == "" { | ||
| 248 | + description = "Tool provided by the editor (" + spec.Method + ")." | ||
| 249 | + } | ||
| 250 | + if required := requiredParams(spec.InputSchema); len(required) > 0 { | ||
| 251 | + description += "\nRequired parameters: " + strings.Join(required, ", ") | ||
| 252 | + } | ||
| 253 | + return description | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +// requiredParams reads the `required` list of a JSON schema. | ||
| 257 | +func requiredParams(schema map[string]any) []string { | ||
| 258 | + list, _ := schema["required"].([]any) | ||
| 259 | + var names []string | ||
| 260 | + for _, v := range list { | ||
| 261 | + if name, ok := v.(string); ok { | ||
| 262 | + names = append(names, name) | ||
| 263 | + } | ||
| 264 | + } | ||
| 265 | + return names | ||
| 266 | +} | ||
| 267 | + | ||
| 268 | +// runClientTool is one call of a client tool, on the event path every ACP | ||
| 269 | +// tool takes: the editor sees the call start, may be asked for permission, | ||
| 270 | +// and sees the result. Whatever happens, the model gets an answer it can | ||
| 271 | +// read; only a loop the detector stops ends the turn. | ||
| 272 | +func (f *front) runClientTool(ctx context.Context, sid sdk.SessionId, t clientTool, in map[string]any) (string, error) { | ||
| 273 | + ev := ui.ToolEvent{ID: ui.NextCallID(), Tool: t.modelName, Title: "client: " + t.spec.Name, Kind: "other", Input: in} | ||
| 274 | + sink := ui.ActiveSink() | ||
| 275 | + if sink != nil { | ||
| 276 | + sink.ToolStart(ev) | ||
| 277 | + } | ||
| 278 | + if mustAskClientTool() && (sink == nil || !sink.Allow(ctx, ev)) { | ||
| 279 | + out := fmt.Sprintf("[%s rejected by user]", t.modelName) | ||
| 280 | + if sink != nil { | ||
| 281 | + sink.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 282 | + } | ||
| 283 | + return f.recordClientTool(t.modelName, in, out, true) | ||
| 284 | + } | ||
| 285 | + if sink != nil { | ||
| 286 | + sink.ToolRunning(ev.ID) | ||
| 287 | + } | ||
| 288 | + out, err := f.callClientTool(ctx, sid, t, in) | ||
| 289 | + failed := err != nil | ||
| 290 | + if failed { | ||
| 291 | + out = fmt.Sprintf("[client tool error: %v]", err) | ||
| 292 | + } | ||
| 293 | + out = mcp.Truncate(out) | ||
| 294 | + if sink != nil { | ||
| 295 | + sink.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) | ||
| 296 | + } | ||
| 297 | + return f.recordClientTool(t.modelName, in, out, failed) | ||
| 298 | +} | ||
| 299 | + | ||
| 300 | +// mustAskClientTool says whether a call waits for the user: always with | ||
| 301 | +// permissions "ask", and with "auto" when yolo is off — the MCP rule. | ||
| 302 | +func mustAskClientTool() bool { | ||
| 303 | + return config.Cfg.ACP.ClientTools.Permissions == config.PermissionsAsk || consent.Required() | ||
| 304 | +} | ||
| 305 | + | ||
| 306 | +// callClientTool sends the extension request and reads the answer. | ||
| 307 | +func (f *front) callClientTool(ctx context.Context, sid sdk.SessionId, t clientTool, in map[string]any) (string, error) { | ||
| 308 | + if missing := missingParams(requiredParams(t.spec.InputSchema), in); len(missing) > 0 { | ||
| 309 | + return "", fmt.Errorf("missing required parameters: %s", strings.Join(missing, ", ")) | ||
| 310 | + } | ||
| 311 | + ctx, cancel := context.WithTimeout(ctx, config.Cfg.ACP.ClientTools.Timeout) | ||
| 312 | + defer cancel() | ||
| 313 | + raw, err := f.conn.CallExtension(ctx, t.spec.Method, callParams(sid, in)) | ||
| 314 | + if err != nil { | ||
| 315 | + return "", err | ||
| 316 | + } | ||
| 317 | + return clientToolOutput(raw) | ||
| 318 | +} | ||
| 319 | + | ||
| 320 | +// missingParams lists the required parameters the model left out. | ||
| 321 | +func missingParams(required []string, in map[string]any) []string { | ||
| 322 | + var missing []string | ||
| 323 | + for _, name := range required { | ||
| 324 | + if _, ok := in[name]; !ok { | ||
| 325 | + missing = append(missing, name) | ||
| 326 | + } | ||
| 327 | + } | ||
| 328 | + return missing | ||
| 329 | +} | ||
| 330 | + | ||
| 331 | +// callParams are the params of a call: the model's arguments, plus the | ||
| 332 | +// session id in `_meta` — ACP's slot for data that is not an argument. The | ||
| 333 | +// model's own `_meta`, if it invented one, is replaced. | ||
| 334 | +// | ||
| 335 | +// callParams("s1", map[string]any{"q": "x"}) | ||
| 336 | +// // {"q": "x", "_meta": {"org.mini-me/sessionId": "s1"}} | ||
| 337 | +func callParams(sid sdk.SessionId, in map[string]any) map[string]any { | ||
| 338 | + params := make(map[string]any, len(in)+1) | ||
| 339 | + for k, v := range in { | ||
| 340 | + params[k] = v | ||
| 341 | + } | ||
| 342 | + params["_meta"] = map[string]any{sessionIDMetaKey: string(sid)} | ||
| 343 | + return params | ||
| 344 | +} | ||
| 345 | + | ||
| 346 | +// clientToolResult is the answer shape mm reads first: MCP's content list. | ||
| 347 | +type clientToolResult struct { | ||
| 348 | + Content []struct { | ||
| 349 | + Type string `json:"type"` | ||
| 350 | + Text string `json:"text"` | ||
| 351 | + } `json:"content"` | ||
| 352 | + IsError bool `json:"isError"` | ||
| 353 | +} | ||
| 354 | + | ||
| 355 | +// clientToolOutput turns the client's answer into the text the model reads. | ||
| 356 | +// Two shapes are accepted: MCP's `{"content":[{"type":"text","text":…}], | ||
| 357 | +// "isError":…}` — the text blocks joined, an error when isError — and any | ||
| 358 | +// other JSON value, given to the model as is (a JSON string unquoted). | ||
| 359 | +func clientToolOutput(raw json.RawMessage) (string, error) { | ||
| 360 | + var result clientToolResult | ||
| 361 | + if err := json.Unmarshal(raw, &result); err == nil && result.Content != nil { | ||
| 362 | + var text strings.Builder | ||
| 363 | + for _, block := range result.Content { | ||
| 364 | + if block.Type == "text" { | ||
| 365 | + text.WriteString(block.Text) | ||
| 366 | + } else { | ||
| 367 | + fmt.Fprintf(&text, "[client tool: non-text content of type %s skipped]", block.Type) | ||
| 368 | + } | ||
| 369 | + } | ||
| 370 | + if result.IsError { | ||
| 371 | + return "", errors.New(text.String()) | ||
| 372 | + } | ||
| 373 | + return text.String(), nil | ||
| 374 | + } | ||
| 375 | + var text string | ||
| 376 | + if json.Unmarshal(raw, &text) == nil { | ||
| 377 | + return text, nil | ||
| 378 | + } | ||
| 379 | + return string(raw), nil | ||
| 380 | +} | ||
| 381 | + | ||
| 382 | +// recordClientTool tells the loop detector about the call, like every other | ||
| 383 | +// tool does: the same call repeated is flagged, then stops the turn. | ||
| 384 | +func (f *front) recordClientTool(name string, in map[string]any, out string, failed bool) (string, error) { | ||
| 385 | + d := f.detector | ||
| 386 | + if d == nil { | ||
| 387 | + return out, nil | ||
| 388 | + } | ||
| 389 | + input, _ := json.Marshal(in) | ||
| 390 | + if !d.Record(detector.Action{ToolName: name, Input: string(input), Output: out, Failed: failed}) { | ||
| 391 | + return out, nil | ||
| 392 | + } | ||
| 393 | + if d.Stuck() { | ||
| 394 | + return "", d.StuckError(name) | ||
| 395 | + } | ||
| 396 | + return out + "\n\n" + d.LoopError(), nil | ||
| 397 | +} | ||
| 398 | + | ||
| 399 | +// turnTools are the tools of one turn: the agent's own, then the session's | ||
| 400 | +// client tools. A new slice, so f.tools is never appended to in place. | ||
| 401 | +// | ||
| 402 | +// tools := f.turnTools(s) // len(f.tools) + len(s.clientTools) | ||
| 403 | +func (f *front) turnTools(s *session) []ai.ToolRef { | ||
| 404 | + if len(s.clientTools) == 0 { | ||
| 405 | + return f.tools | ||
| 406 | + } | ||
| 407 | + return append(append(make([]ai.ToolRef, 0, len(f.tools)+len(s.clientTools)), f.tools...), s.clientTools...) | ||
| 408 | +} | ||
added
internal/acp/clienttools_test.go +350 -0 | new file mode 100644 | ||
| @@ -0,0 +1,350 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "io" | |
| 7 | + "path/filepath" | |
| 8 | + "reflect" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | + | |
| 12 | + "mm/internal/config" | |
| 13 | + "mm/internal/detector" | |
| 14 | + "mm/internal/store" | |
| 15 | + "mm/internal/ui" | |
| 16 | + | |
| 17 | + sdk "github.com/coder/acp-go-sdk" | |
| 18 | + "github.com/firebase/genkit/go/ai" | |
| 19 | +) | |
| 20 | + | |
| 21 | +// buffersTool is the client tool most tests advertise: one required | |
| 22 | +// parameter, an extension method in the client's own namespace. | |
| 23 | +const buffersTool = `{"name":"open buffers","description":"List the open buffers.","method":"_com.example/workspace/buffers",` + | |
| 24 | + `"inputSchema":{"type":"object","properties":{"language":{"type":"string"}},"required":["language"]}}` | |
| 25 | + | |
| 26 | +// replyError answers the agent's request id with a JSON-RPC error. | |
| 27 | +func (w *wire) replyError(id any, code int, message string) { | |
| 28 | + raw, _ := json.Marshal(id) | |
| 29 | + go func() { | |
| 30 | + _, _ = io.WriteString(w.in, `{"jsonrpc":"2.0","id":`+string(raw)+`,"error":{"code":`+itoa(code)+`,"message":"`+message+`"}}`+"\n") | |
| 31 | + }() | |
| 32 | +} | |
| 33 | + | |
| 34 | +// withClientTools sets the acp.clientTools block for one test. | |
| 35 | +func withClientTools(t *testing.T, change func(*config.ClientToolsConfig)) { | |
| 36 | + t.Helper() | |
| 37 | + saved := config.Cfg.ACP.ClientTools | |
| 38 | + t.Cleanup(func() { config.Cfg.ACP.ClientTools = saved }) | |
| 39 | + change(&config.Cfg.ACP.ClientTools) | |
| 40 | +} | |
| 41 | + | |
| 42 | +// newSession sends session/new as request id and returns the live session. | |
| 43 | +func newSession(t *testing.T, w *wire, id int) (sdk.SessionId, *session) { | |
| 44 | + t.Helper() | |
| 45 | + w.send(id, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`) | |
| 46 | + _, resp := w.until(id) | |
| 47 | + result, _ := resp["result"].(map[string]any) | |
| 48 | + sid, _ := result["sessionId"].(string) | |
| 49 | + if sid == "" { | |
| 50 | + t.Fatalf("session/new answered %v", resp) | |
| 51 | + } | |
| 52 | + f := frontOf(t, w) | |
| 53 | + f.mu.Lock() | |
| 54 | + defer f.mu.Unlock() | |
| 55 | + return sdk.SessionId(sid), f.sessions[sdk.SessionId(sid)] | |
| 56 | +} | |
| 57 | + | |
| 58 | +func toolNames(refs []ai.ToolRef) []string { | |
| 59 | + names := make([]string, len(refs)) | |
| 60 | + for i, r := range refs { | |
| 61 | + names[i] = r.Name() | |
| 62 | + } | |
| 63 | + return names | |
| 64 | +} | |
| 65 | + | |
| 66 | +// A list is validated entry by entry: the good ones become tools named | |
| 67 | +// client_<name>, sorted; each bad one is skipped with a warning naming why, | |
| 68 | +// and never costs the others. | |
| 69 | +func TestParseClientToolsKeepsTheValidEntries(t *testing.T) { | |
| 70 | + raw := json.RawMessage(`[ | |
| 71 | + {"name":"zeta","method":"_x/zeta"}, | |
| 72 | + ` + buffersTool + `, | |
| 73 | + {"method":"_x/anonymous"}, | |
| 74 | + {"name":"plain","method":"x/not-an-extension"}, | |
| 75 | + {"name":"zeta","method":"_x/zeta-again"}, | |
| 76 | + {"name":"stringy","method":"_x/s","inputSchema":{"type":"string"}}, | |
| 77 | + 42 | |
| 78 | + ]`) | |
| 79 | + tools, warnings := parseClientTools(raw) | |
| 80 | + var names []string | |
| 81 | + for _, tool := range tools { | |
| 82 | + names = append(names, tool.modelName) | |
| 83 | + } | |
| 84 | + if want := []string{"client_open_buffers", "client_zeta"}; !reflect.DeepEqual(names, want) { | |
| 85 | + t.Errorf("tools = %v, want %v", names, want) | |
| 86 | + } | |
| 87 | + for _, reason := range []string{"no name", "must start with", "duplicate", "inputSchema.type", "not a tool description"} { | |
| 88 | + if !strings.Contains(strings.Join(warnings, "\n"), reason) { | |
| 89 | + t.Errorf("no warning says %q: %v", reason, warnings) | |
| 90 | + } | |
| 91 | + } | |
| 92 | + if _, warnings := parseClientTools(json.RawMessage(`{"name":"x"}`)); len(warnings) != 1 { | |
| 93 | + t.Errorf("a list that is not an array: warnings = %v", warnings) | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +// Discovery "meta" (the default): the list sent at initialize gives every | |
| 98 | +// session of the connection the same tools. | |
| 99 | +func TestMetaDiscoveryGivesEverySessionTheTools(t *testing.T) { | |
| 100 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":[`+buffersTool+`]}}`) | |
| 101 | + for id := 2; id <= 3; id++ { | |
| 102 | + _, s := newSession(t, w, id) | |
| 103 | + if got := toolNames(s.clientTools); !reflect.DeepEqual(got, []string{"client_open_buffers"}) { | |
| 104 | + t.Errorf("session %d tools = %v", id, got) | |
| 105 | + } | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +// A client that advertises nothing, or a configuration that turned the | |
| 110 | +// feature off, leaves the sessions exactly as before: no client tool, and | |
| 111 | +// the turn's tool list is the agent's own slice. | |
| 112 | +func TestNoClientToolsWithoutAdvertisementOrWhenDisabled(t *testing.T) { | |
| 113 | + w, _ := initialized(t, `{}`) | |
| 114 | + _, s := newSession(t, w, 2) | |
| 115 | + if len(s.clientTools) != 0 { | |
| 116 | + t.Errorf("no advertisement: tools = %v", toolNames(s.clientTools)) | |
| 117 | + } | |
| 118 | + | |
| 119 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Enabled = false }) | |
| 120 | + w, _ = initialized(t, `{"_meta":{"org.mini-me/tools":[`+buffersTool+`]}}`) | |
| 121 | + _, s = newSession(t, w, 2) | |
| 122 | + if len(s.clientTools) != 0 { | |
| 123 | + t.Errorf("disabled: tools = %v", toolNames(s.clientTools)) | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +// In "meta" mode, `true` instead of a list is a client written for the | |
| 128 | +// request mode: no tool, and no list request either. | |
| 129 | +func TestMetaDiscoveryIgnoresABareAdvertisement(t *testing.T) { | |
| 130 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | |
| 131 | + _, s := newSession(t, w, 2) | |
| 132 | + if len(s.clientTools) != 0 { | |
| 133 | + t.Errorf("tools = %v", toolNames(s.clientTools)) | |
| 134 | + } | |
| 135 | +} | |
| 136 | + | |
| 137 | +// Discovery "request": session/new makes mm ask the client for its list, | |
| 138 | +// naming the session and its cwd, before answering; the answer gives the | |
| 139 | +// session its tools. | |
| 140 | +func TestRequestDiscoveryAsksForTheListAtSessionNew(t *testing.T) { | |
| 141 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Discovery = config.DiscoveryRequest }) | |
| 142 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | |
| 143 | + cwd := t.TempDir() | |
| 144 | + w.send(2, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`) | |
| 145 | + | |
| 146 | + req := w.nextRequest() | |
| 147 | + if req["method"] != clientToolsListMethod { | |
| 148 | + t.Fatalf("first request = %v, want %s", req["method"], clientToolsListMethod) | |
| 149 | + } | |
| 150 | + params, _ := req["params"].(map[string]any) | |
| 151 | + if params["cwd"] != cwd || params["sessionId"] == "" { | |
| 152 | + t.Errorf("list params = %v", params) | |
| 153 | + } | |
| 154 | + w.reply(req["id"], `{"tools":[`+buffersTool+`]}`) | |
| 155 | + | |
| 156 | + _, resp := w.until(2) | |
| 157 | + sid := sdk.SessionId(resp["result"].(map[string]any)["sessionId"].(string)) | |
| 158 | + if sid != sdk.SessionId(params["sessionId"].(string)) { | |
| 159 | + t.Errorf("the list request named session %v, session/new answered %s", params["sessionId"], sid) | |
| 160 | + } | |
| 161 | + f := frontOf(t, w) | |
| 162 | + if got := toolNames(f.sessions[sid].clientTools); !reflect.DeepEqual(got, []string{"client_open_buffers"}) { | |
| 163 | + t.Errorf("tools = %v", got) | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +// A list request the client fails is a session without client tools, not a | |
| 168 | +// failed session/new. | |
| 169 | +func TestRequestDiscoveryErrorLeavesTheSessionWithoutTools(t *testing.T) { | |
| 170 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Discovery = config.DiscoveryRequest }) | |
| 171 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | |
| 172 | + w.send(2, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`) | |
| 173 | + w.replyError(w.nextRequest()["id"], -32601, "Method not found") | |
| 174 | + _, resp := w.until(2) | |
| 175 | + if resp["error"] != nil { | |
| 176 | + t.Fatalf("session/new failed: %v", resp["error"]) | |
| 177 | + } | |
| 178 | + sid := sdk.SessionId(resp["result"].(map[string]any)["sessionId"].(string)) | |
| 179 | + if tools := frontOf(t, w).sessions[sid].clientTools; len(tools) != 0 { | |
| 180 | + t.Errorf("tools = %v", toolNames(tools)) | |
| 181 | + } | |
| 182 | +} | |
| 183 | + | |
| 184 | +// A resumed session asks for its list too: the client may offer other tools | |
| 185 | +// than when the session was created. | |
| 186 | +func TestRequestDiscoveryAtSessionResume(t *testing.T) { | |
| 187 | + withSessions(t) | |
| 188 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Discovery = config.DiscoveryRequest }) | |
| 189 | + cwd := t.TempDir() | |
| 190 | + const id = "20261110-100000-cccccccc" | |
| 191 | + if err := store.Open(filepath.Join(cwd, ".mm", "sessions")).Save(&store.Session{ID: id, Cwd: cwd, Messages: storedHistory()}); err != nil { | |
| 192 | + t.Fatal(err) | |
| 193 | + } | |
| 194 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | |
| 195 | + w.send(2, "session/resume", `{"sessionId":"`+id+`","cwd":"`+cwd+`"}`) | |
| 196 | + req := w.nextRequest() | |
| 197 | + if req["method"] != clientToolsListMethod { | |
| 198 | + t.Fatalf("request = %v", req["method"]) | |
| 199 | + } | |
| 200 | + w.reply(req["id"], `{"tools":[{"name":"later","method":"_x/later"}]}`) | |
| 201 | + w.until(2) | |
| 202 | + if got := toolNames(frontOf(t, w).sessions[id].clientTools); !reflect.DeepEqual(got, []string{"client_later"}) { | |
| 203 | + t.Errorf("tools = %v", got) | |
| 204 | + } | |
| 205 | +} | |
| 206 | + | |
| 207 | +// startClientTool starts a session with buffersTool and runs one call of it | |
| 208 | +// in the background, as the model would, with the sink of a turn installed. | |
| 209 | +func startClientTool(t *testing.T, in map[string]any) (*wire, sdk.SessionId, <-chan string) { | |
| 210 | + t.Helper() | |
| 211 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":[`+buffersTool+`]}}`) | |
| 212 | + sid, s := newSession(t, w, 2) | |
| 213 | + f := frontOf(t, w) | |
| 214 | + ui.SetActive(&sink{f: f, ctx: context.Background(), sid: sid, s: s}) | |
| 215 | + t.Cleanup(func() { ui.SetActive(nil) }) | |
| 216 | + out := make(chan string, 1) | |
| 217 | + go func() { | |
| 218 | + text, err := f.runClientTool(context.Background(), sid, f.connClientTools[0], in) | |
| 219 | + if err != nil { | |
| 220 | + text = "error: " + err.Error() | |
| 221 | + } | |
| 222 | + out <- text | |
| 223 | + }() | |
| 224 | + return w, sid, out | |
| 225 | +} | |
| 226 | + | |
| 227 | +// A call is a request to the tool's own method: the model's arguments as | |
| 228 | +// params, the session id in _meta. A content answer reaches the model as | |
| 229 | +// its text. | |
| 230 | +func TestClientToolCallSendsTheArgumentsAndTheSession(t *testing.T) { | |
| 231 | + w, sid, out := startClientTool(t, map[string]any{"language": "go"}) | |
| 232 | + req := w.nextRequest() | |
| 233 | + if req["method"] != "_com.example/workspace/buffers" { | |
| 234 | + t.Fatalf("method = %v", req["method"]) | |
| 235 | + } | |
| 236 | + params, _ := req["params"].(map[string]any) | |
| 237 | + meta, _ := params["_meta"].(map[string]any) | |
| 238 | + if params["language"] != "go" || meta[sessionIDMetaKey] != string(sid) { | |
| 239 | + t.Errorf("params = %v", params) | |
| 240 | + } | |
| 241 | + w.reply(req["id"], `{"content":[{"type":"text","text":"main.go\nutil.go"}]}`) | |
| 242 | + if got := <-out; got != "main.go\nutil.go" { | |
| 243 | + t.Errorf("output = %q", got) | |
| 244 | + } | |
| 245 | +} | |
| 246 | + | |
| 247 | +// A client error is an answer the model can read, never a crashed turn. | |
| 248 | +func TestClientToolErrorReachesTheModel(t *testing.T) { | |
| 249 | + w, _, out := startClientTool(t, map[string]any{"language": "go"}) | |
| 250 | + w.replyError(w.nextRequest()["id"], -32601, "Method not found") | |
| 251 | + if got := <-out; !strings.HasPrefix(got, "[client tool error:") || !strings.Contains(got, "Method not found") { | |
| 252 | + t.Errorf("output = %q", got) | |
| 253 | + } | |
| 254 | +} | |
| 255 | + | |
| 256 | +// A missing required parameter is answered by mm, without bothering the | |
| 257 | +// client, so the model can correct its call. | |
| 258 | +func TestClientToolMissingParameterIsAnsweredByMM(t *testing.T) { | |
| 259 | + _, _, out := startClientTool(t, map[string]any{}) | |
| 260 | + if got := <-out; !strings.Contains(got, "missing required parameters: language") { | |
| 261 | + t.Errorf("output = %q", got) | |
| 262 | + } | |
| 263 | +} | |
| 264 | + | |
| 265 | +// With permissions "ask", the editor is asked first; a refusal reaches the | |
| 266 | +// model and the client's method is never called. | |
| 267 | +func TestClientToolAskPermissionRefused(t *testing.T) { | |
| 268 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Permissions = config.PermissionsAsk }) | |
| 269 | + w, _, out := startClientTool(t, map[string]any{"language": "go"}) | |
| 270 | + req := w.nextRequest() | |
| 271 | + if req["method"] != "session/request_permission" { | |
| 272 | + t.Fatalf("first request = %v, want the permission dialog", req["method"]) | |
| 273 | + } | |
| 274 | + w.reply(req["id"], `{"outcome":{"outcome":"cancelled"}}`) | |
| 275 | + if got := <-out; got != "[client_open_buffers rejected by user]" { | |
| 276 | + t.Errorf("output = %q", got) | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 280 | +// The two answer shapes: MCP's content list (text joined, isError an error, | |
| 281 | +// other blocks named), and any other JSON value as is. | |
| 282 | +func TestClientToolOutputShapes(t *testing.T) { | |
| 283 | + cases := []struct { | |
| 284 | + raw, want string | |
| 285 | + failed bool | |
| 286 | + }{ | |
| 287 | + {`{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`, "ab", false}, | |
| 288 | + {`{"content":[{"type":"image","data":"…"}]}`, "[client tool: non-text content of type image skipped]", false}, | |
| 289 | + {`{"content":[{"type":"text","text":"no such buffer"}],"isError":true}`, "no such buffer", true}, | |
| 290 | + {`"plain text"`, "plain text", false}, | |
| 291 | + {`{"count":2}`, `{"count":2}`, false}, | |
| 292 | + } | |
| 293 | + for _, c := range cases { | |
| 294 | + got, err := clientToolOutput(json.RawMessage(c.raw)) | |
| 295 | + if c.failed { | |
| 296 | + if err == nil || err.Error() != c.want { | |
| 297 | + t.Errorf("%s: err = %v, want %q", c.raw, err, c.want) | |
| 298 | + } | |
| 299 | + continue | |
| 300 | + } | |
| 301 | + if err != nil || got != c.want { | |
| 302 | + t.Errorf("%s: got %q, %v; want %q", c.raw, got, err, c.want) | |
| 303 | + } | |
| 304 | + } | |
| 305 | +} | |
| 306 | + | |
| 307 | +// The model sees the client's description and the required parameters, which | |
| 308 | +// the declared schema no longer carries. | |
| 309 | +func TestClientToolDescriptionListsRequiredParameters(t *testing.T) { | |
| 310 | + tools, _ := parseClientTools(json.RawMessage(`[` + buffersTool + `,{"name":"bare","method":"_x/bare"}]`)) | |
| 311 | + if got := clientToolDescription(tools[0].spec); got != "Tool provided by the editor (_x/bare)." { | |
| 312 | + t.Errorf("bare description = %q", got) | |
| 313 | + } | |
| 314 | + if got := clientToolDescription(tools[1].spec); got != "List the open buffers.\nRequired parameters: language" { | |
| 315 | + t.Errorf("description = %q", got) | |
| 316 | + } | |
| 317 | +} | |
| 318 | + | |
| 319 | +// The turn's tools are the agent's, then the session's; the agent's slice is | |
| 320 | +// never appended to in place. | |
| 321 | +func TestTurnToolsAddsTheSessionsClientTools(t *testing.T) { | |
| 322 | + own := ai.NewTool("bash", "", func(*ai.ToolContext, any) (string, error) { return "", nil }) | |
| 323 | + client := ai.NewTool("client_x", "", func(*ai.ToolContext, any) (string, error) { return "", nil }) | |
| 324 | + f := &front{tools: make([]ai.ToolRef, 1, 4)} | |
| 325 | + f.tools[0] = own | |
| 326 | + if got := f.turnTools(&session{}); len(got) != 1 { | |
| 327 | + t.Errorf("no client tools: %v", toolNames(got)) | |
| 328 | + } | |
| 329 | + got := f.turnTools(&session{clientTools: []ai.ToolRef{client}}) | |
| 330 | + if !reflect.DeepEqual(toolNames(got), []string{"bash", "client_x"}) { | |
| 331 | + t.Errorf("turn tools = %v", toolNames(got)) | |
| 332 | + } | |
| 333 | + if extra := f.tools[:2]; extra[1] != nil { | |
| 334 | + t.Error("f.tools was appended to in place") | |
| 335 | + } | |
| 336 | +} | |
| 337 | + | |
| 338 | +// The same call repeated is reported to the loop detector like any tool's: | |
| 339 | +// with a threshold of 3, the fourth identical call is flagged. | |
| 340 | +func TestClientToolCallsReachTheLoopDetector(t *testing.T) { | |
| 341 | + f := &front{detector: detector.NewLoopDetector(10, 3)} | |
| 342 | + in := map[string]any{"language": "go"} | |
| 343 | + var last string | |
| 344 | + for range 4 { | |
| 345 | + last, _ = f.recordClientTool("client_open_buffers", in, "main.go", false) | |
| 346 | + } | |
| 347 | + if last == "main.go" { | |
| 348 | + t.Error("four identical calls were not flagged") | |
| 349 | + } | |
| 350 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,350 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "io" | ||
| 7 | + "path/filepath" | ||
| 8 | + "reflect" | ||
| 9 | + "strings" | ||
| 10 | + "testing" | ||
| 11 | + | ||
| 12 | + "mm/internal/config" | ||
| 13 | + "mm/internal/detector" | ||
| 14 | + "mm/internal/store" | ||
| 15 | + "mm/internal/ui" | ||
| 16 | + | ||
| 17 | + sdk "github.com/coder/acp-go-sdk" | ||
| 18 | + "github.com/firebase/genkit/go/ai" | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +// buffersTool is the client tool most tests advertise: one required | ||
| 22 | +// parameter, an extension method in the client's own namespace. | ||
| 23 | +const buffersTool = `{"name":"open buffers","description":"List the open buffers.","method":"_com.example/workspace/buffers",` + | ||
| 24 | + `"inputSchema":{"type":"object","properties":{"language":{"type":"string"}},"required":["language"]}}` | ||
| 25 | + | ||
| 26 | +// replyError answers the agent's request id with a JSON-RPC error. | ||
| 27 | +func (w *wire) replyError(id any, code int, message string) { | ||
| 28 | + raw, _ := json.Marshal(id) | ||
| 29 | + go func() { | ||
| 30 | + _, _ = io.WriteString(w.in, `{"jsonrpc":"2.0","id":`+string(raw)+`,"error":{"code":`+itoa(code)+`,"message":"`+message+`"}}`+"\n") | ||
| 31 | + }() | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +// withClientTools sets the acp.clientTools block for one test. | ||
| 35 | +func withClientTools(t *testing.T, change func(*config.ClientToolsConfig)) { | ||
| 36 | + t.Helper() | ||
| 37 | + saved := config.Cfg.ACP.ClientTools | ||
| 38 | + t.Cleanup(func() { config.Cfg.ACP.ClientTools = saved }) | ||
| 39 | + change(&config.Cfg.ACP.ClientTools) | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +// newSession sends session/new as request id and returns the live session. | ||
| 43 | +func newSession(t *testing.T, w *wire, id int) (sdk.SessionId, *session) { | ||
| 44 | + t.Helper() | ||
| 45 | + w.send(id, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`) | ||
| 46 | + _, resp := w.until(id) | ||
| 47 | + result, _ := resp["result"].(map[string]any) | ||
| 48 | + sid, _ := result["sessionId"].(string) | ||
| 49 | + if sid == "" { | ||
| 50 | + t.Fatalf("session/new answered %v", resp) | ||
| 51 | + } | ||
| 52 | + f := frontOf(t, w) | ||
| 53 | + f.mu.Lock() | ||
| 54 | + defer f.mu.Unlock() | ||
| 55 | + return sdk.SessionId(sid), f.sessions[sdk.SessionId(sid)] | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +func toolNames(refs []ai.ToolRef) []string { | ||
| 59 | + names := make([]string, len(refs)) | ||
| 60 | + for i, r := range refs { | ||
| 61 | + names[i] = r.Name() | ||
| 62 | + } | ||
| 63 | + return names | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +// A list is validated entry by entry: the good ones become tools named | ||
| 67 | +// client_<name>, sorted; each bad one is skipped with a warning naming why, | ||
| 68 | +// and never costs the others. | ||
| 69 | +func TestParseClientToolsKeepsTheValidEntries(t *testing.T) { | ||
| 70 | + raw := json.RawMessage(`[ | ||
| 71 | + {"name":"zeta","method":"_x/zeta"}, | ||
| 72 | + ` + buffersTool + `, | ||
| 73 | + {"method":"_x/anonymous"}, | ||
| 74 | + {"name":"plain","method":"x/not-an-extension"}, | ||
| 75 | + {"name":"zeta","method":"_x/zeta-again"}, | ||
| 76 | + {"name":"stringy","method":"_x/s","inputSchema":{"type":"string"}}, | ||
| 77 | + 42 | ||
| 78 | + ]`) | ||
| 79 | + tools, warnings := parseClientTools(raw) | ||
| 80 | + var names []string | ||
| 81 | + for _, tool := range tools { | ||
| 82 | + names = append(names, tool.modelName) | ||
| 83 | + } | ||
| 84 | + if want := []string{"client_open_buffers", "client_zeta"}; !reflect.DeepEqual(names, want) { | ||
| 85 | + t.Errorf("tools = %v, want %v", names, want) | ||
| 86 | + } | ||
| 87 | + for _, reason := range []string{"no name", "must start with", "duplicate", "inputSchema.type", "not a tool description"} { | ||
| 88 | + if !strings.Contains(strings.Join(warnings, "\n"), reason) { | ||
| 89 | + t.Errorf("no warning says %q: %v", reason, warnings) | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + if _, warnings := parseClientTools(json.RawMessage(`{"name":"x"}`)); len(warnings) != 1 { | ||
| 93 | + t.Errorf("a list that is not an array: warnings = %v", warnings) | ||
| 94 | + } | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// Discovery "meta" (the default): the list sent at initialize gives every | ||
| 98 | +// session of the connection the same tools. | ||
| 99 | +func TestMetaDiscoveryGivesEverySessionTheTools(t *testing.T) { | ||
| 100 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":[`+buffersTool+`]}}`) | ||
| 101 | + for id := 2; id <= 3; id++ { | ||
| 102 | + _, s := newSession(t, w, id) | ||
| 103 | + if got := toolNames(s.clientTools); !reflect.DeepEqual(got, []string{"client_open_buffers"}) { | ||
| 104 | + t.Errorf("session %d tools = %v", id, got) | ||
| 105 | + } | ||
| 106 | + } | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// A client that advertises nothing, or a configuration that turned the | ||
| 110 | +// feature off, leaves the sessions exactly as before: no client tool, and | ||
| 111 | +// the turn's tool list is the agent's own slice. | ||
| 112 | +func TestNoClientToolsWithoutAdvertisementOrWhenDisabled(t *testing.T) { | ||
| 113 | + w, _ := initialized(t, `{}`) | ||
| 114 | + _, s := newSession(t, w, 2) | ||
| 115 | + if len(s.clientTools) != 0 { | ||
| 116 | + t.Errorf("no advertisement: tools = %v", toolNames(s.clientTools)) | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Enabled = false }) | ||
| 120 | + w, _ = initialized(t, `{"_meta":{"org.mini-me/tools":[`+buffersTool+`]}}`) | ||
| 121 | + _, s = newSession(t, w, 2) | ||
| 122 | + if len(s.clientTools) != 0 { | ||
| 123 | + t.Errorf("disabled: tools = %v", toolNames(s.clientTools)) | ||
| 124 | + } | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// In "meta" mode, `true` instead of a list is a client written for the | ||
| 128 | +// request mode: no tool, and no list request either. | ||
| 129 | +func TestMetaDiscoveryIgnoresABareAdvertisement(t *testing.T) { | ||
| 130 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | ||
| 131 | + _, s := newSession(t, w, 2) | ||
| 132 | + if len(s.clientTools) != 0 { | ||
| 133 | + t.Errorf("tools = %v", toolNames(s.clientTools)) | ||
| 134 | + } | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +// Discovery "request": session/new makes mm ask the client for its list, | ||
| 138 | +// naming the session and its cwd, before answering; the answer gives the | ||
| 139 | +// session its tools. | ||
| 140 | +func TestRequestDiscoveryAsksForTheListAtSessionNew(t *testing.T) { | ||
| 141 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Discovery = config.DiscoveryRequest }) | ||
| 142 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | ||
| 143 | + cwd := t.TempDir() | ||
| 144 | + w.send(2, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`) | ||
| 145 | + | ||
| 146 | + req := w.nextRequest() | ||
| 147 | + if req["method"] != clientToolsListMethod { | ||
| 148 | + t.Fatalf("first request = %v, want %s", req["method"], clientToolsListMethod) | ||
| 149 | + } | ||
| 150 | + params, _ := req["params"].(map[string]any) | ||
| 151 | + if params["cwd"] != cwd || params["sessionId"] == "" { | ||
| 152 | + t.Errorf("list params = %v", params) | ||
| 153 | + } | ||
| 154 | + w.reply(req["id"], `{"tools":[`+buffersTool+`]}`) | ||
| 155 | + | ||
| 156 | + _, resp := w.until(2) | ||
| 157 | + sid := sdk.SessionId(resp["result"].(map[string]any)["sessionId"].(string)) | ||
| 158 | + if sid != sdk.SessionId(params["sessionId"].(string)) { | ||
| 159 | + t.Errorf("the list request named session %v, session/new answered %s", params["sessionId"], sid) | ||
| 160 | + } | ||
| 161 | + f := frontOf(t, w) | ||
| 162 | + if got := toolNames(f.sessions[sid].clientTools); !reflect.DeepEqual(got, []string{"client_open_buffers"}) { | ||
| 163 | + t.Errorf("tools = %v", got) | ||
| 164 | + } | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +// A list request the client fails is a session without client tools, not a | ||
| 168 | +// failed session/new. | ||
| 169 | +func TestRequestDiscoveryErrorLeavesTheSessionWithoutTools(t *testing.T) { | ||
| 170 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Discovery = config.DiscoveryRequest }) | ||
| 171 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | ||
| 172 | + w.send(2, "session/new", `{"cwd":"`+t.TempDir()+`","mcpServers":[]}`) | ||
| 173 | + w.replyError(w.nextRequest()["id"], -32601, "Method not found") | ||
| 174 | + _, resp := w.until(2) | ||
| 175 | + if resp["error"] != nil { | ||
| 176 | + t.Fatalf("session/new failed: %v", resp["error"]) | ||
| 177 | + } | ||
| 178 | + sid := sdk.SessionId(resp["result"].(map[string]any)["sessionId"].(string)) | ||
| 179 | + if tools := frontOf(t, w).sessions[sid].clientTools; len(tools) != 0 { | ||
| 180 | + t.Errorf("tools = %v", toolNames(tools)) | ||
| 181 | + } | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +// A resumed session asks for its list too: the client may offer other tools | ||
| 185 | +// than when the session was created. | ||
| 186 | +func TestRequestDiscoveryAtSessionResume(t *testing.T) { | ||
| 187 | + withSessions(t) | ||
| 188 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Discovery = config.DiscoveryRequest }) | ||
| 189 | + cwd := t.TempDir() | ||
| 190 | + const id = "20261110-100000-cccccccc" | ||
| 191 | + if err := store.Open(filepath.Join(cwd, ".mm", "sessions")).Save(&store.Session{ID: id, Cwd: cwd, Messages: storedHistory()}); err != nil { | ||
| 192 | + t.Fatal(err) | ||
| 193 | + } | ||
| 194 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":true}}`) | ||
| 195 | + w.send(2, "session/resume", `{"sessionId":"`+id+`","cwd":"`+cwd+`"}`) | ||
| 196 | + req := w.nextRequest() | ||
| 197 | + if req["method"] != clientToolsListMethod { | ||
| 198 | + t.Fatalf("request = %v", req["method"]) | ||
| 199 | + } | ||
| 200 | + w.reply(req["id"], `{"tools":[{"name":"later","method":"_x/later"}]}`) | ||
| 201 | + w.until(2) | ||
| 202 | + if got := toolNames(frontOf(t, w).sessions[id].clientTools); !reflect.DeepEqual(got, []string{"client_later"}) { | ||
| 203 | + t.Errorf("tools = %v", got) | ||
| 204 | + } | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +// startClientTool starts a session with buffersTool and runs one call of it | ||
| 208 | +// in the background, as the model would, with the sink of a turn installed. | ||
| 209 | +func startClientTool(t *testing.T, in map[string]any) (*wire, sdk.SessionId, <-chan string) { | ||
| 210 | + t.Helper() | ||
| 211 | + w, _ := initialized(t, `{"_meta":{"org.mini-me/tools":[`+buffersTool+`]}}`) | ||
| 212 | + sid, s := newSession(t, w, 2) | ||
| 213 | + f := frontOf(t, w) | ||
| 214 | + ui.SetActive(&sink{f: f, ctx: context.Background(), sid: sid, s: s}) | ||
| 215 | + t.Cleanup(func() { ui.SetActive(nil) }) | ||
| 216 | + out := make(chan string, 1) | ||
| 217 | + go func() { | ||
| 218 | + text, err := f.runClientTool(context.Background(), sid, f.connClientTools[0], in) | ||
| 219 | + if err != nil { | ||
| 220 | + text = "error: " + err.Error() | ||
| 221 | + } | ||
| 222 | + out <- text | ||
| 223 | + }() | ||
| 224 | + return w, sid, out | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +// A call is a request to the tool's own method: the model's arguments as | ||
| 228 | +// params, the session id in _meta. A content answer reaches the model as | ||
| 229 | +// its text. | ||
| 230 | +func TestClientToolCallSendsTheArgumentsAndTheSession(t *testing.T) { | ||
| 231 | + w, sid, out := startClientTool(t, map[string]any{"language": "go"}) | ||
| 232 | + req := w.nextRequest() | ||
| 233 | + if req["method"] != "_com.example/workspace/buffers" { | ||
| 234 | + t.Fatalf("method = %v", req["method"]) | ||
| 235 | + } | ||
| 236 | + params, _ := req["params"].(map[string]any) | ||
| 237 | + meta, _ := params["_meta"].(map[string]any) | ||
| 238 | + if params["language"] != "go" || meta[sessionIDMetaKey] != string(sid) { | ||
| 239 | + t.Errorf("params = %v", params) | ||
| 240 | + } | ||
| 241 | + w.reply(req["id"], `{"content":[{"type":"text","text":"main.go\nutil.go"}]}`) | ||
| 242 | + if got := <-out; got != "main.go\nutil.go" { | ||
| 243 | + t.Errorf("output = %q", got) | ||
| 244 | + } | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +// A client error is an answer the model can read, never a crashed turn. | ||
| 248 | +func TestClientToolErrorReachesTheModel(t *testing.T) { | ||
| 249 | + w, _, out := startClientTool(t, map[string]any{"language": "go"}) | ||
| 250 | + w.replyError(w.nextRequest()["id"], -32601, "Method not found") | ||
| 251 | + if got := <-out; !strings.HasPrefix(got, "[client tool error:") || !strings.Contains(got, "Method not found") { | ||
| 252 | + t.Errorf("output = %q", got) | ||
| 253 | + } | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +// A missing required parameter is answered by mm, without bothering the | ||
| 257 | +// client, so the model can correct its call. | ||
| 258 | +func TestClientToolMissingParameterIsAnsweredByMM(t *testing.T) { | ||
| 259 | + _, _, out := startClientTool(t, map[string]any{}) | ||
| 260 | + if got := <-out; !strings.Contains(got, "missing required parameters: language") { | ||
| 261 | + t.Errorf("output = %q", got) | ||
| 262 | + } | ||
| 263 | +} | ||
| 264 | + | ||
| 265 | +// With permissions "ask", the editor is asked first; a refusal reaches the | ||
| 266 | +// model and the client's method is never called. | ||
| 267 | +func TestClientToolAskPermissionRefused(t *testing.T) { | ||
| 268 | + withClientTools(t, func(c *config.ClientToolsConfig) { c.Permissions = config.PermissionsAsk }) | ||
| 269 | + w, _, out := startClientTool(t, map[string]any{"language": "go"}) | ||
| 270 | + req := w.nextRequest() | ||
| 271 | + if req["method"] != "session/request_permission" { | ||
| 272 | + t.Fatalf("first request = %v, want the permission dialog", req["method"]) | ||
| 273 | + } | ||
| 274 | + w.reply(req["id"], `{"outcome":{"outcome":"cancelled"}}`) | ||
| 275 | + if got := <-out; got != "[client_open_buffers rejected by user]" { | ||
| 276 | + t.Errorf("output = %q", got) | ||
| 277 | + } | ||
| 278 | +} | ||
| 279 | + | ||
| 280 | +// The two answer shapes: MCP's content list (text joined, isError an error, | ||
| 281 | +// other blocks named), and any other JSON value as is. | ||
| 282 | +func TestClientToolOutputShapes(t *testing.T) { | ||
| 283 | + cases := []struct { | ||
| 284 | + raw, want string | ||
| 285 | + failed bool | ||
| 286 | + }{ | ||
| 287 | + {`{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`, "ab", false}, | ||
| 288 | + {`{"content":[{"type":"image","data":"…"}]}`, "[client tool: non-text content of type image skipped]", false}, | ||
| 289 | + {`{"content":[{"type":"text","text":"no such buffer"}],"isError":true}`, "no such buffer", true}, | ||
| 290 | + {`"plain text"`, "plain text", false}, | ||
| 291 | + {`{"count":2}`, `{"count":2}`, false}, | ||
| 292 | + } | ||
| 293 | + for _, c := range cases { | ||
| 294 | + got, err := clientToolOutput(json.RawMessage(c.raw)) | ||
| 295 | + if c.failed { | ||
| 296 | + if err == nil || err.Error() != c.want { | ||
| 297 | + t.Errorf("%s: err = %v, want %q", c.raw, err, c.want) | ||
| 298 | + } | ||
| 299 | + continue | ||
| 300 | + } | ||
| 301 | + if err != nil || got != c.want { | ||
| 302 | + t.Errorf("%s: got %q, %v; want %q", c.raw, got, err, c.want) | ||
| 303 | + } | ||
| 304 | + } | ||
| 305 | +} | ||
| 306 | + | ||
| 307 | +// The model sees the client's description and the required parameters, which | ||
| 308 | +// the declared schema no longer carries. | ||
| 309 | +func TestClientToolDescriptionListsRequiredParameters(t *testing.T) { | ||
| 310 | + tools, _ := parseClientTools(json.RawMessage(`[` + buffersTool + `,{"name":"bare","method":"_x/bare"}]`)) | ||
| 311 | + if got := clientToolDescription(tools[0].spec); got != "Tool provided by the editor (_x/bare)." { | ||
| 312 | + t.Errorf("bare description = %q", got) | ||
| 313 | + } | ||
| 314 | + if got := clientToolDescription(tools[1].spec); got != "List the open buffers.\nRequired parameters: language" { | ||
| 315 | + t.Errorf("description = %q", got) | ||
| 316 | + } | ||
| 317 | +} | ||
| 318 | + | ||
| 319 | +// The turn's tools are the agent's, then the session's; the agent's slice is | ||
| 320 | +// never appended to in place. | ||
| 321 | +func TestTurnToolsAddsTheSessionsClientTools(t *testing.T) { | ||
| 322 | + own := ai.NewTool("bash", "", func(*ai.ToolContext, any) (string, error) { return "", nil }) | ||
| 323 | + client := ai.NewTool("client_x", "", func(*ai.ToolContext, any) (string, error) { return "", nil }) | ||
| 324 | + f := &front{tools: make([]ai.ToolRef, 1, 4)} | ||
| 325 | + f.tools[0] = own | ||
| 326 | + if got := f.turnTools(&session{}); len(got) != 1 { | ||
| 327 | + t.Errorf("no client tools: %v", toolNames(got)) | ||
| 328 | + } | ||
| 329 | + got := f.turnTools(&session{clientTools: []ai.ToolRef{client}}) | ||
| 330 | + if !reflect.DeepEqual(toolNames(got), []string{"bash", "client_x"}) { | ||
| 331 | + t.Errorf("turn tools = %v", toolNames(got)) | ||
| 332 | + } | ||
| 333 | + if extra := f.tools[:2]; extra[1] != nil { | ||
| 334 | + t.Error("f.tools was appended to in place") | ||
| 335 | + } | ||
| 336 | +} | ||
| 337 | + | ||
| 338 | +// The same call repeated is reported to the loop detector like any tool's: | ||
| 339 | +// with a threshold of 3, the fourth identical call is flagged. | ||
| 340 | +func TestClientToolCallsReachTheLoopDetector(t *testing.T) { | ||
| 341 | + f := &front{detector: detector.NewLoopDetector(10, 3)} | ||
| 342 | + in := map[string]any{"language": "go"} | ||
| 343 | + var last string | ||
| 344 | + for range 4 { | ||
| 345 | + last, _ = f.recordClientTool("client_open_buffers", in, "main.go", false) | ||
| 346 | + } | ||
| 347 | + if last == "main.go" { | ||
| 348 | + t.Error("four identical calls were not flagged") | ||
| 349 | + } | ||
| 350 | +} | ||
modified
internal/acp/persist.go +2 -0 | @@ -40,6 +40,7 @@ func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk. | ||
| 40 | 40 | if err != nil { |
| 41 | 41 | return sdk.LoadSessionResponse{}, err |
| 42 | 42 | } |
| 43 | + s.clientTools = f.sessionClientTools(ctx, p.SessionId, p.Cwd) | |
| 43 | 44 | for _, u := range replayUpdates(s.messages) { |
| 44 | 45 | _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: p.SessionId, Update: u}) |
| 45 | 46 | } |
| @@ -60,6 +61,7 @@ func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) ( | ||
| 60 | 61 | if err != nil { |
| 61 | 62 | return sdk.ResumeSessionResponse{}, err |
| 62 | 63 | } |
| 64 | + s.clientTools = f.sessionClientTools(ctx, p.SessionId, p.Cwd) | |
| 63 | 65 | f.sendCommands(ctx, p.SessionId) |
| 64 | 66 | fmt.Fprintf(os.Stderr, "[acp] session %s resumed from %s: %d message(s)\n", p.SessionId, s.st.Dir(), len(s.messages)-1) |
| 65 | 67 | return sdk.ResumeSessionResponse{}, nil |
| @@ -40,6 +40,7 @@ func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk. | |||
| 40 | if err != nil { | 40 | if err != nil { |
| 41 | return sdk.LoadSessionResponse{}, err | 41 | return sdk.LoadSessionResponse{}, err |
| 42 | } | 42 | } |
| 43 | + s.clientTools = f.sessionClientTools(ctx, p.SessionId, p.Cwd) | ||
| 43 | for _, u := range replayUpdates(s.messages) { | 44 | for _, u := range replayUpdates(s.messages) { |
| 44 | _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: p.SessionId, Update: u}) | 45 | _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: p.SessionId, Update: u}) |
| 45 | } | 46 | } |
| @@ -60,6 +61,7 @@ func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) ( | |||
| 60 | if err != nil { | 61 | if err != nil { |
| 61 | return sdk.ResumeSessionResponse{}, err | 62 | return sdk.ResumeSessionResponse{}, err |
| 62 | } | 63 | } |
| 64 | + s.clientTools = f.sessionClientTools(ctx, p.SessionId, p.Cwd) | ||
| 63 | f.sendCommands(ctx, p.SessionId) | 65 | f.sendCommands(ctx, p.SessionId) |
| 64 | fmt.Fprintf(os.Stderr, "[acp] session %s resumed from %s: %d message(s)\n", p.SessionId, s.st.Dir(), len(s.messages)-1) | 66 | fmt.Fprintf(os.Stderr, "[acp] session %s resumed from %s: %d message(s)\n", p.SessionId, s.st.Dir(), len(s.messages)-1) |
| 65 | return sdk.ResumeSessionResponse{}, nil | 67 | return sdk.ResumeSessionResponse{}, nil |
modified
internal/config/config.go +61 -0 | @@ -296,6 +296,55 @@ type ACPConfig struct { | ||
| 296 | 296 | Name string `yaml:"name"` |
| 297 | 297 | // Title: agentInfo.title, the name an editor shows. Empty = not sent. |
| 298 | 298 | Title string `yaml:"title"` |
| 299 | + // ClientTools: the tools the ACP client exposes as custom extension | |
| 300 | + // methods (internal/acp/clienttools.go, ticket 202). | |
| 301 | + ClientTools ClientToolsConfig `yaml:"clientTools"` | |
| 302 | +} | |
| 303 | + | |
| 304 | +// The two values of ClientToolsConfig.Discovery. | |
| 305 | +const ( | |
| 306 | + // DiscoveryMeta: the client lists its tools at initialize, in | |
| 307 | + // clientCapabilities._meta["org.mini-me/tools"]. One list per connection. | |
| 308 | + DiscoveryMeta = "meta" | |
| 309 | + // DiscoveryRequest: the client only advertises the capability, and mm | |
| 310 | + // asks for the list (_org.mini-me/tools/list) at each session/new, | |
| 311 | + // session/load and session/resume. One list per session. | |
| 312 | + DiscoveryRequest = "request" | |
| 313 | +) | |
| 314 | + | |
| 315 | +// ClientToolsConfig is the `acp.clientTools` block: in ACP mode only, the | |
| 316 | +// tools an ACP client offers through custom extension methods become agent | |
| 317 | +// tools, discovered at run time. Harmless when the client offers none. | |
| 318 | +// | |
| 319 | +// acp: | |
| 320 | +// clientTools: | |
| 321 | +// enabled: true | |
| 322 | +// discovery: meta # meta | request | |
| 323 | +// permissions: auto # auto | ask | |
| 324 | +// timeout: 30s | |
| 325 | +type ClientToolsConfig struct { | |
| 326 | + // Enabled: false ignores whatever the client advertises. | |
| 327 | + Enabled bool `yaml:"enabled"` | |
| 328 | + // Discovery: DiscoveryMeta (default) or DiscoveryRequest. | |
| 329 | + Discovery string `yaml:"discovery"` | |
| 330 | + // Permissions: PermissionsAuto asks only when yolo is off, | |
| 331 | + // PermissionsAsk always asks — the same rule as the MCP tools. | |
| 332 | + Permissions string `yaml:"permissions"` | |
| 333 | + // Timeout bounds one call of a client tool (and the list request). | |
| 334 | + Timeout time.Duration `yaml:"timeout"` | |
| 335 | +} | |
| 336 | + | |
| 337 | +// validate refuses the values the ACP front end could not act on. | |
| 338 | +func (c ClientToolsConfig) validate() error { | |
| 339 | + switch { | |
| 340 | + case c.Discovery != DiscoveryMeta && c.Discovery != DiscoveryRequest: | |
| 341 | + return fmt.Errorf("acp.clientTools.discovery must be %q or %q, got %q", DiscoveryMeta, DiscoveryRequest, c.Discovery) | |
| 342 | + case c.Permissions != PermissionsAuto && c.Permissions != PermissionsAsk: | |
| 343 | + return fmt.Errorf("acp.clientTools.permissions must be %q or %q, got %q", PermissionsAuto, PermissionsAsk, c.Permissions) | |
| 344 | + case c.Timeout <= 0: | |
| 345 | + return errors.New("acp.clientTools.timeout must be > 0") | |
| 346 | + } | |
| 347 | + return nil | |
| 299 | 348 | } |
| 300 | 349 | |
| 301 | 350 | // SessionsConfig is the `sessions:` block of the YAML. |
| @@ -667,6 +716,15 @@ which ones you left running.`, | ||
| 667 | 716 | ACP: ACPConfig{ |
| 668 | 717 | Name: "bob", |
| 669 | 718 | Title: DefaultACPTitle, |
| 719 | + ClientTools: ClientToolsConfig{ | |
| 720 | + Enabled: true, | |
| 721 | + // The list rides in the initialize request: nothing to ask for. | |
| 722 | + Discovery: DiscoveryMeta, | |
| 723 | + // The tools come from the user's own client: no dialog unless | |
| 724 | + // yolo is off. | |
| 725 | + Permissions: PermissionsAuto, | |
| 726 | + Timeout: 30 * time.Second, | |
| 727 | + }, | |
| 670 | 728 | }, |
| 671 | 729 | Yolo: true, |
| 672 | 730 | Context: ContextConfig{ |
| @@ -914,6 +972,9 @@ func (c Config) validate() error { | ||
| 914 | 972 | if err := c.MCP.Validate(); err != nil { |
| 915 | 973 | return err |
| 916 | 974 | } |
| 975 | + if err := c.ACP.ClientTools.validate(); err != nil { | |
| 976 | + return err | |
| 977 | + } | |
| 917 | 978 | if err := c.validateAgents(); err != nil { |
| 918 | 979 | return err |
| 919 | 980 | } |
| @@ -296,6 +296,55 @@ type ACPConfig struct { | |||
| 296 | Name string `yaml:"name"` | 296 | Name string `yaml:"name"` |
| 297 | // Title: agentInfo.title, the name an editor shows. Empty = not sent. | 297 | // Title: agentInfo.title, the name an editor shows. Empty = not sent. |
| 298 | Title string `yaml:"title"` | 298 | Title string `yaml:"title"` |
| 299 | + // ClientTools: the tools the ACP client exposes as custom extension | ||
| 300 | + // methods (internal/acp/clienttools.go, ticket 202). | ||
| 301 | + ClientTools ClientToolsConfig `yaml:"clientTools"` | ||
| 302 | +} | ||
| 303 | + | ||
| 304 | +// The two values of ClientToolsConfig.Discovery. | ||
| 305 | +const ( | ||
| 306 | + // DiscoveryMeta: the client lists its tools at initialize, in | ||
| 307 | + // clientCapabilities._meta["org.mini-me/tools"]. One list per connection. | ||
| 308 | + DiscoveryMeta = "meta" | ||
| 309 | + // DiscoveryRequest: the client only advertises the capability, and mm | ||
| 310 | + // asks for the list (_org.mini-me/tools/list) at each session/new, | ||
| 311 | + // session/load and session/resume. One list per session. | ||
| 312 | + DiscoveryRequest = "request" | ||
| 313 | +) | ||
| 314 | + | ||
| 315 | +// ClientToolsConfig is the `acp.clientTools` block: in ACP mode only, the | ||
| 316 | +// tools an ACP client offers through custom extension methods become agent | ||
| 317 | +// tools, discovered at run time. Harmless when the client offers none. | ||
| 318 | +// | ||
| 319 | +// acp: | ||
| 320 | +// clientTools: | ||
| 321 | +// enabled: true | ||
| 322 | +// discovery: meta # meta | request | ||
| 323 | +// permissions: auto # auto | ask | ||
| 324 | +// timeout: 30s | ||
| 325 | +type ClientToolsConfig struct { | ||
| 326 | + // Enabled: false ignores whatever the client advertises. | ||
| 327 | + Enabled bool `yaml:"enabled"` | ||
| 328 | + // Discovery: DiscoveryMeta (default) or DiscoveryRequest. | ||
| 329 | + Discovery string `yaml:"discovery"` | ||
| 330 | + // Permissions: PermissionsAuto asks only when yolo is off, | ||
| 331 | + // PermissionsAsk always asks — the same rule as the MCP tools. | ||
| 332 | + Permissions string `yaml:"permissions"` | ||
| 333 | + // Timeout bounds one call of a client tool (and the list request). | ||
| 334 | + Timeout time.Duration `yaml:"timeout"` | ||
| 335 | +} | ||
| 336 | + | ||
| 337 | +// validate refuses the values the ACP front end could not act on. | ||
| 338 | +func (c ClientToolsConfig) validate() error { | ||
| 339 | + switch { | ||
| 340 | + case c.Discovery != DiscoveryMeta && c.Discovery != DiscoveryRequest: | ||
| 341 | + return fmt.Errorf("acp.clientTools.discovery must be %q or %q, got %q", DiscoveryMeta, DiscoveryRequest, c.Discovery) | ||
| 342 | + case c.Permissions != PermissionsAuto && c.Permissions != PermissionsAsk: | ||
| 343 | + return fmt.Errorf("acp.clientTools.permissions must be %q or %q, got %q", PermissionsAuto, PermissionsAsk, c.Permissions) | ||
| 344 | + case c.Timeout <= 0: | ||
| 345 | + return errors.New("acp.clientTools.timeout must be > 0") | ||
| 346 | + } | ||
| 347 | + return nil | ||
| 299 | } | 348 | } |
| 300 | 349 | ||
| 301 | // SessionsConfig is the `sessions:` block of the YAML. | 350 | // SessionsConfig is the `sessions:` block of the YAML. |
| @@ -667,6 +716,15 @@ which ones you left running.`, | |||
| 667 | ACP: ACPConfig{ | 716 | ACP: ACPConfig{ |
| 668 | Name: "bob", | 717 | Name: "bob", |
| 669 | Title: DefaultACPTitle, | 718 | Title: DefaultACPTitle, |
| 719 | + ClientTools: ClientToolsConfig{ | ||
| 720 | + Enabled: true, | ||
| 721 | + // The list rides in the initialize request: nothing to ask for. | ||
| 722 | + Discovery: DiscoveryMeta, | ||
| 723 | + // The tools come from the user's own client: no dialog unless | ||
| 724 | + // yolo is off. | ||
| 725 | + Permissions: PermissionsAuto, | ||
| 726 | + Timeout: 30 * time.Second, | ||
| 727 | + }, | ||
| 670 | }, | 728 | }, |
| 671 | Yolo: true, | 729 | Yolo: true, |
| 672 | Context: ContextConfig{ | 730 | Context: ContextConfig{ |
| @@ -914,6 +972,9 @@ func (c Config) validate() error { | |||
| 914 | if err := c.MCP.Validate(); err != nil { | 972 | if err := c.MCP.Validate(); err != nil { |
| 915 | return err | 973 | return err |
| 916 | } | 974 | } |
| 975 | + if err := c.ACP.ClientTools.validate(); err != nil { | ||
| 976 | + return err | ||
| 977 | + } | ||
| 917 | if err := c.validateAgents(); err != nil { | 978 | if err := c.validateAgents(); err != nil { |
| 918 | return err | 979 | return err |
| 919 | } | 980 | } |
modified
internal/config/config_test.go +44 -0 | @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "path/filepath" |
| 6 | 6 | "strings" |
| 7 | 7 | "testing" |
| 8 | + "time" | |
| 8 | 9 | ) |
| 9 | 10 | |
| 10 | 11 | // The sessions block is off by default and points at .mm/sessions, so a YAML |
| @@ -258,3 +259,46 @@ func TestUnloadModelsConfig(t *testing.T) { | ||
| 258 | 259 | t.Error("unloadModels: false was not read") |
| 259 | 260 | } |
| 260 | 261 | } |
| 262 | + | |
| 263 | +// acp.clientTools (ticket 202): on by default with the meta discovery, a | |
| 264 | +// partial block keeps the other defaults, and the values the ACP front end | |
| 265 | +// could not act on are refused by name. | |
| 266 | +func TestACPClientToolsConfig(t *testing.T) { | |
| 267 | + defaults := Cfg | |
| 268 | + t.Cleanup(func() { Cfg = defaults }) | |
| 269 | + | |
| 270 | + want := ClientToolsConfig{Enabled: true, Discovery: DiscoveryMeta, Permissions: PermissionsAuto, Timeout: 30 * time.Second} | |
| 271 | + if Cfg.ACP.ClientTools != want { | |
| 272 | + t.Fatalf("defaults: %+v, want %+v", Cfg.ACP.ClientTools, want) | |
| 273 | + } | |
| 274 | + | |
| 275 | + dir := t.TempDir() | |
| 276 | + partial := filepath.Join(dir, "partial.yaml") | |
| 277 | + if err := os.WriteFile(partial, []byte("acp:\n clientTools:\n discovery: request\n"), 0o644); err != nil { | |
| 278 | + t.Fatal(err) | |
| 279 | + } | |
| 280 | + if _, err := Load(partial); err != nil { | |
| 281 | + t.Fatal(err) | |
| 282 | + } | |
| 283 | + want.Discovery = DiscoveryRequest | |
| 284 | + if Cfg.ACP.ClientTools != want { | |
| 285 | + t.Errorf("after a partial block: %+v, want %+v", Cfg.ACP.ClientTools, want) | |
| 286 | + } | |
| 287 | + | |
| 288 | + for _, tc := range []struct{ name, yaml, inError string }{ | |
| 289 | + {"bad discovery", "acp:\n clientTools:\n discovery: magic\n", "acp.clientTools.discovery"}, | |
| 290 | + {"bad permissions", "acp:\n clientTools:\n permissions: never\n", "acp.clientTools.permissions"}, | |
| 291 | + {"zero timeout", "acp:\n clientTools:\n timeout: 0s\n", "acp.clientTools.timeout"}, | |
| 292 | + } { | |
| 293 | + t.Run(tc.name, func(t *testing.T) { | |
| 294 | + Cfg = defaults | |
| 295 | + path := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "-")+".yaml") | |
| 296 | + if err := os.WriteFile(path, []byte(tc.yaml), 0o644); err != nil { | |
| 297 | + t.Fatal(err) | |
| 298 | + } | |
| 299 | + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), tc.inError) { | |
| 300 | + t.Errorf("Load: err = %v, want one naming %s", err, tc.inError) | |
| 301 | + } | |
| 302 | + }) | |
| 303 | + } | |
| 304 | +} | |
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "path/filepath" | 5 | "path/filepath" |
| 6 | "strings" | 6 | "strings" |
| 7 | "testing" | 7 | "testing" |
| 8 | + "time" | ||
| 8 | ) | 9 | ) |
| 9 | 10 | ||
| 10 | // The sessions block is off by default and points at .mm/sessions, so a YAML | 11 | // The sessions block is off by default and points at .mm/sessions, so a YAML |
| @@ -258,3 +259,46 @@ func TestUnloadModelsConfig(t *testing.T) { | |||
| 258 | t.Error("unloadModels: false was not read") | 259 | t.Error("unloadModels: false was not read") |
| 259 | } | 260 | } |
| 260 | } | 261 | } |
| 262 | + | ||
| 263 | +// acp.clientTools (ticket 202): on by default with the meta discovery, a | ||
| 264 | +// partial block keeps the other defaults, and the values the ACP front end | ||
| 265 | +// could not act on are refused by name. | ||
| 266 | +func TestACPClientToolsConfig(t *testing.T) { | ||
| 267 | + defaults := Cfg | ||
| 268 | + t.Cleanup(func() { Cfg = defaults }) | ||
| 269 | + | ||
| 270 | + want := ClientToolsConfig{Enabled: true, Discovery: DiscoveryMeta, Permissions: PermissionsAuto, Timeout: 30 * time.Second} | ||
| 271 | + if Cfg.ACP.ClientTools != want { | ||
| 272 | + t.Fatalf("defaults: %+v, want %+v", Cfg.ACP.ClientTools, want) | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + dir := t.TempDir() | ||
| 276 | + partial := filepath.Join(dir, "partial.yaml") | ||
| 277 | + if err := os.WriteFile(partial, []byte("acp:\n clientTools:\n discovery: request\n"), 0o644); err != nil { | ||
| 278 | + t.Fatal(err) | ||
| 279 | + } | ||
| 280 | + if _, err := Load(partial); err != nil { | ||
| 281 | + t.Fatal(err) | ||
| 282 | + } | ||
| 283 | + want.Discovery = DiscoveryRequest | ||
| 284 | + if Cfg.ACP.ClientTools != want { | ||
| 285 | + t.Errorf("after a partial block: %+v, want %+v", Cfg.ACP.ClientTools, want) | ||
| 286 | + } | ||
| 287 | + | ||
| 288 | + for _, tc := range []struct{ name, yaml, inError string }{ | ||
| 289 | + {"bad discovery", "acp:\n clientTools:\n discovery: magic\n", "acp.clientTools.discovery"}, | ||
| 290 | + {"bad permissions", "acp:\n clientTools:\n permissions: never\n", "acp.clientTools.permissions"}, | ||
| 291 | + {"zero timeout", "acp:\n clientTools:\n timeout: 0s\n", "acp.clientTools.timeout"}, | ||
| 292 | + } { | ||
| 293 | + t.Run(tc.name, func(t *testing.T) { | ||
| 294 | + Cfg = defaults | ||
| 295 | + path := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "-")+".yaml") | ||
| 296 | + if err := os.WriteFile(path, []byte(tc.yaml), 0o644); err != nil { | ||
| 297 | + t.Fatal(err) | ||
| 298 | + } | ||
| 299 | + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), tc.inError) { | ||
| 300 | + t.Errorf("Load: err = %v, want one naming %s", err, tc.inError) | ||
| 301 | + } | ||
| 302 | + }) | ||
| 303 | + } | ||
| 304 | +} | ||
modified
internal/mcp/tool_wrapper.go +17 -10 | @@ -204,10 +204,10 @@ func wrapTool( | ||
| 204 | 204 | // Terminal path |
| 205 | 205 | return toolTerminal(tctx, d, sc, mcpTool, fullName, in, permissions) |
| 206 | 206 | }, |
| 207 | - ai.WithInputSchema(modelSchema(mcpTool.RawInputSchema))) | |
| 207 | + ai.WithInputSchema(ModelSchema(mcpTool.RawInputSchema))) | |
| 208 | 208 | } |
| 209 | 209 | |
| 210 | -// modelSchema is the input schema declared to the model: the server's, with | |
| 210 | +// ModelSchema is the input schema declared to the model: the server's, with | |
| 211 | 211 | // the parameter names, types and descriptions the model needs to fill a call, |
| 212 | 212 | // but nothing Genkit would reject a call for. Genkit validates a call against |
| 213 | 213 | // the declared schema before running the tool, and a rejected call ends the |
| @@ -217,8 +217,12 @@ func wrapTool( | ||
| 217 | 217 | // property's description, and `additionalProperties: false` and `$schema` go. |
| 218 | 218 | // Without a declared schema the model sees no parameter at all: a model whose |
| 219 | 219 | // tool calls the server parses against the schema (Qwen's XML format on |
| 220 | -// llama.cpp) then sends every call empty. | |
| 221 | -func modelSchema(raw map[string]any) map[string]any { | |
| 220 | +// llama.cpp) then sends every call empty. The ACP client tools (ticket 202) | |
| 221 | +// declare their schemas through it too, for the same reasons. | |
| 222 | +// | |
| 223 | +// schema := mcp.ModelSchema(map[string]any{"type": "object", "required": []any{"q"}}) | |
| 224 | +// // schema == {"type": "object", "properties": {}} | |
| 225 | +func ModelSchema(raw map[string]any) map[string]any { | |
| 222 | 226 | s, _ := lenient(raw).(map[string]any) |
| 223 | 227 | if s == nil { |
| 224 | 228 | s = map[string]any{} |
| @@ -231,7 +235,7 @@ func modelSchema(raw map[string]any) map[string]any { | ||
| 231 | 235 | } |
| 232 | 236 | |
| 233 | 237 | // lenient returns a deep copy of a JSON schema node without the constraints |
| 234 | -// modelSchema drops. | |
| 238 | +// ModelSchema drops. | |
| 235 | 239 | func lenient(node any) any { |
| 236 | 240 | switch v := node.(type) { |
| 237 | 241 | case map[string]any: |
| @@ -327,7 +331,7 @@ func toolEvented( | ||
| 327 | 331 | } |
| 328 | 332 | |
| 329 | 333 | // Truncate and return |
| 330 | - truncated := truncate(out) | |
| 334 | + truncated := Truncate(out) | |
| 331 | 335 | s.ToolEnd(ev.ID, ui.ToolResult{Output: truncated, Failed: failed}) |
| 332 | 336 | |
| 333 | 337 | return record(d, fullName, mcpTool.Name, truncated, failed) |
| @@ -358,7 +362,7 @@ func toolTerminal( | ||
| 358 | 362 | } |
| 359 | 363 | |
| 360 | 364 | // Truncate and return |
| 361 | - return record(d, fullName, mcpTool.Name, truncate(out), err != nil) | |
| 365 | + return record(d, fullName, mcpTool.Name, Truncate(out), err != nil) | |
| 362 | 366 | } |
| 363 | 367 | |
| 364 | 368 | // callMCPTool calls an MCP tool and returns the result as a string. |
| @@ -414,9 +418,12 @@ func validateRequiredParams(required []string, input map[string]any) []string { | ||
| 414 | 418 | return missing |
| 415 | 419 | } |
| 416 | 420 | |
| 417 | -// truncate truncates output to maxOutput characters, keeping head and tail. | |
| 418 | -// This matches the behavior in internal/tools/tools.go | |
| 419 | -func truncate(s string) string { | |
| 421 | +// Truncate cuts s to config.Cfg.MaxOutput characters, keeping head and | |
| 422 | +// tail, the way internal/tools/tools.go does for bash. Exported for the ACP | |
| 423 | +// client tools (ticket 202), which return a client's answer to the model. | |
| 424 | +// | |
| 425 | +// out := mcp.Truncate(answer) // unchanged when it fits in maxOutput | |
| 426 | +func Truncate(s string) string { | |
| 420 | 427 | max := config.Cfg.MaxOutput |
| 421 | 428 | r := []rune(s) |
| 422 | 429 | if len(r) <= max { |
| @@ -204,10 +204,10 @@ func wrapTool( | |||
| 204 | // Terminal path | 204 | // Terminal path |
| 205 | return toolTerminal(tctx, d, sc, mcpTool, fullName, in, permissions) | 205 | return toolTerminal(tctx, d, sc, mcpTool, fullName, in, permissions) |
| 206 | }, | 206 | }, |
| 207 | - ai.WithInputSchema(modelSchema(mcpTool.RawInputSchema))) | 207 | + ai.WithInputSchema(ModelSchema(mcpTool.RawInputSchema))) |
| 208 | } | 208 | } |
| 209 | 209 | ||
| 210 | -// modelSchema is the input schema declared to the model: the server's, with | 210 | +// ModelSchema is the input schema declared to the model: the server's, with |
| 211 | // the parameter names, types and descriptions the model needs to fill a call, | 211 | // the parameter names, types and descriptions the model needs to fill a call, |
| 212 | // but nothing Genkit would reject a call for. Genkit validates a call against | 212 | // but nothing Genkit would reject a call for. Genkit validates a call against |
| 213 | // the declared schema before running the tool, and a rejected call ends the | 213 | // the declared schema before running the tool, and a rejected call ends the |
| @@ -217,8 +217,12 @@ func wrapTool( | |||
| 217 | // property's description, and `additionalProperties: false` and `$schema` go. | 217 | // property's description, and `additionalProperties: false` and `$schema` go. |
| 218 | // Without a declared schema the model sees no parameter at all: a model whose | 218 | // Without a declared schema the model sees no parameter at all: a model whose |
| 219 | // tool calls the server parses against the schema (Qwen's XML format on | 219 | // tool calls the server parses against the schema (Qwen's XML format on |
| 220 | -// llama.cpp) then sends every call empty. | 220 | +// llama.cpp) then sends every call empty. The ACP client tools (ticket 202) |
| 221 | -func modelSchema(raw map[string]any) map[string]any { | 221 | +// declare their schemas through it too, for the same reasons. |
| 222 | +// | ||
| 223 | +// schema := mcp.ModelSchema(map[string]any{"type": "object", "required": []any{"q"}}) | ||
| 224 | +// // schema == {"type": "object", "properties": {}} | ||
| 225 | +func ModelSchema(raw map[string]any) map[string]any { | ||
| 222 | s, _ := lenient(raw).(map[string]any) | 226 | s, _ := lenient(raw).(map[string]any) |
| 223 | if s == nil { | 227 | if s == nil { |
| 224 | s = map[string]any{} | 228 | s = map[string]any{} |
| @@ -231,7 +235,7 @@ func modelSchema(raw map[string]any) map[string]any { | |||
| 231 | } | 235 | } |
| 232 | 236 | ||
| 233 | // lenient returns a deep copy of a JSON schema node without the constraints | 237 | // lenient returns a deep copy of a JSON schema node without the constraints |
| 234 | -// modelSchema drops. | 238 | +// ModelSchema drops. |
| 235 | func lenient(node any) any { | 239 | func lenient(node any) any { |
| 236 | switch v := node.(type) { | 240 | switch v := node.(type) { |
| 237 | case map[string]any: | 241 | case map[string]any: |
| @@ -327,7 +331,7 @@ func toolEvented( | |||
| 327 | } | 331 | } |
| 328 | 332 | ||
| 329 | // Truncate and return | 333 | // Truncate and return |
| 330 | - truncated := truncate(out) | 334 | + truncated := Truncate(out) |
| 331 | s.ToolEnd(ev.ID, ui.ToolResult{Output: truncated, Failed: failed}) | 335 | s.ToolEnd(ev.ID, ui.ToolResult{Output: truncated, Failed: failed}) |
| 332 | 336 | ||
| 333 | return record(d, fullName, mcpTool.Name, truncated, failed) | 337 | return record(d, fullName, mcpTool.Name, truncated, failed) |
| @@ -358,7 +362,7 @@ func toolTerminal( | |||
| 358 | } | 362 | } |
| 359 | 363 | ||
| 360 | // Truncate and return | 364 | // Truncate and return |
| 361 | - return record(d, fullName, mcpTool.Name, truncate(out), err != nil) | 365 | + return record(d, fullName, mcpTool.Name, Truncate(out), err != nil) |
| 362 | } | 366 | } |
| 363 | 367 | ||
| 364 | // callMCPTool calls an MCP tool and returns the result as a string. | 368 | // callMCPTool calls an MCP tool and returns the result as a string. |
| @@ -414,9 +418,12 @@ func validateRequiredParams(required []string, input map[string]any) []string { | |||
| 414 | return missing | 418 | return missing |
| 415 | } | 419 | } |
| 416 | 420 | ||
| 417 | -// truncate truncates output to maxOutput characters, keeping head and tail. | 421 | +// Truncate cuts s to config.Cfg.MaxOutput characters, keeping head and |
| 418 | -// This matches the behavior in internal/tools/tools.go | 422 | +// tail, the way internal/tools/tools.go does for bash. Exported for the ACP |
| 419 | -func truncate(s string) string { | 423 | +// client tools (ticket 202), which return a client's answer to the model. |
| 424 | +// | ||
| 425 | +// out := mcp.Truncate(answer) // unchanged when it fits in maxOutput | ||
| 426 | +func Truncate(s string) string { | ||
| 420 | max := config.Cfg.MaxOutput | 427 | max := config.Cfg.MaxOutput |
| 421 | r := []rune(s) | 428 | r := []rune(s) |
| 422 | if len(r) <= max { | 429 | if len(r) <= max { |
modified
internal/mcp/tool_wrapper_test.go +5 -5 | @@ -63,19 +63,19 @@ func TestModelSchemaKeepsWhatTheModelNeedsAndDropsWhatGenkitRejects(t *testing.T | ||
| 63 | 63 | "properties": map[string]any{"site": map[string]any{"type": "string"}}}, |
| 64 | 64 | }, |
| 65 | 65 | } |
| 66 | - if got := modelSchema(tool.RawInputSchema); !reflect.DeepEqual(got, want) { | |
| 67 | - t.Errorf("modelSchema =\n%v\nwant\n%v", got, want) | |
| 66 | + if got := ModelSchema(tool.RawInputSchema); !reflect.DeepEqual(got, want) { | |
| 67 | + t.Errorf("ModelSchema =\n%v\nwant\n%v", got, want) | |
| 68 | 68 | } |
| 69 | 69 | // The source schema is left alone: callMCPTool still checks `required`. |
| 70 | 70 | if _, ok := tool.RawInputSchema["required"]; !ok { |
| 71 | - t.Error("modelSchema modified the raw schema") | |
| 71 | + t.Error("ModelSchema modified the raw schema") | |
| 72 | 72 | } |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | 75 | func TestModelSchemaWithoutASchemaIsAnEmptyObject(t *testing.T) { |
| 76 | 76 | want := map[string]any{"type": "object", "properties": map[string]any{}} |
| 77 | - if got := modelSchema(nil); !reflect.DeepEqual(got, want) { | |
| 78 | - t.Errorf("modelSchema(nil) = %v", got) | |
| 77 | + if got := ModelSchema(nil); !reflect.DeepEqual(got, want) { | |
| 78 | + t.Errorf("ModelSchema(nil) = %v", got) | |
| 79 | 79 | } |
| 80 | 80 | } |
| 81 | 81 | |
| @@ -63,19 +63,19 @@ func TestModelSchemaKeepsWhatTheModelNeedsAndDropsWhatGenkitRejects(t *testing.T | |||
| 63 | "properties": map[string]any{"site": map[string]any{"type": "string"}}}, | 63 | "properties": map[string]any{"site": map[string]any{"type": "string"}}}, |
| 64 | }, | 64 | }, |
| 65 | } | 65 | } |
| 66 | - if got := modelSchema(tool.RawInputSchema); !reflect.DeepEqual(got, want) { | 66 | + if got := ModelSchema(tool.RawInputSchema); !reflect.DeepEqual(got, want) { |
| 67 | - t.Errorf("modelSchema =\n%v\nwant\n%v", got, want) | 67 | + t.Errorf("ModelSchema =\n%v\nwant\n%v", got, want) |
| 68 | } | 68 | } |
| 69 | // The source schema is left alone: callMCPTool still checks `required`. | 69 | // The source schema is left alone: callMCPTool still checks `required`. |
| 70 | if _, ok := tool.RawInputSchema["required"]; !ok { | 70 | if _, ok := tool.RawInputSchema["required"]; !ok { |
| 71 | - t.Error("modelSchema modified the raw schema") | 71 | + t.Error("ModelSchema modified the raw schema") |
| 72 | } | 72 | } |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | func TestModelSchemaWithoutASchemaIsAnEmptyObject(t *testing.T) { | 75 | func TestModelSchemaWithoutASchemaIsAnEmptyObject(t *testing.T) { |
| 76 | want := map[string]any{"type": "object", "properties": map[string]any{}} | 76 | want := map[string]any{"type": "object", "properties": map[string]any{}} |
| 77 | - if got := modelSchema(nil); !reflect.DeepEqual(got, want) { | 77 | + if got := ModelSchema(nil); !reflect.DeepEqual(got, want) { |
| 78 | - t.Errorf("modelSchema(nil) = %v", got) | 78 | + t.Errorf("ModelSchema(nil) = %v", got) |
| 79 | } | 79 | } |
| 80 | } | 80 | } |
| 81 | 81 | ||
modified
internal/tools/declared.go +1 -1 | @@ -93,7 +93,7 @@ func declaredDescription(t config.DeclaredTool) string { | ||
| 93 | 93 | // `required` nor `enum` as keywords: Genkit validates a call against the |
| 94 | 94 | // declared schema before running the tool and a rejected call ends the |
| 95 | 95 | // turn, where mm prefers to answer with a message the model can act on — |
| 96 | -// the same choice as for MCP tools (mcp.modelSchema). | |
| 96 | +// the same choice as for MCP tools (mcp.ModelSchema). | |
| 97 | 97 | func declaredSchema(t config.DeclaredTool) map[string]any { |
| 98 | 98 | properties := map[string]any{} |
| 99 | 99 | for _, p := range t.Parameters { |
| @@ -93,7 +93,7 @@ func declaredDescription(t config.DeclaredTool) string { | |||
| 93 | // `required` nor `enum` as keywords: Genkit validates a call against the | 93 | // `required` nor `enum` as keywords: Genkit validates a call against the |
| 94 | // declared schema before running the tool and a rejected call ends the | 94 | // declared schema before running the tool and a rejected call ends the |
| 95 | // turn, where mm prefers to answer with a message the model can act on — | 95 | // turn, where mm prefers to answer with a message the model can act on — |
| 96 | -// the same choice as for MCP tools (mcp.modelSchema). | 96 | +// the same choice as for MCP tools (mcp.ModelSchema). |
| 97 | func declaredSchema(t config.DeclaredTool) map[string]any { | 97 | func declaredSchema(t config.DeclaredTool) map[string]any { |
| 98 | properties := map[string]any{} | 98 | properties := map[string]any{} |
| 99 | for _, p := range t.Parameters { | 99 | for _, p := range t.Parameters { |
modified
main.go +1 -1 | @@ -317,7 +317,7 @@ func main() { | ||
| 317 | 317 | // Three front ends, one wiring: everything above — config, engine, tools, |
| 318 | 318 | // detector — is strictly identical whichever loop runs below. |
| 319 | 319 | if *acpMode { |
| 320 | - acp.Run(ctx, e, config.Cfg.System, agentTools, asker) | |
| 320 | + acp.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker) | |
| 321 | 321 | return |
| 322 | 322 | } |
| 323 | 323 | if window { |
| @@ -317,7 +317,7 @@ func main() { | |||
| 317 | // Three front ends, one wiring: everything above — config, engine, tools, | 317 | // Three front ends, one wiring: everything above — config, engine, tools, |
| 318 | // detector — is strictly identical whichever loop runs below. | 318 | // detector — is strictly identical whichever loop runs below. |
| 319 | if *acpMode { | 319 | if *acpMode { |
| 320 | - acp.Run(ctx, e, config.Cfg.System, agentTools, asker) | 320 | + acp.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker) |
| 321 | return | 321 | return |
| 322 | } | 322 | } |
| 323 | if window { | 323 | if window { |