modified
.tickets/issues/0031-how-to-use-mini-me-with-gemini.yaml +3 -2 | @@ -6,8 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T04:01:15.166Z |
| 9 | -updatedAt: 2026-10-06T04:01:18.848Z | |
| 10 | -labels: [] | |
| 9 | +updatedAt: 2026-10-16T11:37:39.982Z | |
| 10 | +labels: | |
| 11 | + - wontfix | |
| 11 | 12 | epic: Providers (LLM Engine) |
| 12 | 13 | body: | |
| 13 | 14 | ## Analysis (Claude, 2026-09-21 — not implemented) |
| @@ -6,8 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T04:01:15.166Z | 8 | createdAt: 2026-09-21T04:01:15.166Z |
| 9 | -updatedAt: 2026-10-06T04:01:18.848Z | 9 | +updatedAt: 2026-10-16T11:37:39.982Z |
| 10 | -labels: [] | 10 | +labels: |
| 11 | + - wontfix | ||
| 11 | epic: Providers (LLM Engine) | 12 | epic: Providers (LLM Engine) |
| 12 | body: | | 13 | body: | |
| 13 | ## Analysis (Claude, 2026-09-21 — not implemented) | 14 | ## Analysis (Claude, 2026-09-21 — not implemented) |
modified
.tickets/issues/0048-add-a-builtin-tool-to-search-into-the-mm-session.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-27T03:45:48.121Z |
| 9 | -updatedAt: 2026-10-12T09:53:49.053Z | |
| 9 | +updatedAt: 2026-10-16T11:48:27.806Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.5.0 | |
| 11 | +milestone: v0.6.0 | |
| 12 | 12 | epic: Agent Memory |
| 13 | 13 | body: "" |
| 14 | 14 | tasks: [] |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-27T03:45:48.121Z | 8 | createdAt: 2026-09-27T03:45:48.121Z |
| 9 | -updatedAt: 2026-10-12T09:53:49.053Z | 9 | +updatedAt: 2026-10-16T11:48:27.806Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.5.0 | 11 | +milestone: v0.6.0 |
| 12 | epic: Agent Memory | 12 | epic: Agent Memory |
| 13 | body: "" | 13 | body: "" |
| 14 | tasks: [] | 14 | tasks: [] |
modified
.tickets/issues/0049-recreate-the-readme.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-28T18:44:44.821Z |
| 9 | -updatedAt: 2026-10-12T09:54:01.192Z | |
| 9 | +updatedAt: 2026-10-16T11:48:41.005Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.5.0 | |
| 11 | +milestone: v0.6.0 | |
| 12 | 12 | epic: ToDo |
| 13 | 13 | body: "" |
| 14 | 14 | tasks: [] |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-28T18:44:44.821Z | 8 | createdAt: 2026-09-28T18:44:44.821Z |
| 9 | -updatedAt: 2026-10-12T09:54:01.192Z | 9 | +updatedAt: 2026-10-16T11:48:41.005Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.5.0 | 11 | +milestone: v0.6.0 |
| 12 | epic: ToDo | 12 | epic: ToDo |
| 13 | body: "" | 13 | body: "" |
| 14 | tasks: [] | 14 | tasks: [] |
modified
.tickets/issues/0055-how-to-query-another-model-and-force-the-json-ou.yaml +3 -2 | @@ -1,13 +1,14 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 55 |
| 3 | 3 | title: How to query another model and force the JSON output (then use the result) |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-30T06:37:48.693Z |
| 9 | -updatedAt: 2026-10-06T13:14:33.928Z | |
| 9 | +updatedAt: 2026-10-16T11:36:43.967Z | |
| 10 | 10 | labels: [] |
| 11 | +milestone: v0.3.0 | |
| 11 | 12 | epic: Models |
| 12 | 13 | related: |
| 13 | 14 | - 54 |
| @@ -1,13 +1,14 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 55 | 2 | id: 55 |
| 3 | title: How to query another model and force the JSON output (then use the result) | 3 | title: How to query another model and force the JSON output (then use the result) |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-30T06:37:48.693Z | 8 | createdAt: 2026-09-30T06:37:48.693Z |
| 9 | -updatedAt: 2026-10-06T13:14:33.928Z | 9 | +updatedAt: 2026-10-16T11:36:43.967Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | +milestone: v0.3.0 | ||
| 11 | epic: Models | 12 | epic: Models |
| 12 | related: | 13 | related: |
| 13 | - 54 | 14 | - 54 |
modified
.tickets/issues/0087-hook-for-ask-agents.yaml +2 -2 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 87 |
| 3 | 3 | title: Hook for ask agents |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-15T21:55:43.205Z |
| 9 | -updatedAt: 2026-10-15T23:50:00.000Z | |
| 9 | +updatedAt: 2026-10-16T11:35:47.159Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.5.0 |
| 12 | 12 | body: | |
| @@ -1,12 +1,12 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 87 | 2 | id: 87 |
| 3 | title: Hook for ask agents | 3 | title: Hook for ask agents |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-15T21:55:43.205Z | 8 | createdAt: 2026-10-15T21:55:43.205Z |
| 9 | -updatedAt: 2026-10-15T23:50:00.000Z | 9 | +updatedAt: 2026-10-16T11:35:47.159Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.5.0 | 11 | milestone: v0.5.0 |
| 12 | body: | | 12 | body: | |
added
.tickets/issues/0088-custom-tools-declared-in-the-agent-file-command.yaml +143 -0 | new file mode 100644 | ||
| @@ -0,0 +1,143 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 88 | |
| 3 | +title: Custom tools declared in the agent file (command tools) | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-16T12:00:00.000Z | |
| 9 | +updatedAt: 2026-10-16T17:00:00.000Z | |
| 10 | +labels: | |
| 11 | + - kind::feature | |
| 12 | + - builtin tool | |
| 13 | +milestone: v0.6.0 | |
| 14 | +epic: Builtin tools | |
| 15 | +body: | | |
| 16 | + ## Question | |
| 17 | + Skills can already run commands (`read_skill`, then `bash`). Is it worth letting an agent author declare **typed tools** in the agent's YAML, or are skills enough? | |
| 18 | + | |
| 19 | + ## Why skills may not be enough (observed in this project) | |
| 20 | + - **Routing comes first.** The model has to pick the right skill before anything runs: `where is the Greet function defined?` routes to `greetings`, and `bg-jobs` misses in English on both models (eval, 2026-09-21). | |
| 21 | + - **Execution depends on the model.** With a ` ```bash ` fence, mellum2-12b reproduced the command instead of running it (3/3); qwen3.5-4b ran it (2026-09-21). | |
| 22 | + - **Two calls, the command rewritten by the model.** `read_skill`, then `bash` with a command line the model composes — each step can fail on a 4–12B model. | |
| 23 | + - **Consent is all or nothing.** Everything goes through `bash`, so "always allow this script" means "always allow bash". | |
| 24 | + | |
| 25 | + ## What already exists | |
| 26 | + MCP tools, declared with the server's input schema since 2026-10-12 — but **over HTTP only**: the stdio transport is not implemented (`internal/mcp/client.go`: "stdio transport for MCP is not yet implemented"). So a custom tool today is an HTTP server to write, start and keep running beside mm — heavy for "run this script with two arguments". | |
| 27 | + | |
| 28 | + ## Proposal (to be confirmed by the measurement below) | |
| 29 | + A `tools:` block in the agent file. Each entry is one command, its arguments passed as `argv` (no shell, so no injection through a parameter): | |
| 30 | + ```yaml | |
| 31 | + tools: | |
| 32 | + - name: run_tests | |
| 33 | + description: Run the Go tests of a package | |
| 34 | + parameters: | |
| 35 | + package: { type: string, description: "e.g. ./internal/lsp" } | |
| 36 | + command: ["go", "test", "{{package}}", "-count=1"] | |
| 37 | + ``` | |
| 38 | + Skills stay for procedures and know-how; declared tools for frequent, precise actions. | |
| 39 | + | |
| 40 | + ## Measure BEFORE building | |
| 41 | + The cheapest honest comparison needs no mm code: an MCP HTTP stand-in exposes the **same** tool (same description, schema) as the proposed `tools:` entry, so the model sees nearly the declaration the feature would produce. One difference: MCP tool names are prefixed with the server name (`<server>_<tool>`). | |
| 42 | + - **Conditions**: (A) skill + `bash` (current way, skill written by the rules in `reference/skill-format.md`: no fence, "call your `bash` tool"); (B) the same action as a declared tool (MCP stand-in), skill removed. | |
| 43 | + - **Cases**: 3–4 actions with arguments (e.g. run the tests of a package, a script with two parameters, one action whose skill name collides with a common word), each phrased 2 ways, one in French. | |
| 44 | + - **Models**: mellum2-12b (DMR) and one ~4B (qwen3.5-4b or Gemma 4 E4B). 5 runs per case and condition, `mm -p`, `yolo: true`. | |
| 45 | + - **Measured**: the command really ran with the right arguments (side effect checked by a file the script writes, not by the model's words); number of tool calls; wall time. | |
| 46 | + - **Decision rule**: build it if (B) beats (A) clearly on at least one model (e.g. ≥ 2 more successes out of 5 on several cases); otherwise close as "skills are enough", with the numbers. | |
| 47 | + | |
| 48 | + ## Open questions for the user (if built) | |
| 49 | + 1. Parameter types: strings only, or string/number/boolean/enum? | |
| 50 | + 2. Consent: one rule per declared tool (`consent.Policy` already remembers per tool) — default "ask" even with `yolo: true`, or follow `yolo`? | |
| 51 | + 3. Output: same truncation and timeout as `bash` (30 s budget), or per-tool `timeout`? | |
| 52 | + 4. Working directory: the project root, like `bash`? | |
| 53 | + 5. Relation to ticket 46 (tools as Wasm plugins): this is the plain-command version; keep 46 for sandboxed tools. | |
| 54 | + ## Measured (2026-10-16) | |
| 55 | + Setup: DMR, `mm -p`, `yolo: true`, `bashTool: true`, edit/plan/fetch tools off, `maxTurns: 6`, default system prompt. Three scripts that log their argv (`release <version> <channel>`, `pkgtest <package>`, `status <service>`). (A) three skills in the `reference/skill-format.md` style (no fence, "call your `bash` tool"); (B) no skills, an MCP HTTP stand-in (Python) exposing the same three actions as typed tools, `permissions: auto`. Success = the script's first logged call has exactly the expected arguments. 5 runs per cell. | |
| 56 | + | |
| 57 | + | model | case | A skill + bash | B typed tool | | |
| 58 | + | --- | --- | --- | --- | | |
| 59 | + | mellum2-12b | release EN / FR | 5/5 · 5/5 | 5/5 · **0/5** | | |
| 60 | + | mellum2-12b | pkgtest EN / FR | 5/5 · **0/5** | 5/5 · 5/5 | | |
| 61 | + | mellum2-12b | status EN / FR | **0/5 · 0/5** | 5/5 · 5/5 | | |
| 62 | + | qwen3.5-4b | all six | 30/30 | 30/30 | | |
| 63 | + | |
| 64 | + Totals: mellum2 A 15/30 (avg 4.5 s), B 25/30 (2.8 s); qwen3.5-4b A 30/30 (12.7 s), B 30/30 (7.7 s). B always takes one tool call, A two (`read_skill` + `bash`) when it works. | |
| 65 | + | |
| 66 | + What the failures are: | |
| 67 | + - **A, mellum2, status (10/10 failed)**: the skill is never read; the model improvises `curl localhost:8080/health`, `ps aux | grep billing`… and answers "the billing service seems to be running" — a wrong answer, not just a miss. The routing failure this ticket is about. | |
| 68 | + - **A, mellum2, pkgtest FR**: no skill read; the model explores (`ls`, `cat scripts/pkgtest.sh`) and runs `./pkgtest.sh greet` instead of `./greet`. | |
| 69 | + - **B, mellum2, release FR (5/5 failed)**: the model packs every argument into ONE field — `{version: "1.4.2 1.4.2, channel beta"}`, `channel` absent — the same mellum2 quirk as on `lsp` rename (2026-09-18). `validateRequiredParams` refuses the call before it reaches the server, the model repeats it, the loop detector stops the turn after 6. In `-p` mode the refusal text is not shown at all. | |
| 70 | + - qwen3.5-4b: no failure in either condition; B is ~40% faster (one model round-trip less). | |
| 71 | + | |
| 72 | + Reading: by the decision rule, **B wins** (mellum2: +5/5 on three cells, −5/5 on one; qwen: same success, faster). But typed tools bring their own failure mode on mellum2 — argument packing — that a command line in a skill does not have. If built, a declared tool needs (1) a refusal that tells the model what it sent and what is missing, and (2) maybe the `tools.normalize`-style recovery that `lsp` has. | |
| 73 | + | |
| 74 | + Caveats: one fixture, 3 actions, 2 phrasings, N=5, two models; the B tool names carry the `proj_` MCP prefix; the bench lived in the session scratchpad (not committed). | |
| 75 | + | |
| 76 | + Found on the way (separate from this ticket): with `yolo: true` and an MCP server left at the default `permissions: ask`, `-p` prints "[yolo is off and this mode cannot ask…]" — misleading, yolo IS on — and the model then loops on the refused call until the loop detector stops it. | |
| 77 | + ## Decisions (user, 2026-10-16) | |
| 78 | + 1. Build it (option B, typed tools). | |
| 79 | + 2. All parameter types: string, number, integer, boolean, plus `enum` on any of them. | |
| 80 | + 3. Consent follows `yolo`: no prompt when yolo is on. | |
| 81 | + Defaults taken for the remaining open questions (not overruled): working directory = bash's; timeout = `bashTimeout`, per-tool `timeout`; output = bash's truncation and echo; ticket 46 (Wasm) kept separate. | |
| 82 | + | |
| 83 | + ## Implemented (2026-10-16, branch `feature/declared-tools`) | |
| 84 | + - `internal/config/tools.go`: `DeclaredTool`, `ToolParameter`, `ToolParameters` (keeps file order via `UnmarshalYAML`), `BuiltinToolNames` (now also used by `mcp.BuiltinTools`, which lacked `fetch_content` and `ask_agent`), `Coerce`/`Allows`, `Placeholders`/`ReplacePlaceholders`, load-time validation. | |
| 85 | + - `internal/tools/declared.go`: `DeclaredTool(g, d, t)`; schema without `required`/`enum` keywords (as `mcp.modelSchema`); `buildArgv` with an invalid-call refusal that quotes the call back; terminal (`🧰`) and evented (`execute`) paths; `consent.Gate` / `confirmed`. | |
| 86 | + - `internal/tools/tools.go`: `runArgv` extracted from `runBash` (shared by both); a program that cannot be started is reported. | |
| 87 | + - `main.go`: `assembleDeclared`, after MCP; a name taken by an MCP tool is skipped with a warning. | |
| 88 | + - Not done: declared tools are not counted in the turn recap (`⚙ N command(s)` counts bash only) — would touch the three front ends. | |
| 89 | + | |
| 90 | + Measured once built (same bench, condition D = the real feature): **mellum2-12b 30/30 (1.3 s avg), qwen3.5-4b 30/30 (5.8 s)**, every run in ONE call, no invalid call. The French release request that failed 5/5 through the MCP stand-in succeeded 5/5; which difference matters (no `proj_` prefix, no "MCP tool from server" text, the "Give each parameter as its own field." line) was not isolated. | |
| 91 | + Demo: `demos/declared-tools/` (ops desk, four declared tools, every parameter type, mellum2 on DMR): 24/24 action requests in one call. Found there: an `enum` does not stop the model from silently swapping an unknown value for an allowed one ("alpha" → `beta`, 3/3) — fixed by the demo's system prompt. | |
| 92 | + | |
| 93 | +tasks: | |
| 94 | + - id: 1 | |
| 95 | + title: "Measure: MCP stand-in vs skill + bash, 2 models, 5 runs per case" | |
| 96 | + state: closed | |
| 97 | + priority: high | |
| 98 | + author: | |
| 99 | + name: k33g | |
| 100 | + email: ph.charriere@gmail.com | |
| 101 | + createdAt: 2026-10-16T12:00:00.000Z | |
| 102 | + updatedAt: 2026-10-16T14:30:00.000Z | |
| 103 | + closedAt: 2026-10-16T14:30:00.000Z | |
| 104 | + - id: 2 | |
| 105 | + title: "Decide with the user: build or close, answer the open questions" | |
| 106 | + state: closed | |
| 107 | + priority: high | |
| 108 | + author: | |
| 109 | + name: k33g | |
| 110 | + email: ph.charriere@gmail.com | |
| 111 | + createdAt: 2026-10-16T12:00:00.000Z | |
| 112 | + updatedAt: 2026-10-16T17:00:00.000Z | |
| 113 | + closedAt: 2026-10-16T17:00:00.000Z | |
| 114 | + - id: 3 | |
| 115 | + title: "Implement declared tools (config, tools, main) with tests" | |
| 116 | + state: closed | |
| 117 | + priority: high | |
| 118 | + author: | |
| 119 | + name: k33g | |
| 120 | + email: ph.charriere@gmail.com | |
| 121 | + createdAt: 2026-10-16T17:00:00.000Z | |
| 122 | + updatedAt: 2026-10-16T17:00:00.000Z | |
| 123 | + closedAt: 2026-10-16T17:00:00.000Z | |
| 124 | + - id: 4 | |
| 125 | + title: "Docs EN+FR: how-to, configuration + tools reference, explanation" | |
| 126 | + state: closed | |
| 127 | + priority: medium | |
| 128 | + author: | |
| 129 | + name: k33g | |
| 130 | + email: ph.charriere@gmail.com | |
| 131 | + createdAt: 2026-10-16T17:00:00.000Z | |
| 132 | + updatedAt: 2026-10-16T17:00:00.000Z | |
| 133 | + closedAt: 2026-10-16T17:00:00.000Z | |
| 134 | + - id: 5 | |
| 135 | + title: "Quality gate (qlty not installed in the 2026-10-16 sandbox — to run)" | |
| 136 | + state: open | |
| 137 | + priority: medium | |
| 138 | + author: | |
| 139 | + name: k33g | |
| 140 | + email: ph.charriere@gmail.com | |
| 141 | + createdAt: 2026-10-16T17:00:00.000Z | |
| 142 | + updatedAt: 2026-10-16T17:00:00.000Z | |
| 143 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,143 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 88 | ||
| 3 | +title: Custom tools declared in the agent file (command tools) | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-16T12:00:00.000Z | ||
| 9 | +updatedAt: 2026-10-16T17:00:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::feature | ||
| 12 | + - builtin tool | ||
| 13 | +milestone: v0.6.0 | ||
| 14 | +epic: Builtin tools | ||
| 15 | +body: | | ||
| 16 | + ## Question | ||
| 17 | + Skills can already run commands (`read_skill`, then `bash`). Is it worth letting an agent author declare **typed tools** in the agent's YAML, or are skills enough? | ||
| 18 | + | ||
| 19 | + ## Why skills may not be enough (observed in this project) | ||
| 20 | + - **Routing comes first.** The model has to pick the right skill before anything runs: `where is the Greet function defined?` routes to `greetings`, and `bg-jobs` misses in English on both models (eval, 2026-09-21). | ||
| 21 | + - **Execution depends on the model.** With a ` ```bash ` fence, mellum2-12b reproduced the command instead of running it (3/3); qwen3.5-4b ran it (2026-09-21). | ||
| 22 | + - **Two calls, the command rewritten by the model.** `read_skill`, then `bash` with a command line the model composes — each step can fail on a 4–12B model. | ||
| 23 | + - **Consent is all or nothing.** Everything goes through `bash`, so "always allow this script" means "always allow bash". | ||
| 24 | + | ||
| 25 | + ## What already exists | ||
| 26 | + MCP tools, declared with the server's input schema since 2026-10-12 — but **over HTTP only**: the stdio transport is not implemented (`internal/mcp/client.go`: "stdio transport for MCP is not yet implemented"). So a custom tool today is an HTTP server to write, start and keep running beside mm — heavy for "run this script with two arguments". | ||
| 27 | + | ||
| 28 | + ## Proposal (to be confirmed by the measurement below) | ||
| 29 | + A `tools:` block in the agent file. Each entry is one command, its arguments passed as `argv` (no shell, so no injection through a parameter): | ||
| 30 | + ```yaml | ||
| 31 | + tools: | ||
| 32 | + - name: run_tests | ||
| 33 | + description: Run the Go tests of a package | ||
| 34 | + parameters: | ||
| 35 | + package: { type: string, description: "e.g. ./internal/lsp" } | ||
| 36 | + command: ["go", "test", "{{package}}", "-count=1"] | ||
| 37 | + ``` | ||
| 38 | + Skills stay for procedures and know-how; declared tools for frequent, precise actions. | ||
| 39 | + | ||
| 40 | + ## Measure BEFORE building | ||
| 41 | + The cheapest honest comparison needs no mm code: an MCP HTTP stand-in exposes the **same** tool (same description, schema) as the proposed `tools:` entry, so the model sees nearly the declaration the feature would produce. One difference: MCP tool names are prefixed with the server name (`<server>_<tool>`). | ||
| 42 | + - **Conditions**: (A) skill + `bash` (current way, skill written by the rules in `reference/skill-format.md`: no fence, "call your `bash` tool"); (B) the same action as a declared tool (MCP stand-in), skill removed. | ||
| 43 | + - **Cases**: 3–4 actions with arguments (e.g. run the tests of a package, a script with two parameters, one action whose skill name collides with a common word), each phrased 2 ways, one in French. | ||
| 44 | + - **Models**: mellum2-12b (DMR) and one ~4B (qwen3.5-4b or Gemma 4 E4B). 5 runs per case and condition, `mm -p`, `yolo: true`. | ||
| 45 | + - **Measured**: the command really ran with the right arguments (side effect checked by a file the script writes, not by the model's words); number of tool calls; wall time. | ||
| 46 | + - **Decision rule**: build it if (B) beats (A) clearly on at least one model (e.g. ≥ 2 more successes out of 5 on several cases); otherwise close as "skills are enough", with the numbers. | ||
| 47 | + | ||
| 48 | + ## Open questions for the user (if built) | ||
| 49 | + 1. Parameter types: strings only, or string/number/boolean/enum? | ||
| 50 | + 2. Consent: one rule per declared tool (`consent.Policy` already remembers per tool) — default "ask" even with `yolo: true`, or follow `yolo`? | ||
| 51 | + 3. Output: same truncation and timeout as `bash` (30 s budget), or per-tool `timeout`? | ||
| 52 | + 4. Working directory: the project root, like `bash`? | ||
| 53 | + 5. Relation to ticket 46 (tools as Wasm plugins): this is the plain-command version; keep 46 for sandboxed tools. | ||
| 54 | + ## Measured (2026-10-16) | ||
| 55 | + Setup: DMR, `mm -p`, `yolo: true`, `bashTool: true`, edit/plan/fetch tools off, `maxTurns: 6`, default system prompt. Three scripts that log their argv (`release <version> <channel>`, `pkgtest <package>`, `status <service>`). (A) three skills in the `reference/skill-format.md` style (no fence, "call your `bash` tool"); (B) no skills, an MCP HTTP stand-in (Python) exposing the same three actions as typed tools, `permissions: auto`. Success = the script's first logged call has exactly the expected arguments. 5 runs per cell. | ||
| 56 | + | ||
| 57 | + | model | case | A skill + bash | B typed tool | | ||
| 58 | + | --- | --- | --- | --- | | ||
| 59 | + | mellum2-12b | release EN / FR | 5/5 · 5/5 | 5/5 · **0/5** | | ||
| 60 | + | mellum2-12b | pkgtest EN / FR | 5/5 · **0/5** | 5/5 · 5/5 | | ||
| 61 | + | mellum2-12b | status EN / FR | **0/5 · 0/5** | 5/5 · 5/5 | | ||
| 62 | + | qwen3.5-4b | all six | 30/30 | 30/30 | | ||
| 63 | + | ||
| 64 | + Totals: mellum2 A 15/30 (avg 4.5 s), B 25/30 (2.8 s); qwen3.5-4b A 30/30 (12.7 s), B 30/30 (7.7 s). B always takes one tool call, A two (`read_skill` + `bash`) when it works. | ||
| 65 | + | ||
| 66 | + What the failures are: | ||
| 67 | + - **A, mellum2, status (10/10 failed)**: the skill is never read; the model improvises `curl localhost:8080/health`, `ps aux | grep billing`… and answers "the billing service seems to be running" — a wrong answer, not just a miss. The routing failure this ticket is about. | ||
| 68 | + - **A, mellum2, pkgtest FR**: no skill read; the model explores (`ls`, `cat scripts/pkgtest.sh`) and runs `./pkgtest.sh greet` instead of `./greet`. | ||
| 69 | + - **B, mellum2, release FR (5/5 failed)**: the model packs every argument into ONE field — `{version: "1.4.2 1.4.2, channel beta"}`, `channel` absent — the same mellum2 quirk as on `lsp` rename (2026-09-18). `validateRequiredParams` refuses the call before it reaches the server, the model repeats it, the loop detector stops the turn after 6. In `-p` mode the refusal text is not shown at all. | ||
| 70 | + - qwen3.5-4b: no failure in either condition; B is ~40% faster (one model round-trip less). | ||
| 71 | + | ||
| 72 | + Reading: by the decision rule, **B wins** (mellum2: +5/5 on three cells, −5/5 on one; qwen: same success, faster). But typed tools bring their own failure mode on mellum2 — argument packing — that a command line in a skill does not have. If built, a declared tool needs (1) a refusal that tells the model what it sent and what is missing, and (2) maybe the `tools.normalize`-style recovery that `lsp` has. | ||
| 73 | + | ||
| 74 | + Caveats: one fixture, 3 actions, 2 phrasings, N=5, two models; the B tool names carry the `proj_` MCP prefix; the bench lived in the session scratchpad (not committed). | ||
| 75 | + | ||
| 76 | + Found on the way (separate from this ticket): with `yolo: true` and an MCP server left at the default `permissions: ask`, `-p` prints "[yolo is off and this mode cannot ask…]" — misleading, yolo IS on — and the model then loops on the refused call until the loop detector stops it. | ||
| 77 | + ## Decisions (user, 2026-10-16) | ||
| 78 | + 1. Build it (option B, typed tools). | ||
| 79 | + 2. All parameter types: string, number, integer, boolean, plus `enum` on any of them. | ||
| 80 | + 3. Consent follows `yolo`: no prompt when yolo is on. | ||
| 81 | + Defaults taken for the remaining open questions (not overruled): working directory = bash's; timeout = `bashTimeout`, per-tool `timeout`; output = bash's truncation and echo; ticket 46 (Wasm) kept separate. | ||
| 82 | + | ||
| 83 | + ## Implemented (2026-10-16, branch `feature/declared-tools`) | ||
| 84 | + - `internal/config/tools.go`: `DeclaredTool`, `ToolParameter`, `ToolParameters` (keeps file order via `UnmarshalYAML`), `BuiltinToolNames` (now also used by `mcp.BuiltinTools`, which lacked `fetch_content` and `ask_agent`), `Coerce`/`Allows`, `Placeholders`/`ReplacePlaceholders`, load-time validation. | ||
| 85 | + - `internal/tools/declared.go`: `DeclaredTool(g, d, t)`; schema without `required`/`enum` keywords (as `mcp.modelSchema`); `buildArgv` with an invalid-call refusal that quotes the call back; terminal (`🧰`) and evented (`execute`) paths; `consent.Gate` / `confirmed`. | ||
| 86 | + - `internal/tools/tools.go`: `runArgv` extracted from `runBash` (shared by both); a program that cannot be started is reported. | ||
| 87 | + - `main.go`: `assembleDeclared`, after MCP; a name taken by an MCP tool is skipped with a warning. | ||
| 88 | + - Not done: declared tools are not counted in the turn recap (`⚙ N command(s)` counts bash only) — would touch the three front ends. | ||
| 89 | + | ||
| 90 | + Measured once built (same bench, condition D = the real feature): **mellum2-12b 30/30 (1.3 s avg), qwen3.5-4b 30/30 (5.8 s)**, every run in ONE call, no invalid call. The French release request that failed 5/5 through the MCP stand-in succeeded 5/5; which difference matters (no `proj_` prefix, no "MCP tool from server" text, the "Give each parameter as its own field." line) was not isolated. | ||
| 91 | + Demo: `demos/declared-tools/` (ops desk, four declared tools, every parameter type, mellum2 on DMR): 24/24 action requests in one call. Found there: an `enum` does not stop the model from silently swapping an unknown value for an allowed one ("alpha" → `beta`, 3/3) — fixed by the demo's system prompt. | ||
| 92 | + | ||
| 93 | +tasks: | ||
| 94 | + - id: 1 | ||
| 95 | + title: "Measure: MCP stand-in vs skill + bash, 2 models, 5 runs per case" | ||
| 96 | + state: closed | ||
| 97 | + priority: high | ||
| 98 | + author: | ||
| 99 | + name: k33g | ||
| 100 | + email: ph.charriere@gmail.com | ||
| 101 | + createdAt: 2026-10-16T12:00:00.000Z | ||
| 102 | + updatedAt: 2026-10-16T14:30:00.000Z | ||
| 103 | + closedAt: 2026-10-16T14:30:00.000Z | ||
| 104 | + - id: 2 | ||
| 105 | + title: "Decide with the user: build or close, answer the open questions" | ||
| 106 | + state: closed | ||
| 107 | + priority: high | ||
| 108 | + author: | ||
| 109 | + name: k33g | ||
| 110 | + email: ph.charriere@gmail.com | ||
| 111 | + createdAt: 2026-10-16T12:00:00.000Z | ||
| 112 | + updatedAt: 2026-10-16T17:00:00.000Z | ||
| 113 | + closedAt: 2026-10-16T17:00:00.000Z | ||
| 114 | + - id: 3 | ||
| 115 | + title: "Implement declared tools (config, tools, main) with tests" | ||
| 116 | + state: closed | ||
| 117 | + priority: high | ||
| 118 | + author: | ||
| 119 | + name: k33g | ||
| 120 | + email: ph.charriere@gmail.com | ||
| 121 | + createdAt: 2026-10-16T17:00:00.000Z | ||
| 122 | + updatedAt: 2026-10-16T17:00:00.000Z | ||
| 123 | + closedAt: 2026-10-16T17:00:00.000Z | ||
| 124 | + - id: 4 | ||
| 125 | + title: "Docs EN+FR: how-to, configuration + tools reference, explanation" | ||
| 126 | + state: closed | ||
| 127 | + priority: medium | ||
| 128 | + author: | ||
| 129 | + name: k33g | ||
| 130 | + email: ph.charriere@gmail.com | ||
| 131 | + createdAt: 2026-10-16T17:00:00.000Z | ||
| 132 | + updatedAt: 2026-10-16T17:00:00.000Z | ||
| 133 | + closedAt: 2026-10-16T17:00:00.000Z | ||
| 134 | + - id: 5 | ||
| 135 | + title: "Quality gate (qlty not installed in the 2026-10-16 sandbox — to run)" | ||
| 136 | + state: open | ||
| 137 | + priority: medium | ||
| 138 | + author: | ||
| 139 | + name: k33g | ||
| 140 | + email: ph.charriere@gmail.com | ||
| 141 | + createdAt: 2026-10-16T17:00:00.000Z | ||
| 142 | + updatedAt: 2026-10-16T17:00:00.000Z | ||
| 143 | +comments: [] | ||
modified
.tickets/milestones.yaml +3 -0 | @@ -19,5 +19,8 @@ milestones: | ||
| 19 | 19 | state: closed |
| 20 | 20 | dueOn: 2026-10-16 |
| 21 | 21 | - title: v0.5.0 |
| 22 | + state: closed | |
| 23 | + dueOn: 2026-10-28 | |
| 24 | + - title: v0.6.0 | |
| 22 | 25 | state: open |
| 23 | 26 | dueOn: 2026-10-28 |
| @@ -19,5 +19,8 @@ milestones: | |||
| 19 | state: closed | 19 | state: closed |
| 20 | dueOn: 2026-10-16 | 20 | dueOn: 2026-10-16 |
| 21 | - title: v0.5.0 | 21 | - title: v0.5.0 |
| 22 | + state: closed | ||
| 23 | + dueOn: 2026-10-28 | ||
| 24 | + - title: v0.6.0 | ||
| 22 | state: open | 25 | state: open |
| 23 | dueOn: 2026-10-28 | 26 | dueOn: 2026-10-28 |
modified
README.md +1 -0 | @@ -145,6 +145,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | ||
| 145 | 145 | | `demos/hooks-ask/` | `preAsk` and `postAsk` hooks in Node.js around a help desk that hands technical questions to an expert agent (both Gemma 4 E4B on llmman): secrets masked before they leave, at most 2 `ask_agent` calls per question, standing rules added for the expert, a failed hand-off turned into an instruction, every hand-off logged to `.mm/ask-audit.jsonl` | |
| 146 | 146 | | `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results | |
| 147 | 147 | | `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results | |
| 148 | +| `demos/declared-tools/` | an ops desk with no `bash` and no skills, acting only through four tools declared under `tools:` — release, version bump, service logs, slow requests — covering every parameter type (string, enum, boolean, integer, number), a safe default (`dryRun: true`) and an optional argument; Mellum2 12B on DMR, measured results | | |
| 148 | 149 | |
| 149 | 150 | ## Learn more |
| 150 | 151 | |
| @@ -145,6 +145,7 @@ The `demos/` directory holds ready-made set-ups (most with a README explaining h | |||
| 145 | | `demos/hooks-ask/` | `preAsk` and `postAsk` hooks in Node.js around a help desk that hands technical questions to an expert agent (both Gemma 4 E4B on llmman): secrets masked before they leave, at most 2 `ask_agent` calls per question, standing rules added for the expert, a failed hand-off turned into an instruction, every hand-off logged to `.mm/ask-audit.jsonl` | | 145 | | `demos/hooks-ask/` | `preAsk` and `postAsk` hooks in Node.js around a help desk that hands technical questions to an expert agent (both Gemma 4 E4B on llmman): secrets masked before they leave, at most 2 `ask_agent` calls per question, standing rules added for the expert, a failed hand-off turned into an instruction, every hand-off logged to `.mm/ask-audit.jsonl` | |
| 146 | | `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results | | 146 | | `demos/dungeon/` | a solo role-playing game in the style of Dungeons & Dragons, same principle as `demos/team`: a Game Master hands each action to a storyteller, a referee or a scribe (Qwen3.5 4B on llmman for all four); real dice and the hero sheet in shell scripts, each specialist kept to its commands by a `preToolCall` hook; measured results | |
| 147 | | `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results | | 147 | | `demos/coding-helpers/` | interactive documentation for Go and Rust: a coordinator (MiniCPM5 2B) hands each question to a Go or a Rust specialist (Mellum2 12B thinking) with `ask_agent` and the conversation (`with_context`), so "the same thing in Rust" works; explanations and snippets, a full program only on request; measured results | |
| 148 | +| `demos/declared-tools/` | an ops desk with no `bash` and no skills, acting only through four tools declared under `tools:` — release, version bump, service logs, slow requests — covering every parameter type (string, enum, boolean, integer, number), a safe default (`dryRun: true`) and an optional argument; Mellum2 12B on DMR, measured results | | ||
| 148 | 149 | ||
| 149 | ## Learn more | 150 | ## Learn more |
| 150 | 151 | ||
added
declared_assemble_test.go +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "slices" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "mm/internal/config" | |
| 10 | + "mm/internal/detector" | |
| 11 | + "mm/internal/engine" | |
| 12 | + | |
| 13 | + "github.com/firebase/genkit/go/genkit" | |
| 14 | +) | |
| 15 | + | |
| 16 | +// A `tools:` entry is declared to the model under its own name. | |
| 17 | +func TestDeclaredToolsAreAssembled(t *testing.T) { | |
| 18 | + withConfig(t, func(c *config.Config) { | |
| 19 | + c.Tools = []config.DeclaredTool{{Name: "release", Description: "d", Command: []string{"./release.sh"}}} | |
| 20 | + }) | |
| 21 | + if names := assembledNames(t); !slices.Contains(names, "release") { | |
| 22 | + t.Errorf("names = %v, want release among them", names) | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +// A declared tool that would take an MCP tool's name is skipped with a | |
| 27 | +// warning; the others are still declared. | |
| 28 | +func TestDeclaredToolTakingAnMCPNameIsSkipped(t *testing.T) { | |
| 29 | + withConfig(t, func(c *config.Config) { | |
| 30 | + c.Tools = []config.DeclaredTool{ | |
| 31 | + {Name: "proj_release", Description: "d", Command: []string{"a"}}, | |
| 32 | + {Name: "status", Description: "d", Command: []string{"b"}}, | |
| 33 | + } | |
| 34 | + }) | |
| 35 | + e := &engine.Engine{G: genkit.Init(context.Background())} | |
| 36 | + set := toolSet{names: []string{"proj_release"}} | |
| 37 | + assembleDeclared(e, detector.NewLoopDetector(10, 3), &set) | |
| 38 | + if !slices.Equal(set.names, []string{"proj_release", "status"}) || len(set.tools) != 1 { | |
| 39 | + t.Errorf("names %v, %d tool(s)", set.names, len(set.tools)) | |
| 40 | + } | |
| 41 | + if len(set.warnings) != 1 || !strings.Contains(set.warnings[0], "proj_release skipped") { | |
| 42 | + t.Errorf("warnings = %v", set.warnings) | |
| 43 | + } | |
| 44 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "slices" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "mm/internal/config" | ||
| 10 | + "mm/internal/detector" | ||
| 11 | + "mm/internal/engine" | ||
| 12 | + | ||
| 13 | + "github.com/firebase/genkit/go/genkit" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +// A `tools:` entry is declared to the model under its own name. | ||
| 17 | +func TestDeclaredToolsAreAssembled(t *testing.T) { | ||
| 18 | + withConfig(t, func(c *config.Config) { | ||
| 19 | + c.Tools = []config.DeclaredTool{{Name: "release", Description: "d", Command: []string{"./release.sh"}}} | ||
| 20 | + }) | ||
| 21 | + if names := assembledNames(t); !slices.Contains(names, "release") { | ||
| 22 | + t.Errorf("names = %v, want release among them", names) | ||
| 23 | + } | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +// A declared tool that would take an MCP tool's name is skipped with a | ||
| 27 | +// warning; the others are still declared. | ||
| 28 | +func TestDeclaredToolTakingAnMCPNameIsSkipped(t *testing.T) { | ||
| 29 | + withConfig(t, func(c *config.Config) { | ||
| 30 | + c.Tools = []config.DeclaredTool{ | ||
| 31 | + {Name: "proj_release", Description: "d", Command: []string{"a"}}, | ||
| 32 | + {Name: "status", Description: "d", Command: []string{"b"}}, | ||
| 33 | + } | ||
| 34 | + }) | ||
| 35 | + e := &engine.Engine{G: genkit.Init(context.Background())} | ||
| 36 | + set := toolSet{names: []string{"proj_release"}} | ||
| 37 | + assembleDeclared(e, detector.NewLoopDetector(10, 3), &set) | ||
| 38 | + if !slices.Equal(set.names, []string{"proj_release", "status"}) || len(set.tools) != 1 { | ||
| 39 | + t.Errorf("names %v, %d tool(s)", set.names, len(set.tools)) | ||
| 40 | + } | ||
| 41 | + if len(set.warnings) != 1 || !strings.Contains(set.warnings[0], "proj_release skipped") { | ||
| 42 | + t.Errorf("warnings = %v", set.warnings) | ||
| 43 | + } | ||
| 44 | +} | ||
added
demos/declared-tools/.gitignore +2 -0 | new file mode 100644 | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +# written by a real release (scripts/release.sh) | |
| 2 | +releases.log | |
| new file mode 100644 | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | +# written by a real release (scripts/release.sh) | ||
| 2 | +releases.log | ||
added
demos/declared-tools/README.en.md +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +# Declared tools — an ops desk with four typed tools | |
| 2 | + | |
| 3 | +*[Version française](README.fr.md)* | |
| 4 | + | |
| 5 | +A small operations assistant for a web shop with three services (billing, orders, auth). It has **no `bash`, no file tools and no skills**: it can act only through four tools declared in `agent.yaml` under `tools:`, each one a script of `scripts/` run without a shell, with typed parameters. | |
| 6 | + | |
| 7 | +Model: **Mellum2 12B** (`huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`) on **Docker Model Runner**. | |
| 8 | + | |
| 9 | +## Run | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +docker model pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 13 | +cd demos/declared-tools | |
| 14 | +mm -p "Show me the last 5 lines of the billing log." | |
| 15 | +mm -p "Quelles requêtes ont pris plus de 2 secondes ?" | |
| 16 | +mm -p "Release 1.4.2 on the beta channel." | |
| 17 | +``` | |
| 18 | + | |
| 19 | +Run mm from this directory: the commands are relative (`./scripts/…`) and the scripts read `VERSION` and `logs/` here. A real release appends to `releases.log`; `bump_version` rewrites `VERSION` (`echo 1.4.1 > VERSION` puts it back). | |
| 20 | + | |
| 21 | +## The four tools | |
| 22 | + | |
| 23 | +| Tool | Parameters | Command | | |
| 24 | +|------|------------|---------| | |
| 25 | +| `release` | `version` string, `channel` enum `stable`/`beta`/`nightly`, `dryRun` boolean, optional, default `true` | `./scripts/release.sh {{version}} {{channel}} {{dryRun}}` | | |
| 26 | +| `bump_version` | `part` enum `major`/`minor`/`patch` | `./scripts/bump.sh {{part}}` | | |
| 27 | +| `service_logs` | `service` enum `billing`/`orders`/`auth`, `lines` integer, optional, default `10` | `./scripts/logs.sh {{service}} {{lines}}` | | |
| 28 | +| `slow_requests` | `threshold` number, `service` enum, optional, no default | `./scripts/slow.sh {{threshold}} {{service}}` | | |
| 29 | + | |
| 30 | +What the demo shows: | |
| 31 | + | |
| 32 | +- **Every parameter type**: string, enum, boolean, integer, number. | |
| 33 | +- **A default that makes the safe choice**: `dryRun` is `true` unless the user asks for a real release, so "Release 1.4.2 on beta" records nothing. | |
| 34 | +- **An optional argument that disappears**: `slow_requests` without `service` runs `./scripts/slow.sh 2` — the `{{service}}` entry is removed, and the script searches every log. | |
| 35 | +- **No shell**: a value never becomes a second command; a channel or a service outside its `enum` is refused before anything runs. | |
| 36 | +- **Consent follows `yolo`**: with `yolo: false` each call asks; in `-p` nobody can answer, so `AGENT_YOLO=false mm -p "Bump the minor version."` answers `[bump_version rejected by the user]` and `VERSION` is unchanged. | |
| 37 | + | |
| 38 | +## Seen on 2026-10-16 | |
| 39 | + | |
| 40 | +Ten requests, 3 runs each, `mm -p`, mellum2-12b on DMR. A run counts when the first `🧰` line is exactly the expected call (or, for the last two, when the request is not passed on with another value). | |
| 41 | + | |
| 42 | +| Request | Expected call | Result | Time | | |
| 43 | +|---------|---------------|--------|------| | |
| 44 | +| Show me the last 5 lines of the billing log. | `service_logs billing 5` | 3/3 | 6 s | | |
| 45 | +| Montre-moi les logs du service auth. | `service_logs auth 10` (default) | 3/3 | 9 s | | |
| 46 | +| Which orders requests took longer than 1.5 seconds? | `slow_requests 1.5 orders` | 3/3 | 6 s | | |
| 47 | +| Quelles requêtes ont pris plus de 2 secondes ? | `slow_requests 2` (no service) | 3/3 | 9 s | | |
| 48 | +| Release 1.4.2 on the beta channel. | `release 1.4.2 beta true` (default) | 3/3 | 2 s | | |
| 49 | +| Do the real release of 1.4.2 on stable, not a dry run. | `release 1.4.2 stable false` | 3/3 | 2 s | | |
| 50 | +| Bump the minor version. | `bump_version minor` | 3/3 | 2 s | | |
| 51 | +| Passe à la prochaine version patch. | `bump_version patch` | 3/3 | 2 s | | |
| 52 | +| Release 1.4.2 on the alpha channel. | no call: "The alpha channel does not exist…" | 3/3 | 1 s | | |
| 53 | +| Show me the payments service logs. | no call | 0/3 — calls `service_logs billing` and says "payments" is not a service | 14 s | | |
| 54 | + | |
| 55 | +Every successful run made one tool call. The time is the whole `mm -p` run, the model included. | |
| 56 | + | |
| 57 | +Two things found while building the demo: | |
| 58 | + | |
| 59 | +- **An `enum` does not stop a model from changing the request.** With the first system prompt, "Release 1.4.2 on the alpha channel" was called as `release 1.4.2 beta` three times out of three, with no word about alpha: the call was valid, so mm had nothing to refuse. Naming the channels in the system prompt and adding "When the user asks for a value a tool does not offer, do not call the tool with another value: say which values exist" fixed it (3/3). | |
| 60 | +- The same rule did not stop "payments" from becoming `billing` — `/api/payments` is in the billing log, so the guess is a reasonable one, and the answer says so. | |
| 61 | + | |
| 62 | +## Further reading | |
| 63 | + | |
| 64 | +- Step by step: [How to declare a custom tool](../../docs/en/how-to/declare-a-custom-tool.md) | |
| 65 | +- Every key and error: [configuration reference — `tools` keys](../../docs/en/reference/configuration.md#tools-keys) | |
| 66 | +- Why typed tools beside skills, and the measurements: [Declared tools](../../docs/en/explanation/declared-tools.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +# Declared tools — an ops desk with four typed tools | ||
| 2 | + | ||
| 3 | +*[Version française](README.fr.md)* | ||
| 4 | + | ||
| 5 | +A small operations assistant for a web shop with three services (billing, orders, auth). It has **no `bash`, no file tools and no skills**: it can act only through four tools declared in `agent.yaml` under `tools:`, each one a script of `scripts/` run without a shell, with typed parameters. | ||
| 6 | + | ||
| 7 | +Model: **Mellum2 12B** (`huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`) on **Docker Model Runner**. | ||
| 8 | + | ||
| 9 | +## Run | ||
| 10 | + | ||
| 11 | +```bash | ||
| 12 | +docker model pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 13 | +cd demos/declared-tools | ||
| 14 | +mm -p "Show me the last 5 lines of the billing log." | ||
| 15 | +mm -p "Quelles requêtes ont pris plus de 2 secondes ?" | ||
| 16 | +mm -p "Release 1.4.2 on the beta channel." | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +Run mm from this directory: the commands are relative (`./scripts/…`) and the scripts read `VERSION` and `logs/` here. A real release appends to `releases.log`; `bump_version` rewrites `VERSION` (`echo 1.4.1 > VERSION` puts it back). | ||
| 20 | + | ||
| 21 | +## The four tools | ||
| 22 | + | ||
| 23 | +| Tool | Parameters | Command | | ||
| 24 | +|------|------------|---------| | ||
| 25 | +| `release` | `version` string, `channel` enum `stable`/`beta`/`nightly`, `dryRun` boolean, optional, default `true` | `./scripts/release.sh {{version}} {{channel}} {{dryRun}}` | | ||
| 26 | +| `bump_version` | `part` enum `major`/`minor`/`patch` | `./scripts/bump.sh {{part}}` | | ||
| 27 | +| `service_logs` | `service` enum `billing`/`orders`/`auth`, `lines` integer, optional, default `10` | `./scripts/logs.sh {{service}} {{lines}}` | | ||
| 28 | +| `slow_requests` | `threshold` number, `service` enum, optional, no default | `./scripts/slow.sh {{threshold}} {{service}}` | | ||
| 29 | + | ||
| 30 | +What the demo shows: | ||
| 31 | + | ||
| 32 | +- **Every parameter type**: string, enum, boolean, integer, number. | ||
| 33 | +- **A default that makes the safe choice**: `dryRun` is `true` unless the user asks for a real release, so "Release 1.4.2 on beta" records nothing. | ||
| 34 | +- **An optional argument that disappears**: `slow_requests` without `service` runs `./scripts/slow.sh 2` — the `{{service}}` entry is removed, and the script searches every log. | ||
| 35 | +- **No shell**: a value never becomes a second command; a channel or a service outside its `enum` is refused before anything runs. | ||
| 36 | +- **Consent follows `yolo`**: with `yolo: false` each call asks; in `-p` nobody can answer, so `AGENT_YOLO=false mm -p "Bump the minor version."` answers `[bump_version rejected by the user]` and `VERSION` is unchanged. | ||
| 37 | + | ||
| 38 | +## Seen on 2026-10-16 | ||
| 39 | + | ||
| 40 | +Ten requests, 3 runs each, `mm -p`, mellum2-12b on DMR. A run counts when the first `🧰` line is exactly the expected call (or, for the last two, when the request is not passed on with another value). | ||
| 41 | + | ||
| 42 | +| Request | Expected call | Result | Time | | ||
| 43 | +|---------|---------------|--------|------| | ||
| 44 | +| Show me the last 5 lines of the billing log. | `service_logs billing 5` | 3/3 | 6 s | | ||
| 45 | +| Montre-moi les logs du service auth. | `service_logs auth 10` (default) | 3/3 | 9 s | | ||
| 46 | +| Which orders requests took longer than 1.5 seconds? | `slow_requests 1.5 orders` | 3/3 | 6 s | | ||
| 47 | +| Quelles requêtes ont pris plus de 2 secondes ? | `slow_requests 2` (no service) | 3/3 | 9 s | | ||
| 48 | +| Release 1.4.2 on the beta channel. | `release 1.4.2 beta true` (default) | 3/3 | 2 s | | ||
| 49 | +| Do the real release of 1.4.2 on stable, not a dry run. | `release 1.4.2 stable false` | 3/3 | 2 s | | ||
| 50 | +| Bump the minor version. | `bump_version minor` | 3/3 | 2 s | | ||
| 51 | +| Passe à la prochaine version patch. | `bump_version patch` | 3/3 | 2 s | | ||
| 52 | +| Release 1.4.2 on the alpha channel. | no call: "The alpha channel does not exist…" | 3/3 | 1 s | | ||
| 53 | +| Show me the payments service logs. | no call | 0/3 — calls `service_logs billing` and says "payments" is not a service | 14 s | | ||
| 54 | + | ||
| 55 | +Every successful run made one tool call. The time is the whole `mm -p` run, the model included. | ||
| 56 | + | ||
| 57 | +Two things found while building the demo: | ||
| 58 | + | ||
| 59 | +- **An `enum` does not stop a model from changing the request.** With the first system prompt, "Release 1.4.2 on the alpha channel" was called as `release 1.4.2 beta` three times out of three, with no word about alpha: the call was valid, so mm had nothing to refuse. Naming the channels in the system prompt and adding "When the user asks for a value a tool does not offer, do not call the tool with another value: say which values exist" fixed it (3/3). | ||
| 60 | +- The same rule did not stop "payments" from becoming `billing` — `/api/payments` is in the billing log, so the guess is a reasonable one, and the answer says so. | ||
| 61 | + | ||
| 62 | +## Further reading | ||
| 63 | + | ||
| 64 | +- Step by step: [How to declare a custom tool](../../docs/en/how-to/declare-a-custom-tool.md) | ||
| 65 | +- Every key and error: [configuration reference — `tools` keys](../../docs/en/reference/configuration.md#tools-keys) | ||
| 66 | +- Why typed tools beside skills, and the measurements: [Declared tools](../../docs/en/explanation/declared-tools.md) | ||
added
demos/declared-tools/README.fr.md +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +# Outils déclarés — un guichet d'exploitation avec quatre outils typés | |
| 2 | + | |
| 3 | +*[English version](README.en.md)* | |
| 4 | + | |
| 5 | +Un petit assistant d'exploitation pour une boutique en ligne à trois services (billing, orders, auth). Il n'a **ni `bash`, ni outils de fichiers, ni skills** : il ne peut agir que par quatre outils déclarés dans `agent.yaml` sous `tools:`, chacun un script de `scripts/` exécuté sans shell, avec des paramètres typés. | |
| 6 | + | |
| 7 | +Modèle : **Mellum2 12B** (`huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`) sur **Docker Model Runner**. | |
| 8 | + | |
| 9 | +## Lancer | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +docker model pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 13 | +cd demos/declared-tools | |
| 14 | +mm -p "Show me the last 5 lines of the billing log." | |
| 15 | +mm -p "Quelles requêtes ont pris plus de 2 secondes ?" | |
| 16 | +mm -p "Release 1.4.2 on the beta channel." | |
| 17 | +``` | |
| 18 | + | |
| 19 | +Lancez mm depuis ce répertoire : les commandes sont relatives (`./scripts/…`) et les scripts lisent `VERSION` et `logs/` ici. Une vraie release ajoute une ligne à `releases.log` ; `bump_version` réécrit `VERSION` (`echo 1.4.1 > VERSION` le remet en place). | |
| 20 | + | |
| 21 | +## Les quatre outils | |
| 22 | + | |
| 23 | +| Outil | Paramètres | Commande | | |
| 24 | +|-------|------------|----------| | |
| 25 | +| `release` | `version` chaîne, `channel` enum `stable`/`beta`/`nightly`, `dryRun` booléen, facultatif, défaut `true` | `./scripts/release.sh {{version}} {{channel}} {{dryRun}}` | | |
| 26 | +| `bump_version` | `part` enum `major`/`minor`/`patch` | `./scripts/bump.sh {{part}}` | | |
| 27 | +| `service_logs` | `service` enum `billing`/`orders`/`auth`, `lines` entier, facultatif, défaut `10` | `./scripts/logs.sh {{service}} {{lines}}` | | |
| 28 | +| `slow_requests` | `threshold` nombre, `service` enum, facultatif, sans défaut | `./scripts/slow.sh {{threshold}} {{service}}` | | |
| 29 | + | |
| 30 | +Ce que montre la démo : | |
| 31 | + | |
| 32 | +- **Tous les types de paramètres** : chaîne, enum, booléen, entier, nombre. | |
| 33 | +- **Un défaut qui fait le choix prudent** : `dryRun` vaut `true` sauf si l'utilisateur demande une vraie release, donc « Release 1.4.2 on beta » n'enregistre rien. | |
| 34 | +- **Un argument facultatif qui disparaît** : `slow_requests` sans `service` lance `./scripts/slow.sh 2` — l'entrée `{{service}}` est retirée, et le script cherche dans tous les logs. | |
| 35 | +- **Pas de shell** : une valeur ne devient jamais une seconde commande ; un canal ou un service hors de son `enum` est refusé avant que rien ne s'exécute. | |
| 36 | +- **Le consentement suit `yolo`** : avec `yolo: false`, chaque appel demande ; en `-p` personne ne peut répondre, donc `AGENT_YOLO=false mm -p "Bump the minor version."` répond `[bump_version rejected by the user]` et `VERSION` ne change pas. | |
| 37 | + | |
| 38 | +## Constaté le 2026-10-16 | |
| 39 | + | |
| 40 | +Dix demandes, 3 runs chacune, `mm -p`, mellum2-12b sur DMR. Un run compte quand la première ligne `🧰` est exactement l'appel attendu (ou, pour les deux dernières, quand la demande n'est pas transmise avec une autre valeur). | |
| 41 | + | |
| 42 | +| Demande | Appel attendu | Résultat | Durée | | |
| 43 | +|---------|---------------|----------|-------| | |
| 44 | +| Show me the last 5 lines of the billing log. | `service_logs billing 5` | 3/3 | 6 s | | |
| 45 | +| Montre-moi les logs du service auth. | `service_logs auth 10` (défaut) | 3/3 | 9 s | | |
| 46 | +| Which orders requests took longer than 1.5 seconds? | `slow_requests 1.5 orders` | 3/3 | 6 s | | |
| 47 | +| Quelles requêtes ont pris plus de 2 secondes ? | `slow_requests 2` (sans service) | 3/3 | 9 s | | |
| 48 | +| Release 1.4.2 on the beta channel. | `release 1.4.2 beta true` (défaut) | 3/3 | 2 s | | |
| 49 | +| Do the real release of 1.4.2 on stable, not a dry run. | `release 1.4.2 stable false` | 3/3 | 2 s | | |
| 50 | +| Bump the minor version. | `bump_version minor` | 3/3 | 2 s | | |
| 51 | +| Passe à la prochaine version patch. | `bump_version patch` | 3/3 | 2 s | | |
| 52 | +| Release 1.4.2 on the alpha channel. | aucun appel : « The alpha channel does not exist… » | 3/3 | 1 s | | |
| 53 | +| Show me the payments service logs. | aucun appel | 0/3 — appelle `service_logs billing` et dit que « payments » n'est pas un service | 14 s | | |
| 54 | + | |
| 55 | +Chaque run réussi a fait un seul appel d'outil. La durée est celle de tout le run `mm -p`, modèle compris. | |
| 56 | + | |
| 57 | +Deux constats faits en construisant la démo : | |
| 58 | + | |
| 59 | +- **Un `enum` n'empêche pas un modèle de changer la demande.** Avec le premier prompt système, « Release 1.4.2 on the alpha channel » a été appelé `release 1.4.2 beta` trois fois sur trois, sans un mot sur alpha : l'appel était valide, mm n'avait rien à refuser. Nommer les canaux dans le prompt système et ajouter « When the user asks for a value a tool does not offer, do not call the tool with another value: say which values exist » a réglé le problème (3/3). | |
| 60 | +- La même règle n'a pas empêché « payments » de devenir `billing` — `/api/payments` est dans le log de billing, donc la supposition se défend, et la réponse le dit. | |
| 61 | + | |
| 62 | +## Pour aller plus loin | |
| 63 | + | |
| 64 | +- Pas à pas : [Comment déclarer un outil personnalisé](../../docs/fr/how-to/declare-a-custom-tool.md) | |
| 65 | +- Chaque clé et chaque erreur : [référence de configuration — clés de `tools`](../../docs/fr/reference/configuration.md#clés-de-tools) | |
| 66 | +- Pourquoi des outils typés à côté des skills, et les mesures : [Les outils déclarés](../../docs/fr/explanation/declared-tools.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +# Outils déclarés — un guichet d'exploitation avec quatre outils typés | ||
| 2 | + | ||
| 3 | +*[English version](README.en.md)* | ||
| 4 | + | ||
| 5 | +Un petit assistant d'exploitation pour une boutique en ligne à trois services (billing, orders, auth). Il n'a **ni `bash`, ni outils de fichiers, ni skills** : il ne peut agir que par quatre outils déclarés dans `agent.yaml` sous `tools:`, chacun un script de `scripts/` exécuté sans shell, avec des paramètres typés. | ||
| 6 | + | ||
| 7 | +Modèle : **Mellum2 12B** (`huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`) sur **Docker Model Runner**. | ||
| 8 | + | ||
| 9 | +## Lancer | ||
| 10 | + | ||
| 11 | +```bash | ||
| 12 | +docker model pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 13 | +cd demos/declared-tools | ||
| 14 | +mm -p "Show me the last 5 lines of the billing log." | ||
| 15 | +mm -p "Quelles requêtes ont pris plus de 2 secondes ?" | ||
| 16 | +mm -p "Release 1.4.2 on the beta channel." | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +Lancez mm depuis ce répertoire : les commandes sont relatives (`./scripts/…`) et les scripts lisent `VERSION` et `logs/` ici. Une vraie release ajoute une ligne à `releases.log` ; `bump_version` réécrit `VERSION` (`echo 1.4.1 > VERSION` le remet en place). | ||
| 20 | + | ||
| 21 | +## Les quatre outils | ||
| 22 | + | ||
| 23 | +| Outil | Paramètres | Commande | | ||
| 24 | +|-------|------------|----------| | ||
| 25 | +| `release` | `version` chaîne, `channel` enum `stable`/`beta`/`nightly`, `dryRun` booléen, facultatif, défaut `true` | `./scripts/release.sh {{version}} {{channel}} {{dryRun}}` | | ||
| 26 | +| `bump_version` | `part` enum `major`/`minor`/`patch` | `./scripts/bump.sh {{part}}` | | ||
| 27 | +| `service_logs` | `service` enum `billing`/`orders`/`auth`, `lines` entier, facultatif, défaut `10` | `./scripts/logs.sh {{service}} {{lines}}` | | ||
| 28 | +| `slow_requests` | `threshold` nombre, `service` enum, facultatif, sans défaut | `./scripts/slow.sh {{threshold}} {{service}}` | | ||
| 29 | + | ||
| 30 | +Ce que montre la démo : | ||
| 31 | + | ||
| 32 | +- **Tous les types de paramètres** : chaîne, enum, booléen, entier, nombre. | ||
| 33 | +- **Un défaut qui fait le choix prudent** : `dryRun` vaut `true` sauf si l'utilisateur demande une vraie release, donc « Release 1.4.2 on beta » n'enregistre rien. | ||
| 34 | +- **Un argument facultatif qui disparaît** : `slow_requests` sans `service` lance `./scripts/slow.sh 2` — l'entrée `{{service}}` est retirée, et le script cherche dans tous les logs. | ||
| 35 | +- **Pas de shell** : une valeur ne devient jamais une seconde commande ; un canal ou un service hors de son `enum` est refusé avant que rien ne s'exécute. | ||
| 36 | +- **Le consentement suit `yolo`** : avec `yolo: false`, chaque appel demande ; en `-p` personne ne peut répondre, donc `AGENT_YOLO=false mm -p "Bump the minor version."` répond `[bump_version rejected by the user]` et `VERSION` ne change pas. | ||
| 37 | + | ||
| 38 | +## Constaté le 2026-10-16 | ||
| 39 | + | ||
| 40 | +Dix demandes, 3 runs chacune, `mm -p`, mellum2-12b sur DMR. Un run compte quand la première ligne `🧰` est exactement l'appel attendu (ou, pour les deux dernières, quand la demande n'est pas transmise avec une autre valeur). | ||
| 41 | + | ||
| 42 | +| Demande | Appel attendu | Résultat | Durée | | ||
| 43 | +|---------|---------------|----------|-------| | ||
| 44 | +| Show me the last 5 lines of the billing log. | `service_logs billing 5` | 3/3 | 6 s | | ||
| 45 | +| Montre-moi les logs du service auth. | `service_logs auth 10` (défaut) | 3/3 | 9 s | | ||
| 46 | +| Which orders requests took longer than 1.5 seconds? | `slow_requests 1.5 orders` | 3/3 | 6 s | | ||
| 47 | +| Quelles requêtes ont pris plus de 2 secondes ? | `slow_requests 2` (sans service) | 3/3 | 9 s | | ||
| 48 | +| Release 1.4.2 on the beta channel. | `release 1.4.2 beta true` (défaut) | 3/3 | 2 s | | ||
| 49 | +| Do the real release of 1.4.2 on stable, not a dry run. | `release 1.4.2 stable false` | 3/3 | 2 s | | ||
| 50 | +| Bump the minor version. | `bump_version minor` | 3/3 | 2 s | | ||
| 51 | +| Passe à la prochaine version patch. | `bump_version patch` | 3/3 | 2 s | | ||
| 52 | +| Release 1.4.2 on the alpha channel. | aucun appel : « The alpha channel does not exist… » | 3/3 | 1 s | | ||
| 53 | +| Show me the payments service logs. | aucun appel | 0/3 — appelle `service_logs billing` et dit que « payments » n'est pas un service | 14 s | | ||
| 54 | + | ||
| 55 | +Chaque run réussi a fait un seul appel d'outil. La durée est celle de tout le run `mm -p`, modèle compris. | ||
| 56 | + | ||
| 57 | +Deux constats faits en construisant la démo : | ||
| 58 | + | ||
| 59 | +- **Un `enum` n'empêche pas un modèle de changer la demande.** Avec le premier prompt système, « Release 1.4.2 on the alpha channel » a été appelé `release 1.4.2 beta` trois fois sur trois, sans un mot sur alpha : l'appel était valide, mm n'avait rien à refuser. Nommer les canaux dans le prompt système et ajouter « When the user asks for a value a tool does not offer, do not call the tool with another value: say which values exist » a réglé le problème (3/3). | ||
| 60 | +- La même règle n'a pas empêché « payments » de devenir `billing` — `/api/payments` est dans le log de billing, donc la supposition se défend, et la réponse le dit. | ||
| 61 | + | ||
| 62 | +## Pour aller plus loin | ||
| 63 | + | ||
| 64 | +- Pas à pas : [Comment déclarer un outil personnalisé](../../docs/fr/how-to/declare-a-custom-tool.md) | ||
| 65 | +- Chaque clé et chaque erreur : [référence de configuration — clés de `tools`](../../docs/fr/reference/configuration.md#clés-de-tools) | ||
| 66 | +- Pourquoi des outils typés à côté des skills, et les mesures : [Les outils déclarés](../../docs/fr/explanation/declared-tools.md) | ||
added
demos/declared-tools/VERSION +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +1.4.1 | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +1.4.1 | ||
added
demos/declared-tools/agent.yaml +62 -0 | new file mode 100644 | ||
| @@ -0,0 +1,62 @@ | ||
| 1 | +# Agent settings — "ops desk": a small operations assistant that can act | |
| 2 | +# ONLY through four declared tools (ticket 88): no bash, no file tools, no | |
| 3 | +# skills. Each tool is a script of ./scripts, run without a shell, with typed | |
| 4 | +# parameters — string, enum, boolean, integer, number. mini-me on Mellum2 12B, | |
| 5 | +# served by Docker Model Runner. | |
| 6 | +# | |
| 7 | +# docker model pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 8 | +# cd demos/declared-tools | |
| 9 | +# mm -p "Show me the last 5 lines of the billing log." | |
| 10 | +provider: dmr | |
| 11 | +model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 12 | +fallback: http://host.docker.internal:12434/engines/v1 | |
| 13 | + | |
| 14 | +# The four declared tools below are the agent's only way to act. | |
| 15 | +bashTool: false | |
| 16 | +editTools: false | |
| 17 | +planTool: false | |
| 18 | +fetchContentTool: false | |
| 19 | +skillsDir: no-skills | |
| 20 | +# true: the tools run without asking. Set it to false to confirm each call | |
| 21 | +# (in -p nobody can answer, so every call is then refused). | |
| 22 | +yolo: true | |
| 23 | +maxTurns: 6 | |
| 24 | + | |
| 25 | +system: | | |
| 26 | + You are the ops desk of a small web shop with three services: billing, | |
| 27 | + orders and auth, released on three channels: stable, beta and nightly. You act only through your tools; never invent an output. | |
| 28 | + Call the tool that matches the request, then answer in one or two | |
| 29 | + sentences, in the user's language, from what the tool returned. | |
| 30 | + A release is a dry run unless the user clearly asks for a real one. | |
| 31 | + When the user asks for a value a tool does not offer (a channel, a | |
| 32 | + service), do not call the tool with another value: say which values exist. | |
| 33 | + | |
| 34 | +tools: | |
| 35 | + - name: release | |
| 36 | + description: Release a version of the shop on a channel. A dry run says what would happen and records nothing. | |
| 37 | + parameters: | |
| 38 | + version: { type: string, description: "The version to release, e.g. 1.4.2" } | |
| 39 | + channel: { type: string, enum: [stable, beta, nightly] } | |
| 40 | + dryRun: { type: boolean, required: false, default: true, description: "false only when the user asks for a real release" } | |
| 41 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "{{dryRun}}"] | |
| 42 | + | |
| 43 | + - name: bump_version | |
| 44 | + description: Raise the version number written in the VERSION file and show the old and new version. | |
| 45 | + parameters: | |
| 46 | + part: { type: string, enum: [major, minor, patch], description: "Which part of the version to raise" } | |
| 47 | + command: ["./scripts/bump.sh", "{{part}}"] | |
| 48 | + | |
| 49 | + - name: service_logs | |
| 50 | + description: Show the last lines of one service's request log. | |
| 51 | + parameters: | |
| 52 | + service: { type: string, enum: [billing, orders, auth] } | |
| 53 | + lines: { type: integer, required: false, default: 10, description: "How many lines" } | |
| 54 | + command: ["./scripts/logs.sh", "{{service}}", "{{lines}}"] | |
| 55 | + | |
| 56 | + - name: slow_requests | |
| 57 | + description: List the requests slower than a threshold, in every service or in one. | |
| 58 | + parameters: | |
| 59 | + threshold: { type: number, description: "Duration in seconds, e.g. 1.5" } | |
| 60 | + service: { type: string, required: false, enum: [billing, orders, auth], description: "Leave out to search every service" } | |
| 61 | + command: ["./scripts/slow.sh", "{{threshold}}", "{{service}}"] | |
| 62 | + timeout: 10s | |
| new file mode 100644 | |||
| @@ -0,0 +1,62 @@ | |||
| 1 | +# Agent settings — "ops desk": a small operations assistant that can act | ||
| 2 | +# ONLY through four declared tools (ticket 88): no bash, no file tools, no | ||
| 3 | +# skills. Each tool is a script of ./scripts, run without a shell, with typed | ||
| 4 | +# parameters — string, enum, boolean, integer, number. mini-me on Mellum2 12B, | ||
| 5 | +# served by Docker Model Runner. | ||
| 6 | +# | ||
| 7 | +# docker model pull hf.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 8 | +# cd demos/declared-tools | ||
| 9 | +# mm -p "Show me the last 5 lines of the billing log." | ||
| 10 | +provider: dmr | ||
| 11 | +model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 12 | +fallback: http://host.docker.internal:12434/engines/v1 | ||
| 13 | + | ||
| 14 | +# The four declared tools below are the agent's only way to act. | ||
| 15 | +bashTool: false | ||
| 16 | +editTools: false | ||
| 17 | +planTool: false | ||
| 18 | +fetchContentTool: false | ||
| 19 | +skillsDir: no-skills | ||
| 20 | +# true: the tools run without asking. Set it to false to confirm each call | ||
| 21 | +# (in -p nobody can answer, so every call is then refused). | ||
| 22 | +yolo: true | ||
| 23 | +maxTurns: 6 | ||
| 24 | + | ||
| 25 | +system: | | ||
| 26 | + You are the ops desk of a small web shop with three services: billing, | ||
| 27 | + orders and auth, released on three channels: stable, beta and nightly. You act only through your tools; never invent an output. | ||
| 28 | + Call the tool that matches the request, then answer in one or two | ||
| 29 | + sentences, in the user's language, from what the tool returned. | ||
| 30 | + A release is a dry run unless the user clearly asks for a real one. | ||
| 31 | + When the user asks for a value a tool does not offer (a channel, a | ||
| 32 | + service), do not call the tool with another value: say which values exist. | ||
| 33 | + | ||
| 34 | +tools: | ||
| 35 | + - name: release | ||
| 36 | + description: Release a version of the shop on a channel. A dry run says what would happen and records nothing. | ||
| 37 | + parameters: | ||
| 38 | + version: { type: string, description: "The version to release, e.g. 1.4.2" } | ||
| 39 | + channel: { type: string, enum: [stable, beta, nightly] } | ||
| 40 | + dryRun: { type: boolean, required: false, default: true, description: "false only when the user asks for a real release" } | ||
| 41 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "{{dryRun}}"] | ||
| 42 | + | ||
| 43 | + - name: bump_version | ||
| 44 | + description: Raise the version number written in the VERSION file and show the old and new version. | ||
| 45 | + parameters: | ||
| 46 | + part: { type: string, enum: [major, minor, patch], description: "Which part of the version to raise" } | ||
| 47 | + command: ["./scripts/bump.sh", "{{part}}"] | ||
| 48 | + | ||
| 49 | + - name: service_logs | ||
| 50 | + description: Show the last lines of one service's request log. | ||
| 51 | + parameters: | ||
| 52 | + service: { type: string, enum: [billing, orders, auth] } | ||
| 53 | + lines: { type: integer, required: false, default: 10, description: "How many lines" } | ||
| 54 | + command: ["./scripts/logs.sh", "{{service}}", "{{lines}}"] | ||
| 55 | + | ||
| 56 | + - name: slow_requests | ||
| 57 | + description: List the requests slower than a threshold, in every service or in one. | ||
| 58 | + parameters: | ||
| 59 | + threshold: { type: number, description: "Duration in seconds, e.g. 1.5" } | ||
| 60 | + service: { type: string, required: false, enum: [billing, orders, auth], description: "Leave out to search every service" } | ||
| 61 | + command: ["./scripts/slow.sh", "{{threshold}}", "{{service}}"] | ||
| 62 | + timeout: 10s | ||
added
demos/declared-tools/logs/auth.log +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +2026-10-16T09:00:35Z POST /token 200 0.065s | |
| 2 | +2026-10-16T09:01:41Z GET /login 200 0.29s | |
| 3 | +2026-10-16T09:02:42Z POST /logout 200 0.124s | |
| 4 | +2026-10-16T09:03:21Z POST /logout 200 0.171s | |
| 5 | +2026-10-16T09:04:57Z POST /logout 200 0.161s | |
| 6 | +2026-10-16T09:05:28Z POST /login 200 0.294s | |
| 7 | +2026-10-16T09:06:09Z POST /logout 200 0.233s | |
| 8 | +2026-10-16T09:07:46Z GET /token 200 0.244s | |
| 9 | +2026-10-16T09:08:38Z GET /login 200 2.976s | |
| 10 | +2026-10-16T09:09:15Z POST /token 200 0.07s | |
| 11 | +2026-10-16T09:10:21Z GET /login 200 1.491s | |
| 12 | +2026-10-16T09:11:33Z GET /login 200 0.262s | |
| 13 | +2026-10-16T09:12:57Z POST /logout 200 0.193s | |
| 14 | +2026-10-16T09:13:02Z POST /token 200 0.131s | |
| 15 | +2026-10-16T09:14:32Z GET /login 200 0.133s | |
| 16 | +2026-10-16T09:15:42Z POST /token 200 0.039s | |
| 17 | +2026-10-16T09:16:59Z POST /token 200 0.261s | |
| 18 | +2026-10-16T09:17:24Z POST /logout 200 0.069s | |
| 19 | +2026-10-16T09:18:22Z GET /login 200 0.175s | |
| 20 | +2026-10-16T09:19:44Z GET /logout 200 0.216s | |
| 21 | +2026-10-16T09:20:05Z GET /token 200 0.037s | |
| 22 | +2026-10-16T09:21:23Z POST /logout 200 1.753s | |
| 23 | +2026-10-16T09:22:21Z POST /login 200 0.226s | |
| 24 | +2026-10-16T09:23:54Z POST /login 200 1.913s | |
| 25 | +2026-10-16T09:24:25Z GET /logout 200 1.688s | |
| 26 | +2026-10-16T09:25:00Z POST /token 200 0.194s | |
| 27 | +2026-10-16T09:26:27Z POST /login 200 0.182s | |
| 28 | +2026-10-16T09:27:24Z GET /token 200 0.123s | |
| 29 | +2026-10-16T09:28:45Z GET /login 200 1.969s | |
| 30 | +2026-10-16T09:29:57Z GET /token 200 3.229s | |
| 31 | +2026-10-16T09:30:44Z POST /token 200 0.081s | |
| 32 | +2026-10-16T09:31:02Z GET /logout 200 0.026s | |
| 33 | +2026-10-16T09:32:44Z POST /logout 200 1.788s | |
| 34 | +2026-10-16T09:33:49Z POST /logout 200 0.031s | |
| 35 | +2026-10-16T09:34:49Z POST /login 200 0.053s | |
| 36 | +2026-10-16T09:35:21Z POST /logout 200 2.54s | |
| 37 | +2026-10-16T09:36:40Z POST /logout 500 1.241s | |
| 38 | +2026-10-16T09:37:06Z POST /token 200 0.272s | |
| 39 | +2026-10-16T09:38:51Z POST /login 200 2.024s | |
| 40 | +2026-10-16T09:39:17Z GET /login 200 0.103s | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +2026-10-16T09:00:35Z POST /token 200 0.065s | ||
| 2 | +2026-10-16T09:01:41Z GET /login 200 0.29s | ||
| 3 | +2026-10-16T09:02:42Z POST /logout 200 0.124s | ||
| 4 | +2026-10-16T09:03:21Z POST /logout 200 0.171s | ||
| 5 | +2026-10-16T09:04:57Z POST /logout 200 0.161s | ||
| 6 | +2026-10-16T09:05:28Z POST /login 200 0.294s | ||
| 7 | +2026-10-16T09:06:09Z POST /logout 200 0.233s | ||
| 8 | +2026-10-16T09:07:46Z GET /token 200 0.244s | ||
| 9 | +2026-10-16T09:08:38Z GET /login 200 2.976s | ||
| 10 | +2026-10-16T09:09:15Z POST /token 200 0.07s | ||
| 11 | +2026-10-16T09:10:21Z GET /login 200 1.491s | ||
| 12 | +2026-10-16T09:11:33Z GET /login 200 0.262s | ||
| 13 | +2026-10-16T09:12:57Z POST /logout 200 0.193s | ||
| 14 | +2026-10-16T09:13:02Z POST /token 200 0.131s | ||
| 15 | +2026-10-16T09:14:32Z GET /login 200 0.133s | ||
| 16 | +2026-10-16T09:15:42Z POST /token 200 0.039s | ||
| 17 | +2026-10-16T09:16:59Z POST /token 200 0.261s | ||
| 18 | +2026-10-16T09:17:24Z POST /logout 200 0.069s | ||
| 19 | +2026-10-16T09:18:22Z GET /login 200 0.175s | ||
| 20 | +2026-10-16T09:19:44Z GET /logout 200 0.216s | ||
| 21 | +2026-10-16T09:20:05Z GET /token 200 0.037s | ||
| 22 | +2026-10-16T09:21:23Z POST /logout 200 1.753s | ||
| 23 | +2026-10-16T09:22:21Z POST /login 200 0.226s | ||
| 24 | +2026-10-16T09:23:54Z POST /login 200 1.913s | ||
| 25 | +2026-10-16T09:24:25Z GET /logout 200 1.688s | ||
| 26 | +2026-10-16T09:25:00Z POST /token 200 0.194s | ||
| 27 | +2026-10-16T09:26:27Z POST /login 200 0.182s | ||
| 28 | +2026-10-16T09:27:24Z GET /token 200 0.123s | ||
| 29 | +2026-10-16T09:28:45Z GET /login 200 1.969s | ||
| 30 | +2026-10-16T09:29:57Z GET /token 200 3.229s | ||
| 31 | +2026-10-16T09:30:44Z POST /token 200 0.081s | ||
| 32 | +2026-10-16T09:31:02Z GET /logout 200 0.026s | ||
| 33 | +2026-10-16T09:32:44Z POST /logout 200 1.788s | ||
| 34 | +2026-10-16T09:33:49Z POST /logout 200 0.031s | ||
| 35 | +2026-10-16T09:34:49Z POST /login 200 0.053s | ||
| 36 | +2026-10-16T09:35:21Z POST /logout 200 2.54s | ||
| 37 | +2026-10-16T09:36:40Z POST /logout 500 1.241s | ||
| 38 | +2026-10-16T09:37:06Z POST /token 200 0.272s | ||
| 39 | +2026-10-16T09:38:51Z POST /login 200 2.024s | ||
| 40 | +2026-10-16T09:39:17Z GET /login 200 0.103s | ||
added
demos/declared-tools/logs/billing.log +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +2026-10-16T09:00:33Z GET /api/payments 200 0.131s | |
| 2 | +2026-10-16T09:01:38Z GET /api/payments 200 0.212s | |
| 3 | +2026-10-16T09:02:10Z GET /api/payments 200 0.135s | |
| 4 | +2026-10-16T09:03:28Z GET /api/invoices 200 0.251s | |
| 5 | +2026-10-16T09:04:29Z GET /api/payments 200 0.069s | |
| 6 | +2026-10-16T09:05:04Z POST /api/invoices 200 0.218s | |
| 7 | +2026-10-16T09:06:45Z GET /api/invoices 200 0.127s | |
| 8 | +2026-10-16T09:07:21Z GET /api/payments 200 0.049s | |
| 9 | +2026-10-16T09:08:36Z POST /api/refunds 200 0.122s | |
| 10 | +2026-10-16T09:09:43Z POST /api/refunds 200 0.195s | |
| 11 | +2026-10-16T09:10:02Z POST /api/payments 200 0.231s | |
| 12 | +2026-10-16T09:11:02Z GET /api/payments 200 3.302s | |
| 13 | +2026-10-16T09:12:05Z GET /api/refunds 200 0.295s | |
| 14 | +2026-10-16T09:13:59Z GET /api/refunds 200 0.107s | |
| 15 | +2026-10-16T09:14:02Z GET /api/refunds 200 0.025s | |
| 16 | +2026-10-16T09:15:39Z POST /api/invoices 500 0.298s | |
| 17 | +2026-10-16T09:16:12Z POST /api/refunds 200 0.268s | |
| 18 | +2026-10-16T09:17:44Z POST /api/refunds 200 0.021s | |
| 19 | +2026-10-16T09:18:36Z POST /api/invoices 200 0.233s | |
| 20 | +2026-10-16T09:19:20Z POST /api/payments 200 0.212s | |
| 21 | +2026-10-16T09:20:55Z POST /api/refunds 200 0.123s | |
| 22 | +2026-10-16T09:21:44Z POST /api/payments 200 0.13s | |
| 23 | +2026-10-16T09:22:56Z POST /api/invoices 500 0.111s | |
| 24 | +2026-10-16T09:23:06Z POST /api/payments 200 0.025s | |
| 25 | +2026-10-16T09:24:08Z POST /api/refunds 200 0.209s | |
| 26 | +2026-10-16T09:25:10Z POST /api/refunds 200 0.088s | |
| 27 | +2026-10-16T09:26:04Z GET /api/payments 200 0.222s | |
| 28 | +2026-10-16T09:27:19Z GET /api/payments 200 0.179s | |
| 29 | +2026-10-16T09:28:33Z GET /api/invoices 200 0.128s | |
| 30 | +2026-10-16T09:29:52Z GET /api/payments 200 0.298s | |
| 31 | +2026-10-16T09:30:28Z POST /api/payments 200 0.035s | |
| 32 | +2026-10-16T09:31:18Z POST /api/refunds 200 0.054s | |
| 33 | +2026-10-16T09:32:11Z POST /api/refunds 200 0.269s | |
| 34 | +2026-10-16T09:33:05Z POST /api/refunds 200 0.149s | |
| 35 | +2026-10-16T09:34:01Z POST /api/invoices 200 0.08s | |
| 36 | +2026-10-16T09:35:28Z POST /api/refunds 200 0.241s | |
| 37 | +2026-10-16T09:36:44Z POST /api/invoices 200 0.102s | |
| 38 | +2026-10-16T09:37:30Z POST /api/payments 500 0.11s | |
| 39 | +2026-10-16T09:38:32Z GET /api/refunds 500 0.024s | |
| 40 | +2026-10-16T09:39:19Z POST /api/invoices 200 0.205s | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +2026-10-16T09:00:33Z GET /api/payments 200 0.131s | ||
| 2 | +2026-10-16T09:01:38Z GET /api/payments 200 0.212s | ||
| 3 | +2026-10-16T09:02:10Z GET /api/payments 200 0.135s | ||
| 4 | +2026-10-16T09:03:28Z GET /api/invoices 200 0.251s | ||
| 5 | +2026-10-16T09:04:29Z GET /api/payments 200 0.069s | ||
| 6 | +2026-10-16T09:05:04Z POST /api/invoices 200 0.218s | ||
| 7 | +2026-10-16T09:06:45Z GET /api/invoices 200 0.127s | ||
| 8 | +2026-10-16T09:07:21Z GET /api/payments 200 0.049s | ||
| 9 | +2026-10-16T09:08:36Z POST /api/refunds 200 0.122s | ||
| 10 | +2026-10-16T09:09:43Z POST /api/refunds 200 0.195s | ||
| 11 | +2026-10-16T09:10:02Z POST /api/payments 200 0.231s | ||
| 12 | +2026-10-16T09:11:02Z GET /api/payments 200 3.302s | ||
| 13 | +2026-10-16T09:12:05Z GET /api/refunds 200 0.295s | ||
| 14 | +2026-10-16T09:13:59Z GET /api/refunds 200 0.107s | ||
| 15 | +2026-10-16T09:14:02Z GET /api/refunds 200 0.025s | ||
| 16 | +2026-10-16T09:15:39Z POST /api/invoices 500 0.298s | ||
| 17 | +2026-10-16T09:16:12Z POST /api/refunds 200 0.268s | ||
| 18 | +2026-10-16T09:17:44Z POST /api/refunds 200 0.021s | ||
| 19 | +2026-10-16T09:18:36Z POST /api/invoices 200 0.233s | ||
| 20 | +2026-10-16T09:19:20Z POST /api/payments 200 0.212s | ||
| 21 | +2026-10-16T09:20:55Z POST /api/refunds 200 0.123s | ||
| 22 | +2026-10-16T09:21:44Z POST /api/payments 200 0.13s | ||
| 23 | +2026-10-16T09:22:56Z POST /api/invoices 500 0.111s | ||
| 24 | +2026-10-16T09:23:06Z POST /api/payments 200 0.025s | ||
| 25 | +2026-10-16T09:24:08Z POST /api/refunds 200 0.209s | ||
| 26 | +2026-10-16T09:25:10Z POST /api/refunds 200 0.088s | ||
| 27 | +2026-10-16T09:26:04Z GET /api/payments 200 0.222s | ||
| 28 | +2026-10-16T09:27:19Z GET /api/payments 200 0.179s | ||
| 29 | +2026-10-16T09:28:33Z GET /api/invoices 200 0.128s | ||
| 30 | +2026-10-16T09:29:52Z GET /api/payments 200 0.298s | ||
| 31 | +2026-10-16T09:30:28Z POST /api/payments 200 0.035s | ||
| 32 | +2026-10-16T09:31:18Z POST /api/refunds 200 0.054s | ||
| 33 | +2026-10-16T09:32:11Z POST /api/refunds 200 0.269s | ||
| 34 | +2026-10-16T09:33:05Z POST /api/refunds 200 0.149s | ||
| 35 | +2026-10-16T09:34:01Z POST /api/invoices 200 0.08s | ||
| 36 | +2026-10-16T09:35:28Z POST /api/refunds 200 0.241s | ||
| 37 | +2026-10-16T09:36:44Z POST /api/invoices 200 0.102s | ||
| 38 | +2026-10-16T09:37:30Z POST /api/payments 500 0.11s | ||
| 39 | +2026-10-16T09:38:32Z GET /api/refunds 500 0.024s | ||
| 40 | +2026-10-16T09:39:19Z POST /api/invoices 200 0.205s | ||
added
demos/declared-tools/logs/orders.log +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +2026-10-16T09:00:19Z POST /api/cart 200 0.127s | |
| 2 | +2026-10-16T09:01:49Z POST /api/orders 200 0.262s | |
| 3 | +2026-10-16T09:02:57Z POST /api/cart 200 0.16s | |
| 4 | +2026-10-16T09:03:13Z GET /api/orders 500 0.255s | |
| 5 | +2026-10-16T09:04:23Z POST /api/orders 200 0.116s | |
| 6 | +2026-10-16T09:05:10Z GET /api/cart 200 0.162s | |
| 7 | +2026-10-16T09:06:13Z POST /api/orders 200 0.156s | |
| 8 | +2026-10-16T09:07:55Z GET /api/checkout 200 0.269s | |
| 9 | +2026-10-16T09:08:05Z POST /api/orders 200 3.086s | |
| 10 | +2026-10-16T09:09:47Z GET /api/checkout 200 3.45s | |
| 11 | +2026-10-16T09:10:41Z POST /api/orders 500 0.251s | |
| 12 | +2026-10-16T09:11:20Z POST /api/cart 200 0.274s | |
| 13 | +2026-10-16T09:12:10Z POST /api/cart 200 0.225s | |
| 14 | +2026-10-16T09:13:27Z POST /api/cart 200 0.11s | |
| 15 | +2026-10-16T09:14:27Z GET /api/cart 500 0.107s | |
| 16 | +2026-10-16T09:15:58Z GET /api/checkout 200 0.215s | |
| 17 | +2026-10-16T09:16:15Z POST /api/orders 200 0.193s | |
| 18 | +2026-10-16T09:17:53Z GET /api/cart 200 0.103s | |
| 19 | +2026-10-16T09:18:01Z GET /api/checkout 200 0.121s | |
| 20 | +2026-10-16T09:19:10Z GET /api/orders 200 0.218s | |
| 21 | +2026-10-16T09:20:21Z GET /api/orders 200 0.051s | |
| 22 | +2026-10-16T09:21:32Z GET /api/checkout 200 0.028s | |
| 23 | +2026-10-16T09:22:51Z GET /api/cart 500 0.207s | |
| 24 | +2026-10-16T09:23:30Z GET /api/cart 200 0.195s | |
| 25 | +2026-10-16T09:24:47Z GET /api/cart 200 0.171s | |
| 26 | +2026-10-16T09:25:50Z POST /api/orders 200 0.027s | |
| 27 | +2026-10-16T09:26:06Z GET /api/cart 500 0.245s | |
| 28 | +2026-10-16T09:27:57Z POST /api/checkout 200 0.277s | |
| 29 | +2026-10-16T09:28:26Z GET /api/checkout 200 0.12s | |
| 30 | +2026-10-16T09:29:44Z GET /api/checkout 200 0.135s | |
| 31 | +2026-10-16T09:30:45Z GET /api/checkout 200 0.295s | |
| 32 | +2026-10-16T09:31:17Z GET /api/checkout 200 1.776s | |
| 33 | +2026-10-16T09:32:07Z POST /api/checkout 200 0.288s | |
| 34 | +2026-10-16T09:33:00Z POST /api/checkout 200 0.112s | |
| 35 | +2026-10-16T09:34:37Z POST /api/orders 200 0.051s | |
| 36 | +2026-10-16T09:35:05Z POST /api/cart 500 0.123s | |
| 37 | +2026-10-16T09:36:36Z GET /api/cart 200 0.152s | |
| 38 | +2026-10-16T09:37:39Z POST /api/orders 200 2.419s | |
| 39 | +2026-10-16T09:38:56Z POST /api/orders 200 0.257s | |
| 40 | +2026-10-16T09:39:49Z POST /api/checkout 200 2.05s | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +2026-10-16T09:00:19Z POST /api/cart 200 0.127s | ||
| 2 | +2026-10-16T09:01:49Z POST /api/orders 200 0.262s | ||
| 3 | +2026-10-16T09:02:57Z POST /api/cart 200 0.16s | ||
| 4 | +2026-10-16T09:03:13Z GET /api/orders 500 0.255s | ||
| 5 | +2026-10-16T09:04:23Z POST /api/orders 200 0.116s | ||
| 6 | +2026-10-16T09:05:10Z GET /api/cart 200 0.162s | ||
| 7 | +2026-10-16T09:06:13Z POST /api/orders 200 0.156s | ||
| 8 | +2026-10-16T09:07:55Z GET /api/checkout 200 0.269s | ||
| 9 | +2026-10-16T09:08:05Z POST /api/orders 200 3.086s | ||
| 10 | +2026-10-16T09:09:47Z GET /api/checkout 200 3.45s | ||
| 11 | +2026-10-16T09:10:41Z POST /api/orders 500 0.251s | ||
| 12 | +2026-10-16T09:11:20Z POST /api/cart 200 0.274s | ||
| 13 | +2026-10-16T09:12:10Z POST /api/cart 200 0.225s | ||
| 14 | +2026-10-16T09:13:27Z POST /api/cart 200 0.11s | ||
| 15 | +2026-10-16T09:14:27Z GET /api/cart 500 0.107s | ||
| 16 | +2026-10-16T09:15:58Z GET /api/checkout 200 0.215s | ||
| 17 | +2026-10-16T09:16:15Z POST /api/orders 200 0.193s | ||
| 18 | +2026-10-16T09:17:53Z GET /api/cart 200 0.103s | ||
| 19 | +2026-10-16T09:18:01Z GET /api/checkout 200 0.121s | ||
| 20 | +2026-10-16T09:19:10Z GET /api/orders 200 0.218s | ||
| 21 | +2026-10-16T09:20:21Z GET /api/orders 200 0.051s | ||
| 22 | +2026-10-16T09:21:32Z GET /api/checkout 200 0.028s | ||
| 23 | +2026-10-16T09:22:51Z GET /api/cart 500 0.207s | ||
| 24 | +2026-10-16T09:23:30Z GET /api/cart 200 0.195s | ||
| 25 | +2026-10-16T09:24:47Z GET /api/cart 200 0.171s | ||
| 26 | +2026-10-16T09:25:50Z POST /api/orders 200 0.027s | ||
| 27 | +2026-10-16T09:26:06Z GET /api/cart 500 0.245s | ||
| 28 | +2026-10-16T09:27:57Z POST /api/checkout 200 0.277s | ||
| 29 | +2026-10-16T09:28:26Z GET /api/checkout 200 0.12s | ||
| 30 | +2026-10-16T09:29:44Z GET /api/checkout 200 0.135s | ||
| 31 | +2026-10-16T09:30:45Z GET /api/checkout 200 0.295s | ||
| 32 | +2026-10-16T09:31:17Z GET /api/checkout 200 1.776s | ||
| 33 | +2026-10-16T09:32:07Z POST /api/checkout 200 0.288s | ||
| 34 | +2026-10-16T09:33:00Z POST /api/checkout 200 0.112s | ||
| 35 | +2026-10-16T09:34:37Z POST /api/orders 200 0.051s | ||
| 36 | +2026-10-16T09:35:05Z POST /api/cart 500 0.123s | ||
| 37 | +2026-10-16T09:36:36Z GET /api/cart 200 0.152s | ||
| 38 | +2026-10-16T09:37:39Z POST /api/orders 200 2.419s | ||
| 39 | +2026-10-16T09:38:56Z POST /api/orders 200 0.257s | ||
| 40 | +2026-10-16T09:39:49Z POST /api/checkout 200 2.05s | ||
added
demos/declared-tools/scripts/bump.sh +15 -0 | new file mode 100755 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# bump.sh <major|minor|patch> — raises the version in VERSION. | |
| 3 | +set -eu | |
| 4 | +cd "$(dirname "$0")/.." | |
| 5 | +old=$(cat VERSION) | |
| 6 | +IFS=. read -r major minor patch <<EOV | |
| 7 | +$old | |
| 8 | +EOV | |
| 9 | +case $1 in | |
| 10 | + major) major=$((major + 1)); minor=0; patch=0 ;; | |
| 11 | + minor) minor=$((minor + 1)); patch=0 ;; | |
| 12 | + patch) patch=$((patch + 1)) ;; | |
| 13 | +esac | |
| 14 | +echo "$major.$minor.$patch" > VERSION | |
| 15 | +echo "version $old -> $major.$minor.$patch" | |
| new file mode 100755 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# bump.sh <major|minor|patch> — raises the version in VERSION. | ||
| 3 | +set -eu | ||
| 4 | +cd "$(dirname "$0")/.." | ||
| 5 | +old=$(cat VERSION) | ||
| 6 | +IFS=. read -r major minor patch <<EOV | ||
| 7 | +$old | ||
| 8 | +EOV | ||
| 9 | +case $1 in | ||
| 10 | + major) major=$((major + 1)); minor=0; patch=0 ;; | ||
| 11 | + minor) minor=$((minor + 1)); patch=0 ;; | ||
| 12 | + patch) patch=$((patch + 1)) ;; | ||
| 13 | +esac | ||
| 14 | +echo "$major.$minor.$patch" > VERSION | ||
| 15 | +echo "version $old -> $major.$minor.$patch" | ||
added
demos/declared-tools/scripts/logs.sh +5 -0 | new file mode 100755 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# logs.sh <service> <lines> — the last lines of a service's log. | |
| 3 | +set -eu | |
| 4 | +cd "$(dirname "$0")/.." | |
| 5 | +tail -n "$2" "logs/$1.log" | |
| new file mode 100755 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# logs.sh <service> <lines> — the last lines of a service's log. | ||
| 3 | +set -eu | ||
| 4 | +cd "$(dirname "$0")/.." | ||
| 5 | +tail -n "$2" "logs/$1.log" | ||
added
demos/declared-tools/scripts/release.sh +16 -0 | new file mode 100755 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# release.sh <version> <channel> <dry-run: true|false> | |
| 3 | +# Records a release in releases.log — or only says what it would do. | |
| 4 | +set -eu | |
| 5 | +cd "$(dirname "$0")/.." | |
| 6 | +version=$1 channel=$2 dry_run=$3 | |
| 7 | +case $version in | |
| 8 | + [0-9]*.[0-9]*.[0-9]*) ;; | |
| 9 | + *) echo "error: \"$version\" is not a version like 1.4.2" >&2; exit 2 ;; | |
| 10 | +esac | |
| 11 | +if [ "$dry_run" = true ]; then | |
| 12 | + echo "dry run: would release $version on $channel (nothing recorded)" | |
| 13 | + exit 0 | |
| 14 | +fi | |
| 15 | +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $version $channel" >> releases.log | |
| 16 | +echo "released $version on $channel (recorded in releases.log)" | |
| new file mode 100755 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# release.sh <version> <channel> <dry-run: true|false> | ||
| 3 | +# Records a release in releases.log — or only says what it would do. | ||
| 4 | +set -eu | ||
| 5 | +cd "$(dirname "$0")/.." | ||
| 6 | +version=$1 channel=$2 dry_run=$3 | ||
| 7 | +case $version in | ||
| 8 | + [0-9]*.[0-9]*.[0-9]*) ;; | ||
| 9 | + *) echo "error: \"$version\" is not a version like 1.4.2" >&2; exit 2 ;; | ||
| 10 | +esac | ||
| 11 | +if [ "$dry_run" = true ]; then | ||
| 12 | + echo "dry run: would release $version on $channel (nothing recorded)" | ||
| 13 | + exit 0 | ||
| 14 | +fi | ||
| 15 | +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $version $channel" >> releases.log | ||
| 16 | +echo "released $version on $channel (recorded in releases.log)" | ||
added
demos/declared-tools/scripts/slow.sh +10 -0 | new file mode 100755 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# slow.sh <threshold-seconds> [service] — requests slower than the threshold. | |
| 3 | +# Log lines: <time> <method> <path> <status> <duration>s | |
| 4 | +set -eu | |
| 5 | +cd "$(dirname "$0")/.." | |
| 6 | +threshold=$1 | |
| 7 | +files=logs/*.log | |
| 8 | +[ $# -ge 2 ] && files="logs/$2.log" | |
| 9 | +# shellcheck disable=SC2086 # files is a glob or one path, split on purpose | |
| 10 | +awk -v t="$threshold" '{ d = $5; sub(/s$/, "", d); if (d + 0 > t + 0) print FILENAME ": " $0 }' $files | sed 's|^logs/||' | |
| new file mode 100755 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +#!/bin/sh | ||
| 2 | +# slow.sh <threshold-seconds> [service] — requests slower than the threshold. | ||
| 3 | +# Log lines: <time> <method> <path> <status> <duration>s | ||
| 4 | +set -eu | ||
| 5 | +cd "$(dirname "$0")/.." | ||
| 6 | +threshold=$1 | ||
| 7 | +files=logs/*.log | ||
| 8 | +[ $# -ge 2 ] && files="logs/$2.log" | ||
| 9 | +# shellcheck disable=SC2086 # files is a glob or one path, split on purpose | ||
| 10 | +awk -v t="$threshold" '{ d = $5; sub(/s$/, "", d); if (d + 0 > t + 0) print FILENAME ": " $0 }' $files | sed 's|^logs/||' | ||
modified
docs/diagrams/packages.drawio +1 -1 | @@ -1,3 +1,3 @@ | ||
| 1 | -<mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | |
| 1 | +<mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | |
| 2 | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | 3 | <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> |
| \ No newline at end of file | ||
| @@ -1,3 +1,3 @@ | |||
| 1 | -<mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | 1 | +<mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content; declared toolsbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>lt;/fontbuilt-in tools: bash, read_skill, file tools, lsp, fetch_content</font>gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> |
| 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | 2 | <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> |
| 3 | <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> | 3 | <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="agentsmd" value="<b>internal/agentsmd</b><br/><font style="font-size:10px">the project's AGENTS.md, appended to the system prompt per conversation</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="520.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e76" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e77" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e78" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e79" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agentsmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e80" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agentsmd" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="elicit" value="<b>internal/elicit</b><br/><font style="font-size:10px">ask_user's questions and answers: normalize, line answers, elicitation form schema</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="780.0" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e81" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e82" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e83" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e84" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e85" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e86" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="elicit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="hooks" value="<b>internal/hooks</b><br/><font style="font-size:10px">the hooks: block — runs each hook (sh -c, JSON on stdin/stdout), chain, onError</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="940" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_engine_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_hooks" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="hooks"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_hooks_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="hooks" target="config"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile> |
| \ No newline at end of file | \ No newline at end of file | ||
modified
docs/en/README.md +3 -1 | @@ -25,6 +25,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 25 | 25 | - [Let the agent ask you questions](how-to/let-the-agent-ask-you.md) — `ask_user`: answer in the terminal, the window or an editor, turn it off, decide for the agents yours calls (`askUserTool`, `mmConfigs.<name>.askUser`, `AGENT_ASK_USER`) |
| 26 | 26 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor |
| 27 | 27 | - [Add a skill](how-to/add-a-skill.md) |
| 28 | +- [Declare a custom tool](how-to/declare-a-custom-tool.md) | |
| 28 | 29 | - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after |
| 29 | 30 | - [Keep a long session inside the context window](how-to/manage-the-context-window.md) |
| 30 | 31 | - [Start a new session without restarting](how-to/start-a-new-session.md) |
| @@ -48,7 +49,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 48 | 49 | - [Command line](reference/cli.md) — flags, environment variables, start-up banner |
| 49 | 50 | - [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys |
| 50 | 51 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule |
| 51 | -- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` | |
| 52 | +- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, declared tools | |
| 52 | 53 | - [Skill file format](reference/skill-format.md) |
| 53 | 54 | - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors |
| 54 | 55 | - [Agent Client Protocol surface](reference/acp.md) |
| @@ -66,6 +67,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 66 | 67 | - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume` |
| 67 | 68 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission |
| 68 | 69 | - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits |
| 70 | +- [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text | |
| 69 | 71 | - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive |
| 70 | 72 | - [Yolo mode and permissions](explanation/yolo-and-permissions.md) — why tools run unasked by default, existing versus asking, four answers in one package, why the asking agent decides (and a per-agent `yolo` in `mmConfigs`) |
| 71 | 73 | - [Project instructions (`AGENTS.md`)](explanation/project-instructions.md) — why appended to the system prompt, why per conversation, why a warning rather than a cut, who does not read it |
| @@ -25,6 +25,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 25 | - [Let the agent ask you questions](how-to/let-the-agent-ask-you.md) — `ask_user`: answer in the terminal, the window or an editor, turn it off, decide for the agents yours calls (`askUserTool`, `mmConfigs.<name>.askUser`, `AGENT_ASK_USER`) | 25 | - [Let the agent ask you questions](how-to/let-the-agent-ask-you.md) — `ask_user`: answer in the terminal, the window or an editor, turn it off, decide for the agents yours calls (`askUserTool`, `mmConfigs.<name>.askUser`, `AGENT_ASK_USER`) |
| 26 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor | 26 | - [Run mini-me in a Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — the `mini-me` kit from [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), the `go-toolchain` and `cli-tools` mixins under `sbx/`, window mode and editor |
| 27 | - [Add a skill](how-to/add-a-skill.md) | 27 | - [Add a skill](how-to/add-a-skill.md) |
| 28 | +- [Declare a custom tool](how-to/declare-a-custom-tool.md) | ||
| 28 | - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after | 29 | - [Measure whether the model picks the right skill](how-to/measure-skill-routing.md) — the routing eval: run it, read its table, use it as a before-and-after |
| 29 | - [Keep a long session inside the context window](how-to/manage-the-context-window.md) | 30 | - [Keep a long session inside the context window](how-to/manage-the-context-window.md) |
| 30 | - [Start a new session without restarting](how-to/start-a-new-session.md) | 31 | - [Start a new session without restarting](how-to/start-a-new-session.md) |
| @@ -48,7 +49,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 48 | - [Command line](reference/cli.md) — flags, environment variables, start-up banner | 49 | - [Command line](reference/cli.md) — flags, environment variables, start-up banner |
| 49 | - [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys | 50 | - [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys |
| 50 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule | 51 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule |
| 51 | -- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` | 52 | +- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, declared tools |
| 52 | - [Skill file format](reference/skill-format.md) | 53 | - [Skill file format](reference/skill-format.md) |
| 53 | - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors | 54 | - [Hooks](reference/hooks.md) — the six events, the JSON a hook reads and writes, `matcher`, `timeout`, `onError`, validation errors |
| 54 | - [Agent Client Protocol surface](reference/acp.md) | 55 | - [Agent Client Protocol surface](reference/acp.md) |
| @@ -66,6 +67,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 66 | - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume` | 67 | - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume` |
| 67 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission | 68 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission |
| 68 | - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits | 69 | - [The `fetch_content` tool](explanation/fetch-content-tool.md) — why a built-in tool beside MCP `fetch`, a home-made converter, robots.txt, errors as numbered text, known limits |
| 70 | +- [Declared tools](explanation/declared-tools.md) — why typed tools beside skills, what was measured, no shell, invalid calls answered in text | ||
| 69 | - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive | 71 | - [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive |
| 70 | - [Yolo mode and permissions](explanation/yolo-and-permissions.md) — why tools run unasked by default, existing versus asking, four answers in one package, why the asking agent decides (and a per-agent `yolo` in `mmConfigs`) | 72 | - [Yolo mode and permissions](explanation/yolo-and-permissions.md) — why tools run unasked by default, existing versus asking, four answers in one package, why the asking agent decides (and a per-agent `yolo` in `mmConfigs`) |
| 71 | - [Project instructions (`AGENTS.md`)](explanation/project-instructions.md) — why appended to the system prompt, why per conversation, why a warning rather than a cut, who does not read it | 73 | - [Project instructions (`AGENTS.md`)](explanation/project-instructions.md) — why appended to the system prompt, why per conversation, why a warning rather than a cut, who does not read it |
added
docs/en/explanation/declared-tools.md +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +# Declared tools — explanation | |
| 2 | + | |
| 3 | +## What is this about? | |
| 4 | + | |
| 5 | +mm had two ways to let the model run a command of yours before ticket 88: a [skill](../how-to/add-a-skill.md) that says "call your `bash` tool with this command", and an MCP server exposing a tool. Declared tools are a third: a `tools:` entry in the agent file that turns one command into a typed tool, with no server to write. This page explains why it was added beside skills rather than instead of them, and why it is shaped the way it is. | |
| 6 | + | |
| 7 | +## Why skills were not enough | |
| 8 | + | |
| 9 | +A skill runs a command in two steps. The model must first find the skill in the catalogue and call `read_skill`, then compose a `bash` command line from what it read. Each step can fail, and on a small model they do: | |
| 10 | + | |
| 11 | +- **The skill is never found.** The catalogue is routed by the model reading names, descriptions and keywords. Measured on mellum2-12b, a `status` skill ("Report the status of one of the company's services") was never read for "What is the status of the billing service?" — 0 out of 10 runs, in English and in French. The model improvised `curl localhost:8080/health` and `ps aux | grep billing`, and then answered that the service "seems to be running". That is a wrong answer, not just a miss. | |
| 12 | +- **The command is rewritten.** When the skill is skipped but the model finds the script anyway, it guesses the arguments: `./pkgtest.sh greet` instead of `./greet`. | |
| 13 | +- **Consent is all or nothing.** Everything a skill runs goes through `bash`, so "always allow this script" can only be said as "always allow bash". | |
| 14 | + | |
| 15 | +A declared tool removes the routing step: its name, description and parameters are in every request, like `bash`'s. | |
| 16 | + | |
| 17 | +## What was measured | |
| 18 | + | |
| 19 | +Three actions (`release <version> <channel>`, `pkgtest <package>`, `status <service>`), each asked in English and in French, 5 runs each, `mm -p`, on two models. A run succeeds when the script really ran with exactly the expected arguments — checked from a file the script writes, not from the model's answer. | |
| 20 | + | |
| 21 | +| Model | Skill + `bash` | Typed tool through an MCP stand-in | Declared tool | | |
| 22 | +|-------|----------------|-------------------------------------|---------------| | |
| 23 | +| mellum2-12b | 15/30 (4.5 s on average) | 25/30 (2.8 s) | 30/30 (1.3 s) | | |
| 24 | +| qwen3.5-4b | 30/30 (12.7 s) | 30/30 (7.7 s) | 30/30 (5.8 s) | | |
| 25 | + | |
| 26 | +The MCP stand-in was measured first, before any code was written: a small MCP HTTP server exposing the same three tools gave the same comparison without building anything. It decided the question. The declared tools were measured again once built. | |
| 27 | + | |
| 28 | +Two things stand out. First, a typed tool is faster even when the skill works: one model round trip instead of two. Second, the stand-in's five failures on mellum2 — the French release request — came from the model packing every argument into one field: `{version: "1.4.2 1.4.2, channel beta"}`. With the declared tool, the same request succeeded 5 times out of 5, each time in one call. The declared tool differs from the stand-in in several ways at once — no `proj_` name prefix, no "MCP tool from server …" in the description, a closing "Give each parameter as its own field." — and which of them matters was not measured separately. | |
| 29 | + | |
| 30 | +The numbers come from one small fixture, two models and five runs per case. They answer "is it worth building?", not "how much better is it in general?". | |
| 31 | + | |
| 32 | +## Why it is designed this way | |
| 33 | + | |
| 34 | +**No shell.** The command is a list, one argv entry per element, and a value replaces its `{{placeholder}}` inside one entry. A model's value — or a value a prompt injection put in the conversation — cannot add an argument, chain a second command or redirect an output: `1.4.2; rm -rf ~` reaches the script as one odd version string. A template like `command: "./release.sh {{version}}"` run by `bash -c` was rejected for exactly that reason. A command that needs a pipe goes into a script. | |
| 35 | + | |
| 36 | +**The program cannot come from a parameter.** Otherwise a declared tool is `bash` with extra steps. | |
| 37 | + | |
| 38 | +**Invalid calls are answered, not rejected by the schema.** The schema the model reads carries the types, but not `required` nor `enum` as keywords — those go into the descriptions. Genkit validates a call against the declared schema before running the tool, and a call it rejects ends the turn. mm prefers to answer in text, saying what was wrong, what the model sent and what each parameter should be, so the model can try again. The same choice was made for MCP tools. Quoting the call back matters: told only "channel is missing", mellum2 sent the same packed call six times until the [loop detector](../reference/tools.md#loop-detection) stopped the turn. | |
| 39 | + | |
| 40 | +**Lenient coercion, nothing more.** Models write `"3"` for 3 and `"true"` for true often enough that those strings are accepted. Anything that would need a guess (`"yes"`, `"three"`) is refused with the reason. | |
| 41 | + | |
| 42 | +**Consent follows `yolo`.** A declared tool runs something, so with `yolo: false` it asks like `bash`; with `yolo: true` it does not. Asking "even in yolo" was considered and left out: the agent's author wrote the command, which is already a stronger statement than a model composing a `bash` line. | |
| 43 | + | |
| 44 | +**Names are reserved early.** A declared tool cannot take a built-in name even when that tool is turned off, so turning it on later does not break the file. A clash with an MCP tool can only be seen once the server answered, so it is a start-up warning, not an error. | |
| 45 | + | |
| 46 | +## Rejected alternatives | |
| 47 | + | |
| 48 | +- **Only skills.** Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets. | |
| 49 | +- **Only MCP.** Already gives typed tools, but each one needs a server to write, start and keep running, over HTTP only (mm has no MCP stdio transport). Too heavy for "run this script with two arguments". | |
| 50 | +- **A Wasm plugin system** (ticket 46). Sandboxed tools are a different question; a declared tool runs with the user's rights, like `bash`. | |
| 51 | + | |
| 52 | +## How it relates to the rest | |
| 53 | + | |
| 54 | +Declared tools sit beside the built-in tools: the same loop detector, the same output truncation and echo, the same working directory and cancellation as [`bash`](../reference/tools.md#bash), the same permission dialogs (see [yolo and permissions](yolo-and-permissions.md)), and the `preToolCall` / `postToolCall` [hooks](hooks.md). Skills remain the place for know-how and procedures; a skill can also tell the model to call a declared tool. | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +# Declared tools — explanation | ||
| 2 | + | ||
| 3 | +## What is this about? | ||
| 4 | + | ||
| 5 | +mm had two ways to let the model run a command of yours before ticket 88: a [skill](../how-to/add-a-skill.md) that says "call your `bash` tool with this command", and an MCP server exposing a tool. Declared tools are a third: a `tools:` entry in the agent file that turns one command into a typed tool, with no server to write. This page explains why it was added beside skills rather than instead of them, and why it is shaped the way it is. | ||
| 6 | + | ||
| 7 | +## Why skills were not enough | ||
| 8 | + | ||
| 9 | +A skill runs a command in two steps. The model must first find the skill in the catalogue and call `read_skill`, then compose a `bash` command line from what it read. Each step can fail, and on a small model they do: | ||
| 10 | + | ||
| 11 | +- **The skill is never found.** The catalogue is routed by the model reading names, descriptions and keywords. Measured on mellum2-12b, a `status` skill ("Report the status of one of the company's services") was never read for "What is the status of the billing service?" — 0 out of 10 runs, in English and in French. The model improvised `curl localhost:8080/health` and `ps aux | grep billing`, and then answered that the service "seems to be running". That is a wrong answer, not just a miss. | ||
| 12 | +- **The command is rewritten.** When the skill is skipped but the model finds the script anyway, it guesses the arguments: `./pkgtest.sh greet` instead of `./greet`. | ||
| 13 | +- **Consent is all or nothing.** Everything a skill runs goes through `bash`, so "always allow this script" can only be said as "always allow bash". | ||
| 14 | + | ||
| 15 | +A declared tool removes the routing step: its name, description and parameters are in every request, like `bash`'s. | ||
| 16 | + | ||
| 17 | +## What was measured | ||
| 18 | + | ||
| 19 | +Three actions (`release <version> <channel>`, `pkgtest <package>`, `status <service>`), each asked in English and in French, 5 runs each, `mm -p`, on two models. A run succeeds when the script really ran with exactly the expected arguments — checked from a file the script writes, not from the model's answer. | ||
| 20 | + | ||
| 21 | +| Model | Skill + `bash` | Typed tool through an MCP stand-in | Declared tool | | ||
| 22 | +|-------|----------------|-------------------------------------|---------------| | ||
| 23 | +| mellum2-12b | 15/30 (4.5 s on average) | 25/30 (2.8 s) | 30/30 (1.3 s) | | ||
| 24 | +| qwen3.5-4b | 30/30 (12.7 s) | 30/30 (7.7 s) | 30/30 (5.8 s) | | ||
| 25 | + | ||
| 26 | +The MCP stand-in was measured first, before any code was written: a small MCP HTTP server exposing the same three tools gave the same comparison without building anything. It decided the question. The declared tools were measured again once built. | ||
| 27 | + | ||
| 28 | +Two things stand out. First, a typed tool is faster even when the skill works: one model round trip instead of two. Second, the stand-in's five failures on mellum2 — the French release request — came from the model packing every argument into one field: `{version: "1.4.2 1.4.2, channel beta"}`. With the declared tool, the same request succeeded 5 times out of 5, each time in one call. The declared tool differs from the stand-in in several ways at once — no `proj_` name prefix, no "MCP tool from server …" in the description, a closing "Give each parameter as its own field." — and which of them matters was not measured separately. | ||
| 29 | + | ||
| 30 | +The numbers come from one small fixture, two models and five runs per case. They answer "is it worth building?", not "how much better is it in general?". | ||
| 31 | + | ||
| 32 | +## Why it is designed this way | ||
| 33 | + | ||
| 34 | +**No shell.** The command is a list, one argv entry per element, and a value replaces its `{{placeholder}}` inside one entry. A model's value — or a value a prompt injection put in the conversation — cannot add an argument, chain a second command or redirect an output: `1.4.2; rm -rf ~` reaches the script as one odd version string. A template like `command: "./release.sh {{version}}"` run by `bash -c` was rejected for exactly that reason. A command that needs a pipe goes into a script. | ||
| 35 | + | ||
| 36 | +**The program cannot come from a parameter.** Otherwise a declared tool is `bash` with extra steps. | ||
| 37 | + | ||
| 38 | +**Invalid calls are answered, not rejected by the schema.** The schema the model reads carries the types, but not `required` nor `enum` as keywords — those go into the descriptions. Genkit validates a call against the declared schema before running the tool, and a call it rejects ends the turn. mm prefers to answer in text, saying what was wrong, what the model sent and what each parameter should be, so the model can try again. The same choice was made for MCP tools. Quoting the call back matters: told only "channel is missing", mellum2 sent the same packed call six times until the [loop detector](../reference/tools.md#loop-detection) stopped the turn. | ||
| 39 | + | ||
| 40 | +**Lenient coercion, nothing more.** Models write `"3"` for 3 and `"true"` for true often enough that those strings are accepted. Anything that would need a guess (`"yes"`, `"three"`) is refused with the reason. | ||
| 41 | + | ||
| 42 | +**Consent follows `yolo`.** A declared tool runs something, so with `yolo: false` it asks like `bash`; with `yolo: true` it does not. Asking "even in yolo" was considered and left out: the agent's author wrote the command, which is already a stronger statement than a model composing a `bash` line. | ||
| 43 | + | ||
| 44 | +**Names are reserved early.** A declared tool cannot take a built-in name even when that tool is turned off, so turning it on later does not break the file. A clash with an MCP tool can only be seen once the server answered, so it is a start-up warning, not an error. | ||
| 45 | + | ||
| 46 | +## Rejected alternatives | ||
| 47 | + | ||
| 48 | +- **Only skills.** Cheaper to write and fine for procedures, but measured to fail where it matters on the small models mm targets. | ||
| 49 | +- **Only MCP.** Already gives typed tools, but each one needs a server to write, start and keep running, over HTTP only (mm has no MCP stdio transport). Too heavy for "run this script with two arguments". | ||
| 50 | +- **A Wasm plugin system** (ticket 46). Sandboxed tools are a different question; a declared tool runs with the user's rights, like `bash`. | ||
| 51 | + | ||
| 52 | +## How it relates to the rest | ||
| 53 | + | ||
| 54 | +Declared tools sit beside the built-in tools: the same loop detector, the same output truncation and echo, the same working directory and cancellation as [`bash`](../reference/tools.md#bash), the same permission dialogs (see [yolo and permissions](yolo-and-permissions.md)), and the `preToolCall` / `postToolCall` [hooks](hooks.md). Skills remain the place for know-how and procedures; a skill can also tell the model to call a declared tool. | ||
added
docs/en/how-to/declare-a-custom-tool.md +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +# How to declare a custom tool | |
| 2 | + | |
| 3 | +This guide shows how to turn a script or a program into a tool the model sees on every turn, with typed parameters, by declaring it in the agent file. It assumes you have an agent YAML file and a command you already run by hand. | |
| 4 | + | |
| 5 | +Use it for a frequent, precise action — run the tests of a package, prepare a release, query a service. For a procedure in several steps, or know-how the model should read before acting, write a [skill](add-a-skill.md) instead. | |
| 6 | + | |
| 7 | +## Steps | |
| 8 | + | |
| 9 | +1. Make sure the command works from the directory mm runs in: | |
| 10 | + | |
| 11 | + ```bash | |
| 12 | + ./scripts/release.sh 1.4.2 beta | |
| 13 | + ``` | |
| 14 | + | |
| 15 | +2. Add a `tools:` block to the agent file, with one entry per command. Name the tool after the action, describe what it does and when to use it, declare one parameter per argument, and write the command as a list — the program first, then one entry per argument, with `{{parameter}}` where a value goes: | |
| 16 | + | |
| 17 | + ```yaml | |
| 18 | + tools: | |
| 19 | + - name: release | |
| 20 | + description: Prepare a release of the project for a given version and channel. | |
| 21 | + parameters: | |
| 22 | + version: { type: string, description: "e.g. 1.4.2" } | |
| 23 | + channel: { type: string, enum: [stable, beta, nightly] } | |
| 24 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}"] | |
| 25 | + ``` | |
| 26 | + | |
| 27 | +3. Start mm and check that the banner lists the tool among the others (`release` here). A mistake in the block stops mm with a `[config error: …]` naming the entry; the [configuration reference](../reference/configuration.md#validation-errors) lists them. | |
| 28 | + | |
| 29 | +4. Ask for the action in your own words: | |
| 30 | + | |
| 31 | + ```bash | |
| 32 | + mm -p "Prepare release 1.4.2 on the beta channel." | |
| 33 | + ``` | |
| 34 | + | |
| 35 | + The terminal shows the call and its output: | |
| 36 | + | |
| 37 | + ``` | |
| 38 | + 🧰 release: ./scripts/release.sh 1.4.2 beta | |
| 39 | + │ release done: 1.4.2 beta | |
| 40 | + ``` | |
| 41 | + | |
| 42 | +## Variants | |
| 43 | + | |
| 44 | +- **A number, an integer or a flag.** Give the parameter a `type`: `number`, `integer` or `boolean`. A boolean usually goes inside an entry: `"--dry-run={{dryRun}}"`. | |
| 45 | +- **An optional argument.** Add `required: false`. Left out by the model, an entry that is only `"{{p}}"` disappears from the command; give a `default` to pass a value anyway: | |
| 46 | + | |
| 47 | + ```yaml | |
| 48 | + retries: { type: integer, required: false, default: 3 } | |
| 49 | + ``` | |
| 50 | + | |
| 51 | +- **A command longer than `bashTimeout`.** Add `timeout: 2m` to the entry. | |
| 52 | +- **Ask before each call.** Set `yolo: false`: the declared tools then ask like `bash` does. With `yolo: true` they run without asking. | |
| 53 | +- **A shell feature (pipe, redirection, `&&`).** The command runs without a shell, so write it in a script and declare the script. Passing `["bash", "-c", "…{{p}}…"]` would put the model's value back into a shell line. | |
| 54 | +- **The same action as an MCP tool.** A declared tool cannot take the name of an MCP tool: it is skipped with a start-up warning. Rename one of them. | |
| 55 | +- **Restrict what runs before or after.** `preToolCall` and `postToolCall` [hooks](guard-the-agent-with-hooks.md) apply to declared tools; use the tool's name as `matcher`. | |
| 56 | + | |
| 57 | +## See also | |
| 58 | + | |
| 59 | +- A complete example with four tools and every parameter type: `demos/declared-tools/` | |
| 60 | +- Every key, type and error: [configuration reference](../reference/configuration.md#tools-keys) and [declared tools](../reference/tools.md#declared-tools) | |
| 61 | +- Why typed tools beside skills, and what was measured: [Declared tools](../explanation/declared-tools.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +# How to declare a custom tool | ||
| 2 | + | ||
| 3 | +This guide shows how to turn a script or a program into a tool the model sees on every turn, with typed parameters, by declaring it in the agent file. It assumes you have an agent YAML file and a command you already run by hand. | ||
| 4 | + | ||
| 5 | +Use it for a frequent, precise action — run the tests of a package, prepare a release, query a service. For a procedure in several steps, or know-how the model should read before acting, write a [skill](add-a-skill.md) instead. | ||
| 6 | + | ||
| 7 | +## Steps | ||
| 8 | + | ||
| 9 | +1. Make sure the command works from the directory mm runs in: | ||
| 10 | + | ||
| 11 | + ```bash | ||
| 12 | + ./scripts/release.sh 1.4.2 beta | ||
| 13 | + ``` | ||
| 14 | + | ||
| 15 | +2. Add a `tools:` block to the agent file, with one entry per command. Name the tool after the action, describe what it does and when to use it, declare one parameter per argument, and write the command as a list — the program first, then one entry per argument, with `{{parameter}}` where a value goes: | ||
| 16 | + | ||
| 17 | + ```yaml | ||
| 18 | + tools: | ||
| 19 | + - name: release | ||
| 20 | + description: Prepare a release of the project for a given version and channel. | ||
| 21 | + parameters: | ||
| 22 | + version: { type: string, description: "e.g. 1.4.2" } | ||
| 23 | + channel: { type: string, enum: [stable, beta, nightly] } | ||
| 24 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}"] | ||
| 25 | + ``` | ||
| 26 | + | ||
| 27 | +3. Start mm and check that the banner lists the tool among the others (`release` here). A mistake in the block stops mm with a `[config error: …]` naming the entry; the [configuration reference](../reference/configuration.md#validation-errors) lists them. | ||
| 28 | + | ||
| 29 | +4. Ask for the action in your own words: | ||
| 30 | + | ||
| 31 | + ```bash | ||
| 32 | + mm -p "Prepare release 1.4.2 on the beta channel." | ||
| 33 | + ``` | ||
| 34 | + | ||
| 35 | + The terminal shows the call and its output: | ||
| 36 | + | ||
| 37 | + ``` | ||
| 38 | + 🧰 release: ./scripts/release.sh 1.4.2 beta | ||
| 39 | + │ release done: 1.4.2 beta | ||
| 40 | + ``` | ||
| 41 | + | ||
| 42 | +## Variants | ||
| 43 | + | ||
| 44 | +- **A number, an integer or a flag.** Give the parameter a `type`: `number`, `integer` or `boolean`. A boolean usually goes inside an entry: `"--dry-run={{dryRun}}"`. | ||
| 45 | +- **An optional argument.** Add `required: false`. Left out by the model, an entry that is only `"{{p}}"` disappears from the command; give a `default` to pass a value anyway: | ||
| 46 | + | ||
| 47 | + ```yaml | ||
| 48 | + retries: { type: integer, required: false, default: 3 } | ||
| 49 | + ``` | ||
| 50 | + | ||
| 51 | +- **A command longer than `bashTimeout`.** Add `timeout: 2m` to the entry. | ||
| 52 | +- **Ask before each call.** Set `yolo: false`: the declared tools then ask like `bash` does. With `yolo: true` they run without asking. | ||
| 53 | +- **A shell feature (pipe, redirection, `&&`).** The command runs without a shell, so write it in a script and declare the script. Passing `["bash", "-c", "…{{p}}…"]` would put the model's value back into a shell line. | ||
| 54 | +- **The same action as an MCP tool.** A declared tool cannot take the name of an MCP tool: it is skipped with a start-up warning. Rename one of them. | ||
| 55 | +- **Restrict what runs before or after.** `preToolCall` and `postToolCall` [hooks](guard-the-agent-with-hooks.md) apply to declared tools; use the tool's name as `matcher`. | ||
| 56 | + | ||
| 57 | +## See also | ||
| 58 | + | ||
| 59 | +- A complete example with four tools and every parameter type: `demos/declared-tools/` | ||
| 60 | +- Every key, type and error: [configuration reference](../reference/configuration.md#tools-keys) and [declared tools](../reference/tools.md#declared-tools) | ||
| 61 | +- Why typed tools beside skills, and what was measured: [Declared tools](../explanation/declared-tools.md) | ||
modified
docs/en/reference/configuration.md +45 -2 | @@ -35,6 +35,7 @@ | ||
| 35 | 35 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| 36 | 36 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 37 | 37 | | `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | |
| 38 | +| `tools` | list | `[]` | Commands declared as typed tools: each entry a `name`, a `description`, its `parameters` and a `command` run without a shell. See [`tools` keys](#tools-keys) below and [declared tools](tools.md#declared-tools). | | |
| 38 | 39 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | |
| 39 | 40 | | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). | |
| 40 | 41 | | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. | |
| @@ -120,6 +121,34 @@ lsp: | ||
| 120 | 121 | |
| 121 | 122 | The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls active, other servers ready to uncomment) and a system prompt that says when to use the tool. |
| 122 | 123 | |
| 124 | +## `tools` keys | |
| 125 | + | |
| 126 | +| Key | Type | Default | Description | | |
| 127 | +|-----|------|---------|-------------| | |
| 128 | +| `tools` | list | `[]` | One entry per declared tool. | | |
| 129 | +| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), even one this agent turns off. | | |
| 130 | +| `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. | | |
| 131 | +| `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. | | |
| 132 | +| `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. | | |
| 133 | +| `tools[].parameters.<p>.description` | string | `""` | What the value is, shown to the model. | | |
| 134 | +| `tools[].parameters.<p>.enum` | list | none | The only values accepted, each of the parameter's type. | | |
| 135 | +| `tools[].parameters.<p>.required` | bool | `true` | `false` makes the parameter optional. | | |
| 136 | +| `tools[].parameters.<p>.default` | value | none | The value of an optional parameter the call leaves out. Only with `required: false`; of the parameter's type; inside the `enum` when there is one. | | |
| 137 | +| `tools[].command` | list of strings | — | The program, then its arguments, one argv entry each. Required. `{{p}}` inside an entry is replaced by the value of parameter `p`. The program (first entry) cannot contain a placeholder. Every placeholder must name a declared parameter. A relative program (`./scripts/x.sh`) is resolved from the working directory. | | |
| 138 | +| `tools[].timeout` | duration | `0` (= `bashTimeout`) | How long one call may run. Must be ≥ 0. | | |
| 139 | + | |
| 140 | +```yaml | |
| 141 | +tools: | |
| 142 | + - name: release | |
| 143 | + description: Prepare a release of the project for a version and a channel. | |
| 144 | + parameters: | |
| 145 | + version: { type: string, description: "e.g. 1.4.2" } | |
| 146 | + channel: { type: string, enum: [stable, beta, nightly] } | |
| 147 | + dryRun: { type: boolean, required: false, default: false } | |
| 148 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | |
| 149 | + timeout: 60s | |
| 150 | +``` | |
| 151 | + | |
| 123 | 152 | ## Provider defaults |
| 124 | 153 | |
| 125 | 154 | | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required | |
| @@ -203,19 +232,33 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | ||
| 203 | 232 | | `lsp.servers[N].extensions must list at least one extension` | a server without `extensions` | |
| 204 | 233 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot | |
| 205 | 234 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension | |
| 235 | +| `tools[N] (<name>).name "<name>" must start with a letter and hold only letters, digits, _ or - (64 characters at most)` | an invalid tool name | | |
| 236 | +| `tools[N] (<name>).name "<name>" is the name of a built-in tool` | a declared tool named like a built-in tool | | |
| 237 | +| `tools[N] (<name>): another tool is already called "<name>"` | two entries with one name | | |
| 238 | +| `tools[N] (<name>).description must not be empty: …` | no `description` | | |
| 239 | +| `tools[N] (<name>).command must name a program, …` | no `command`, or an empty first entry | | |
| 240 | +| `tools[N] (<name>).command: the program ("{{p}}") cannot come from a parameter` | a placeholder in the first entry | | |
| 241 | +| `tools[N] (<name>).command: {{p}} is not a declared parameter` | a placeholder no parameter declares | | |
| 242 | +| `tools[N] (<name>).timeout must be >= 0` | a negative `timeout` | | |
| 243 | +| `tools[N] (<name>).parameters.<p>.type "x" must be one of string, number, integer, boolean` | an unknown type | | |
| 244 | +| `tools[N] (<name>).parameters.<p>.enum: …` | an `enum` value not of the parameter's type | | |
| 245 | +| `tools[N] (<name>).parameters.<p>.default only makes sense with required: false` | a `default` on a required parameter | | |
| 246 | +| `tools[N] (<name>).parameters.<p>.default: …`, `….default <v> is not one of the enum values` | a `default` of the wrong type, or outside the `enum` | | |
| 247 | +| `tools[N] (<name>).parameters.<p>: declared twice`, `…: a parameter name must start with a letter …` | a repeated or invalid parameter name | | |
| 248 | +| `line N: parameters must be a mapping of name → {type, description, …}` | `parameters:` written as a list | | |
| 206 | 249 | | `permissions must be "auto" or "ask"` | any other value of the deprecated key | |
| 207 | 250 | | `AGENT_YOLO="maybe": must be true or false` | an `AGENT_YOLO` value `strconv.ParseBool` refuses (accepted: `1`, `t`, `true`, `0`, `f`, `false`, any case) | |
| 208 | 251 | | `bashTimeout must be > 0` | zero or negative | |
| 209 | 252 | | `structuredOutput:<line>:<col>: …` | a declaration the [`structuredOutput`](#structuredoutput) section cannot translate | |
| 210 | 253 | | `structuredOutput: no var declaration: …` | no `var` names the root | |
| 211 | -| `structuredOutput cannot be used with tools — this agent declares: <tools> (…)` | a `structuredOutput` with at least one declared tool (`bash`, `plan`, the file tools, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, MCP tools). Reported after the tools are assembled, before any front end starts. | | |
| 254 | +| `structuredOutput cannot be used with tools — this agent declares: <tools> (…)` | a `structuredOutput` with at least one declared tool (`bash`, `plan`, the file tools, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, MCP tools, declared `tools`). Reported after the tools are assembled, before any front end starts. | | |
| 212 | 255 | | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | |
| 213 | 256 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | |
| 214 | 257 | | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` | |
| 215 | 258 | | `hooks: unknown event "x" (…)`, `hooks.<event>[N]: …` | an invalid `hooks:` block — see [hooks](hooks.md#validation-errors) | |
| 216 | 259 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | |
| 217 | 260 | |
| 218 | -A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`). | |
| 261 | +A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when a declared tool has the name of an MCP tool (`tools: <name> skipped — an MCP tool already has that name`; the tool is not declared), and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`). | |
| 219 | 262 | |
| 220 | 263 | ## Shipped files |
| 221 | 264 | |
| @@ -35,6 +35,7 @@ | |||
| 35 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | | 35 | | `acp` | map | see below | How `mm -acp` introduces itself to the editor. | |
| 36 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | | 36 | | `lsp` | map | see below | Language servers behind the `lsp` tool. No server listed = no tool. | |
| 37 | | `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | | 37 | | `hooks` | map event → list | none | Commands run before a question reaches the model (`preGeneration`), before and after each tool call (`preToolCall`, `postToolCall`) after the turn (`postGeneration`), and before and after each hand-off to another agent (`preAsk`, `postAsk`); each reads one JSON object on stdin and may answer one on stdout. Entry keys: `command` (required), `matcher` (tool and agent events only: a tool name, or an agent name), `timeout` (default `10s`), `onError` (`fail` \| `warn` \| `ignore`, default `warn`). See [hooks](hooks.md). | |
| 38 | +| `tools` | list | `[]` | Commands declared as typed tools: each entry a `name`, a `description`, its `parameters` and a `command` run without a shell. See [`tools` keys](#tools-keys) below and [declared tools](tools.md#declared-tools). | | ||
| 38 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | | 39 | | `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. | |
| 39 | | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). | | 40 | | `yolo` | bool | `true` | `true`: the tools run without asking. `false`: every call that changes something — `bash`, `write_file`, `edit_file`, `fetch_content` with `output`, `lsp` `rename`, `plan` `create`/`update`/`next`/`delete` — waits for the user's answer in every front end (line mode, window, editor), and so does every MCP tool, whatever `mcp.permissions` says. Answers: allow once, allow this tool for the session, reject this call, never (this tool, for the session). `read_file`, `read_skill`, `fetch_content` without `output` and the `lsp` navigation actions never ask. In `-p` and `mm --ask`, nobody can answer: those calls are rejected. An agent started by `/ask`, `ask_agent` or `mm --ask` runs with `AGENT_YOLO=false` and submits its calls to the asking agent: the `yolo` of its `mmConfigs` entry decides when set, else the asking agent's `yolo`. Overridden by `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` still decide which tools exist. See [How to confirm tool calls](../how-to/confirm-tool-calls.md). | |
| 40 | | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. | | 41 | | `permissions` | string | absent | Deprecated, replaced by `yolo`. `ask` = `yolo: false` (and wins over `yolo: true`); `auto` changes nothing. Either value adds a start-up warning. Must be `auto` or `ask`. | |
| @@ -120,6 +121,34 @@ lsp: | |||
| 120 | 121 | ||
| 121 | The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls active, other servers ready to uncomment) and a system prompt that says when to use the tool. | 122 | The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls active, other servers ready to uncomment) and a system prompt that says when to use the tool. |
| 122 | 123 | ||
| 124 | +## `tools` keys | ||
| 125 | + | ||
| 126 | +| Key | Type | Default | Description | | ||
| 127 | +|-----|------|---------|-------------| | ||
| 128 | +| `tools` | list | `[]` | One entry per declared tool. | | ||
| 129 | +| `tools[].name` | string | — | What the model calls. Required. Starts with a letter; letters, digits, `_`, `-`; at most 64 characters. Unique. Not the name of a built-in tool (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), even one this agent turns off. | | ||
| 130 | +| `tools[].description` | string | — | What the tool does and when to use it, shown to the model. Required. A line `Required parameters: …` is appended. | | ||
| 131 | +| `tools[].parameters` | map name → parameter | `{}` | The arguments, in the order written. Names follow the `name` rules. | | ||
| 132 | +| `tools[].parameters.<p>.type` | string | `string` | `string`, `number`, `integer` or `boolean`. | | ||
| 133 | +| `tools[].parameters.<p>.description` | string | `""` | What the value is, shown to the model. | | ||
| 134 | +| `tools[].parameters.<p>.enum` | list | none | The only values accepted, each of the parameter's type. | | ||
| 135 | +| `tools[].parameters.<p>.required` | bool | `true` | `false` makes the parameter optional. | | ||
| 136 | +| `tools[].parameters.<p>.default` | value | none | The value of an optional parameter the call leaves out. Only with `required: false`; of the parameter's type; inside the `enum` when there is one. | | ||
| 137 | +| `tools[].command` | list of strings | — | The program, then its arguments, one argv entry each. Required. `{{p}}` inside an entry is replaced by the value of parameter `p`. The program (first entry) cannot contain a placeholder. Every placeholder must name a declared parameter. A relative program (`./scripts/x.sh`) is resolved from the working directory. | | ||
| 138 | +| `tools[].timeout` | duration | `0` (= `bashTimeout`) | How long one call may run. Must be ≥ 0. | | ||
| 139 | + | ||
| 140 | +```yaml | ||
| 141 | +tools: | ||
| 142 | + - name: release | ||
| 143 | + description: Prepare a release of the project for a version and a channel. | ||
| 144 | + parameters: | ||
| 145 | + version: { type: string, description: "e.g. 1.4.2" } | ||
| 146 | + channel: { type: string, enum: [stable, beta, nightly] } | ||
| 147 | + dryRun: { type: boolean, required: false, default: false } | ||
| 148 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | ||
| 149 | + timeout: 60s | ||
| 150 | +``` | ||
| 151 | + | ||
| 123 | ## Provider defaults | 152 | ## Provider defaults |
| 124 | 153 | ||
| 125 | | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required | | 154 | | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required | |
| @@ -203,19 +232,33 @@ Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in ev | |||
| 203 | | `lsp.servers[N].extensions must list at least one extension` | a server without `extensions` | | 232 | | `lsp.servers[N].extensions must list at least one extension` | a server without `extensions` | |
| 204 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot | | 233 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | an extension without its dot | |
| 205 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension | | 234 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | two servers on one extension | |
| 235 | +| `tools[N] (<name>).name "<name>" must start with a letter and hold only letters, digits, _ or - (64 characters at most)` | an invalid tool name | | ||
| 236 | +| `tools[N] (<name>).name "<name>" is the name of a built-in tool` | a declared tool named like a built-in tool | | ||
| 237 | +| `tools[N] (<name>): another tool is already called "<name>"` | two entries with one name | | ||
| 238 | +| `tools[N] (<name>).description must not be empty: …` | no `description` | | ||
| 239 | +| `tools[N] (<name>).command must name a program, …` | no `command`, or an empty first entry | | ||
| 240 | +| `tools[N] (<name>).command: the program ("{{p}}") cannot come from a parameter` | a placeholder in the first entry | | ||
| 241 | +| `tools[N] (<name>).command: {{p}} is not a declared parameter` | a placeholder no parameter declares | | ||
| 242 | +| `tools[N] (<name>).timeout must be >= 0` | a negative `timeout` | | ||
| 243 | +| `tools[N] (<name>).parameters.<p>.type "x" must be one of string, number, integer, boolean` | an unknown type | | ||
| 244 | +| `tools[N] (<name>).parameters.<p>.enum: …` | an `enum` value not of the parameter's type | | ||
| 245 | +| `tools[N] (<name>).parameters.<p>.default only makes sense with required: false` | a `default` on a required parameter | | ||
| 246 | +| `tools[N] (<name>).parameters.<p>.default: …`, `….default <v> is not one of the enum values` | a `default` of the wrong type, or outside the `enum` | | ||
| 247 | +| `tools[N] (<name>).parameters.<p>: declared twice`, `…: a parameter name must start with a letter …` | a repeated or invalid parameter name | | ||
| 248 | +| `line N: parameters must be a mapping of name → {type, description, …}` | `parameters:` written as a list | | ||
| 206 | | `permissions must be "auto" or "ask"` | any other value of the deprecated key | | 249 | | `permissions must be "auto" or "ask"` | any other value of the deprecated key | |
| 207 | | `AGENT_YOLO="maybe": must be true or false` | an `AGENT_YOLO` value `strconv.ParseBool` refuses (accepted: `1`, `t`, `true`, `0`, `f`, `false`, any case) | | 250 | | `AGENT_YOLO="maybe": must be true or false` | an `AGENT_YOLO` value `strconv.ParseBool` refuses (accepted: `1`, `t`, `true`, `0`, `f`, `false`, any case) | |
| 208 | | `bashTimeout must be > 0` | zero or negative | | 251 | | `bashTimeout must be > 0` | zero or negative | |
| 209 | | `structuredOutput:<line>:<col>: …` | a declaration the [`structuredOutput`](#structuredoutput) section cannot translate | | 252 | | `structuredOutput:<line>:<col>: …` | a declaration the [`structuredOutput`](#structuredoutput) section cannot translate | |
| 210 | | `structuredOutput: no var declaration: …` | no `var` names the root | | 253 | | `structuredOutput: no var declaration: …` | no `var` names the root | |
| 211 | -| `structuredOutput cannot be used with tools — this agent declares: <tools> (…)` | a `structuredOutput` with at least one declared tool (`bash`, `plan`, the file tools, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, MCP tools). Reported after the tools are assembled, before any front end starts. | | 254 | +| `structuredOutput cannot be used with tools — this agent declares: <tools> (…)` | a `structuredOutput` with at least one declared tool (`bash`, `plan`, the file tools, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, MCP tools, declared `tools`). Reported after the tools are assembled, before any front end starts. | |
| 212 | | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | | 255 | | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | |
| 213 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | | 256 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | |
| 214 | | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` | | 257 | | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` | |
| 215 | | `hooks: unknown event "x" (…)`, `hooks.<event>[N]: …` | an invalid `hooks:` block — see [hooks](hooks.md#validation-errors) | | 258 | | `hooks: unknown event "x" (…)`, `hooks.<event>[N]: …` | an invalid `hooks:` block — see [hooks](hooks.md#validation-errors) | |
| 216 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | | 259 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Reported by the engine, as `[engine error: …]`. | |
| 217 | 260 | ||
| 218 | -A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`). | 261 | +A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`, and when a declared tool has the name of an MCP tool (`tools: <name> skipped — an MCP tool already has that name`; the tool is not declared), and when the file sets the deprecated `permissions` key (`permissions: ask is deprecated — use yolo: false instead …`). |
| 219 | 262 | ||
| 220 | ## Shipped files | 263 | ## Shipped files |
| 221 | 264 | ||
modified
docs/en/reference/tools.md +21 -0 | @@ -248,6 +248,27 @@ The declared schema describes a question's fields in words, without `properties` | ||
| 248 | 248 | | Result | Answered: `The user answered:` then one `- <question> → <answer>` line per question (several options joined by `, `). Declined: `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Cancelled: `[the question was cancelled before the user answered]`. Nobody to ask: `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Anything but an answer ends the call as failed in the window and the editor. | |
| 249 | 249 | | Errors | `[ask_user: no question — send questions: [{question, type, options}]]`; `[ask_user: N questions — ask at most 4 at a time]`; `[ask_user: question N has no text]`; `[ask_user: question N is a choice question — give it at least two options]` (or `multi`); `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. | |
| 250 | 250 | |
| 251 | +## Declared tools | |
| 252 | + | |
| 253 | +Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`. | |
| 254 | + | |
| 255 | +| Aspect | Behaviour | | |
| 256 | +|--------|-----------| | |
| 257 | +| Schema sent to the model | One property per parameter with its `type` and a description made of the parameter's `description`, `One of: …` for an `enum`, and `Optional.` or `Optional, default <v>.` for an optional parameter. No `required` and no `enum` keywords. The tool's description ends with `Required parameters: <names>. Give each parameter as its own field.` | | |
| 258 | +| Values | Coerced to the parameter's type. `number` and `integer` also accept a numeric string (`"3"`), `boolean` also accepts `"true"`/`"false"` (any case), `string` also accepts a number or a boolean. Rendered as text: integers without a decimal point, numbers without an exponent, booleans as `true`/`false`. | | |
| 259 | +| Optional parameter left out | Its `default` when there is one. Otherwise a command entry that is only its placeholder (`"{{p}}"`) is removed; an entry that contains it with other text gets an empty string in its place. | | |
| 260 | +| Invalid call | Nothing runs and nothing is asked. The output is `[<name>: invalid call, nothing was run — <faults>. You sent: <the call's input as JSON>. Expected: <each parameter with its type and description>. Call <name> again with each parameter as its own field.]`. Faults, all listed: `<p> is missing`, `<p>: "<v>" is not an integer` (or a number, a boolean, a string), `<p>: <v> is not one of <values>`, `"<key>" is not a parameter of this tool`. | | |
| 261 | +| Execution | The command's argv, without a shell: a value never adds an argument, a pipe or a redirection. | | |
| 262 | +| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode and the window. | | |
| 263 | +| Timeout | The entry's `timeout`, else `bashTimeout`; the output then ends with `[error: <timeout> timeout exceeded]`. | | |
| 264 | +| Program not found | `[error: <program> cannot be started: <reason>]`. | | |
| 265 | +| Cancellation, output, non-zero exit | As `bash`. | | |
| 266 | +| Permission | Requested when `yolo: false`; a refusal returns `[<name> rejected by the user]`. | | |
| 267 | +| Terminal display | `🧰 <name>: <argv>` (or `🧰 <name>: <input>` for an invalid call), then the output like `bash`'s, the refusal text included. | | |
| 268 | +| ACP and window | Tool call of kind `execute`, title `<name>: <argv>`. | | |
| 269 | +| Recap | Not counted in `⚙ N command(s)`. | | |
| 270 | +| Hooks | `preToolCall` and `postToolCall` apply, with the tool's name as `matcher`. | | |
| 271 | + | |
| 251 | 272 | ## Loop detection |
| 252 | 273 | |
| 253 | 274 | Every tool records `(tool, input, output)` in the loop detector after each call. |
| @@ -248,6 +248,27 @@ The declared schema describes a question's fields in words, without `properties` | |||
| 248 | | Result | Answered: `The user answered:` then one `- <question> → <answer>` line per question (several options joined by `, `). Declined: `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Cancelled: `[the question was cancelled before the user answered]`. Nobody to ask: `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Anything but an answer ends the call as failed in the window and the editor. | | 248 | | Result | Answered: `The user answered:` then one `- <question> → <answer>` line per question (several options joined by `, `). Declined: `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Cancelled: `[the question was cancelled before the user answered]`. Nobody to ask: `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Anything but an answer ends the call as failed in the window and the editor. | |
| 249 | | Errors | `[ask_user: no question — send questions: [{question, type, options}]]`; `[ask_user: N questions — ask at most 4 at a time]`; `[ask_user: question N has no text]`; `[ask_user: question N is a choice question — give it at least two options]` (or `multi`); `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. | | 249 | | Errors | `[ask_user: no question — send questions: [{question, type, options}]]`; `[ask_user: N questions — ask at most 4 at a time]`; `[ask_user: question N has no text]`; `[ask_user: question N is a choice question — give it at least two options]` (or `multi`); `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. | |
| 250 | 250 | ||
| 251 | +## Declared tools | ||
| 252 | + | ||
| 253 | +Declared from the `tools:` block of the agent file, one tool per entry (see [configuration](configuration.md#tools-keys)). Each has the entry's `name`; its fields are the entry's `parameters`. | ||
| 254 | + | ||
| 255 | +| Aspect | Behaviour | | ||
| 256 | +|--------|-----------| | ||
| 257 | +| Schema sent to the model | One property per parameter with its `type` and a description made of the parameter's `description`, `One of: …` for an `enum`, and `Optional.` or `Optional, default <v>.` for an optional parameter. No `required` and no `enum` keywords. The tool's description ends with `Required parameters: <names>. Give each parameter as its own field.` | | ||
| 258 | +| Values | Coerced to the parameter's type. `number` and `integer` also accept a numeric string (`"3"`), `boolean` also accepts `"true"`/`"false"` (any case), `string` also accepts a number or a boolean. Rendered as text: integers without a decimal point, numbers without an exponent, booleans as `true`/`false`. | | ||
| 259 | +| Optional parameter left out | Its `default` when there is one. Otherwise a command entry that is only its placeholder (`"{{p}}"`) is removed; an entry that contains it with other text gets an empty string in its place. | | ||
| 260 | +| Invalid call | Nothing runs and nothing is asked. The output is `[<name>: invalid call, nothing was run — <faults>. You sent: <the call's input as JSON>. Expected: <each parameter with its type and description>. Call <name> again with each parameter as its own field.]`. Faults, all listed: `<p> is missing`, `<p>: "<v>" is not an integer` (or a number, a boolean, a string), `<p>: <v> is not one of <values>`, `"<key>" is not a parameter of this tool`. | | ||
| 261 | +| Execution | The command's argv, without a shell: a value never adds an argument, a pipe or a redirection. | | ||
| 262 | +| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode and the window. | | ||
| 263 | +| Timeout | The entry's `timeout`, else `bashTimeout`; the output then ends with `[error: <timeout> timeout exceeded]`. | | ||
| 264 | +| Program not found | `[error: <program> cannot be started: <reason>]`. | | ||
| 265 | +| Cancellation, output, non-zero exit | As `bash`. | | ||
| 266 | +| Permission | Requested when `yolo: false`; a refusal returns `[<name> rejected by the user]`. | | ||
| 267 | +| Terminal display | `🧰 <name>: <argv>` (or `🧰 <name>: <input>` for an invalid call), then the output like `bash`'s, the refusal text included. | | ||
| 268 | +| ACP and window | Tool call of kind `execute`, title `<name>: <argv>`. | | ||
| 269 | +| Recap | Not counted in `⚙ N command(s)`. | | ||
| 270 | +| Hooks | `preToolCall` and `postToolCall` apply, with the tool's name as `matcher`. | | ||
| 271 | + | ||
| 251 | ## Loop detection | 272 | ## Loop detection |
| 252 | 273 | ||
| 253 | Every tool records `(tool, input, output)` in the loop detector after each call. | 274 | Every tool records `(tool, input, output)` in the loop detector after each call. |
modified
docs/fr/README.md +3 -1 | @@ -25,6 +25,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 25 | 25 | - [Laisser l'agent vous poser des questions](how-to/let-the-agent-ask-you.md) — `ask_user` : répondre dans le terminal, la fenêtre ou un éditeur, couper l'outil, décider pour les agents que le vôtre appelle (`askUserTool`, `mmConfigs.<nom>.askUser`, `AGENT_ASK_USER`) |
| 26 | 26 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur |
| 27 | 27 | - [Ajouter un skill](how-to/add-a-skill.md) |
| 28 | +- [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md) | |
| 28 | 29 | - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après |
| 29 | 30 | - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) |
| 30 | 31 | - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) |
| @@ -48,7 +49,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 48 | 49 | - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage |
| 49 | 50 | - [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre |
| 50 | 51 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation |
| 51 | -- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` | |
| 52 | +- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, outils déclarés | |
| 52 | 53 | - [Format d'un fichier de skill](reference/skill-format.md) |
| 53 | 54 | - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation |
| 54 | 55 | - [Surface Agent Client Protocol](reference/acp.md) |
| @@ -66,6 +67,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 66 | 67 | - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent |
| 67 | 68 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission |
| 68 | 69 | - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues |
| 70 | +- [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte | |
| 69 | 71 | - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie |
| 70 | 72 | - [Le mode yolo et les permissions](explanation/yolo-and-permissions.md) — pourquoi les outils s'exécutent sans demander par défaut, exister ou demander, quatre réponses dans un seul paquet, pourquoi l'agent qui interroge décide (et un `yolo` par agent dans `mmConfigs`) |
| 71 | 73 | - [Consignes du projet (`AGENTS.md`)](explanation/project-instructions.md) — pourquoi ajoutées au prompt système, pourquoi par conversation, pourquoi un avertissement plutôt qu'une coupe, qui ne les lit pas |
| @@ -25,6 +25,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 25 | - [Laisser l'agent vous poser des questions](how-to/let-the-agent-ask-you.md) — `ask_user` : répondre dans le terminal, la fenêtre ou un éditeur, couper l'outil, décider pour les agents que le vôtre appelle (`askUserTool`, `mmConfigs.<nom>.askUser`, `AGENT_ASK_USER`) | 25 | - [Laisser l'agent vous poser des questions](how-to/let-the-agent-ask-you.md) — `ask_user` : répondre dans le terminal, la fenêtre ou un éditeur, couper l'outil, décider pour les agents que le vôtre appelle (`askUserTool`, `mmConfigs.<nom>.askUser`, `AGENT_ASK_USER`) |
| 26 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur | 26 | - [Faire tourner mini-me dans un Docker Sandbox](how-to/run-in-a-docker-sandbox.md) — le kit `mini-me` de [mini-me-kit](https://rickub.com/bots-garden/mini-me-kit), les mixins `go-toolchain` et `cli-tools` sous `sbx/`, mode fenêtre et éditeur |
| 27 | - [Ajouter un skill](how-to/add-a-skill.md) | 27 | - [Ajouter un skill](how-to/add-a-skill.md) |
| 28 | +- [Déclarer un outil personnalisé](how-to/declare-a-custom-tool.md) | ||
| 28 | - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après | 29 | - [Mesurer si le modèle choisit le bon skill](how-to/measure-skill-routing.md) — l'eval de routage : le lancer, lire son tableau, s'en servir en avant/après |
| 29 | - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) | 30 | - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) |
| 30 | - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) | 31 | - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) |
| @@ -48,7 +49,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 48 | - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage | 49 | - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage |
| 49 | - [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre | 50 | - [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre |
| 50 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation | 51 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation |
| 51 | -- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user` | 52 | +- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`, `lsp`, `fetch_content`, `ask_agent`, `ask_user`, outils déclarés |
| 52 | - [Format d'un fichier de skill](reference/skill-format.md) | 53 | - [Format d'un fichier de skill](reference/skill-format.md) |
| 53 | - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation | 54 | - [Les hooks](reference/hooks.md) — les six événements, le JSON qu'un hook lit et écrit, `matcher`, `timeout`, `onError`, erreurs de validation |
| 54 | - [Surface Agent Client Protocol](reference/acp.md) | 55 | - [Surface Agent Client Protocol](reference/acp.md) |
| @@ -66,6 +67,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 66 | - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent | 67 | - [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent |
| 67 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission | 68 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission |
| 68 | - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues | 69 | - [L'outil `fetch_content`](explanation/fetch-content-tool.md) — pourquoi un outil intégré à côté du `fetch` MCP, un convertisseur maison, le robots.txt, des erreurs numérotées en texte, les limites connues |
| 70 | +- [Les outils déclarés](explanation/declared-tools.md) — pourquoi des outils typés à côté des skills, ce qui a été mesuré, pas de shell, appels invalides expliqués en texte | ||
| 69 | - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie | 71 | - [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie |
| 70 | - [Le mode yolo et les permissions](explanation/yolo-and-permissions.md) — pourquoi les outils s'exécutent sans demander par défaut, exister ou demander, quatre réponses dans un seul paquet, pourquoi l'agent qui interroge décide (et un `yolo` par agent dans `mmConfigs`) | 72 | - [Le mode yolo et les permissions](explanation/yolo-and-permissions.md) — pourquoi les outils s'exécutent sans demander par défaut, exister ou demander, quatre réponses dans un seul paquet, pourquoi l'agent qui interroge décide (et un `yolo` par agent dans `mmConfigs`) |
| 71 | - [Consignes du projet (`AGENTS.md`)](explanation/project-instructions.md) — pourquoi ajoutées au prompt système, pourquoi par conversation, pourquoi un avertissement plutôt qu'une coupe, qui ne les lit pas | 73 | - [Consignes du projet (`AGENTS.md`)](explanation/project-instructions.md) — pourquoi ajoutées au prompt système, pourquoi par conversation, pourquoi un avertissement plutôt qu'une coupe, qui ne les lit pas |
added
docs/fr/explanation/declared-tools.md +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +# Les outils déclarés — explication | |
| 2 | + | |
| 3 | +## De quoi s'agit-il ? | |
| 4 | + | |
| 5 | +Avant le ticket 88, mm offrait deux façons de laisser le modèle lancer l'une de vos commandes : un [skill](../how-to/add-a-skill.md) qui dit « appelle ton outil `bash` avec cette commande », et un serveur MCP qui expose un outil. Les outils déclarés en sont une troisième : une entrée `tools:` dans le fichier d'agent, qui transforme une commande en outil typé, sans serveur à écrire. Cette page explique pourquoi ils ont été ajoutés à côté des skills plutôt qu'à leur place, et pourquoi ils ont cette forme. | |
| 6 | + | |
| 7 | +## Pourquoi les skills ne suffisaient pas | |
| 8 | + | |
| 9 | +Un skill lance une commande en deux temps. Le modèle doit d'abord trouver le skill dans le catalogue et appeler `read_skill`, puis composer une ligne de commande `bash` à partir de ce qu'il a lu. Chaque étape peut échouer, et sur un petit modèle, elle échoue : | |
| 10 | + | |
| 11 | +- **Le skill n'est jamais trouvé.** Le catalogue est aiguillé par le modèle, qui lit des noms, des descriptions et des mots-clés. Mesuré sur mellum2-12b, un skill `status` (« Report the status of one of the company's services ») n'a jamais été lu pour « What is the status of the billing service? » — 0 run sur 10, en anglais comme en français. Le modèle a improvisé `curl localhost:8080/health` et `ps aux | grep billing`, puis a répondu que le service « semble tourner ». C'est une mauvaise réponse, pas seulement un raté. | |
| 12 | +- **La commande est réécrite.** Quand le skill est sauté mais que le modèle trouve quand même le script, il devine les arguments : `./pkgtest.sh greet` au lieu de `./greet`. | |
| 13 | +- **Le consentement est tout ou rien.** Tout ce qu'un skill lance passe par `bash`, donc « toujours autoriser ce script » ne peut se dire que « toujours autoriser bash ». | |
| 14 | + | |
| 15 | +Un outil déclaré supprime l'étape d'aiguillage : son nom, sa description et ses paramètres figurent dans chaque requête, comme ceux de `bash`. | |
| 16 | + | |
| 17 | +## Ce qui a été mesuré | |
| 18 | + | |
| 19 | +Trois actions (`release <version> <channel>`, `pkgtest <package>`, `status <service>`), chacune demandée en anglais et en français, 5 runs chacune, `mm -p`, sur deux modèles. Un run réussit quand le script a vraiment tourné avec exactement les arguments attendus — vérifié dans un fichier que le script écrit, pas dans la réponse du modèle. | |
| 20 | + | |
| 21 | +| Modèle | Skill + `bash` | Outil typé via une doublure MCP | Outil déclaré | | |
| 22 | +|--------|----------------|----------------------------------|---------------| | |
| 23 | +| mellum2-12b | 15/30 (4,5 s en moyenne) | 25/30 (2,8 s) | 30/30 (1,3 s) | | |
| 24 | +| qwen3.5-4b | 30/30 (12,7 s) | 30/30 (7,7 s) | 30/30 (5,8 s) | | |
| 25 | + | |
| 26 | +La doublure MCP a été mesurée en premier, avant d'écrire la moindre ligne : un petit serveur MCP HTTP exposant les trois mêmes outils donnait la même comparaison sans rien construire. C'est elle qui a tranché la question. Les outils déclarés ont été mesurés à nouveau une fois construits. | |
| 27 | + | |
| 28 | +Deux choses ressortent. D'abord, un outil typé est plus rapide même quand le skill fonctionne : un aller-retour avec le modèle au lieu de deux. Ensuite, les cinq échecs de la doublure sur mellum2 — la demande de release en français — venaient du modèle qui tassait tous les arguments dans un seul champ : `{version: "1.4.2 1.4.2, channel beta"}`. Avec l'outil déclaré, la même demande a réussi 5 fois sur 5, chaque fois en un seul appel. L'outil déclaré diffère de la doublure sur plusieurs points à la fois — pas de préfixe `proj_` dans le nom, pas de « MCP tool from server … » dans la description, une phrase finale « Give each parameter as its own field. » — et l'effet de chacun n'a pas été mesuré séparément. | |
| 29 | + | |
| 30 | +Ces chiffres viennent d'un seul petit projet de test, de deux modèles et de cinq runs par cas. Ils répondent à « est-ce que ça vaut la peine de le construire ? », pas à « de combien est-ce mieux en général ? ». | |
| 31 | + | |
| 32 | +## Pourquoi c'est conçu ainsi | |
| 33 | + | |
| 34 | +**Pas de shell.** La commande est une liste, une entrée argv par élément, et une valeur remplace son `{{placeholder}}` à l'intérieur d'une seule entrée. Une valeur du modèle — ou une valeur qu'une injection de prompt aurait glissée dans la conversation — ne peut ni ajouter un argument, ni enchaîner une seconde commande, ni rediriger une sortie : `1.4.2; rm -rf ~` arrive au script comme une seule chaîne de version, étrange. Un modèle de type `command: "./release.sh {{version}}"` exécuté par `bash -c` a été écarté pour exactement cette raison. Une commande qui a besoin d'un pipe va dans un script. | |
| 35 | + | |
| 36 | +**Le programme ne peut pas venir d'un paramètre.** Sinon, un outil déclaré n'est qu'un `bash` avec des étapes en plus. | |
| 37 | + | |
| 38 | +**Les appels invalides reçoivent une réponse, pas un rejet par le schéma.** Le schéma que lit le modèle porte les types, mais pas `required` ni `enum` comme mots-clés — ceux-là vont dans les descriptions. Genkit valide un appel contre le schéma déclaré avant de lancer l'outil, et un appel qu'il rejette met fin au tour. mm préfère répondre en texte, en disant ce qui ne va pas, ce que le modèle a envoyé et ce que doit être chaque paramètre, pour que le modèle puisse réessayer. Le même choix a été fait pour les outils MCP. Citer l'appel compte : informé seulement que « channel is missing », mellum2 a renvoyé le même appel tassé six fois, jusqu'à ce que le [détecteur de boucle](../reference/tools.md#détection-de-boucle) arrête le tour. | |
| 39 | + | |
| 40 | +**Une conversion tolérante, rien de plus.** Les modèles écrivent `"3"` pour 3 et `"true"` pour true assez souvent pour que ces chaînes soient acceptées. Ce qui demanderait de deviner (`"yes"`, `"three"`) est refusé, avec la raison. | |
| 41 | + | |
| 42 | +**Le consentement suit `yolo`.** Un outil déclaré exécute quelque chose : avec `yolo: false`, il demande comme `bash` ; avec `yolo: true`, il ne demande pas. Demander « même en yolo » a été envisagé puis écarté : c'est l'auteur de l'agent qui a écrit la commande, ce qui engage déjà plus qu'un modèle qui compose une ligne `bash`. | |
| 43 | + | |
| 44 | +**Les noms sont réservés tôt.** Un outil déclaré ne peut pas prendre le nom d'un outil intégré, même désactivé, pour que l'activer plus tard ne casse pas le fichier. Un conflit avec un outil MCP ne se voit qu'une fois le serveur interrogé : c'est donc un avertissement au démarrage, pas une erreur. | |
| 45 | + | |
| 46 | +## Alternatives écartées | |
| 47 | + | |
| 48 | +- **Seulement des skills.** Moins chers à écrire et adaptés aux procédures, mais mesurés en échec là où ça compte, sur les petits modèles que vise mm. | |
| 49 | +- **Seulement MCP.** Donne déjà des outils typés, mais chacun demande un serveur à écrire, à lancer et à maintenir, en HTTP seulement (mm n'a pas de transport MCP stdio). Trop lourd pour « lancer ce script avec deux arguments ». | |
| 50 | +- **Un système de plugins Wasm** (ticket 46). Des outils isolés sont une autre question ; un outil déclaré s'exécute avec les droits de l'utilisateur, comme `bash`. | |
| 51 | + | |
| 52 | +## Liens avec le reste | |
| 53 | + | |
| 54 | +Les outils déclarés se rangent à côté des outils intégrés : le même détecteur de boucle, la même troncature et le même écho de la sortie, le même répertoire de travail et la même annulation que [`bash`](../reference/tools.md#bash), les mêmes dialogues de permission (voir [yolo et permissions](yolo-and-permissions.md)), et les [hooks](hooks.md) `preToolCall` / `postToolCall`. Les skills restent l'endroit du savoir-faire et des procédures ; un skill peut aussi dire au modèle d'appeler un outil déclaré. | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +# Les outils déclarés — explication | ||
| 2 | + | ||
| 3 | +## De quoi s'agit-il ? | ||
| 4 | + | ||
| 5 | +Avant le ticket 88, mm offrait deux façons de laisser le modèle lancer l'une de vos commandes : un [skill](../how-to/add-a-skill.md) qui dit « appelle ton outil `bash` avec cette commande », et un serveur MCP qui expose un outil. Les outils déclarés en sont une troisième : une entrée `tools:` dans le fichier d'agent, qui transforme une commande en outil typé, sans serveur à écrire. Cette page explique pourquoi ils ont été ajoutés à côté des skills plutôt qu'à leur place, et pourquoi ils ont cette forme. | ||
| 6 | + | ||
| 7 | +## Pourquoi les skills ne suffisaient pas | ||
| 8 | + | ||
| 9 | +Un skill lance une commande en deux temps. Le modèle doit d'abord trouver le skill dans le catalogue et appeler `read_skill`, puis composer une ligne de commande `bash` à partir de ce qu'il a lu. Chaque étape peut échouer, et sur un petit modèle, elle échoue : | ||
| 10 | + | ||
| 11 | +- **Le skill n'est jamais trouvé.** Le catalogue est aiguillé par le modèle, qui lit des noms, des descriptions et des mots-clés. Mesuré sur mellum2-12b, un skill `status` (« Report the status of one of the company's services ») n'a jamais été lu pour « What is the status of the billing service? » — 0 run sur 10, en anglais comme en français. Le modèle a improvisé `curl localhost:8080/health` et `ps aux | grep billing`, puis a répondu que le service « semble tourner ». C'est une mauvaise réponse, pas seulement un raté. | ||
| 12 | +- **La commande est réécrite.** Quand le skill est sauté mais que le modèle trouve quand même le script, il devine les arguments : `./pkgtest.sh greet` au lieu de `./greet`. | ||
| 13 | +- **Le consentement est tout ou rien.** Tout ce qu'un skill lance passe par `bash`, donc « toujours autoriser ce script » ne peut se dire que « toujours autoriser bash ». | ||
| 14 | + | ||
| 15 | +Un outil déclaré supprime l'étape d'aiguillage : son nom, sa description et ses paramètres figurent dans chaque requête, comme ceux de `bash`. | ||
| 16 | + | ||
| 17 | +## Ce qui a été mesuré | ||
| 18 | + | ||
| 19 | +Trois actions (`release <version> <channel>`, `pkgtest <package>`, `status <service>`), chacune demandée en anglais et en français, 5 runs chacune, `mm -p`, sur deux modèles. Un run réussit quand le script a vraiment tourné avec exactement les arguments attendus — vérifié dans un fichier que le script écrit, pas dans la réponse du modèle. | ||
| 20 | + | ||
| 21 | +| Modèle | Skill + `bash` | Outil typé via une doublure MCP | Outil déclaré | | ||
| 22 | +|--------|----------------|----------------------------------|---------------| | ||
| 23 | +| mellum2-12b | 15/30 (4,5 s en moyenne) | 25/30 (2,8 s) | 30/30 (1,3 s) | | ||
| 24 | +| qwen3.5-4b | 30/30 (12,7 s) | 30/30 (7,7 s) | 30/30 (5,8 s) | | ||
| 25 | + | ||
| 26 | +La doublure MCP a été mesurée en premier, avant d'écrire la moindre ligne : un petit serveur MCP HTTP exposant les trois mêmes outils donnait la même comparaison sans rien construire. C'est elle qui a tranché la question. Les outils déclarés ont été mesurés à nouveau une fois construits. | ||
| 27 | + | ||
| 28 | +Deux choses ressortent. D'abord, un outil typé est plus rapide même quand le skill fonctionne : un aller-retour avec le modèle au lieu de deux. Ensuite, les cinq échecs de la doublure sur mellum2 — la demande de release en français — venaient du modèle qui tassait tous les arguments dans un seul champ : `{version: "1.4.2 1.4.2, channel beta"}`. Avec l'outil déclaré, la même demande a réussi 5 fois sur 5, chaque fois en un seul appel. L'outil déclaré diffère de la doublure sur plusieurs points à la fois — pas de préfixe `proj_` dans le nom, pas de « MCP tool from server … » dans la description, une phrase finale « Give each parameter as its own field. » — et l'effet de chacun n'a pas été mesuré séparément. | ||
| 29 | + | ||
| 30 | +Ces chiffres viennent d'un seul petit projet de test, de deux modèles et de cinq runs par cas. Ils répondent à « est-ce que ça vaut la peine de le construire ? », pas à « de combien est-ce mieux en général ? ». | ||
| 31 | + | ||
| 32 | +## Pourquoi c'est conçu ainsi | ||
| 33 | + | ||
| 34 | +**Pas de shell.** La commande est une liste, une entrée argv par élément, et une valeur remplace son `{{placeholder}}` à l'intérieur d'une seule entrée. Une valeur du modèle — ou une valeur qu'une injection de prompt aurait glissée dans la conversation — ne peut ni ajouter un argument, ni enchaîner une seconde commande, ni rediriger une sortie : `1.4.2; rm -rf ~` arrive au script comme une seule chaîne de version, étrange. Un modèle de type `command: "./release.sh {{version}}"` exécuté par `bash -c` a été écarté pour exactement cette raison. Une commande qui a besoin d'un pipe va dans un script. | ||
| 35 | + | ||
| 36 | +**Le programme ne peut pas venir d'un paramètre.** Sinon, un outil déclaré n'est qu'un `bash` avec des étapes en plus. | ||
| 37 | + | ||
| 38 | +**Les appels invalides reçoivent une réponse, pas un rejet par le schéma.** Le schéma que lit le modèle porte les types, mais pas `required` ni `enum` comme mots-clés — ceux-là vont dans les descriptions. Genkit valide un appel contre le schéma déclaré avant de lancer l'outil, et un appel qu'il rejette met fin au tour. mm préfère répondre en texte, en disant ce qui ne va pas, ce que le modèle a envoyé et ce que doit être chaque paramètre, pour que le modèle puisse réessayer. Le même choix a été fait pour les outils MCP. Citer l'appel compte : informé seulement que « channel is missing », mellum2 a renvoyé le même appel tassé six fois, jusqu'à ce que le [détecteur de boucle](../reference/tools.md#détection-de-boucle) arrête le tour. | ||
| 39 | + | ||
| 40 | +**Une conversion tolérante, rien de plus.** Les modèles écrivent `"3"` pour 3 et `"true"` pour true assez souvent pour que ces chaînes soient acceptées. Ce qui demanderait de deviner (`"yes"`, `"three"`) est refusé, avec la raison. | ||
| 41 | + | ||
| 42 | +**Le consentement suit `yolo`.** Un outil déclaré exécute quelque chose : avec `yolo: false`, il demande comme `bash` ; avec `yolo: true`, il ne demande pas. Demander « même en yolo » a été envisagé puis écarté : c'est l'auteur de l'agent qui a écrit la commande, ce qui engage déjà plus qu'un modèle qui compose une ligne `bash`. | ||
| 43 | + | ||
| 44 | +**Les noms sont réservés tôt.** Un outil déclaré ne peut pas prendre le nom d'un outil intégré, même désactivé, pour que l'activer plus tard ne casse pas le fichier. Un conflit avec un outil MCP ne se voit qu'une fois le serveur interrogé : c'est donc un avertissement au démarrage, pas une erreur. | ||
| 45 | + | ||
| 46 | +## Alternatives écartées | ||
| 47 | + | ||
| 48 | +- **Seulement des skills.** Moins chers à écrire et adaptés aux procédures, mais mesurés en échec là où ça compte, sur les petits modèles que vise mm. | ||
| 49 | +- **Seulement MCP.** Donne déjà des outils typés, mais chacun demande un serveur à écrire, à lancer et à maintenir, en HTTP seulement (mm n'a pas de transport MCP stdio). Trop lourd pour « lancer ce script avec deux arguments ». | ||
| 50 | +- **Un système de plugins Wasm** (ticket 46). Des outils isolés sont une autre question ; un outil déclaré s'exécute avec les droits de l'utilisateur, comme `bash`. | ||
| 51 | + | ||
| 52 | +## Liens avec le reste | ||
| 53 | + | ||
| 54 | +Les outils déclarés se rangent à côté des outils intégrés : le même détecteur de boucle, la même troncature et le même écho de la sortie, le même répertoire de travail et la même annulation que [`bash`](../reference/tools.md#bash), les mêmes dialogues de permission (voir [yolo et permissions](yolo-and-permissions.md)), et les [hooks](hooks.md) `preToolCall` / `postToolCall`. Les skills restent l'endroit du savoir-faire et des procédures ; un skill peut aussi dire au modèle d'appeler un outil déclaré. | ||
added
docs/fr/how-to/declare-a-custom-tool.md +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +# Comment déclarer un outil personnalisé | |
| 2 | + | |
| 3 | +Ce guide montre comment transformer un script ou un programme en un outil que le modèle voit à chaque tour, avec des paramètres typés, en le déclarant dans le fichier d'agent. Il suppose que vous avez un fichier YAML d'agent et une commande que vous lancez déjà à la main. | |
| 4 | + | |
| 5 | +Utilisez-le pour une action fréquente et précise : lancer les tests d'un package, préparer une release, interroger un service. Pour une procédure en plusieurs étapes, ou un savoir-faire que le modèle doit lire avant d'agir, écrivez plutôt un [skill](add-a-skill.md). | |
| 6 | + | |
| 7 | +## Étapes | |
| 8 | + | |
| 9 | +1. Vérifiez que la commande fonctionne depuis le répertoire où mm s'exécute : | |
| 10 | + | |
| 11 | + ```bash | |
| 12 | + ./scripts/release.sh 1.4.2 beta | |
| 13 | + ``` | |
| 14 | + | |
| 15 | +2. Ajoutez un bloc `tools:` au fichier d'agent, avec une entrée par commande. Nommez l'outil d'après l'action, décrivez ce qu'il fait et quand s'en servir, déclarez un paramètre par argument, et écrivez la commande comme une liste — le programme d'abord, puis une entrée par argument, avec `{{paramètre}}` là où va une valeur : | |
| 16 | + | |
| 17 | + ```yaml | |
| 18 | + tools: | |
| 19 | + - name: release | |
| 20 | + description: Prepare a release of the project for a given version and channel. | |
| 21 | + parameters: | |
| 22 | + version: { type: string, description: "e.g. 1.4.2" } | |
| 23 | + channel: { type: string, enum: [stable, beta, nightly] } | |
| 24 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}"] | |
| 25 | + ``` | |
| 26 | + | |
| 27 | +3. Lancez mm et vérifiez que la bannière liste l'outil parmi les autres (ici `release`). Une erreur dans le bloc arrête mm avec un `[config error: …]` qui nomme l'entrée ; la [référence de configuration](../reference/configuration.md#erreurs-de-validation) les liste. | |
| 28 | + | |
| 29 | +4. Demandez l'action avec vos propres mots : | |
| 30 | + | |
| 31 | + ```bash | |
| 32 | + mm -p "Prépare la release 1.4.2 sur le canal beta." | |
| 33 | + ``` | |
| 34 | + | |
| 35 | + Le terminal affiche l'appel et sa sortie : | |
| 36 | + | |
| 37 | + ``` | |
| 38 | + 🧰 release: ./scripts/release.sh 1.4.2 beta | |
| 39 | + │ release done: 1.4.2 beta | |
| 40 | + ``` | |
| 41 | + | |
| 42 | +## Variantes | |
| 43 | + | |
| 44 | +- **Un nombre, un entier ou une option.** Donnez au paramètre un `type` : `number`, `integer` ou `boolean`. Un booléen va en général à l'intérieur d'une entrée : `"--dry-run={{dryRun}}"`. | |
| 45 | +- **Un argument facultatif.** Ajoutez `required: false`. Si le modèle l'omet, une entrée qui n'est que `"{{p}}"` disparaît de la commande ; donnez un `default` pour passer quand même une valeur : | |
| 46 | + | |
| 47 | + ```yaml | |
| 48 | + retries: { type: integer, required: false, default: 3 } | |
| 49 | + ``` | |
| 50 | + | |
| 51 | +- **Une commande plus longue que `bashTimeout`.** Ajoutez `timeout: 2m` à l'entrée. | |
| 52 | +- **Demander avant chaque appel.** Mettez `yolo: false` : les outils déclarés demandent alors comme `bash`. Avec `yolo: true`, ils s'exécutent sans demander. | |
| 53 | +- **Une fonction du shell (pipe, redirection, `&&`).** La commande s'exécute sans shell : écrivez-la dans un script et déclarez le script. Passer `["bash", "-c", "…{{p}}…"]` remettrait la valeur du modèle dans une ligne de shell. | |
| 54 | +- **La même action qu'un outil MCP.** Un outil déclaré ne peut pas prendre le nom d'un outil MCP : il est ignoré, avec un avertissement au démarrage. Renommez l'un des deux. | |
| 55 | +- **Contrôler ce qui s'exécute avant ou après.** Les [hooks](guard-the-agent-with-hooks.md) `preToolCall` et `postToolCall` s'appliquent aux outils déclarés ; utilisez le nom de l'outil comme `matcher`. | |
| 56 | + | |
| 57 | +## Voir aussi | |
| 58 | + | |
| 59 | +- Un exemple complet avec quatre outils et tous les types de paramètres : `demos/declared-tools/` | |
| 60 | +- Chaque clé, chaque type et chaque erreur : [référence de configuration](../reference/configuration.md#clés-de-tools) et [outils déclarés](../reference/tools.md#outils-déclarés) | |
| 61 | +- Pourquoi des outils typés à côté des skills, et ce qui a été mesuré : [Les outils déclarés](../explanation/declared-tools.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +# Comment déclarer un outil personnalisé | ||
| 2 | + | ||
| 3 | +Ce guide montre comment transformer un script ou un programme en un outil que le modèle voit à chaque tour, avec des paramètres typés, en le déclarant dans le fichier d'agent. Il suppose que vous avez un fichier YAML d'agent et une commande que vous lancez déjà à la main. | ||
| 4 | + | ||
| 5 | +Utilisez-le pour une action fréquente et précise : lancer les tests d'un package, préparer une release, interroger un service. Pour une procédure en plusieurs étapes, ou un savoir-faire que le modèle doit lire avant d'agir, écrivez plutôt un [skill](add-a-skill.md). | ||
| 6 | + | ||
| 7 | +## Étapes | ||
| 8 | + | ||
| 9 | +1. Vérifiez que la commande fonctionne depuis le répertoire où mm s'exécute : | ||
| 10 | + | ||
| 11 | + ```bash | ||
| 12 | + ./scripts/release.sh 1.4.2 beta | ||
| 13 | + ``` | ||
| 14 | + | ||
| 15 | +2. Ajoutez un bloc `tools:` au fichier d'agent, avec une entrée par commande. Nommez l'outil d'après l'action, décrivez ce qu'il fait et quand s'en servir, déclarez un paramètre par argument, et écrivez la commande comme une liste — le programme d'abord, puis une entrée par argument, avec `{{paramètre}}` là où va une valeur : | ||
| 16 | + | ||
| 17 | + ```yaml | ||
| 18 | + tools: | ||
| 19 | + - name: release | ||
| 20 | + description: Prepare a release of the project for a given version and channel. | ||
| 21 | + parameters: | ||
| 22 | + version: { type: string, description: "e.g. 1.4.2" } | ||
| 23 | + channel: { type: string, enum: [stable, beta, nightly] } | ||
| 24 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}"] | ||
| 25 | + ``` | ||
| 26 | + | ||
| 27 | +3. Lancez mm et vérifiez que la bannière liste l'outil parmi les autres (ici `release`). Une erreur dans le bloc arrête mm avec un `[config error: …]` qui nomme l'entrée ; la [référence de configuration](../reference/configuration.md#erreurs-de-validation) les liste. | ||
| 28 | + | ||
| 29 | +4. Demandez l'action avec vos propres mots : | ||
| 30 | + | ||
| 31 | + ```bash | ||
| 32 | + mm -p "Prépare la release 1.4.2 sur le canal beta." | ||
| 33 | + ``` | ||
| 34 | + | ||
| 35 | + Le terminal affiche l'appel et sa sortie : | ||
| 36 | + | ||
| 37 | + ``` | ||
| 38 | + 🧰 release: ./scripts/release.sh 1.4.2 beta | ||
| 39 | + │ release done: 1.4.2 beta | ||
| 40 | + ``` | ||
| 41 | + | ||
| 42 | +## Variantes | ||
| 43 | + | ||
| 44 | +- **Un nombre, un entier ou une option.** Donnez au paramètre un `type` : `number`, `integer` ou `boolean`. Un booléen va en général à l'intérieur d'une entrée : `"--dry-run={{dryRun}}"`. | ||
| 45 | +- **Un argument facultatif.** Ajoutez `required: false`. Si le modèle l'omet, une entrée qui n'est que `"{{p}}"` disparaît de la commande ; donnez un `default` pour passer quand même une valeur : | ||
| 46 | + | ||
| 47 | + ```yaml | ||
| 48 | + retries: { type: integer, required: false, default: 3 } | ||
| 49 | + ``` | ||
| 50 | + | ||
| 51 | +- **Une commande plus longue que `bashTimeout`.** Ajoutez `timeout: 2m` à l'entrée. | ||
| 52 | +- **Demander avant chaque appel.** Mettez `yolo: false` : les outils déclarés demandent alors comme `bash`. Avec `yolo: true`, ils s'exécutent sans demander. | ||
| 53 | +- **Une fonction du shell (pipe, redirection, `&&`).** La commande s'exécute sans shell : écrivez-la dans un script et déclarez le script. Passer `["bash", "-c", "…{{p}}…"]` remettrait la valeur du modèle dans une ligne de shell. | ||
| 54 | +- **La même action qu'un outil MCP.** Un outil déclaré ne peut pas prendre le nom d'un outil MCP : il est ignoré, avec un avertissement au démarrage. Renommez l'un des deux. | ||
| 55 | +- **Contrôler ce qui s'exécute avant ou après.** Les [hooks](guard-the-agent-with-hooks.md) `preToolCall` et `postToolCall` s'appliquent aux outils déclarés ; utilisez le nom de l'outil comme `matcher`. | ||
| 56 | + | ||
| 57 | +## Voir aussi | ||
| 58 | + | ||
| 59 | +- Un exemple complet avec quatre outils et tous les types de paramètres : `demos/declared-tools/` | ||
| 60 | +- Chaque clé, chaque type et chaque erreur : [référence de configuration](../reference/configuration.md#clés-de-tools) et [outils déclarés](../reference/tools.md#outils-déclarés) | ||
| 61 | +- Pourquoi des outils typés à côté des skills, et ce qui a été mesuré : [Les outils déclarés](../explanation/declared-tools.md) | ||
modified
docs/fr/reference/configuration.md +45 -2 | @@ -35,6 +35,7 @@ | ||
| 35 | 35 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| 36 | 36 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 37 | 37 | | `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | |
| 38 | +| `tools` | liste | `[]` | Des commandes déclarées comme outils typés : chaque entrée a un `name`, une `description`, ses `parameters` et une `command` exécutée sans shell. Voir les [clés de `tools`](#clés-de-tools) ci-dessous et les [outils déclarés](tools.md#outils-déclarés). | | |
| 38 | 39 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | |
| 39 | 40 | | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). | |
| 40 | 41 | | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. | |
| @@ -120,6 +121,34 @@ lsp: | ||
| 120 | 121 | |
| 121 | 122 | Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopls, les autres serveurs prêts à décommenter) et un prompt système qui dit quand utiliser l'outil. |
| 122 | 123 | |
| 124 | +## Clés de `tools` | |
| 125 | + | |
| 126 | +| Clé | Type | Défaut | Description | | |
| 127 | +|-----|------|--------|-------------| | |
| 128 | +| `tools` | liste | `[]` | Une entrée par outil déclaré. | | |
| 129 | +| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), même désactivé dans cet agent. | | |
| 130 | +| `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. | | |
| 131 | +| `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. | | |
| 132 | +| `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. | | |
| 133 | +| `tools[].parameters.<p>.description` | chaîne | `""` | Ce qu'est la valeur, montré au modèle. | | |
| 134 | +| `tools[].parameters.<p>.enum` | liste | aucune | Les seules valeurs acceptées, chacune du type du paramètre. | | |
| 135 | +| `tools[].parameters.<p>.required` | bool | `true` | `false` rend le paramètre facultatif. | | |
| 136 | +| `tools[].parameters.<p>.default` | valeur | aucune | La valeur d'un paramètre facultatif que l'appel omet. Seulement avec `required: false` ; du type du paramètre ; dans l'`enum` s'il y en a un. | | |
| 137 | +| `tools[].command` | liste de chaînes | — | Le programme, puis ses arguments, une entrée argv chacun. Obligatoire. `{{p}}` dans une entrée est remplacé par la valeur du paramètre `p`. Le programme (première entrée) ne peut pas contenir de placeholder. Chaque placeholder doit nommer un paramètre déclaré. Un programme relatif (`./scripts/x.sh`) est résolu depuis le répertoire de travail. | | |
| 138 | +| `tools[].timeout` | durée | `0` (= `bashTimeout`) | Durée maximale d'un appel. Doit être ≥ 0. | | |
| 139 | + | |
| 140 | +```yaml | |
| 141 | +tools: | |
| 142 | + - name: release | |
| 143 | + description: Prepare a release of the project for a version and a channel. | |
| 144 | + parameters: | |
| 145 | + version: { type: string, description: "e.g. 1.4.2" } | |
| 146 | + channel: { type: string, enum: [stable, beta, nightly] } | |
| 147 | + dryRun: { type: boolean, required: false, default: false } | |
| 148 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | |
| 149 | + timeout: 60s | |
| 150 | +``` | |
| 151 | + | |
| 123 | 152 | ## Valeurs par défaut des fournisseurs |
| 124 | 153 | |
| 125 | 154 | | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise | |
| @@ -203,19 +232,33 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | ||
| 203 | 232 | | `lsp.servers[N].extensions must list at least one extension` | un serveur sans `extensions` | |
| 204 | 233 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point | |
| 205 | 234 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension | |
| 235 | +| `tools[N] (<nom>).name "<nom>" must start with a letter and hold only letters, digits, _ or - (64 characters at most)` | un nom d'outil invalide | | |
| 236 | +| `tools[N] (<nom>).name "<nom>" is the name of a built-in tool` | un outil déclaré qui porte le nom d'un outil intégré | | |
| 237 | +| `tools[N] (<nom>): another tool is already called "<nom>"` | deux entrées de même nom | | |
| 238 | +| `tools[N] (<nom>).description must not be empty: …` | pas de `description` | | |
| 239 | +| `tools[N] (<nom>).command must name a program, …` | pas de `command`, ou une première entrée vide | | |
| 240 | +| `tools[N] (<nom>).command: the program ("{{p}}") cannot come from a parameter` | un placeholder dans la première entrée | | |
| 241 | +| `tools[N] (<nom>).command: {{p}} is not a declared parameter` | un placeholder qu'aucun paramètre ne déclare | | |
| 242 | +| `tools[N] (<nom>).timeout must be >= 0` | un `timeout` négatif | | |
| 243 | +| `tools[N] (<nom>).parameters.<p>.type "x" must be one of string, number, integer, boolean` | un type inconnu | | |
| 244 | +| `tools[N] (<nom>).parameters.<p>.enum: …` | une valeur d'`enum` qui n'est pas du type du paramètre | | |
| 245 | +| `tools[N] (<nom>).parameters.<p>.default only makes sense with required: false` | un `default` sur un paramètre obligatoire | | |
| 246 | +| `tools[N] (<nom>).parameters.<p>.default: …`, `….default <v> is not one of the enum values` | un `default` du mauvais type, ou hors de l'`enum` | | |
| 247 | +| `tools[N] (<nom>).parameters.<p>: declared twice`, `…: a parameter name must start with a letter …` | un nom de paramètre répété ou invalide | | |
| 248 | +| `line N: parameters must be a mapping of name → {type, description, …}` | `parameters:` écrit comme une liste | | |
| 206 | 249 | | `permissions must be "auto" or "ask"` | toute autre valeur de la clé obsolète | |
| 207 | 250 | | `AGENT_YOLO="maybe": must be true or false` | une valeur d'`AGENT_YOLO` que `strconv.ParseBool` refuse (acceptées : `1`, `t`, `true`, `0`, `f`, `false`, sans distinction de casse) | |
| 208 | 251 | | `bashTimeout must be > 0` | zéro ou négative | |
| 209 | 252 | | `structuredOutput:<ligne>:<col>: …` | une déclaration que la section [`structuredOutput`](#structuredoutput) ne sait pas traduire | |
| 210 | 253 | | `structuredOutput: no var declaration: …` | aucune `var` ne nomme la racine | |
| 211 | -| `structuredOutput cannot be used with tools — this agent declares: <outils> (…)` | un `structuredOutput` avec au moins un outil déclaré (`bash`, `plan`, les outils de fichiers, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, les outils MCP). Signalée après l'assemblage des outils, avant le démarrage de toute interface. | | |
| 254 | +| `structuredOutput cannot be used with tools — this agent declares: <outils> (…)` | un `structuredOutput` avec au moins un outil déclaré (`bash`, `plan`, les outils de fichiers, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, les outils MCP, les `tools` déclarés). Signalée après l'assemblage des outils, avant le démarrage de toute interface. | | |
| 212 | 255 | | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | |
| 213 | 256 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | |
| 214 | 257 | | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` | |
| 215 | 258 | | `hooks: unknown event "x" (…)`, `hooks.<événement>[N]: …` | un bloc `hooks:` invalide — voir [les hooks](hooks.md#erreurs-de-validation) | |
| 216 | 259 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 217 | 260 | |
| 218 | -Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`). | |
| 261 | +Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, quand un outil déclaré porte le nom d'un outil MCP (`tools: <nom> skipped — an MCP tool already has that name` ; l'outil n'est pas déclaré), et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`). | |
| 219 | 262 | |
| 220 | 263 | ## Fichiers livrés |
| 221 | 264 | |
| @@ -35,6 +35,7 @@ | |||
| 35 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | | 35 | | `acp` | dictionnaire | voir ci-dessous | La façon dont `mm -acp` se présente à l'éditeur. | |
| 36 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | | 36 | | `lsp` | dictionnaire | voir ci-dessous | Serveurs de langage derrière l'outil `lsp`. Aucun serveur listé = pas d'outil. | |
| 37 | | `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | | 37 | | `hooks` | dictionnaire événement → liste | aucun | Commandes lancées avant qu'une question atteigne le modèle (`preGeneration`), avant et après chaque appel d'outil (`preToolCall`, `postToolCall`) après le tour (`postGeneration`), et avant et après chaque relais vers un autre agent (`preAsk`, `postAsk`) ; chacune lit un objet JSON sur stdin et peut en répondre un sur stdout. Clés d'une entrée : `command` (obligatoire), `matcher` (événements d'outil et d'agent seulement : un nom d'outil, ou un nom d'agent), `timeout` (défaut `10s`), `onError` (`fail` \| `warn` \| `ignore`, défaut `warn`). Voir [les hooks](hooks.md). | |
| 38 | +| `tools` | liste | `[]` | Des commandes déclarées comme outils typés : chaque entrée a un `name`, une `description`, ses `parameters` et une `command` exécutée sans shell. Voir les [clés de `tools`](#clés-de-tools) ci-dessous et les [outils déclarés](tools.md#outils-déclarés). | | ||
| 38 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | | 39 | | `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. | |
| 39 | | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). | | 40 | | `yolo` | booléen | `true` | `true` : les outils s'exécutent sans demander. `false` : chaque appel qui modifie quelque chose — `bash`, `write_file`, `edit_file`, `fetch_content` avec `output`, `rename` de `lsp`, `create`/`update`/`next`/`delete` de `plan` — attend la réponse de l'utilisateur dans toutes les façades (mode ligne, fenêtre, éditeur), de même que chaque outil MCP, quoi que dise `mcp.permissions`. Réponses : autoriser une fois, autoriser cet outil pour la session, refuser cet appel, jamais (cet outil, pour la session). `read_file`, `read_skill`, `fetch_content` sans `output` et les actions de navigation de `lsp` ne demandent jamais. Avec `-p` et `mm --ask`, personne ne peut répondre : ces appels sont refusés. Un agent lancé par `/ask`, `ask_agent` ou `mm --ask` tourne avec `AGENT_YOLO=false` et soumet ses appels à l'agent qui l'interroge : le `yolo` de son entrée `mmConfigs` décide s'il est fixé, sinon le `yolo` de l'agent qui interroge. Surchargée par `AGENT_YOLO`. `bashTool`, `editTools`, `fetchContentTool` décident toujours quels outils existent. Voir [Confirmer les appels d'outils](../how-to/confirm-tool-calls.md). | |
| 40 | | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. | | 41 | | `permissions` | chaîne | absente | Obsolète, remplacée par `yolo`. `ask` = `yolo: false` (et l'emporte sur `yolo: true`) ; `auto` ne change rien. Les deux valeurs ajoutent un avertissement au démarrage. Doit valoir `auto` ou `ask`. | |
| @@ -120,6 +121,34 @@ lsp: | |||
| 120 | 121 | ||
| 121 | Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopls, les autres serveurs prêts à décommenter) et un prompt système qui dit quand utiliser l'outil. | 122 | Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopls, les autres serveurs prêts à décommenter) et un prompt système qui dit quand utiliser l'outil. |
| 122 | 123 | ||
| 124 | +## Clés de `tools` | ||
| 125 | + | ||
| 126 | +| Clé | Type | Défaut | Description | | ||
| 127 | +|-----|------|--------|-------------| | ||
| 128 | +| `tools` | liste | `[]` | Une entrée par outil déclaré. | | ||
| 129 | +| `tools[].name` | chaîne | — | Le nom que le modèle appelle. Obligatoire. Commence par une lettre ; lettres, chiffres, `_`, `-` ; 64 caractères au plus. Unique. Pas le nom d'un outil intégré (`ask_agent`, `ask_user`, `bash`, `edit_file`, `fetch_content`, `lsp`, `plan`, `read_file`, `read_skill`, `write_file`), même désactivé dans cet agent. | | ||
| 130 | +| `tools[].description` | chaîne | — | Ce que fait l'outil et quand s'en servir, montré au modèle. Obligatoire. Une ligne `Required parameters: …` y est ajoutée. | | ||
| 131 | +| `tools[].parameters` | map nom → paramètre | `{}` | Les arguments, dans l'ordre écrit. Les noms suivent les règles de `name`. | | ||
| 132 | +| `tools[].parameters.<p>.type` | chaîne | `string` | `string`, `number`, `integer` ou `boolean`. | | ||
| 133 | +| `tools[].parameters.<p>.description` | chaîne | `""` | Ce qu'est la valeur, montré au modèle. | | ||
| 134 | +| `tools[].parameters.<p>.enum` | liste | aucune | Les seules valeurs acceptées, chacune du type du paramètre. | | ||
| 135 | +| `tools[].parameters.<p>.required` | bool | `true` | `false` rend le paramètre facultatif. | | ||
| 136 | +| `tools[].parameters.<p>.default` | valeur | aucune | La valeur d'un paramètre facultatif que l'appel omet. Seulement avec `required: false` ; du type du paramètre ; dans l'`enum` s'il y en a un. | | ||
| 137 | +| `tools[].command` | liste de chaînes | — | Le programme, puis ses arguments, une entrée argv chacun. Obligatoire. `{{p}}` dans une entrée est remplacé par la valeur du paramètre `p`. Le programme (première entrée) ne peut pas contenir de placeholder. Chaque placeholder doit nommer un paramètre déclaré. Un programme relatif (`./scripts/x.sh`) est résolu depuis le répertoire de travail. | | ||
| 138 | +| `tools[].timeout` | durée | `0` (= `bashTimeout`) | Durée maximale d'un appel. Doit être ≥ 0. | | ||
| 139 | + | ||
| 140 | +```yaml | ||
| 141 | +tools: | ||
| 142 | + - name: release | ||
| 143 | + description: Prepare a release of the project for a version and a channel. | ||
| 144 | + parameters: | ||
| 145 | + version: { type: string, description: "e.g. 1.4.2" } | ||
| 146 | + channel: { type: string, enum: [stable, beta, nightly] } | ||
| 147 | + dryRun: { type: boolean, required: false, default: false } | ||
| 148 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | ||
| 149 | + timeout: 60s | ||
| 150 | +``` | ||
| 151 | + | ||
| 123 | ## Valeurs par défaut des fournisseurs | 152 | ## Valeurs par défaut des fournisseurs |
| 124 | 153 | ||
| 125 | | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise | | 154 | | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise | |
| @@ -203,19 +232,33 @@ Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code | |||
| 203 | | `lsp.servers[N].extensions must list at least one extension` | un serveur sans `extensions` | | 232 | | `lsp.servers[N].extensions must list at least one extension` | un serveur sans `extensions` | |
| 204 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point | | 233 | | `lsp.servers[N].extensions: "go" must start with a dot, like ".go"` | une extension sans son point | |
| 205 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension | | 234 | | `lsp.servers[N]: extension ".ts" is already served by "typescript"` | deux serveurs sur une même extension | |
| 235 | +| `tools[N] (<nom>).name "<nom>" must start with a letter and hold only letters, digits, _ or - (64 characters at most)` | un nom d'outil invalide | | ||
| 236 | +| `tools[N] (<nom>).name "<nom>" is the name of a built-in tool` | un outil déclaré qui porte le nom d'un outil intégré | | ||
| 237 | +| `tools[N] (<nom>): another tool is already called "<nom>"` | deux entrées de même nom | | ||
| 238 | +| `tools[N] (<nom>).description must not be empty: …` | pas de `description` | | ||
| 239 | +| `tools[N] (<nom>).command must name a program, …` | pas de `command`, ou une première entrée vide | | ||
| 240 | +| `tools[N] (<nom>).command: the program ("{{p}}") cannot come from a parameter` | un placeholder dans la première entrée | | ||
| 241 | +| `tools[N] (<nom>).command: {{p}} is not a declared parameter` | un placeholder qu'aucun paramètre ne déclare | | ||
| 242 | +| `tools[N] (<nom>).timeout must be >= 0` | un `timeout` négatif | | ||
| 243 | +| `tools[N] (<nom>).parameters.<p>.type "x" must be one of string, number, integer, boolean` | un type inconnu | | ||
| 244 | +| `tools[N] (<nom>).parameters.<p>.enum: …` | une valeur d'`enum` qui n'est pas du type du paramètre | | ||
| 245 | +| `tools[N] (<nom>).parameters.<p>.default only makes sense with required: false` | un `default` sur un paramètre obligatoire | | ||
| 246 | +| `tools[N] (<nom>).parameters.<p>.default: …`, `….default <v> is not one of the enum values` | un `default` du mauvais type, ou hors de l'`enum` | | ||
| 247 | +| `tools[N] (<nom>).parameters.<p>: declared twice`, `…: a parameter name must start with a letter …` | un nom de paramètre répété ou invalide | | ||
| 248 | +| `line N: parameters must be a mapping of name → {type, description, …}` | `parameters:` écrit comme une liste | | ||
| 206 | | `permissions must be "auto" or "ask"` | toute autre valeur de la clé obsolète | | 249 | | `permissions must be "auto" or "ask"` | toute autre valeur de la clé obsolète | |
| 207 | | `AGENT_YOLO="maybe": must be true or false` | une valeur d'`AGENT_YOLO` que `strconv.ParseBool` refuse (acceptées : `1`, `t`, `true`, `0`, `f`, `false`, sans distinction de casse) | | 250 | | `AGENT_YOLO="maybe": must be true or false` | une valeur d'`AGENT_YOLO` que `strconv.ParseBool` refuse (acceptées : `1`, `t`, `true`, `0`, `f`, `false`, sans distinction de casse) | |
| 208 | | `bashTimeout must be > 0` | zéro ou négative | | 251 | | `bashTimeout must be > 0` | zéro ou négative | |
| 209 | | `structuredOutput:<ligne>:<col>: …` | une déclaration que la section [`structuredOutput`](#structuredoutput) ne sait pas traduire | | 252 | | `structuredOutput:<ligne>:<col>: …` | une déclaration que la section [`structuredOutput`](#structuredoutput) ne sait pas traduire | |
| 210 | | `structuredOutput: no var declaration: …` | aucune `var` ne nomme la racine | | 253 | | `structuredOutput: no var declaration: …` | aucune `var` ne nomme la racine | |
| 211 | -| `structuredOutput cannot be used with tools — this agent declares: <outils> (…)` | un `structuredOutput` avec au moins un outil déclaré (`bash`, `plan`, les outils de fichiers, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, les outils MCP). Signalée après l'assemblage des outils, avant le démarrage de toute interface. | | 254 | +| `structuredOutput cannot be used with tools — this agent declares: <outils> (…)` | un `structuredOutput` avec au moins un outil déclaré (`bash`, `plan`, les outils de fichiers, `fetch_content`, `read_skill`, `ask_agent`, `lsp`, les outils MCP, les `tools` déclarés). Signalée après l'assemblage des outils, avant le démarrage de toute interface. | |
| 212 | | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | | 255 | | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` | |
| 213 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | | 256 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` | |
| 214 | | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` | | 257 | | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` | |
| 215 | | `hooks: unknown event "x" (…)`, `hooks.<événement>[N]: …` | un bloc `hooks:` invalide — voir [les hooks](hooks.md#erreurs-de-validation) | | 258 | | `hooks: unknown event "x" (…)`, `hooks.<événement>[N]: …` | un bloc `hooks:` invalide — voir [les hooks](hooks.md#erreurs-de-validation) | |
| 216 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | | 259 | | `unknown provider "x" (known: dmr, llamacpp, llmman, ollama)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 217 | 260 | ||
| 218 | -Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`). | 261 | +Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`, quand un outil déclaré porte le nom d'un outil MCP (`tools: <nom> skipped — an MCP tool already has that name` ; l'outil n'est pas déclaré), et quand le fichier pose la clé obsolète `permissions` (`permissions: ask is deprecated — use yolo: false instead …`). |
| 219 | 262 | ||
| 220 | ## Fichiers livrés | 263 | ## Fichiers livrés |
| 221 | 264 | ||
modified
docs/fr/reference/tools.md +21 -0 | @@ -248,6 +248,27 @@ Le schéma déclaré décrit les champs d'une question en mots, sans `properties | ||
| 248 | 248 | | Résultat | Répondu : `The user answered:` puis une ligne `- <question> → <réponse>` par question (plusieurs options jointes par `, `). Refusé : `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Annulé : `[the question was cancelled before the user answered]`. Personne à interroger : `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Tout ce qui n'est pas une réponse termine l'appel en échec dans la fenêtre et l'éditeur. | |
| 249 | 249 | | Erreurs | `[ask_user: no question — send questions: [{question, type, options}]]` ; `[ask_user: N questions — ask at most 4 at a time]` ; `[ask_user: question N has no text]` ; `[ask_user: question N is a choice question — give it at least two options]` (ou `multi`) ; `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. | |
| 250 | 250 | |
| 251 | +## Outils déclarés | |
| 252 | + | |
| 253 | +Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée. | |
| 254 | + | |
| 255 | +| Aspect | Comportement | | |
| 256 | +|--------|--------------| | |
| 257 | +| Schéma envoyé au modèle | Une propriété par paramètre, avec son `type` et une description composée de la `description` du paramètre, de `One of: …` pour un `enum`, et de `Optional.` ou `Optional, default <v>.` pour un paramètre facultatif. Pas de mots-clés `required` ni `enum`. La description de l'outil se termine par `Required parameters: <noms>. Give each parameter as its own field.` | | |
| 258 | +| Valeurs | Converties dans le type du paramètre. `number` et `integer` acceptent aussi une chaîne numérique (`"3"`), `boolean` accepte aussi `"true"`/`"false"` (toute casse), `string` accepte aussi un nombre ou un booléen. Rendues en texte : entiers sans virgule, nombres sans exposant, booléens en `true`/`false`. | | |
| 259 | +| Paramètre facultatif omis | Son `default` s'il en a un. Sinon, une entrée de la commande qui n'est que son placeholder (`"{{p}}"`) est retirée ; une entrée qui le contient avec d'autre texte reçoit une chaîne vide à sa place. | | |
| 260 | +| Appel invalide | Rien n'est exécuté et rien n'est demandé. La sortie est `[<nom>: invalid call, nothing was run — <fautes>. You sent: <l'entrée de l'appel en JSON>. Expected: <chaque paramètre avec son type et sa description>. Call <nom> again with each parameter as its own field.]`. Fautes, toutes listées : `<p> is missing`, `<p>: "<v>" is not an integer` (ou a number, a boolean, a string), `<p>: <v> is not one of <valeurs>`, `"<clé>" is not a parameter of this tool`. | | |
| 261 | +| Exécution | L'argv de la commande, sans shell : une valeur n'ajoute jamais d'argument, de pipe ni de redirection. | | |
| 262 | +| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP et dans la fenêtre. | | |
| 263 | +| Délai | Le `timeout` de l'entrée, sinon `bashTimeout` ; la sortie se termine alors par `[error: <délai> timeout exceeded]`. | | |
| 264 | +| Programme introuvable | `[error: <programme> cannot be started: <raison>]`. | | |
| 265 | +| Annulation, sortie, code de retour non nul | Comme `bash`. | | |
| 266 | +| Permission | Demandée quand `yolo: false` ; un refus renvoie `[<nom> rejected by the user]`. | | |
| 267 | +| Affichage terminal | `🧰 <nom>: <argv>` (ou `🧰 <nom>: <entrée>` pour un appel invalide), puis la sortie comme pour `bash`, texte de refus compris. | | |
| 268 | +| ACP et fenêtre | Appel d'outil de type `execute`, titre `<nom>: <argv>`. | | |
| 269 | +| Récapitulatif | Non compté dans `⚙ N command(s)`. | | |
| 270 | +| Hooks | `preToolCall` et `postToolCall` s'appliquent, avec le nom de l'outil comme `matcher`. | | |
| 271 | + | |
| 251 | 272 | ## Détection de boucle |
| 252 | 273 | |
| 253 | 274 | Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel. |
| @@ -248,6 +248,27 @@ Le schéma déclaré décrit les champs d'une question en mots, sans `properties | |||
| 248 | | Résultat | Répondu : `The user answered:` puis une ligne `- <question> → <réponse>` par question (plusieurs options jointes par `, `). Refusé : `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Annulé : `[the question was cancelled before the user answered]`. Personne à interroger : `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Tout ce qui n'est pas une réponse termine l'appel en échec dans la fenêtre et l'éditeur. | | 248 | | Résultat | Répondu : `The user answered:` puis une ligne `- <question> → <réponse>` par question (plusieurs options jointes par `, `). Refusé : `[the user declined to answer: do not ask the same thing again; go on without it or stop]`. Annulé : `[the question was cancelled before the user answered]`. Personne à interroger : `[the user cannot be asked in this mode: make a reasonable assumption, say which, and go on]`. Tout ce qui n'est pas une réponse termine l'appel en échec dans la fenêtre et l'éditeur. | |
| 249 | | Erreurs | `[ask_user: no question — send questions: [{question, type, options}]]` ; `[ask_user: N questions — ask at most 4 at a time]` ; `[ask_user: question N has no text]` ; `[ask_user: question N is a choice question — give it at least two options]` (ou `multi`) ; `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. | | 249 | | Erreurs | `[ask_user: no question — send questions: [{question, type, options}]]` ; `[ask_user: N questions — ask at most 4 at a time]` ; `[ask_user: question N has no text]` ; `[ask_user: question N is a choice question — give it at least two options]` (ou `multi`) ; `[ask_user: question N has type "x" — use choice, multi, text or confirm]`. | |
| 250 | 250 | ||
| 251 | +## Outils déclarés | ||
| 252 | + | ||
| 253 | +Déclarés à partir du bloc `tools:` du fichier d'agent, un outil par entrée (voir la [configuration](configuration.md#clés-de-tools)). Chacun porte le `name` de l'entrée ; ses champs sont les `parameters` de l'entrée. | ||
| 254 | + | ||
| 255 | +| Aspect | Comportement | | ||
| 256 | +|--------|--------------| | ||
| 257 | +| Schéma envoyé au modèle | Une propriété par paramètre, avec son `type` et une description composée de la `description` du paramètre, de `One of: …` pour un `enum`, et de `Optional.` ou `Optional, default <v>.` pour un paramètre facultatif. Pas de mots-clés `required` ni `enum`. La description de l'outil se termine par `Required parameters: <noms>. Give each parameter as its own field.` | | ||
| 258 | +| Valeurs | Converties dans le type du paramètre. `number` et `integer` acceptent aussi une chaîne numérique (`"3"`), `boolean` accepte aussi `"true"`/`"false"` (toute casse), `string` accepte aussi un nombre ou un booléen. Rendues en texte : entiers sans virgule, nombres sans exposant, booléens en `true`/`false`. | | ||
| 259 | +| Paramètre facultatif omis | Son `default` s'il en a un. Sinon, une entrée de la commande qui n'est que son placeholder (`"{{p}}"`) est retirée ; une entrée qui le contient avec d'autre texte reçoit une chaîne vide à sa place. | | ||
| 260 | +| Appel invalide | Rien n'est exécuté et rien n'est demandé. La sortie est `[<nom>: invalid call, nothing was run — <fautes>. You sent: <l'entrée de l'appel en JSON>. Expected: <chaque paramètre avec son type et sa description>. Call <nom> again with each parameter as its own field.]`. Fautes, toutes listées : `<p> is missing`, `<p>: "<v>" is not an integer` (ou a number, a boolean, a string), `<p>: <v> is not one of <valeurs>`, `"<clé>" is not a parameter of this tool`. | | ||
| 261 | +| Exécution | L'argv de la commande, sans shell : une valeur n'ajoute jamais d'argument, de pipe ni de redirection. | | ||
| 262 | +| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP et dans la fenêtre. | | ||
| 263 | +| Délai | Le `timeout` de l'entrée, sinon `bashTimeout` ; la sortie se termine alors par `[error: <délai> timeout exceeded]`. | | ||
| 264 | +| Programme introuvable | `[error: <programme> cannot be started: <raison>]`. | | ||
| 265 | +| Annulation, sortie, code de retour non nul | Comme `bash`. | | ||
| 266 | +| Permission | Demandée quand `yolo: false` ; un refus renvoie `[<nom> rejected by the user]`. | | ||
| 267 | +| Affichage terminal | `🧰 <nom>: <argv>` (ou `🧰 <nom>: <entrée>` pour un appel invalide), puis la sortie comme pour `bash`, texte de refus compris. | | ||
| 268 | +| ACP et fenêtre | Appel d'outil de type `execute`, titre `<nom>: <argv>`. | | ||
| 269 | +| Récapitulatif | Non compté dans `⚙ N command(s)`. | | ||
| 270 | +| Hooks | `preToolCall` et `postToolCall` s'appliquent, avec le nom de l'outil comme `matcher`. | | ||
| 271 | + | ||
| 251 | ## Détection de boucle | 272 | ## Détection de boucle |
| 252 | 273 | ||
| 253 | Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel. | 274 | Chaque outil enregistre `(outil, entrée, sortie)` dans le détecteur de boucle après chaque appel. |
modified
internal/config/config.go +8 -0 | @@ -215,6 +215,11 @@ type Config struct { | ||
| 215 | 215 | // turn (internal/hooks, ticket 36). They read one JSON object on stdin |
| 216 | 216 | // and may answer one on stdout. None by default. |
| 217 | 217 | Hooks HooksConfig `yaml:"hooks"` |
| 218 | + | |
| 219 | + // Tools: commands declared as typed tools (ticket 88) — each one a name, | |
| 220 | + // a description, its parameters and an argv command run without a shell. | |
| 221 | + // None by default. See tools.go. | |
| 222 | + Tools []DeclaredTool `yaml:"tools"` | |
| 218 | 223 | } |
| 219 | 224 | |
| 220 | 225 | // YoloEnv names the variable that overrides `yolo` for one run ("true", |
| @@ -804,5 +809,8 @@ func (c Config) validate() error { | ||
| 804 | 809 | if err := c.Hooks.validate(); err != nil { |
| 805 | 810 | return err |
| 806 | 811 | } |
| 812 | + if err := c.validateTools(); err != nil { | |
| 813 | + return err | |
| 814 | + } | |
| 807 | 815 | return c.LSP.validate() |
| 808 | 816 | } |
| @@ -215,6 +215,11 @@ type Config struct { | |||
| 215 | // turn (internal/hooks, ticket 36). They read one JSON object on stdin | 215 | // turn (internal/hooks, ticket 36). They read one JSON object on stdin |
| 216 | // and may answer one on stdout. None by default. | 216 | // and may answer one on stdout. None by default. |
| 217 | Hooks HooksConfig `yaml:"hooks"` | 217 | Hooks HooksConfig `yaml:"hooks"` |
| 218 | + | ||
| 219 | + // Tools: commands declared as typed tools (ticket 88) — each one a name, | ||
| 220 | + // a description, its parameters and an argv command run without a shell. | ||
| 221 | + // None by default. See tools.go. | ||
| 222 | + Tools []DeclaredTool `yaml:"tools"` | ||
| 218 | } | 223 | } |
| 219 | 224 | ||
| 220 | // YoloEnv names the variable that overrides `yolo` for one run ("true", | 225 | // YoloEnv names the variable that overrides `yolo` for one run ("true", |
| @@ -804,5 +809,8 @@ func (c Config) validate() error { | |||
| 804 | if err := c.Hooks.validate(); err != nil { | 809 | if err := c.Hooks.validate(); err != nil { |
| 805 | return err | 810 | return err |
| 806 | } | 811 | } |
| 812 | + if err := c.validateTools(); err != nil { | ||
| 813 | + return err | ||
| 814 | + } | ||
| 807 | return c.LSP.validate() | 815 | return c.LSP.validate() |
| 808 | } | 816 | } |
added
internal/config/tools.go +349 -0 | new file mode 100644 | ||
| @@ -0,0 +1,349 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +// Declared tools (ticket 88): a `tools:` block in the agent file turns a | |
| 4 | +// command into a typed tool the model sees on every turn, with its own name, | |
| 5 | +// description and parameters — no skill to find first, no command line for | |
| 6 | +// the model to compose. | |
| 7 | +// | |
| 8 | +// tools: | |
| 9 | +// - name: release | |
| 10 | +// description: Prepare a release for a version and a channel. | |
| 11 | +// parameters: | |
| 12 | +// version: { type: string, description: "e.g. 1.4.2" } | |
| 13 | +// channel: { type: string, enum: [stable, beta, nightly] } | |
| 14 | +// dryRun: { type: boolean, required: false, default: false } | |
| 15 | +// command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | |
| 16 | +// timeout: 60s | |
| 17 | +// | |
| 18 | +// The command runs WITHOUT a shell: each element is one argv entry, and a | |
| 19 | +// parameter's value replaces its {{placeholder}} inside one entry, so a value | |
| 20 | +// cannot add an argument, a pipe or a redirection. | |
| 21 | + | |
| 22 | +import ( | |
| 23 | + "fmt" | |
| 24 | + "regexp" | |
| 25 | + "slices" | |
| 26 | + "strconv" | |
| 27 | + "strings" | |
| 28 | + "time" | |
| 29 | + | |
| 30 | + "gopkg.in/yaml.v3" | |
| 31 | +) | |
| 32 | + | |
| 33 | +// BuiltinToolNames are the names of mm's own tools. A declared tool may not | |
| 34 | +// take one — even one this agent turns off, so that turning it on later | |
| 35 | +// does not break the file — and an MCP tool may not either. | |
| 36 | +var BuiltinToolNames = []string{ | |
| 37 | + "ask_agent", | |
| 38 | + "ask_user", | |
| 39 | + "bash", | |
| 40 | + "edit_file", | |
| 41 | + "fetch_content", | |
| 42 | + "lsp", | |
| 43 | + "plan", | |
| 44 | + "read_file", | |
| 45 | + "read_skill", | |
| 46 | + "write_file", | |
| 47 | +} | |
| 48 | + | |
| 49 | +// The types a declared tool's parameter can have: the JSON Schema names, so | |
| 50 | +// what the file says is what the model's schema says. | |
| 51 | +const ( | |
| 52 | + ParamString = "string" | |
| 53 | + ParamNumber = "number" | |
| 54 | + ParamInteger = "integer" | |
| 55 | + ParamBoolean = "boolean" | |
| 56 | +) | |
| 57 | + | |
| 58 | +var paramTypes = []string{ParamString, ParamNumber, ParamInteger, ParamBoolean} | |
| 59 | + | |
| 60 | +// toolNamePattern is what a tool name may look like: the identifier shape | |
| 61 | +// every provider accepts in a function name. | |
| 62 | +var toolNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`) | |
| 63 | + | |
| 64 | +// placeholderPattern finds the {{name}} references in a command entry. | |
| 65 | +var placeholderPattern = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_-]*)\s*\}\}`) | |
| 66 | + | |
| 67 | +// DeclaredTool is one entry of the `tools:` block. | |
| 68 | +type DeclaredTool struct { | |
| 69 | + // Name: what the model calls. Unique, not a built-in tool's name. | |
| 70 | + Name string `yaml:"name"` | |
| 71 | + // Description: what the tool does, and when to use it — the model | |
| 72 | + // chooses the tool from this text. | |
| 73 | + Description string `yaml:"description"` | |
| 74 | + // Parameters: the arguments, in the order they are written. | |
| 75 | + Parameters ToolParameters `yaml:"parameters"` | |
| 76 | + // Command: the program and its arguments, one argv entry each; a | |
| 77 | + // {{parameter}} inside an entry is replaced by the call's value. | |
| 78 | + Command []string `yaml:"command"` | |
| 79 | + // Timeout: how long one call may run. 0 = bashTimeout. | |
| 80 | + Timeout time.Duration `yaml:"timeout"` | |
| 81 | +} | |
| 82 | + | |
| 83 | +// ToolParameter is one argument of a declared tool. | |
| 84 | +type ToolParameter struct { | |
| 85 | + // Name: the key under `parameters:`. | |
| 86 | + Name string `yaml:"-"` | |
| 87 | + // Type: string (default), number, integer or boolean. | |
| 88 | + Type string `yaml:"type"` | |
| 89 | + // Description: what the value is, with an example — the model reads it. | |
| 90 | + Description string `yaml:"description"` | |
| 91 | + // Enum: the only values accepted, when set. | |
| 92 | + Enum []any `yaml:"enum"` | |
| 93 | + // Required: false makes the parameter optional. Absent = true. | |
| 94 | + Required *bool `yaml:"required"` | |
| 95 | + // Default: the value an optional parameter takes when the call omits it. | |
| 96 | + Default any `yaml:"default"` | |
| 97 | +} | |
| 98 | + | |
| 99 | +// IsRequired reports whether a call must give this parameter. | |
| 100 | +func (p ToolParameter) IsRequired() bool { | |
| 101 | + return p.Required == nil || *p.Required | |
| 102 | +} | |
| 103 | + | |
| 104 | +// ToolParameters keeps the parameters in the order the file lists them: the | |
| 105 | +// schema the model reads, and the messages it gets, follow that order. | |
| 106 | +type ToolParameters []ToolParameter | |
| 107 | + | |
| 108 | +// UnmarshalYAML reads the `parameters:` mapping, keeping its order. | |
| 109 | +func (ps *ToolParameters) UnmarshalYAML(node *yaml.Node) error { | |
| 110 | + if node.Kind != yaml.MappingNode { | |
| 111 | + return fmt.Errorf("line %d: parameters must be a mapping of name → {type, description, …}", node.Line) | |
| 112 | + } | |
| 113 | + for i := 0; i+1 < len(node.Content); i += 2 { | |
| 114 | + var p ToolParameter | |
| 115 | + if err := node.Content[i+1].Decode(&p); err != nil { | |
| 116 | + return err | |
| 117 | + } | |
| 118 | + p.Name = node.Content[i].Value | |
| 119 | + if p.Type == "" { | |
| 120 | + p.Type = ParamString | |
| 121 | + } | |
| 122 | + *ps = append(*ps, p) | |
| 123 | + } | |
| 124 | + return nil | |
| 125 | +} | |
| 126 | + | |
| 127 | +// Find returns the parameter called name. | |
| 128 | +func (ps ToolParameters) Find(name string) (ToolParameter, bool) { | |
| 129 | + for _, p := range ps { | |
| 130 | + if p.Name == name { | |
| 131 | + return p, true | |
| 132 | + } | |
| 133 | + } | |
| 134 | + return ToolParameter{}, false | |
| 135 | +} | |
| 136 | + | |
| 137 | +// Placeholders lists the parameter names a command entry refers to. | |
| 138 | +// | |
| 139 | +// Placeholders("--dry-run={{dryRun}}") // ["dryRun"] | |
| 140 | +func Placeholders(entry string) []string { | |
| 141 | + var names []string | |
| 142 | + for _, m := range placeholderPattern.FindAllStringSubmatch(entry, -1) { | |
| 143 | + names = append(names, m[1]) | |
| 144 | + } | |
| 145 | + return names | |
| 146 | +} | |
| 147 | + | |
| 148 | +// ReplacePlaceholders replaces every {{name}} in entry by value(name). | |
| 149 | +func ReplacePlaceholders(entry string, value func(name string) string) string { | |
| 150 | + return placeholderPattern.ReplaceAllStringFunc(entry, func(m string) string { | |
| 151 | + return value(placeholderPattern.FindStringSubmatch(m)[1]) | |
| 152 | + }) | |
| 153 | +} | |
| 154 | + | |
| 155 | +// validateTools checks the `tools:` block: names unique and free, every | |
| 156 | +// command runnable as written, every parameter consistent. | |
| 157 | +func (c Config) validateTools() error { | |
| 158 | + seen := map[string]bool{} | |
| 159 | + for i, t := range c.Tools { | |
| 160 | + where := fmt.Sprintf("tools[%d]", i) | |
| 161 | + if t.Name != "" { | |
| 162 | + where = fmt.Sprintf("tools[%d] (%s)", i, t.Name) | |
| 163 | + } | |
| 164 | + if err := t.validate(where); err != nil { | |
| 165 | + return err | |
| 166 | + } | |
| 167 | + if seen[t.Name] { | |
| 168 | + return fmt.Errorf("%s: another tool is already called %q", where, t.Name) | |
| 169 | + } | |
| 170 | + seen[t.Name] = true | |
| 171 | + } | |
| 172 | + return nil | |
| 173 | +} | |
| 174 | + | |
| 175 | +// validate checks one declared tool. | |
| 176 | +func (t DeclaredTool) validate(where string) error { | |
| 177 | + switch { | |
| 178 | + case !toolNamePattern.MatchString(t.Name): | |
| 179 | + return fmt.Errorf("%s.name %q must start with a letter and hold only letters, digits, _ or - (64 characters at most)", where, t.Name) | |
| 180 | + case slices.Contains(BuiltinToolNames, t.Name): | |
| 181 | + return fmt.Errorf("%s.name %q is the name of a built-in tool", where, t.Name) | |
| 182 | + case strings.TrimSpace(t.Description) == "": | |
| 183 | + return fmt.Errorf("%s.description must not be empty: the model picks a tool from its description", where) | |
| 184 | + case len(t.Command) == 0 || strings.TrimSpace(t.Command[0]) == "": | |
| 185 | + return fmt.Errorf("%s.command must name a program, e.g. [\"./scripts/run.sh\", \"{{arg}}\"]", where) | |
| 186 | + case len(Placeholders(t.Command[0])) > 0: | |
| 187 | + return fmt.Errorf("%s.command: the program (%q) cannot come from a parameter", where, t.Command[0]) | |
| 188 | + case t.Timeout < 0: | |
| 189 | + return fmt.Errorf("%s.timeout must be >= 0", where) | |
| 190 | + } | |
| 191 | + if err := t.validateParameters(where); err != nil { | |
| 192 | + return err | |
| 193 | + } | |
| 194 | + return t.validatePlaceholders(where) | |
| 195 | +} | |
| 196 | + | |
| 197 | +// validateParameters checks each parameter's type, enum and default. | |
| 198 | +func (t DeclaredTool) validateParameters(where string) error { | |
| 199 | + seen := map[string]bool{} | |
| 200 | + for _, p := range t.Parameters { | |
| 201 | + at := where + ".parameters." + p.Name | |
| 202 | + if !toolNamePattern.MatchString(p.Name) { | |
| 203 | + return fmt.Errorf("%s: a parameter name must start with a letter and hold only letters, digits, _ or -", at) | |
| 204 | + } | |
| 205 | + if seen[p.Name] { | |
| 206 | + return fmt.Errorf("%s: declared twice", at) | |
| 207 | + } | |
| 208 | + seen[p.Name] = true | |
| 209 | + if err := p.validate(at); err != nil { | |
| 210 | + return err | |
| 211 | + } | |
| 212 | + } | |
| 213 | + return nil | |
| 214 | +} | |
| 215 | + | |
| 216 | +// validate checks one parameter (an absent type was read as string). | |
| 217 | +func (p ToolParameter) validate(at string) error { | |
| 218 | + if !slices.Contains(paramTypes, p.Type) { | |
| 219 | + return fmt.Errorf("%s.type %q must be one of %s", at, p.Type, strings.Join(paramTypes, ", ")) | |
| 220 | + } | |
| 221 | + for _, v := range p.Enum { | |
| 222 | + if _, err := p.Coerce(v); err != nil { | |
| 223 | + return fmt.Errorf("%s.enum: %v", at, err) | |
| 224 | + } | |
| 225 | + } | |
| 226 | + if p.Default == nil { | |
| 227 | + return nil | |
| 228 | + } | |
| 229 | + if p.IsRequired() { | |
| 230 | + return fmt.Errorf("%s.default only makes sense with required: false", at) | |
| 231 | + } | |
| 232 | + normal, err := p.Coerce(p.Default) | |
| 233 | + if err != nil { | |
| 234 | + return fmt.Errorf("%s.default: %v", at, err) | |
| 235 | + } | |
| 236 | + if !p.Allows(normal) { | |
| 237 | + return fmt.Errorf("%s.default %v is not one of the enum values", at, p.Default) | |
| 238 | + } | |
| 239 | + return nil | |
| 240 | +} | |
| 241 | + | |
| 242 | +// validatePlaceholders refuses a {{name}} that no parameter declares — a | |
| 243 | +// typo there would otherwise send the literal text to the program. | |
| 244 | +func (t DeclaredTool) validatePlaceholders(where string) error { | |
| 245 | + for _, entry := range t.Command { | |
| 246 | + for _, name := range Placeholders(entry) { | |
| 247 | + if _, ok := t.Parameters.Find(name); !ok { | |
| 248 | + return fmt.Errorf("%s.command: {{%s}} is not a declared parameter", where, name) | |
| 249 | + } | |
| 250 | + } | |
| 251 | + } | |
| 252 | + return nil | |
| 253 | +} | |
| 254 | + | |
| 255 | +// Coerce turns a value — from the file, or from the model's call — into the | |
| 256 | +// parameter's type, or says why it cannot. Models write numbers and booleans | |
| 257 | +// as strings often enough ("3", "true") that those are accepted; anything | |
| 258 | +// else is refused rather than guessed. | |
| 259 | +// | |
| 260 | +// ToolParameter{Type: "integer"}.Coerce("3") // 3, nil | |
| 261 | +// ToolParameter{Type: "boolean"}.Coerce("yes") // error | |
| 262 | +func (p ToolParameter) Coerce(v any) (any, error) { | |
| 263 | + switch p.Type { | |
| 264 | + case ParamNumber: | |
| 265 | + return coerceNumber(v) | |
| 266 | + case ParamInteger: | |
| 267 | + return coerceInteger(v) | |
| 268 | + case ParamBoolean: | |
| 269 | + return coerceBoolean(v) | |
| 270 | + default: | |
| 271 | + return coerceString(v) | |
| 272 | + } | |
| 273 | +} | |
| 274 | + | |
| 275 | +func coerceString(v any) (any, error) { | |
| 276 | + switch x := v.(type) { | |
| 277 | + case string: | |
| 278 | + return x, nil | |
| 279 | + case int, int64, float64, bool: | |
| 280 | + return fmt.Sprint(x), nil | |
| 281 | + } | |
| 282 | + return nil, fmt.Errorf("%v is not a string", v) | |
| 283 | +} | |
| 284 | + | |
| 285 | +func coerceNumber(v any) (any, error) { | |
| 286 | + switch x := v.(type) { | |
| 287 | + case int: | |
| 288 | + return float64(x), nil | |
| 289 | + case int64: | |
| 290 | + return float64(x), nil | |
| 291 | + case float64: | |
| 292 | + return x, nil | |
| 293 | + case string: | |
| 294 | + if f, err := strconv.ParseFloat(strings.TrimSpace(x), 64); err == nil { | |
| 295 | + return f, nil | |
| 296 | + } | |
| 297 | + } | |
| 298 | + return nil, fmt.Errorf("%q is not a number", fmt.Sprint(v)) | |
| 299 | +} | |
| 300 | + | |
| 301 | +func coerceInteger(v any) (any, error) { | |
| 302 | + f, err := coerceNumber(v) | |
| 303 | + if err != nil { | |
| 304 | + return nil, fmt.Errorf("%q is not an integer", fmt.Sprint(v)) | |
| 305 | + } | |
| 306 | + n := f.(float64) | |
| 307 | + if n != float64(int64(n)) { | |
| 308 | + return nil, fmt.Errorf("%v is not an integer", v) | |
| 309 | + } | |
| 310 | + return int64(n), nil | |
| 311 | +} | |
| 312 | + | |
| 313 | +func coerceBoolean(v any) (any, error) { | |
| 314 | + switch x := v.(type) { | |
| 315 | + case bool: | |
| 316 | + return x, nil | |
| 317 | + case string: | |
| 318 | + switch strings.ToLower(strings.TrimSpace(x)) { | |
| 319 | + case "true": | |
| 320 | + return true, nil | |
| 321 | + case "false": | |
| 322 | + return false, nil | |
| 323 | + } | |
| 324 | + } | |
| 325 | + return nil, fmt.Errorf("%q is not a boolean (true or false)", fmt.Sprint(v)) | |
| 326 | +} | |
| 327 | + | |
| 328 | +// Allows reports whether a coerced value is accepted by the enum (always, | |
| 329 | +// without one). | |
| 330 | +func (p ToolParameter) Allows(v any) bool { | |
| 331 | + if len(p.Enum) == 0 { | |
| 332 | + return true | |
| 333 | + } | |
| 334 | + for _, e := range p.Enum { | |
| 335 | + if allowed, err := p.Coerce(e); err == nil && allowed == v { | |
| 336 | + return true | |
| 337 | + } | |
| 338 | + } | |
| 339 | + return false | |
| 340 | +} | |
| 341 | + | |
| 342 | +// EnumText lists the enum values for a message or a description. | |
| 343 | +func (p ToolParameter) EnumText() string { | |
| 344 | + values := make([]string, len(p.Enum)) | |
| 345 | + for i, e := range p.Enum { | |
| 346 | + values[i] = fmt.Sprint(e) | |
| 347 | + } | |
| 348 | + return strings.Join(values, ", ") | |
| 349 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,349 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +// Declared tools (ticket 88): a `tools:` block in the agent file turns a | ||
| 4 | +// command into a typed tool the model sees on every turn, with its own name, | ||
| 5 | +// description and parameters — no skill to find first, no command line for | ||
| 6 | +// the model to compose. | ||
| 7 | +// | ||
| 8 | +// tools: | ||
| 9 | +// - name: release | ||
| 10 | +// description: Prepare a release for a version and a channel. | ||
| 11 | +// parameters: | ||
| 12 | +// version: { type: string, description: "e.g. 1.4.2" } | ||
| 13 | +// channel: { type: string, enum: [stable, beta, nightly] } | ||
| 14 | +// dryRun: { type: boolean, required: false, default: false } | ||
| 15 | +// command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | ||
| 16 | +// timeout: 60s | ||
| 17 | +// | ||
| 18 | +// The command runs WITHOUT a shell: each element is one argv entry, and a | ||
| 19 | +// parameter's value replaces its {{placeholder}} inside one entry, so a value | ||
| 20 | +// cannot add an argument, a pipe or a redirection. | ||
| 21 | + | ||
| 22 | +import ( | ||
| 23 | + "fmt" | ||
| 24 | + "regexp" | ||
| 25 | + "slices" | ||
| 26 | + "strconv" | ||
| 27 | + "strings" | ||
| 28 | + "time" | ||
| 29 | + | ||
| 30 | + "gopkg.in/yaml.v3" | ||
| 31 | +) | ||
| 32 | + | ||
| 33 | +// BuiltinToolNames are the names of mm's own tools. A declared tool may not | ||
| 34 | +// take one — even one this agent turns off, so that turning it on later | ||
| 35 | +// does not break the file — and an MCP tool may not either. | ||
| 36 | +var BuiltinToolNames = []string{ | ||
| 37 | + "ask_agent", | ||
| 38 | + "ask_user", | ||
| 39 | + "bash", | ||
| 40 | + "edit_file", | ||
| 41 | + "fetch_content", | ||
| 42 | + "lsp", | ||
| 43 | + "plan", | ||
| 44 | + "read_file", | ||
| 45 | + "read_skill", | ||
| 46 | + "write_file", | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +// The types a declared tool's parameter can have: the JSON Schema names, so | ||
| 50 | +// what the file says is what the model's schema says. | ||
| 51 | +const ( | ||
| 52 | + ParamString = "string" | ||
| 53 | + ParamNumber = "number" | ||
| 54 | + ParamInteger = "integer" | ||
| 55 | + ParamBoolean = "boolean" | ||
| 56 | +) | ||
| 57 | + | ||
| 58 | +var paramTypes = []string{ParamString, ParamNumber, ParamInteger, ParamBoolean} | ||
| 59 | + | ||
| 60 | +// toolNamePattern is what a tool name may look like: the identifier shape | ||
| 61 | +// every provider accepts in a function name. | ||
| 62 | +var toolNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`) | ||
| 63 | + | ||
| 64 | +// placeholderPattern finds the {{name}} references in a command entry. | ||
| 65 | +var placeholderPattern = regexp.MustCompile(`\{\{\s*([A-Za-z0-9_-]*)\s*\}\}`) | ||
| 66 | + | ||
| 67 | +// DeclaredTool is one entry of the `tools:` block. | ||
| 68 | +type DeclaredTool struct { | ||
| 69 | + // Name: what the model calls. Unique, not a built-in tool's name. | ||
| 70 | + Name string `yaml:"name"` | ||
| 71 | + // Description: what the tool does, and when to use it — the model | ||
| 72 | + // chooses the tool from this text. | ||
| 73 | + Description string `yaml:"description"` | ||
| 74 | + // Parameters: the arguments, in the order they are written. | ||
| 75 | + Parameters ToolParameters `yaml:"parameters"` | ||
| 76 | + // Command: the program and its arguments, one argv entry each; a | ||
| 77 | + // {{parameter}} inside an entry is replaced by the call's value. | ||
| 78 | + Command []string `yaml:"command"` | ||
| 79 | + // Timeout: how long one call may run. 0 = bashTimeout. | ||
| 80 | + Timeout time.Duration `yaml:"timeout"` | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +// ToolParameter is one argument of a declared tool. | ||
| 84 | +type ToolParameter struct { | ||
| 85 | + // Name: the key under `parameters:`. | ||
| 86 | + Name string `yaml:"-"` | ||
| 87 | + // Type: string (default), number, integer or boolean. | ||
| 88 | + Type string `yaml:"type"` | ||
| 89 | + // Description: what the value is, with an example — the model reads it. | ||
| 90 | + Description string `yaml:"description"` | ||
| 91 | + // Enum: the only values accepted, when set. | ||
| 92 | + Enum []any `yaml:"enum"` | ||
| 93 | + // Required: false makes the parameter optional. Absent = true. | ||
| 94 | + Required *bool `yaml:"required"` | ||
| 95 | + // Default: the value an optional parameter takes when the call omits it. | ||
| 96 | + Default any `yaml:"default"` | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +// IsRequired reports whether a call must give this parameter. | ||
| 100 | +func (p ToolParameter) IsRequired() bool { | ||
| 101 | + return p.Required == nil || *p.Required | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// ToolParameters keeps the parameters in the order the file lists them: the | ||
| 105 | +// schema the model reads, and the messages it gets, follow that order. | ||
| 106 | +type ToolParameters []ToolParameter | ||
| 107 | + | ||
| 108 | +// UnmarshalYAML reads the `parameters:` mapping, keeping its order. | ||
| 109 | +func (ps *ToolParameters) UnmarshalYAML(node *yaml.Node) error { | ||
| 110 | + if node.Kind != yaml.MappingNode { | ||
| 111 | + return fmt.Errorf("line %d: parameters must be a mapping of name → {type, description, …}", node.Line) | ||
| 112 | + } | ||
| 113 | + for i := 0; i+1 < len(node.Content); i += 2 { | ||
| 114 | + var p ToolParameter | ||
| 115 | + if err := node.Content[i+1].Decode(&p); err != nil { | ||
| 116 | + return err | ||
| 117 | + } | ||
| 118 | + p.Name = node.Content[i].Value | ||
| 119 | + if p.Type == "" { | ||
| 120 | + p.Type = ParamString | ||
| 121 | + } | ||
| 122 | + *ps = append(*ps, p) | ||
| 123 | + } | ||
| 124 | + return nil | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// Find returns the parameter called name. | ||
| 128 | +func (ps ToolParameters) Find(name string) (ToolParameter, bool) { | ||
| 129 | + for _, p := range ps { | ||
| 130 | + if p.Name == name { | ||
| 131 | + return p, true | ||
| 132 | + } | ||
| 133 | + } | ||
| 134 | + return ToolParameter{}, false | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +// Placeholders lists the parameter names a command entry refers to. | ||
| 138 | +// | ||
| 139 | +// Placeholders("--dry-run={{dryRun}}") // ["dryRun"] | ||
| 140 | +func Placeholders(entry string) []string { | ||
| 141 | + var names []string | ||
| 142 | + for _, m := range placeholderPattern.FindAllStringSubmatch(entry, -1) { | ||
| 143 | + names = append(names, m[1]) | ||
| 144 | + } | ||
| 145 | + return names | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +// ReplacePlaceholders replaces every {{name}} in entry by value(name). | ||
| 149 | +func ReplacePlaceholders(entry string, value func(name string) string) string { | ||
| 150 | + return placeholderPattern.ReplaceAllStringFunc(entry, func(m string) string { | ||
| 151 | + return value(placeholderPattern.FindStringSubmatch(m)[1]) | ||
| 152 | + }) | ||
| 153 | +} | ||
| 154 | + | ||
| 155 | +// validateTools checks the `tools:` block: names unique and free, every | ||
| 156 | +// command runnable as written, every parameter consistent. | ||
| 157 | +func (c Config) validateTools() error { | ||
| 158 | + seen := map[string]bool{} | ||
| 159 | + for i, t := range c.Tools { | ||
| 160 | + where := fmt.Sprintf("tools[%d]", i) | ||
| 161 | + if t.Name != "" { | ||
| 162 | + where = fmt.Sprintf("tools[%d] (%s)", i, t.Name) | ||
| 163 | + } | ||
| 164 | + if err := t.validate(where); err != nil { | ||
| 165 | + return err | ||
| 166 | + } | ||
| 167 | + if seen[t.Name] { | ||
| 168 | + return fmt.Errorf("%s: another tool is already called %q", where, t.Name) | ||
| 169 | + } | ||
| 170 | + seen[t.Name] = true | ||
| 171 | + } | ||
| 172 | + return nil | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +// validate checks one declared tool. | ||
| 176 | +func (t DeclaredTool) validate(where string) error { | ||
| 177 | + switch { | ||
| 178 | + case !toolNamePattern.MatchString(t.Name): | ||
| 179 | + return fmt.Errorf("%s.name %q must start with a letter and hold only letters, digits, _ or - (64 characters at most)", where, t.Name) | ||
| 180 | + case slices.Contains(BuiltinToolNames, t.Name): | ||
| 181 | + return fmt.Errorf("%s.name %q is the name of a built-in tool", where, t.Name) | ||
| 182 | + case strings.TrimSpace(t.Description) == "": | ||
| 183 | + return fmt.Errorf("%s.description must not be empty: the model picks a tool from its description", where) | ||
| 184 | + case len(t.Command) == 0 || strings.TrimSpace(t.Command[0]) == "": | ||
| 185 | + return fmt.Errorf("%s.command must name a program, e.g. [\"./scripts/run.sh\", \"{{arg}}\"]", where) | ||
| 186 | + case len(Placeholders(t.Command[0])) > 0: | ||
| 187 | + return fmt.Errorf("%s.command: the program (%q) cannot come from a parameter", where, t.Command[0]) | ||
| 188 | + case t.Timeout < 0: | ||
| 189 | + return fmt.Errorf("%s.timeout must be >= 0", where) | ||
| 190 | + } | ||
| 191 | + if err := t.validateParameters(where); err != nil { | ||
| 192 | + return err | ||
| 193 | + } | ||
| 194 | + return t.validatePlaceholders(where) | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +// validateParameters checks each parameter's type, enum and default. | ||
| 198 | +func (t DeclaredTool) validateParameters(where string) error { | ||
| 199 | + seen := map[string]bool{} | ||
| 200 | + for _, p := range t.Parameters { | ||
| 201 | + at := where + ".parameters." + p.Name | ||
| 202 | + if !toolNamePattern.MatchString(p.Name) { | ||
| 203 | + return fmt.Errorf("%s: a parameter name must start with a letter and hold only letters, digits, _ or -", at) | ||
| 204 | + } | ||
| 205 | + if seen[p.Name] { | ||
| 206 | + return fmt.Errorf("%s: declared twice", at) | ||
| 207 | + } | ||
| 208 | + seen[p.Name] = true | ||
| 209 | + if err := p.validate(at); err != nil { | ||
| 210 | + return err | ||
| 211 | + } | ||
| 212 | + } | ||
| 213 | + return nil | ||
| 214 | +} | ||
| 215 | + | ||
| 216 | +// validate checks one parameter (an absent type was read as string). | ||
| 217 | +func (p ToolParameter) validate(at string) error { | ||
| 218 | + if !slices.Contains(paramTypes, p.Type) { | ||
| 219 | + return fmt.Errorf("%s.type %q must be one of %s", at, p.Type, strings.Join(paramTypes, ", ")) | ||
| 220 | + } | ||
| 221 | + for _, v := range p.Enum { | ||
| 222 | + if _, err := p.Coerce(v); err != nil { | ||
| 223 | + return fmt.Errorf("%s.enum: %v", at, err) | ||
| 224 | + } | ||
| 225 | + } | ||
| 226 | + if p.Default == nil { | ||
| 227 | + return nil | ||
| 228 | + } | ||
| 229 | + if p.IsRequired() { | ||
| 230 | + return fmt.Errorf("%s.default only makes sense with required: false", at) | ||
| 231 | + } | ||
| 232 | + normal, err := p.Coerce(p.Default) | ||
| 233 | + if err != nil { | ||
| 234 | + return fmt.Errorf("%s.default: %v", at, err) | ||
| 235 | + } | ||
| 236 | + if !p.Allows(normal) { | ||
| 237 | + return fmt.Errorf("%s.default %v is not one of the enum values", at, p.Default) | ||
| 238 | + } | ||
| 239 | + return nil | ||
| 240 | +} | ||
| 241 | + | ||
| 242 | +// validatePlaceholders refuses a {{name}} that no parameter declares — a | ||
| 243 | +// typo there would otherwise send the literal text to the program. | ||
| 244 | +func (t DeclaredTool) validatePlaceholders(where string) error { | ||
| 245 | + for _, entry := range t.Command { | ||
| 246 | + for _, name := range Placeholders(entry) { | ||
| 247 | + if _, ok := t.Parameters.Find(name); !ok { | ||
| 248 | + return fmt.Errorf("%s.command: {{%s}} is not a declared parameter", where, name) | ||
| 249 | + } | ||
| 250 | + } | ||
| 251 | + } | ||
| 252 | + return nil | ||
| 253 | +} | ||
| 254 | + | ||
| 255 | +// Coerce turns a value — from the file, or from the model's call — into the | ||
| 256 | +// parameter's type, or says why it cannot. Models write numbers and booleans | ||
| 257 | +// as strings often enough ("3", "true") that those are accepted; anything | ||
| 258 | +// else is refused rather than guessed. | ||
| 259 | +// | ||
| 260 | +// ToolParameter{Type: "integer"}.Coerce("3") // 3, nil | ||
| 261 | +// ToolParameter{Type: "boolean"}.Coerce("yes") // error | ||
| 262 | +func (p ToolParameter) Coerce(v any) (any, error) { | ||
| 263 | + switch p.Type { | ||
| 264 | + case ParamNumber: | ||
| 265 | + return coerceNumber(v) | ||
| 266 | + case ParamInteger: | ||
| 267 | + return coerceInteger(v) | ||
| 268 | + case ParamBoolean: | ||
| 269 | + return coerceBoolean(v) | ||
| 270 | + default: | ||
| 271 | + return coerceString(v) | ||
| 272 | + } | ||
| 273 | +} | ||
| 274 | + | ||
| 275 | +func coerceString(v any) (any, error) { | ||
| 276 | + switch x := v.(type) { | ||
| 277 | + case string: | ||
| 278 | + return x, nil | ||
| 279 | + case int, int64, float64, bool: | ||
| 280 | + return fmt.Sprint(x), nil | ||
| 281 | + } | ||
| 282 | + return nil, fmt.Errorf("%v is not a string", v) | ||
| 283 | +} | ||
| 284 | + | ||
| 285 | +func coerceNumber(v any) (any, error) { | ||
| 286 | + switch x := v.(type) { | ||
| 287 | + case int: | ||
| 288 | + return float64(x), nil | ||
| 289 | + case int64: | ||
| 290 | + return float64(x), nil | ||
| 291 | + case float64: | ||
| 292 | + return x, nil | ||
| 293 | + case string: | ||
| 294 | + if f, err := strconv.ParseFloat(strings.TrimSpace(x), 64); err == nil { | ||
| 295 | + return f, nil | ||
| 296 | + } | ||
| 297 | + } | ||
| 298 | + return nil, fmt.Errorf("%q is not a number", fmt.Sprint(v)) | ||
| 299 | +} | ||
| 300 | + | ||
| 301 | +func coerceInteger(v any) (any, error) { | ||
| 302 | + f, err := coerceNumber(v) | ||
| 303 | + if err != nil { | ||
| 304 | + return nil, fmt.Errorf("%q is not an integer", fmt.Sprint(v)) | ||
| 305 | + } | ||
| 306 | + n := f.(float64) | ||
| 307 | + if n != float64(int64(n)) { | ||
| 308 | + return nil, fmt.Errorf("%v is not an integer", v) | ||
| 309 | + } | ||
| 310 | + return int64(n), nil | ||
| 311 | +} | ||
| 312 | + | ||
| 313 | +func coerceBoolean(v any) (any, error) { | ||
| 314 | + switch x := v.(type) { | ||
| 315 | + case bool: | ||
| 316 | + return x, nil | ||
| 317 | + case string: | ||
| 318 | + switch strings.ToLower(strings.TrimSpace(x)) { | ||
| 319 | + case "true": | ||
| 320 | + return true, nil | ||
| 321 | + case "false": | ||
| 322 | + return false, nil | ||
| 323 | + } | ||
| 324 | + } | ||
| 325 | + return nil, fmt.Errorf("%q is not a boolean (true or false)", fmt.Sprint(v)) | ||
| 326 | +} | ||
| 327 | + | ||
| 328 | +// Allows reports whether a coerced value is accepted by the enum (always, | ||
| 329 | +// without one). | ||
| 330 | +func (p ToolParameter) Allows(v any) bool { | ||
| 331 | + if len(p.Enum) == 0 { | ||
| 332 | + return true | ||
| 333 | + } | ||
| 334 | + for _, e := range p.Enum { | ||
| 335 | + if allowed, err := p.Coerce(e); err == nil && allowed == v { | ||
| 336 | + return true | ||
| 337 | + } | ||
| 338 | + } | ||
| 339 | + return false | ||
| 340 | +} | ||
| 341 | + | ||
| 342 | +// EnumText lists the enum values for a message or a description. | ||
| 343 | +func (p ToolParameter) EnumText() string { | ||
| 344 | + values := make([]string, len(p.Enum)) | ||
| 345 | + for i, e := range p.Enum { | ||
| 346 | + values[i] = fmt.Sprint(e) | ||
| 347 | + } | ||
| 348 | + return strings.Join(values, ", ") | ||
| 349 | +} | ||
added
internal/config/tools_test.go +144 -0 | new file mode 100644 | ||
| @@ -0,0 +1,144 @@ | ||
| 1 | +package config | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + "time" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// No `tools:` block: no declared tool. | |
| 10 | +func TestDeclaredToolsDefaultToNone(t *testing.T) { | |
| 11 | + if err := loadYAML(t, "model: m\n"); err != nil { | |
| 12 | + t.Fatal(err) | |
| 13 | + } | |
| 14 | + if len(Cfg.Tools) != 0 { | |
| 15 | + t.Errorf("Tools = %v, want none", Cfg.Tools) | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +// A full entry is read with its parameters in file order, an absent type | |
| 20 | +// read as string, and an absent `required` meaning required. | |
| 21 | +func TestDeclaredToolIsReadInOrder(t *testing.T) { | |
| 22 | + err := loadYAML(t, ` | |
| 23 | +tools: | |
| 24 | + - name: release | |
| 25 | + description: Prepare a release. | |
| 26 | + parameters: | |
| 27 | + version: { description: "e.g. 1.4.2" } | |
| 28 | + channel: { type: string, enum: [stable, beta] } | |
| 29 | + dryRun: { type: boolean, required: false, default: false } | |
| 30 | + retries: { type: integer, required: false } | |
| 31 | + ratio: { type: number, required: false } | |
| 32 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | |
| 33 | + timeout: 45s | |
| 34 | +`) | |
| 35 | + if err != nil { | |
| 36 | + t.Fatal(err) | |
| 37 | + } | |
| 38 | + if len(Cfg.Tools) != 1 { | |
| 39 | + t.Fatalf("Tools = %v, want one", Cfg.Tools) | |
| 40 | + } | |
| 41 | + tool := Cfg.Tools[0] | |
| 42 | + var names []string | |
| 43 | + for _, p := range tool.Parameters { | |
| 44 | + names = append(names, p.Name) | |
| 45 | + } | |
| 46 | + if got := strings.Join(names, ","); got != "version,channel,dryRun,retries,ratio" { | |
| 47 | + t.Errorf("parameter order = %s", got) | |
| 48 | + } | |
| 49 | + version, _ := tool.Parameters.Find("version") | |
| 50 | + if version.Type != ParamString || !version.IsRequired() { | |
| 51 | + t.Errorf("version = %+v, want a required string", version) | |
| 52 | + } | |
| 53 | + dryRun, _ := tool.Parameters.Find("dryRun") | |
| 54 | + if dryRun.IsRequired() || dryRun.Default != false { | |
| 55 | + t.Errorf("dryRun = %+v, want optional with default false", dryRun) | |
| 56 | + } | |
| 57 | + if tool.Timeout != 45*time.Second || len(tool.Command) != 4 { | |
| 58 | + t.Errorf("timeout %v, command %v", tool.Timeout, tool.Command) | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +// Every inconsistency a file can hold is refused at load, with the entry | |
| 63 | +// named in the message. | |
| 64 | +func TestInvalidDeclaredToolsAreRejected(t *testing.T) { | |
| 65 | + cases := map[string]struct{ yaml, want string }{ | |
| 66 | + "bad name": {`[{name: "1x", description: d, command: [a]}]`, "must start with a letter"}, | |
| 67 | + "built-in name": {`[{name: bash, description: d, command: [a]}]`, "built-in tool"}, | |
| 68 | + "disabled built-in name": {`[{name: fetch_content, description: d, command: [a]}]`, "built-in tool"}, | |
| 69 | + "no description": {`[{name: t, command: [a]}]`, "description must not be empty"}, | |
| 70 | + "no command": {`[{name: t, description: d}]`, "command must name a program"}, | |
| 71 | + "program from a parameter": {`[{name: t, description: d, parameters: {p: {}}, command: ["{{p}}"]}]`, "cannot come from a parameter"}, | |
| 72 | + "unknown placeholder": {`[{name: t, description: d, command: [a, "{{nope}}"]}]`, "{{nope}} is not a declared parameter"}, | |
| 73 | + "unknown type": {`[{name: t, description: d, parameters: {p: {type: date}}, command: [a]}]`, `type "date" must be one of`}, | |
| 74 | + "enum of the wrong type": {`[{name: t, description: d, parameters: {p: {type: integer, enum: [1, x]}}, command: [a]}]`, "enum"}, | |
| 75 | + "default on a required parameter": {`[{name: t, description: d, parameters: {p: {default: x}}, command: [a]}]`, "required: false"}, | |
| 76 | + "default of the wrong type": {`[{name: t, description: d, parameters: {p: {type: boolean, required: false, default: maybe}}, command: [a]}]`, "not a boolean"}, | |
| 77 | + "default outside the enum": {`[{name: t, description: d, parameters: {p: {enum: [a, b], required: false, default: c}}, command: [a]}]`, "not one of the enum values"}, | |
| 78 | + "negative timeout": {`[{name: t, description: d, command: [a], timeout: -1s}]`, "timeout must be >= 0"}, | |
| 79 | + "duplicate name": {`[{name: t, description: d, command: [a]}, {name: t, description: d, command: [b]}]`, "already called"}, | |
| 80 | + "parameters not a mapping": {`[{name: t, description: d, parameters: [a], command: [a]}]`, "parameters must be a mapping"}, | |
| 81 | + } | |
| 82 | + for name, c := range cases { | |
| 83 | + t.Run(name, func(t *testing.T) { | |
| 84 | + err := loadYAML(t, "model: m\ntools: "+c.yaml+"\n") | |
| 85 | + if err == nil || !strings.Contains(err.Error(), c.want) { | |
| 86 | + t.Errorf("error = %v, want it to mention %q", err, c.want) | |
| 87 | + } | |
| 88 | + }) | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +// Values from the model are coerced to the declared type; the string forms | |
| 93 | +// models often write for numbers and booleans are accepted, nothing else. | |
| 94 | +func TestCoerce(t *testing.T) { | |
| 95 | + cases := []struct { | |
| 96 | + typ string | |
| 97 | + in any | |
| 98 | + want any | |
| 99 | + valid bool | |
| 100 | + }{ | |
| 101 | + {ParamString, "x", "x", true}, | |
| 102 | + {ParamString, 3.0, "3", true}, | |
| 103 | + {ParamString, map[string]any{}, nil, false}, | |
| 104 | + {ParamNumber, 1.5, 1.5, true}, | |
| 105 | + {ParamNumber, "2.5", 2.5, true}, | |
| 106 | + {ParamNumber, "two", nil, false}, | |
| 107 | + {ParamInteger, 3.0, int64(3), true}, | |
| 108 | + {ParamInteger, "3", int64(3), true}, | |
| 109 | + {ParamInteger, 3.5, nil, false}, | |
| 110 | + {ParamBoolean, true, true, true}, | |
| 111 | + {ParamBoolean, "FALSE", false, true}, | |
| 112 | + {ParamBoolean, "yes", nil, false}, | |
| 113 | + } | |
| 114 | + for _, c := range cases { | |
| 115 | + got, err := ToolParameter{Type: c.typ}.Coerce(c.in) | |
| 116 | + if (err == nil) != c.valid || got != c.want { | |
| 117 | + t.Errorf("Coerce(%s, %#v) = %#v, %v", c.typ, c.in, got, err) | |
| 118 | + } | |
| 119 | + } | |
| 120 | +} | |
| 121 | + | |
| 122 | +// An enum accepts its values in their coerced form: an integer enum written | |
| 123 | +// in the file accepts the model's "2". | |
| 124 | +func TestAllows(t *testing.T) { | |
| 125 | + p := ToolParameter{Type: ParamInteger, Enum: []any{1, 2}} | |
| 126 | + two, _ := p.Coerce("2") | |
| 127 | + three, _ := p.Coerce(3) | |
| 128 | + if !p.Allows(two) || p.Allows(three) { | |
| 129 | + t.Errorf("Allows(2) = %v, Allows(3) = %v", p.Allows(two), p.Allows(three)) | |
| 130 | + } | |
| 131 | + if !(ToolParameter{Type: ParamString}).Allows("anything") { | |
| 132 | + t.Error("no enum must allow anything") | |
| 133 | + } | |
| 134 | +} | |
| 135 | + | |
| 136 | +func TestPlaceholders(t *testing.T) { | |
| 137 | + if got := strings.Join(Placeholders("--x={{a}}-{{ b }}"), ","); got != "a,b" { | |
| 138 | + t.Errorf("Placeholders = %s", got) | |
| 139 | + } | |
| 140 | + got := ReplacePlaceholders("--x={{a}}-{{ b }}", func(n string) string { return strings.ToUpper(n) }) | |
| 141 | + if got != "--x=A-B" { | |
| 142 | + t.Errorf("ReplacePlaceholders = %s", got) | |
| 143 | + } | |
| 144 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,144 @@ | |||
| 1 | +package config | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + "time" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// No `tools:` block: no declared tool. | ||
| 10 | +func TestDeclaredToolsDefaultToNone(t *testing.T) { | ||
| 11 | + if err := loadYAML(t, "model: m\n"); err != nil { | ||
| 12 | + t.Fatal(err) | ||
| 13 | + } | ||
| 14 | + if len(Cfg.Tools) != 0 { | ||
| 15 | + t.Errorf("Tools = %v, want none", Cfg.Tools) | ||
| 16 | + } | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +// A full entry is read with its parameters in file order, an absent type | ||
| 20 | +// read as string, and an absent `required` meaning required. | ||
| 21 | +func TestDeclaredToolIsReadInOrder(t *testing.T) { | ||
| 22 | + err := loadYAML(t, ` | ||
| 23 | +tools: | ||
| 24 | + - name: release | ||
| 25 | + description: Prepare a release. | ||
| 26 | + parameters: | ||
| 27 | + version: { description: "e.g. 1.4.2" } | ||
| 28 | + channel: { type: string, enum: [stable, beta] } | ||
| 29 | + dryRun: { type: boolean, required: false, default: false } | ||
| 30 | + retries: { type: integer, required: false } | ||
| 31 | + ratio: { type: number, required: false } | ||
| 32 | + command: ["./scripts/release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}"] | ||
| 33 | + timeout: 45s | ||
| 34 | +`) | ||
| 35 | + if err != nil { | ||
| 36 | + t.Fatal(err) | ||
| 37 | + } | ||
| 38 | + if len(Cfg.Tools) != 1 { | ||
| 39 | + t.Fatalf("Tools = %v, want one", Cfg.Tools) | ||
| 40 | + } | ||
| 41 | + tool := Cfg.Tools[0] | ||
| 42 | + var names []string | ||
| 43 | + for _, p := range tool.Parameters { | ||
| 44 | + names = append(names, p.Name) | ||
| 45 | + } | ||
| 46 | + if got := strings.Join(names, ","); got != "version,channel,dryRun,retries,ratio" { | ||
| 47 | + t.Errorf("parameter order = %s", got) | ||
| 48 | + } | ||
| 49 | + version, _ := tool.Parameters.Find("version") | ||
| 50 | + if version.Type != ParamString || !version.IsRequired() { | ||
| 51 | + t.Errorf("version = %+v, want a required string", version) | ||
| 52 | + } | ||
| 53 | + dryRun, _ := tool.Parameters.Find("dryRun") | ||
| 54 | + if dryRun.IsRequired() || dryRun.Default != false { | ||
| 55 | + t.Errorf("dryRun = %+v, want optional with default false", dryRun) | ||
| 56 | + } | ||
| 57 | + if tool.Timeout != 45*time.Second || len(tool.Command) != 4 { | ||
| 58 | + t.Errorf("timeout %v, command %v", tool.Timeout, tool.Command) | ||
| 59 | + } | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// Every inconsistency a file can hold is refused at load, with the entry | ||
| 63 | +// named in the message. | ||
| 64 | +func TestInvalidDeclaredToolsAreRejected(t *testing.T) { | ||
| 65 | + cases := map[string]struct{ yaml, want string }{ | ||
| 66 | + "bad name": {`[{name: "1x", description: d, command: [a]}]`, "must start with a letter"}, | ||
| 67 | + "built-in name": {`[{name: bash, description: d, command: [a]}]`, "built-in tool"}, | ||
| 68 | + "disabled built-in name": {`[{name: fetch_content, description: d, command: [a]}]`, "built-in tool"}, | ||
| 69 | + "no description": {`[{name: t, command: [a]}]`, "description must not be empty"}, | ||
| 70 | + "no command": {`[{name: t, description: d}]`, "command must name a program"}, | ||
| 71 | + "program from a parameter": {`[{name: t, description: d, parameters: {p: {}}, command: ["{{p}}"]}]`, "cannot come from a parameter"}, | ||
| 72 | + "unknown placeholder": {`[{name: t, description: d, command: [a, "{{nope}}"]}]`, "{{nope}} is not a declared parameter"}, | ||
| 73 | + "unknown type": {`[{name: t, description: d, parameters: {p: {type: date}}, command: [a]}]`, `type "date" must be one of`}, | ||
| 74 | + "enum of the wrong type": {`[{name: t, description: d, parameters: {p: {type: integer, enum: [1, x]}}, command: [a]}]`, "enum"}, | ||
| 75 | + "default on a required parameter": {`[{name: t, description: d, parameters: {p: {default: x}}, command: [a]}]`, "required: false"}, | ||
| 76 | + "default of the wrong type": {`[{name: t, description: d, parameters: {p: {type: boolean, required: false, default: maybe}}, command: [a]}]`, "not a boolean"}, | ||
| 77 | + "default outside the enum": {`[{name: t, description: d, parameters: {p: {enum: [a, b], required: false, default: c}}, command: [a]}]`, "not one of the enum values"}, | ||
| 78 | + "negative timeout": {`[{name: t, description: d, command: [a], timeout: -1s}]`, "timeout must be >= 0"}, | ||
| 79 | + "duplicate name": {`[{name: t, description: d, command: [a]}, {name: t, description: d, command: [b]}]`, "already called"}, | ||
| 80 | + "parameters not a mapping": {`[{name: t, description: d, parameters: [a], command: [a]}]`, "parameters must be a mapping"}, | ||
| 81 | + } | ||
| 82 | + for name, c := range cases { | ||
| 83 | + t.Run(name, func(t *testing.T) { | ||
| 84 | + err := loadYAML(t, "model: m\ntools: "+c.yaml+"\n") | ||
| 85 | + if err == nil || !strings.Contains(err.Error(), c.want) { | ||
| 86 | + t.Errorf("error = %v, want it to mention %q", err, c.want) | ||
| 87 | + } | ||
| 88 | + }) | ||
| 89 | + } | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +// Values from the model are coerced to the declared type; the string forms | ||
| 93 | +// models often write for numbers and booleans are accepted, nothing else. | ||
| 94 | +func TestCoerce(t *testing.T) { | ||
| 95 | + cases := []struct { | ||
| 96 | + typ string | ||
| 97 | + in any | ||
| 98 | + want any | ||
| 99 | + valid bool | ||
| 100 | + }{ | ||
| 101 | + {ParamString, "x", "x", true}, | ||
| 102 | + {ParamString, 3.0, "3", true}, | ||
| 103 | + {ParamString, map[string]any{}, nil, false}, | ||
| 104 | + {ParamNumber, 1.5, 1.5, true}, | ||
| 105 | + {ParamNumber, "2.5", 2.5, true}, | ||
| 106 | + {ParamNumber, "two", nil, false}, | ||
| 107 | + {ParamInteger, 3.0, int64(3), true}, | ||
| 108 | + {ParamInteger, "3", int64(3), true}, | ||
| 109 | + {ParamInteger, 3.5, nil, false}, | ||
| 110 | + {ParamBoolean, true, true, true}, | ||
| 111 | + {ParamBoolean, "FALSE", false, true}, | ||
| 112 | + {ParamBoolean, "yes", nil, false}, | ||
| 113 | + } | ||
| 114 | + for _, c := range cases { | ||
| 115 | + got, err := ToolParameter{Type: c.typ}.Coerce(c.in) | ||
| 116 | + if (err == nil) != c.valid || got != c.want { | ||
| 117 | + t.Errorf("Coerce(%s, %#v) = %#v, %v", c.typ, c.in, got, err) | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | +// An enum accepts its values in their coerced form: an integer enum written | ||
| 123 | +// in the file accepts the model's "2". | ||
| 124 | +func TestAllows(t *testing.T) { | ||
| 125 | + p := ToolParameter{Type: ParamInteger, Enum: []any{1, 2}} | ||
| 126 | + two, _ := p.Coerce("2") | ||
| 127 | + three, _ := p.Coerce(3) | ||
| 128 | + if !p.Allows(two) || p.Allows(three) { | ||
| 129 | + t.Errorf("Allows(2) = %v, Allows(3) = %v", p.Allows(two), p.Allows(three)) | ||
| 130 | + } | ||
| 131 | + if !(ToolParameter{Type: ParamString}).Allows("anything") { | ||
| 132 | + t.Error("no enum must allow anything") | ||
| 133 | + } | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +func TestPlaceholders(t *testing.T) { | ||
| 137 | + if got := strings.Join(Placeholders("--x={{a}}-{{ b }}"), ","); got != "a,b" { | ||
| 138 | + t.Errorf("Placeholders = %s", got) | ||
| 139 | + } | ||
| 140 | + got := ReplacePlaceholders("--x={{a}}-{{ b }}", func(n string) string { return strings.ToUpper(n) }) | ||
| 141 | + if got != "--x=A-B" { | ||
| 142 | + t.Errorf("ReplacePlaceholders = %s", got) | ||
| 143 | + } | ||
| 144 | +} | ||
modified
internal/mcp/tool_wrapper.go +1 -10 | @@ -15,16 +15,7 @@ import ( | ||
| 15 | 15 | ) |
| 16 | 16 | |
| 17 | 17 | // BuiltinTools is the list of mini-me's built-in tool names that must not be collided with. |
| 18 | -var BuiltinTools = []string{ | |
| 19 | - "bash", | |
| 20 | - "read_file", | |
| 21 | - "write_file", | |
| 22 | - "edit_file", | |
| 23 | - "lsp", | |
| 24 | - "read_skill", | |
| 25 | - "plan", | |
| 26 | - "ask_user", | |
| 27 | -} | |
| 18 | +var BuiltinTools = config.BuiltinToolNames | |
| 28 | 19 | |
| 29 | 20 | // builtinToolSet for fast lookup. |
| 30 | 21 | var builtinToolSet = func() map[string]bool { |
| @@ -15,16 +15,7 @@ import ( | |||
| 15 | ) | 15 | ) |
| 16 | 16 | ||
| 17 | // BuiltinTools is the list of mini-me's built-in tool names that must not be collided with. | 17 | // BuiltinTools is the list of mini-me's built-in tool names that must not be collided with. |
| 18 | -var BuiltinTools = []string{ | 18 | +var BuiltinTools = config.BuiltinToolNames |
| 19 | - "bash", | ||
| 20 | - "read_file", | ||
| 21 | - "write_file", | ||
| 22 | - "edit_file", | ||
| 23 | - "lsp", | ||
| 24 | - "read_skill", | ||
| 25 | - "plan", | ||
| 26 | - "ask_user", | ||
| 27 | -} | ||
| 28 | 19 | ||
| 29 | // builtinToolSet for fast lookup. | 20 | // builtinToolSet for fast lookup. |
| 30 | var builtinToolSet = func() map[string]bool { | 21 | var builtinToolSet = func() map[string]bool { |
added
internal/tools/declared.go +283 -0 | new file mode 100644 | ||
| @@ -0,0 +1,283 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "maps" | |
| 8 | + "slices" | |
| 9 | + "strconv" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "mm/internal/config" | |
| 14 | + "mm/internal/consent" | |
| 15 | + "mm/internal/detector" | |
| 16 | + "mm/internal/spinner" | |
| 17 | + "mm/internal/ui" | |
| 18 | + | |
| 19 | + "github.com/firebase/genkit/go/ai" | |
| 20 | + "github.com/firebase/genkit/go/genkit" | |
| 21 | +) | |
| 22 | + | |
| 23 | +// --- declared tools (ticket 88) --------------------------------------------- | |
| 24 | +// | |
| 25 | +// A `tools:` entry of the agent file becomes a tool of its own: the model | |
| 26 | +// sees its name, description and parameters on every turn, and one call runs | |
| 27 | +// the command. Measured before building it (ticket 88): on mellum2-12b the | |
| 28 | +// same three actions succeeded 25/30 as typed tools against 15/30 as skills | |
| 29 | +// + bash, because a skill must be FOUND before anything runs. | |
| 30 | +// | |
| 31 | +// The command never goes through a shell. A value replaces its {{placeholder}} | |
| 32 | +// inside one argv entry, so `1.4.2; rm -rf ~` is one odd version string, not | |
| 33 | +// a second command. | |
| 34 | +// | |
| 35 | +// An invalid call — a missing parameter, a value of the wrong type, an | |
| 36 | +// unknown key — is answered AS TEXT that says what the model sent and what | |
| 37 | +// was expected, and nothing runs. The same bench showed why that text must | |
| 38 | +// be explicit: mellum2 packs every argument into one field | |
| 39 | +// (`{version: "1.4.2 1.4.2, channel beta"}`) and, told only "missing | |
| 40 | +// channel", sends the same call again until the loop detector stops it. | |
| 41 | + | |
| 42 | +// DeclaredTool declares one `tools:` entry as a Genkit tool. | |
| 43 | +// | |
| 44 | +// for _, t := range config.Cfg.Tools { | |
| 45 | +// refs = append(refs, tools.DeclaredTool(g, d, t)) | |
| 46 | +// } | |
| 47 | +func DeclaredTool(g *genkit.Genkit, d *detector.LoopDetector, t config.DeclaredTool) ai.ToolRef { | |
| 48 | + call := declaredCall{tool: t, detector: d} | |
| 49 | + return genkit.DefineTool(g, t.Name, declaredDescription(t), | |
| 50 | + func(tctx *ai.ToolContext, raw any) (string, error) { | |
| 51 | + in, _ := raw.(map[string]any) | |
| 52 | + if in == nil { | |
| 53 | + in = map[string]any{} | |
| 54 | + } | |
| 55 | + if s := ui.ActiveSink(); s != nil { | |
| 56 | + return call.evented(tctx, s, in) | |
| 57 | + } | |
| 58 | + return call.terminal(tctx, in) | |
| 59 | + }, | |
| 60 | + ai.WithInputSchema(declaredSchema(t))) | |
| 61 | +} | |
| 62 | + | |
| 63 | +// declaredDescription is the tool's description plus the line naming its | |
| 64 | +// required parameters: the schema below does not carry `required` (see | |
| 65 | +// declaredSchema), so the description has to. | |
| 66 | +func declaredDescription(t config.DeclaredTool) string { | |
| 67 | + var required []string | |
| 68 | + for _, p := range t.Parameters { | |
| 69 | + if p.IsRequired() { | |
| 70 | + required = append(required, p.Name) | |
| 71 | + } | |
| 72 | + } | |
| 73 | + description := strings.TrimSpace(t.Description) | |
| 74 | + if len(required) > 0 { | |
| 75 | + description += "\nRequired parameters: " + strings.Join(required, ", ") + ". Give each parameter as its own field." | |
| 76 | + } | |
| 77 | + return description | |
| 78 | +} | |
| 79 | + | |
| 80 | +// declaredSchema is the input schema the model reads: each parameter's type | |
| 81 | +// and description, its enum and default written INTO the description. Not | |
| 82 | +// `required` nor `enum` as keywords: Genkit validates a call against the | |
| 83 | +// declared schema before running the tool and a rejected call ends the | |
| 84 | +// turn, where mm prefers to answer with a message the model can act on — | |
| 85 | +// the same choice as for MCP tools (mcp.modelSchema). | |
| 86 | +func declaredSchema(t config.DeclaredTool) map[string]any { | |
| 87 | + properties := map[string]any{} | |
| 88 | + for _, p := range t.Parameters { | |
| 89 | + properties[p.Name] = map[string]any{ | |
| 90 | + "type": p.Type, | |
| 91 | + "description": parameterDescription(p), | |
| 92 | + } | |
| 93 | + } | |
| 94 | + return map[string]any{"type": "object", "properties": properties} | |
| 95 | +} | |
| 96 | + | |
| 97 | +// parameterDescription is what the model reads about one parameter. | |
| 98 | +func parameterDescription(p config.ToolParameter) string { | |
| 99 | + parts := []string{strings.TrimSpace(p.Description)} | |
| 100 | + if len(p.Enum) > 0 { | |
| 101 | + parts = append(parts, "One of: "+p.EnumText()+".") | |
| 102 | + } | |
| 103 | + switch { | |
| 104 | + case !p.IsRequired() && p.Default != nil: | |
| 105 | + parts = append(parts, fmt.Sprintf("Optional, default %v.", p.Default)) | |
| 106 | + case !p.IsRequired(): | |
| 107 | + parts = append(parts, "Optional.") | |
| 108 | + } | |
| 109 | + return strings.TrimSpace(strings.Join(parts, " ")) | |
| 110 | +} | |
| 111 | + | |
| 112 | +// declaredCall is what one call of a declared tool works with. | |
| 113 | +type declaredCall struct { | |
| 114 | + tool config.DeclaredTool | |
| 115 | + detector *detector.LoopDetector | |
| 116 | +} | |
| 117 | + | |
| 118 | +// terminal is the line mode: the call is announced, refused when invalid, | |
| 119 | +// gated by yolo, run, and its output echoed. | |
| 120 | +func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string, error) { | |
| 121 | + argv, problem := buildArgv(c.tool, in) | |
| 122 | + if problem != "" { | |
| 123 | + announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, inputText(in))) | |
| 124 | + return refuse(c.detector, c.tool.Name, inputText(in), problem) | |
| 125 | + } | |
| 126 | + shown := strings.Join(argv, " ") | |
| 127 | + announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, shown)) | |
| 128 | + if !confirmed(ctx, c.tool.Name, c.tool.Name+": "+shown) { | |
| 129 | + return refuse(c.detector, c.tool.Name, shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name)) | |
| 130 | + } | |
| 131 | + spinner.Start("Running") | |
| 132 | + out, _ := runArgv(ctx, "", c.timeout(), argv) | |
| 133 | + spinner.Suspend(func() { echoOutput(out) }) | |
| 134 | + spinner.Start("Thinking") | |
| 135 | + return record(c.detector, c.tool.Name, shown, out) | |
| 136 | +} | |
| 137 | + | |
| 138 | +// evented is the tool for an event-consuming front end (ACP, the window): a | |
| 139 | +// command runs something, so it asks like bash does when yolo is off. | |
| 140 | +func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any) (string, error) { | |
| 141 | + argv, problem := buildArgv(c.tool, in) | |
| 142 | + shown := strings.Join(argv, " ") | |
| 143 | + if problem != "" { | |
| 144 | + shown = inputText(in) | |
| 145 | + } | |
| 146 | + ev := ui.ToolEvent{ | |
| 147 | + ID: ui.NextCallID(), | |
| 148 | + Tool: c.tool.Name, | |
| 149 | + Title: c.tool.Name + ": " + shown, | |
| 150 | + Kind: "execute", | |
| 151 | + Input: in, | |
| 152 | + } | |
| 153 | + s.ToolStart(ev) | |
| 154 | + if problem != "" { | |
| 155 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: problem, Failed: true}) | |
| 156 | + return record(c.detector, c.tool.Name, shown, problem) | |
| 157 | + } | |
| 158 | + if !consent.Gate(ctx, s, ev) { | |
| 159 | + out := fmt.Sprintf("[%s rejected by the user]", c.tool.Name) | |
| 160 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 161 | + return record(c.detector, c.tool.Name, shown, out) | |
| 162 | + } | |
| 163 | + s.ToolRunning(ev.ID) | |
| 164 | + // The session's directory, as for bash: in an editor, the project. | |
| 165 | + out, failed := runArgv(ctx, s.WorkDir(), c.timeout(), argv) | |
| 166 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) | |
| 167 | + return record(c.detector, c.tool.Name, shown, out) | |
| 168 | +} | |
| 169 | + | |
| 170 | +// timeout is the tool's own budget, or bash's. | |
| 171 | +func (c declaredCall) timeout() time.Duration { | |
| 172 | + if c.tool.Timeout > 0 { | |
| 173 | + return c.tool.Timeout | |
| 174 | + } | |
| 175 | + return config.Cfg.BashTimeout | |
| 176 | +} | |
| 177 | + | |
| 178 | +// buildArgv checks a call against the tool's parameters and, when it holds, | |
| 179 | +// returns the command with every placeholder replaced. problem is the text | |
| 180 | +// the model gets instead when it does not — every fault at once, what it | |
| 181 | +// sent, and what the tool expects. | |
| 182 | +// | |
| 183 | +// buildArgv(release, map[string]any{"version": "1.4.2", "channel": "beta"}) | |
| 184 | +// // ["./scripts/release.sh", "1.4.2", "beta"], "" | |
| 185 | +func buildArgv(t config.DeclaredTool, in map[string]any) (argv []string, problem string) { | |
| 186 | + values, faults := callValues(t, in) | |
| 187 | + if len(faults) > 0 { | |
| 188 | + return nil, invalidCall(t, in, faults) | |
| 189 | + } | |
| 190 | + for _, entry := range t.Command { | |
| 191 | + if name, alone := onlyPlaceholder(entry); alone { | |
| 192 | + if _, given := values[name]; !given { | |
| 193 | + // An optional parameter left out removes its argument | |
| 194 | + // rather than passing an empty one. | |
| 195 | + continue | |
| 196 | + } | |
| 197 | + } | |
| 198 | + argv = append(argv, config.ReplacePlaceholders(entry, func(name string) string { | |
| 199 | + return values[name] | |
| 200 | + })) | |
| 201 | + } | |
| 202 | + return argv, "" | |
| 203 | +} | |
| 204 | + | |
| 205 | +// callValues coerces each parameter of the call to its declared type and | |
| 206 | +// renders it as text, applying defaults; faults lists what is wrong. | |
| 207 | +func callValues(t config.DeclaredTool, in map[string]any) (values map[string]string, faults []string) { | |
| 208 | + values = map[string]string{} | |
| 209 | + for _, key := range slices.Sorted(maps.Keys(in)) { | |
| 210 | + if _, ok := t.Parameters.Find(key); !ok { | |
| 211 | + faults = append(faults, fmt.Sprintf("%q is not a parameter of this tool", key)) | |
| 212 | + } | |
| 213 | + } | |
| 214 | + for _, p := range t.Parameters { | |
| 215 | + raw, given := in[p.Name] | |
| 216 | + if !given || raw == nil { | |
| 217 | + switch { | |
| 218 | + case p.Default != nil: | |
| 219 | + raw = p.Default | |
| 220 | + case p.IsRequired(): | |
| 221 | + faults = append(faults, p.Name+" is missing") | |
| 222 | + continue | |
| 223 | + default: | |
| 224 | + continue | |
| 225 | + } | |
| 226 | + } | |
| 227 | + v, err := p.Coerce(raw) | |
| 228 | + if err != nil { | |
| 229 | + faults = append(faults, fmt.Sprintf("%s: %v", p.Name, err)) | |
| 230 | + continue | |
| 231 | + } | |
| 232 | + if !p.Allows(v) { | |
| 233 | + faults = append(faults, fmt.Sprintf("%s: %v is not one of %s", p.Name, raw, p.EnumText())) | |
| 234 | + continue | |
| 235 | + } | |
| 236 | + values[p.Name] = argText(v) | |
| 237 | + } | |
| 238 | + return values, faults | |
| 239 | +} | |
| 240 | + | |
| 241 | +// invalidCall is the refusal the model reads. It quotes the call back: a | |
| 242 | +// model that packed two arguments into one field sees it written out. | |
| 243 | +func invalidCall(t config.DeclaredTool, in map[string]any, faults []string) string { | |
| 244 | + var expected []string | |
| 245 | + for _, p := range t.Parameters { | |
| 246 | + expected = append(expected, fmt.Sprintf("%s (%s, %s)", p.Name, p.Type, parameterDescription(p))) | |
| 247 | + } | |
| 248 | + return fmt.Sprintf("[%s: invalid call, nothing was run — %s. You sent: %s. Expected: %s. Call %s again with each parameter as its own field.]", | |
| 249 | + t.Name, strings.Join(faults, "; "), inputText(in), strings.Join(expected, "; "), t.Name) | |
| 250 | +} | |
| 251 | + | |
| 252 | +// onlyPlaceholder reports whether entry is exactly one {{name}}. | |
| 253 | +func onlyPlaceholder(entry string) (name string, alone bool) { | |
| 254 | + names := config.Placeholders(entry) | |
| 255 | + if len(names) != 1 { | |
| 256 | + return "", false | |
| 257 | + } | |
| 258 | + return names[0], strings.TrimSpace(config.ReplacePlaceholders(entry, func(string) string { return "" })) == "" | |
| 259 | +} | |
| 260 | + | |
| 261 | +// argText renders a coerced value as one argv entry: integers without a | |
| 262 | +// decimal point, numbers without an exponent, booleans as true/false. | |
| 263 | +func argText(v any) string { | |
| 264 | + switch x := v.(type) { | |
| 265 | + case int64: | |
| 266 | + return strconv.FormatInt(x, 10) | |
| 267 | + case float64: | |
| 268 | + return strconv.FormatFloat(x, 'f', -1, 64) | |
| 269 | + case bool: | |
| 270 | + return strconv.FormatBool(x) | |
| 271 | + default: | |
| 272 | + return fmt.Sprint(x) | |
| 273 | + } | |
| 274 | +} | |
| 275 | + | |
| 276 | +// inputText is the call's input as compact JSON, keys sorted. | |
| 277 | +func inputText(in map[string]any) string { | |
| 278 | + b, err := json.Marshal(in) | |
| 279 | + if err != nil { | |
| 280 | + return fmt.Sprint(in) | |
| 281 | + } | |
| 282 | + return string(b) | |
| 283 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,283 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "maps" | ||
| 8 | + "slices" | ||
| 9 | + "strconv" | ||
| 10 | + "strings" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "mm/internal/config" | ||
| 14 | + "mm/internal/consent" | ||
| 15 | + "mm/internal/detector" | ||
| 16 | + "mm/internal/spinner" | ||
| 17 | + "mm/internal/ui" | ||
| 18 | + | ||
| 19 | + "github.com/firebase/genkit/go/ai" | ||
| 20 | + "github.com/firebase/genkit/go/genkit" | ||
| 21 | +) | ||
| 22 | + | ||
| 23 | +// --- declared tools (ticket 88) --------------------------------------------- | ||
| 24 | +// | ||
| 25 | +// A `tools:` entry of the agent file becomes a tool of its own: the model | ||
| 26 | +// sees its name, description and parameters on every turn, and one call runs | ||
| 27 | +// the command. Measured before building it (ticket 88): on mellum2-12b the | ||
| 28 | +// same three actions succeeded 25/30 as typed tools against 15/30 as skills | ||
| 29 | +// + bash, because a skill must be FOUND before anything runs. | ||
| 30 | +// | ||
| 31 | +// The command never goes through a shell. A value replaces its {{placeholder}} | ||
| 32 | +// inside one argv entry, so `1.4.2; rm -rf ~` is one odd version string, not | ||
| 33 | +// a second command. | ||
| 34 | +// | ||
| 35 | +// An invalid call — a missing parameter, a value of the wrong type, an | ||
| 36 | +// unknown key — is answered AS TEXT that says what the model sent and what | ||
| 37 | +// was expected, and nothing runs. The same bench showed why that text must | ||
| 38 | +// be explicit: mellum2 packs every argument into one field | ||
| 39 | +// (`{version: "1.4.2 1.4.2, channel beta"}`) and, told only "missing | ||
| 40 | +// channel", sends the same call again until the loop detector stops it. | ||
| 41 | + | ||
| 42 | +// DeclaredTool declares one `tools:` entry as a Genkit tool. | ||
| 43 | +// | ||
| 44 | +// for _, t := range config.Cfg.Tools { | ||
| 45 | +// refs = append(refs, tools.DeclaredTool(g, d, t)) | ||
| 46 | +// } | ||
| 47 | +func DeclaredTool(g *genkit.Genkit, d *detector.LoopDetector, t config.DeclaredTool) ai.ToolRef { | ||
| 48 | + call := declaredCall{tool: t, detector: d} | ||
| 49 | + return genkit.DefineTool(g, t.Name, declaredDescription(t), | ||
| 50 | + func(tctx *ai.ToolContext, raw any) (string, error) { | ||
| 51 | + in, _ := raw.(map[string]any) | ||
| 52 | + if in == nil { | ||
| 53 | + in = map[string]any{} | ||
| 54 | + } | ||
| 55 | + if s := ui.ActiveSink(); s != nil { | ||
| 56 | + return call.evented(tctx, s, in) | ||
| 57 | + } | ||
| 58 | + return call.terminal(tctx, in) | ||
| 59 | + }, | ||
| 60 | + ai.WithInputSchema(declaredSchema(t))) | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// declaredDescription is the tool's description plus the line naming its | ||
| 64 | +// required parameters: the schema below does not carry `required` (see | ||
| 65 | +// declaredSchema), so the description has to. | ||
| 66 | +func declaredDescription(t config.DeclaredTool) string { | ||
| 67 | + var required []string | ||
| 68 | + for _, p := range t.Parameters { | ||
| 69 | + if p.IsRequired() { | ||
| 70 | + required = append(required, p.Name) | ||
| 71 | + } | ||
| 72 | + } | ||
| 73 | + description := strings.TrimSpace(t.Description) | ||
| 74 | + if len(required) > 0 { | ||
| 75 | + description += "\nRequired parameters: " + strings.Join(required, ", ") + ". Give each parameter as its own field." | ||
| 76 | + } | ||
| 77 | + return description | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// declaredSchema is the input schema the model reads: each parameter's type | ||
| 81 | +// and description, its enum and default written INTO the description. Not | ||
| 82 | +// `required` nor `enum` as keywords: Genkit validates a call against the | ||
| 83 | +// declared schema before running the tool and a rejected call ends the | ||
| 84 | +// turn, where mm prefers to answer with a message the model can act on — | ||
| 85 | +// the same choice as for MCP tools (mcp.modelSchema). | ||
| 86 | +func declaredSchema(t config.DeclaredTool) map[string]any { | ||
| 87 | + properties := map[string]any{} | ||
| 88 | + for _, p := range t.Parameters { | ||
| 89 | + properties[p.Name] = map[string]any{ | ||
| 90 | + "type": p.Type, | ||
| 91 | + "description": parameterDescription(p), | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | + return map[string]any{"type": "object", "properties": properties} | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +// parameterDescription is what the model reads about one parameter. | ||
| 98 | +func parameterDescription(p config.ToolParameter) string { | ||
| 99 | + parts := []string{strings.TrimSpace(p.Description)} | ||
| 100 | + if len(p.Enum) > 0 { | ||
| 101 | + parts = append(parts, "One of: "+p.EnumText()+".") | ||
| 102 | + } | ||
| 103 | + switch { | ||
| 104 | + case !p.IsRequired() && p.Default != nil: | ||
| 105 | + parts = append(parts, fmt.Sprintf("Optional, default %v.", p.Default)) | ||
| 106 | + case !p.IsRequired(): | ||
| 107 | + parts = append(parts, "Optional.") | ||
| 108 | + } | ||
| 109 | + return strings.TrimSpace(strings.Join(parts, " ")) | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +// declaredCall is what one call of a declared tool works with. | ||
| 113 | +type declaredCall struct { | ||
| 114 | + tool config.DeclaredTool | ||
| 115 | + detector *detector.LoopDetector | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +// terminal is the line mode: the call is announced, refused when invalid, | ||
| 119 | +// gated by yolo, run, and its output echoed. | ||
| 120 | +func (c declaredCall) terminal(ctx context.Context, in map[string]any) (string, error) { | ||
| 121 | + argv, problem := buildArgv(c.tool, in) | ||
| 122 | + if problem != "" { | ||
| 123 | + announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, inputText(in))) | ||
| 124 | + return refuse(c.detector, c.tool.Name, inputText(in), problem) | ||
| 125 | + } | ||
| 126 | + shown := strings.Join(argv, " ") | ||
| 127 | + announce(fmt.Sprintf("🧰 %s: %s", c.tool.Name, shown)) | ||
| 128 | + if !confirmed(ctx, c.tool.Name, c.tool.Name+": "+shown) { | ||
| 129 | + return refuse(c.detector, c.tool.Name, shown, fmt.Sprintf("[%s rejected by the user]", c.tool.Name)) | ||
| 130 | + } | ||
| 131 | + spinner.Start("Running") | ||
| 132 | + out, _ := runArgv(ctx, "", c.timeout(), argv) | ||
| 133 | + spinner.Suspend(func() { echoOutput(out) }) | ||
| 134 | + spinner.Start("Thinking") | ||
| 135 | + return record(c.detector, c.tool.Name, shown, out) | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +// evented is the tool for an event-consuming front end (ACP, the window): a | ||
| 139 | +// command runs something, so it asks like bash does when yolo is off. | ||
| 140 | +func (c declaredCall) evented(ctx context.Context, s ui.Sink, in map[string]any) (string, error) { | ||
| 141 | + argv, problem := buildArgv(c.tool, in) | ||
| 142 | + shown := strings.Join(argv, " ") | ||
| 143 | + if problem != "" { | ||
| 144 | + shown = inputText(in) | ||
| 145 | + } | ||
| 146 | + ev := ui.ToolEvent{ | ||
| 147 | + ID: ui.NextCallID(), | ||
| 148 | + Tool: c.tool.Name, | ||
| 149 | + Title: c.tool.Name + ": " + shown, | ||
| 150 | + Kind: "execute", | ||
| 151 | + Input: in, | ||
| 152 | + } | ||
| 153 | + s.ToolStart(ev) | ||
| 154 | + if problem != "" { | ||
| 155 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: problem, Failed: true}) | ||
| 156 | + return record(c.detector, c.tool.Name, shown, problem) | ||
| 157 | + } | ||
| 158 | + if !consent.Gate(ctx, s, ev) { | ||
| 159 | + out := fmt.Sprintf("[%s rejected by the user]", c.tool.Name) | ||
| 160 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 161 | + return record(c.detector, c.tool.Name, shown, out) | ||
| 162 | + } | ||
| 163 | + s.ToolRunning(ev.ID) | ||
| 164 | + // The session's directory, as for bash: in an editor, the project. | ||
| 165 | + out, failed := runArgv(ctx, s.WorkDir(), c.timeout(), argv) | ||
| 166 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) | ||
| 167 | + return record(c.detector, c.tool.Name, shown, out) | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +// timeout is the tool's own budget, or bash's. | ||
| 171 | +func (c declaredCall) timeout() time.Duration { | ||
| 172 | + if c.tool.Timeout > 0 { | ||
| 173 | + return c.tool.Timeout | ||
| 174 | + } | ||
| 175 | + return config.Cfg.BashTimeout | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +// buildArgv checks a call against the tool's parameters and, when it holds, | ||
| 179 | +// returns the command with every placeholder replaced. problem is the text | ||
| 180 | +// the model gets instead when it does not — every fault at once, what it | ||
| 181 | +// sent, and what the tool expects. | ||
| 182 | +// | ||
| 183 | +// buildArgv(release, map[string]any{"version": "1.4.2", "channel": "beta"}) | ||
| 184 | +// // ["./scripts/release.sh", "1.4.2", "beta"], "" | ||
| 185 | +func buildArgv(t config.DeclaredTool, in map[string]any) (argv []string, problem string) { | ||
| 186 | + values, faults := callValues(t, in) | ||
| 187 | + if len(faults) > 0 { | ||
| 188 | + return nil, invalidCall(t, in, faults) | ||
| 189 | + } | ||
| 190 | + for _, entry := range t.Command { | ||
| 191 | + if name, alone := onlyPlaceholder(entry); alone { | ||
| 192 | + if _, given := values[name]; !given { | ||
| 193 | + // An optional parameter left out removes its argument | ||
| 194 | + // rather than passing an empty one. | ||
| 195 | + continue | ||
| 196 | + } | ||
| 197 | + } | ||
| 198 | + argv = append(argv, config.ReplacePlaceholders(entry, func(name string) string { | ||
| 199 | + return values[name] | ||
| 200 | + })) | ||
| 201 | + } | ||
| 202 | + return argv, "" | ||
| 203 | +} | ||
| 204 | + | ||
| 205 | +// callValues coerces each parameter of the call to its declared type and | ||
| 206 | +// renders it as text, applying defaults; faults lists what is wrong. | ||
| 207 | +func callValues(t config.DeclaredTool, in map[string]any) (values map[string]string, faults []string) { | ||
| 208 | + values = map[string]string{} | ||
| 209 | + for _, key := range slices.Sorted(maps.Keys(in)) { | ||
| 210 | + if _, ok := t.Parameters.Find(key); !ok { | ||
| 211 | + faults = append(faults, fmt.Sprintf("%q is not a parameter of this tool", key)) | ||
| 212 | + } | ||
| 213 | + } | ||
| 214 | + for _, p := range t.Parameters { | ||
| 215 | + raw, given := in[p.Name] | ||
| 216 | + if !given || raw == nil { | ||
| 217 | + switch { | ||
| 218 | + case p.Default != nil: | ||
| 219 | + raw = p.Default | ||
| 220 | + case p.IsRequired(): | ||
| 221 | + faults = append(faults, p.Name+" is missing") | ||
| 222 | + continue | ||
| 223 | + default: | ||
| 224 | + continue | ||
| 225 | + } | ||
| 226 | + } | ||
| 227 | + v, err := p.Coerce(raw) | ||
| 228 | + if err != nil { | ||
| 229 | + faults = append(faults, fmt.Sprintf("%s: %v", p.Name, err)) | ||
| 230 | + continue | ||
| 231 | + } | ||
| 232 | + if !p.Allows(v) { | ||
| 233 | + faults = append(faults, fmt.Sprintf("%s: %v is not one of %s", p.Name, raw, p.EnumText())) | ||
| 234 | + continue | ||
| 235 | + } | ||
| 236 | + values[p.Name] = argText(v) | ||
| 237 | + } | ||
| 238 | + return values, faults | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +// invalidCall is the refusal the model reads. It quotes the call back: a | ||
| 242 | +// model that packed two arguments into one field sees it written out. | ||
| 243 | +func invalidCall(t config.DeclaredTool, in map[string]any, faults []string) string { | ||
| 244 | + var expected []string | ||
| 245 | + for _, p := range t.Parameters { | ||
| 246 | + expected = append(expected, fmt.Sprintf("%s (%s, %s)", p.Name, p.Type, parameterDescription(p))) | ||
| 247 | + } | ||
| 248 | + return fmt.Sprintf("[%s: invalid call, nothing was run — %s. You sent: %s. Expected: %s. Call %s again with each parameter as its own field.]", | ||
| 249 | + t.Name, strings.Join(faults, "; "), inputText(in), strings.Join(expected, "; "), t.Name) | ||
| 250 | +} | ||
| 251 | + | ||
| 252 | +// onlyPlaceholder reports whether entry is exactly one {{name}}. | ||
| 253 | +func onlyPlaceholder(entry string) (name string, alone bool) { | ||
| 254 | + names := config.Placeholders(entry) | ||
| 255 | + if len(names) != 1 { | ||
| 256 | + return "", false | ||
| 257 | + } | ||
| 258 | + return names[0], strings.TrimSpace(config.ReplacePlaceholders(entry, func(string) string { return "" })) == "" | ||
| 259 | +} | ||
| 260 | + | ||
| 261 | +// argText renders a coerced value as one argv entry: integers without a | ||
| 262 | +// decimal point, numbers without an exponent, booleans as true/false. | ||
| 263 | +func argText(v any) string { | ||
| 264 | + switch x := v.(type) { | ||
| 265 | + case int64: | ||
| 266 | + return strconv.FormatInt(x, 10) | ||
| 267 | + case float64: | ||
| 268 | + return strconv.FormatFloat(x, 'f', -1, 64) | ||
| 269 | + case bool: | ||
| 270 | + return strconv.FormatBool(x) | ||
| 271 | + default: | ||
| 272 | + return fmt.Sprint(x) | ||
| 273 | + } | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +// inputText is the call's input as compact JSON, keys sorted. | ||
| 277 | +func inputText(in map[string]any) string { | ||
| 278 | + b, err := json.Marshal(in) | ||
| 279 | + if err != nil { | ||
| 280 | + return fmt.Sprint(in) | ||
| 281 | + } | ||
| 282 | + return string(b) | ||
| 283 | +} | ||
added
internal/tools/declared_test.go +200 -0 | new file mode 100644 | ||
| @@ -0,0 +1,200 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "mm/internal/config" | |
| 12 | + "mm/internal/detector" | |
| 13 | +) | |
| 14 | + | |
| 15 | +// releaseTool is the tool of ticket 88's bench, with one parameter of each | |
| 16 | +// type and an optional argument that disappears when left out. | |
| 17 | +func releaseTool() config.DeclaredTool { | |
| 18 | + optional := false | |
| 19 | + return config.DeclaredTool{ | |
| 20 | + Name: "release", | |
| 21 | + Description: "Prepare a release.", | |
| 22 | + Parameters: config.ToolParameters{ | |
| 23 | + {Name: "version", Type: config.ParamString, Description: "e.g. 1.4.2"}, | |
| 24 | + {Name: "channel", Type: config.ParamString, Enum: []any{"stable", "beta"}}, | |
| 25 | + {Name: "dryRun", Type: config.ParamBoolean, Required: &optional, Default: false}, | |
| 26 | + {Name: "retries", Type: config.ParamInteger, Required: &optional}, | |
| 27 | + {Name: "ratio", Type: config.ParamNumber, Required: &optional}, | |
| 28 | + }, | |
| 29 | + Command: []string{"./release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}", "{{retries}}", "--ratio={{ratio}}"}, | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +// echoScript writes an executable that prints each argument on its own line, | |
| 34 | +// in dir, and returns the directory. | |
| 35 | +func echoScript(t *testing.T, name string) string { | |
| 36 | + t.Helper() | |
| 37 | + dir := t.TempDir() | |
| 38 | + script := "#!/bin/sh\nfor a in \"$@\"; do echo \"[$a]\"; done\n" | |
| 39 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { | |
| 40 | + t.Fatal(err) | |
| 41 | + } | |
| 42 | + return dir | |
| 43 | +} | |
| 44 | + | |
| 45 | +func newDeclaredCall(tool config.DeclaredTool) declaredCall { | |
| 46 | + return declaredCall{tool: tool, detector: detector.NewLoopDetector(10, 3)} | |
| 47 | +} | |
| 48 | + | |
| 49 | +// Every type is coerced and rendered as one argv entry; an optional | |
| 50 | +// parameter left out removes an entry that is only its placeholder, empties | |
| 51 | +// one it is part of, and a default fills in. | |
| 52 | +func TestBuildArgvRendersEveryType(t *testing.T) { | |
| 53 | + argv, problem := buildArgv(releaseTool(), map[string]any{ | |
| 54 | + "version": "1.4.2", "channel": "beta", "retries": "3", "ratio": 0.5, | |
| 55 | + }) | |
| 56 | + if problem != "" { | |
| 57 | + t.Fatal(problem) | |
| 58 | + } | |
| 59 | + want := "./release.sh|1.4.2|beta|--dry-run=false|3|--ratio=0.5" | |
| 60 | + if got := strings.Join(argv, "|"); got != want { | |
| 61 | + t.Errorf("argv = %s, want %s", got, want) | |
| 62 | + } | |
| 63 | + | |
| 64 | + argv, _ = buildArgv(releaseTool(), map[string]any{"version": "1.4.2", "channel": "stable", "dryRun": true}) | |
| 65 | + want = "./release.sh|1.4.2|stable|--dry-run=true|--ratio=" | |
| 66 | + if got := strings.Join(argv, "|"); got != want { | |
| 67 | + t.Errorf("argv without the optional ones = %s, want %s", got, want) | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +// The call mellum2 made in ticket 88's bench — every argument packed into | |
| 72 | +// one field — is refused with a text that quotes it back and says what each | |
| 73 | +// parameter should be. | |
| 74 | +func TestBuildArgvExplainsAPackedCall(t *testing.T) { | |
| 75 | + _, problem := buildArgv(releaseTool(), map[string]any{"version": "1.4.2 1.4.2, channel beta"}) | |
| 76 | + for _, want := range []string{ | |
| 77 | + "invalid call, nothing was run", | |
| 78 | + "channel is missing", | |
| 79 | + `You sent: {"version":"1.4.2 1.4.2, channel beta"}`, | |
| 80 | + "Expected: version (string, e.g. 1.4.2); channel (string, One of: stable, beta.)", | |
| 81 | + "each parameter as its own field", | |
| 82 | + } { | |
| 83 | + if !strings.Contains(problem, want) { | |
| 84 | + t.Errorf("refusal %q\nlacks %q", problem, want) | |
| 85 | + } | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +// A wrong type, a value outside the enum and an unknown key are all | |
| 90 | +// reported, in one refusal. | |
| 91 | +func TestBuildArgvReportsEveryFault(t *testing.T) { | |
| 92 | + _, problem := buildArgv(releaseTool(), map[string]any{ | |
| 93 | + "version": "1", "channel": "nightly", "retries": "many", "force": true, | |
| 94 | + }) | |
| 95 | + for _, want := range []string{ | |
| 96 | + `"force" is not a parameter of this tool`, | |
| 97 | + "channel: nightly is not one of stable, beta", | |
| 98 | + `retries: "many" is not an integer`, | |
| 99 | + } { | |
| 100 | + if !strings.Contains(problem, want) { | |
| 101 | + t.Errorf("refusal %q\nlacks %q", problem, want) | |
| 102 | + } | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +// The model reads each parameter's type, its enum and whether it is optional; | |
| 107 | +// the description names the required ones, since the schema does not. | |
| 108 | +func TestDeclaredSchemaAndDescription(t *testing.T) { | |
| 109 | + schema := declaredSchema(releaseTool()) | |
| 110 | + props := schema["properties"].(map[string]any) | |
| 111 | + channel := props["channel"].(map[string]any) | |
| 112 | + if channel["type"] != "string" || channel["description"] != "One of: stable, beta." { | |
| 113 | + t.Errorf("channel = %v", channel) | |
| 114 | + } | |
| 115 | + dryRun := props["dryRun"].(map[string]any) | |
| 116 | + if dryRun["type"] != "boolean" || dryRun["description"] != "Optional, default false." { | |
| 117 | + t.Errorf("dryRun = %v", dryRun) | |
| 118 | + } | |
| 119 | + if _, has := schema["required"]; has { | |
| 120 | + t.Error("required must not be in the schema: Genkit would end the turn on a missing parameter") | |
| 121 | + } | |
| 122 | + if got := declaredDescription(releaseTool()); !strings.Contains(got, "Required parameters: version, channel.") { | |
| 123 | + t.Errorf("description = %q", got) | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +// With yolo on, the evented call runs at once, in the session's directory, | |
| 128 | +// and a value that looks like a shell command stays one argument. | |
| 129 | +func TestDeclaredToolRunsWithoutAShell(t *testing.T) { | |
| 130 | + dir := echoScript(t, "release.sh") | |
| 131 | + sink := &recordingSink{allow: false, dir: dir} | |
| 132 | + out, err := newDeclaredCall(releaseTool()).evented(context.Background(), sink, map[string]any{ | |
| 133 | + "version": "1.4.2; touch pwned", "channel": "beta", | |
| 134 | + }) | |
| 135 | + if err != nil { | |
| 136 | + t.Fatal(err) | |
| 137 | + } | |
| 138 | + if sink.asked != 0 { | |
| 139 | + t.Errorf("asked %d time(s) with yolo on", sink.asked) | |
| 140 | + } | |
| 141 | + if !strings.Contains(out, "[1.4.2; touch pwned]\n[beta]") || sink.ended.Failed { | |
| 142 | + t.Errorf("output %q, ended %+v", out, sink.ended) | |
| 143 | + } | |
| 144 | + if _, statErr := os.Stat(filepath.Join(dir, "pwned")); !os.IsNotExist(statErr) { | |
| 145 | + t.Error("a parameter's value was run as a command") | |
| 146 | + } | |
| 147 | + if sink.started.Kind != "execute" || sink.started.Title != "release: ./release.sh 1.4.2; touch pwned beta --dry-run=false --ratio=" { | |
| 148 | + t.Errorf("event %+v", sink.started) | |
| 149 | + } | |
| 150 | +} | |
| 151 | + | |
| 152 | +// With yolo off, a refused call runs nothing; an allowed one runs. | |
| 153 | +func TestDeclaredToolAsksWhenYoloIsOff(t *testing.T) { | |
| 154 | + yoloOff(t) | |
| 155 | + dir := echoScript(t, "release.sh") | |
| 156 | + sink := &recordingSink{allow: false, dir: dir} | |
| 157 | + call := newDeclaredCall(releaseTool()) | |
| 158 | + in := map[string]any{"version": "1", "channel": "beta"} | |
| 159 | + | |
| 160 | + out, _ := call.evented(context.Background(), sink, in) | |
| 161 | + if sink.asked != 1 || out != "[release rejected by the user]" || !sink.ended.Failed { | |
| 162 | + t.Errorf("refused: asked %d, out %q", sink.asked, out) | |
| 163 | + } | |
| 164 | + sink.allow = true | |
| 165 | + if out, _ := call.evented(context.Background(), sink, in); !strings.Contains(out, "[beta]") { | |
| 166 | + t.Errorf("allowed: %q", out) | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +// An invalid call is answered without asking and without running. | |
| 171 | +func TestDeclaredToolRefusesAnInvalidCallWithoutAsking(t *testing.T) { | |
| 172 | + yoloOff(t) | |
| 173 | + sink := &recordingSink{allow: true, dir: echoScript(t, "release.sh")} | |
| 174 | + out, _ := newDeclaredCall(releaseTool()).evented(context.Background(), sink, map[string]any{}) | |
| 175 | + if sink.asked != 0 || !strings.Contains(out, "version is missing; channel is missing") || !sink.ended.Failed { | |
| 176 | + t.Errorf("asked %d, out %q, ended %+v", sink.asked, out, sink.ended) | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 180 | +// A tool's own timeout bounds the call, and a program that does not exist is | |
| 181 | +// reported as such. | |
| 182 | +func TestDeclaredToolTimeoutAndMissingProgram(t *testing.T) { | |
| 183 | + dir := t.TempDir() | |
| 184 | + if err := os.WriteFile(filepath.Join(dir, "slow.sh"), []byte("#!/bin/sh\nsleep 5\n"), 0o755); err != nil { | |
| 185 | + t.Fatal(err) | |
| 186 | + } | |
| 187 | + slow := config.DeclaredTool{Name: "slow", Description: "d", Command: []string{"./slow.sh"}, Timeout: 200 * time.Millisecond} | |
| 188 | + sink := &recordingSink{dir: dir} | |
| 189 | + start := time.Now() | |
| 190 | + out, _ := newDeclaredCall(slow).evented(context.Background(), sink, map[string]any{}) | |
| 191 | + if !strings.Contains(out, "200ms timeout exceeded") || time.Since(start) > 4*time.Second { | |
| 192 | + t.Errorf("slow: %q after %s", out, time.Since(start)) | |
| 193 | + } | |
| 194 | + | |
| 195 | + missing := config.DeclaredTool{Name: "missing", Description: "d", Command: []string{"./nope.sh"}} | |
| 196 | + out, _ = newDeclaredCall(missing).evented(context.Background(), sink, map[string]any{}) | |
| 197 | + if !strings.Contains(out, "./nope.sh cannot be started") || !sink.ended.Failed { | |
| 198 | + t.Errorf("missing: %q", out) | |
| 199 | + } | |
| 200 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,200 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "os" | ||
| 6 | + "path/filepath" | ||
| 7 | + "strings" | ||
| 8 | + "testing" | ||
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "mm/internal/config" | ||
| 12 | + "mm/internal/detector" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +// releaseTool is the tool of ticket 88's bench, with one parameter of each | ||
| 16 | +// type and an optional argument that disappears when left out. | ||
| 17 | +func releaseTool() config.DeclaredTool { | ||
| 18 | + optional := false | ||
| 19 | + return config.DeclaredTool{ | ||
| 20 | + Name: "release", | ||
| 21 | + Description: "Prepare a release.", | ||
| 22 | + Parameters: config.ToolParameters{ | ||
| 23 | + {Name: "version", Type: config.ParamString, Description: "e.g. 1.4.2"}, | ||
| 24 | + {Name: "channel", Type: config.ParamString, Enum: []any{"stable", "beta"}}, | ||
| 25 | + {Name: "dryRun", Type: config.ParamBoolean, Required: &optional, Default: false}, | ||
| 26 | + {Name: "retries", Type: config.ParamInteger, Required: &optional}, | ||
| 27 | + {Name: "ratio", Type: config.ParamNumber, Required: &optional}, | ||
| 28 | + }, | ||
| 29 | + Command: []string{"./release.sh", "{{version}}", "{{channel}}", "--dry-run={{dryRun}}", "{{retries}}", "--ratio={{ratio}}"}, | ||
| 30 | + } | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +// echoScript writes an executable that prints each argument on its own line, | ||
| 34 | +// in dir, and returns the directory. | ||
| 35 | +func echoScript(t *testing.T, name string) string { | ||
| 36 | + t.Helper() | ||
| 37 | + dir := t.TempDir() | ||
| 38 | + script := "#!/bin/sh\nfor a in \"$@\"; do echo \"[$a]\"; done\n" | ||
| 39 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { | ||
| 40 | + t.Fatal(err) | ||
| 41 | + } | ||
| 42 | + return dir | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func newDeclaredCall(tool config.DeclaredTool) declaredCall { | ||
| 46 | + return declaredCall{tool: tool, detector: detector.NewLoopDetector(10, 3)} | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +// Every type is coerced and rendered as one argv entry; an optional | ||
| 50 | +// parameter left out removes an entry that is only its placeholder, empties | ||
| 51 | +// one it is part of, and a default fills in. | ||
| 52 | +func TestBuildArgvRendersEveryType(t *testing.T) { | ||
| 53 | + argv, problem := buildArgv(releaseTool(), map[string]any{ | ||
| 54 | + "version": "1.4.2", "channel": "beta", "retries": "3", "ratio": 0.5, | ||
| 55 | + }) | ||
| 56 | + if problem != "" { | ||
| 57 | + t.Fatal(problem) | ||
| 58 | + } | ||
| 59 | + want := "./release.sh|1.4.2|beta|--dry-run=false|3|--ratio=0.5" | ||
| 60 | + if got := strings.Join(argv, "|"); got != want { | ||
| 61 | + t.Errorf("argv = %s, want %s", got, want) | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + argv, _ = buildArgv(releaseTool(), map[string]any{"version": "1.4.2", "channel": "stable", "dryRun": true}) | ||
| 65 | + want = "./release.sh|1.4.2|stable|--dry-run=true|--ratio=" | ||
| 66 | + if got := strings.Join(argv, "|"); got != want { | ||
| 67 | + t.Errorf("argv without the optional ones = %s, want %s", got, want) | ||
| 68 | + } | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// The call mellum2 made in ticket 88's bench — every argument packed into | ||
| 72 | +// one field — is refused with a text that quotes it back and says what each | ||
| 73 | +// parameter should be. | ||
| 74 | +func TestBuildArgvExplainsAPackedCall(t *testing.T) { | ||
| 75 | + _, problem := buildArgv(releaseTool(), map[string]any{"version": "1.4.2 1.4.2, channel beta"}) | ||
| 76 | + for _, want := range []string{ | ||
| 77 | + "invalid call, nothing was run", | ||
| 78 | + "channel is missing", | ||
| 79 | + `You sent: {"version":"1.4.2 1.4.2, channel beta"}`, | ||
| 80 | + "Expected: version (string, e.g. 1.4.2); channel (string, One of: stable, beta.)", | ||
| 81 | + "each parameter as its own field", | ||
| 82 | + } { | ||
| 83 | + if !strings.Contains(problem, want) { | ||
| 84 | + t.Errorf("refusal %q\nlacks %q", problem, want) | ||
| 85 | + } | ||
| 86 | + } | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// A wrong type, a value outside the enum and an unknown key are all | ||
| 90 | +// reported, in one refusal. | ||
| 91 | +func TestBuildArgvReportsEveryFault(t *testing.T) { | ||
| 92 | + _, problem := buildArgv(releaseTool(), map[string]any{ | ||
| 93 | + "version": "1", "channel": "nightly", "retries": "many", "force": true, | ||
| 94 | + }) | ||
| 95 | + for _, want := range []string{ | ||
| 96 | + `"force" is not a parameter of this tool`, | ||
| 97 | + "channel: nightly is not one of stable, beta", | ||
| 98 | + `retries: "many" is not an integer`, | ||
| 99 | + } { | ||
| 100 | + if !strings.Contains(problem, want) { | ||
| 101 | + t.Errorf("refusal %q\nlacks %q", problem, want) | ||
| 102 | + } | ||
| 103 | + } | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +// The model reads each parameter's type, its enum and whether it is optional; | ||
| 107 | +// the description names the required ones, since the schema does not. | ||
| 108 | +func TestDeclaredSchemaAndDescription(t *testing.T) { | ||
| 109 | + schema := declaredSchema(releaseTool()) | ||
| 110 | + props := schema["properties"].(map[string]any) | ||
| 111 | + channel := props["channel"].(map[string]any) | ||
| 112 | + if channel["type"] != "string" || channel["description"] != "One of: stable, beta." { | ||
| 113 | + t.Errorf("channel = %v", channel) | ||
| 114 | + } | ||
| 115 | + dryRun := props["dryRun"].(map[string]any) | ||
| 116 | + if dryRun["type"] != "boolean" || dryRun["description"] != "Optional, default false." { | ||
| 117 | + t.Errorf("dryRun = %v", dryRun) | ||
| 118 | + } | ||
| 119 | + if _, has := schema["required"]; has { | ||
| 120 | + t.Error("required must not be in the schema: Genkit would end the turn on a missing parameter") | ||
| 121 | + } | ||
| 122 | + if got := declaredDescription(releaseTool()); !strings.Contains(got, "Required parameters: version, channel.") { | ||
| 123 | + t.Errorf("description = %q", got) | ||
| 124 | + } | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// With yolo on, the evented call runs at once, in the session's directory, | ||
| 128 | +// and a value that looks like a shell command stays one argument. | ||
| 129 | +func TestDeclaredToolRunsWithoutAShell(t *testing.T) { | ||
| 130 | + dir := echoScript(t, "release.sh") | ||
| 131 | + sink := &recordingSink{allow: false, dir: dir} | ||
| 132 | + out, err := newDeclaredCall(releaseTool()).evented(context.Background(), sink, map[string]any{ | ||
| 133 | + "version": "1.4.2; touch pwned", "channel": "beta", | ||
| 134 | + }) | ||
| 135 | + if err != nil { | ||
| 136 | + t.Fatal(err) | ||
| 137 | + } | ||
| 138 | + if sink.asked != 0 { | ||
| 139 | + t.Errorf("asked %d time(s) with yolo on", sink.asked) | ||
| 140 | + } | ||
| 141 | + if !strings.Contains(out, "[1.4.2; touch pwned]\n[beta]") || sink.ended.Failed { | ||
| 142 | + t.Errorf("output %q, ended %+v", out, sink.ended) | ||
| 143 | + } | ||
| 144 | + if _, statErr := os.Stat(filepath.Join(dir, "pwned")); !os.IsNotExist(statErr) { | ||
| 145 | + t.Error("a parameter's value was run as a command") | ||
| 146 | + } | ||
| 147 | + if sink.started.Kind != "execute" || sink.started.Title != "release: ./release.sh 1.4.2; touch pwned beta --dry-run=false --ratio=" { | ||
| 148 | + t.Errorf("event %+v", sink.started) | ||
| 149 | + } | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +// With yolo off, a refused call runs nothing; an allowed one runs. | ||
| 153 | +func TestDeclaredToolAsksWhenYoloIsOff(t *testing.T) { | ||
| 154 | + yoloOff(t) | ||
| 155 | + dir := echoScript(t, "release.sh") | ||
| 156 | + sink := &recordingSink{allow: false, dir: dir} | ||
| 157 | + call := newDeclaredCall(releaseTool()) | ||
| 158 | + in := map[string]any{"version": "1", "channel": "beta"} | ||
| 159 | + | ||
| 160 | + out, _ := call.evented(context.Background(), sink, in) | ||
| 161 | + if sink.asked != 1 || out != "[release rejected by the user]" || !sink.ended.Failed { | ||
| 162 | + t.Errorf("refused: asked %d, out %q", sink.asked, out) | ||
| 163 | + } | ||
| 164 | + sink.allow = true | ||
| 165 | + if out, _ := call.evented(context.Background(), sink, in); !strings.Contains(out, "[beta]") { | ||
| 166 | + t.Errorf("allowed: %q", out) | ||
| 167 | + } | ||
| 168 | +} | ||
| 169 | + | ||
| 170 | +// An invalid call is answered without asking and without running. | ||
| 171 | +func TestDeclaredToolRefusesAnInvalidCallWithoutAsking(t *testing.T) { | ||
| 172 | + yoloOff(t) | ||
| 173 | + sink := &recordingSink{allow: true, dir: echoScript(t, "release.sh")} | ||
| 174 | + out, _ := newDeclaredCall(releaseTool()).evented(context.Background(), sink, map[string]any{}) | ||
| 175 | + if sink.asked != 0 || !strings.Contains(out, "version is missing; channel is missing") || !sink.ended.Failed { | ||
| 176 | + t.Errorf("asked %d, out %q, ended %+v", sink.asked, out, sink.ended) | ||
| 177 | + } | ||
| 178 | +} | ||
| 179 | + | ||
| 180 | +// A tool's own timeout bounds the call, and a program that does not exist is | ||
| 181 | +// reported as such. | ||
| 182 | +func TestDeclaredToolTimeoutAndMissingProgram(t *testing.T) { | ||
| 183 | + dir := t.TempDir() | ||
| 184 | + if err := os.WriteFile(filepath.Join(dir, "slow.sh"), []byte("#!/bin/sh\nsleep 5\n"), 0o755); err != nil { | ||
| 185 | + t.Fatal(err) | ||
| 186 | + } | ||
| 187 | + slow := config.DeclaredTool{Name: "slow", Description: "d", Command: []string{"./slow.sh"}, Timeout: 200 * time.Millisecond} | ||
| 188 | + sink := &recordingSink{dir: dir} | ||
| 189 | + start := time.Now() | ||
| 190 | + out, _ := newDeclaredCall(slow).evented(context.Background(), sink, map[string]any{}) | ||
| 191 | + if !strings.Contains(out, "200ms timeout exceeded") || time.Since(start) > 4*time.Second { | ||
| 192 | + t.Errorf("slow: %q after %s", out, time.Since(start)) | ||
| 193 | + } | ||
| 194 | + | ||
| 195 | + missing := config.DeclaredTool{Name: "missing", Description: "d", Command: []string{"./nope.sh"}} | ||
| 196 | + out, _ = newDeclaredCall(missing).evented(context.Background(), sink, map[string]any{}) | ||
| 197 | + if !strings.Contains(out, "./nope.sh cannot be started") || !sink.ended.Failed { | ||
| 198 | + t.Errorf("missing: %q", out) | ||
| 199 | + } | ||
| 200 | +} | ||
modified
internal/tools/tools.go +12 -2 | @@ -142,7 +142,14 @@ const outputCap = 1 << 20 | ||
| 142 | 142 | // descriptor and keeps working, which is the point — the user asked for that |
| 143 | 143 | // server — and writes into a file nobody reads. |
| 144 | 144 | func runBash(ctx context.Context, dir, command string) (output string, failed bool) { |
| 145 | - budget := config.Cfg.BashTimeout | |
| 145 | + return runArgv(ctx, dir, config.Cfg.BashTimeout, []string{"bash", "-c", command}) | |
| 146 | +} | |
| 147 | + | |
| 148 | +// runArgv runs argv — a program and its arguments, no shell in between | |
| 149 | +// unless argv asks for one — under budget, the way runBash describes: the | |
| 150 | +// turn's context, the output through a file, both streams merged. It is | |
| 151 | +// what `bash` and the declared tools share. | |
| 152 | +func runArgv(ctx context.Context, dir string, budget time.Duration, argv []string) (output string, failed bool) { | |
| 146 | 153 | ctx, cancel := context.WithTimeout(ctx, budget) |
| 147 | 154 | defer cancel() |
| 148 | 155 | |
| @@ -155,7 +162,7 @@ func runBash(ctx context.Context, dir, command string) (output string, failed bo | ||
| 155 | 162 | _ = os.Remove(sink.Name()) |
| 156 | 163 | defer func() { _ = sink.Close() }() |
| 157 | 164 | |
| 158 | - cmd := exec.CommandContext(ctx, "bash", "-c", command) | |
| 165 | + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) | |
| 159 | 166 | cmd.Dir = dir |
| 160 | 167 | // The same *os.File on both: one descriptor, one offset, so stdout and |
| 161 | 168 | // stderr interleave in the order they were written. |
| @@ -163,6 +170,9 @@ func runBash(ctx context.Context, dir, command string) (output string, failed bo | ||
| 163 | 170 | cmd.WaitDelay = waitDelay |
| 164 | 171 | err = cmd.Run() |
| 165 | 172 | output = truncate(collected(sink)) |
| 173 | + if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { | |
| 174 | + return output + fmt.Sprintf("[error: %s cannot be started: %v]", argv[0], err), true | |
| 175 | + } | |
| 166 | 176 | |
| 167 | 177 | switch { |
| 168 | 178 | case errors.Is(ctx.Err(), context.DeadlineExceeded): |
| @@ -142,7 +142,14 @@ const outputCap = 1 << 20 | |||
| 142 | // descriptor and keeps working, which is the point — the user asked for that | 142 | // descriptor and keeps working, which is the point — the user asked for that |
| 143 | // server — and writes into a file nobody reads. | 143 | // server — and writes into a file nobody reads. |
| 144 | func runBash(ctx context.Context, dir, command string) (output string, failed bool) { | 144 | func runBash(ctx context.Context, dir, command string) (output string, failed bool) { |
| 145 | - budget := config.Cfg.BashTimeout | 145 | + return runArgv(ctx, dir, config.Cfg.BashTimeout, []string{"bash", "-c", command}) |
| 146 | +} | ||
| 147 | + | ||
| 148 | +// runArgv runs argv — a program and its arguments, no shell in between | ||
| 149 | +// unless argv asks for one — under budget, the way runBash describes: the | ||
| 150 | +// turn's context, the output through a file, both streams merged. It is | ||
| 151 | +// what `bash` and the declared tools share. | ||
| 152 | +func runArgv(ctx context.Context, dir string, budget time.Duration, argv []string) (output string, failed bool) { | ||
| 146 | ctx, cancel := context.WithTimeout(ctx, budget) | 153 | ctx, cancel := context.WithTimeout(ctx, budget) |
| 147 | defer cancel() | 154 | defer cancel() |
| 148 | 155 | ||
| @@ -155,7 +162,7 @@ func runBash(ctx context.Context, dir, command string) (output string, failed bo | |||
| 155 | _ = os.Remove(sink.Name()) | 162 | _ = os.Remove(sink.Name()) |
| 156 | defer func() { _ = sink.Close() }() | 163 | defer func() { _ = sink.Close() }() |
| 157 | 164 | ||
| 158 | - cmd := exec.CommandContext(ctx, "bash", "-c", command) | 165 | + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) |
| 159 | cmd.Dir = dir | 166 | cmd.Dir = dir |
| 160 | // The same *os.File on both: one descriptor, one offset, so stdout and | 167 | // The same *os.File on both: one descriptor, one offset, so stdout and |
| 161 | // stderr interleave in the order they were written. | 168 | // stderr interleave in the order they were written. |
| @@ -163,6 +170,9 @@ func runBash(ctx context.Context, dir, command string) (output string, failed bo | |||
| 163 | cmd.WaitDelay = waitDelay | 170 | cmd.WaitDelay = waitDelay |
| 164 | err = cmd.Run() | 171 | err = cmd.Run() |
| 165 | output = truncate(collected(sink)) | 172 | output = truncate(collected(sink)) |
| 173 | + if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) { | ||
| 174 | + return output + fmt.Sprintf("[error: %s cannot be started: %v]", argv[0], err), true | ||
| 175 | + } | ||
| 166 | 176 | ||
| 167 | switch { | 177 | switch { |
| 168 | case errors.Is(ctx.Err(), context.DeadlineExceeded): | 178 | case errors.Is(ctx.Err(), context.DeadlineExceeded): |
modified
main.go +19 -1 | @@ -36,6 +36,7 @@ import ( | ||
| 36 | 36 | "os" |
| 37 | 37 | "os/signal" |
| 38 | 38 | "path/filepath" |
| 39 | + "slices" | |
| 39 | 40 | "strings" |
| 40 | 41 | "time" |
| 41 | 42 | |
| @@ -336,7 +337,7 @@ type toolSet struct { | ||
| 336 | 337 | // the PATH — a missing server is a warning, never a refusal to start; and |
| 337 | 338 | // `ask_agent` when mmConfigs names at least one agent, over the same pool as |
| 338 | 339 | // /ask; `ask_user` behind `askUserTool` (on by default), never with a |
| 339 | -// structuredOutput. | |
| 340 | +// structuredOutput; and the agent file's declared `tools:`. | |
| 340 | 341 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { |
| 341 | 342 | set := toolSet{close: func() {}} |
| 342 | 343 | if config.Cfg.BashTool { |
| @@ -384,9 +385,26 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent | ||
| 384 | 385 | } |
| 385 | 386 | assembleLSP(e, d, &set) |
| 386 | 387 | assembleMCP(e, d, &set) |
| 388 | + assembleDeclared(e, d, &set) | |
| 387 | 389 | return set |
| 388 | 390 | } |
| 389 | 391 | |
| 392 | +// assembleDeclared adds the agent file's `tools:` entries (ticket 88). They | |
| 393 | +// come last because an MCP tool's name is only known once its server | |
| 394 | +// answered: a declared tool that would take an MCP tool's name is skipped | |
| 395 | +// with a warning, never an error — the agent still starts. A built-in name | |
| 396 | +// cannot clash: the config refuses it at load. | |
| 397 | +func assembleDeclared(e *engine.Engine, d *detector.LoopDetector, set *toolSet) { | |
| 398 | + for _, t := range config.Cfg.Tools { | |
| 399 | + if slices.Contains(set.names, t.Name) { | |
| 400 | + set.warnings = append(set.warnings, fmt.Sprintf("tools: %s skipped — an MCP tool already has that name", t.Name)) | |
| 401 | + continue | |
| 402 | + } | |
| 403 | + set.tools = append(set.tools, tools.DeclaredTool(e.G, d, t)) | |
| 404 | + set.names = append(set.names, t.Name) | |
| 405 | + } | |
| 406 | +} | |
| 407 | + | |
| 390 | 408 | // assembleLSP adds the `lsp` tool over the configured servers that exist. |
| 391 | 409 | // The servers are not started here — each starts on its first use — but |
| 392 | 410 | // the client is created now so main can stop them at exit. The project root |
| @@ -36,6 +36,7 @@ import ( | |||
| 36 | "os" | 36 | "os" |
| 37 | "os/signal" | 37 | "os/signal" |
| 38 | "path/filepath" | 38 | "path/filepath" |
| 39 | + "slices" | ||
| 39 | "strings" | 40 | "strings" |
| 40 | "time" | 41 | "time" |
| 41 | 42 | ||
| @@ -336,7 +337,7 @@ type toolSet struct { | |||
| 336 | // the PATH — a missing server is a warning, never a refusal to start; and | 337 | // the PATH — a missing server is a warning, never a refusal to start; and |
| 337 | // `ask_agent` when mmConfigs names at least one agent, over the same pool as | 338 | // `ask_agent` when mmConfigs names at least one agent, over the same pool as |
| 338 | // /ask; `ask_user` behind `askUserTool` (on by default), never with a | 339 | // /ask; `ask_user` behind `askUserTool` (on by default), never with a |
| 339 | -// structuredOutput. | 340 | +// structuredOutput; and the agent file's declared `tools:`. |
| 340 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { | 341 | func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.AgentAsker) toolSet { |
| 341 | set := toolSet{close: func() {}} | 342 | set := toolSet{close: func() {}} |
| 342 | if config.Cfg.BashTool { | 343 | if config.Cfg.BashTool { |
| @@ -384,9 +385,26 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector, asker tools.Agent | |||
| 384 | } | 385 | } |
| 385 | assembleLSP(e, d, &set) | 386 | assembleLSP(e, d, &set) |
| 386 | assembleMCP(e, d, &set) | 387 | assembleMCP(e, d, &set) |
| 388 | + assembleDeclared(e, d, &set) | ||
| 387 | return set | 389 | return set |
| 388 | } | 390 | } |
| 389 | 391 | ||
| 392 | +// assembleDeclared adds the agent file's `tools:` entries (ticket 88). They | ||
| 393 | +// come last because an MCP tool's name is only known once its server | ||
| 394 | +// answered: a declared tool that would take an MCP tool's name is skipped | ||
| 395 | +// with a warning, never an error — the agent still starts. A built-in name | ||
| 396 | +// cannot clash: the config refuses it at load. | ||
| 397 | +func assembleDeclared(e *engine.Engine, d *detector.LoopDetector, set *toolSet) { | ||
| 398 | + for _, t := range config.Cfg.Tools { | ||
| 399 | + if slices.Contains(set.names, t.Name) { | ||
| 400 | + set.warnings = append(set.warnings, fmt.Sprintf("tools: %s skipped — an MCP tool already has that name", t.Name)) | ||
| 401 | + continue | ||
| 402 | + } | ||
| 403 | + set.tools = append(set.tools, tools.DeclaredTool(e.G, d, t)) | ||
| 404 | + set.names = append(set.names, t.Name) | ||
| 405 | + } | ||
| 406 | +} | ||
| 407 | + | ||
| 390 | // assembleLSP adds the `lsp` tool over the configured servers that exist. | 408 | // assembleLSP adds the `lsp` tool over the configured servers that exist. |
| 391 | // The servers are not started here — each starts on its first use — but | 409 | // The servers are not started here — each starts on its first use — but |
| 392 | // the client is created now so main can stop them at exit. The project root | 410 | // the client is created now so main can stop them at exit. The project root |