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

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

Download ZIP

🛟 Updated.

k33g committed 2026-11-11T00:10:08+01:00 Browse files
bfca5aa parent: 47a9a4e
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:
5252 description: Security update (golang.org/x/net v0.62.0), published 2026-11-02
5353 dueOn: 2026-10-31
5454 - title: v1.0.5
55- state: open
55+ state: closed
5656 dueOn: 2026-10-31
5757 - title: v1.0.6
5858 state: open
@@ -52,7 +52,7 @@ milestones:
52 description: Security update (golang.org/x/net v0.62.0), published 2026-11-0252 description: Security update (golang.org/x/net v0.62.0), published 2026-11-02
53 dueOn: 2026-10-3153 dueOn: 2026-10-31
54 - title: v1.0.554 - title: v1.0.5
55- state: open55+ state: closed
56 dueOn: 2026-10-3156 dueOn: 2026-10-31
57 - title: v1.0.657 - title: v1.0.6
58 state: open58 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
1010 | [`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 |
1111 | [`go/`](go/README.en.md) | Go (ticket 168) | done: package `minime`, four examples, tests |
1212 | [`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 |
1314 | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | not started |
1415
1516 What every client does:
@@ -22,5 +23,6 @@ What every client does:
2223 | not streamed | join the text chunks until `session/prompt` answers with its `stopReason` |
2324 | cancel | the `session/cancel` notification; the prompt then answers `stopReason: "cancelled"` |
2425 | 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)) |
2527
2628 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è
1010 | [`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 |
1111 | [`go/`](go/README.fr.md) | Go (ticket 168) | fait : paquet `minime`, quatre exemples, des tests |
1212 | [`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 |
1314 | `python/`, `rust/`, `swift/`, `nushell/` | tickets 169–172 | pas commencés |
1415
1516 Ce que fait chaque client :
@@ -22,5 +23,6 @@ Ce que fait chaque client :
2223 | sans stream | concaténer les morceaux de texte jusqu'à la réponse de `session/prompt` et son `stopReason` |
2324 | annuler | la notification `session/cancel` ; le prompt répond alors `stopReason: "cancelled"` |
2425 | 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)) |
2527
2628 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
3838 go run ./examples/stream # streamed, with the tool calls
3939 go run ./examples/cancel # cancelled after 3 seconds
4040 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
4142 ```
4243
4344 `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
5859 | `Options.Cwd` | the session's working directory, where the tools run. Default: the current directory. |
5960 | `Options.MM` | the mm binary. Default: `$MM_BIN`, then `mm` on the PATH. |
6061 | `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. |
6164 | `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. |
6265 | `agent.Complete(ctx, prompt) (Result, error)` | one turn; returns `Result{Text, StopReason, Usage}` once it is over. |
6366 | `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:
9295 - **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.
9396 - **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`.
9497 - **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>`.
96111
97112 ## Tests
98113
99114 ```bash
100115 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
102117 ```
103118
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.
105120
106121 ## Measured (2026-11-01, llmman on the author's host, mm v1.0.3 built from the working tree)
107122
@@ -112,6 +127,12 @@ The test binary plays a fake mm (`fakemm_test.go`): started again with `MINIME_F
112127 | `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 |
113128 | `permission` | `pwd` refused: the model **invented** an answer (`/root`); allowed: the real directory. A refusal does not stop a small model from guessing. |
114129
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+
115136 ## When to choose this, and when not
116137
117138 - **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 calls38 go run ./examples/stream # streamed, with the tool calls
39 go run ./examples/cancel # cancelled after 3 seconds39 go run ./examples/cancel # cancelled after 3 seconds
40 go run ./examples/permission # a command refused, then allowed40 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 ## Tests112 ## Tests
98 113
99 ```bash114 ```bash
100 cd demo.acp.sdk/go115 cd demo.acp.sdk/go
101-go test ./... # 40 tests and subtests, no model, no mm, ~1 s116+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 not136 ## 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
3838 go run ./examples/stream # en stream, avec les appels d'outils
3939 go run ./examples/cancel # annulé au bout de 3 secondes
4040 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
4142 ```
4243
4344 `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
5859 | `Options.Cwd` | le répertoire de travail de la session, là où les outils s'exécutent. Par défaut : le répertoire courant. |
5960 | `Options.MM` | le binaire mm. Par défaut : `$MM_BIN`, puis `mm` dans le PATH. |
6061 | `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. |
6164 | `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. |
6265 | `agent.Complete(ctx, prompt) (Result, error)` | un tour ; renvoie `Result{Text, StopReason, Usage}` une fois terminé. |
6366 | `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 :
9295 - **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.
9396 - **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`.
9497 - **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>`.
96111
97112 ## Tests
98113
99114 ```bash
100115 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
102117 ```
103118
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.
105120
106121 ## Mesures (2026-11-01, llmman sur la machine de l'auteur, mm v1.0.3 compilé depuis l'arbre de travail)
107122
@@ -112,6 +127,12 @@ Le binaire de test joue un faux mm (`fakemm_test.go`) : relancé avec `MINIME_FA
112127 | `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 |
113128 | `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. |
114129
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+
115136 ## Quand le choisir, et quand ne pas le choisir
116137
117138 - **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'outils38 go run ./examples/stream # en stream, avec les appels d'outils
39 go run ./examples/cancel # annulé au bout de 3 secondes39 go run ./examples/cancel # annulé au bout de 3 secondes
40 go run ./examples/permission # une commande refusée, puis autorisée40 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 ## Tests112 ## Tests
98 113
99 ```bash114 ```bash
100 cd demo.acp.sdk/go115 cd demo.acp.sdk/go
101-go test ./... # 40 tests et sous-tests, sans modèle ni mm, ~1 s116+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 choisir136 ## 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) {
6565 if err != nil {
6666 return nil, err
6767 }
68+ tools, err := newToolbox(opts.Tools, opts.ToolDiscovery)
69+ if err != nil {
70+ return nil, err
71+ }
6872 stdin, err := cmd.StdinPipe()
6973 if err != nil {
7074 return nil, fmt.Errorf("minime: %w", err)
@@ -84,7 +88,7 @@ func Start(ctx context.Context, opts Options) (*Agent, error) {
8488 a := &Agent{
8589 cmd: cmd,
8690 stdin: stdin,
87- client: &client{onPermission: opts.OnPermission},
91+ client: &client{onPermission: opts.OnPermission, tools: tools},
8892 cwd: cwd,
8993 stderr: &stderrTail{},
9094 exited: make(chan struct{}),
@@ -137,11 +141,13 @@ func command(opts Options) (*exec.Cmd, string, error) {
137141 func (a *Agent) open(ctx context.Context) error {
138142 ctx, cancel := context.WithTimeout(ctx, startTimeout)
139143 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.
142147 _, 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()},
145151 })
146152 if err != nil {
147153 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, err66 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 back144+ // 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
22
33 import (
44 "context"
5+ "encoding/json"
56 "strings"
67 "sync"
78 "sync/atomic"
@@ -62,12 +63,23 @@ func (t *turn) answer() string {
6263 // and its permission requests. It implements sdk.Client.
6364 type client struct {
6465 onPermission func(ctx context.Context, call ToolCall) Decision
66+ tools *toolbox // the Options.Tools, answered in HandleExtensionMethod
6567
6668 mu sync.Mutex
6769 current *turn // nil between turns
6870 }
6971
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+}
7183
7284 func (c *client) begin(t *turn) {
7385 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) Decision65 onPermission func(ctx context.Context, call ToolCall) Decision
66+ tools *toolbox // the Options.Tools, answered in HandleExtensionMethod
65 67
66 mu sync.Mutex68 mu sync.Mutex
67 current *turn // nil between turns69 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
22
33 import (
44 "context"
5+ "encoding/json"
56 "fmt"
67 "io"
78 "log/slog"
@@ -62,22 +63,81 @@ func runFakeMM() {
6263 // deaf "Once", then ignores session/cancel for 3 s
6364 // fail a JSON-RPC error carrying mm's explanation under data.error
6465 // 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.
6573 type fakeAgent struct {
6674 conn *sdk.AgentSideConnection
6775 mu sync.Mutex
6876 sessions int
6977 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
7081 }
7182
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+ }
7389 return sdk.InitializeResponse{ProtocolVersion: sdk.ProtocolVersionNumber, AgentInfo: &sdk.Implementation{Name: "fake-mm"}}, nil
7490 }
7591
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) {
7793 f.mu.Lock()
78- defer f.mu.Unlock()
7994 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)
81141 }
82142
83143 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
111171 case "crash":
112172 fmt.Fprintln(os.Stderr, "fake mm: boom")
113173 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
114181 }
115182 say(sdk.UpdateAgentMessageText("?"))
116183 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 s63 // deaf "Once", then ignores session/cancel for 3 s
63 // fail a JSON-RPC error carrying mm's explanation under data.error64 // fail a JSON-RPC error carrying mm's explanation under data.error
64 // crash a line on stderr, then exit 365 // 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.AgentSideConnection74 conn *sdk.AgentSideConnection
67 mu sync.Mutex75 mu sync.Mutex
68 sessions int76 sessions int
69 turns map[sdk.SessionId]int77 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"}}, nil89 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))}, nil95+ 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, nil183 return endTurn, nil
modified demo.acp.sdk/go/minime.go +10 -0
@@ -55,6 +55,16 @@ type Options struct {
5555 // lines). Without it the lines are read and dropped; the last one is
5656 // added to the error when mm exits.
5757 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
5868 }
5969
6070 // 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 is55 // 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 @@
11 <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="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
22 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
33 <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" 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="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="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="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell>
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="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_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="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" 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="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" 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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" 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="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" 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="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" 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="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" 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="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit&lt;/font&gt;" 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="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" 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="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_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="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" 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="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" 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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" 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="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" 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="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" 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="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" 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="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit&lt;/font&gt;" 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="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" 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="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>1 <mxfile host="app.diagrams.net" modified="2026-10-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="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>2 <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3 <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" 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="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="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="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell>3 <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json), the questions ↑ recalls, /memory and search_memory&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="saved" value="&lt;b&gt;internal/saved&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;an answer to a new file: /save, save_answer&lt;/font&gt;" 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="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook, a hook's one-call approval&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="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="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="&lt;b&gt;internal/agentsmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the project's AGENTS.md, appended to the system prompt per conversation&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="&lt;b&gt;internal/elicit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ask_user's questions and answers: normalize, line answers, elicitation form schema&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell>
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="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_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="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" 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="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" 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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" 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="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" 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="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" 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="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" 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="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit&lt;/font&gt;" 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="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" 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="&lt;b&gt;internal/hooks&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_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="&lt;b&gt;internal/docsearch&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;BM25 over the text files of folders: /docs, search_docs&lt;/font&gt;" 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="&lt;b&gt;internal/textfold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;case and accents folded, rune for rune&lt;/font&gt;" 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="&lt;b&gt;internal/agentinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm init: the agent.yaml wizard, presets.yaml, the server's model facts&lt;/font&gt;" 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="&lt;b&gt;internal/wasmtool&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;runs the wasm tools and hooks: JSON on stdin, declared folders only, timeout&lt;/font&gt;" 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="&lt;b&gt;github.com/tetratelabs/wazero&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;WebAssembly runtime in pure Go, WASI preview 1&lt;/font&gt;" 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="&lt;b&gt;internal/decide&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;decision models: /v1/systemone client, skill and tool routing&lt;/font&gt;" 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="&lt;b&gt;internal/dockerinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm dockerinit: writes the Dockerfile and compose.yaml&lt;/font&gt;" 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="&lt;b&gt;internal/sbxinit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;mm sbxinit: writes sbxenv.yaml with the latest kit&lt;/font&gt;" 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="&lt;b&gt;internal/scaffold&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what mm dockerinit and mm sbxinit share: the agent name, writing without destroying&lt;/font&gt;" 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)
2323 - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo`
2424 - [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`
2525 - [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`
2627 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
2728 - [Turn a model's reasoning off](how-to/turn-reasoning-off.md) — `chat_template_kwargs` or `reasoning_effort` in `sampling`, per server
2829 - [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)
7273 - [Skill file format](reference/skill-format.md)
7374 - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors
7475 - [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
7577
7678 ### MCP (Model Context Protocol) Reference
7779 - [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 callback25 - [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 server28 - [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 errors74 - [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) Reference78 ### MCP (Model Context Protocol) Reference
77 - [MCP Configuration](reference/mcp-configuration.md) — All MCP-specific configuration options79 - [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 @@
2626
2727 | Method | Behaviour |
2828 |--------|-----------|
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)). |
3030 | `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. |
3232 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
3333 | `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`. |
3535 | `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`. |
3737 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |
3838
3939 ## Skills as commands
@@ -151,6 +151,17 @@ Without form support, a form whose questions are all `choice` or `confirm` goes
151151
152152 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.
153153
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+
154165 ## Tool kinds
155166
156167 | `mm` kind | ACP `ToolKind` | Tools |
@@ -160,7 +171,7 @@ When mm is the **client** — an agent asking another one through `/ask`, `ask_a
160171 | `edit` | `edit` | `write_file`, `edit_file` |
161172 | `fetch` | `fetch` | `fetch_content` |
162173 | `search` | `search` | `search_memory`, `search_docs` |
163-| `other` | `other` | `ask_agent`, `ask_user` |
174+| `other` | `other` | `ask_agent`, `ask_user`, client tools (`client_<name>`) |
164175
165176 ## Tool call identifiers
166177
@@ -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 commands39 ## 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 kinds165 ## 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 identifiers176 ## 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
120120 |-----|------|---------|-------------|
121121 | `acp.name` | string | `bob` | `agentInfo.name` in the `initialize` response. Must not be empty. |
122122 | `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. |
123127
124128 The version is not configurable: `agentInfo.version` is always the binary's, as printed by `mm --version`.
125129
@@ -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:
2323 - [Laisser votre agent appeler d'autres agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, descriptions dans `mmConfigs`, `yolo` par agent
2424 - [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`
2525 - [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`
2627 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
2728 - [Couper le raisonnement d'un modèle](how-to/turn-reasoning-off.md) — `chat_template_kwargs` ou `reasoning_effort` dans `sampling`, selon le serveur
2829 - [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:
7273 - [Format d'un fichier de skill](reference/skill-format.md)
7374 - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation
7475 - [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
7577
7678 ### Référence MCP (Model Context Protocol)
7779 - [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 agent23 - [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 permission25 - [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 serveur28 - [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 validation74 - [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 à MCP79 - [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 @@
2626
2727 | Méthode | Comportement |
2828 |---------|--------------|
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)). |
3030 | `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`. |
3232 | `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. |
3333 | `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`. |
3535 | `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`. |
3737 | `session/set_mode`, `session/set_config_option`, `session/close`, `logout` | `method not found`. |
3838
3939 ## Les skills comme commandes
@@ -151,6 +151,17 @@ Sans prise en charge des formulaires, un formulaire dont toutes les questions so
151151
152152 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.
153153
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+
154165 ## Genres d'outils
155166
156167 | Genre `mm` | `ToolKind` ACP | Outils |
@@ -160,7 +171,7 @@ Quand mm est le **client** — un agent qui en interroge un autre par `/ask`, `a
160171 | `edit` | `edit` | `write_file`, `edit_file` |
161172 | `fetch` | `fetch` | `fetch_content` |
162173 | `search` | `search` | `search_memory`, `search_docs` |
163-| `other` | `other` | `ask_agent`, `ask_user` |
174+| `other` | `other` | `ask_agent`, `ask_user`, outils client (`client_<name>`) |
164175
165176 ## Identifiants d'appel d'outil
166177
@@ -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 commandes39 ## 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'outils165 ## 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'outil176 ## 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
120120 |-----|------|--------|-------------|
121121 | `acp.name` | chaîne | `bob` | `agentInfo.name` dans la réponse à `initialize`. Ne doit pas être vide. |
122122 | `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. |
123127
124128 La version n'est pas paramétrable : `agentInfo.version` est toujours celle du binaire, telle qu'affichée par `mm --version`.
125129
@@ -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 (
3636 "mm/internal/config"
3737 "mm/internal/consent"
3838 "mm/internal/decide"
39+ "mm/internal/detector"
3940 "mm/internal/docsearch"
4041 "mm/internal/engine"
4142 "mm/internal/mention"
@@ -68,6 +69,9 @@ type session struct {
6869 // usage is the token accounting: totals for the session/prompt response,
6970 // the last measure for the context meter (usage.go).
7071 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
7175 }
7276
7377 type front struct {
@@ -89,6 +93,17 @@ type front struct {
8993 // initialize: the ask_user tool may send it whole forms (elicit.go).
9094 elicitForm bool
9195
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+
92107 mu sync.Mutex // guards sessions
93108 sessions map[sdk.SessionId]*session
94109
@@ -105,10 +120,13 @@ var _ sdk.Agent = (*front)(nil)
105120 // client closes it. Same arguments as agent.Run: main.go's wiring — engine,
106121 // system prompt, tool list — is identical for the two front ends.
107122 //
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) {
110127 f := newFront(e, system, tools, os.Stdout, os.Stdin)
111128 f.asker = asker
129+ f.detector = d
112130 select {
113131 case <-f.conn.Done(): // the editor closed our stdin: we are done
114132 case <-ctx.Done():
@@ -206,6 +224,7 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init
206224 if e := p.ClientCapabilities.Elicitation; e != nil && e.Form != nil {
207225 f.elicitForm = true
208226 }
227+ f.readClientToolsCapability(p.ClientCapabilities.Meta)
209228 fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d elicitation(form)=%v\n",
210229 clientName(p.ClientInfo), p.ProtocolVersion, f.elicitForm)
211230 return sdk.InitializeResponse{
@@ -264,6 +283,9 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
264283 // The AGENTS.md of THIS session's project: each session has its own cwd.
265284 messages: history.Fresh(agentsmd.System(f.system, p.Cwd)),
266285 }
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)
267289 f.persistNew(sid, s)
268290 f.mu.Lock()
269291 f.sessions[sid] = s
@@ -448,7 +470,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
448470 }
449471 }
450472
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))
452474
453475 // Same rule as the REPL, for the same reason: keep the FULL history even
454476 // 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 usage71 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 bool94 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 sessions107 mu sync.Mutex // guards sessions
93 sessions map[sdk.SessionId]*session108 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 = asker128 f.asker = asker
129+ f.detector = d
112 select {130 select {
113 case <-f.conn.Done(): // the editor closed our stdin: we are done131 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 = true225 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] = s291 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 even475 // 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 are476 // 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.
4040 if err != nil {
4141 return sdk.LoadSessionResponse{}, err
4242 }
43+ s.clientTools = f.sessionClientTools(ctx, p.SessionId, p.Cwd)
4344 for _, u := range replayUpdates(s.messages) {
4445 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: p.SessionId, Update: u})
4546 }
@@ -60,6 +61,7 @@ func (f *front) ResumeSession(ctx context.Context, p sdk.ResumeSessionRequest) (
6061 if err != nil {
6162 return sdk.ResumeSessionResponse{}, err
6263 }
64+ s.clientTools = f.sessionClientTools(ctx, p.SessionId, p.Cwd)
6365 f.sendCommands(ctx, p.SessionId)
6466 fmt.Fprintf(os.Stderr, "[acp] session %s resumed from %s: %d message(s)\n", p.SessionId, s.st.Dir(), len(s.messages)-1)
6567 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{}, err41 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{}, err62 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{}, nil67 return sdk.ResumeSessionResponse{}, nil
modified internal/config/config.go +61 -0
@@ -296,6 +296,55 @@ type ACPConfig struct {
296296 Name string `yaml:"name"`
297297 // Title: agentInfo.title, the name an editor shows. Empty = not sent.
298298 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
299348 }
300349
301350 // SessionsConfig is the `sessions:` block of the YAML.
@@ -667,6 +716,15 @@ which ones you left running.`,
667716 ACP: ACPConfig{
668717 Name: "bob",
669718 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+ },
670728 },
671729 Yolo: true,
672730 Context: ContextConfig{
@@ -914,6 +972,9 @@ func (c Config) validate() error {
914972 if err := c.MCP.Validate(); err != nil {
915973 return err
916974 }
975+ if err := c.ACP.ClientTools.validate(); err != nil {
976+ return err
977+ }
917978 if err := c.validateAgents(); err != nil {
918979 return err
919980 }
@@ -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 err973 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 err979 return err
919 }980 }
modified internal/config/config_test.go +44 -0
@@ -5,6 +5,7 @@ import (
55 "path/filepath"
66 "strings"
77 "testing"
8+ "time"
89 )
910
1011 // The sessions block is off by default and points at .mm/sessions, so a YAML
@@ -258,3 +259,46 @@ func TestUnloadModelsConfig(t *testing.T) {
258259 t.Error("unloadModels: false was not read")
259260 }
260261 }
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 YAML11 // 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(
204204 // Terminal path
205205 return toolTerminal(tctx, d, sc, mcpTool, fullName, in, permissions)
206206 },
207- ai.WithInputSchema(modelSchema(mcpTool.RawInputSchema)))
207+ ai.WithInputSchema(ModelSchema(mcpTool.RawInputSchema)))
208208 }
209209
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
211211 // the parameter names, types and descriptions the model needs to fill a call,
212212 // but nothing Genkit would reject a call for. Genkit validates a call against
213213 // the declared schema before running the tool, and a rejected call ends the
@@ -217,8 +217,12 @@ func wrapTool(
217217 // property's description, and `additionalProperties: false` and `$schema` go.
218218 // Without a declared schema the model sees no parameter at all: a model whose
219219 // 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 {
222226 s, _ := lenient(raw).(map[string]any)
223227 if s == nil {
224228 s = map[string]any{}
@@ -231,7 +235,7 @@ func modelSchema(raw map[string]any) map[string]any {
231235 }
232236
233237 // lenient returns a deep copy of a JSON schema node without the constraints
234-// modelSchema drops.
238+// ModelSchema drops.
235239 func lenient(node any) any {
236240 switch v := node.(type) {
237241 case map[string]any:
@@ -327,7 +331,7 @@ func toolEvented(
327331 }
328332
329333 // Truncate and return
330- truncated := truncate(out)
334+ truncated := Truncate(out)
331335 s.ToolEnd(ev.ID, ui.ToolResult{Output: truncated, Failed: failed})
332336
333337 return record(d, fullName, mcpTool.Name, truncated, failed)
@@ -358,7 +362,7 @@ func toolTerminal(
358362 }
359363
360364 // 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)
362366 }
363367
364368 // 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 {
414418 return missing
415419 }
416420
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 {
420427 max := config.Cfg.MaxOutput
421428 r := []rune(s)
422429 if len(r) <= max {
@@ -204,10 +204,10 @@ func wrapTool(
204 // Terminal path204 // 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, with210+// 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 against212 // 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 the213 // 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 whose218 // 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 on219 // 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 constraints237 // 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 return333 // 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 return364 // 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 missing418 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.go422+// 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.MaxOutput427 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
6363 "properties": map[string]any{"site": map[string]any{"type": "string"}}},
6464 },
6565 }
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)
6868 }
6969 // The source schema is left alone: callMCPTool still checks `required`.
7070 if _, ok := tool.RawInputSchema["required"]; !ok {
71- t.Error("modelSchema modified the raw schema")
71+ t.Error("ModelSchema modified the raw schema")
7272 }
7373 }
7474
7575 func TestModelSchemaWithoutASchemaIsAnEmptyObject(t *testing.T) {
7676 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)
7979 }
8080 }
8181
@@ -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 {
9393 // `required` nor `enum` as keywords: Genkit validates a call against the
9494 // declared schema before running the tool and a rejected call ends the
9595 // 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).
9797 func declaredSchema(t config.DeclaredTool) map[string]any {
9898 properties := map[string]any{}
9999 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 the93 // `required` nor `enum` as keywords: Genkit validates a call against the
94 // declared schema before running the tool and a rejected call ends the94 // 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() {
317317 // Three front ends, one wiring: everything above — config, engine, tools,
318318 // detector — is strictly identical whichever loop runs below.
319319 if *acpMode {
320- acp.Run(ctx, e, config.Cfg.System, agentTools, asker)
320+ acp.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker)
321321 return
322322 }
323323 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 return321 return
322 }322 }
323 if window {323 if window {