🛟 Updated.Unverified
ffb1541 parent: 3384e6d 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell> | |
| 2 | - <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell> | |
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry"/></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry"/></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell></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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | |
| 2 | + <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | |
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell></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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell> | ||
| 2 | - <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell> | ||
| 3 | - <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry"/></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry"/></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry"/></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry"/></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry"/></mxCell></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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry" /></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry" /></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry" /></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp, fetch_content</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry" /></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry" /></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry" /></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="version" value="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry" /></mxCell> | ||
| 2 | + <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry" /></mxCell> | ||
| 3 | + <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry" /></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry" /></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry" /></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry" /></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry" /></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="lsp" value="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry" /></mxCell><mxCell id="lspdev" value="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry" /></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry" /></mxCell><mxCell id="webmd" value="<b>internal/webmd</b><br/><font style="font-size:10px">web page → Markdown: fetch (robots.txt, charset → UTF-8), clean, convert</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="460" width="220" height="60" as="geometry" /></mxCell><mxCell id="xnet" value="<b>golang.org/x/net/html (+ x/text)</b><br/><font style="font-size:10px">HTML5 parser, charset lookup</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="1400" y="760" width="220" height="60" as="geometry" /></mxCell><mxCell id="e_main_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tools_webmd" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="webmd"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_webmd_xnet" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="webmd" target="xnet"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="ask" value="<b>internal/ask</b><br/><font style="font-size:10px">/ask another mm agent: child mm -acp, ACP client side</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="640" width="200" height="60" as="geometry" /></mxCell><mxCell id="e_main_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_agent_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_acp_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_tui_ask" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ask"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_command" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="command"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_config" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_compact" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="compact"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_ui" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_acpsdk" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="acpsdk"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_genkit" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="genkit"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e_ask_yaml" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="ask" target="yaml"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="consent" value="<b>internal/consent</b><br/><font style="font-size:10px">yolo switch, the four answers, per-session memory, the line mode prompt hook</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="500" width="200" height="60" as="geometry" /></mxCell><mxCell id="mcp" value="<b>internal/mcp</b><br/><font style="font-size:10px">MCP clients (stdio, HTTP), their tools wrapped as mm tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1400" y="560" width="200" height="60" as="geometry" /></mxCell><mxCell id="e62" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e63" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="consent" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e64" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e65" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e66" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e67" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="ask" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e68" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e69" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="consent"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e70" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="config"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e71" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="ui"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e72" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="mcp" target="detector"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e73" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="e74" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="mcp"><mxGeometry relative="1" as="geometry" /></mxCell><mxCell id="structured" value="<b>internal/structured</b><br/><font style="font-size:10px">structuredOutput: Go declarations → JSON Schema (go/parser)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="940.0" width="200" height="60" as="geometry" /></mxCell><mxCell id="e75" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="config" target="structured"><mxGeometry relative="1" as="geometry" /></mxCell></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) | ||
| 15 | 15 | - [Run mini-me on Ollama](how-to/run-with-ollama.md) — `provider: ollama`, the demo config in `demo-ollama/` |
| 16 | 16 | - [Ask another mm agent from your session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<name>`, `/ask-with-context-<name>` |
| 17 | 17 | - [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 | 19 | - [Let your agent call other agents](how-to/let-the-agent-call-other-agents.md) — `ask_agent`, `mmConfigs` descriptions, per-agent `yolo` |
| 19 | 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 | 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 | 64 | - [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 | 65 | - [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 | 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 | 68 | - [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 | 69 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 68 | 70 | |
| @@ -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 jq | 17 | - [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 limits | 64 | - [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 alive | 65 | - [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 limit | 68 | - [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 | ||
| 9 | 9 | | Package | Responsibility | |
| 10 | 10 | |---------|----------------| |
| 11 | 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 | 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 | 14 | | `tools` | The built-in tools the model can call, `ask_agent` included (it asks an `mmConfigs` agent through `ask`). | |
| 14 | 15 | | `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/ | ||
| 132 | 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 | 133 | | Compatibility | Incompatible with `-acp` and `-tui`; prompt mode takes precedence and runs regardless. | |
| 134 | 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 | 137 | ## Ask mode specifics |
| 137 | 138 | |
| @@ -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 specifics | 137 | ## Ask mode specifics |
| 137 | 138 | ||
modified
docs/en/reference/configuration.md +53 -1 | @@ -18,6 +18,7 @@ | ||
| 18 | 18 | | `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. | |
| 19 | 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 | 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 | 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 | 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 | 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 | 107 | | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | no | |
| 107 | 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 | 158 | ## 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 | 162 | | Message | Cause | |
| 114 | 163 | |---------|-------| |
| @@ -133,6 +182,9 @@ Reported as `[config error: <path>: <message>]`, exit code 1. | ||
| 133 | 182 | | `permissions must be "auto" or "ask"` | any other value of the deprecated key | |
| 134 | 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 | 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 | 188 | | `mmConfigs: "Bob" is not a valid name (…)` | a name with uppercase letters, spaces or other characters, or starting with `-` | |
| 137 | 189 | | `mmConfigs: "with-context-x" is not a valid name (it would read as /ask-with-context-…)` | a name starting with `with-context` | |
| 138 | 190 | | `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 errors | 158 | ## 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: | ||
| 15 | 15 | - [Faire tourner mini-me sur Ollama](how-to/run-with-ollama.md) — `provider: ollama`, la config de démo dans `demo-ollama/` |
| 16 | 16 | - [Interroger un autre agent mm depuis votre session](how-to/ask-another-agent.md) — `mmConfigs`, `/ask-<nom>`, `/ask-with-context-<nom>` |
| 17 | 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 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 | 19 | - [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 | 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 | 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 | 64 | - [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 | 65 | - [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 | 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 | 68 | - [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 | 69 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 68 | 70 | |
| @@ -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 jq | 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 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 agent | 19 | - [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 connues | 64 | - [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 vie | 65 | - [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 profondeur | 68 | - [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 | ||
| 9 | 9 | | Paquet | Responsabilité | |
| 10 | 10 | |--------|----------------| |
| 11 | 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 | 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 | 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 | 15 | | `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/ | ||
| 132 | 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 | 133 | | Compatibilité | Incompatible avec `-acp` et `-tui` ; le mode invite prend la priorité et s'exécute dans tous les cas. | |
| 134 | 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 | 137 | ## Spécificités du mode ask |
| 137 | 138 | |
| @@ -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 ask | 137 | ## Spécificités du mode ask |
| 137 | 138 | ||
modified
docs/fr/reference/configuration.md +53 -1 | @@ -18,6 +18,7 @@ | ||
| 18 | 18 | | `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. | |
| 19 | 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 | 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 | 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 | 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 | 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 | 107 | | `llmman` | `http://127.0.0.1:17434/v1` | `http://host.docker.internal:17434/v1` | `LLMMAN_API_KEY` | non | |
| 107 | 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 | 158 | ## 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 | 162 | | Message | Cause | |
| 114 | 163 | |---------|-------| |
| @@ -133,6 +182,9 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1 | ||
| 133 | 182 | | `permissions must be "auto" or "ask"` | toute autre valeur de la clé obsolète | |
| 134 | 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 | 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 | 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 | 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 | 190 | | `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 validation | 158 | ## 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 { | ||
| 79 | 79 | // System: the system prompt — what the agent is, and what it may do. |
| 80 | 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 | 93 | // SkillsDir: directory of markdown procedures exposed by the `read_skill` |
| 83 | 94 | // tool. When it holds no *.md file, the tool is not declared at all. |
| 84 | 95 | SkillsDir string `yaml:"skillsDir"` |
| @@ -573,6 +584,9 @@ func Load(cliPath string) (string, error) { | ||
| 573 | 584 | if err := Cfg.validate(); err != nil { |
| 574 | 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 | 590 | return path, nil |
| 577 | 591 | } |
| 578 | 592 | |
| @@ -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, nil | 590 | 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 | ||
| 355 | 355 | ai.WithConfig(config.Cfg.Sampling), |
| 356 | 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 | 364 | // The prefix is printed by the first token rather than up front, so the |
| 360 | 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 | 513 | think.done() |
| 514 | + err = cutStructuredAnswer(resp, err) | |
| 509 | 515 | |
| 510 | 516 | // `full` stops at the last call to the model; its answer is appended after. |
| 511 | 517 | 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 the | 364 | // 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 | ||
| 199 | 199 | // the config to the SDK struct first and loses them. Ticket 76. |
| 200 | 200 | 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 | 201 | Label: b.Model, |
| 202 | - Supports: &compat_oai.Multimodal, // enables Tools, Multiturn, SystemRole… | |
| 202 | + Supports: modelSupports(), | |
| 203 | 203 | }).(*ai.ModelAction)) |
| 204 | 204 | 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 | 220 | // middlewares is the HTTP client's chain, outermost first. The trace comes |
| 208 | 221 | // last, next to the network: it records the request as it leaves, after the |
| 209 | 222 | // 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 keys | 200 | 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, nil | 204 | 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 comes | 220 | // 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 the | 221 | // 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 ( | ||
| 32 | 32 | "errors" |
| 33 | 33 | "flag" |
| 34 | 34 | "fmt" |
| 35 | + "io" | |
| 35 | 36 | "os" |
| 36 | 37 | "os/signal" |
| 37 | 38 | "path/filepath" |
| @@ -148,11 +149,11 @@ func main() { | ||
| 148 | 149 | *configPath = flag.Arg(0) |
| 149 | 150 | } |
| 150 | 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 | 153 | os.Exit(1) |
| 153 | 154 | } |
| 154 | 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 | 157 | os.Exit(1) |
| 157 | 158 | } |
| 158 | 159 | |
| @@ -185,7 +186,7 @@ func main() { | ||
| 185 | 186 | // Settings: built-in defaults, overridden by the YAML file if there is one. |
| 186 | 187 | path, err := config.Load(*configPath) |
| 187 | 188 | if err != nil { |
| 188 | - say("[config error: %v ]", err) | |
| 189 | + fail("[config error: %v ]", err) | |
| 189 | 190 | os.Exit(1) |
| 190 | 191 | } |
| 191 | 192 | |
| @@ -214,7 +215,7 @@ func main() { | ||
| 214 | 215 | |
| 215 | 216 | e, err := engine.New(ctx) |
| 216 | 217 | if err != nil { |
| 217 | - say("[engine error: %v ]", err) | |
| 218 | + fail("[engine error: %v ]", err) | |
| 218 | 219 | os.Exit(1) |
| 219 | 220 | } |
| 220 | 221 | |
| @@ -228,6 +229,12 @@ func main() { | ||
| 228 | 229 | |
| 229 | 230 | set := assembleTools(e, loopDetector, asker) |
| 230 | 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 | 238 | agentTools := set.tools |
| 232 | 239 | set.warnings = append(set.warnings, ask.Missing()...) |
| 233 | 240 | |
| @@ -236,6 +243,9 @@ func main() { | ||
| 236 | 243 | } |
| 237 | 244 | |
| 238 | 245 | // 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 | 249 | if *promptMode != "" { |
| 240 | 250 | // In prompt mode, no banner, no session persistence |
| 241 | 251 | agent.RunPrompt(ctx, e, config.Cfg.System, agentTools, loopDetector, *promptMode) |
| @@ -257,6 +267,18 @@ func main() { | ||
| 257 | 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 | 282 | // runWindow starts the single-window front end, falling back to the line |
| 261 | 283 | // mode — banner first — if the terminal cannot be opened after all. |
| 262 | 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 | 526 | // "skills: 0" alone sends people hunting; the resolved path says at once |
| 505 | 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 | 532 | info.Warnings = append(info.Warnings, |
| 508 | 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 | 565 | // No tool at all is a legal set-up — a chat with the model — but rarely the |
| 541 | 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 | 568 | info.Warnings = append(info.Warnings, |
| 544 | 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 | 573 | say("%s[warning: %s]%s", dim, w, off) |
| 549 | 574 | } |
| 550 | 575 | toolCol := strings.Join(toolNames, ", ") |
| 551 | - if toolCol == "" { | |
| 576 | + switch { | |
| 577 | + case structuredAgent: | |
| 578 | + toolCol = "none (structured output: JSON answers)" | |
| 579 | + case toolCol == "": | |
| 552 | 580 | toolCol = "none" |
| 553 | 581 | } |
| 554 | 582 | // 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 | 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 | 664 | // resolveSkillsDir makes the skills directory absolute. An absolute dir is |
| 625 | 665 | // kept as-is. A relative one follows the config file in ACP mode when there |
| 626 | 666 | // 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.tools | 238 | 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 exit | 245 | // 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 persistence | 250 | // 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 line | 282 | // 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 once | 526 | // "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 the | 565 | // 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 for | 582 | // 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 is | 664 | // 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 there | 665 | // 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 the | 666 | // is one (the editor's working directory is not the project's), and the |
modified
main_test.go +20 -0 | @@ -1,9 +1,11 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 4 | 5 | "testing" |
| 5 | 6 | |
| 6 | 7 | "mm/internal/config" |
| 8 | + "mm/internal/ui" | |
| 7 | 9 | ) |
| 8 | 10 | |
| 9 | 11 | // The banner says which way yolo is set, both ways. |
| @@ -20,3 +22,21 @@ func TestYoloColumn(t *testing.T) { | ||
| 20 | 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 | +} | |
| @@ -1,9 +1,11 @@ | |||
| 1 | package main | 1 | 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 | +} | ||