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

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

Download ZIP

Merge feature/structured-output into main

rickub committed 2026-10-09T03:06:52Z Browse files
3e8d3b0 parents: 3384e6d ffb1541
added .tickets/issues/0080-structured-output-from-go-type-declarations.yaml +138 -0
new file mode 100644
@@ -0,0 +1,138 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 80
3+title: Structured output from Go type declarations in the config
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-08T10:00:00.000Z
9+updatedAt: 2026-10-08T16:00:00.000Z
10+labels:
11+ - enhancement
12+epic: Models
13+related:
14+ - 54
15+ - 55
16+body: |
17+ ## Goal
18+
19+ Make the model answer with JSON that follows a structure written in the agent's YAML file, as Go type declarations:
20+
21+ ```yaml
22+ structuredOutput: |
23+ type Language struct {
24+ Name string `json:"name"` // language name, in English
25+ }
26+ type Country struct {
27+ Name string `json:"name"`
28+ Languages []Language `json:"languages"`
29+ }
30+ var Countries = []Country{}
31+ ```
32+
33+ `mm countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" | jq '.[].name'`
34+
35+ ## How the string becomes a structure
36+
37+ No Go type is created at runtime: Go cannot compile code while running. mm reads the text as SOURCE code:
38+
39+ 1. `go/parser` (standard library) parses `package schema` + the section into a syntax tree.
40+ 2. mm walks the tree: every `type X struct` is a definition, `var X = T{}` names the root, every field gives a key (its `json` tag, or the Go name when there is no tag), a type, and a description (its `//` comment).
41+ 3. That becomes a JSON Schema, given to Genkit with `ai.WithOutputSchema`. The OpenAI-compatible plugin sends it as `response_format: {type: json_schema, strict: true}`, so the server constrains generation (a grammar in llama.cpp), and Genkit checks the answer against the schema before handing it back.
42+
43+ Type mapping: `string` → string, `int*`/`uint*` → integer, `float*` → number, `bool` → boolean, `[]T` → array, `map[string]T` → object with additionalProperties, a declared struct → `$defs` + `$ref`, `*T` or `omitempty` → optional (required but nullable, which strict mode accepts). Anything else (interface, chan, func, generics, undefined type) is a config error with its line:col, at start-up.
44+
45+ ## Decisions
46+
47+ - Keys come from `json` tags; without a tag, the Go name as is (like encoding/json).
48+ - An agent that declares tools cannot have structuredOutput: config error. llama.cpp refuses a grammar together with tools, and a constrained answer leaves no room for a tool call.
49+ - Rejected: `reflect.StructOf` (no named or recursive types, no descriptions), yaegi (an interpreter as a dependency to read declarations), a YAML shape notation (a mini-language to invent, no descriptions).
50+ - The root may be an array: llama.cpp accepts it. OpenAI's hosted API demands an object root: use a struct there.
51+
52+ ## Found while building it (2026-10-08)
53+
54+ - `sampling.response_format` is rejected by Genkit's compat_oai plugin; the road is `ai.WithOutputSchema`, native only when the model declares `Constrained: ai.ConstrainedSupportNoTools`. Genkit validates the answer itself: mm has no validator of its own.
55+ - The schema alone does not reach the model (grammar = syntax only, Genkit adds no prompt text when the constraint is native): the declarations are appended to the system prompt. Before that, Qwen3.5-4B wrote the country's name in `capital` every time.
56+ - Properties are sent in declaration order (`structured.Properties`), but llama.cpp b11429 reorders them alphabetically in its grammar.
57+ - A small model can repeat list items until max_tokens: mm names that case ("cut off by max_tokens"); the demo bounds its lists in the comments.
58+ - Quality gate (task 6) not measured: qlty is not installed in the sandbox of 2026-10-08.
59+tasks:
60+ - id: 1
61+ title: "Package internal/structured: parse the Go declarations (go/parser) into a JSON Schema; errors with line:col"
62+ state: closed
63+ priority: none
64+ author:
65+ name: k33g
66+ email: ph.charriere@gmail.com
67+ createdAt: 2026-10-08T10:00:00.000Z
68+ updatedAt: 2026-10-08T11:00:00.000Z
69+ closedAt: 2026-10-08T11:00:00.000Z
70+ - id: 2
71+ title: "Config: structuredOutput field, compiled when the config loads; invalid declarations = config error"
72+ state: closed
73+ priority: none
74+ author:
75+ name: k33g
76+ email: ph.charriere@gmail.com
77+ createdAt: 2026-10-08T10:00:00.000Z
78+ updatedAt: 2026-10-08T16:00:00.000Z
79+ closedAt: 2026-10-08T16:00:00.000Z
80+ - id: 3
81+ title: "Engine: model declares constrained output (no tools); Generate passes ai.WithOutputSchema when structuredOutput is set"
82+ state: closed
83+ priority: none
84+ author:
85+ name: k33g
86+ email: ph.charriere@gmail.com
87+ createdAt: 2026-10-08T10:00:00.000Z
88+ updatedAt: 2026-10-08T16:00:00.000Z
89+ closedAt: 2026-10-08T16:00:00.000Z
90+ - id: 4
91+ title: "Refuse structuredOutput when the agent declares any tool (config error listing the tools)"
92+ state: closed
93+ priority: none
94+ author:
95+ name: k33g
96+ email: ph.charriere@gmail.com
97+ createdAt: 2026-10-08T10:00:00.000Z
98+ updatedAt: 2026-10-08T16:00:00.000Z
99+ closedAt: 2026-10-08T16:00:00.000Z
100+ - id: 5
101+ title: "Live check on a local server (llama.cpp / llmman): -p prints the JSON, jq reads it"
102+ state: closed
103+ priority: none
104+ author:
105+ name: k33g
106+ email: ph.charriere@gmail.com
107+ createdAt: 2026-10-08T10:00:00.000Z
108+ updatedAt: 2026-10-08T16:00:00.000Z
109+ closedAt: 2026-10-08T16:00:00.000Z
110+ - id: 6
111+ title: "Quality gate (quality skill)"
112+ state: open
113+ priority: none
114+ author:
115+ name: k33g
116+ email: ph.charriere@gmail.com
117+ createdAt: 2026-10-08T10:00:00.000Z
118+ - id: 7
119+ title: "Docs EN+FR (how-to + reference/configuration) and .memory update"
120+ state: closed
121+ priority: none
122+ author:
123+ name: k33g
124+ email: ph.charriere@gmail.com
125+ createdAt: 2026-10-08T10:00:00.000Z
126+ updatedAt: 2026-10-08T16:00:00.000Z
127+ closedAt: 2026-10-08T16:00:00.000Z
128+ - id: 8
129+ title: "Demo folder demo-structured-output: a simple JSON agent (countries), a second one (triage), a coordinator using both through ask_agent, READMEs FR+EN"
130+ state: closed
131+ priority: none
132+ author:
133+ name: k33g
134+ email: ph.charriere@gmail.com
135+ createdAt: 2026-10-08T14:00:00.000Z
136+ updatedAt: 2026-10-08T16:00:00.000Z
137+ closedAt: 2026-10-08T16:00:00.000Z
138+comments: []
new file mode 100644
@@ -0,0 +1,138 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 80
3+title: Structured output from Go type declarations in the config
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-10-08T10:00:00.000Z
9+updatedAt: 2026-10-08T16:00:00.000Z
10+labels:
11+ - enhancement
12+epic: Models
13+related:
14+ - 54
15+ - 55
16+body: |
17+ ## Goal
18+
19+ Make the model answer with JSON that follows a structure written in the agent's YAML file, as Go type declarations:
20+
21+ ```yaml
22+ structuredOutput: |
23+ type Language struct {
24+ Name string `json:"name"` // language name, in English
25+ }
26+ type Country struct {
27+ Name string `json:"name"`
28+ Languages []Language `json:"languages"`
29+ }
30+ var Countries = []Country{}
31+ ```
32+
33+ `mm countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" | jq '.[].name'`
34+
35+ ## How the string becomes a structure
36+
37+ No Go type is created at runtime: Go cannot compile code while running. mm reads the text as SOURCE code:
38+
39+ 1. `go/parser` (standard library) parses `package schema` + the section into a syntax tree.
40+ 2. mm walks the tree: every `type X struct` is a definition, `var X = T{}` names the root, every field gives a key (its `json` tag, or the Go name when there is no tag), a type, and a description (its `//` comment).
41+ 3. That becomes a JSON Schema, given to Genkit with `ai.WithOutputSchema`. The OpenAI-compatible plugin sends it as `response_format: {type: json_schema, strict: true}`, so the server constrains generation (a grammar in llama.cpp), and Genkit checks the answer against the schema before handing it back.
42+
43+ Type mapping: `string` → string, `int*`/`uint*` → integer, `float*` → number, `bool` → boolean, `[]T` → array, `map[string]T` → object with additionalProperties, a declared struct → `$defs` + `$ref`, `*T` or `omitempty` → optional (required but nullable, which strict mode accepts). Anything else (interface, chan, func, generics, undefined type) is a config error with its line:col, at start-up.
44+
45+ ## Decisions
46+
47+ - Keys come from `json` tags; without a tag, the Go name as is (like encoding/json).
48+ - An agent that declares tools cannot have structuredOutput: config error. llama.cpp refuses a grammar together with tools, and a constrained answer leaves no room for a tool call.
49+ - Rejected: `reflect.StructOf` (no named or recursive types, no descriptions), yaegi (an interpreter as a dependency to read declarations), a YAML shape notation (a mini-language to invent, no descriptions).
50+ - The root may be an array: llama.cpp accepts it. OpenAI's hosted API demands an object root: use a struct there.
51+
52+ ## Found while building it (2026-10-08)
53+
54+ - `sampling.response_format` is rejected by Genkit's compat_oai plugin; the road is `ai.WithOutputSchema`, native only when the model declares `Constrained: ai.ConstrainedSupportNoTools`. Genkit validates the answer itself: mm has no validator of its own.
55+ - The schema alone does not reach the model (grammar = syntax only, Genkit adds no prompt text when the constraint is native): the declarations are appended to the system prompt. Before that, Qwen3.5-4B wrote the country's name in `capital` every time.
56+ - Properties are sent in declaration order (`structured.Properties`), but llama.cpp b11429 reorders them alphabetically in its grammar.
57+ - A small model can repeat list items until max_tokens: mm names that case ("cut off by max_tokens"); the demo bounds its lists in the comments.
58+ - Quality gate (task 6) not measured: qlty is not installed in the sandbox of 2026-10-08.
59+tasks:
60+ - id: 1
61+ title: "Package internal/structured: parse the Go declarations (go/parser) into a JSON Schema; errors with line:col"
62+ state: closed
63+ priority: none
64+ author:
65+ name: k33g
66+ email: ph.charriere@gmail.com
67+ createdAt: 2026-10-08T10:00:00.000Z
68+ updatedAt: 2026-10-08T11:00:00.000Z
69+ closedAt: 2026-10-08T11:00:00.000Z
70+ - id: 2
71+ title: "Config: structuredOutput field, compiled when the config loads; invalid declarations = config error"
72+ state: closed
73+ priority: none
74+ author:
75+ name: k33g
76+ email: ph.charriere@gmail.com
77+ createdAt: 2026-10-08T10:00:00.000Z
78+ updatedAt: 2026-10-08T16:00:00.000Z
79+ closedAt: 2026-10-08T16:00:00.000Z
80+ - id: 3
81+ title: "Engine: model declares constrained output (no tools); Generate passes ai.WithOutputSchema when structuredOutput is set"
82+ state: closed
83+ priority: none
84+ author:
85+ name: k33g
86+ email: ph.charriere@gmail.com
87+ createdAt: 2026-10-08T10:00:00.000Z
88+ updatedAt: 2026-10-08T16:00:00.000Z
89+ closedAt: 2026-10-08T16:00:00.000Z
90+ - id: 4
91+ title: "Refuse structuredOutput when the agent declares any tool (config error listing the tools)"
92+ state: closed
93+ priority: none
94+ author:
95+ name: k33g
96+ email: ph.charriere@gmail.com
97+ createdAt: 2026-10-08T10:00:00.000Z
98+ updatedAt: 2026-10-08T16:00:00.000Z
99+ closedAt: 2026-10-08T16:00:00.000Z
100+ - id: 5
101+ title: "Live check on a local server (llama.cpp / llmman): -p prints the JSON, jq reads it"
102+ state: closed
103+ priority: none
104+ author:
105+ name: k33g
106+ email: ph.charriere@gmail.com
107+ createdAt: 2026-10-08T10:00:00.000Z
108+ updatedAt: 2026-10-08T16:00:00.000Z
109+ closedAt: 2026-10-08T16:00:00.000Z
110+ - id: 6
111+ title: "Quality gate (quality skill)"
112+ state: open
113+ priority: none
114+ author:
115+ name: k33g
116+ email: ph.charriere@gmail.com
117+ createdAt: 2026-10-08T10:00:00.000Z
118+ - id: 7
119+ title: "Docs EN+FR (how-to + reference/configuration) and .memory update"
120+ state: closed
121+ priority: none
122+ author:
123+ name: k33g
124+ email: ph.charriere@gmail.com
125+ createdAt: 2026-10-08T10:00:00.000Z
126+ updatedAt: 2026-10-08T16:00:00.000Z
127+ closedAt: 2026-10-08T16:00:00.000Z
128+ - id: 8
129+ title: "Demo folder demo-structured-output: a simple JSON agent (countries), a second one (triage), a coordinator using both through ask_agent, READMEs FR+EN"
130+ state: closed
131+ priority: none
132+ author:
133+ name: k33g
134+ email: ph.charriere@gmail.com
135+ createdAt: 2026-10-08T14:00:00.000Z
136+ updatedAt: 2026-10-08T16:00:00.000Z
137+ closedAt: 2026-10-08T16:00:00.000Z
138+comments: []
added demo-structured-output/README.en.md +159 -0
new file mode 100644
@@ -0,0 +1,159 @@
1+# mini-me demo: JSON answers (`structuredOutput`)
2+
3+This directory shows how to make a mini-me agent answer **only with JSON**, in a shape you declare as Go types in its YAML file, and then how another agent uses that JSON. The models are served by [llmman](https://github.com/llmmanorg/llmman). Version française : [README.md](README.md).
4+
5+## What is here
6+
7+| File | Role |
8+|---|---|
9+| `agent.countries.yaml` | **countries**: a simple JSON agent. A question about countries → an array of countries (name, capital, languages). Array root: `var Countries = []Country{}`. |
10+| `agent.triage.yaml` | **triage**: a second JSON agent. Bug reports → an object `{"reports": [...]}` (title, severity, component, summary, duplicate). Struct root: `var Answer = Triage{}`. |
11+| `agent.coordinator.yaml` | **Bob**, the coordinator: a normal agent with tools (`ask_agent`, `read_file`, `write_file`), who hands questions to countries and triage and works from their JSON. |
12+| `reports.txt` | Four bug reports for the multi-agent example (the 4th repeats the 1st). |
13+
14+All three agents use `hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`.
15+
16+## Prerequisites
17+
18+1. llmman running (`llmman serve`, on `127.0.0.1:17434`; from a Docker Sandbox, mm falls back to `host.docker.internal:17434` by itself).
19+2. The model, pulled beforehand (otherwise the first question pulls it and waits):
20+
21+ ```bash
22+ llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
23+ ```
24+
25+3. `mm` built at the repository root (`go build -o mm .`). **Run it from this directory**: the `mmConfigs` paths are relative to `agent.coordinator.yaml`, and `reports.txt` is read from the current directory.
26+
27+ ```bash
28+ cd demo-structured-output
29+ ```
30+
31+4. `jq`, to read the JSON (optional).
32+
33+If `jq` answers `parse error: Invalid numeric literal at line 1, column 8`, `mm` did not give it JSON: run the command again without `| jq` to read the message. The usual case: the command runs outside this directory, and `agent.countries.yaml` is not found (`[config error: open agent.countries.yaml: no such file or directory ]`). Since the version that adds `structuredOutput`, these errors go to stderr and `jq` receives nothing.
34+
35+## How the shape of the answer is declared
36+
37+In `agent.countries.yaml`:
38+
39+```yaml
40+structuredOutput: |
41+ // A language spoken in the country.
42+ type Language struct {
43+ Name string `json:"name"` // the language's name, in English
44+ Official bool `json:"official"` // true when it has an official status at the national level
45+ }
46+
47+ type Country struct {
48+ Name string `json:"name"` // the country's common name, in English
49+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
50+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
51+ }
52+
53+ // One entry per country named in the question, in the order asked.
54+ var Countries = []Country{}
55+```
56+
57+- Every `type` is a definition; the one `var` names the **root** of the answer.
58+- A field's key is its `json` tag (its Go name when it has none).
59+- A pointer (`*string`) or `omitempty` makes the value optional: it may be `null`.
60+- **The `//` comments are the instructions the model reads**: mm appends them to its system prompt. Write them as instructions, and bound the lists (`at most 4`) — otherwise a small model may repeat items until `max_tokens`.
61+
62+mm translates these declarations into a JSON Schema, the server constrains the generation to it, and the answer is checked before it is handed back.
63+
64+A JSON agent has **no tool at all** (`bashTool`, `editTools`, `planTool` set to `false`, no skills, no `mmConfigs`): a constrained answer leaves no room for a tool call, and mm refuses to start otherwise.
65+
66+## 1. A simple agent: one question, JSON back
67+
68+```bash
69+../mm agent.countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" | jq .
70+```
71+
72+With `-p`, stdout carries **only the JSON** (no 🤖, no statistics), and the exit status is 1 when the answer is missing or invalid. So it can be processed directly:
73+
74+```bash
75+../mm agent.countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" \
76+ | jq -c '.[] | {name, capital, languages: [.languages[].name]}'
77+```
78+
79+```
80+{"name":"France","capital":"Paris","languages":["French","Occitan","Breton","Catalan"]}
81+{"name":"Canada","capital":"Ottawa","languages":["English","French","Algonquin","Innu-aimun"]}
82+{"name":"Vietnam","capital":"Hanoi","languages":["Vietnamese","French","Chinese","English"]}
83+{"name":"United States","capital":"Washington, D.C.","languages":["English","Spanish","Chinese","Vietnamese"]}
84+```
85+
86+The minor languages may vary with the wording of the question; the name and the capital stay stable.
87+
88+The second agent, on the reports file:
89+
90+```bash
91+../mm agent.triage.yaml -p "$(cat reports.txt)" | jq -c '.reports[] | {severity, title, duplicate}'
92+```
93+
94+```
95+{"severity":"high","title":"Export freezes and closes on large projects","duplicate":null}
96+{"severity":"low","title":"Date format incorrect in Settings","duplicate":null}
97+{"severity":"low","title":"Typo on login page","duplicate":null}
98+{"severity":"high","title":"Export crashes on big projects","duplicate":1}
99+```
100+
101+In an interactive session (`../mm agent.countries.yaml`, or `../mm -tui agent.countries.yaml`), every answer is JSON, shown like any answer; the banner says `tools: none (structured output: JSON answers)`.
102+
103+## 2. Several agents: Bob uses the others' JSON
104+
105+Bob cannot have a `structuredOutput` himself: he has tools. He declares the two JSON agents under `mmConfigs` and calls them with `ask_agent`; their JSON comes back to him as the tool's result.
106+
107+```yaml
108+mmConfigs:
109+ countries:
110+ path: agent.countries.yaml
111+ description: Facts about countries (capital, languages). Answers in JSON. Name every country in the prompt.
112+ triage:
113+ path: agent.triage.yaml
114+ description: Triages bug reports (title, severity, component, summary, duplicate). Answers in JSON. Put the full text of every report in the prompt.
115+```
116+
117+Read the reports, have them triaged, write a table:
118+
119+```bash
120+../mm agent.coordinator.yaml -p "Read reports.txt, have the reports triaged, then write triage.md: a Markdown table (title, severity, component, duplicate of) sorted by severity, high first."
121+```
122+
123+```
124+📄 read_file: reports.txt
125+🗣 ask_agent: triage (llmman/hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M) — Triage these bug reports from r…
126+{"reports":[{"component":"Export","duplicate":null,"severity":"high",…}]}
127+💾 write_file: triage.md (340 bytes)
128+```
129+
130+A factual question, which Bob hands to countries:
131+
132+```bash
133+../mm agent.coordinator.yaml -p "Which of Belgium and Switzerland has more official languages? Answer in one sentence."
134+```
135+
136+Or in a session, to watch the agent work: `../mm -tui agent.coordinator.yaml`.
137+
138+## 3. Ask a JSON agent from a shell, through Bob's config
139+
140+`mm --ask` prints only the asked agent's answer — here, its JSON:
141+
142+```bash
143+../mm agent.coordinator.yaml --ask countries -p "capital and languages of Vietnam" | jq .
144+```
145+
146+## Good to know
147+
148+- **llama.cpp (so llmman) writes the keys in alphabetical order**, whatever the declaration order: here `capital` before `name`. The model fills a field from what it already wrote: make every comment self-sufficient ("the capital CITY (not the country)").
149+- **Cut answer**: `the answer was cut off by max_tokens before its JSON was complete` — bound the lists in their comments, or raise `sampling.max_tokens`.
150+- **Reasoning**: countries and triage turn it off (`chat_template_kwargs.enable_thinking: false`) — a lookup does not need it, and it is faster. Bob keeps it: without reasoning, the 4B model answered from memory and claimed it had asked countries.
151+- **OpenAI hosted API**: its strict mode demands an object at the root — use a struct, as `agent.triage.yaml` does.
152+- **With tools**: `structuredOutput cannot be used with tools — this agent declares: …`; turn the listed tools off.
153+- To see the exact request (the `response_format` sent): `AGENT_TRACE=true ../mm agent.countries.yaml -p "…"`, then the files in `.mm/traces/`.
154+
155+## Further reading
156+
157+- [Get answers as JSON](../docs/en/how-to/get-json-answers.md)
158+- [`structuredOutput` reference](../docs/en/reference/configuration.md#structuredoutput)
159+- [Structured output — explanation](../docs/en/explanation/structured-output.md)
new file mode 100644
@@ -0,0 +1,159 @@
1+# mini-me demo: JSON answers (`structuredOutput`)
2+
3+This directory shows how to make a mini-me agent answer **only with JSON**, in a shape you declare as Go types in its YAML file, and then how another agent uses that JSON. The models are served by [llmman](https://github.com/llmmanorg/llmman). Version française : [README.md](README.md).
4+
5+## What is here
6+
7+| File | Role |
8+|---|---|
9+| `agent.countries.yaml` | **countries**: a simple JSON agent. A question about countries → an array of countries (name, capital, languages). Array root: `var Countries = []Country{}`. |
10+| `agent.triage.yaml` | **triage**: a second JSON agent. Bug reports → an object `{"reports": [...]}` (title, severity, component, summary, duplicate). Struct root: `var Answer = Triage{}`. |
11+| `agent.coordinator.yaml` | **Bob**, the coordinator: a normal agent with tools (`ask_agent`, `read_file`, `write_file`), who hands questions to countries and triage and works from their JSON. |
12+| `reports.txt` | Four bug reports for the multi-agent example (the 4th repeats the 1st). |
13+
14+All three agents use `hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`.
15+
16+## Prerequisites
17+
18+1. llmman running (`llmman serve`, on `127.0.0.1:17434`; from a Docker Sandbox, mm falls back to `host.docker.internal:17434` by itself).
19+2. The model, pulled beforehand (otherwise the first question pulls it and waits):
20+
21+ ```bash
22+ llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
23+ ```
24+
25+3. `mm` built at the repository root (`go build -o mm .`). **Run it from this directory**: the `mmConfigs` paths are relative to `agent.coordinator.yaml`, and `reports.txt` is read from the current directory.
26+
27+ ```bash
28+ cd demo-structured-output
29+ ```
30+
31+4. `jq`, to read the JSON (optional).
32+
33+If `jq` answers `parse error: Invalid numeric literal at line 1, column 8`, `mm` did not give it JSON: run the command again without `| jq` to read the message. The usual case: the command runs outside this directory, and `agent.countries.yaml` is not found (`[config error: open agent.countries.yaml: no such file or directory ]`). Since the version that adds `structuredOutput`, these errors go to stderr and `jq` receives nothing.
34+
35+## How the shape of the answer is declared
36+
37+In `agent.countries.yaml`:
38+
39+```yaml
40+structuredOutput: |
41+ // A language spoken in the country.
42+ type Language struct {
43+ Name string `json:"name"` // the language's name, in English
44+ Official bool `json:"official"` // true when it has an official status at the national level
45+ }
46+
47+ type Country struct {
48+ Name string `json:"name"` // the country's common name, in English
49+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
50+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
51+ }
52+
53+ // One entry per country named in the question, in the order asked.
54+ var Countries = []Country{}
55+```
56+
57+- Every `type` is a definition; the one `var` names the **root** of the answer.
58+- A field's key is its `json` tag (its Go name when it has none).
59+- A pointer (`*string`) or `omitempty` makes the value optional: it may be `null`.
60+- **The `//` comments are the instructions the model reads**: mm appends them to its system prompt. Write them as instructions, and bound the lists (`at most 4`) — otherwise a small model may repeat items until `max_tokens`.
61+
62+mm translates these declarations into a JSON Schema, the server constrains the generation to it, and the answer is checked before it is handed back.
63+
64+A JSON agent has **no tool at all** (`bashTool`, `editTools`, `planTool` set to `false`, no skills, no `mmConfigs`): a constrained answer leaves no room for a tool call, and mm refuses to start otherwise.
65+
66+## 1. A simple agent: one question, JSON back
67+
68+```bash
69+../mm agent.countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" | jq .
70+```
71+
72+With `-p`, stdout carries **only the JSON** (no 🤖, no statistics), and the exit status is 1 when the answer is missing or invalid. So it can be processed directly:
73+
74+```bash
75+../mm agent.countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" \
76+ | jq -c '.[] | {name, capital, languages: [.languages[].name]}'
77+```
78+
79+```
80+{"name":"France","capital":"Paris","languages":["French","Occitan","Breton","Catalan"]}
81+{"name":"Canada","capital":"Ottawa","languages":["English","French","Algonquin","Innu-aimun"]}
82+{"name":"Vietnam","capital":"Hanoi","languages":["Vietnamese","French","Chinese","English"]}
83+{"name":"United States","capital":"Washington, D.C.","languages":["English","Spanish","Chinese","Vietnamese"]}
84+```
85+
86+The minor languages may vary with the wording of the question; the name and the capital stay stable.
87+
88+The second agent, on the reports file:
89+
90+```bash
91+../mm agent.triage.yaml -p "$(cat reports.txt)" | jq -c '.reports[] | {severity, title, duplicate}'
92+```
93+
94+```
95+{"severity":"high","title":"Export freezes and closes on large projects","duplicate":null}
96+{"severity":"low","title":"Date format incorrect in Settings","duplicate":null}
97+{"severity":"low","title":"Typo on login page","duplicate":null}
98+{"severity":"high","title":"Export crashes on big projects","duplicate":1}
99+```
100+
101+In an interactive session (`../mm agent.countries.yaml`, or `../mm -tui agent.countries.yaml`), every answer is JSON, shown like any answer; the banner says `tools: none (structured output: JSON answers)`.
102+
103+## 2. Several agents: Bob uses the others' JSON
104+
105+Bob cannot have a `structuredOutput` himself: he has tools. He declares the two JSON agents under `mmConfigs` and calls them with `ask_agent`; their JSON comes back to him as the tool's result.
106+
107+```yaml
108+mmConfigs:
109+ countries:
110+ path: agent.countries.yaml
111+ description: Facts about countries (capital, languages). Answers in JSON. Name every country in the prompt.
112+ triage:
113+ path: agent.triage.yaml
114+ description: Triages bug reports (title, severity, component, summary, duplicate). Answers in JSON. Put the full text of every report in the prompt.
115+```
116+
117+Read the reports, have them triaged, write a table:
118+
119+```bash
120+../mm agent.coordinator.yaml -p "Read reports.txt, have the reports triaged, then write triage.md: a Markdown table (title, severity, component, duplicate of) sorted by severity, high first."
121+```
122+
123+```
124+📄 read_file: reports.txt
125+🗣 ask_agent: triage (llmman/hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M) — Triage these bug reports from r…
126+{"reports":[{"component":"Export","duplicate":null,"severity":"high",…}]}
127+💾 write_file: triage.md (340 bytes)
128+```
129+
130+A factual question, which Bob hands to countries:
131+
132+```bash
133+../mm agent.coordinator.yaml -p "Which of Belgium and Switzerland has more official languages? Answer in one sentence."
134+```
135+
136+Or in a session, to watch the agent work: `../mm -tui agent.coordinator.yaml`.
137+
138+## 3. Ask a JSON agent from a shell, through Bob's config
139+
140+`mm --ask` prints only the asked agent's answer — here, its JSON:
141+
142+```bash
143+../mm agent.coordinator.yaml --ask countries -p "capital and languages of Vietnam" | jq .
144+```
145+
146+## Good to know
147+
148+- **llama.cpp (so llmman) writes the keys in alphabetical order**, whatever the declaration order: here `capital` before `name`. The model fills a field from what it already wrote: make every comment self-sufficient ("the capital CITY (not the country)").
149+- **Cut answer**: `the answer was cut off by max_tokens before its JSON was complete` — bound the lists in their comments, or raise `sampling.max_tokens`.
150+- **Reasoning**: countries and triage turn it off (`chat_template_kwargs.enable_thinking: false`) — a lookup does not need it, and it is faster. Bob keeps it: without reasoning, the 4B model answered from memory and claimed it had asked countries.
151+- **OpenAI hosted API**: its strict mode demands an object at the root — use a struct, as `agent.triage.yaml` does.
152+- **With tools**: `structuredOutput cannot be used with tools — this agent declares: …`; turn the listed tools off.
153+- To see the exact request (the `response_format` sent): `AGENT_TRACE=true ../mm agent.countries.yaml -p "…"`, then the files in `.mm/traces/`.
154+
155+## Further reading
156+
157+- [Get answers as JSON](../docs/en/how-to/get-json-answers.md)
158+- [`structuredOutput` reference](../docs/en/reference/configuration.md#structuredoutput)
159+- [Structured output — explanation](../docs/en/explanation/structured-output.md)
added demo-structured-output/README.md +159 -0
new file mode 100644
@@ -0,0 +1,159 @@
1+# Démo mini-me : réponses en JSON (`structuredOutput`)
2+
3+Ce dossier montre comment faire répondre un agent mini-me **uniquement en JSON**, selon une forme que vous déclarez en types Go dans son fichier YAML, puis comment un autre agent exploite ce JSON. Les modèles sont servis par [llmman](https://github.com/llmmanorg/llmman). English version: [README.en.md](README.en.md).
4+
5+## Ce qu'il y a ici
6+
7+| Fichier | Rôle |
8+|---|---|
9+| `agent.countries.yaml` | **countries** : un agent JSON simple. Une question sur des pays → un tableau de pays (nom, capitale, langues). Racine tableau : `var Countries = []Country{}`. |
10+| `agent.triage.yaml` | **triage** : un deuxième agent JSON. Des rapports de bugs → un objet `{"reports": [...]}` (titre, gravité, composant, résumé, doublon). Racine struct : `var Answer = Triage{}`. |
11+| `agent.coordinator.yaml` | **Bob**, le coordinateur : un agent normal, avec des outils (`ask_agent`, `read_file`, `write_file`), qui confie les questions à countries et triage et travaille à partir de leur JSON. |
12+| `reports.txt` | Quatre rapports de bugs pour l'exemple multi-agents (le 4ᵉ répète le 1ᵉʳ). |
13+
14+Les trois agents utilisent `hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`.
15+
16+## Prérequis
17+
18+1. llmman qui tourne (`llmman serve`, sur `127.0.0.1:17434` ; depuis un Docker Sandbox, mm se replie tout seul sur `host.docker.internal:17434`).
19+2. Le modèle, téléchargé à l'avance (sinon la première question le télécharge et attend) :
20+
21+ ```bash
22+ llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
23+ ```
24+
25+3. `mm` compilé à la racine du dépôt (`go build -o mm .`). **Lancez-le depuis ce dossier** : les chemins de `mmConfigs` sont relatifs à `agent.coordinator.yaml`, et `reports.txt` est lu dans le dossier courant.
26+
27+ ```bash
28+ cd demo-structured-output
29+ ```
30+
31+4. `jq`, pour lire le JSON (facultatif).
32+
33+Si `jq` répond `parse error: Invalid numeric literal at line 1, column 8`, c'est que `mm` ne vous a pas donné de JSON : relancez sans `| jq` pour lire le message. Le cas le plus courant : la commande est lancée hors de ce dossier, et `agent.countries.yaml` est introuvable (`[config error: open agent.countries.yaml: no such file or directory ]`). Depuis la version qui ajoute `structuredOutput`, ces erreurs vont sur stderr et `jq` ne reçoit plus rien.
34+
35+## Comment se déclare la forme de la réponse
36+
37+Dans `agent.countries.yaml` :
38+
39+```yaml
40+structuredOutput: |
41+ // A language spoken in the country.
42+ type Language struct {
43+ Name string `json:"name"` // the language's name, in English
44+ Official bool `json:"official"` // true when it has an official status at the national level
45+ }
46+
47+ type Country struct {
48+ Name string `json:"name"` // the country's common name, in English
49+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
50+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
51+ }
52+
53+ // One entry per country named in the question, in the order asked.
54+ var Countries = []Country{}
55+```
56+
57+- Chaque `type` est une définition, l'unique `var` nomme la **racine** de la réponse.
58+- La clé d'un champ est son tag `json` (son nom Go s'il n'en a pas).
59+- Un pointeur (`*string`) ou `omitempty` rend la valeur facultative : elle peut valoir `null`.
60+- **Les commentaires `//` sont les consignes que lit le modèle** : mm les ajoute à son prompt système. Écrivez-les comme des instructions, et bornez les listes (`at most 4`) — sinon un petit modèle peut en répéter les éléments jusqu'à `max_tokens`.
61+
62+mm traduit ces déclarations en JSON Schema, le serveur contraint la génération à ce schéma, et la réponse est vérifiée avant d'être rendue.
63+
64+Un agent JSON **n'a aucun outil** (`bashTool`, `editTools`, `planTool` à `false`, pas de skills, pas de `mmConfigs`) : une réponse contrainte ne laisse pas de place à un appel d'outil, et mm refuse de démarrer sinon.
65+
66+## 1. Un agent simple : une question, du JSON
67+
68+```bash
69+mm agent.countries.yaml -p "donne moi les langages parlés de la France, le Canada, le Vietnam et les US" | jq .
70+```
71+
72+Avec `-p`, stdout ne porte **que le JSON** (rien d'autre : ni 🤖, ni statistiques), et le code de sortie vaut 1 si la réponse manque ou est invalide. On peut donc le traiter directement :
73+
74+```bash
75+mm agent.countries.yaml -p "donne moi les langages parlés de la France, le Canada, le Vietnam et les US" \
76+ | jq -c '.[] | {name, capital, langues: [.languages[].name]}'
77+```
78+
79+```
80+{"name":"France","capital":"Paris","langues":["French","Occitan","Breton","Catalan"]}
81+{"name":"Canada","capital":"Ottawa","langues":["English","French","Algonquin","Innu-aimun"]}
82+{"name":"Vietnam","capital":"Hanoi","langues":["Vietnamese","French","Chinese","English"]}
83+{"name":"United States","capital":"Washington, D.C.","langues":["English","Spanish","Chinese","Vietnamese"]}
84+```
85+
86+Les langues secondaires peuvent varier d'une formulation de la question à l'autre ; le nom et la capitale restent stables.
87+
88+Le deuxième agent, sur le fichier de rapports :
89+
90+```bash
91+../mm agent.triage.yaml -p "$(cat reports.txt)" | jq -c '.reports[] | {severity, title, duplicate}'
92+```
93+
94+```
95+{"severity":"high","title":"Export freezes and closes on large projects","duplicate":null}
96+{"severity":"low","title":"Date format incorrect in Settings","duplicate":null}
97+{"severity":"low","title":"Typo on login page","duplicate":null}
98+{"severity":"high","title":"Export crashes on big projects","duplicate":1}
99+```
100+
101+En session interactive (`../mm agent.countries.yaml`, ou `../mm -tui agent.countries.yaml`), chaque réponse est du JSON, affiché comme n'importe quelle réponse ; la bannière indique `tools: none (structured output: JSON answers)`.
102+
103+## 2. Plusieurs agents : Bob exploite le JSON des autres
104+
105+Bob ne peut pas avoir de `structuredOutput` lui-même : il a des outils. Il déclare les deux agents JSON sous `mmConfigs` et les appelle avec `ask_agent` ; leur JSON lui revient comme résultat de l'outil.
106+
107+```yaml
108+mmConfigs:
109+ countries:
110+ path: agent.countries.yaml
111+ description: Facts about countries (capital, languages). Answers in JSON. Name every country in the prompt.
112+ triage:
113+ path: agent.triage.yaml
114+ description: Triages bug reports (title, severity, component, summary, duplicate). Answers in JSON. Put the full text of every report in the prompt.
115+```
116+
117+Lire les rapports, les faire trier, écrire un tableau :
118+
119+```bash
120+mm agent.coordinator.yaml -p "Read reports.txt, have the reports triaged, then write triage.md: a Markdown table (title, severity, component, duplicate of) sorted by severity, high first."
121+```
122+
123+```
124+📄 read_file: reports.txt
125+🗣 ask_agent: triage (llmman/hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M) — Triage these bug reports from r…
126+{"reports":[{"component":"Export","duplicate":null,"severity":"high",…}]}
127+💾 write_file: triage.md (340 bytes)
128+```
129+
130+Une question de faits, que Bob confie à countries :
131+
132+```bash
133+mm agent.coordinator.yaml -p "Which of Belgium and Switzerland has more official languages? Answer in one sentence."
134+```
135+
136+Ou en session, pour voir l'agent travailler : `mm -tui agent.coordinator.yaml`.
137+
138+## 3. Interroger un agent JSON depuis un shell, par la config de Bob
139+
140+`mm --ask` n'imprime que la réponse de l'agent demandé — ici, son JSON :
141+
142+```bash
143+mm agent.coordinator.yaml --ask countries -p "capital and languages of Vietnam" | jq .
144+```
145+
146+## Ce qu'il faut savoir
147+
148+- **llama.cpp (donc llmman) écrit les clés dans l'ordre alphabétique**, quel que soit l'ordre de déclaration : ici `capital` avant `name`. Le modèle remplit un champ à partir de ce qu'il a déjà écrit : rendez chaque commentaire autosuffisant (« the capital CITY (not the country) »).
149+- **Réponse coupée** : `the answer was cut off by max_tokens before its JSON was complete` — bornez les listes dans leurs commentaires ou augmentez `sampling.max_tokens`.
150+- **Raisonnement** : countries et triage l'ont coupé (`chat_template_kwargs.enable_thinking: false`) — une consultation n'en a pas besoin, et c'est plus rapide. Bob le garde : sans raisonnement, le modèle 4B répondait de mémoire en prétendant avoir demandé à countries.
151+- **API OpenAI hébergée** : son mode strict exige un objet à la racine — utilisez une struct, comme dans `agent.triage.yaml`.
152+- **Avec des outils** : `structuredOutput cannot be used with tools — this agent declares: …` ; désactivez les outils listés.
153+- Pour voir la requête exacte (le `response_format` envoyé) : `AGENT_TRACE=true ../mm agent.countries.yaml -p "…"`, puis les fichiers de `.mm/traces/`.
154+
155+## Pour aller plus loin
156+
157+- [Obtenir des réponses en JSON](../docs/fr/how-to/get-json-answers.md)
158+- [Référence de `structuredOutput`](../docs/fr/reference/configuration.md#structuredoutput)
159+- [Sortie structurée — explication](../docs/fr/explanation/structured-output.md)
new file mode 100644
@@ -0,0 +1,159 @@
1+# Démo mini-me : réponses en JSON (`structuredOutput`)
2+
3+Ce dossier montre comment faire répondre un agent mini-me **uniquement en JSON**, selon une forme que vous déclarez en types Go dans son fichier YAML, puis comment un autre agent exploite ce JSON. Les modèles sont servis par [llmman](https://github.com/llmmanorg/llmman). English version: [README.en.md](README.en.md).
4+
5+## Ce qu'il y a ici
6+
7+| Fichier | Rôle |
8+|---|---|
9+| `agent.countries.yaml` | **countries** : un agent JSON simple. Une question sur des pays → un tableau de pays (nom, capitale, langues). Racine tableau : `var Countries = []Country{}`. |
10+| `agent.triage.yaml` | **triage** : un deuxième agent JSON. Des rapports de bugs → un objet `{"reports": [...]}` (titre, gravité, composant, résumé, doublon). Racine struct : `var Answer = Triage{}`. |
11+| `agent.coordinator.yaml` | **Bob**, le coordinateur : un agent normal, avec des outils (`ask_agent`, `read_file`, `write_file`), qui confie les questions à countries et triage et travaille à partir de leur JSON. |
12+| `reports.txt` | Quatre rapports de bugs pour l'exemple multi-agents (le 4ᵉ répète le 1ᵉʳ). |
13+
14+Les trois agents utilisent `hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M`.
15+
16+## Prérequis
17+
18+1. llmman qui tourne (`llmman serve`, sur `127.0.0.1:17434` ; depuis un Docker Sandbox, mm se replie tout seul sur `host.docker.internal:17434`).
19+2. Le modèle, téléchargé à l'avance (sinon la première question le télécharge et attend) :
20+
21+ ```bash
22+ llmman pull hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
23+ ```
24+
25+3. `mm` compilé à la racine du dépôt (`go build -o mm .`). **Lancez-le depuis ce dossier** : les chemins de `mmConfigs` sont relatifs à `agent.coordinator.yaml`, et `reports.txt` est lu dans le dossier courant.
26+
27+ ```bash
28+ cd demo-structured-output
29+ ```
30+
31+4. `jq`, pour lire le JSON (facultatif).
32+
33+Si `jq` répond `parse error: Invalid numeric literal at line 1, column 8`, c'est que `mm` ne vous a pas donné de JSON : relancez sans `| jq` pour lire le message. Le cas le plus courant : la commande est lancée hors de ce dossier, et `agent.countries.yaml` est introuvable (`[config error: open agent.countries.yaml: no such file or directory ]`). Depuis la version qui ajoute `structuredOutput`, ces erreurs vont sur stderr et `jq` ne reçoit plus rien.
34+
35+## Comment se déclare la forme de la réponse
36+
37+Dans `agent.countries.yaml` :
38+
39+```yaml
40+structuredOutput: |
41+ // A language spoken in the country.
42+ type Language struct {
43+ Name string `json:"name"` // the language's name, in English
44+ Official bool `json:"official"` // true when it has an official status at the national level
45+ }
46+
47+ type Country struct {
48+ Name string `json:"name"` // the country's common name, in English
49+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
50+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
51+ }
52+
53+ // One entry per country named in the question, in the order asked.
54+ var Countries = []Country{}
55+```
56+
57+- Chaque `type` est une définition, l'unique `var` nomme la **racine** de la réponse.
58+- La clé d'un champ est son tag `json` (son nom Go s'il n'en a pas).
59+- Un pointeur (`*string`) ou `omitempty` rend la valeur facultative : elle peut valoir `null`.
60+- **Les commentaires `//` sont les consignes que lit le modèle** : mm les ajoute à son prompt système. Écrivez-les comme des instructions, et bornez les listes (`at most 4`) — sinon un petit modèle peut en répéter les éléments jusqu'à `max_tokens`.
61+
62+mm traduit ces déclarations en JSON Schema, le serveur contraint la génération à ce schéma, et la réponse est vérifiée avant d'être rendue.
63+
64+Un agent JSON **n'a aucun outil** (`bashTool`, `editTools`, `planTool` à `false`, pas de skills, pas de `mmConfigs`) : une réponse contrainte ne laisse pas de place à un appel d'outil, et mm refuse de démarrer sinon.
65+
66+## 1. Un agent simple : une question, du JSON
67+
68+```bash
69+mm agent.countries.yaml -p "donne moi les langages parlés de la France, le Canada, le Vietnam et les US" | jq .
70+```
71+
72+Avec `-p`, stdout ne porte **que le JSON** (rien d'autre : ni 🤖, ni statistiques), et le code de sortie vaut 1 si la réponse manque ou est invalide. On peut donc le traiter directement :
73+
74+```bash
75+mm agent.countries.yaml -p "donne moi les langages parlés de la France, le Canada, le Vietnam et les US" \
76+ | jq -c '.[] | {name, capital, langues: [.languages[].name]}'
77+```
78+
79+```
80+{"name":"France","capital":"Paris","langues":["French","Occitan","Breton","Catalan"]}
81+{"name":"Canada","capital":"Ottawa","langues":["English","French","Algonquin","Innu-aimun"]}
82+{"name":"Vietnam","capital":"Hanoi","langues":["Vietnamese","French","Chinese","English"]}
83+{"name":"United States","capital":"Washington, D.C.","langues":["English","Spanish","Chinese","Vietnamese"]}
84+```
85+
86+Les langues secondaires peuvent varier d'une formulation de la question à l'autre ; le nom et la capitale restent stables.
87+
88+Le deuxième agent, sur le fichier de rapports :
89+
90+```bash
91+../mm agent.triage.yaml -p "$(cat reports.txt)" | jq -c '.reports[] | {severity, title, duplicate}'
92+```
93+
94+```
95+{"severity":"high","title":"Export freezes and closes on large projects","duplicate":null}
96+{"severity":"low","title":"Date format incorrect in Settings","duplicate":null}
97+{"severity":"low","title":"Typo on login page","duplicate":null}
98+{"severity":"high","title":"Export crashes on big projects","duplicate":1}
99+```
100+
101+En session interactive (`../mm agent.countries.yaml`, ou `../mm -tui agent.countries.yaml`), chaque réponse est du JSON, affiché comme n'importe quelle réponse ; la bannière indique `tools: none (structured output: JSON answers)`.
102+
103+## 2. Plusieurs agents : Bob exploite le JSON des autres
104+
105+Bob ne peut pas avoir de `structuredOutput` lui-même : il a des outils. Il déclare les deux agents JSON sous `mmConfigs` et les appelle avec `ask_agent` ; leur JSON lui revient comme résultat de l'outil.
106+
107+```yaml
108+mmConfigs:
109+ countries:
110+ path: agent.countries.yaml
111+ description: Facts about countries (capital, languages). Answers in JSON. Name every country in the prompt.
112+ triage:
113+ path: agent.triage.yaml
114+ description: Triages bug reports (title, severity, component, summary, duplicate). Answers in JSON. Put the full text of every report in the prompt.
115+```
116+
117+Lire les rapports, les faire trier, écrire un tableau :
118+
119+```bash
120+mm agent.coordinator.yaml -p "Read reports.txt, have the reports triaged, then write triage.md: a Markdown table (title, severity, component, duplicate of) sorted by severity, high first."
121+```
122+
123+```
124+📄 read_file: reports.txt
125+🗣 ask_agent: triage (llmman/hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M) — Triage these bug reports from r…
126+{"reports":[{"component":"Export","duplicate":null,"severity":"high",…}]}
127+💾 write_file: triage.md (340 bytes)
128+```
129+
130+Une question de faits, que Bob confie à countries :
131+
132+```bash
133+mm agent.coordinator.yaml -p "Which of Belgium and Switzerland has more official languages? Answer in one sentence."
134+```
135+
136+Ou en session, pour voir l'agent travailler : `mm -tui agent.coordinator.yaml`.
137+
138+## 3. Interroger un agent JSON depuis un shell, par la config de Bob
139+
140+`mm --ask` n'imprime que la réponse de l'agent demandé — ici, son JSON :
141+
142+```bash
143+mm agent.coordinator.yaml --ask countries -p "capital and languages of Vietnam" | jq .
144+```
145+
146+## Ce qu'il faut savoir
147+
148+- **llama.cpp (donc llmman) écrit les clés dans l'ordre alphabétique**, quel que soit l'ordre de déclaration : ici `capital` avant `name`. Le modèle remplit un champ à partir de ce qu'il a déjà écrit : rendez chaque commentaire autosuffisant (« the capital CITY (not the country) »).
149+- **Réponse coupée** : `the answer was cut off by max_tokens before its JSON was complete` — bornez les listes dans leurs commentaires ou augmentez `sampling.max_tokens`.
150+- **Raisonnement** : countries et triage l'ont coupé (`chat_template_kwargs.enable_thinking: false`) — une consultation n'en a pas besoin, et c'est plus rapide. Bob le garde : sans raisonnement, le modèle 4B répondait de mémoire en prétendant avoir demandé à countries.
151+- **API OpenAI hébergée** : son mode strict exige un objet à la racine — utilisez une struct, comme dans `agent.triage.yaml`.
152+- **Avec des outils** : `structuredOutput cannot be used with tools — this agent declares: …` ; désactivez les outils listés.
153+- Pour voir la requête exacte (le `response_format` envoyé) : `AGENT_TRACE=true ../mm agent.countries.yaml -p "…"`, puis les fichiers de `.mm/traces/`.
154+
155+## Pour aller plus loin
156+
157+- [Obtenir des réponses en JSON](../docs/fr/how-to/get-json-answers.md)
158+- [Référence de `structuredOutput`](../docs/fr/reference/configuration.md#structuredoutput)
159+- [Sortie structurée — explication](../docs/fr/explanation/structured-output.md)
added demo-structured-output/agent.coordinator.yaml +65 -0
new file mode 100644
@@ -0,0 +1,65 @@
1+# Agent settings — Bob, the coordinator: a free-text agent WITH tools, that
2+# hands the questions needing structured data to two JSON-only agents and
3+# works from their answers (ticket 80).
4+#
5+# mm agent.coordinator.yaml # talk to Bob
6+# mm agent.coordinator.yaml -p "Read reports.txt, have it triaged, and write triage.md"
7+# mm agent.coordinator.yaml --ask countries -p "capital of Vietnam" | jq # skip Bob
8+#
9+# Bob cannot have a structuredOutput himself: he has tools, and an answer
10+# constrained to a schema leaves no room for a tool call. The JSON agents have
11+# no tool at all. That is the split: Bob acts, they answer in a fixed shape.
12+provider: llmman
13+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
14+baseUrl: http://127.0.0.1:17434/v1
15+fallback: http://host.docker.internal:17434/v1
16+
17+# The agents Bob can ask with the `ask_agent` tool. Their answer — the JSON —
18+# comes back to him as the tool's result. Paths are relative to this file.
19+mmConfigs:
20+ countries:
21+ path: agent.countries.yaml
22+ description: Facts about countries (capital, languages). Answers in JSON. Name every country in the prompt.
23+ triage:
24+ path: agent.triage.yaml
25+ description: Triages bug reports (title, severity, component, summary, duplicate). Answers in JSON. Put the full text of every report in the prompt.
26+
27+# Bob reads and writes files (read_file, write_file, edit_file); no shell.
28+bashTool: false
29+editTools: true
30+planTool: false
31+skillsDir: no-skills
32+
33+# Bob's file writes run without asking, so that `-p` runs to the end. Set it
34+# to false to approve each write.
35+yolo: true
36+
37+acp:
38+ name: bob
39+ title: Bob (coordinator)
40+
41+system: |
42+ Your name is Bob. You coordinate two agents that answer in JSON, through
43+ your `ask_agent` tool:
44+ - `countries` for facts about countries;
45+ - `triage` to triage bug reports.
46+ When a question is about one or more countries, call `countries` FIRST,
47+ before you write anything: you do not answer country facts from memory.
48+ When you are given bug reports, call `triage` FIRST.
49+ They do not see this conversation: give them everything in the prompt —
50+ the countries by name, the full text of the reports. Call each agent ONCE
51+ per task, then work from its JSON: never invent a value it did not give.
52+
53+ To read a file, use `read_file`; to create one, `write_file`.
54+ Answer the user in English, briefly, and say which agent you asked.
55+
56+sampling:
57+ temperature: 0.0
58+ parallel_tool_calls: false
59+ # Bob keeps his reasoning (Qwen3.5 thinks by default): without it, the 4B
60+ # model answered country questions from memory and claimed it had asked.
61+ max_tokens: 8192
62+
63+maxTurns: 20
64+watchdogTimeout: 2m
65+firstTokenTimeout: 3m
new file mode 100644
@@ -0,0 +1,65 @@
1+# Agent settings — Bob, the coordinator: a free-text agent WITH tools, that
2+# hands the questions needing structured data to two JSON-only agents and
3+# works from their answers (ticket 80).
4+#
5+# mm agent.coordinator.yaml # talk to Bob
6+# mm agent.coordinator.yaml -p "Read reports.txt, have it triaged, and write triage.md"
7+# mm agent.coordinator.yaml --ask countries -p "capital of Vietnam" | jq # skip Bob
8+#
9+# Bob cannot have a structuredOutput himself: he has tools, and an answer
10+# constrained to a schema leaves no room for a tool call. The JSON agents have
11+# no tool at all. That is the split: Bob acts, they answer in a fixed shape.
12+provider: llmman
13+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
14+baseUrl: http://127.0.0.1:17434/v1
15+fallback: http://host.docker.internal:17434/v1
16+
17+# The agents Bob can ask with the `ask_agent` tool. Their answer — the JSON —
18+# comes back to him as the tool's result. Paths are relative to this file.
19+mmConfigs:
20+ countries:
21+ path: agent.countries.yaml
22+ description: Facts about countries (capital, languages). Answers in JSON. Name every country in the prompt.
23+ triage:
24+ path: agent.triage.yaml
25+ description: Triages bug reports (title, severity, component, summary, duplicate). Answers in JSON. Put the full text of every report in the prompt.
26+
27+# Bob reads and writes files (read_file, write_file, edit_file); no shell.
28+bashTool: false
29+editTools: true
30+planTool: false
31+skillsDir: no-skills
32+
33+# Bob's file writes run without asking, so that `-p` runs to the end. Set it
34+# to false to approve each write.
35+yolo: true
36+
37+acp:
38+ name: bob
39+ title: Bob (coordinator)
40+
41+system: |
42+ Your name is Bob. You coordinate two agents that answer in JSON, through
43+ your `ask_agent` tool:
44+ - `countries` for facts about countries;
45+ - `triage` to triage bug reports.
46+ When a question is about one or more countries, call `countries` FIRST,
47+ before you write anything: you do not answer country facts from memory.
48+ When you are given bug reports, call `triage` FIRST.
49+ They do not see this conversation: give them everything in the prompt —
50+ the countries by name, the full text of the reports. Call each agent ONCE
51+ per task, then work from its JSON: never invent a value it did not give.
52+
53+ To read a file, use `read_file`; to create one, `write_file`.
54+ Answer the user in English, briefly, and say which agent you asked.
55+
56+sampling:
57+ temperature: 0.0
58+ parallel_tool_calls: false
59+ # Bob keeps his reasoning (Qwen3.5 thinks by default): without it, the 4B
60+ # model answered country questions from memory and claimed it had asked.
61+ max_tokens: 8192
62+
63+maxTurns: 20
64+watchdogTimeout: 2m
65+firstTokenTimeout: 3m
added demo-structured-output/agent.countries.yaml +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+# Agent settings — "countries": an agent that answers ONLY in JSON, with the
2+# shape declared below in `structuredOutput` (ticket 80). mini-me on
3+# Qwen3.5-4B, served by llmman (https://github.com/llmmanorg/llmman).
4+#
5+# llmman serve # 127.0.0.1:17434
6+# mm agent.countries.yaml -p "languages spoken in France and Canada" | jq .
7+provider: llmman
8+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
9+baseUrl: http://127.0.0.1:17434/v1
10+# From a Docker sandbox, the daemon runs on the host.
11+fallback: http://host.docker.internal:17434/v1
12+
13+# The shape of EVERY answer, written as Go type declarations:
14+# - every `type` is a definition, the one `var` names the root of the answer;
15+# - a field's key is its `json` tag (its Go name when there is none);
16+# - a `//` comment becomes the field's description — the model reads it;
17+# - a pointer (or `omitempty`) makes a field optional: it may be null.
18+# mm turns it into a JSON Schema; the server constrains the generation to it
19+# (response_format json_schema), and the answer is checked before it is shown.
20+# llama.cpp (so llmman) writes the keys in ALPHABETICAL order whatever the
21+# declaration order: here `capital` comes before `name`. A model fills a field
22+# from what it already wrote, so make every description self-sufficient.
23+structuredOutput: |
24+ // A language spoken in the country.
25+ type Language struct {
26+ Name string `json:"name"` // the language's name, in English
27+ Official bool `json:"official"` // true when it has an official status at the national level
28+ }
29+
30+ type Country struct {
31+ Name string `json:"name"` // the country's common name, in English
32+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
33+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
34+ }
35+
36+ // One entry per country named in the question, in the order asked.
37+ var Countries = []Country{}
38+
39+# An answer constrained to a schema leaves no room for a tool call: every tool
40+# is off — mm refuses to start otherwise. No skills either (the directory does
41+# not exist, so no read_skill tool).
42+bashTool: false
43+editTools: false
44+planTool: false
45+skillsDir: no-skills
46+
47+acp:
48+ name: countries
49+ title: Countries (JSON)
50+
51+system: |
52+ You answer questions about countries. Fill every field from what you know.
53+
54+sampling:
55+ temperature: 0.0
56+ max_tokens: 4096
57+ # Qwen3.5 thinks before it answers by default; a lookup needs no reasoning.
58+ chat_template_kwargs:
59+ enable_thinking: false
60+
61+watchdogTimeout: 30s
62+firstTokenTimeout: 3m
new file mode 100644
@@ -0,0 +1,62 @@
1+# Agent settings — "countries": an agent that answers ONLY in JSON, with the
2+# shape declared below in `structuredOutput` (ticket 80). mini-me on
3+# Qwen3.5-4B, served by llmman (https://github.com/llmmanorg/llmman).
4+#
5+# llmman serve # 127.0.0.1:17434
6+# mm agent.countries.yaml -p "languages spoken in France and Canada" | jq .
7+provider: llmman
8+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
9+baseUrl: http://127.0.0.1:17434/v1
10+# From a Docker sandbox, the daemon runs on the host.
11+fallback: http://host.docker.internal:17434/v1
12+
13+# The shape of EVERY answer, written as Go type declarations:
14+# - every `type` is a definition, the one `var` names the root of the answer;
15+# - a field's key is its `json` tag (its Go name when there is none);
16+# - a `//` comment becomes the field's description — the model reads it;
17+# - a pointer (or `omitempty`) makes a field optional: it may be null.
18+# mm turns it into a JSON Schema; the server constrains the generation to it
19+# (response_format json_schema), and the answer is checked before it is shown.
20+# llama.cpp (so llmman) writes the keys in ALPHABETICAL order whatever the
21+# declaration order: here `capital` comes before `name`. A model fills a field
22+# from what it already wrote, so make every description self-sufficient.
23+structuredOutput: |
24+ // A language spoken in the country.
25+ type Language struct {
26+ Name string `json:"name"` // the language's name, in English
27+ Official bool `json:"official"` // true when it has an official status at the national level
28+ }
29+
30+ type Country struct {
31+ Name string `json:"name"` // the country's common name, in English
32+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
33+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
34+ }
35+
36+ // One entry per country named in the question, in the order asked.
37+ var Countries = []Country{}
38+
39+# An answer constrained to a schema leaves no room for a tool call: every tool
40+# is off — mm refuses to start otherwise. No skills either (the directory does
41+# not exist, so no read_skill tool).
42+bashTool: false
43+editTools: false
44+planTool: false
45+skillsDir: no-skills
46+
47+acp:
48+ name: countries
49+ title: Countries (JSON)
50+
51+system: |
52+ You answer questions about countries. Fill every field from what you know.
53+
54+sampling:
55+ temperature: 0.0
56+ max_tokens: 4096
57+ # Qwen3.5 thinks before it answers by default; a lookup needs no reasoning.
58+ chat_template_kwargs:
59+ enable_thinking: false
60+
61+watchdogTimeout: 30s
62+firstTokenTimeout: 3m
added demo-structured-output/agent.triage.yaml +51 -0
new file mode 100644
@@ -0,0 +1,51 @@
1+# Agent settings — "triage": reads bug reports and answers ONLY in JSON, one
2+# entry per report (ticket 80). Asked by the coordinator (agent.coordinator.yaml)
3+# through ask_agent, or straight from a shell:
4+#
5+# mm agent.triage.yaml -p "$(cat reports.txt)" | jq '.reports[] | select(.severity == "high")'
6+provider: llmman
7+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+
11+# A struct root — the shape OpenAI's hosted API demands (an object at the top),
12+# and the one to choose when the answer should be extensible later.
13+structuredOutput: |
14+ // One bug report, triaged.
15+ type Report struct {
16+ Title string `json:"title"` // a short title, at most 8 words, in English
17+ Severity string `json:"severity"` // exactly one of: "low", "medium", "high" — high = data loss, crash or security
18+ Component string `json:"component"` // the part of the product concerned, in one or two words
19+ Summary string `json:"summary"` // one sentence: what happens and when
20+ Duplicate *int `json:"duplicate"` // the number (1-based) of an EARLIER report this one repeats; null otherwise
21+ }
22+
23+ type Triage struct {
24+ Reports []Report `json:"reports"` // one entry per report given, in the order given
25+ }
26+
27+ var Answer = Triage{}
28+
29+# Every tool off: an answer constrained to a schema leaves no room for a tool call.
30+bashTool: false
31+editTools: false
32+planTool: false
33+skillsDir: no-skills
34+
35+acp:
36+ name: triage
37+ title: Triage (JSON)
38+
39+system: |
40+ You triage bug reports for a software team. You are given one or more
41+ reports, numbered or separated by blank lines. Judge each one only from
42+ its text.
43+
44+sampling:
45+ temperature: 0.0
46+ max_tokens: 4096
47+ chat_template_kwargs:
48+ enable_thinking: false
49+
50+watchdogTimeout: 30s
51+firstTokenTimeout: 3m
new file mode 100644
@@ -0,0 +1,51 @@
1+# Agent settings — "triage": reads bug reports and answers ONLY in JSON, one
2+# entry per report (ticket 80). Asked by the coordinator (agent.coordinator.yaml)
3+# through ask_agent, or straight from a shell:
4+#
5+# mm agent.triage.yaml -p "$(cat reports.txt)" | jq '.reports[] | select(.severity == "high")'
6+provider: llmman
7+model: hf.co/unsloth/Qwen3.5-4B-GGUF:Q4_K_M
8+baseUrl: http://127.0.0.1:17434/v1
9+fallback: http://host.docker.internal:17434/v1
10+
11+# A struct root — the shape OpenAI's hosted API demands (an object at the top),
12+# and the one to choose when the answer should be extensible later.
13+structuredOutput: |
14+ // One bug report, triaged.
15+ type Report struct {
16+ Title string `json:"title"` // a short title, at most 8 words, in English
17+ Severity string `json:"severity"` // exactly one of: "low", "medium", "high" — high = data loss, crash or security
18+ Component string `json:"component"` // the part of the product concerned, in one or two words
19+ Summary string `json:"summary"` // one sentence: what happens and when
20+ Duplicate *int `json:"duplicate"` // the number (1-based) of an EARLIER report this one repeats; null otherwise
21+ }
22+
23+ type Triage struct {
24+ Reports []Report `json:"reports"` // one entry per report given, in the order given
25+ }
26+
27+ var Answer = Triage{}
28+
29+# Every tool off: an answer constrained to a schema leaves no room for a tool call.
30+bashTool: false
31+editTools: false
32+planTool: false
33+skillsDir: no-skills
34+
35+acp:
36+ name: triage
37+ title: Triage (JSON)
38+
39+system: |
40+ You triage bug reports for a software team. You are given one or more
41+ reports, numbered or separated by blank lines. Judge each one only from
42+ its text.
43+
44+sampling:
45+ temperature: 0.0
46+ max_tokens: 4096
47+ chat_template_kwargs:
48+ enable_thinking: false
49+
50+watchdogTimeout: 30s
51+firstTokenTimeout: 3m
added demo-structured-output/reports.txt +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+1. When I click "Export" on a project with more than 500 tasks, the app freezes and then closes. I lost my unsaved changes.
2+2. The "Settings" page shows the date as 10/08/2026 even though my language is French.
3+3. Typo on the login page: "Pasword" instead of "Password".
4+4. Exporting a big project crashes the application, same as someone reported before I think.
new file mode 100644
@@ -0,0 +1,4 @@
1+1. When I click "Export" on a project with more than 500 tasks, the app freezes and then closes. I lost my unsaved changes.
2+2. The "Settings" page shows the date as 10/08/2026 even though my language is French.
3+3. Typo on the login page: "Pasword" instead of "Password".
4+4. Exporting a big project crashes the application, same as someone reported before I think.
added demo-structured-output/triage.md +8 -0
new file mode 100644
@@ -0,0 +1,8 @@
1+# Bug Report Triage
2+
3+| Title | Severity | Component | Duplicate of |
4+|-------|----------|-----------|--------------|
5+| Export freeze and crash with large projects | high | Export | - |
6+| Export crash on large projects | high | Export | 1 |
7+| Date format incorrect in Settings | low | Settings | - |
8+| Typo on login page | low | Login | - |
new file mode 100644
@@ -0,0 +1,8 @@
1+# Bug Report Triage
2+
3+| Title | Severity | Component | Duplicate of |
4+|-------|----------|-----------|--------------|
5+| Export freeze and crash with large projects | high | Export | - |
6+| Export crash on large projects | high | Export | 1 |
7+| Date format incorrect in Settings | low | Settings | - |
8+| Typo on login page | low | Login | - |
modified docs/diagrams/packages.drawio +3 -3
@@ -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="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell>
2- <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry"/></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry"/></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2+ <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell></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="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell>
2- <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell>
3- <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry"/></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry"/></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-10-06T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1060"><root><mxCell id="0" /><mxCell id="1" parent="0" /><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp, fetch_content&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell>
2+ <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell>
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="&lt;b&gt;internal/webmd&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="&lt;b&gt;golang.org/x/net/html (+ x/text)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;HTML5 parser, charset lookup&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="&lt;b&gt;internal/ask&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;/ask another mm agent: child mm -acp, ACP client side&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="&lt;b&gt;internal/consent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;yolo switch, the four answers, per-session memory, the line mode prompt hook&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="&lt;b&gt;internal/mcp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;MCP clients (stdio, HTTP), their tools wrapped as mm tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="&lt;b&gt;internal/structured&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;structuredOutput: Go declarations → JSON Schema (go/parser)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +2 -0
@@ -15,6 +15,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
1515 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/`
1616 - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`
1717 - [See the JSON sent to the model and its answer](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, reading the files with jq
18+- [Get answers as JSON](how-to/get-json-answers.md) — `structuredOutput`: Go declarations, `-p … | jq`, a JSON agent behind a coordinator
1819 - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo`
1920 - [Ask another agent from a skill or a script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context`
2021 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
@@ -63,6 +64,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
6364 - [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
6465 - [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
6566 - [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`)
67+- [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer
6668 - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit
6769 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
6870
@@ -15,6 +15,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
15 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/`15 - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/`
16 - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`16 - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>`
17 - [See the JSON sent to the model and its answer](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, reading the files with jq17 - [See the JSON sent to the model and its answer](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, reading the files with jq
18+- [Get answers as JSON](how-to/get-json-answers.md) — `structuredOutput`: Go declarations, `-p … | jq`, a JSON agent behind a coordinator
18 - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo`19 - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo`
19 - [Ask another agent from a skill or a script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context`20 - [Ask another agent from a skill or a script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context`
20 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)21 - [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md)
@@ -63,6 +64,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
63 - [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 limits64 - [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
64 - [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 alive65 - [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
65 - [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`)66 - [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`)
67+- [Structured output](explanation/structured-output.md) — why Go declarations, why they also go in the system prompt, why no tools, what guarantees the answer
66 - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit68 - [Asking another agent](explanation/asking-another-agent.md) — why another mm agent is a child process spoken to over ACP, the history pair, fresh sessions, `ask_agent` and per-agent `yolo`, the depth limit
67 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)69 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
68 70
modified docs/en/explanation/architecture.md +1 -0
@@ -9,6 +9,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
99 | Package | Responsibility |
1010 |---------|----------------|
1111 | `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. |
12+| `structured` | Translates the Go declarations of `structuredOutput` into the JSON Schema the answer must follow, with `go/parser`; used by `config` when the file loads. See [Structured output](structured-output.md). |
1213 | `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, the provider registry, the reasoning a thinking model streams beside its answer (see [Thinking models](thinking-models.md)), and the optional trace of every request and response (`trace.go`). |
1314 | `tools` | The built-in tools the model can call, `ask_agent` included (it asks an `mmConfigs` agent through `ask`). |
1415 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
@@ -9,6 +9,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
9 | Package | Responsibility |9 | Package | Responsibility |
10 |---------|----------------|10 |---------|----------------|
11 | `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. |11 | `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. |
12+| `structured` | Translates the Go declarations of `structuredOutput` into the JSON Schema the answer must follow, with `go/parser`; used by `config` when the file loads. See [Structured output](structured-output.md). |
12 | `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, the provider registry, the reasoning a thinking model streams beside its answer (see [Thinking models](thinking-models.md)), and the optional trace of every request and response (`trace.go`). |13 | `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, the provider registry, the reasoning a thinking model streams beside its answer (see [Thinking models](thinking-models.md)), and the optional trace of every request and response (`trace.go`). |
13 | `tools` | The built-in tools the model can call, `ask_agent` included (it asks an `mmConfigs` agent through `ask`). |14 | `tools` | The built-in tools the model can call, `ask_agent` included (it asks an `mmConfigs` agent through `ask`). |
14 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |15 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
added docs/en/explanation/structured-output.md +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+# Structured output — explanation
2+
3+## What is this about?
4+
5+Some answers are meant for a program rather than a person: a list of countries for a script, a bug triage for a coordinator agent. Free text is a poor carrier for that — the program has to guess where the data starts, and a model drifts from the format it was asked for. Servers that speak the OpenAI API can instead **constrain** the generation: given a JSON Schema in `response_format`, llama.cpp compiles it into a grammar, and the model cannot produce a token outside it. `structuredOutput` (ticket 80) is how an mm agent opts into that.
6+
7+## Why Go declarations
8+
9+A JSON Schema is the wire format, but it is verbose and easy to get subtly wrong by hand. The section is written as Go type declarations instead: compact, readable, familiar to the people who write mm's configurations — and to models trained on code.
10+
11+No Go type is created at runtime: Go cannot compile code while it runs. mm reads the text as **source**. `go/parser`, from the standard library, builds its syntax tree; `internal/structured` walks it and translates each declaration — a struct into a closed object, a slice into an array, a declared type into a `$ref` — then hands the schema to Genkit with `ai.WithOutputSchema`. Unsupported constructs are refused when the configuration loads, with their line in the YAML block, rather than as a failed answer later.
12+
13+Rejected alternatives:
14+
15+- **`reflect.StructOf`** builds struct types at runtime, but cannot express named or recursive types, ignores comments, and would only re-check what the schema already says.
16+- **An embedded Go interpreter** (yaegi): a large dependency to run code of which only the declarations are ever used.
17+- **A YAML shape notation** (`countries: [{name: string}]`): shorter, but a mini-language to invent and document, with no place for descriptions.
18+- **A raw JSON Schema** in the config: possible, but it is what the Go form exists to spare people from writing.
19+
20+## Why the declarations are also in the system prompt
21+
22+The schema constrains the **syntax**, not the meaning. When the constraint is native, Genkit puts nothing about the schema in the prompt, and llama.cpp keeps only the grammar: the model sees key names, never the descriptions. Measured on Qwen3.5-4B: asked for capitals, it wrote the country's name in `capital` every time. mm therefore appends the declarations themselves, comments included, to the system prompt. That is why the comments matter: they are the instructions the model reads.
23+
24+## Why keys keep their declaration order — and why it may not help
25+
26+`properties` are sent in declaration order, because a model fills a field from what it has already written: declaring `Name` first is a way to say "decide this first". A plain Go map would have sorted them. But llama.cpp (build b11429, measured through llmman) reorders the keys alphabetically when it builds its grammar, so on that server `capital` is still written before `name`. Servers that respect the order benefit; on llama.cpp, the remedy is a self-sufficient comment on every field.
27+
28+## Why an agent with tools cannot have one
29+
30+A server that constrains every answer to the schema leaves no room for a tool call, and llama.cpp refuses grammar constraints together with tools. Genkit's own rule says the same (`ConstrainedSupportNoTools`). Rather than a hidden two-phase turn (work with tools, then reformat), mm keeps the roles apart: an agent either acts, or answers in a fixed shape. The two meet through `ask_agent` — a coordinator with tools asks a JSON agent and receives the JSON as the tool's result. The configuration error lists the tools to turn off.
31+
32+## What guarantees the answer
33+
34+Three layers: the server's grammar (when the server supports it), Genkit's validation of the answer against the schema (a non-conforming answer fails the turn), and — in `-p` — an exit status a script can test, with nothing but the JSON on stdout. The usual failure is not bad syntax but a cut answer: a small model repeating list items until `max_tokens`. mm names that case explicitly, because the fix is a setting or a bounded list, not the model's syntax.
35+
36+## How it relates to the rest
37+
38+- [Asking another agent](asking-another-agent.md) — how the JSON reaches a coordinator.
39+- [Thinking models](thinking-models.md) — a lookup agent rarely needs reasoning; a coordinator often does (on Qwen3.5-4B, without it, the coordinator answered from memory and claimed it had asked).
40+- [How to get answers as JSON](../how-to/get-json-answers.md) and the [`structuredOutput` reference](../reference/configuration.md#structuredoutput).
new file mode 100644
@@ -0,0 +1,40 @@
1+# Structured output — explanation
2+
3+## What is this about?
4+
5+Some answers are meant for a program rather than a person: a list of countries for a script, a bug triage for a coordinator agent. Free text is a poor carrier for that — the program has to guess where the data starts, and a model drifts from the format it was asked for. Servers that speak the OpenAI API can instead **constrain** the generation: given a JSON Schema in `response_format`, llama.cpp compiles it into a grammar, and the model cannot produce a token outside it. `structuredOutput` (ticket 80) is how an mm agent opts into that.
6+
7+## Why Go declarations
8+
9+A JSON Schema is the wire format, but it is verbose and easy to get subtly wrong by hand. The section is written as Go type declarations instead: compact, readable, familiar to the people who write mm's configurations — and to models trained on code.
10+
11+No Go type is created at runtime: Go cannot compile code while it runs. mm reads the text as **source**. `go/parser`, from the standard library, builds its syntax tree; `internal/structured` walks it and translates each declaration — a struct into a closed object, a slice into an array, a declared type into a `$ref` — then hands the schema to Genkit with `ai.WithOutputSchema`. Unsupported constructs are refused when the configuration loads, with their line in the YAML block, rather than as a failed answer later.
12+
13+Rejected alternatives:
14+
15+- **`reflect.StructOf`** builds struct types at runtime, but cannot express named or recursive types, ignores comments, and would only re-check what the schema already says.
16+- **An embedded Go interpreter** (yaegi): a large dependency to run code of which only the declarations are ever used.
17+- **A YAML shape notation** (`countries: [{name: string}]`): shorter, but a mini-language to invent and document, with no place for descriptions.
18+- **A raw JSON Schema** in the config: possible, but it is what the Go form exists to spare people from writing.
19+
20+## Why the declarations are also in the system prompt
21+
22+The schema constrains the **syntax**, not the meaning. When the constraint is native, Genkit puts nothing about the schema in the prompt, and llama.cpp keeps only the grammar: the model sees key names, never the descriptions. Measured on Qwen3.5-4B: asked for capitals, it wrote the country's name in `capital` every time. mm therefore appends the declarations themselves, comments included, to the system prompt. That is why the comments matter: they are the instructions the model reads.
23+
24+## Why keys keep their declaration order — and why it may not help
25+
26+`properties` are sent in declaration order, because a model fills a field from what it has already written: declaring `Name` first is a way to say "decide this first". A plain Go map would have sorted them. But llama.cpp (build b11429, measured through llmman) reorders the keys alphabetically when it builds its grammar, so on that server `capital` is still written before `name`. Servers that respect the order benefit; on llama.cpp, the remedy is a self-sufficient comment on every field.
27+
28+## Why an agent with tools cannot have one
29+
30+A server that constrains every answer to the schema leaves no room for a tool call, and llama.cpp refuses grammar constraints together with tools. Genkit's own rule says the same (`ConstrainedSupportNoTools`). Rather than a hidden two-phase turn (work with tools, then reformat), mm keeps the roles apart: an agent either acts, or answers in a fixed shape. The two meet through `ask_agent` — a coordinator with tools asks a JSON agent and receives the JSON as the tool's result. The configuration error lists the tools to turn off.
31+
32+## What guarantees the answer
33+
34+Three layers: the server's grammar (when the server supports it), Genkit's validation of the answer against the schema (a non-conforming answer fails the turn), and — in `-p` — an exit status a script can test, with nothing but the JSON on stdout. The usual failure is not bad syntax but a cut answer: a small model repeating list items until `max_tokens`. mm names that case explicitly, because the fix is a setting or a bounded list, not the model's syntax.
35+
36+## How it relates to the rest
37+
38+- [Asking another agent](asking-another-agent.md) — how the JSON reaches a coordinator.
39+- [Thinking models](thinking-models.md) — a lookup agent rarely needs reasoning; a coordinator often does (on Qwen3.5-4B, without it, the coordinator answered from memory and claimed it had asked).
40+- [How to get answers as JSON](../how-to/get-json-answers.md) and the [`structuredOutput` reference](../reference/configuration.md#structuredoutput).
added docs/en/how-to/get-json-answers.md +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+# How to get answers as JSON (`structuredOutput`)
2+
3+This guide shows how to make an agent answer only with JSON of a shape you declare, so that a script, `jq` or another agent can use the answer. It assumes you already run `mm` with a configuration file, on a server that supports `response_format: json_schema` (llama.cpp, so `dmr`, `llamacpp`, `llmman`; Ollama; the OpenAI API). The `demo-structured-output/` directory holds the files used below.
4+
5+## Steps
6+
7+1. Write a configuration file for a JSON-only agent, with every tool off — an answer constrained to a schema leaves no room for a tool call, and mm refuses to start otherwise:
8+
9+ ```yaml
10+ bashTool: false
11+ editTools: false
12+ planTool: false
13+ skillsDir: no-skills # a directory that does not exist: no read_skill tool
14+ ```
15+
16+ Leave out `mmConfigs`, `lsp` and `mcp`.
17+
18+2. Declare the shape of the answer under `structuredOutput`, as Go types and one `var` naming the root:
19+
20+ ```yaml
21+ structuredOutput: |
22+ type Language struct {
23+ Name string `json:"name"` // the language's name, in English
24+ Official bool `json:"official"` // true when it has an official status at the national level
25+ }
26+
27+ type Country struct {
28+ Name string `json:"name"` // the country's common name, in English
29+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
30+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
31+ }
32+
33+ // One entry per country named in the question, in the order asked.
34+ var Countries = []Country{}
35+ ```
36+
37+ The `json` tags give the keys. The comments are what the model reads to fill each field: write them as instructions.
38+
39+3. Ask a question with `-p` and pipe the answer:
40+
41+ ```bash
42+ mm agent.countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" | jq -c '.[] | {name, capital}'
43+ ```
44+
45+ ```
46+ {"name":"France","capital":"Paris"}
47+ {"name":"Canada","capital":"Ottawa"}
48+ {"name":"Vietnam","capital":"Hanoi"}
49+ {"name":"United States","capital":"Washington, D.C."}
50+ ```
51+
52+ stdout carries the JSON alone; the exit status is 1 when the answer is missing or invalid.
53+
54+## Variants
55+
56+- **Let another agent use the JSON.** Give a coordinator — an agent with tools — the JSON agent in its `mmConfigs`; it calls it with `ask_agent` and gets the JSON as the tool's result. See [How to let your agent call other agents](let-the-agent-call-other-agents.md) and `demo-structured-output/agent.coordinator.yaml`. The coordinator itself cannot have a `structuredOutput`: it has tools.
57+- **Ask the JSON agent through the coordinator's config**, from a shell: `mm agent.coordinator.yaml --ask countries -p "capital of Vietnam" | jq .` (see [How to ask another agent from a skill or a script](ask-another-agent-from-a-skill.md)).
58+- **In the terminal or the window**, the agent answers each question with JSON, shown like any answer.
59+- **For the OpenAI hosted API**, make the root a struct (`var Answer = Triage{}`): its strict mode demands an object at the top. llama.cpp and Ollama accept an array.
60+- **Optional fields**: a pointer (`*string`) or `json:",omitempty"` lets the value be `null`; the key is always present.
61+
62+## If it goes wrong
63+
64+- `the answer was cut off by max_tokens before its JSON was complete` — a small model can repeat list items until the limit. Bound the lists in their comments (`at most 4`, `each once`), or raise `sampling.max_tokens`.
65+- A field holds the wrong thing (a country's name in `capital`) — sharpen its comment. With llama.cpp the model writes the keys in alphabetical order whatever the declaration order, so a field may be filled before the one it depends on: make each comment self-sufficient.
66+- `structuredOutput cannot be used with tools — this agent declares: …` — turn off the tools listed.
67+- `structuredOutput:<line>:<col>: …` — the declaration at that line of the block is not supported; see the [`structuredOutput` reference](../reference/configuration.md#structuredoutput).
68+- A thinking model is slow to answer: turn its reasoning off for a lookup agent ([How to turn a model's reasoning off](turn-reasoning-off.md)).
69+
70+## See also
71+
72+- [`structuredOutput` reference](../reference/configuration.md#structuredoutput) — the type mapping, the errors
73+- [Prompt mode specifics](../reference/cli.md#prompt-mode-specifics) — what `-p` prints
74+- [Structured output — explanation](../explanation/structured-output.md) — why Go declarations, why no tools
new file mode 100644
@@ -0,0 +1,74 @@
1+# How to get answers as JSON (`structuredOutput`)
2+
3+This guide shows how to make an agent answer only with JSON of a shape you declare, so that a script, `jq` or another agent can use the answer. It assumes you already run `mm` with a configuration file, on a server that supports `response_format: json_schema` (llama.cpp, so `dmr`, `llamacpp`, `llmman`; Ollama; the OpenAI API). The `demo-structured-output/` directory holds the files used below.
4+
5+## Steps
6+
7+1. Write a configuration file for a JSON-only agent, with every tool off — an answer constrained to a schema leaves no room for a tool call, and mm refuses to start otherwise:
8+
9+ ```yaml
10+ bashTool: false
11+ editTools: false
12+ planTool: false
13+ skillsDir: no-skills # a directory that does not exist: no read_skill tool
14+ ```
15+
16+ Leave out `mmConfigs`, `lsp` and `mcp`.
17+
18+2. Declare the shape of the answer under `structuredOutput`, as Go types and one `var` naming the root:
19+
20+ ```yaml
21+ structuredOutput: |
22+ type Language struct {
23+ Name string `json:"name"` // the language's name, in English
24+ Official bool `json:"official"` // true when it has an official status at the national level
25+ }
26+
27+ type Country struct {
28+ Name string `json:"name"` // the country's common name, in English
29+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
30+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
31+ }
32+
33+ // One entry per country named in the question, in the order asked.
34+ var Countries = []Country{}
35+ ```
36+
37+ The `json` tags give the keys. The comments are what the model reads to fill each field: write them as instructions.
38+
39+3. Ask a question with `-p` and pipe the answer:
40+
41+ ```bash
42+ mm agent.countries.yaml -p "languages spoken in France, Canada, Vietnam and the US" | jq -c '.[] | {name, capital}'
43+ ```
44+
45+ ```
46+ {"name":"France","capital":"Paris"}
47+ {"name":"Canada","capital":"Ottawa"}
48+ {"name":"Vietnam","capital":"Hanoi"}
49+ {"name":"United States","capital":"Washington, D.C."}
50+ ```
51+
52+ stdout carries the JSON alone; the exit status is 1 when the answer is missing or invalid.
53+
54+## Variants
55+
56+- **Let another agent use the JSON.** Give a coordinator — an agent with tools — the JSON agent in its `mmConfigs`; it calls it with `ask_agent` and gets the JSON as the tool's result. See [How to let your agent call other agents](let-the-agent-call-other-agents.md) and `demo-structured-output/agent.coordinator.yaml`. The coordinator itself cannot have a `structuredOutput`: it has tools.
57+- **Ask the JSON agent through the coordinator's config**, from a shell: `mm agent.coordinator.yaml --ask countries -p "capital of Vietnam" | jq .` (see [How to ask another agent from a skill or a script](ask-another-agent-from-a-skill.md)).
58+- **In the terminal or the window**, the agent answers each question with JSON, shown like any answer.
59+- **For the OpenAI hosted API**, make the root a struct (`var Answer = Triage{}`): its strict mode demands an object at the top. llama.cpp and Ollama accept an array.
60+- **Optional fields**: a pointer (`*string`) or `json:",omitempty"` lets the value be `null`; the key is always present.
61+
62+## If it goes wrong
63+
64+- `the answer was cut off by max_tokens before its JSON was complete` — a small model can repeat list items until the limit. Bound the lists in their comments (`at most 4`, `each once`), or raise `sampling.max_tokens`.
65+- A field holds the wrong thing (a country's name in `capital`) — sharpen its comment. With llama.cpp the model writes the keys in alphabetical order whatever the declaration order, so a field may be filled before the one it depends on: make each comment self-sufficient.
66+- `structuredOutput cannot be used with tools — this agent declares: …` — turn off the tools listed.
67+- `structuredOutput:<line>:<col>: …` — the declaration at that line of the block is not supported; see the [`structuredOutput` reference](../reference/configuration.md#structuredoutput).
68+- A thinking model is slow to answer: turn its reasoning off for a lookup agent ([How to turn a model's reasoning off](turn-reasoning-off.md)).
69+
70+## See also
71+
72+- [`structuredOutput` reference](../reference/configuration.md#structuredoutput) — the type mapping, the errors
73+- [Prompt mode specifics](../reference/cli.md#prompt-mode-specifics) — what `-p` prints
74+- [Structured output — explanation](../explanation/structured-output.md) — why Go declarations, why no tools
modified docs/en/reference/cli.md +1 -0
@@ -132,6 +132,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
132132 | Config file | Can appear before or after the `-prompt`/`-p` flag: `mm -p "prompt" config.yaml` and `mm config.yaml -p "prompt"` both work. |
133133 | Compatibility | Incompatible with `-acp` and `-tui`; prompt mode takes precedence and runs regardless. |
134134 | Permissions | Nobody can answer. With `yolo: false`, every call that would ask is rejected (`[command rejected by the user]` for the model) and stderr says once: `[yolo is off and this mode cannot ask: tool calls that need a confirmation are rejected — set yolo: true to let them run]`. MCP tools marked `ask` are rejected the same way, whatever `yolo` says. |
135+| With `structuredOutput` | stdout carries the JSON answer and a newline, nothing else: no streamed text, no 🤖, no statistics line. Messages go to stderr. A failed or invalid answer prints `[structured output: <error>]` on stderr, nothing on stdout, and exits with status 1; a valid answer exits with status 0. See [`structuredOutput`](configuration.md#structuredoutput). |
135136
136137 ## Ask mode specifics
137138
@@ -132,6 +132,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
132 | Config file | Can appear before or after the `-prompt`/`-p` flag: `mm -p "prompt" config.yaml` and `mm config.yaml -p "prompt"` both work. |132 | Config file | Can appear before or after the `-prompt`/`-p` flag: `mm -p "prompt" config.yaml` and `mm config.yaml -p "prompt"` both work. |
133 | Compatibility | Incompatible with `-acp` and `-tui`; prompt mode takes precedence and runs regardless. |133 | Compatibility | Incompatible with `-acp` and `-tui`; prompt mode takes precedence and runs regardless. |
134 | Permissions | Nobody can answer. With `yolo: false`, every call that would ask is rejected (`[command rejected by the user]` for the model) and stderr says once: `[yolo is off and this mode cannot ask: tool calls that need a confirmation are rejected — set yolo: true to let them run]`. MCP tools marked `ask` are rejected the same way, whatever `yolo` says. |134 | Permissions | Nobody can answer. With `yolo: false`, every call that would ask is rejected (`[command rejected by the user]` for the model) and stderr says once: `[yolo is off and this mode cannot ask: tool calls that need a confirmation are rejected — set yolo: true to let them run]`. MCP tools marked `ask` are rejected the same way, whatever `yolo` says. |
135+| With `structuredOutput` | stdout carries the JSON answer and a newline, nothing else: no streamed text, no 🤖, no statistics line. Messages go to stderr. A failed or invalid answer prints `[structured output: <error>]` on stderr, nothing on stdout, and exits with status 1; a valid answer exits with status 0. See [`structuredOutput`](configuration.md#structuredoutput). |
135 136
136 ## Ask mode specifics137 ## Ask mode specifics
137 138
modified docs/en/reference/configuration.md +53 -1
@@ -18,6 +18,7 @@
1818 | `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. |
1919 | `showThinking` | bool | `true` | Show what a thinking model reasons about before it answers. Only the display is optional: the reasoning is always read off the wire, because it is what keeps the watchdog alive while the model thinks. See [Thinking models](../explanation/thinking-models.md). |
2020 | `system` | string | built-in prompt | The system prompt, first message of every history. |
21+| `structuredOutput` | string (Go declarations) | `""` | The shape every answer must follow, as JSON: Go `type` declarations and one `var` naming the root of the answer. See [`structuredOutput`](#structuredoutput) below. Empty = free-text answers. Incompatible with any declared tool. |
2122 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
2223 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |
2324 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
@@ -106,9 +107,57 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ
106107 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no |
107108 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | no |
108109
110+## `structuredOutput`
111+
112+A YAML block string holding Go declarations. Each `type` is a definition; the one `var` (`var X T` or `var X = T{}`) names the root of the answer. Only `type` and `var` declarations are allowed.
113+
114+| Go | JSON Schema |
115+|----|-------------|
116+| `string` | `{"type": "string"}` |
117+| `bool` | `{"type": "boolean"}` |
118+| `int`, `int8` … `int64`, `uint` … `uint64` | `{"type": "integer"}` |
119+| `float32`, `float64` | `{"type": "number"}` |
120+| `[]T` | `{"type": "array", "items": T}` |
121+| `map[string]T` | `{"type": "object", "additionalProperties": T}` |
122+| `struct { … }` | `{"type": "object", "properties": …, "required": [every key], "additionalProperties": false}`, keys in declaration order |
123+| a declared type `Name` | `{"$ref": "#/$defs/Name"}`; a struct root is inlined, `$defs` holds the types the root reaches |
124+| `*T` field, or `json:",omitempty"` | the key stays required, its value is `anyOf: [T, null]` |
125+
126+| Field element | Effect |
127+|---------------|--------|
128+| `json:"name"` tag | the key; without a tag, the Go field name as written |
129+| `json:"-"` tag | field skipped |
130+| `//` comment above or beside a declaration or field | its `description` |
131+
132+Refused, with `structuredOutput:<line>:<col>: …` counted from the first line of the block: `func`, `import`, `const`; generic types; aliases (`type A = B`); embedded fields; fixed-size arrays; map keys other than `string`; `interface`, `chan`, `func`, qualified (`time.Time`) and undefined types; a pointer outside a struct field; a type declared twice; a key used twice; zero or more than one `var`; a `var` without a type.
133+
134+When the block is not empty:
135+
136+- the declarations are appended to `system`, under an `OUTPUT FORMAT` paragraph;
137+- every model call carries `response_format: {"type": "json_schema", "json_schema": {"name": "output", "strict": true, "schema": …}}`;
138+- the answer is validated against the schema; one that does not match fails the turn with `model failed to generate output matching expected schema: …`, prefixed with `the answer was cut off by max_tokens before its JSON was complete …` when the server stopped on `max_tokens`;
139+- `-p` prints the JSON answer alone on stdout (see [Prompt mode specifics](cli.md#prompt-mode-specifics));
140+- the banner shows `tools: none (structured output: JSON answers)` and no "no skills" / "no tool" warning.
141+
142+Example:
143+
144+```yaml
145+structuredOutput: |
146+ type Language struct {
147+ Name string `json:"name"` // the language's name, in English
148+ Official bool `json:"official"`
149+ }
150+ type Country struct {
151+ Name string `json:"name"`
152+ Capital *string `json:"capital"` // null when unknown
153+ Languages []Language `json:"languages"` // at most 4
154+ }
155+ var Countries = []Country{}
156+```
157+
109158 ## Validation errors
110159
111-Reported as `[config error: <path>: <message>]`, exit code 1.
160+Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in every mode, so that `-p … | jq` never reads an error as the answer. `[engine error: …]` and `[usage: …]` too.
112161
113162 | Message | Cause |
114163 |---------|-------|
@@ -133,6 +182,9 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
133182 | `permissions must be "auto" or "ask"` | any other value of the deprecated key |
134183 | `AGENT_YOLO="maybe": must be true or false` | an `AGENT_YOLO` value `strconv.ParseBool` refuses (accepted: `1`, `t`, `true`, `0`, `f`, `false`, any case) |
135184 | `bashTimeout must be > 0` | zero or negative |
185+| `structuredOutput:<line>:<col>: …` | a declaration the [`structuredOutput`](#structuredoutput) section cannot translate |
186+| `structuredOutput: no var declaration: …` | no `var` names the root |
187+| `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. |
136188 | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |
137189 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |
138190 | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` |
@@ -18,6 +18,7 @@
18 | `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. |18 | `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. |
19 | `showThinking` | bool | `true` | Show what a thinking model reasons about before it answers. Only the display is optional: the reasoning is always read off the wire, because it is what keeps the watchdog alive while the model thinks. See [Thinking models](../explanation/thinking-models.md). |19 | `showThinking` | bool | `true` | Show what a thinking model reasons about before it answers. Only the display is optional: the reasoning is always read off the wire, because it is what keeps the watchdog alive while the model thinks. See [Thinking models](../explanation/thinking-models.md). |
20 | `system` | string | built-in prompt | The system prompt, first message of every history. |20 | `system` | string | built-in prompt | The system prompt, first message of every history. |
21+| `structuredOutput` | string (Go declarations) | `""` | The shape every answer must follow, as JSON: Go `type` declarations and one `var` naming the root of the answer. See [`structuredOutput`](#structuredoutput) below. Empty = free-text answers. Incompatible with any declared tool. |
21 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |22 | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. |
22 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |23 | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. |
23 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |24 | `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. |
@@ -106,9 +107,57 @@ The shipped `agent.lsp.yaml` is `agent.dmr.yaml` with this block on (gopls activ
106 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no |107 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no |
107 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | no |108 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | no |
108 109
110+## `structuredOutput`
111+
112+A YAML block string holding Go declarations. Each `type` is a definition; the one `var` (`var X T` or `var X = T{}`) names the root of the answer. Only `type` and `var` declarations are allowed.
113+
114+| Go | JSON Schema |
115+|----|-------------|
116+| `string` | `{"type": "string"}` |
117+| `bool` | `{"type": "boolean"}` |
118+| `int`, `int8` … `int64`, `uint` … `uint64` | `{"type": "integer"}` |
119+| `float32`, `float64` | `{"type": "number"}` |
120+| `[]T` | `{"type": "array", "items": T}` |
121+| `map[string]T` | `{"type": "object", "additionalProperties": T}` |
122+| `struct { … }` | `{"type": "object", "properties": …, "required": [every key], "additionalProperties": false}`, keys in declaration order |
123+| a declared type `Name` | `{"$ref": "#/$defs/Name"}`; a struct root is inlined, `$defs` holds the types the root reaches |
124+| `*T` field, or `json:",omitempty"` | the key stays required, its value is `anyOf: [T, null]` |
125+
126+| Field element | Effect |
127+|---------------|--------|
128+| `json:"name"` tag | the key; without a tag, the Go field name as written |
129+| `json:"-"` tag | field skipped |
130+| `//` comment above or beside a declaration or field | its `description` |
131+
132+Refused, with `structuredOutput:<line>:<col>: …` counted from the first line of the block: `func`, `import`, `const`; generic types; aliases (`type A = B`); embedded fields; fixed-size arrays; map keys other than `string`; `interface`, `chan`, `func`, qualified (`time.Time`) and undefined types; a pointer outside a struct field; a type declared twice; a key used twice; zero or more than one `var`; a `var` without a type.
133+
134+When the block is not empty:
135+
136+- the declarations are appended to `system`, under an `OUTPUT FORMAT` paragraph;
137+- every model call carries `response_format: {"type": "json_schema", "json_schema": {"name": "output", "strict": true, "schema": …}}`;
138+- the answer is validated against the schema; one that does not match fails the turn with `model failed to generate output matching expected schema: …`, prefixed with `the answer was cut off by max_tokens before its JSON was complete …` when the server stopped on `max_tokens`;
139+- `-p` prints the JSON answer alone on stdout (see [Prompt mode specifics](cli.md#prompt-mode-specifics));
140+- the banner shows `tools: none (structured output: JSON answers)` and no "no skills" / "no tool" warning.
141+
142+Example:
143+
144+```yaml
145+structuredOutput: |
146+ type Language struct {
147+ Name string `json:"name"` // the language's name, in English
148+ Official bool `json:"official"`
149+ }
150+ type Country struct {
151+ Name string `json:"name"`
152+ Capital *string `json:"capital"` // null when unknown
153+ Languages []Language `json:"languages"` // at most 4
154+ }
155+ var Countries = []Country{}
156+```
157+
109 ## Validation errors158 ## Validation errors
110 159
111-Reported as `[config error: <path>: <message>]`, exit code 1.160+Reported on stderr as `[config error: <path>: <message>]`, exit code 1 — in every mode, so that `-p … | jq` never reads an error as the answer. `[engine error: …]` and `[usage: …]` too.
112 161
113 | Message | Cause |162 | Message | Cause |
114 |---------|-------|163 |---------|-------|
@@ -133,6 +182,9 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
133 | `permissions must be "auto" or "ask"` | any other value of the deprecated key |182 | `permissions must be "auto" or "ask"` | any other value of the deprecated key |
134 | `AGENT_YOLO="maybe": must be true or false` | an `AGENT_YOLO` value `strconv.ParseBool` refuses (accepted: `1`, `t`, `true`, `0`, `f`, `false`, any case) |183 | `AGENT_YOLO="maybe": must be true or false` | an `AGENT_YOLO` value `strconv.ParseBool` refuses (accepted: `1`, `t`, `true`, `0`, `f`, `false`, any case) |
135 | `bashTimeout must be > 0` | zero or negative |184 | `bashTimeout must be > 0` | zero or negative |
185+| `structuredOutput:<line>:<col>: …` | a declaration the [`structuredOutput`](#structuredoutput) section cannot translate |
186+| `structuredOutput: no var declaration: …` | no `var` names the root |
187+| `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. |
136 | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |188 | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` |
137 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |189 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` |
138 | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` |190 | `mmConfigs.<name>: the path must not be empty` | `<name>: ""`, or a long form without `path` |
modified docs/fr/README.md +2 -0
@@ -15,6 +15,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
1515 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/`
1616 - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`
1717 - [Voir le JSON envoyé au modèle et sa réponse](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, lire les fichiers avec jq
18+- [Obtenir des réponses en JSON](how-to/get-json-answers.md) — `structuredOutput` : déclarations Go, `-p … | jq`, un agent JSON derrière un coordinateur
1819 - [Laisser votre agent appeler d'autres agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, descriptions dans `mmConfigs`, `yolo` par agent
1920 - [Interroger un autre agent depuis une skill ou un script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context`
2021 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
@@ -63,6 +64,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
6364 - [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
6465 - [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
6566 - [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`)
67+- [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse
6668 - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur
6769 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
6870
@@ -15,6 +15,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
15 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/`15 - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/`
16 - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`16 - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>`
17 - [Voir le JSON envoyé au modèle et sa réponse](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, lire les fichiers avec jq17 - [Voir le JSON envoyé au modèle et sa réponse](how-to/inspect-model-traffic.md) — `trace`, `AGENT_TRACE`, lire les fichiers avec jq
18+- [Obtenir des réponses en JSON](how-to/get-json-answers.md) — `structuredOutput` : déclarations Go, `-p … | jq`, un agent JSON derrière un coordinateur
18 - [Laisser votre agent appeler d'autres agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, descriptions dans `mmConfigs`, `yolo` par agent19 - [Laisser votre agent appeler d'autres agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, descriptions dans `mmConfigs`, `yolo` par agent
19 - [Interroger un autre agent depuis une skill ou un script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context`20 - [Interroger un autre agent depuis une skill ou un script](how-to/ask-another-agent-from-a-skill.md) — `"$MM_BIN" --ask <agent>`, `--ask-with-context`
20 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)21 - [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md)
@@ -63,6 +64,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
63 - [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 connues64 - [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
64 - [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 vie65 - [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
65 - [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`)66 - [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`)
67+- [Sortie structurée](explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi aussi dans le prompt système, pourquoi pas d'outils, ce qui garantit la réponse
66 - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur68 - [Interroger un autre agent](explanation/asking-another-agent.md) — pourquoi un autre agent mm est un processus enfant interrogé en ACP, la paire de messages, les sessions neuves, `ask_agent` et le `yolo` par agent, la limite de profondeur
67 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)69 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
68 70
modified docs/fr/explanation/architecture.md +1 -0
@@ -9,6 +9,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
99 | Paquet | Responsabilité |
1010 |--------|----------------|
1111 | `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. |
12+| `structured` | Traduit les déclarations Go de `structuredOutput` en JSON Schema que doit suivre la réponse, avec `go/parser` ; utilisé par `config` au chargement du fichier. Voir [Sortie structurée](structured-output.md). |
1213 | `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, le registre des fournisseurs, le raisonnement qu'un modèle diffuse à côté de sa réponse (voir [Modèles à raisonnement](thinking-models.md)), et la trace optionnelle de chaque requête et réponse (`trace.go`). |
1314 | `tools` | Les outils intégrés que le modèle peut appeler, `ask_agent` compris (il interroge un agent de `mmConfigs` via `ask`). |
1415 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
@@ -9,6 +9,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
9 | Paquet | Responsabilité |9 | Paquet | Responsabilité |
10 |--------|----------------|10 |--------|----------------|
11 | `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. |11 | `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. |
12+| `structured` | Traduit les déclarations Go de `structuredOutput` en JSON Schema que doit suivre la réponse, avec `go/parser` ; utilisé par `config` au chargement du fichier. Voir [Sortie structurée](structured-output.md). |
12 | `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, le registre des fournisseurs, le raisonnement qu'un modèle diffuse à côté de sa réponse (voir [Modèles à raisonnement](thinking-models.md)), et la trace optionnelle de chaque requête et réponse (`trace.go`). |13 | `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, le registre des fournisseurs, le raisonnement qu'un modèle diffuse à côté de sa réponse (voir [Modèles à raisonnement](thinking-models.md)), et la trace optionnelle de chaque requête et réponse (`trace.go`). |
13 | `tools` | Les outils intégrés que le modèle peut appeler, `ask_agent` compris (il interroge un agent de `mmConfigs` via `ask`). |14 | `tools` | Les outils intégrés que le modèle peut appeler, `ask_agent` compris (il interroge un agent de `mmConfigs` via `ask`). |
14 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |15 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
added docs/fr/explanation/structured-output.md +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+# Sortie structurée — explication
2+
3+## De quoi s'agit-il ?
4+
5+Certaines réponses sont destinées à un programme plutôt qu'à une personne : une liste de pays pour un script, un tri de rapports de bugs pour un agent coordinateur. Le texte libre s'y prête mal — le programme doit deviner où commencent les données, et un modèle s'écarte du format demandé. Les serveurs qui parlent l'API OpenAI peuvent au contraire **contraindre** la génération : avec un JSON Schema dans `response_format`, llama.cpp le compile en grammaire, et le modèle ne peut produire aucun token hors de celle-ci. `structuredOutput` (ticket 80) est la façon dont un agent mm l'active.
6+
7+## Pourquoi des déclarations Go
8+
9+Le JSON Schema est le format sur le fil, mais il est verbeux et facile à rater à la main. La section s'écrit donc en déclarations de types Go : compactes, lisibles, familières à ceux qui écrivent les configurations de mm — et aux modèles entraînés sur du code.
10+
11+Aucun type Go n'est créé à l'exécution : Go ne sait pas compiler du code pendant qu'il tourne. mm lit le texte comme du **code source**. `go/parser`, de la bibliothèque standard, construit son arbre syntaxique ; `internal/structured` le parcourt et traduit chaque déclaration — une struct en objet fermé, une slice en tableau, un type déclaré en `$ref` — puis remet le schéma à Genkit avec `ai.WithOutputSchema`. Les constructions non prises en charge sont refusées au chargement de la configuration, avec leur ligne dans le bloc YAML, plutôt qu'en réponse ratée plus tard.
12+
13+Alternatives écartées :
14+
15+- **`reflect.StructOf`** fabrique des types struct à l'exécution, mais ne sait exprimer ni les types nommés ni les types récursifs, ignore les commentaires, et ne ferait que revérifier ce que dit déjà le schéma.
16+- **Un interpréteur Go embarqué** (yaegi) : une grosse dépendance pour exécuter du code dont on n'utilise que les déclarations.
17+- **Une notation de forme en YAML** (`countries: [{name: string}]`) : plus courte, mais un mini-langage à inventer et à documenter, sans place pour les descriptions.
18+- **Un JSON Schema brut** dans la config : possible, mais c'est précisément ce que la forme Go évite d'écrire.
19+
20+## Pourquoi les déclarations vont aussi dans le prompt système
21+
22+Le schéma contraint la **syntaxe**, pas le sens. Quand la contrainte est native, Genkit ne met rien du schéma dans le prompt, et llama.cpp n'en garde que la grammaire : le modèle voit les noms des clés, jamais les descriptions. Mesuré sur Qwen3.5-4B : à qui l'on demandait des capitales, il écrivait chaque fois le nom du pays dans `capital`. mm ajoute donc les déclarations elles-mêmes, commentaires compris, au prompt système. C'est pourquoi les commentaires comptent : ce sont les consignes que lit le modèle.
23+
24+## Pourquoi les clés gardent leur ordre de déclaration — et pourquoi cela peut ne rien changer
25+
26+Les `properties` partent dans l'ordre de déclaration, car un modèle remplit un champ à partir de ce qu'il a déjà écrit : déclarer `Name` en premier, c'est dire « décide d'abord ceci ». Une simple map Go les aurait triées. Mais llama.cpp (build b11429, mesuré à travers llmman) réordonne les clés par ordre alphabétique en construisant sa grammaire : sur ce serveur, `capital` est toujours écrit avant `name`. Les serveurs qui respectent l'ordre en profitent ; sur llama.cpp, le remède est un commentaire autosuffisant sur chaque champ.
27+
28+## Pourquoi un agent qui a des outils ne peut pas en avoir
29+
30+Un serveur qui contraint chaque réponse au schéma ne laisse aucune place à un appel d'outil, et llama.cpp refuse les contraintes de grammaire en même temps que des outils. La règle de Genkit dit la même chose (`ConstrainedSupportNoTools`). Plutôt qu'un tour caché en deux temps (travailler avec les outils, puis reformater), mm sépare les rôles : un agent agit, ou répond dans une forme fixe. Les deux se rejoignent par `ask_agent` — un coordinateur qui a des outils interroge un agent JSON et reçoit le JSON comme résultat de l'outil. L'erreur de configuration liste les outils à désactiver.
31+
32+## Ce qui garantit la réponse
33+
34+Trois couches : la grammaire du serveur (quand il la prend en charge), la validation de la réponse par Genkit contre le schéma (une réponse non conforme fait échouer le tour), et — avec `-p` — un code de sortie qu'un script peut tester, avec rien d'autre que le JSON sur stdout. L'échec habituel n'est pas une syntaxe fautive mais une réponse coupée : un petit modèle qui répète les éléments d'une liste jusqu'à `max_tokens`. mm nomme ce cas explicitement, car le remède est un réglage ou une liste bornée, pas la syntaxe du modèle.
35+
36+## Liens avec le reste
37+
38+- [Interroger un autre agent](asking-another-agent.md) — comment le JSON parvient à un coordinateur.
39+- [Modèles qui raisonnent](thinking-models.md) — un agent de consultation a rarement besoin de raisonner ; un coordinateur, souvent (sur Qwen3.5-4B, sans raisonnement, le coordinateur répondait de mémoire en affirmant avoir demandé).
40+- [Comment obtenir des réponses en JSON](../how-to/get-json-answers.md) et la [référence de `structuredOutput`](../reference/configuration.md#structuredoutput).
new file mode 100644
@@ -0,0 +1,40 @@
1+# Sortie structurée — explication
2+
3+## De quoi s'agit-il ?
4+
5+Certaines réponses sont destinées à un programme plutôt qu'à une personne : une liste de pays pour un script, un tri de rapports de bugs pour un agent coordinateur. Le texte libre s'y prête mal — le programme doit deviner où commencent les données, et un modèle s'écarte du format demandé. Les serveurs qui parlent l'API OpenAI peuvent au contraire **contraindre** la génération : avec un JSON Schema dans `response_format`, llama.cpp le compile en grammaire, et le modèle ne peut produire aucun token hors de celle-ci. `structuredOutput` (ticket 80) est la façon dont un agent mm l'active.
6+
7+## Pourquoi des déclarations Go
8+
9+Le JSON Schema est le format sur le fil, mais il est verbeux et facile à rater à la main. La section s'écrit donc en déclarations de types Go : compactes, lisibles, familières à ceux qui écrivent les configurations de mm — et aux modèles entraînés sur du code.
10+
11+Aucun type Go n'est créé à l'exécution : Go ne sait pas compiler du code pendant qu'il tourne. mm lit le texte comme du **code source**. `go/parser`, de la bibliothèque standard, construit son arbre syntaxique ; `internal/structured` le parcourt et traduit chaque déclaration — une struct en objet fermé, une slice en tableau, un type déclaré en `$ref` — puis remet le schéma à Genkit avec `ai.WithOutputSchema`. Les constructions non prises en charge sont refusées au chargement de la configuration, avec leur ligne dans le bloc YAML, plutôt qu'en réponse ratée plus tard.
12+
13+Alternatives écartées :
14+
15+- **`reflect.StructOf`** fabrique des types struct à l'exécution, mais ne sait exprimer ni les types nommés ni les types récursifs, ignore les commentaires, et ne ferait que revérifier ce que dit déjà le schéma.
16+- **Un interpréteur Go embarqué** (yaegi) : une grosse dépendance pour exécuter du code dont on n'utilise que les déclarations.
17+- **Une notation de forme en YAML** (`countries: [{name: string}]`) : plus courte, mais un mini-langage à inventer et à documenter, sans place pour les descriptions.
18+- **Un JSON Schema brut** dans la config : possible, mais c'est précisément ce que la forme Go évite d'écrire.
19+
20+## Pourquoi les déclarations vont aussi dans le prompt système
21+
22+Le schéma contraint la **syntaxe**, pas le sens. Quand la contrainte est native, Genkit ne met rien du schéma dans le prompt, et llama.cpp n'en garde que la grammaire : le modèle voit les noms des clés, jamais les descriptions. Mesuré sur Qwen3.5-4B : à qui l'on demandait des capitales, il écrivait chaque fois le nom du pays dans `capital`. mm ajoute donc les déclarations elles-mêmes, commentaires compris, au prompt système. C'est pourquoi les commentaires comptent : ce sont les consignes que lit le modèle.
23+
24+## Pourquoi les clés gardent leur ordre de déclaration — et pourquoi cela peut ne rien changer
25+
26+Les `properties` partent dans l'ordre de déclaration, car un modèle remplit un champ à partir de ce qu'il a déjà écrit : déclarer `Name` en premier, c'est dire « décide d'abord ceci ». Une simple map Go les aurait triées. Mais llama.cpp (build b11429, mesuré à travers llmman) réordonne les clés par ordre alphabétique en construisant sa grammaire : sur ce serveur, `capital` est toujours écrit avant `name`. Les serveurs qui respectent l'ordre en profitent ; sur llama.cpp, le remède est un commentaire autosuffisant sur chaque champ.
27+
28+## Pourquoi un agent qui a des outils ne peut pas en avoir
29+
30+Un serveur qui contraint chaque réponse au schéma ne laisse aucune place à un appel d'outil, et llama.cpp refuse les contraintes de grammaire en même temps que des outils. La règle de Genkit dit la même chose (`ConstrainedSupportNoTools`). Plutôt qu'un tour caché en deux temps (travailler avec les outils, puis reformater), mm sépare les rôles : un agent agit, ou répond dans une forme fixe. Les deux se rejoignent par `ask_agent` — un coordinateur qui a des outils interroge un agent JSON et reçoit le JSON comme résultat de l'outil. L'erreur de configuration liste les outils à désactiver.
31+
32+## Ce qui garantit la réponse
33+
34+Trois couches : la grammaire du serveur (quand il la prend en charge), la validation de la réponse par Genkit contre le schéma (une réponse non conforme fait échouer le tour), et — avec `-p` — un code de sortie qu'un script peut tester, avec rien d'autre que le JSON sur stdout. L'échec habituel n'est pas une syntaxe fautive mais une réponse coupée : un petit modèle qui répète les éléments d'une liste jusqu'à `max_tokens`. mm nomme ce cas explicitement, car le remède est un réglage ou une liste bornée, pas la syntaxe du modèle.
35+
36+## Liens avec le reste
37+
38+- [Interroger un autre agent](asking-another-agent.md) — comment le JSON parvient à un coordinateur.
39+- [Modèles qui raisonnent](thinking-models.md) — un agent de consultation a rarement besoin de raisonner ; un coordinateur, souvent (sur Qwen3.5-4B, sans raisonnement, le coordinateur répondait de mémoire en affirmant avoir demandé).
40+- [Comment obtenir des réponses en JSON](../how-to/get-json-answers.md) et la [référence de `structuredOutput`](../reference/configuration.md#structuredoutput).
added docs/fr/how-to/get-json-answers.md +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+# Comment obtenir des réponses en JSON (`structuredOutput`)
2+
3+Ce guide montre comment faire répondre un agent uniquement en JSON, selon une forme que vous déclarez, pour qu'un script, `jq` ou un autre agent puisse exploiter la réponse. Il suppose que vous lancez déjà `mm` avec un fichier de configuration, sur un serveur qui accepte `response_format: json_schema` (llama.cpp, donc `dmr`, `llamacpp`, `llmman` ; Ollama ; l'API OpenAI). Le dossier `demo-structured-output/` contient les fichiers utilisés ci-dessous.
4+
5+## Étapes
6+
7+1. Écrivez un fichier de configuration pour un agent qui ne répond qu'en JSON, tous ses outils désactivés : une réponse contrainte par un schéma ne laisse aucune place à un appel d'outil, et mm refuse de démarrer sinon :
8+
9+ ```yaml
10+ bashTool: false
11+ editTools: false
12+ planTool: false
13+ skillsDir: no-skills # un dossier qui n'existe pas : pas d'outil read_skill
14+ ```
15+
16+ N'ajoutez ni `mmConfigs`, ni `lsp`, ni `mcp`.
17+
18+2. Déclarez la forme de la réponse sous `structuredOutput`, en types Go et une `var` qui nomme la racine :
19+
20+ ```yaml
21+ structuredOutput: |
22+ type Language struct {
23+ Name string `json:"name"` // the language's name, in English
24+ Official bool `json:"official"` // true when it has an official status at the national level
25+ }
26+
27+ type Country struct {
28+ Name string `json:"name"` // the country's common name, in English
29+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
30+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
31+ }
32+
33+ // One entry per country named in the question, in the order asked.
34+ var Countries = []Country{}
35+ ```
36+
37+ Les tags `json` donnent les clés. Les commentaires sont ce que le modèle lit pour remplir chaque champ : écrivez-les comme des consignes.
38+
39+3. Posez une question avec `-p` et envoyez la réponse dans un pipe :
40+
41+ ```bash
42+ mm agent.countries.yaml -p "donne moi les langages parlés de la France, le Canada, le Vietnam et les US" | jq -c '.[] | {name, capital}'
43+ ```
44+
45+ ```
46+ {"name":"France","capital":"Paris"}
47+ {"name":"Canada","capital":"Ottawa"}
48+ {"name":"Vietnam","capital":"Hanoi"}
49+ {"name":"United States","capital":"Washington, D.C."}
50+ ```
51+
52+ stdout ne porte que le JSON ; le code de sortie vaut 1 quand la réponse manque ou est invalide.
53+
54+## Variantes
55+
56+- **Faire exploiter le JSON par un autre agent.** Déclarez l'agent JSON dans les `mmConfigs` d'un coordinateur — un agent qui a des outils — ; il l'appelle avec `ask_agent` et reçoit le JSON comme résultat de l'outil. Voir [Comment laisser votre agent appeler d'autres agents](let-the-agent-call-other-agents.md) et `demo-structured-output/agent.coordinator.yaml`. Le coordinateur, lui, ne peut pas avoir de `structuredOutput` : il a des outils.
57+- **Interroger l'agent JSON par la config du coordinateur**, depuis un shell : `mm agent.coordinator.yaml --ask countries -p "capitale du Vietnam" | jq .` (voir [Comment interroger un autre agent depuis un skill ou un script](ask-another-agent-from-a-skill.md)).
58+- **Dans le terminal ou la fenêtre**, l'agent répond à chaque question en JSON, affiché comme n'importe quelle réponse.
59+- **Pour l'API hébergée d'OpenAI**, faites de la racine une struct (`var Answer = Triage{}`) : son mode strict exige un objet au sommet. llama.cpp et Ollama acceptent un tableau.
60+- **Champs facultatifs** : un pointeur (`*string`) ou `json:",omitempty"` autorise la valeur `null` ; la clé est toujours présente.
61+
62+## En cas de problème
63+
64+- `the answer was cut off by max_tokens before its JSON was complete` — un petit modèle peut répéter les éléments d'une liste jusqu'à la limite. Bornez les listes dans leurs commentaires (`at most 4`, `each once`), ou augmentez `sampling.max_tokens`.
65+- Un champ contient la mauvaise chose (le nom du pays dans `capital`) — précisez son commentaire. Avec llama.cpp, le modèle écrit les clés dans l'ordre alphabétique quel que soit l'ordre de déclaration : un champ peut être rempli avant celui dont il dépend, rendez donc chaque commentaire autosuffisant.
66+- `structuredOutput cannot be used with tools — this agent declares: …` — désactivez les outils listés.
67+- `structuredOutput:<ligne>:<col>: …` — la déclaration à cette ligne du bloc n'est pas prise en charge ; voir la [référence de `structuredOutput`](../reference/configuration.md#structuredoutput).
68+- Un modèle qui raisonne met du temps à répondre : coupez son raisonnement pour un agent de consultation ([Comment couper le raisonnement d'un modèle](turn-reasoning-off.md)).
69+
70+## Voir aussi
71+
72+- [Référence de `structuredOutput`](../reference/configuration.md#structuredoutput) — la correspondance des types, les erreurs
73+- [Spécificités du mode invite](../reference/cli.md#spécificités-du-mode-invite) — ce qu'imprime `-p`
74+- [Sortie structurée — explication](../explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi pas d'outils
new file mode 100644
@@ -0,0 +1,74 @@
1+# Comment obtenir des réponses en JSON (`structuredOutput`)
2+
3+Ce guide montre comment faire répondre un agent uniquement en JSON, selon une forme que vous déclarez, pour qu'un script, `jq` ou un autre agent puisse exploiter la réponse. Il suppose que vous lancez déjà `mm` avec un fichier de configuration, sur un serveur qui accepte `response_format: json_schema` (llama.cpp, donc `dmr`, `llamacpp`, `llmman` ; Ollama ; l'API OpenAI). Le dossier `demo-structured-output/` contient les fichiers utilisés ci-dessous.
4+
5+## Étapes
6+
7+1. Écrivez un fichier de configuration pour un agent qui ne répond qu'en JSON, tous ses outils désactivés : une réponse contrainte par un schéma ne laisse aucune place à un appel d'outil, et mm refuse de démarrer sinon :
8+
9+ ```yaml
10+ bashTool: false
11+ editTools: false
12+ planTool: false
13+ skillsDir: no-skills # un dossier qui n'existe pas : pas d'outil read_skill
14+ ```
15+
16+ N'ajoutez ni `mmConfigs`, ni `lsp`, ni `mcp`.
17+
18+2. Déclarez la forme de la réponse sous `structuredOutput`, en types Go et une `var` qui nomme la racine :
19+
20+ ```yaml
21+ structuredOutput: |
22+ type Language struct {
23+ Name string `json:"name"` // the language's name, in English
24+ Official bool `json:"official"` // true when it has an official status at the national level
25+ }
26+
27+ type Country struct {
28+ Name string `json:"name"` // the country's common name, in English
29+ Capital *string `json:"capital"` // the capital CITY (not the country), in English; null when unknown
30+ Languages []Language `json:"languages"` // the main languages only, at most 4, most spoken first, each once
31+ }
32+
33+ // One entry per country named in the question, in the order asked.
34+ var Countries = []Country{}
35+ ```
36+
37+ Les tags `json` donnent les clés. Les commentaires sont ce que le modèle lit pour remplir chaque champ : écrivez-les comme des consignes.
38+
39+3. Posez une question avec `-p` et envoyez la réponse dans un pipe :
40+
41+ ```bash
42+ mm agent.countries.yaml -p "donne moi les langages parlés de la France, le Canada, le Vietnam et les US" | jq -c '.[] | {name, capital}'
43+ ```
44+
45+ ```
46+ {"name":"France","capital":"Paris"}
47+ {"name":"Canada","capital":"Ottawa"}
48+ {"name":"Vietnam","capital":"Hanoi"}
49+ {"name":"United States","capital":"Washington, D.C."}
50+ ```
51+
52+ stdout ne porte que le JSON ; le code de sortie vaut 1 quand la réponse manque ou est invalide.
53+
54+## Variantes
55+
56+- **Faire exploiter le JSON par un autre agent.** Déclarez l'agent JSON dans les `mmConfigs` d'un coordinateur — un agent qui a des outils — ; il l'appelle avec `ask_agent` et reçoit le JSON comme résultat de l'outil. Voir [Comment laisser votre agent appeler d'autres agents](let-the-agent-call-other-agents.md) et `demo-structured-output/agent.coordinator.yaml`. Le coordinateur, lui, ne peut pas avoir de `structuredOutput` : il a des outils.
57+- **Interroger l'agent JSON par la config du coordinateur**, depuis un shell : `mm agent.coordinator.yaml --ask countries -p "capitale du Vietnam" | jq .` (voir [Comment interroger un autre agent depuis un skill ou un script](ask-another-agent-from-a-skill.md)).
58+- **Dans le terminal ou la fenêtre**, l'agent répond à chaque question en JSON, affiché comme n'importe quelle réponse.
59+- **Pour l'API hébergée d'OpenAI**, faites de la racine une struct (`var Answer = Triage{}`) : son mode strict exige un objet au sommet. llama.cpp et Ollama acceptent un tableau.
60+- **Champs facultatifs** : un pointeur (`*string`) ou `json:",omitempty"` autorise la valeur `null` ; la clé est toujours présente.
61+
62+## En cas de problème
63+
64+- `the answer was cut off by max_tokens before its JSON was complete` — un petit modèle peut répéter les éléments d'une liste jusqu'à la limite. Bornez les listes dans leurs commentaires (`at most 4`, `each once`), ou augmentez `sampling.max_tokens`.
65+- Un champ contient la mauvaise chose (le nom du pays dans `capital`) — précisez son commentaire. Avec llama.cpp, le modèle écrit les clés dans l'ordre alphabétique quel que soit l'ordre de déclaration : un champ peut être rempli avant celui dont il dépend, rendez donc chaque commentaire autosuffisant.
66+- `structuredOutput cannot be used with tools — this agent declares: …` — désactivez les outils listés.
67+- `structuredOutput:<ligne>:<col>: …` — la déclaration à cette ligne du bloc n'est pas prise en charge ; voir la [référence de `structuredOutput`](../reference/configuration.md#structuredoutput).
68+- Un modèle qui raisonne met du temps à répondre : coupez son raisonnement pour un agent de consultation ([Comment couper le raisonnement d'un modèle](turn-reasoning-off.md)).
69+
70+## Voir aussi
71+
72+- [Référence de `structuredOutput`](../reference/configuration.md#structuredoutput) — la correspondance des types, les erreurs
73+- [Spécificités du mode invite](../reference/cli.md#spécificités-du-mode-invite) — ce qu'imprime `-p`
74+- [Sortie structurée — explication](../explanation/structured-output.md) — pourquoi des déclarations Go, pourquoi pas d'outils
modified docs/fr/reference/cli.md +1 -0
@@ -132,6 +132,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
132132 | Fichier de config | Peut apparaître avant ou après le flag `-prompt`/`-p` : `mm -p "prompt" config.yaml` et `mm config.yaml -p "prompt"` fonctionnent tous deux. |
133133 | Compatibilité | Incompatible avec `-acp` et `-tui` ; le mode invite prend la priorité et s'exécute dans tous les cas. |
134134 | Permissions | Personne ne peut répondre. Avec `yolo: false`, chaque appel qui demanderait est refusé (`[command rejected by the user]` pour le modèle) et stderr le dit une fois : `[yolo is off and this mode cannot ask: tool calls that need a confirmation are rejected — set yolo: true to let them run]`. Les outils MCP marqués `ask` sont refusés de la même façon, quoi que dise `yolo`. |
135+| Avec `structuredOutput` | stdout porte la réponse JSON et un saut de ligne, rien d'autre : pas de texte en flux, pas de 🤖, pas de ligne de statistiques. Les messages vont sur stderr. Une réponse en échec ou invalide imprime `[structured output: <erreur>]` sur stderr, rien sur stdout, et sort avec le code 1 ; une réponse valide sort avec le code 0. Voir [`structuredOutput`](configuration.md#structuredoutput). |
135136
136137 ## Spécificités du mode ask
137138
@@ -132,6 +132,7 @@ mm v0.1.7 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
132 | Fichier de config | Peut apparaître avant ou après le flag `-prompt`/`-p` : `mm -p "prompt" config.yaml` et `mm config.yaml -p "prompt"` fonctionnent tous deux. |132 | Fichier de config | Peut apparaître avant ou après le flag `-prompt`/`-p` : `mm -p "prompt" config.yaml` et `mm config.yaml -p "prompt"` fonctionnent tous deux. |
133 | Compatibilité | Incompatible avec `-acp` et `-tui` ; le mode invite prend la priorité et s'exécute dans tous les cas. |133 | Compatibilité | Incompatible avec `-acp` et `-tui` ; le mode invite prend la priorité et s'exécute dans tous les cas. |
134 | Permissions | Personne ne peut répondre. Avec `yolo: false`, chaque appel qui demanderait est refusé (`[command rejected by the user]` pour le modèle) et stderr le dit une fois : `[yolo is off and this mode cannot ask: tool calls that need a confirmation are rejected — set yolo: true to let them run]`. Les outils MCP marqués `ask` sont refusés de la même façon, quoi que dise `yolo`. |134 | Permissions | Personne ne peut répondre. Avec `yolo: false`, chaque appel qui demanderait est refusé (`[command rejected by the user]` pour le modèle) et stderr le dit une fois : `[yolo is off and this mode cannot ask: tool calls that need a confirmation are rejected — set yolo: true to let them run]`. Les outils MCP marqués `ask` sont refusés de la même façon, quoi que dise `yolo`. |
135+| Avec `structuredOutput` | stdout porte la réponse JSON et un saut de ligne, rien d'autre : pas de texte en flux, pas de 🤖, pas de ligne de statistiques. Les messages vont sur stderr. Une réponse en échec ou invalide imprime `[structured output: <erreur>]` sur stderr, rien sur stdout, et sort avec le code 1 ; une réponse valide sort avec le code 0. Voir [`structuredOutput`](configuration.md#structuredoutput). |
135 136
136 ## Spécificités du mode ask137 ## Spécificités du mode ask
137 138
modified docs/fr/reference/configuration.md +53 -1
@@ -18,6 +18,7 @@
1818 | `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. |
1919 | `showThinking` | booléen | `true` | Affiche ce qu'un modèle à raisonnement réfléchit avant de répondre. Seul l'affichage est optionnel : le raisonnement est toujours lu sur le fil, car c'est lui qui maintient le chien de garde en vie pendant que le modèle réfléchit. Voir [Modèles à raisonnement](../explanation/thinking-models.md). |
2020 | `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. |
21+| `structuredOutput` | chaîne (déclarations Go) | `""` | La forme que doit suivre chaque réponse, en JSON : des déclarations `type` Go et une `var` qui nomme la racine de la réponse. Voir [`structuredOutput`](#structuredoutput) ci-dessous. Vide = réponses en texte libre. Incompatible avec tout outil déclaré. |
2122 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
2223 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |
2324 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
@@ -106,9 +107,57 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl
106107 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non |
107108 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | non |
108109
110+## `structuredOutput`
111+
112+Un bloc YAML qui contient des déclarations Go. Chaque `type` est une définition ; l'unique `var` (`var X T` ou `var X = T{}`) nomme la racine de la réponse. Seules les déclarations `type` et `var` sont acceptées.
113+
114+| Go | JSON Schema |
115+|----|-------------|
116+| `string` | `{"type": "string"}` |
117+| `bool` | `{"type": "boolean"}` |
118+| `int`, `int8` … `int64`, `uint` … `uint64` | `{"type": "integer"}` |
119+| `float32`, `float64` | `{"type": "number"}` |
120+| `[]T` | `{"type": "array", "items": T}` |
121+| `map[string]T` | `{"type": "object", "additionalProperties": T}` |
122+| `struct { … }` | `{"type": "object", "properties": …, "required": [toutes les clés], "additionalProperties": false}`, clés dans l'ordre de déclaration |
123+| un type déclaré `Name` | `{"$ref": "#/$defs/Name"}` ; une racine struct est mise en ligne, `$defs` contient les types qu'atteint la racine |
124+| champ `*T`, ou `json:",omitempty"` | la clé reste obligatoire, sa valeur est `anyOf: [T, null]` |
125+
126+| Élément d'un champ | Effet |
127+|--------------------|-------|
128+| tag `json:"name"` | la clé ; sans tag, le nom Go du champ tel qu'écrit |
129+| tag `json:"-"` | champ ignoré |
130+| commentaire `//` au-dessus ou à côté d'une déclaration ou d'un champ | sa `description` |
131+
132+Refusés, avec `structuredOutput:<ligne>:<col>: …` compté depuis la première ligne du bloc : `func`, `import`, `const` ; les types génériques ; les alias (`type A = B`) ; les champs embarqués ; les tableaux de taille fixe ; les clés de map autres que `string` ; `interface`, `chan`, `func`, les types qualifiés (`time.Time`) et non définis ; un pointeur hors d'un champ de struct ; un type déclaré deux fois ; une clé utilisée deux fois ; zéro ou plus d'une `var` ; une `var` sans type.
133+
134+Quand le bloc n'est pas vide :
135+
136+- les déclarations sont ajoutées à `system`, sous un paragraphe `OUTPUT FORMAT` ;
137+- chaque appel au modèle porte `response_format: {"type": "json_schema", "json_schema": {"name": "output", "strict": true, "schema": …}}` ;
138+- la réponse est validée contre le schéma ; une réponse non conforme fait échouer le tour avec `model failed to generate output matching expected schema: …`, précédé de `the answer was cut off by max_tokens before its JSON was complete …` quand le serveur s'est arrêté sur `max_tokens` ;
139+- `-p` imprime la réponse JSON seule sur stdout (voir [Spécificités du mode invite](cli.md#spécificités-du-mode-invite)) ;
140+- la bannière affiche `tools: none (structured output: JSON answers)`, sans avertissement « no skills » ni « no tool ».
141+
142+Exemple :
143+
144+```yaml
145+structuredOutput: |
146+ type Language struct {
147+ Name string `json:"name"` // the language's name, in English
148+ Official bool `json:"official"`
149+ }
150+ type Country struct {
151+ Name string `json:"name"`
152+ Capital *string `json:"capital"` // null when unknown
153+ Languages []Language `json:"languages"` // at most 4
154+ }
155+ var Countries = []Country{}
156+```
157+
109158 ## Erreurs de validation
110159
111-Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1.
160+Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code de sortie 1 — dans tous les modes, pour que `-p … | jq` ne prenne jamais une erreur pour la réponse. De même pour `[engine error: …]` et `[usage: …]`.
112161
113162 | Message | Cause |
114163 |---------|-------|
@@ -133,6 +182,9 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
133182 | `permissions must be "auto" or "ask"` | toute autre valeur de la clé obsolète |
134183 | `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) |
135184 | `bashTimeout must be > 0` | zéro ou négative |
185+| `structuredOutput:<ligne>:<col>: …` | une déclaration que la section [`structuredOutput`](#structuredoutput) ne sait pas traduire |
186+| `structuredOutput: no var declaration: …` | aucune `var` ne nomme la racine |
187+| `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. |
136188 | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |
137189 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |
138190 | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` |
@@ -18,6 +18,7 @@
18 | `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. |18 | `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. |
19 | `showThinking` | booléen | `true` | Affiche ce qu'un modèle à raisonnement réfléchit avant de répondre. Seul l'affichage est optionnel : le raisonnement est toujours lu sur le fil, car c'est lui qui maintient le chien de garde en vie pendant que le modèle réfléchit. Voir [Modèles à raisonnement](../explanation/thinking-models.md). |19 | `showThinking` | booléen | `true` | Affiche ce qu'un modèle à raisonnement réfléchit avant de répondre. Seul l'affichage est optionnel : le raisonnement est toujours lu sur le fil, car c'est lui qui maintient le chien de garde en vie pendant que le modèle réfléchit. Voir [Modèles à raisonnement](../explanation/thinking-models.md). |
20 | `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. |20 | `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. |
21+| `structuredOutput` | chaîne (déclarations Go) | `""` | La forme que doit suivre chaque réponse, en JSON : des déclarations `type` Go et une `var` qui nomme la racine de la réponse. Voir [`structuredOutput`](#structuredoutput) ci-dessous. Vide = réponses en texte libre. Incompatible avec tout outil déclaré. |
21 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |22 | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. |
22 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |23 | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. |
23 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |24 | `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. |
@@ -106,9 +107,57 @@ Le fichier livré `agent.lsp.yaml` est `agent.dmr.yaml` avec ce bloc actif (gopl
106 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non |107 | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non |
107 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | non |108 | `ollama` | `http://localhost:11434/v1` | `http://host.docker.internal:11434/v1` | `OLLAMA_API_KEY` | non |
108 109
110+## `structuredOutput`
111+
112+Un bloc YAML qui contient des déclarations Go. Chaque `type` est une définition ; l'unique `var` (`var X T` ou `var X = T{}`) nomme la racine de la réponse. Seules les déclarations `type` et `var` sont acceptées.
113+
114+| Go | JSON Schema |
115+|----|-------------|
116+| `string` | `{"type": "string"}` |
117+| `bool` | `{"type": "boolean"}` |
118+| `int`, `int8` … `int64`, `uint` … `uint64` | `{"type": "integer"}` |
119+| `float32`, `float64` | `{"type": "number"}` |
120+| `[]T` | `{"type": "array", "items": T}` |
121+| `map[string]T` | `{"type": "object", "additionalProperties": T}` |
122+| `struct { … }` | `{"type": "object", "properties": …, "required": [toutes les clés], "additionalProperties": false}`, clés dans l'ordre de déclaration |
123+| un type déclaré `Name` | `{"$ref": "#/$defs/Name"}` ; une racine struct est mise en ligne, `$defs` contient les types qu'atteint la racine |
124+| champ `*T`, ou `json:",omitempty"` | la clé reste obligatoire, sa valeur est `anyOf: [T, null]` |
125+
126+| Élément d'un champ | Effet |
127+|--------------------|-------|
128+| tag `json:"name"` | la clé ; sans tag, le nom Go du champ tel qu'écrit |
129+| tag `json:"-"` | champ ignoré |
130+| commentaire `//` au-dessus ou à côté d'une déclaration ou d'un champ | sa `description` |
131+
132+Refusés, avec `structuredOutput:<ligne>:<col>: …` compté depuis la première ligne du bloc : `func`, `import`, `const` ; les types génériques ; les alias (`type A = B`) ; les champs embarqués ; les tableaux de taille fixe ; les clés de map autres que `string` ; `interface`, `chan`, `func`, les types qualifiés (`time.Time`) et non définis ; un pointeur hors d'un champ de struct ; un type déclaré deux fois ; une clé utilisée deux fois ; zéro ou plus d'une `var` ; une `var` sans type.
133+
134+Quand le bloc n'est pas vide :
135+
136+- les déclarations sont ajoutées à `system`, sous un paragraphe `OUTPUT FORMAT` ;
137+- chaque appel au modèle porte `response_format: {"type": "json_schema", "json_schema": {"name": "output", "strict": true, "schema": …}}` ;
138+- la réponse est validée contre le schéma ; une réponse non conforme fait échouer le tour avec `model failed to generate output matching expected schema: …`, précédé de `the answer was cut off by max_tokens before its JSON was complete …` quand le serveur s'est arrêté sur `max_tokens` ;
139+- `-p` imprime la réponse JSON seule sur stdout (voir [Spécificités du mode invite](cli.md#spécificités-du-mode-invite)) ;
140+- la bannière affiche `tools: none (structured output: JSON answers)`, sans avertissement « no skills » ni « no tool ».
141+
142+Exemple :
143+
144+```yaml
145+structuredOutput: |
146+ type Language struct {
147+ Name string `json:"name"` // the language's name, in English
148+ Official bool `json:"official"`
149+ }
150+ type Country struct {
151+ Name string `json:"name"`
152+ Capital *string `json:"capital"` // null when unknown
153+ Languages []Language `json:"languages"` // at most 4
154+ }
155+ var Countries = []Country{}
156+```
157+
109 ## Erreurs de validation158 ## Erreurs de validation
110 159
111-Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1.160+Signalées sur stderr sous la forme `[config error: <chemin>: <message>]`, code de sortie 1 — dans tous les modes, pour que `-p … | jq` ne prenne jamais une erreur pour la réponse. De même pour `[engine error: …]` et `[usage: …]`.
112 161
113 | Message | Cause |162 | Message | Cause |
114 |---------|-------|163 |---------|-------|
@@ -133,6 +182,9 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
133 | `permissions must be "auto" or "ask"` | toute autre valeur de la clé obsolète |182 | `permissions must be "auto" or "ask"` | toute autre valeur de la clé obsolète |
134 | `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) |183 | `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) |
135 | `bashTimeout must be > 0` | zéro ou négative |184 | `bashTimeout must be > 0` | zéro ou négative |
185+| `structuredOutput:<ligne>:<col>: …` | une déclaration que la section [`structuredOutput`](#structuredoutput) ne sait pas traduire |
186+| `structuredOutput: no var declaration: …` | aucune `var` ne nomme la racine |
187+| `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. |
136 | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |188 | `mmConfigs: "Bob" is not a valid name (…)` | un nom avec des majuscules, des espaces ou d'autres caractères, ou commençant par `-` |
137 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |189 | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | un nom commençant par `with-context` |
138 | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` |190 | `mmConfigs.<nom>: the path must not be empty` | `<nom>: ""`, ou une forme longue sans `path` |
added internal/agent/structured.go +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/firebase/genkit/go/ai"
9+
10+ "mm/internal/engine"
11+ "mm/internal/session"
12+ "mm/internal/ui"
13+)
14+
15+// RunStructuredPrompt answers one prompt for an agent with a
16+// `structuredOutput` section (ticket 80), and writes the JSON answer — and
17+// nothing else — to out, followed by a newline, so that
18+// `mm countries.yaml -p "…" | jq` works. The streamed chunks, the stats line
19+// and the 🤖 prefix of RunPrompt would corrupt that JSON: the chunks go to a
20+// quiet sink instead, and everything human (warnings, errors) is the
21+// caller's to send to stderr (ui.Out). No tool is offered: an agent with a
22+// structuredOutput declares none (config.StructuredOutputConflict).
23+//
24+// if err := agent.RunStructuredPrompt(ctx, e, config.Cfg.System, prompt, os.Stdout); err != nil {
25+// // exit non-zero: the pipe must know the answer is missing
26+// }
27+func RunStructuredPrompt(ctx context.Context, e *engine.Engine, system, prompt string, out io.Writer) error {
28+ ui.SetActive(quietSink{})
29+ defer ui.SetActive(nil)
30+
31+ messages := append(session.Fresh(system), ai.NewUserTextMessage(prompt))
32+ resp, _, err := e.Generate(ctx, messages, nil)
33+ if err != nil {
34+ return err
35+ }
36+ _, err = fmt.Fprintln(out, resp.Text())
37+ return err
38+}
39+
40+// quietSink swallows the streamed chunks of a structured answer: the JSON
41+// is printed once, whole, by RunStructuredPrompt. With no tool declared,
42+// the tool events never come.
43+type quietSink struct{}
44+
45+var _ ui.Sink = quietSink{}
46+
47+func (quietSink) Text(string) {}
48+func (quietSink) Reasoning(string) {}
49+func (quietSink) ToolStart(ui.ToolEvent) {}
50+func (quietSink) Allow(context.Context, ui.ToolEvent) bool { return false }
51+func (quietSink) ToolRunning(string) {}
52+func (quietSink) ToolEnd(string, ui.ToolResult) {}
53+func (quietSink) WorkDir() string { return "" }
new file mode 100644
@@ -0,0 +1,53 @@
1+package agent
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/firebase/genkit/go/ai"
9+
10+ "mm/internal/engine"
11+ "mm/internal/session"
12+ "mm/internal/ui"
13+)
14+
15+// RunStructuredPrompt answers one prompt for an agent with a
16+// `structuredOutput` section (ticket 80), and writes the JSON answer — and
17+// nothing else — to out, followed by a newline, so that
18+// `mm countries.yaml -p "…" | jq` works. The streamed chunks, the stats line
19+// and the 🤖 prefix of RunPrompt would corrupt that JSON: the chunks go to a
20+// quiet sink instead, and everything human (warnings, errors) is the
21+// caller's to send to stderr (ui.Out). No tool is offered: an agent with a
22+// structuredOutput declares none (config.StructuredOutputConflict).
23+//
24+// if err := agent.RunStructuredPrompt(ctx, e, config.Cfg.System, prompt, os.Stdout); err != nil {
25+// // exit non-zero: the pipe must know the answer is missing
26+// }
27+func RunStructuredPrompt(ctx context.Context, e *engine.Engine, system, prompt string, out io.Writer) error {
28+ ui.SetActive(quietSink{})
29+ defer ui.SetActive(nil)
30+
31+ messages := append(session.Fresh(system), ai.NewUserTextMessage(prompt))
32+ resp, _, err := e.Generate(ctx, messages, nil)
33+ if err != nil {
34+ return err
35+ }
36+ _, err = fmt.Fprintln(out, resp.Text())
37+ return err
38+}
39+
40+// quietSink swallows the streamed chunks of a structured answer: the JSON
41+// is printed once, whole, by RunStructuredPrompt. With no tool declared,
42+// the tool events never come.
43+type quietSink struct{}
44+
45+var _ ui.Sink = quietSink{}
46+
47+func (quietSink) Text(string) {}
48+func (quietSink) Reasoning(string) {}
49+func (quietSink) ToolStart(ui.ToolEvent) {}
50+func (quietSink) Allow(context.Context, ui.ToolEvent) bool { return false }
51+func (quietSink) ToolRunning(string) {}
52+func (quietSink) ToolEnd(string, ui.ToolResult) {}
53+func (quietSink) WorkDir() string { return "" }
added internal/agent/structured_test.go +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "strconv"
10+ "testing"
11+
12+ "mm/internal/config"
13+ "mm/internal/engine"
14+ "mm/internal/structured"
15+)
16+
17+// fakeModel serves one streamed answer, content, as a llama.cpp server would.
18+func fakeModel(t *testing.T, content string) *engine.Engine {
19+ t.Helper()
20+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
21+ w.Header().Set("Content-Type", "text/event-stream")
22+ fmt.Fprintf(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":%s}}]}\n\n", strconv.Quote(content))
23+ fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
24+ fmt.Fprint(w, "data: [DONE]\n\n")
25+ }))
26+ t.Cleanup(srv.Close)
27+ p, err := engine.Lookup("llamacpp")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ b := engine.Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "fake-model", APIKey: "not-needed"}
32+ g, model, err := p.Open(context.Background(), b)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ return &engine.Engine{G: g, Model: model, Provider: p, Backend: b}
37+}
38+
39+// withSchema compiles src as config.Cfg.OutputSchema for one test.
40+func withSchema(t *testing.T, src string) {
41+ t.Helper()
42+ schema, err := structured.Schema(src)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ old := config.Cfg.OutputSchema
47+ config.Cfg.OutputSchema = schema
48+ t.Cleanup(func() { config.Cfg.OutputSchema = old })
49+}
50+
51+// stdout carries the JSON and nothing else: no 🤖, no stats line, no echo
52+// of the streamed chunks.
53+func TestStructuredPromptPrintsOnlyTheJSON(t *testing.T) {
54+ withSchema(t, "var Names []string")
55+ e := fakeModel(t, `["Ada","Grace"]`)
56+ var out bytes.Buffer
57+ if err := RunStructuredPrompt(context.Background(), e, "system", "names?", &out); err != nil {
58+ t.Fatalf("RunStructuredPrompt: %v", err)
59+ }
60+ if out.String() != "[\"Ada\",\"Grace\"]\n" {
61+ t.Errorf("stdout = %q, want the JSON alone", out.String())
62+ }
63+}
64+
65+// A refused answer is an error and leaves stdout empty: a pipe must not
66+// read half an answer as a whole one.
67+func TestStructuredPromptFailsOnAnInvalidAnswer(t *testing.T) {
68+ withSchema(t, "var Names []string")
69+ e := fakeModel(t, `{"names":"Ada"}`)
70+ var out bytes.Buffer
71+ if err := RunStructuredPrompt(context.Background(), e, "system", "names?", &out); err == nil {
72+ t.Error("RunStructuredPrompt returned no error for an answer outside the schema")
73+ }
74+ if out.Len() != 0 {
75+ t.Errorf("stdout = %q, want nothing", out.String())
76+ }
77+}
new file mode 100644
@@ -0,0 +1,77 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "strconv"
10+ "testing"
11+
12+ "mm/internal/config"
13+ "mm/internal/engine"
14+ "mm/internal/structured"
15+)
16+
17+// fakeModel serves one streamed answer, content, as a llama.cpp server would.
18+func fakeModel(t *testing.T, content string) *engine.Engine {
19+ t.Helper()
20+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
21+ w.Header().Set("Content-Type", "text/event-stream")
22+ fmt.Fprintf(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":%s}}]}\n\n", strconv.Quote(content))
23+ fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
24+ fmt.Fprint(w, "data: [DONE]\n\n")
25+ }))
26+ t.Cleanup(srv.Close)
27+ p, err := engine.Lookup("llamacpp")
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ b := engine.Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "fake-model", APIKey: "not-needed"}
32+ g, model, err := p.Open(context.Background(), b)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ return &engine.Engine{G: g, Model: model, Provider: p, Backend: b}
37+}
38+
39+// withSchema compiles src as config.Cfg.OutputSchema for one test.
40+func withSchema(t *testing.T, src string) {
41+ t.Helper()
42+ schema, err := structured.Schema(src)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ old := config.Cfg.OutputSchema
47+ config.Cfg.OutputSchema = schema
48+ t.Cleanup(func() { config.Cfg.OutputSchema = old })
49+}
50+
51+// stdout carries the JSON and nothing else: no 🤖, no stats line, no echo
52+// of the streamed chunks.
53+func TestStructuredPromptPrintsOnlyTheJSON(t *testing.T) {
54+ withSchema(t, "var Names []string")
55+ e := fakeModel(t, `["Ada","Grace"]`)
56+ var out bytes.Buffer
57+ if err := RunStructuredPrompt(context.Background(), e, "system", "names?", &out); err != nil {
58+ t.Fatalf("RunStructuredPrompt: %v", err)
59+ }
60+ if out.String() != "[\"Ada\",\"Grace\"]\n" {
61+ t.Errorf("stdout = %q, want the JSON alone", out.String())
62+ }
63+}
64+
65+// A refused answer is an error and leaves stdout empty: a pipe must not
66+// read half an answer as a whole one.
67+func TestStructuredPromptFailsOnAnInvalidAnswer(t *testing.T) {
68+ withSchema(t, "var Names []string")
69+ e := fakeModel(t, `{"names":"Ada"}`)
70+ var out bytes.Buffer
71+ if err := RunStructuredPrompt(context.Background(), e, "system", "names?", &out); err == nil {
72+ t.Error("RunStructuredPrompt returned no error for an answer outside the schema")
73+ }
74+ if out.Len() != 0 {
75+ t.Errorf("stdout = %q, want nothing", out.String())
76+ }
77+}
modified internal/config/config.go +14 -0
@@ -79,6 +79,17 @@ type Config struct {
7979 // System: the system prompt — what the agent is, and what it may do.
8080 System string `yaml:"system"`
8181
82+ // StructuredOutput: Go type declarations the answer must follow, as JSON
83+ // (internal/structured, ticket 80): `type` declarations and one `var`
84+ // naming the root. Empty = a free-text answer. Every answer is then
85+ // constrained by the server, so an agent that declares a tool cannot have
86+ // one — main refuses to start (see StructuredOutputConflict).
87+ StructuredOutput string `yaml:"structuredOutput"`
88+
89+ // OutputSchema: StructuredOutput compiled into a JSON Schema by Load, nil
90+ // when the section is empty. Never read from the file.
91+ OutputSchema map[string]any `yaml:"-"`
92+
8293 // SkillsDir: directory of markdown procedures exposed by the `read_skill`
8394 // tool. When it holds no *.md file, the tool is not declared at all.
8495 SkillsDir string `yaml:"skillsDir"`
@@ -573,6 +584,9 @@ func Load(cliPath string) (string, error) {
573584 if err := Cfg.validate(); err != nil {
574585 return "", fmt.Errorf("%s: %w", path, err)
575586 }
587+ if err := compileStructuredOutput(); err != nil {
588+ return "", fmt.Errorf("%s: %w", path, err)
589+ }
576590 return path, nil
577591 }
578592
@@ -79,6 +79,17 @@ type Config struct {
79 // System: the system prompt — what the agent is, and what it may do.79 // System: the system prompt — what the agent is, and what it may do.
80 System string `yaml:"system"`80 System string `yaml:"system"`
81 81
82+ // StructuredOutput: Go type declarations the answer must follow, as JSON
83+ // (internal/structured, ticket 80): `type` declarations and one `var`
84+ // naming the root. Empty = a free-text answer. Every answer is then
85+ // constrained by the server, so an agent that declares a tool cannot have
86+ // one — main refuses to start (see StructuredOutputConflict).
87+ StructuredOutput string `yaml:"structuredOutput"`
88+
89+ // OutputSchema: StructuredOutput compiled into a JSON Schema by Load, nil
90+ // when the section is empty. Never read from the file.
91+ OutputSchema map[string]any `yaml:"-"`
92+
82 // SkillsDir: directory of markdown procedures exposed by the `read_skill`93 // SkillsDir: directory of markdown procedures exposed by the `read_skill`
83 // tool. When it holds no *.md file, the tool is not declared at all.94 // tool. When it holds no *.md file, the tool is not declared at all.
84 SkillsDir string `yaml:"skillsDir"`95 SkillsDir string `yaml:"skillsDir"`
@@ -573,6 +584,9 @@ func Load(cliPath string) (string, error) {
573 if err := Cfg.validate(); err != nil {584 if err := Cfg.validate(); err != nil {
574 return "", fmt.Errorf("%s: %w", path, err)585 return "", fmt.Errorf("%s: %w", path, err)
575 }586 }
587+ if err := compileStructuredOutput(); err != nil {
588+ return "", fmt.Errorf("%s: %w", path, err)
589+ }
576 return path, nil590 return path, nil
577 }591 }
578 592
added internal/config/structured.go +49 -0
new file mode 100644
@@ -0,0 +1,49 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/structured"
8+)
9+
10+// compileStructuredOutput turns the loaded `structuredOutput` section into
11+// Cfg.OutputSchema, and appends the declarations to the system prompt
12+// (structured.Instructions: the server's grammar constrains the syntax, only
13+// the prompt tells the model what each field means). A declaration that
14+// cannot be translated is a config error, reported with its line in the
15+// section: better at start-up than as a failed answer in the middle of a
16+// conversation.
17+func compileStructuredOutput() error {
18+ Cfg.OutputSchema = nil
19+ if strings.TrimSpace(Cfg.StructuredOutput) == "" {
20+ return nil
21+ }
22+ schema, err := structured.Schema(Cfg.StructuredOutput)
23+ if err != nil {
24+ return err
25+ }
26+ Cfg.OutputSchema = schema
27+ Cfg.System = strings.TrimRight(Cfg.System, "\n") + "\n\n" + structured.Instructions(Cfg.StructuredOutput)
28+ return nil
29+}
30+
31+// StructuredOutputConflict returns the error main stops on when the agent
32+// has a `structuredOutput` AND declares tools, nil otherwise. The two cannot
33+// meet: a server constrains EVERY answer to the schema, so the model could
34+// never write a tool call — and llama.cpp refuses the request outright
35+// ("Cannot use custom grammar constraints with tools"). tools are the
36+// names of the tools the agent declares.
37+//
38+// if err := config.StructuredOutputConflict([]string{"bash"}); err != nil {
39+// // structuredOutput cannot be used with tools — this agent declares: bash (…)
40+// }
41+func StructuredOutputConflict(tools []string) error {
42+ if Cfg.OutputSchema == nil || len(tools) == 0 {
43+ return nil
44+ }
45+ return fmt.Errorf("structuredOutput cannot be used with tools — this agent declares: %s "+
46+ "(turn them off: bashTool: false, planTool: false, editTools: false, fetchContentTool: false, "+
47+ "no skillsDir with skills, no mmConfigs, no lsp servers, mcp disabled)",
48+ strings.Join(tools, ", "))
49+}
new file mode 100644
@@ -0,0 +1,49 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/structured"
8+)
9+
10+// compileStructuredOutput turns the loaded `structuredOutput` section into
11+// Cfg.OutputSchema, and appends the declarations to the system prompt
12+// (structured.Instructions: the server's grammar constrains the syntax, only
13+// the prompt tells the model what each field means). A declaration that
14+// cannot be translated is a config error, reported with its line in the
15+// section: better at start-up than as a failed answer in the middle of a
16+// conversation.
17+func compileStructuredOutput() error {
18+ Cfg.OutputSchema = nil
19+ if strings.TrimSpace(Cfg.StructuredOutput) == "" {
20+ return nil
21+ }
22+ schema, err := structured.Schema(Cfg.StructuredOutput)
23+ if err != nil {
24+ return err
25+ }
26+ Cfg.OutputSchema = schema
27+ Cfg.System = strings.TrimRight(Cfg.System, "\n") + "\n\n" + structured.Instructions(Cfg.StructuredOutput)
28+ return nil
29+}
30+
31+// StructuredOutputConflict returns the error main stops on when the agent
32+// has a `structuredOutput` AND declares tools, nil otherwise. The two cannot
33+// meet: a server constrains EVERY answer to the schema, so the model could
34+// never write a tool call — and llama.cpp refuses the request outright
35+// ("Cannot use custom grammar constraints with tools"). tools are the
36+// names of the tools the agent declares.
37+//
38+// if err := config.StructuredOutputConflict([]string{"bash"}); err != nil {
39+// // structuredOutput cannot be used with tools — this agent declares: bash (…)
40+// }
41+func StructuredOutputConflict(tools []string) error {
42+ if Cfg.OutputSchema == nil || len(tools) == 0 {
43+ return nil
44+ }
45+ return fmt.Errorf("structuredOutput cannot be used with tools — this agent declares: %s "+
46+ "(turn them off: bashTool: false, planTool: false, editTools: false, fetchContentTool: false, "+
47+ "no skillsDir with skills, no mmConfigs, no lsp servers, mcp disabled)",
48+ strings.Join(tools, ", "))
49+}
added internal/config/structured_test.go +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestStructuredOutputIsCompiledWhenTheFileLoads(t *testing.T) {
9+ err := loadYAML(t, "model: m\nstructuredOutput: |\n type Answer struct {\n Ok bool `json:\"ok\"`\n }\n var A = Answer{}\n")
10+ if err != nil {
11+ t.Fatal(err)
12+ }
13+ if Cfg.OutputSchema == nil || Cfg.OutputSchema["type"] != "object" {
14+ t.Errorf("OutputSchema = %v, want the compiled object schema", Cfg.OutputSchema)
15+ }
16+}
17+
18+// The model reads what each field means in its system prompt: the grammar
19+// only constrains the syntax.
20+func TestStructuredOutputIsExplainedInTheSystemPrompt(t *testing.T) {
21+ err := loadYAML(t, "model: m\nsystem: You answer about countries.\nstructuredOutput: |\n type C struct {\n Capital string `json:\"capital\"` // the capital CITY\n }\n var X C\n")
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ if !strings.HasPrefix(Cfg.System, "You answer about countries.\n\nOUTPUT FORMAT\n") || !strings.Contains(Cfg.System, "// the capital CITY") {
26+ t.Errorf("system prompt:\n%s", Cfg.System)
27+ }
28+}
29+
30+func TestNoStructuredOutputLeavesNoSchema(t *testing.T) {
31+ if err := loadYAML(t, "model: m\n"); err != nil {
32+ t.Fatal(err)
33+ }
34+ if Cfg.OutputSchema != nil {
35+ t.Errorf("OutputSchema = %v, want nil", Cfg.OutputSchema)
36+ }
37+}
38+
39+func TestAnInvalidDeclarationIsAConfigError(t *testing.T) {
40+ err := loadYAML(t, "model: m\nstructuredOutput: |\n type A struct {\n B Langage\n }\n var X A\n")
41+ if err == nil || !strings.Contains(err.Error(), `structuredOutput:2:5: type "Langage" is not defined`) {
42+ t.Errorf("Load = %v, want the undefined type with its line in the section", err)
43+ }
44+}
45+
46+func TestStructuredOutputRefusesDeclaredTools(t *testing.T) {
47+ if err := loadYAML(t, "model: m\nstructuredOutput: \"var X bool\"\n"); err != nil {
48+ t.Fatal(err)
49+ }
50+ if err := StructuredOutputConflict(nil); err != nil {
51+ t.Errorf("no tools: %v, want nil", err)
52+ }
53+ err := StructuredOutputConflict([]string{"bash", "plan"})
54+ if err == nil || !strings.Contains(err.Error(), "this agent declares: bash, plan") {
55+ t.Errorf("with tools: %v, want the list of tools", err)
56+ }
57+ Cfg.OutputSchema = nil
58+ if err := StructuredOutputConflict([]string{"bash"}); err != nil {
59+ t.Errorf("no structuredOutput: %v, want nil", err)
60+ }
61+}
new file mode 100644
@@ -0,0 +1,61 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestStructuredOutputIsCompiledWhenTheFileLoads(t *testing.T) {
9+ err := loadYAML(t, "model: m\nstructuredOutput: |\n type Answer struct {\n Ok bool `json:\"ok\"`\n }\n var A = Answer{}\n")
10+ if err != nil {
11+ t.Fatal(err)
12+ }
13+ if Cfg.OutputSchema == nil || Cfg.OutputSchema["type"] != "object" {
14+ t.Errorf("OutputSchema = %v, want the compiled object schema", Cfg.OutputSchema)
15+ }
16+}
17+
18+// The model reads what each field means in its system prompt: the grammar
19+// only constrains the syntax.
20+func TestStructuredOutputIsExplainedInTheSystemPrompt(t *testing.T) {
21+ err := loadYAML(t, "model: m\nsystem: You answer about countries.\nstructuredOutput: |\n type C struct {\n Capital string `json:\"capital\"` // the capital CITY\n }\n var X C\n")
22+ if err != nil {
23+ t.Fatal(err)
24+ }
25+ if !strings.HasPrefix(Cfg.System, "You answer about countries.\n\nOUTPUT FORMAT\n") || !strings.Contains(Cfg.System, "// the capital CITY") {
26+ t.Errorf("system prompt:\n%s", Cfg.System)
27+ }
28+}
29+
30+func TestNoStructuredOutputLeavesNoSchema(t *testing.T) {
31+ if err := loadYAML(t, "model: m\n"); err != nil {
32+ t.Fatal(err)
33+ }
34+ if Cfg.OutputSchema != nil {
35+ t.Errorf("OutputSchema = %v, want nil", Cfg.OutputSchema)
36+ }
37+}
38+
39+func TestAnInvalidDeclarationIsAConfigError(t *testing.T) {
40+ err := loadYAML(t, "model: m\nstructuredOutput: |\n type A struct {\n B Langage\n }\n var X A\n")
41+ if err == nil || !strings.Contains(err.Error(), `structuredOutput:2:5: type "Langage" is not defined`) {
42+ t.Errorf("Load = %v, want the undefined type with its line in the section", err)
43+ }
44+}
45+
46+func TestStructuredOutputRefusesDeclaredTools(t *testing.T) {
47+ if err := loadYAML(t, "model: m\nstructuredOutput: \"var X bool\"\n"); err != nil {
48+ t.Fatal(err)
49+ }
50+ if err := StructuredOutputConflict(nil); err != nil {
51+ t.Errorf("no tools: %v, want nil", err)
52+ }
53+ err := StructuredOutputConflict([]string{"bash", "plan"})
54+ if err == nil || !strings.Contains(err.Error(), "this agent declares: bash, plan") {
55+ t.Errorf("with tools: %v, want the list of tools", err)
56+ }
57+ Cfg.OutputSchema = nil
58+ if err := StructuredOutputConflict([]string{"bash"}); err != nil {
59+ t.Errorf("no structuredOutput: %v, want nil", err)
60+ }
61+}
modified internal/engine/engine.go +6 -0
@@ -355,6 +355,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
355355 ai.WithConfig(config.Cfg.Sampling),
356356 ai.WithUse(capture),
357357 }
358+ // structuredOutput (ticket 80): the server constrains the answer to the
359+ // schema, and Genkit checks it against the schema before handing it back.
360+ if config.Cfg.OutputSchema != nil {
361+ opts = append(opts, ai.WithOutputSchema(config.Cfg.OutputSchema))
362+ }
358363
359364 // The prefix is printed by the first token rather than up front, so the
360365 // spinner has a line to itself and vanishes without taking anything with it.
@@ -506,6 +511,7 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
506511 }
507512
508513 think.done()
514+ err = cutStructuredAnswer(resp, err)
509515
510516 // `full` stops at the last call to the model; its answer is appended after.
511517 if resp != nil && resp.Message != nil {
@@ -355,6 +355,11 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
355 ai.WithConfig(config.Cfg.Sampling),355 ai.WithConfig(config.Cfg.Sampling),
356 ai.WithUse(capture),356 ai.WithUse(capture),
357 }357 }
358+ // structuredOutput (ticket 80): the server constrains the answer to the
359+ // schema, and Genkit checks it against the schema before handing it back.
360+ if config.Cfg.OutputSchema != nil {
361+ opts = append(opts, ai.WithOutputSchema(config.Cfg.OutputSchema))
362+ }
358 363
359 // The prefix is printed by the first token rather than up front, so the364 // The prefix is printed by the first token rather than up front, so the
360 // spinner has a line to itself and vanishes without taking anything with it.365 // spinner has a line to itself and vanishes without taking anything with it.
@@ -506,6 +511,7 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
506 }511 }
507 512
508 think.done()513 think.done()
514+ err = cutStructuredAnswer(resp, err)
509 515
510 // `full` stops at the last call to the model; its answer is appended after.516 // `full` stops at the last call to the model; its answer is appended after.
511 if resp != nil && resp.Message != nil {517 if resp != nil && resp.Message != nil {
modified internal/engine/openai_compat.go +14 -1
@@ -199,11 +199,24 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str
199199 // the config to the SDK struct first and loses them. Ticket 76.
200200 genkit.RegisterAction(g, client.DefineModel(p.name, b.Model, ai.ModelOptions{ //nolint:staticcheck // see above: the only constructor that keeps provider-specific sampling keys
201201 Label: b.Model,
202- Supports: &compat_oai.Multimodal, // enables Tools, Multiturn, SystemRole…
202+ Supports: modelSupports(),
203203 }).(*ai.ModelAction))
204204 return g, p.name + "/" + b.Model, nil
205205 }
206206
207+// modelSupports is what the model declares to Genkit: the plugin's
208+// Multimodal set (Tools, Multiturn, SystemRole…) plus native constrained
209+// output when no tool is offered. Without that declaration, Genkit does not
210+// send a `structuredOutput` schema as `response_format` — it only pastes it
211+// into the prompt and hopes (ticket 80). "No tools" because a server that
212+// constrains every answer to a schema leaves no room for a tool call;
213+// config.StructuredOutputConflict keeps the two apart anyway.
214+func modelSupports() *ai.ModelSupports {
215+ supports := compat_oai.Multimodal
216+ supports.Constrained = ai.ConstrainedSupportNoTools
217+ return &supports
218+}
219+
207220 // middlewares is the HTTP client's chain, outermost first. The trace comes
208221 // last, next to the network: it records the request as it leaves, after the
209222 // others have added their fields, and the response before they read it.
@@ -199,11 +199,24 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str
199 // the config to the SDK struct first and loses them. Ticket 76.199 // the config to the SDK struct first and loses them. Ticket 76.
200 genkit.RegisterAction(g, client.DefineModel(p.name, b.Model, ai.ModelOptions{ //nolint:staticcheck // see above: the only constructor that keeps provider-specific sampling keys200 genkit.RegisterAction(g, client.DefineModel(p.name, b.Model, ai.ModelOptions{ //nolint:staticcheck // see above: the only constructor that keeps provider-specific sampling keys
201 Label: b.Model,201 Label: b.Model,
202- Supports: &compat_oai.Multimodal, // enables Tools, Multiturn, SystemRole…202+ Supports: modelSupports(),
203 }).(*ai.ModelAction))203 }).(*ai.ModelAction))
204 return g, p.name + "/" + b.Model, nil204 return g, p.name + "/" + b.Model, nil
205 }205 }
206 206
207+// modelSupports is what the model declares to Genkit: the plugin's
208+// Multimodal set (Tools, Multiturn, SystemRole…) plus native constrained
209+// output when no tool is offered. Without that declaration, Genkit does not
210+// send a `structuredOutput` schema as `response_format` — it only pastes it
211+// into the prompt and hopes (ticket 80). "No tools" because a server that
212+// constrains every answer to a schema leaves no room for a tool call;
213+// config.StructuredOutputConflict keeps the two apart anyway.
214+func modelSupports() *ai.ModelSupports {
215+ supports := compat_oai.Multimodal
216+ supports.Constrained = ai.ConstrainedSupportNoTools
217+ return &supports
218+}
219+
207 // middlewares is the HTTP client's chain, outermost first. The trace comes220 // middlewares is the HTTP client's chain, outermost first. The trace comes
208 // last, next to the network: it records the request as it leaves, after the221 // last, next to the network: it records the request as it leaves, after the
209 // others have added their fields, and the response before they read it.222 // others have added their fields, and the response before they read it.
added internal/engine/structured.go +24 -0
new file mode 100644
@@ -0,0 +1,24 @@
1+package engine
2+
3+import (
4+ "fmt"
5+
6+ "github.com/firebase/genkit/go/ai"
7+
8+ "mm/internal/config"
9+)
10+
11+// cutStructuredAnswer names the usual reason a structured answer fails:
12+// max_tokens reached before the JSON was closed. Genkit only says "data is
13+// not valid JSON: unexpected end of JSON input", which points at the model's
14+// syntax when the fix is a setting — or a bounded list in the declarations:
15+// a small model can repeat array items until the limit (Qwen3.5-4B listed
16+// French regional languages for 4096 tokens, ticket 80). Any other error is
17+// returned unchanged.
18+func cutStructuredAnswer(resp *ai.ModelResponse, err error) error {
19+ if err == nil || config.Cfg.OutputSchema == nil || resp == nil || resp.FinishReason != ai.FinishReasonLength {
20+ return err
21+ }
22+ return fmt.Errorf("the answer was cut off by max_tokens before its JSON was complete — raise sampling.max_tokens, "+
23+ "or bound the lists in structuredOutput's comments (e.g. \"at most 5\"): %w", err)
24+}
new file mode 100644
@@ -0,0 +1,24 @@
1+package engine
2+
3+import (
4+ "fmt"
5+
6+ "github.com/firebase/genkit/go/ai"
7+
8+ "mm/internal/config"
9+)
10+
11+// cutStructuredAnswer names the usual reason a structured answer fails:
12+// max_tokens reached before the JSON was closed. Genkit only says "data is
13+// not valid JSON: unexpected end of JSON input", which points at the model's
14+// syntax when the fix is a setting — or a bounded list in the declarations:
15+// a small model can repeat array items until the limit (Qwen3.5-4B listed
16+// French regional languages for 4096 tokens, ticket 80). Any other error is
17+// returned unchanged.
18+func cutStructuredAnswer(resp *ai.ModelResponse, err error) error {
19+ if err == nil || config.Cfg.OutputSchema == nil || resp == nil || resp.FinishReason != ai.FinishReasonLength {
20+ return err
21+ }
22+ return fmt.Errorf("the answer was cut off by max_tokens before its JSON was complete — raise sampling.max_tokens, "+
23+ "or bound the lists in structuredOutput's comments (e.g. \"at most 5\"): %w", err)
24+}
added internal/engine/structured_test.go +151 -0
new file mode 100644
@@ -0,0 +1,151 @@
1+package engine
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strconv"
10+ "strings"
11+ "testing"
12+
13+ "github.com/firebase/genkit/go/ai"
14+
15+ "mm/internal/config"
16+ "mm/internal/structured"
17+)
18+
19+// withOutputSchema compiles src as config.Cfg.OutputSchema for one test.
20+func withOutputSchema(t *testing.T, src string) {
21+ t.Helper()
22+ schema, err := structured.Schema(src)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ old := config.Cfg.OutputSchema
27+ config.Cfg.OutputSchema = schema
28+ t.Cleanup(func() { config.Cfg.OutputSchema = old })
29+}
30+
31+// answering streams content as the model's whole answer.
32+func answering(content string) func(http.ResponseWriter) {
33+ return func(w http.ResponseWriter) {
34+ fmt.Fprint(w, sseChunkLine(`{"role":"assistant","content":`+strconv.Quote(content)+`}`, ""))
35+ fmt.Fprint(w, sseChunkLine(`{}`, "stop"))
36+ fmt.Fprint(w, "data: [DONE]\n\n")
37+ }
38+}
39+
40+const countriesSource = "type Country struct {\n Name string `json:\"name\"`\n Languages []string `json:\"languages\"`\n}\nvar Countries = []Country{}"
41+
42+// The schema reaches the server as a strict json_schema response_format —
43+// natively, not pasted into the prompt — and a conforming answer comes back
44+// unchanged.
45+func TestStructuredOutputIsSentAsResponseFormat(t *testing.T) {
46+ withOutputSchema(t, countriesSource)
47+ answer := `[{"name":"France","languages":["French"]}]`
48+ srv, bodies := recordingLlama(t, answering(answer))
49+ e := engineOn(t, srv.URL+"/v1")
50+
51+ resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("France?")}, nil)
52+ if err != nil {
53+ t.Fatalf("Generate: %v", err)
54+ }
55+ if resp.Text() != answer {
56+ t.Errorf("answer %q, want %q", resp.Text(), answer)
57+ }
58+ got := bodies()
59+ if len(got) != 1 {
60+ t.Fatalf("%d request(s), want 1", len(got))
61+ }
62+ format, _ := got[0]["response_format"].(map[string]any)
63+ spec, _ := format["json_schema"].(map[string]any)
64+ schema, _ := spec["schema"].(map[string]any)
65+ if format["type"] != "json_schema" || spec["strict"] != true || schema["type"] != "array" {
66+ t.Errorf("response_format = %v, want a strict json_schema of an array", got[0]["response_format"])
67+ }
68+ messages := fmt.Sprint(got[0]["messages"])
69+ if strings.Contains(messages, "conform to the following schema") {
70+ t.Errorf("the schema was pasted into the prompt: %s", messages)
71+ }
72+}
73+
74+// An answer that does not follow the schema fails the turn, with Genkit's
75+// reason, instead of passing for a valid one.
76+func TestAnAnswerOutsideTheSchemaIsAnError(t *testing.T) {
77+ withOutputSchema(t, countriesSource)
78+ srv, _ := recordingLlama(t, answering(`[{"name":"France","languages":"French"}]`))
79+ e := engineOn(t, srv.URL+"/v1")
80+
81+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("France?")}, nil)
82+ if err == nil || !strings.Contains(err.Error(), "schema") {
83+ t.Errorf("Generate = %v, want a schema error", err)
84+ }
85+}
86+
87+// Without structuredOutput, no response_format: the free-text agents are
88+// untouched.
89+func TestNoStructuredOutputSendsNoResponseFormat(t *testing.T) {
90+ srv, bodies := recordingLlama(t, goodAnswer)
91+ e := engineOn(t, srv.URL+"/v1")
92+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil {
93+ t.Fatalf("Generate: %v", err)
94+ }
95+ if rf, set := bodies()[0]["response_format"]; set {
96+ t.Errorf("response_format = %v, want none", rf)
97+ }
98+}
99+
100+// Types that reference each other go through $defs: Genkit's validator must
101+// resolve them, or every nested answer would be refused.
102+func TestReferencedTypesAreValidated(t *testing.T) {
103+ withOutputSchema(t, "type Language struct {\n Name string `json:\"name\"`\n}\ntype Country struct {\n Name string `json:\"name\"`\n Languages []Language `json:\"languages\"`\n Capital *string `json:\"capital\"`\n}\nvar Countries = []Country{}")
104+ cases := map[string]bool{
105+ `[{"name":"France","languages":[{"name":"French"}],"capital":"Paris"}]`: true,
106+ `[{"name":"Atlantis","languages":[],"capital":null}]`: true,
107+ `[{"name":"France","languages":[{"label":"French"}],"capital":null}]`: false,
108+ }
109+ for answer, valid := range cases {
110+ srv, _ := recordingLlama(t, answering(answer))
111+ _, _, err := engineOn(t, srv.URL+"/v1").Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("?")}, nil)
112+ if (err == nil) != valid {
113+ t.Errorf("%s: err = %v, want valid = %v", answer, err, valid)
114+ }
115+ }
116+}
117+
118+// The properties reach the server in declaration order, not sorted: a
119+// grammar makes the model write them in that order (structured.Properties).
120+func TestPropertiesReachTheServerInDeclarationOrder(t *testing.T) {
121+ withOutputSchema(t, "type Country struct {\n Name string `json:\"name\"`\n Capital string `json:\"capital\"`\n}\nvar C Country")
122+ var raw string
123+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124+ b, _ := io.ReadAll(r.Body)
125+ raw = string(b)
126+ w.Header().Set("Content-Type", "text/event-stream")
127+ answering(`{"name":"France","capital":"Paris"}`)(w)
128+ }))
129+ t.Cleanup(srv.Close)
130+ if _, _, err := engineOn(t, srv.URL+"/v1").Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("?")}, nil); err != nil {
131+ t.Fatalf("Generate: %v", err)
132+ }
133+ name, capital := strings.Index(raw, `"name":{`), strings.Index(raw, `"capital":{`)
134+ if name < 0 || capital < 0 || name > capital {
135+ t.Errorf("name at %d, capital at %d in the request, want name first:\n%s", name, capital, raw)
136+ }
137+}
138+
139+// An answer cut by max_tokens says so, rather than only "invalid JSON".
140+func TestACutStructuredAnswerNamesMaxTokens(t *testing.T) {
141+ withOutputSchema(t, "var Names []string")
142+ srv, _ := recordingLlama(t, func(w http.ResponseWriter) {
143+ fmt.Fprint(w, sseChunkLine(`{"role":"assistant","content":"[\"Ada\",\"Gra"}`, ""))
144+ fmt.Fprint(w, sseChunkLine(`{}`, "length"))
145+ fmt.Fprint(w, "data: [DONE]\n\n")
146+ })
147+ _, _, err := engineOn(t, srv.URL+"/v1").Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("?")}, nil)
148+ if err == nil || !strings.Contains(err.Error(), "cut off by max_tokens") {
149+ t.Errorf("Generate = %v, want the max_tokens explanation", err)
150+ }
151+}
new file mode 100644
@@ -0,0 +1,151 @@
1+package engine
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "strconv"
10+ "strings"
11+ "testing"
12+
13+ "github.com/firebase/genkit/go/ai"
14+
15+ "mm/internal/config"
16+ "mm/internal/structured"
17+)
18+
19+// withOutputSchema compiles src as config.Cfg.OutputSchema for one test.
20+func withOutputSchema(t *testing.T, src string) {
21+ t.Helper()
22+ schema, err := structured.Schema(src)
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ old := config.Cfg.OutputSchema
27+ config.Cfg.OutputSchema = schema
28+ t.Cleanup(func() { config.Cfg.OutputSchema = old })
29+}
30+
31+// answering streams content as the model's whole answer.
32+func answering(content string) func(http.ResponseWriter) {
33+ return func(w http.ResponseWriter) {
34+ fmt.Fprint(w, sseChunkLine(`{"role":"assistant","content":`+strconv.Quote(content)+`}`, ""))
35+ fmt.Fprint(w, sseChunkLine(`{}`, "stop"))
36+ fmt.Fprint(w, "data: [DONE]\n\n")
37+ }
38+}
39+
40+const countriesSource = "type Country struct {\n Name string `json:\"name\"`\n Languages []string `json:\"languages\"`\n}\nvar Countries = []Country{}"
41+
42+// The schema reaches the server as a strict json_schema response_format —
43+// natively, not pasted into the prompt — and a conforming answer comes back
44+// unchanged.
45+func TestStructuredOutputIsSentAsResponseFormat(t *testing.T) {
46+ withOutputSchema(t, countriesSource)
47+ answer := `[{"name":"France","languages":["French"]}]`
48+ srv, bodies := recordingLlama(t, answering(answer))
49+ e := engineOn(t, srv.URL+"/v1")
50+
51+ resp, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("France?")}, nil)
52+ if err != nil {
53+ t.Fatalf("Generate: %v", err)
54+ }
55+ if resp.Text() != answer {
56+ t.Errorf("answer %q, want %q", resp.Text(), answer)
57+ }
58+ got := bodies()
59+ if len(got) != 1 {
60+ t.Fatalf("%d request(s), want 1", len(got))
61+ }
62+ format, _ := got[0]["response_format"].(map[string]any)
63+ spec, _ := format["json_schema"].(map[string]any)
64+ schema, _ := spec["schema"].(map[string]any)
65+ if format["type"] != "json_schema" || spec["strict"] != true || schema["type"] != "array" {
66+ t.Errorf("response_format = %v, want a strict json_schema of an array", got[0]["response_format"])
67+ }
68+ messages := fmt.Sprint(got[0]["messages"])
69+ if strings.Contains(messages, "conform to the following schema") {
70+ t.Errorf("the schema was pasted into the prompt: %s", messages)
71+ }
72+}
73+
74+// An answer that does not follow the schema fails the turn, with Genkit's
75+// reason, instead of passing for a valid one.
76+func TestAnAnswerOutsideTheSchemaIsAnError(t *testing.T) {
77+ withOutputSchema(t, countriesSource)
78+ srv, _ := recordingLlama(t, answering(`[{"name":"France","languages":"French"}]`))
79+ e := engineOn(t, srv.URL+"/v1")
80+
81+ _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("France?")}, nil)
82+ if err == nil || !strings.Contains(err.Error(), "schema") {
83+ t.Errorf("Generate = %v, want a schema error", err)
84+ }
85+}
86+
87+// Without structuredOutput, no response_format: the free-text agents are
88+// untouched.
89+func TestNoStructuredOutputSendsNoResponseFormat(t *testing.T) {
90+ srv, bodies := recordingLlama(t, goodAnswer)
91+ e := engineOn(t, srv.URL+"/v1")
92+ if _, _, err := e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil {
93+ t.Fatalf("Generate: %v", err)
94+ }
95+ if rf, set := bodies()[0]["response_format"]; set {
96+ t.Errorf("response_format = %v, want none", rf)
97+ }
98+}
99+
100+// Types that reference each other go through $defs: Genkit's validator must
101+// resolve them, or every nested answer would be refused.
102+func TestReferencedTypesAreValidated(t *testing.T) {
103+ withOutputSchema(t, "type Language struct {\n Name string `json:\"name\"`\n}\ntype Country struct {\n Name string `json:\"name\"`\n Languages []Language `json:\"languages\"`\n Capital *string `json:\"capital\"`\n}\nvar Countries = []Country{}")
104+ cases := map[string]bool{
105+ `[{"name":"France","languages":[{"name":"French"}],"capital":"Paris"}]`: true,
106+ `[{"name":"Atlantis","languages":[],"capital":null}]`: true,
107+ `[{"name":"France","languages":[{"label":"French"}],"capital":null}]`: false,
108+ }
109+ for answer, valid := range cases {
110+ srv, _ := recordingLlama(t, answering(answer))
111+ _, _, err := engineOn(t, srv.URL+"/v1").Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("?")}, nil)
112+ if (err == nil) != valid {
113+ t.Errorf("%s: err = %v, want valid = %v", answer, err, valid)
114+ }
115+ }
116+}
117+
118+// The properties reach the server in declaration order, not sorted: a
119+// grammar makes the model write them in that order (structured.Properties).
120+func TestPropertiesReachTheServerInDeclarationOrder(t *testing.T) {
121+ withOutputSchema(t, "type Country struct {\n Name string `json:\"name\"`\n Capital string `json:\"capital\"`\n}\nvar C Country")
122+ var raw string
123+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124+ b, _ := io.ReadAll(r.Body)
125+ raw = string(b)
126+ w.Header().Set("Content-Type", "text/event-stream")
127+ answering(`{"name":"France","capital":"Paris"}`)(w)
128+ }))
129+ t.Cleanup(srv.Close)
130+ if _, _, err := engineOn(t, srv.URL+"/v1").Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("?")}, nil); err != nil {
131+ t.Fatalf("Generate: %v", err)
132+ }
133+ name, capital := strings.Index(raw, `"name":{`), strings.Index(raw, `"capital":{`)
134+ if name < 0 || capital < 0 || name > capital {
135+ t.Errorf("name at %d, capital at %d in the request, want name first:\n%s", name, capital, raw)
136+ }
137+}
138+
139+// An answer cut by max_tokens says so, rather than only "invalid JSON".
140+func TestACutStructuredAnswerNamesMaxTokens(t *testing.T) {
141+ withOutputSchema(t, "var Names []string")
142+ srv, _ := recordingLlama(t, func(w http.ResponseWriter) {
143+ fmt.Fprint(w, sseChunkLine(`{"role":"assistant","content":"[\"Ada\",\"Gra"}`, ""))
144+ fmt.Fprint(w, sseChunkLine(`{}`, "length"))
145+ fmt.Fprint(w, "data: [DONE]\n\n")
146+ })
147+ _, _, err := engineOn(t, srv.URL+"/v1").Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("?")}, nil)
148+ if err == nil || !strings.Contains(err.Error(), "cut off by max_tokens") {
149+ t.Errorf("Generate = %v, want the max_tokens explanation", err)
150+ }
151+}
added internal/structured/example_test.go +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+package structured_test
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+
7+ "mm/internal/structured"
8+)
9+
10+func ExampleSchema() {
11+ schema, err := structured.Schema("type Answer struct {\n Ok bool `json:\"ok\"` // true when it worked\n}\nvar A = Answer{}")
12+ if err != nil {
13+ fmt.Println(err)
14+ return
15+ }
16+ out, _ := json.Marshal(schema)
17+ fmt.Println(string(out))
18+ // Output: {"additionalProperties":false,"properties":{"ok":{"description":"true when it worked","type":"boolean"}},"required":["ok"],"type":"object"}
19+}
new file mode 100644
@@ -0,0 +1,19 @@
1+package structured_test
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+
7+ "mm/internal/structured"
8+)
9+
10+func ExampleSchema() {
11+ schema, err := structured.Schema("type Answer struct {\n Ok bool `json:\"ok\"` // true when it worked\n}\nvar A = Answer{}")
12+ if err != nil {
13+ fmt.Println(err)
14+ return
15+ }
16+ out, _ := json.Marshal(schema)
17+ fmt.Println(string(out))
18+ // Output: {"additionalProperties":false,"properties":{"ok":{"description":"true when it worked","type":"boolean"}},"required":["ok"],"type":"object"}
19+}
added internal/structured/ordered.go +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+package structured
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+)
7+
8+// Properties is a struct's `properties`, kept in declaration order.
9+//
10+// The order is not cosmetic. A server that constrains generation with a
11+// grammar (llama.cpp) makes the model write the keys in the order the schema
12+// lists them, and a model fills a field from what it has already written: with
13+// a plain map — which encoding/json sorts — `capital` came before `name`, and
14+// a 4B model filled it with the country's name (measured on Qwen3.5-4B, ticket
15+// 80). Declaring `Name` first is how the author says "decide this first".
16+//
17+// It marshals as a JSON object; Get reads a key back.
18+type Properties struct {
19+ keys []string
20+ values map[string]map[string]any
21+}
22+
23+// set adds key, or replaces its schema without moving it.
24+func (p *Properties) set(key string, schema map[string]any) {
25+ if p.values == nil {
26+ p.values = map[string]map[string]any{}
27+ }
28+ if _, seen := p.values[key]; !seen {
29+ p.keys = append(p.keys, key)
30+ }
31+ p.values[key] = schema
32+}
33+
34+// Get returns the schema of one property, nil when there is no such key.
35+func (p *Properties) Get(key string) map[string]any {
36+ return p.values[key]
37+}
38+
39+// Keys returns the property names in declaration order.
40+func (p *Properties) Keys() []string {
41+ return append([]string(nil), p.keys...)
42+}
43+
44+// MarshalJSON writes the properties as an object, in declaration order.
45+func (p *Properties) MarshalJSON() ([]byte, error) {
46+ var buf bytes.Buffer
47+ buf.WriteByte('{')
48+ for i, key := range p.keys {
49+ if i > 0 {
50+ buf.WriteByte(',')
51+ }
52+ name, err := json.Marshal(key)
53+ if err != nil {
54+ return nil, err
55+ }
56+ value, err := json.Marshal(p.values[key])
57+ if err != nil {
58+ return nil, err
59+ }
60+ buf.Write(name)
61+ buf.WriteByte(':')
62+ buf.Write(value)
63+ }
64+ buf.WriteByte('}')
65+ return buf.Bytes(), nil
66+}
new file mode 100644
@@ -0,0 +1,66 @@
1+package structured
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+)
7+
8+// Properties is a struct's `properties`, kept in declaration order.
9+//
10+// The order is not cosmetic. A server that constrains generation with a
11+// grammar (llama.cpp) makes the model write the keys in the order the schema
12+// lists them, and a model fills a field from what it has already written: with
13+// a plain map — which encoding/json sorts — `capital` came before `name`, and
14+// a 4B model filled it with the country's name (measured on Qwen3.5-4B, ticket
15+// 80). Declaring `Name` first is how the author says "decide this first".
16+//
17+// It marshals as a JSON object; Get reads a key back.
18+type Properties struct {
19+ keys []string
20+ values map[string]map[string]any
21+}
22+
23+// set adds key, or replaces its schema without moving it.
24+func (p *Properties) set(key string, schema map[string]any) {
25+ if p.values == nil {
26+ p.values = map[string]map[string]any{}
27+ }
28+ if _, seen := p.values[key]; !seen {
29+ p.keys = append(p.keys, key)
30+ }
31+ p.values[key] = schema
32+}
33+
34+// Get returns the schema of one property, nil when there is no such key.
35+func (p *Properties) Get(key string) map[string]any {
36+ return p.values[key]
37+}
38+
39+// Keys returns the property names in declaration order.
40+func (p *Properties) Keys() []string {
41+ return append([]string(nil), p.keys...)
42+}
43+
44+// MarshalJSON writes the properties as an object, in declaration order.
45+func (p *Properties) MarshalJSON() ([]byte, error) {
46+ var buf bytes.Buffer
47+ buf.WriteByte('{')
48+ for i, key := range p.keys {
49+ if i > 0 {
50+ buf.WriteByte(',')
51+ }
52+ name, err := json.Marshal(key)
53+ if err != nil {
54+ return nil, err
55+ }
56+ value, err := json.Marshal(p.values[key])
57+ if err != nil {
58+ return nil, err
59+ }
60+ buf.Write(name)
61+ buf.WriteByte(':')
62+ buf.Write(value)
63+ }
64+ buf.WriteByte('}')
65+ return buf.Bytes(), nil
66+}
added internal/structured/structured.go +267 -0
new file mode 100644
@@ -0,0 +1,267 @@
1+// Package structured turns the Go type declarations of a config's
2+// `structuredOutput` section into the JSON Schema the model's answer must
3+// follow (ticket 80).
4+//
5+// No Go type is created at runtime — Go cannot compile code while it runs.
6+// The section is read as SOURCE: go/parser builds its syntax tree, and the
7+// tree is translated, declaration by declaration, into a JSON Schema:
8+//
9+// type Language struct {
10+// Name string `json:"name"` // language name, in English
11+// }
12+// type Country struct {
13+// Name string `json:"name"`
14+// Languages []Language `json:"languages"`
15+// }
16+// var Countries = []Country{}
17+//
18+// Every `type` is a definition, the one `var` names the answer's root, a
19+// field's key is its `json` tag (its Go name when there is none) and its `//`
20+// comment becomes a description — the model reads it. See types.go for the
21+// mapping of each Go type.
22+package structured
23+
24+import (
25+ "fmt"
26+ "go/ast"
27+ "go/parser"
28+ "go/token"
29+ "strings"
30+)
31+
32+// sourceName is the file name errors carry: the config key, so that
33+// "structuredOutput:4:15: …" points at the right place in the YAML block.
34+const sourceName = "structuredOutput"
35+
36+// header makes the section a Go file: the parser demands a package clause.
37+// It takes one line, which pos() takes back off every error position.
38+const header = "package schema\n"
39+
40+// Schema compiles the Go declarations in src into a JSON Schema.
41+//
42+// src holds `type` declarations and exactly one `var` whose type is the
43+// answer's root. An error names the line and column, counted from the first
44+// line of src, of what could not be translated.
45+//
46+// Example:
47+//
48+// schema, err := structured.Schema("type Answer struct {\n Ok bool `json:\"ok\"`\n}\nvar A = Answer{}")
49+// // schema: {"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"],"additionalProperties":false}
50+func Schema(src string) (map[string]any, error) {
51+ fset := token.NewFileSet()
52+ file, err := parser.ParseFile(fset, sourceName, header+src, parser.ParseComments)
53+ if err != nil {
54+ return nil, fmt.Errorf("%s", shiftLines(err.Error()))
55+ }
56+ c := &compiler{fset: fset, types: map[string]*ast.TypeSpec{}, used: map[string]bool{}}
57+ root, err := c.collect(file)
58+ if err != nil {
59+ return nil, err
60+ }
61+ return c.document(root)
62+}
63+
64+// compiler holds what one Schema call learned from the source.
65+type compiler struct {
66+ fset *token.FileSet
67+ types map[string]*ast.TypeSpec // declared types, by name
68+ order []string // declaration order, for stable $defs
69+ used map[string]bool // types reached from the root
70+}
71+
72+// errorf builds an error located at node, in the source's own line numbers.
73+func (c *compiler) errorf(node ast.Node, format string, args ...any) error {
74+ p := c.fset.Position(node.Pos())
75+ return fmt.Errorf("%s:%d:%d: %s", sourceName, p.Line-1, p.Column, fmt.Sprintf(format, args...))
76+}
77+
78+// collect records every type declaration and returns the root variable.
79+// Anything that is neither a type nor the one var is refused: the section
80+// describes data, it does not run.
81+func (c *compiler) collect(file *ast.File) (*ast.ValueSpec, error) {
82+ var root *ast.ValueSpec
83+ for _, decl := range file.Decls {
84+ gen, ok := decl.(*ast.GenDecl)
85+ if !ok || (gen.Tok != token.TYPE && gen.Tok != token.VAR) {
86+ return nil, c.errorf(decl, "only type and var declarations are allowed")
87+ }
88+ for _, spec := range gen.Specs {
89+ var err error
90+ if gen.Tok == token.TYPE {
91+ err = c.addType(spec.(*ast.TypeSpec), gen)
92+ } else {
93+ root, err = c.setRoot(root, spec.(*ast.ValueSpec), gen)
94+ }
95+ if err != nil {
96+ return nil, err
97+ }
98+ }
99+ }
100+ if root == nil {
101+ return nil, fmt.Errorf("%s: no var declaration: add one to name the answer, e.g. var Countries = []Country{}", sourceName)
102+ }
103+ return root, nil
104+}
105+
106+// addType records one type declaration. A lone declaration's comment sits
107+// on the GenDecl, a grouped one's on the spec: both are kept as its doc.
108+func (c *compiler) addType(spec *ast.TypeSpec, gen *ast.GenDecl) error {
109+ name := spec.Name.Name
110+ switch {
111+ case spec.TypeParams != nil:
112+ return c.errorf(spec, "type %s: generic types are not supported", name)
113+ case spec.Assign.IsValid():
114+ return c.errorf(spec, "type %s: aliases are not supported, declare the type", name)
115+ case c.types[name] != nil:
116+ return c.errorf(spec, "type %s is declared twice", name)
117+ }
118+ if spec.Doc == nil && len(gen.Specs) == 1 {
119+ spec.Doc = gen.Doc
120+ }
121+ c.types[name] = spec
122+ c.order = append(c.order, name)
123+ return nil
124+}
125+
126+// setRoot accepts the one var declaration, `var X T` or `var X = T{}`.
127+func (c *compiler) setRoot(previous, spec *ast.ValueSpec, gen *ast.GenDecl) (*ast.ValueSpec, error) {
128+ if previous != nil || len(spec.Names) != 1 {
129+ return nil, c.errorf(spec, "only one var may name the answer")
130+ }
131+ if spec.Type == nil && rootLiteral(spec) == nil {
132+ return nil, c.errorf(spec, "var %s: give it a type, e.g. var %s = []Country{}", spec.Names[0].Name, spec.Names[0].Name)
133+ }
134+ if spec.Doc == nil {
135+ spec.Doc = gen.Doc
136+ }
137+ return spec, nil
138+}
139+
140+// rootLiteral returns the composite literal of `var X = T{}`, or nil.
141+func rootLiteral(spec *ast.ValueSpec) *ast.CompositeLit {
142+ if len(spec.Values) != 1 {
143+ return nil
144+ }
145+ lit, _ := spec.Values[0].(*ast.CompositeLit)
146+ if lit == nil || lit.Type == nil {
147+ return nil
148+ }
149+ return lit
150+}
151+
152+// document builds the whole schema: the root's own schema at the top level —
153+// a struct root is inlined rather than referenced, the plainest shape for a
154+// server's grammar converter — plus `$defs` for every type the root reaches.
155+func (c *compiler) document(root *ast.ValueSpec) (map[string]any, error) {
156+ rootType := root.Type
157+ if rootType == nil {
158+ rootType = rootLiteral(root).Type
159+ }
160+ schema, err := c.rootSchema(rootType)
161+ if err != nil {
162+ return nil, err
163+ }
164+ if d := describe(root.Doc, root.Comment); d != "" {
165+ schema["description"] = d
166+ }
167+ defs, err := c.definitions()
168+ if err != nil {
169+ return nil, err
170+ }
171+ if len(defs) > 0 {
172+ schema["$defs"] = defs
173+ }
174+ return schema, nil
175+}
176+
177+// rootSchema inlines a declared root type, any other type is translated as is.
178+func (c *compiler) rootSchema(expr ast.Expr) (map[string]any, error) {
179+ if ident, ok := expr.(*ast.Ident); ok && c.types[ident.Name] != nil {
180+ return c.declared(c.types[ident.Name])
181+ }
182+ return c.typeSchema(expr)
183+}
184+
185+// definitions translates every type the root reaches, in declaration order.
186+// Translating one may reach others, hence the loop until nothing new is used.
187+func (c *compiler) definitions() (map[string]any, error) {
188+ defs := map[string]any{}
189+ for done := false; !done; {
190+ done = true
191+ for _, name := range c.order {
192+ if !c.used[name] || defs[name] != nil {
193+ continue
194+ }
195+ schema, err := c.declared(c.types[name])
196+ if err != nil {
197+ return nil, err
198+ }
199+ defs[name] = schema
200+ done = false
201+ }
202+ }
203+ return defs, nil
204+}
205+
206+// declared translates one type declaration, with its doc as description.
207+func (c *compiler) declared(spec *ast.TypeSpec) (map[string]any, error) {
208+ schema, err := c.typeSchema(spec.Type)
209+ if err != nil {
210+ return nil, err
211+ }
212+ if d := describe(spec.Doc, spec.Comment); d != "" {
213+ schema["description"] = d
214+ }
215+ return schema, nil
216+}
217+
218+// describe joins the comments written above and beside a declaration.
219+func describe(groups ...*ast.CommentGroup) string {
220+ var parts []string
221+ for _, g := range groups {
222+ if text := strings.TrimSpace(g.Text()); text != "" {
223+ parts = append(parts, strings.Join(strings.Fields(text), " "))
224+ }
225+ }
226+ return strings.Join(parts, " ")
227+}
228+
229+// shiftLines takes the header's line back off a parser error, so that its
230+// positions count from the first line of the YAML block. A parser error
231+// reads "structuredOutput:5:3: expected …" (several joined by newlines).
232+func shiftLines(msg string) string {
233+ lines := strings.Split(msg, "\n")
234+ for i, line := range lines {
235+ var row, col int
236+ var rest string
237+ prefix := sourceName + ":"
238+ if !strings.HasPrefix(line, prefix) {
239+ continue
240+ }
241+ if n, _ := fmt.Sscanf(line[len(prefix):], "%d:%d:", &row, &col); n == 2 {
242+ _, rest, _ = strings.Cut(line[len(prefix):], ": ")
243+ lines[i] = fmt.Sprintf("%s%d:%d: %s", prefix, row-1, col, rest)
244+ }
245+ }
246+ return strings.Join(lines, "\n")
247+}
248+
249+// Instructions is the paragraph appended to the system prompt of an agent
250+// with a structuredOutput: the declarations themselves, comments included.
251+//
252+// The schema alone does not reach the model. A server that constrains the
253+// generation (llama.cpp) turns it into a grammar — syntax only — and Genkit
254+// pastes nothing into the prompt when the constraint is native. The model
255+// then knows the keys and nothing else: asked for capitals, Qwen3.5-4B wrote
256+// the country's name in `capital` every time, its description unread
257+// (ticket 80). The Go source is shorter than the JSON Schema and reads
258+// naturally to a model trained on code.
259+//
260+// system += "\n\n" + structured.Instructions(src)
261+func Instructions(src string) string {
262+ return "OUTPUT FORMAT\n" +
263+ "Answer with JSON only — no prose, no Markdown fence. Its shape is given by the Go declarations below: " +
264+ "the `var` is the root of the answer, a key is the field's `json` tag (its Go name when there is none), " +
265+ "a pointer may be null, and the comments say what each field must hold.\n\n" +
266+ strings.TrimSpace(src) + "\n"
267+}
new file mode 100644
@@ -0,0 +1,267 @@
1+// Package structured turns the Go type declarations of a config's
2+// `structuredOutput` section into the JSON Schema the model's answer must
3+// follow (ticket 80).
4+//
5+// No Go type is created at runtime — Go cannot compile code while it runs.
6+// The section is read as SOURCE: go/parser builds its syntax tree, and the
7+// tree is translated, declaration by declaration, into a JSON Schema:
8+//
9+// type Language struct {
10+// Name string `json:"name"` // language name, in English
11+// }
12+// type Country struct {
13+// Name string `json:"name"`
14+// Languages []Language `json:"languages"`
15+// }
16+// var Countries = []Country{}
17+//
18+// Every `type` is a definition, the one `var` names the answer's root, a
19+// field's key is its `json` tag (its Go name when there is none) and its `//`
20+// comment becomes a description — the model reads it. See types.go for the
21+// mapping of each Go type.
22+package structured
23+
24+import (
25+ "fmt"
26+ "go/ast"
27+ "go/parser"
28+ "go/token"
29+ "strings"
30+)
31+
32+// sourceName is the file name errors carry: the config key, so that
33+// "structuredOutput:4:15: …" points at the right place in the YAML block.
34+const sourceName = "structuredOutput"
35+
36+// header makes the section a Go file: the parser demands a package clause.
37+// It takes one line, which pos() takes back off every error position.
38+const header = "package schema\n"
39+
40+// Schema compiles the Go declarations in src into a JSON Schema.
41+//
42+// src holds `type` declarations and exactly one `var` whose type is the
43+// answer's root. An error names the line and column, counted from the first
44+// line of src, of what could not be translated.
45+//
46+// Example:
47+//
48+// schema, err := structured.Schema("type Answer struct {\n Ok bool `json:\"ok\"`\n}\nvar A = Answer{}")
49+// // schema: {"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"],"additionalProperties":false}
50+func Schema(src string) (map[string]any, error) {
51+ fset := token.NewFileSet()
52+ file, err := parser.ParseFile(fset, sourceName, header+src, parser.ParseComments)
53+ if err != nil {
54+ return nil, fmt.Errorf("%s", shiftLines(err.Error()))
55+ }
56+ c := &compiler{fset: fset, types: map[string]*ast.TypeSpec{}, used: map[string]bool{}}
57+ root, err := c.collect(file)
58+ if err != nil {
59+ return nil, err
60+ }
61+ return c.document(root)
62+}
63+
64+// compiler holds what one Schema call learned from the source.
65+type compiler struct {
66+ fset *token.FileSet
67+ types map[string]*ast.TypeSpec // declared types, by name
68+ order []string // declaration order, for stable $defs
69+ used map[string]bool // types reached from the root
70+}
71+
72+// errorf builds an error located at node, in the source's own line numbers.
73+func (c *compiler) errorf(node ast.Node, format string, args ...any) error {
74+ p := c.fset.Position(node.Pos())
75+ return fmt.Errorf("%s:%d:%d: %s", sourceName, p.Line-1, p.Column, fmt.Sprintf(format, args...))
76+}
77+
78+// collect records every type declaration and returns the root variable.
79+// Anything that is neither a type nor the one var is refused: the section
80+// describes data, it does not run.
81+func (c *compiler) collect(file *ast.File) (*ast.ValueSpec, error) {
82+ var root *ast.ValueSpec
83+ for _, decl := range file.Decls {
84+ gen, ok := decl.(*ast.GenDecl)
85+ if !ok || (gen.Tok != token.TYPE && gen.Tok != token.VAR) {
86+ return nil, c.errorf(decl, "only type and var declarations are allowed")
87+ }
88+ for _, spec := range gen.Specs {
89+ var err error
90+ if gen.Tok == token.TYPE {
91+ err = c.addType(spec.(*ast.TypeSpec), gen)
92+ } else {
93+ root, err = c.setRoot(root, spec.(*ast.ValueSpec), gen)
94+ }
95+ if err != nil {
96+ return nil, err
97+ }
98+ }
99+ }
100+ if root == nil {
101+ return nil, fmt.Errorf("%s: no var declaration: add one to name the answer, e.g. var Countries = []Country{}", sourceName)
102+ }
103+ return root, nil
104+}
105+
106+// addType records one type declaration. A lone declaration's comment sits
107+// on the GenDecl, a grouped one's on the spec: both are kept as its doc.
108+func (c *compiler) addType(spec *ast.TypeSpec, gen *ast.GenDecl) error {
109+ name := spec.Name.Name
110+ switch {
111+ case spec.TypeParams != nil:
112+ return c.errorf(spec, "type %s: generic types are not supported", name)
113+ case spec.Assign.IsValid():
114+ return c.errorf(spec, "type %s: aliases are not supported, declare the type", name)
115+ case c.types[name] != nil:
116+ return c.errorf(spec, "type %s is declared twice", name)
117+ }
118+ if spec.Doc == nil && len(gen.Specs) == 1 {
119+ spec.Doc = gen.Doc
120+ }
121+ c.types[name] = spec
122+ c.order = append(c.order, name)
123+ return nil
124+}
125+
126+// setRoot accepts the one var declaration, `var X T` or `var X = T{}`.
127+func (c *compiler) setRoot(previous, spec *ast.ValueSpec, gen *ast.GenDecl) (*ast.ValueSpec, error) {
128+ if previous != nil || len(spec.Names) != 1 {
129+ return nil, c.errorf(spec, "only one var may name the answer")
130+ }
131+ if spec.Type == nil && rootLiteral(spec) == nil {
132+ return nil, c.errorf(spec, "var %s: give it a type, e.g. var %s = []Country{}", spec.Names[0].Name, spec.Names[0].Name)
133+ }
134+ if spec.Doc == nil {
135+ spec.Doc = gen.Doc
136+ }
137+ return spec, nil
138+}
139+
140+// rootLiteral returns the composite literal of `var X = T{}`, or nil.
141+func rootLiteral(spec *ast.ValueSpec) *ast.CompositeLit {
142+ if len(spec.Values) != 1 {
143+ return nil
144+ }
145+ lit, _ := spec.Values[0].(*ast.CompositeLit)
146+ if lit == nil || lit.Type == nil {
147+ return nil
148+ }
149+ return lit
150+}
151+
152+// document builds the whole schema: the root's own schema at the top level —
153+// a struct root is inlined rather than referenced, the plainest shape for a
154+// server's grammar converter — plus `$defs` for every type the root reaches.
155+func (c *compiler) document(root *ast.ValueSpec) (map[string]any, error) {
156+ rootType := root.Type
157+ if rootType == nil {
158+ rootType = rootLiteral(root).Type
159+ }
160+ schema, err := c.rootSchema(rootType)
161+ if err != nil {
162+ return nil, err
163+ }
164+ if d := describe(root.Doc, root.Comment); d != "" {
165+ schema["description"] = d
166+ }
167+ defs, err := c.definitions()
168+ if err != nil {
169+ return nil, err
170+ }
171+ if len(defs) > 0 {
172+ schema["$defs"] = defs
173+ }
174+ return schema, nil
175+}
176+
177+// rootSchema inlines a declared root type, any other type is translated as is.
178+func (c *compiler) rootSchema(expr ast.Expr) (map[string]any, error) {
179+ if ident, ok := expr.(*ast.Ident); ok && c.types[ident.Name] != nil {
180+ return c.declared(c.types[ident.Name])
181+ }
182+ return c.typeSchema(expr)
183+}
184+
185+// definitions translates every type the root reaches, in declaration order.
186+// Translating one may reach others, hence the loop until nothing new is used.
187+func (c *compiler) definitions() (map[string]any, error) {
188+ defs := map[string]any{}
189+ for done := false; !done; {
190+ done = true
191+ for _, name := range c.order {
192+ if !c.used[name] || defs[name] != nil {
193+ continue
194+ }
195+ schema, err := c.declared(c.types[name])
196+ if err != nil {
197+ return nil, err
198+ }
199+ defs[name] = schema
200+ done = false
201+ }
202+ }
203+ return defs, nil
204+}
205+
206+// declared translates one type declaration, with its doc as description.
207+func (c *compiler) declared(spec *ast.TypeSpec) (map[string]any, error) {
208+ schema, err := c.typeSchema(spec.Type)
209+ if err != nil {
210+ return nil, err
211+ }
212+ if d := describe(spec.Doc, spec.Comment); d != "" {
213+ schema["description"] = d
214+ }
215+ return schema, nil
216+}
217+
218+// describe joins the comments written above and beside a declaration.
219+func describe(groups ...*ast.CommentGroup) string {
220+ var parts []string
221+ for _, g := range groups {
222+ if text := strings.TrimSpace(g.Text()); text != "" {
223+ parts = append(parts, strings.Join(strings.Fields(text), " "))
224+ }
225+ }
226+ return strings.Join(parts, " ")
227+}
228+
229+// shiftLines takes the header's line back off a parser error, so that its
230+// positions count from the first line of the YAML block. A parser error
231+// reads "structuredOutput:5:3: expected …" (several joined by newlines).
232+func shiftLines(msg string) string {
233+ lines := strings.Split(msg, "\n")
234+ for i, line := range lines {
235+ var row, col int
236+ var rest string
237+ prefix := sourceName + ":"
238+ if !strings.HasPrefix(line, prefix) {
239+ continue
240+ }
241+ if n, _ := fmt.Sscanf(line[len(prefix):], "%d:%d:", &row, &col); n == 2 {
242+ _, rest, _ = strings.Cut(line[len(prefix):], ": ")
243+ lines[i] = fmt.Sprintf("%s%d:%d: %s", prefix, row-1, col, rest)
244+ }
245+ }
246+ return strings.Join(lines, "\n")
247+}
248+
249+// Instructions is the paragraph appended to the system prompt of an agent
250+// with a structuredOutput: the declarations themselves, comments included.
251+//
252+// The schema alone does not reach the model. A server that constrains the
253+// generation (llama.cpp) turns it into a grammar — syntax only — and Genkit
254+// pastes nothing into the prompt when the constraint is native. The model
255+// then knows the keys and nothing else: asked for capitals, Qwen3.5-4B wrote
256+// the country's name in `capital` every time, its description unread
257+// (ticket 80). The Go source is shorter than the JSON Schema and reads
258+// naturally to a model trained on code.
259+//
260+// system += "\n\n" + structured.Instructions(src)
261+func Instructions(src string) string {
262+ return "OUTPUT FORMAT\n" +
263+ "Answer with JSON only — no prose, no Markdown fence. Its shape is given by the Go declarations below: " +
264+ "the `var` is the root of the answer, a key is the field's `json` tag (its Go name when there is none), " +
265+ "a pointer may be null, and the comments say what each field must hold.\n\n" +
266+ strings.TrimSpace(src) + "\n"
267+}
added internal/structured/structured_test.go +156 -0
new file mode 100644
@@ -0,0 +1,156 @@
1+package structured
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+)
8+
9+// compile runs Schema and returns its JSON, failing the test on an error.
10+func compile(t *testing.T, src string) string {
11+ t.Helper()
12+ schema, err := Schema(src)
13+ if err != nil {
14+ t.Fatalf("Schema: %v", err)
15+ }
16+ out, err := json.Marshal(schema)
17+ if err != nil {
18+ t.Fatalf("marshal: %v", err)
19+ }
20+ return string(out)
21+}
22+
23+// failure runs Schema, expects an error, and returns its message.
24+func failure(t *testing.T, src string) string {
25+ t.Helper()
26+ schema, err := Schema(src)
27+ if err == nil {
28+ t.Fatalf("Schema returned no error, schema: %v", schema)
29+ }
30+ return err.Error()
31+}
32+
33+// The keys keep their declaration order (Name before Languages): see
34+// Properties.
35+func TestTheUsersExampleBecomesAnArrayOfCountries(t *testing.T) {
36+ got := compile(t, `
37+type Language struct {
38+ Name string
39+}
40+
41+type Country struct {
42+ Name string
43+ Languages []Language
44+}
45+
46+var Countries = []Country{}
47+`)
48+ want := `{"$defs":{` +
49+ `"Country":{"additionalProperties":false,"properties":{"Name":{"type":"string"},"Languages":{"items":{"$ref":"#/$defs/Language"},"type":"array"}},"required":["Name","Languages"],"type":"object"},` +
50+ `"Language":{"additionalProperties":false,"properties":{"Name":{"type":"string"}},"required":["Name"],"type":"object"}},` +
51+ `"items":{"$ref":"#/$defs/Country"},"type":"array"}`
52+ if got != want {
53+ t.Errorf("schema\n got %s\nwant %s", got, want)
54+ }
55+}
56+
57+func TestJSONTagsNameTheKeys(t *testing.T) {
58+ got := compile(t, "type A struct {\n Name string `json:\"name\"`\n Skipped int `json:\"-\"`\n Kept bool `json:\",omitempty\"`\n}\nvar X A")
59+ for _, want := range []string{`"name":{"type":"string"}`, `"required":["name","Kept"]`} {
60+ if !strings.Contains(got, want) {
61+ t.Errorf("schema lacks %s\n%s", want, got)
62+ }
63+ }
64+ if strings.Contains(got, "Skipped") {
65+ t.Errorf("json:\"-\" field kept: %s", got)
66+ }
67+}
68+
69+func TestPointerAndOmitemptyAreRequiredButNullable(t *testing.T) {
70+ got := compile(t, "type A struct {\n Capital *string `json:\"capital\"`\n Size int `json:\"size,omitempty\"`\n}\nvar X A")
71+ for _, want := range []string{
72+ `"capital":{"anyOf":[{"type":"string"},{"type":"null"}]}`,
73+ `"size":{"anyOf":[{"type":"integer"},{"type":"null"}]}`,
74+ `"required":["capital","size"]`,
75+ } {
76+ if !strings.Contains(got, want) {
77+ t.Errorf("schema lacks %s\n%s", want, got)
78+ }
79+ }
80+}
81+
82+func TestCommentsBecomeDescriptions(t *testing.T) {
83+ got := compile(t, "// A country of the world.\ntype Country struct {\n // in English\n Name string `json:\"name\"` // official name\n}\n// Every country asked about.\nvar Countries = []Country{}")
84+ for _, want := range []string{
85+ `"description":"Every country asked about."`,
86+ `"description":"A country of the world."`,
87+ `"name":{"description":"in English official name","type":"string"}`,
88+ } {
89+ if !strings.Contains(got, want) {
90+ t.Errorf("schema lacks %s\n%s", want, got)
91+ }
92+ }
93+}
94+
95+func TestAStructRootIsInlinedAndOnlyReachedTypesAreDefined(t *testing.T) {
96+ got := compile(t, "type Unused struct{ A int }\ntype Answer struct {\n Ok bool `json:\"ok\"`\n}\nvar A = Answer{}")
97+ want := `{"additionalProperties":false,"properties":{"ok":{"type":"boolean"}},"required":["ok"],"type":"object"}`
98+ if got != want {
99+ t.Errorf("schema\n got %s\nwant %s", got, want)
100+ }
101+}
102+
103+func TestRecursiveTypesUseReferences(t *testing.T) {
104+ got := compile(t, "type Node struct {\n Name string\n Children []Node\n}\nvar Tree Node")
105+ for _, want := range []string{`"Children":{"items":{"$ref":"#/$defs/Node"}`, `"$defs":{"Node":`} {
106+ if !strings.Contains(got, want) {
107+ t.Errorf("schema lacks %s\n%s", want, got)
108+ }
109+ }
110+}
111+
112+func TestBasicTypesAndMaps(t *testing.T) {
113+ got := compile(t, "var X map[string][]float64")
114+ want := `{"additionalProperties":{"items":{"type":"number"},"type":"array"},"type":"object"}`
115+ if got != want {
116+ t.Errorf("schema\n got %s\nwant %s", got, want)
117+ }
118+}
119+
120+func TestErrorsPointAtTheLineOfTheSection(t *testing.T) {
121+ cases := []struct{ name, src, want string }{
122+ {"undefined type", "type A struct {\n B Langage\n}\nvar X A", `structuredOutput:2:4: type "Langage" is not defined`},
123+ {"syntax error", "type A struct {\n B string\n", "structuredOutput:2:11: expected '}'"},
124+ {"no var", "type A struct{}", "no var declaration"},
125+ {"two vars", "var X int\nvar Y int", "structuredOutput:2:5: only one var"},
126+ {"var without type", "var X = 3", "give it a type"},
127+ {"func", "func F() {}\nvar X int", "only type and var declarations"},
128+ {"import", "import \"time\"\nvar X time.Time", "only type and var declarations"},
129+ {"selector", "var X time.Time", "unsupported type time.Time"},
130+ {"interface", "var X interface{}", "unsupported type interface"},
131+ {"generic", "type L[T any] struct{ V T }\nvar X int", "generic types are not supported"},
132+ {"alias", "type S = string\nvar X S", "aliases are not supported"},
133+ {"duplicate type", "type A int\ntype A int\nvar X A", "declared twice"},
134+ {"embedded", "type B struct{}\ntype A struct{ B }\nvar X A", "embedded fields"},
135+ {"map key", "var X map[int]string", "map keys must be string"},
136+ {"array", "var X [3]string", "fixed-size arrays"},
137+ {"pointer root", "var X *string", "only allowed as a struct field"},
138+ {"duplicate key", "type A struct {\n B string `json:\"x\"`\n C string `json:\"x\"`\n}\nvar X A", `key "x" is used twice`},
139+ }
140+ for _, tc := range cases {
141+ t.Run(tc.name, func(t *testing.T) {
142+ if got := failure(t, tc.src); !strings.Contains(got, tc.want) {
143+ t.Errorf("error %q does not contain %q", got, tc.want)
144+ }
145+ })
146+ }
147+}
148+
149+func TestInstructionsCarryTheDeclarationsAndTheirComments(t *testing.T) {
150+ got := Instructions("\n type A struct {\n Capital string `json:\"capital\"` // the capital CITY\n }\n var X A\n\n")
151+ for _, want := range []string{"OUTPUT FORMAT", "Answer with JSON only", "`json:\"capital\"` // the capital CITY", "var X A\n"} {
152+ if !strings.Contains(got, want) {
153+ t.Errorf("instructions lack %q:\n%s", want, got)
154+ }
155+ }
156+}
new file mode 100644
@@ -0,0 +1,156 @@
1+package structured
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+ "testing"
7+)
8+
9+// compile runs Schema and returns its JSON, failing the test on an error.
10+func compile(t *testing.T, src string) string {
11+ t.Helper()
12+ schema, err := Schema(src)
13+ if err != nil {
14+ t.Fatalf("Schema: %v", err)
15+ }
16+ out, err := json.Marshal(schema)
17+ if err != nil {
18+ t.Fatalf("marshal: %v", err)
19+ }
20+ return string(out)
21+}
22+
23+// failure runs Schema, expects an error, and returns its message.
24+func failure(t *testing.T, src string) string {
25+ t.Helper()
26+ schema, err := Schema(src)
27+ if err == nil {
28+ t.Fatalf("Schema returned no error, schema: %v", schema)
29+ }
30+ return err.Error()
31+}
32+
33+// The keys keep their declaration order (Name before Languages): see
34+// Properties.
35+func TestTheUsersExampleBecomesAnArrayOfCountries(t *testing.T) {
36+ got := compile(t, `
37+type Language struct {
38+ Name string
39+}
40+
41+type Country struct {
42+ Name string
43+ Languages []Language
44+}
45+
46+var Countries = []Country{}
47+`)
48+ want := `{"$defs":{` +
49+ `"Country":{"additionalProperties":false,"properties":{"Name":{"type":"string"},"Languages":{"items":{"$ref":"#/$defs/Language"},"type":"array"}},"required":["Name","Languages"],"type":"object"},` +
50+ `"Language":{"additionalProperties":false,"properties":{"Name":{"type":"string"}},"required":["Name"],"type":"object"}},` +
51+ `"items":{"$ref":"#/$defs/Country"},"type":"array"}`
52+ if got != want {
53+ t.Errorf("schema\n got %s\nwant %s", got, want)
54+ }
55+}
56+
57+func TestJSONTagsNameTheKeys(t *testing.T) {
58+ got := compile(t, "type A struct {\n Name string `json:\"name\"`\n Skipped int `json:\"-\"`\n Kept bool `json:\",omitempty\"`\n}\nvar X A")
59+ for _, want := range []string{`"name":{"type":"string"}`, `"required":["name","Kept"]`} {
60+ if !strings.Contains(got, want) {
61+ t.Errorf("schema lacks %s\n%s", want, got)
62+ }
63+ }
64+ if strings.Contains(got, "Skipped") {
65+ t.Errorf("json:\"-\" field kept: %s", got)
66+ }
67+}
68+
69+func TestPointerAndOmitemptyAreRequiredButNullable(t *testing.T) {
70+ got := compile(t, "type A struct {\n Capital *string `json:\"capital\"`\n Size int `json:\"size,omitempty\"`\n}\nvar X A")
71+ for _, want := range []string{
72+ `"capital":{"anyOf":[{"type":"string"},{"type":"null"}]}`,
73+ `"size":{"anyOf":[{"type":"integer"},{"type":"null"}]}`,
74+ `"required":["capital","size"]`,
75+ } {
76+ if !strings.Contains(got, want) {
77+ t.Errorf("schema lacks %s\n%s", want, got)
78+ }
79+ }
80+}
81+
82+func TestCommentsBecomeDescriptions(t *testing.T) {
83+ got := compile(t, "// A country of the world.\ntype Country struct {\n // in English\n Name string `json:\"name\"` // official name\n}\n// Every country asked about.\nvar Countries = []Country{}")
84+ for _, want := range []string{
85+ `"description":"Every country asked about."`,
86+ `"description":"A country of the world."`,
87+ `"name":{"description":"in English official name","type":"string"}`,
88+ } {
89+ if !strings.Contains(got, want) {
90+ t.Errorf("schema lacks %s\n%s", want, got)
91+ }
92+ }
93+}
94+
95+func TestAStructRootIsInlinedAndOnlyReachedTypesAreDefined(t *testing.T) {
96+ got := compile(t, "type Unused struct{ A int }\ntype Answer struct {\n Ok bool `json:\"ok\"`\n}\nvar A = Answer{}")
97+ want := `{"additionalProperties":false,"properties":{"ok":{"type":"boolean"}},"required":["ok"],"type":"object"}`
98+ if got != want {
99+ t.Errorf("schema\n got %s\nwant %s", got, want)
100+ }
101+}
102+
103+func TestRecursiveTypesUseReferences(t *testing.T) {
104+ got := compile(t, "type Node struct {\n Name string\n Children []Node\n}\nvar Tree Node")
105+ for _, want := range []string{`"Children":{"items":{"$ref":"#/$defs/Node"}`, `"$defs":{"Node":`} {
106+ if !strings.Contains(got, want) {
107+ t.Errorf("schema lacks %s\n%s", want, got)
108+ }
109+ }
110+}
111+
112+func TestBasicTypesAndMaps(t *testing.T) {
113+ got := compile(t, "var X map[string][]float64")
114+ want := `{"additionalProperties":{"items":{"type":"number"},"type":"array"},"type":"object"}`
115+ if got != want {
116+ t.Errorf("schema\n got %s\nwant %s", got, want)
117+ }
118+}
119+
120+func TestErrorsPointAtTheLineOfTheSection(t *testing.T) {
121+ cases := []struct{ name, src, want string }{
122+ {"undefined type", "type A struct {\n B Langage\n}\nvar X A", `structuredOutput:2:4: type "Langage" is not defined`},
123+ {"syntax error", "type A struct {\n B string\n", "structuredOutput:2:11: expected '}'"},
124+ {"no var", "type A struct{}", "no var declaration"},
125+ {"two vars", "var X int\nvar Y int", "structuredOutput:2:5: only one var"},
126+ {"var without type", "var X = 3", "give it a type"},
127+ {"func", "func F() {}\nvar X int", "only type and var declarations"},
128+ {"import", "import \"time\"\nvar X time.Time", "only type and var declarations"},
129+ {"selector", "var X time.Time", "unsupported type time.Time"},
130+ {"interface", "var X interface{}", "unsupported type interface"},
131+ {"generic", "type L[T any] struct{ V T }\nvar X int", "generic types are not supported"},
132+ {"alias", "type S = string\nvar X S", "aliases are not supported"},
133+ {"duplicate type", "type A int\ntype A int\nvar X A", "declared twice"},
134+ {"embedded", "type B struct{}\ntype A struct{ B }\nvar X A", "embedded fields"},
135+ {"map key", "var X map[int]string", "map keys must be string"},
136+ {"array", "var X [3]string", "fixed-size arrays"},
137+ {"pointer root", "var X *string", "only allowed as a struct field"},
138+ {"duplicate key", "type A struct {\n B string `json:\"x\"`\n C string `json:\"x\"`\n}\nvar X A", `key "x" is used twice`},
139+ }
140+ for _, tc := range cases {
141+ t.Run(tc.name, func(t *testing.T) {
142+ if got := failure(t, tc.src); !strings.Contains(got, tc.want) {
143+ t.Errorf("error %q does not contain %q", got, tc.want)
144+ }
145+ })
146+ }
147+}
148+
149+func TestInstructionsCarryTheDeclarationsAndTheirComments(t *testing.T) {
150+ got := Instructions("\n type A struct {\n Capital string `json:\"capital\"` // the capital CITY\n }\n var X A\n\n")
151+ for _, want := range []string{"OUTPUT FORMAT", "Answer with JSON only", "`json:\"capital\"` // the capital CITY", "var X A\n"} {
152+ if !strings.Contains(got, want) {
153+ t.Errorf("instructions lack %q:\n%s", want, got)
154+ }
155+ }
156+}
added internal/structured/types.go +183 -0
new file mode 100644
@@ -0,0 +1,183 @@
1+package structured
2+
3+import (
4+ "go/ast"
5+ "reflect"
6+ "strconv"
7+ "strings"
8+)
9+
10+// basicTypes maps Go's predeclared types to JSON Schema types. Anything
11+// absent — complex numbers, runes as such, any, error — has no faithful JSON
12+// shape and is refused.
13+var basicTypes = map[string]string{
14+ "string": "string",
15+ "bool": "boolean",
16+ "int": "integer", "int8": "integer", "int16": "integer", "int32": "integer", "int64": "integer",
17+ "uint": "integer", "uint8": "integer", "uint16": "integer", "uint32": "integer", "uint64": "integer",
18+ "float32": "number", "float64": "number",
19+}
20+
21+// typeSchema translates one type expression:
22+//
23+// string, bool, int…, float… → string, boolean, integer, number
24+// []T → array of T
25+// map[string]T → object whose values are T
26+// Name (declared) → $ref to #/$defs/Name
27+// struct { … } → object (see structSchema)
28+// *T → T, optional (see field)
29+func (c *compiler) typeSchema(expr ast.Expr) (map[string]any, error) {
30+ switch t := expr.(type) {
31+ case *ast.Ident:
32+ return c.named(t)
33+ case *ast.ArrayType:
34+ if t.Len != nil {
35+ return nil, c.errorf(t, "fixed-size arrays are not supported, use a slice")
36+ }
37+ return c.container(map[string]any{"type": "array"}, "items", t.Elt)
38+ case *ast.MapType:
39+ if key, ok := t.Key.(*ast.Ident); !ok || key.Name != "string" {
40+ return nil, c.errorf(t, "map keys must be string: JSON object keys are strings")
41+ }
42+ return c.container(map[string]any{"type": "object"}, "additionalProperties", t.Value)
43+ case *ast.StructType:
44+ return c.structSchema(t)
45+ case *ast.StarExpr:
46+ return nil, c.errorf(t, "a pointer is only allowed as a struct field's type, to make it optional")
47+ case *ast.ParenExpr:
48+ return c.typeSchema(t.X)
49+ }
50+ return nil, c.errorf(expr, "unsupported type %s", typeString(expr))
51+}
52+
53+// named translates a type name: a predeclared type or a declared one.
54+func (c *compiler) named(ident *ast.Ident) (map[string]any, error) {
55+ if c.types[ident.Name] != nil {
56+ c.used[ident.Name] = true
57+ return map[string]any{"$ref": "#/$defs/" + ident.Name}, nil
58+ }
59+ if kind, ok := basicTypes[ident.Name]; ok {
60+ return map[string]any{"type": kind}, nil
61+ }
62+ return nil, c.errorf(ident, "type %q is not defined", ident.Name)
63+}
64+
65+// container fills the slot (items, additionalProperties) of an array or a map.
66+func (c *compiler) container(schema map[string]any, slot string, elem ast.Expr) (map[string]any, error) {
67+ inner, err := c.typeSchema(elem)
68+ if err != nil {
69+ return nil, err
70+ }
71+ schema[slot] = inner
72+ return schema, nil
73+}
74+
75+// structSchema translates a struct into a closed object, its keys in
76+// declaration order (see Properties): every key listed in `required`, no
77+// other key allowed. That is what OpenAI's strict mode
78+// demands, and what makes a grammar-constrained server emit every field.
79+// An optional field stays required, but may be null (see field).
80+func (c *compiler) structSchema(st *ast.StructType) (map[string]any, error) {
81+ properties := &Properties{}
82+ required := []string{}
83+ for _, f := range st.Fields.List {
84+ if len(f.Names) == 0 {
85+ return nil, c.errorf(f, "embedded fields are not supported, give the field a name")
86+ }
87+ for _, name := range f.Names {
88+ key, schema, err := c.field(name, f)
89+ if err != nil {
90+ return nil, err
91+ }
92+ if key == "" {
93+ continue
94+ }
95+ if properties.Get(key) != nil {
96+ return nil, c.errorf(name, "key %q is used twice", key)
97+ }
98+ properties.set(key, schema)
99+ required = append(required, key)
100+ }
101+ }
102+ return map[string]any{
103+ "type": "object",
104+ "properties": properties,
105+ "required": required,
106+ "additionalProperties": false,
107+ }, nil
108+}
109+
110+// field translates one struct field into its key and schema. The key is
111+// the `json` tag's name, or the Go name as is; `json:"-"` skips the field
112+// (empty key). A pointer or `omitempty` makes it optional: the value may be
113+// null.
114+func (c *compiler) field(name *ast.Ident, f *ast.Field) (string, map[string]any, error) {
115+ key, omitEmpty, skip := jsonTag(f.Tag)
116+ if skip {
117+ return "", nil, nil
118+ }
119+ if key == "" {
120+ key = name.Name
121+ }
122+ expr, pointer := f.Type, false
123+ if star, ok := expr.(*ast.StarExpr); ok {
124+ expr, pointer = star.X, true
125+ }
126+ schema, err := c.typeSchema(expr)
127+ if err != nil {
128+ return "", nil, err
129+ }
130+ if pointer || omitEmpty {
131+ schema = map[string]any{"anyOf": []any{schema, map[string]any{"type": "null"}}}
132+ }
133+ if d := describe(f.Doc, f.Comment); d != "" {
134+ schema["description"] = d
135+ }
136+ return key, schema, nil
137+}
138+
139+// jsonTag reads a field's `json:"name,omitempty"` tag the way encoding/json
140+// does: the name (empty: none given), whether omitempty is set, and whether
141+// the field is skipped with "-".
142+func jsonTag(tag *ast.BasicLit) (name string, omitEmpty, skip bool) {
143+ if tag == nil {
144+ return "", false, false
145+ }
146+ raw, err := strconv.Unquote(tag.Value)
147+ if err != nil {
148+ return "", false, false
149+ }
150+ value, ok := reflect.StructTag(raw).Lookup("json")
151+ if !ok {
152+ return "", false, false
153+ }
154+ if value == "-" {
155+ return "", false, true
156+ }
157+ name, options, _ := strings.Cut(value, ",")
158+ for _, option := range strings.Split(options, ",") {
159+ if option == "omitempty" || option == "omitzero" {
160+ omitEmpty = true
161+ }
162+ }
163+ return name, omitEmpty, false
164+}
165+
166+// typeString names an unsupported type in an error message.
167+func typeString(expr ast.Expr) string {
168+ switch t := expr.(type) {
169+ case *ast.SelectorExpr:
170+ return typeString(t.X) + "." + t.Sel.Name
171+ case *ast.Ident:
172+ return t.Name
173+ case *ast.InterfaceType:
174+ return "interface"
175+ case *ast.ChanType:
176+ return "chan"
177+ case *ast.FuncType:
178+ return "func"
179+ case *ast.IndexExpr, *ast.IndexListExpr:
180+ return "(generic instantiation)"
181+ }
182+ return "(expression)"
183+}
new file mode 100644
@@ -0,0 +1,183 @@
1+package structured
2+
3+import (
4+ "go/ast"
5+ "reflect"
6+ "strconv"
7+ "strings"
8+)
9+
10+// basicTypes maps Go's predeclared types to JSON Schema types. Anything
11+// absent — complex numbers, runes as such, any, error — has no faithful JSON
12+// shape and is refused.
13+var basicTypes = map[string]string{
14+ "string": "string",
15+ "bool": "boolean",
16+ "int": "integer", "int8": "integer", "int16": "integer", "int32": "integer", "int64": "integer",
17+ "uint": "integer", "uint8": "integer", "uint16": "integer", "uint32": "integer", "uint64": "integer",
18+ "float32": "number", "float64": "number",
19+}
20+
21+// typeSchema translates one type expression:
22+//
23+// string, bool, int…, float… → string, boolean, integer, number
24+// []T → array of T
25+// map[string]T → object whose values are T
26+// Name (declared) → $ref to #/$defs/Name
27+// struct { … } → object (see structSchema)
28+// *T → T, optional (see field)
29+func (c *compiler) typeSchema(expr ast.Expr) (map[string]any, error) {
30+ switch t := expr.(type) {
31+ case *ast.Ident:
32+ return c.named(t)
33+ case *ast.ArrayType:
34+ if t.Len != nil {
35+ return nil, c.errorf(t, "fixed-size arrays are not supported, use a slice")
36+ }
37+ return c.container(map[string]any{"type": "array"}, "items", t.Elt)
38+ case *ast.MapType:
39+ if key, ok := t.Key.(*ast.Ident); !ok || key.Name != "string" {
40+ return nil, c.errorf(t, "map keys must be string: JSON object keys are strings")
41+ }
42+ return c.container(map[string]any{"type": "object"}, "additionalProperties", t.Value)
43+ case *ast.StructType:
44+ return c.structSchema(t)
45+ case *ast.StarExpr:
46+ return nil, c.errorf(t, "a pointer is only allowed as a struct field's type, to make it optional")
47+ case *ast.ParenExpr:
48+ return c.typeSchema(t.X)
49+ }
50+ return nil, c.errorf(expr, "unsupported type %s", typeString(expr))
51+}
52+
53+// named translates a type name: a predeclared type or a declared one.
54+func (c *compiler) named(ident *ast.Ident) (map[string]any, error) {
55+ if c.types[ident.Name] != nil {
56+ c.used[ident.Name] = true
57+ return map[string]any{"$ref": "#/$defs/" + ident.Name}, nil
58+ }
59+ if kind, ok := basicTypes[ident.Name]; ok {
60+ return map[string]any{"type": kind}, nil
61+ }
62+ return nil, c.errorf(ident, "type %q is not defined", ident.Name)
63+}
64+
65+// container fills the slot (items, additionalProperties) of an array or a map.
66+func (c *compiler) container(schema map[string]any, slot string, elem ast.Expr) (map[string]any, error) {
67+ inner, err := c.typeSchema(elem)
68+ if err != nil {
69+ return nil, err
70+ }
71+ schema[slot] = inner
72+ return schema, nil
73+}
74+
75+// structSchema translates a struct into a closed object, its keys in
76+// declaration order (see Properties): every key listed in `required`, no
77+// other key allowed. That is what OpenAI's strict mode
78+// demands, and what makes a grammar-constrained server emit every field.
79+// An optional field stays required, but may be null (see field).
80+func (c *compiler) structSchema(st *ast.StructType) (map[string]any, error) {
81+ properties := &Properties{}
82+ required := []string{}
83+ for _, f := range st.Fields.List {
84+ if len(f.Names) == 0 {
85+ return nil, c.errorf(f, "embedded fields are not supported, give the field a name")
86+ }
87+ for _, name := range f.Names {
88+ key, schema, err := c.field(name, f)
89+ if err != nil {
90+ return nil, err
91+ }
92+ if key == "" {
93+ continue
94+ }
95+ if properties.Get(key) != nil {
96+ return nil, c.errorf(name, "key %q is used twice", key)
97+ }
98+ properties.set(key, schema)
99+ required = append(required, key)
100+ }
101+ }
102+ return map[string]any{
103+ "type": "object",
104+ "properties": properties,
105+ "required": required,
106+ "additionalProperties": false,
107+ }, nil
108+}
109+
110+// field translates one struct field into its key and schema. The key is
111+// the `json` tag's name, or the Go name as is; `json:"-"` skips the field
112+// (empty key). A pointer or `omitempty` makes it optional: the value may be
113+// null.
114+func (c *compiler) field(name *ast.Ident, f *ast.Field) (string, map[string]any, error) {
115+ key, omitEmpty, skip := jsonTag(f.Tag)
116+ if skip {
117+ return "", nil, nil
118+ }
119+ if key == "" {
120+ key = name.Name
121+ }
122+ expr, pointer := f.Type, false
123+ if star, ok := expr.(*ast.StarExpr); ok {
124+ expr, pointer = star.X, true
125+ }
126+ schema, err := c.typeSchema(expr)
127+ if err != nil {
128+ return "", nil, err
129+ }
130+ if pointer || omitEmpty {
131+ schema = map[string]any{"anyOf": []any{schema, map[string]any{"type": "null"}}}
132+ }
133+ if d := describe(f.Doc, f.Comment); d != "" {
134+ schema["description"] = d
135+ }
136+ return key, schema, nil
137+}
138+
139+// jsonTag reads a field's `json:"name,omitempty"` tag the way encoding/json
140+// does: the name (empty: none given), whether omitempty is set, and whether
141+// the field is skipped with "-".
142+func jsonTag(tag *ast.BasicLit) (name string, omitEmpty, skip bool) {
143+ if tag == nil {
144+ return "", false, false
145+ }
146+ raw, err := strconv.Unquote(tag.Value)
147+ if err != nil {
148+ return "", false, false
149+ }
150+ value, ok := reflect.StructTag(raw).Lookup("json")
151+ if !ok {
152+ return "", false, false
153+ }
154+ if value == "-" {
155+ return "", false, true
156+ }
157+ name, options, _ := strings.Cut(value, ",")
158+ for _, option := range strings.Split(options, ",") {
159+ if option == "omitempty" || option == "omitzero" {
160+ omitEmpty = true
161+ }
162+ }
163+ return name, omitEmpty, false
164+}
165+
166+// typeString names an unsupported type in an error message.
167+func typeString(expr ast.Expr) string {
168+ switch t := expr.(type) {
169+ case *ast.SelectorExpr:
170+ return typeString(t.X) + "." + t.Sel.Name
171+ case *ast.Ident:
172+ return t.Name
173+ case *ast.InterfaceType:
174+ return "interface"
175+ case *ast.ChanType:
176+ return "chan"
177+ case *ast.FuncType:
178+ return "func"
179+ case *ast.IndexExpr, *ast.IndexListExpr:
180+ return "(generic instantiation)"
181+ }
182+ return "(expression)"
183+}
modified main.go +47 -7
@@ -32,6 +32,7 @@ import (
3232 "errors"
3333 "flag"
3434 "fmt"
35+ "io"
3536 "os"
3637 "os/signal"
3738 "path/filepath"
@@ -148,11 +149,11 @@ func main() {
148149 *configPath = flag.Arg(0)
149150 }
150151 if configExplicit && flag.NArg() > 0 {
151- say("[usage: --config already specified, got %d extra arguments]", flag.NArg())
152+ fail("[usage: --config already specified, got %d extra arguments]", flag.NArg())
152153 os.Exit(1)
153154 }
154155 if flag.NArg() > 1 {
155- say("[usage: at most one config file, got %d arguments]", flag.NArg())
156+ fail("[usage: at most one config file, got %d arguments]", flag.NArg())
156157 os.Exit(1)
157158 }
158159
@@ -185,7 +186,7 @@ func main() {
185186 // Settings: built-in defaults, overridden by the YAML file if there is one.
186187 path, err := config.Load(*configPath)
187188 if err != nil {
188- say("[config error: %v ]", err)
189+ fail("[config error: %v ]", err)
189190 os.Exit(1)
190191 }
191192
@@ -214,7 +215,7 @@ func main() {
214215
215216 e, err := engine.New(ctx)
216217 if err != nil {
217- say("[engine error: %v ]", err)
218+ fail("[engine error: %v ]", err)
218219 os.Exit(1)
219220 }
220221
@@ -228,6 +229,12 @@ func main() {
228229
229230 set := assembleTools(e, loopDetector, asker)
230231 defer set.close()
232+ // A structuredOutput constrains every answer: no room for a tool call.
233+ if err := config.StructuredOutputConflict(set.names); err != nil {
234+ fail("[config error: %v ]", err)
235+ set.close()
236+ os.Exit(1)
237+ }
231238 agentTools := set.tools
232239 set.warnings = append(set.warnings, ask.Missing()...)
233240
@@ -236,6 +243,9 @@ func main() {
236243 }
237244
238245 // Handle prompt mode: run a single prompt and exit
246+ if *promptMode != "" && config.Cfg.OutputSchema != nil {
247+ os.Exit(runStructuredPrompt(ctx, e, *promptMode))
248+ }
239249 if *promptMode != "" {
240250 // In prompt mode, no banner, no session persistence
241251 agent.RunPrompt(ctx, e, config.Cfg.System, agentTools, loopDetector, *promptMode)
@@ -257,6 +267,18 @@ func main() {
257267 agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker)
258268 }
259269
270+// runStructuredPrompt is -p for an agent with a structuredOutput: the JSON
271+// answer alone on stdout, everything human on stderr, and an exit status a
272+// pipe can test (0 answered, 1 failed).
273+func runStructuredPrompt(ctx context.Context, e *engine.Engine, prompt string) int {
274+ ui.Out = os.Stderr
275+ if err := agent.RunStructuredPrompt(ctx, e, config.Cfg.System, prompt, os.Stdout); err != nil {
276+ say("[structured output: %v]", err)
277+ return 1
278+ }
279+ return 0
280+}
281+
260282 // runWindow starts the single-window front end, falling back to the line
261283 // mode — banner first — if the terminal cannot be opened after all.
262284 func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string, asker ask.Runner) {
@@ -503,7 +525,10 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
503525 }
504526 // "skills: 0" alone sends people hunting; the resolved path says at once
505527 // whether mm looked in the wrong place or found the right one empty.
506- if skillCount == 0 {
528+ // An agent with a structuredOutput has neither tools nor skills on
529+ // purpose (config.StructuredOutputConflict): nothing to warn about.
530+ structuredAgent := config.Cfg.OutputSchema != nil
531+ if skillCount == 0 && !structuredAgent {
507532 info.Warnings = append(info.Warnings,
508533 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
509534 }
@@ -539,7 +564,7 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
539564 }
540565 // No tool at all is a legal set-up — a chat with the model — but rarely the
541566 // intended one; say so where the "tools:" column would otherwise be empty.
542- if len(toolNames) == 0 {
567+ if len(toolNames) == 0 && !structuredAgent {
543568 info.Warnings = append(info.Warnings,
544569 "no tool is declared (bashTool and editTools are both off, and there are no skills): the model can only answer, not act")
545570 }
@@ -548,7 +573,10 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
548573 say("%s[warning: %s]%s", dim, w, off)
549574 }
550575 toolCol := strings.Join(toolNames, ", ")
551- if toolCol == "" {
576+ switch {
577+ case structuredAgent:
578+ toolCol = "none (structured output: JSON answers)"
579+ case toolCol == "":
552580 toolCol = "none"
553581 }
554582 // The version leads the line: it is the first thing anyone is asked for
@@ -621,6 +649,18 @@ func say(format string, args ...any) {
621649 _, _ = fmt.Fprintf(ui.Out, format+"\n", args...)
622650 }
623651
652+// errOut is where fail writes: stderr, a variable for the tests.
653+var errOut io.Writer = os.Stderr
654+
655+// fail prints an error that stops mm before any front end starts — usage,
656+// config, engine — on stderr, whatever the mode. On stdout it would be read
657+// as the answer: `mm agents.yaml -p … | jq` fed "[config error: …]" to jq
658+// ("Invalid numeric literal at line 1, column 8") when the config file was
659+// not found.
660+func fail(format string, args ...any) {
661+ _, _ = fmt.Fprintf(errOut, format+"\n", args...)
662+}
663+
624664 // resolveSkillsDir makes the skills directory absolute. An absolute dir is
625665 // kept as-is. A relative one follows the config file in ACP mode when there
626666 // is one (the editor's working directory is not the project's), and the
@@ -32,6 +32,7 @@ import (
32 "errors"32 "errors"
33 "flag"33 "flag"
34 "fmt"34 "fmt"
35+ "io"
35 "os"36 "os"
36 "os/signal"37 "os/signal"
37 "path/filepath"38 "path/filepath"
@@ -148,11 +149,11 @@ func main() {
148 *configPath = flag.Arg(0)149 *configPath = flag.Arg(0)
149 }150 }
150 if configExplicit && flag.NArg() > 0 {151 if configExplicit && flag.NArg() > 0 {
151- say("[usage: --config already specified, got %d extra arguments]", flag.NArg())152+ fail("[usage: --config already specified, got %d extra arguments]", flag.NArg())
152 os.Exit(1)153 os.Exit(1)
153 }154 }
154 if flag.NArg() > 1 {155 if flag.NArg() > 1 {
155- say("[usage: at most one config file, got %d arguments]", flag.NArg())156+ fail("[usage: at most one config file, got %d arguments]", flag.NArg())
156 os.Exit(1)157 os.Exit(1)
157 }158 }
158 159
@@ -185,7 +186,7 @@ func main() {
185 // Settings: built-in defaults, overridden by the YAML file if there is one.186 // Settings: built-in defaults, overridden by the YAML file if there is one.
186 path, err := config.Load(*configPath)187 path, err := config.Load(*configPath)
187 if err != nil {188 if err != nil {
188- say("[config error: %v ]", err)189+ fail("[config error: %v ]", err)
189 os.Exit(1)190 os.Exit(1)
190 }191 }
191 192
@@ -214,7 +215,7 @@ func main() {
214 215
215 e, err := engine.New(ctx)216 e, err := engine.New(ctx)
216 if err != nil {217 if err != nil {
217- say("[engine error: %v ]", err)218+ fail("[engine error: %v ]", err)
218 os.Exit(1)219 os.Exit(1)
219 }220 }
220 221
@@ -228,6 +229,12 @@ func main() {
228 229
229 set := assembleTools(e, loopDetector, asker)230 set := assembleTools(e, loopDetector, asker)
230 defer set.close()231 defer set.close()
232+ // A structuredOutput constrains every answer: no room for a tool call.
233+ if err := config.StructuredOutputConflict(set.names); err != nil {
234+ fail("[config error: %v ]", err)
235+ set.close()
236+ os.Exit(1)
237+ }
231 agentTools := set.tools238 agentTools := set.tools
232 set.warnings = append(set.warnings, ask.Missing()...)239 set.warnings = append(set.warnings, ask.Missing()...)
233 240
@@ -236,6 +243,9 @@ func main() {
236 }243 }
237 244
238 // Handle prompt mode: run a single prompt and exit245 // Handle prompt mode: run a single prompt and exit
246+ if *promptMode != "" && config.Cfg.OutputSchema != nil {
247+ os.Exit(runStructuredPrompt(ctx, e, *promptMode))
248+ }
239 if *promptMode != "" {249 if *promptMode != "" {
240 // In prompt mode, no banner, no session persistence250 // In prompt mode, no banner, no session persistence
241 agent.RunPrompt(ctx, e, config.Cfg.System, agentTools, loopDetector, *promptMode)251 agent.RunPrompt(ctx, e, config.Cfg.System, agentTools, loopDetector, *promptMode)
@@ -257,6 +267,18 @@ func main() {
257 agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker)267 agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector, asker)
258 }268 }
259 269
270+// runStructuredPrompt is -p for an agent with a structuredOutput: the JSON
271+// answer alone on stdout, everything human on stderr, and an exit status a
272+// pipe can test (0 answered, 1 failed).
273+func runStructuredPrompt(ctx context.Context, e *engine.Engine, prompt string) int {
274+ ui.Out = os.Stderr
275+ if err := agent.RunStructuredPrompt(ctx, e, config.Cfg.System, prompt, os.Stdout); err != nil {
276+ say("[structured output: %v]", err)
277+ return 1
278+ }
279+ return 0
280+}
281+
260 // runWindow starts the single-window front end, falling back to the line282 // runWindow starts the single-window front end, falling back to the line
261 // mode — banner first — if the terminal cannot be opened after all.283 // mode — banner first — if the terminal cannot be opened after all.
262 func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string, asker ask.Runner) {284 func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string, asker ask.Runner) {
@@ -503,7 +525,10 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
503 }525 }
504 // "skills: 0" alone sends people hunting; the resolved path says at once526 // "skills: 0" alone sends people hunting; the resolved path says at once
505 // whether mm looked in the wrong place or found the right one empty.527 // whether mm looked in the wrong place or found the right one empty.
506- if skillCount == 0 {528+ // An agent with a structuredOutput has neither tools nor skills on
529+ // purpose (config.StructuredOutputConflict): nothing to warn about.
530+ structuredAgent := config.Cfg.OutputSchema != nil
531+ if skillCount == 0 && !structuredAgent {
507 info.Warnings = append(info.Warnings,532 info.Warnings = append(info.Warnings,
508 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))533 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
509 }534 }
@@ -539,7 +564,7 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
539 }564 }
540 // No tool at all is a legal set-up — a chat with the model — but rarely the565 // No tool at all is a legal set-up — a chat with the model — but rarely the
541 // intended one; say so where the "tools:" column would otherwise be empty.566 // intended one; say so where the "tools:" column would otherwise be empty.
542- if len(toolNames) == 0 {567+ if len(toolNames) == 0 && !structuredAgent {
543 info.Warnings = append(info.Warnings,568 info.Warnings = append(info.Warnings,
544 "no tool is declared (bashTool and editTools are both off, and there are no skills): the model can only answer, not act")569 "no tool is declared (bashTool and editTools are both off, and there are no skills): the model can only answer, not act")
545 }570 }
@@ -548,7 +573,10 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
548 say("%s[warning: %s]%s", dim, w, off)573 say("%s[warning: %s]%s", dim, w, off)
549 }574 }
550 toolCol := strings.Join(toolNames, ", ")575 toolCol := strings.Join(toolNames, ", ")
551- if toolCol == "" {576+ switch {
577+ case structuredAgent:
578+ toolCol = "none (structured output: JSON answers)"
579+ case toolCol == "":
552 toolCol = "none"580 toolCol = "none"
553 }581 }
554 // The version leads the line: it is the first thing anyone is asked for582 // The version leads the line: it is the first thing anyone is asked for
@@ -621,6 +649,18 @@ func say(format string, args ...any) {
621 _, _ = fmt.Fprintf(ui.Out, format+"\n", args...)649 _, _ = fmt.Fprintf(ui.Out, format+"\n", args...)
622 }650 }
623 651
652+// errOut is where fail writes: stderr, a variable for the tests.
653+var errOut io.Writer = os.Stderr
654+
655+// fail prints an error that stops mm before any front end starts — usage,
656+// config, engine — on stderr, whatever the mode. On stdout it would be read
657+// as the answer: `mm agents.yaml -p … | jq` fed "[config error: …]" to jq
658+// ("Invalid numeric literal at line 1, column 8") when the config file was
659+// not found.
660+func fail(format string, args ...any) {
661+ _, _ = fmt.Fprintf(errOut, format+"\n", args...)
662+}
663+
624 // resolveSkillsDir makes the skills directory absolute. An absolute dir is664 // resolveSkillsDir makes the skills directory absolute. An absolute dir is
625 // kept as-is. A relative one follows the config file in ACP mode when there665 // kept as-is. A relative one follows the config file in ACP mode when there
626 // is one (the editor's working directory is not the project's), and the666 // is one (the editor's working directory is not the project's), and the
modified main_test.go +20 -0
@@ -1,9 +1,11 @@
11 package main
22
33 import (
4+ "bytes"
45 "testing"
56
67 "mm/internal/config"
8+ "mm/internal/ui"
79 )
810
911 // The banner says which way yolo is set, both ways.
@@ -20,3 +22,21 @@ func TestYoloColumn(t *testing.T) {
2022 t.Errorf("yolo off: %q", got)
2123 }
2224 }
25+
26+// A start-up error goes to stderr, never to ui.Out: in `-p` mode stdout is
27+// the answer, and a script would read "[config error: …]" as one.
28+func TestStartupErrorsGoToStderr(t *testing.T) {
29+ var stderr, out bytes.Buffer
30+ oldErr, oldOut := errOut, ui.Out
31+ errOut, ui.Out = &stderr, &out
32+ t.Cleanup(func() { errOut, ui.Out = oldErr, oldOut })
33+
34+ fail("[config error: %v ]", "open agent.countries.yaml: no such file or directory")
35+
36+ if out.Len() != 0 {
37+ t.Errorf("ui.Out got %q, want nothing", out.String())
38+ }
39+ if stderr.String() != "[config error: open agent.countries.yaml: no such file or directory ]\n" {
40+ t.Errorf("stderr got %q", stderr.String())
41+ }
42+}
@@ -1,9 +1,11 @@
1 package main1 package main
2 2
3 import (3 import (
4+ "bytes"
4 "testing"5 "testing"
5 6
6 "mm/internal/config"7 "mm/internal/config"
8+ "mm/internal/ui"
7 )9 )
8 10
9 // The banner says which way yolo is set, both ways.11 // The banner says which way yolo is set, both ways.
@@ -20,3 +22,21 @@ func TestYoloColumn(t *testing.T) {
20 t.Errorf("yolo off: %q", got)22 t.Errorf("yolo off: %q", got)
21 }23 }
22 }24 }
25+
26+// A start-up error goes to stderr, never to ui.Out: in `-p` mode stdout is
27+// the answer, and a script would read "[config error: …]" as one.
28+func TestStartupErrorsGoToStderr(t *testing.T) {
29+ var stderr, out bytes.Buffer
30+ oldErr, oldOut := errOut, ui.Out
31+ errOut, ui.Out = &stderr, &out
32+ t.Cleanup(func() { errOut, ui.Out = oldErr, oldOut })
33+
34+ fail("[config error: %v ]", "open agent.countries.yaml: no such file or directory")
35+
36+ if out.Len() != 0 {
37+ t.Errorf("ui.Out got %q, want nothing", out.String())
38+ }
39+ if stderr.String() != "[config error: open agent.countries.yaml: no such file or directory ]\n" {
40+ t.Errorf("stderr got %q", stderr.String())
41+ }
42+}