💾 Saved.
d722711 parent: 79fc998 added
.gitignore +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +kits | |
| 2 | +notes.txt | |
| 3 | +tmp | |
| 4 | +mm | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +kits | ||
| 2 | +notes.txt | ||
| 3 | +tmp | ||
| 4 | +mm | ||
added
.memory/README.md +11 -0 | new file mode 100644 | ||
| @@ -0,0 +1,11 @@ | ||
| 1 | +# .memory — project record for whoever continues building mini-me | |
| 2 | + | |
| 3 | +Read this directory before doing anything else in a session. | |
| 4 | + | |
| 5 | +| File | What it is | | |
| 6 | +|------|------------| | |
| 7 | +| `summary.md` | Snapshot of the project as it is now: architecture, decisions in force, real commands. Edited in place, never regenerated. | | |
| 8 | +| `history.md` | One dated entry per session, appended at the end, never rewritten. | | |
| 9 | +| `handoffs/YYYY-MM-DD-<slug>.md` | Where a session stopped: work in flight, next steps, traps. | | |
| 10 | + | |
| 11 | +`docs/` is for people who use `mm`; this directory is for people who build it. | |
| new file mode 100644 | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | +# .memory — project record for whoever continues building mini-me | ||
| 2 | + | ||
| 3 | +Read this directory before doing anything else in a session. | ||
| 4 | + | ||
| 5 | +| File | What it is | | ||
| 6 | +|------|------------| | ||
| 7 | +| `summary.md` | Snapshot of the project as it is now: architecture, decisions in force, real commands. Edited in place, never regenerated. | | ||
| 8 | +| `history.md` | One dated entry per session, appended at the end, never rewritten. | | ||
| 9 | +| `handoffs/YYYY-MM-DD-<slug>.md` | Where a session stopped: work in flight, next steps, traps. | | ||
| 10 | + | ||
| 11 | +`docs/` is for people who use `mm`; this directory is for people who build it. | ||
added
.memory/handoffs/2026-09-15-diataxis-docs.md +50 -0 | new file mode 100644 | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +# Handoff — 2026-09-15 — Diátaxis documentation bootstrap (agent "riker") | |
| 2 | + | |
| 3 | +## Where it stopped | |
| 4 | + | |
| 5 | +`docs/` exists with a complete bilingual four-quadrant structure (`docs/en/`, `docs/fr/`, identical file names). Every page is "complete, not finished": usable now, open to growth. All relative links resolve (checked with a shell loop over `](…)` targets). Nothing committed — the whole repository is untracked apart from two old commits. | |
| 6 | + | |
| 7 | +## Filing plan that was applied (compass) | |
| 8 | + | |
| 9 | +| Topic | Quadrant | File (both languages) | | |
| 10 | +|-------|----------|-----------------------| | |
| 11 | +| First session with DMR | Tutorial | `tutorials/getting-started.md` | | |
| 12 | +| Run on llama.cpp | How-to | `how-to/run-with-llama-cpp.md` | | |
| 13 | +| Host in an editor (ACP/Zed) | How-to | `how-to/use-from-an-editor.md` | | |
| 14 | +| Add a skill | How-to | `how-to/add-a-skill.md` | | |
| 15 | +| Long session inside the window | How-to | `how-to/manage-the-context-window.md` | | |
| 16 | +| Start a new session (`/new`) | How-to | `how-to/start-a-new-session.md` — written by the other agent (bob) | | |
| 17 | +| CLI flags, env vars, banner, exit codes | Reference | `reference/cli.md` | | |
| 18 | +| Slash commands and keys | Reference | `reference/slash-commands.md` — bob edited the `/new` row after me | | |
| 19 | +| YAML keys, defaults, validation errors | Reference | `reference/configuration.md` | | |
| 20 | +| Built-in tools | Reference | `reference/tools.md` | | |
| 21 | +| Skill file format | Reference | `reference/skill-format.md` | | |
| 22 | +| ACP surface | Reference | `reference/acp.md` | | |
| 23 | +| One loop, two front ends | Explanation | `explanation/architecture.md` | | |
| 24 | +| Providers | Explanation | `explanation/providers.md` | | |
| 25 | +| Context compression | Explanation | `explanation/context-compression.md` | | |
| 26 | + | |
| 27 | +The skill's mandatory language question was not asked (user unavailable); "Both" was taken from the methodical-dev convention. If the user wants a single language, delete the other tree and the selector in `docs/README.md`. | |
| 28 | + | |
| 29 | +## Traps found (documented, not fixed — no Go changes were in my scope) | |
| 30 | + | |
| 31 | +1. **Shipped skills are invisible.** `skills.List` globs `<skillsDir>/*.md`; the examples are `skills/<name>/SKILL.md`. Banner shows `skills: 0`, `read_skill` is never declared. Either flatten the files or teach the loader to read `*/SKILL.md`. The docs describe the current behaviour and tell the user to flatten. | |
| 32 | +2. **`go test ./...` fails** in `mm/internal/skills`: `TestShippedSkillsHaveDescriptions` hard-codes `../../../demo/skills`, a path from the project this code was extracted from. Fix the path (and the layout above) or the quality gate will never be green. | |
| 33 | +3. The tutorial's banner line was captured verbatim from a real run; it now includes `"/new" to start a new session.` because bob changed `agent.go`. If the banner text changes again, update both tutorials. | |
| 34 | +4. `agent.yaml` calls the agent "Bob", ACP `agentInfo.name` is `bob` / version `0.11.0`, the binary is `mm`. The docs use `mm` for the binary and quote the ACP values as-is. | |
| 35 | + | |
| 36 | +## Not verified | |
| 37 | + | |
| 38 | +- No model server in the sandbox: step 3 of the tutorial (a real generation) was written from the code paths, not observed. The banner and `-h` output were observed. | |
| 39 | +- `docs/diagrams/packages.drawio` was created by bob; I linked it from both indexes and both architecture pages without validating its content. | |
| 40 | + | |
| 41 | +## Suggested next increments | |
| 42 | + | |
| 43 | +- A how-to for `editTools: false` (bash-only A/B mode) once someone has actually run it. | |
| 44 | +- A reference page for the engine's runtime messages (`[watchdog: …]`, `Retrying without streaming`) — currently only mentioned in passing. | |
| 45 | +- Explanation of the loop detector once its thresholds (history 10, threshold 3, set in `main.go`) are considered stable. | |
| 46 | +- bob updated `reference/slash-commands.md` and `reference/acp.md` in both languages for `/new` (row, ACP section, `available_commands_update`) after I wrote them; at the end of my session they were consistent with the code. Re-check only if `/new` changes again. | |
| 47 | + | |
| 48 | +## Addendum (same day, orchestrator) | |
| 49 | + | |
| 50 | +The Zed snippet in `how-to/use-from-an-editor.md` (EN + FR) was missing `"type": "custom"`; the user confirmed Zed needs it. Fixed in both pages. Any future editor how-to must include that key. | |
| new file mode 100644 | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | +# Handoff — 2026-09-15 — Diátaxis documentation bootstrap (agent "riker") | ||
| 2 | + | ||
| 3 | +## Where it stopped | ||
| 4 | + | ||
| 5 | +`docs/` exists with a complete bilingual four-quadrant structure (`docs/en/`, `docs/fr/`, identical file names). Every page is "complete, not finished": usable now, open to growth. All relative links resolve (checked with a shell loop over `](…)` targets). Nothing committed — the whole repository is untracked apart from two old commits. | ||
| 6 | + | ||
| 7 | +## Filing plan that was applied (compass) | ||
| 8 | + | ||
| 9 | +| Topic | Quadrant | File (both languages) | | ||
| 10 | +|-------|----------|-----------------------| | ||
| 11 | +| First session with DMR | Tutorial | `tutorials/getting-started.md` | | ||
| 12 | +| Run on llama.cpp | How-to | `how-to/run-with-llama-cpp.md` | | ||
| 13 | +| Host in an editor (ACP/Zed) | How-to | `how-to/use-from-an-editor.md` | | ||
| 14 | +| Add a skill | How-to | `how-to/add-a-skill.md` | | ||
| 15 | +| Long session inside the window | How-to | `how-to/manage-the-context-window.md` | | ||
| 16 | +| Start a new session (`/new`) | How-to | `how-to/start-a-new-session.md` — written by the other agent (bob) | | ||
| 17 | +| CLI flags, env vars, banner, exit codes | Reference | `reference/cli.md` | | ||
| 18 | +| Slash commands and keys | Reference | `reference/slash-commands.md` — bob edited the `/new` row after me | | ||
| 19 | +| YAML keys, defaults, validation errors | Reference | `reference/configuration.md` | | ||
| 20 | +| Built-in tools | Reference | `reference/tools.md` | | ||
| 21 | +| Skill file format | Reference | `reference/skill-format.md` | | ||
| 22 | +| ACP surface | Reference | `reference/acp.md` | | ||
| 23 | +| One loop, two front ends | Explanation | `explanation/architecture.md` | | ||
| 24 | +| Providers | Explanation | `explanation/providers.md` | | ||
| 25 | +| Context compression | Explanation | `explanation/context-compression.md` | | ||
| 26 | + | ||
| 27 | +The skill's mandatory language question was not asked (user unavailable); "Both" was taken from the methodical-dev convention. If the user wants a single language, delete the other tree and the selector in `docs/README.md`. | ||
| 28 | + | ||
| 29 | +## Traps found (documented, not fixed — no Go changes were in my scope) | ||
| 30 | + | ||
| 31 | +1. **Shipped skills are invisible.** `skills.List` globs `<skillsDir>/*.md`; the examples are `skills/<name>/SKILL.md`. Banner shows `skills: 0`, `read_skill` is never declared. Either flatten the files or teach the loader to read `*/SKILL.md`. The docs describe the current behaviour and tell the user to flatten. | ||
| 32 | +2. **`go test ./...` fails** in `mm/internal/skills`: `TestShippedSkillsHaveDescriptions` hard-codes `../../../demo/skills`, a path from the project this code was extracted from. Fix the path (and the layout above) or the quality gate will never be green. | ||
| 33 | +3. The tutorial's banner line was captured verbatim from a real run; it now includes `"/new" to start a new session.` because bob changed `agent.go`. If the banner text changes again, update both tutorials. | ||
| 34 | +4. `agent.yaml` calls the agent "Bob", ACP `agentInfo.name` is `bob` / version `0.11.0`, the binary is `mm`. The docs use `mm` for the binary and quote the ACP values as-is. | ||
| 35 | + | ||
| 36 | +## Not verified | ||
| 37 | + | ||
| 38 | +- No model server in the sandbox: step 3 of the tutorial (a real generation) was written from the code paths, not observed. The banner and `-h` output were observed. | ||
| 39 | +- `docs/diagrams/packages.drawio` was created by bob; I linked it from both indexes and both architecture pages without validating its content. | ||
| 40 | + | ||
| 41 | +## Suggested next increments | ||
| 42 | + | ||
| 43 | +- A how-to for `editTools: false` (bash-only A/B mode) once someone has actually run it. | ||
| 44 | +- A reference page for the engine's runtime messages (`[watchdog: …]`, `Retrying without streaming`) — currently only mentioned in passing. | ||
| 45 | +- Explanation of the loop detector once its thresholds (history 10, threshold 3, set in `main.go`) are considered stable. | ||
| 46 | +- bob updated `reference/slash-commands.md` and `reference/acp.md` in both languages for `/new` (row, ACP section, `available_commands_update`) after I wrote them; at the end of my session they were consistent with the code. Re-check only if `/new` changes again. | ||
| 47 | + | ||
| 48 | +## Addendum (same day, orchestrator) | ||
| 49 | + | ||
| 50 | +The Zed snippet in `how-to/use-from-an-editor.md` (EN + FR) was missing `"type": "custom"`; the user confirmed Zed needs it. Fixed in both pages. Any future editor how-to must include that key. | ||
added
.memory/handoffs/2026-09-15-new-command.md +41 -0 | new file mode 100644 | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +# Handoff — 2026-09-15 — `/new` slash command | |
| 2 | + | |
| 3 | +## State | |
| 4 | + | |
| 5 | +`/new` works in both front ends and is documented in EN and FR. `go build`, `go vet` and every test pass except one pre-existing failure (below). The quality gate is configured (`.qlty/qlty.toml`, exclusions `kits/**` and `tmp/**`) and FAILS on pre-existing debt only. `.memory/` was created by this session. Nothing is committed: all of `internal/`, `main.go`, `go.mod`, `docs/`, `.qlty/`, `.quality/`, `.memory/` are untracked (only `README.md`, `git.sh`, `.tickets/*.yaml`, `.vscode/` are tracked). | |
| 6 | + | |
| 7 | +## In flight | |
| 8 | + | |
| 9 | +Nothing on the `/new` feature itself. Agent "riker" was writing `docs/` at the same time; the pages this session edited in place (`docs/{en,fr}/reference/slash-commands.md`, `acp.md`, both `README.md`, both `explanation/architecture.md`) may be rewritten by riker — check that the `/new` rows, the "ACP mode" paragraph, the "Available commands" section and the `packages.drawio` link survived. | |
| 10 | + | |
| 11 | +## Next steps | |
| 12 | + | |
| 13 | +1. Decide the pre-existing test: `internal/skills/skills_test.go` `TestShippedSkillsHaveDescriptions` looks for `../../../demo/skills`; this repo has `./skills`. Changing the path to `../../skills` is a one-line fix, left to the user. | |
| 14 | +2. Decide the quality debt: bump `go.mod` `go 1.25.0` → `1.26` (clears 47 of 55 osv findings), `go get -u` the indirect deps flagged (`jsonparser`, `otel`, `x/sys`), fix the 12 `errcheck` sites, and refactor `agent.Run` (complexity 121) — the last one needs tests around the REPL first. | |
| 15 | +3. Verify in a real Zed that the slash menu shows `/new` (the notification is sent before the `session/new` response). | |
| 16 | +4. Commit: propose one commit for code + tests + docs + `.qlty/` + `.quality/` + `.memory/` (see history entry). Not done — the skill forbids committing without an explicit request. | |
| 17 | + | |
| 18 | +## Open questions / blockers | |
| 19 | + | |
| 20 | +- Should `/compact` also be exposed over ACP? Not asked; the code deliberately advertises only what `session/prompt` intercepts. | |
| 21 | +- Should `/new` over ACP also drop "allow always" grants? This session chose to keep them (same session id, same promise to the user). | |
| 22 | + | |
| 23 | +## Watch out for | |
| 24 | + | |
| 25 | +- `qlty init` only looks at git-tracked files: it detected no Go here and enabled no Go plugin. The Go plugins were added by hand in `.qlty/qlty.toml`; do not re-run `qlty init`. | |
| 26 | +- qlty's default `exclude_patterns` contains `**/config/**`, which would silently hide `internal/config`. It was removed from the list; do not paste the defaults back. | |
| 27 | +- Inside `internal/acp`, `mm/internal/session` is imported as `history` because the package has its own `session` struct. | |
| 28 | +- Piped stdin to the REPL loses lines after the first (the `bufio.Reader` is re-created per iteration). Use the scripted JSON-RPC client approach (python `subprocess`, one JSON object per line) to smoke-test ACP, and expect only the first line of a piped REPL session to be read. | |
| 29 | +- Run Go through `bash -l -c "..."` when `go` is not on `PATH`. | |
| 30 | + | |
| 31 | +## Addendum (same day, orchestrator) — step 3 done, root cause found | |
| 32 | + | |
| 33 | +The user tried it in Zed: "Available commands for mini-me: none". Cause: the notification went out before the `session/new` response, and Zed drops updates for a session id it does not know yet. Fixed by a `responseWriter` wrapper around the SDK's output that fires the notification after the response line is written (see `internal/acp/acp.go`, history entry of the same day). Verified on the wire with the real binary and with a pipe-driven test; still to be confirmed by the user inside Zed. If Zed still shows no commands, the next suspect is the `initialize` exchange (Zed may require `protocolVersion` ≥ 1 and reads `agentCapabilities`), not the ordering. | |
| 34 | + | |
| 35 | +## Addendum 2 (same day, orchestrator) — `/compact` and `/abort` over ACP | |
| 36 | + | |
| 37 | +User confirmed `/new` appears in Zed after the ordering fix. `/compact` and `/abort` were added the same way (history entry of the same day). Left for the user to confirm in Zed: the two new menu entries, and `/abort` while a generation runs — Zed may not send a prompt during a running turn, in which case `/abort` will only ever say "No generation in progress" and the stop button remains the way to cancel. If so, the honest move is to drop `/abort` from `availableCommands()` rather than advertise a no-op. | |
| 38 | + | |
| 39 | +## Addendum 3 — trap: sandbox builds clobber the Mac binary | |
| 40 | + | |
| 41 | +`go build -o mm .` in the sandbox writes a linux/arm64 ELF at the repo's `./mm`, the same path the user's `install.sh` builds and copies from. Do not run `install.sh` from the sandbox, and tell the user to rebuild natively after sandbox builds. Separately, `install.sh` copies over the installed binary in place; on Apple Silicon that can yield `Killed: 9` until the target is removed first — fix pending the user's go-ahead. | |
| new file mode 100644 | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | +# Handoff — 2026-09-15 — `/new` slash command | ||
| 2 | + | ||
| 3 | +## State | ||
| 4 | + | ||
| 5 | +`/new` works in both front ends and is documented in EN and FR. `go build`, `go vet` and every test pass except one pre-existing failure (below). The quality gate is configured (`.qlty/qlty.toml`, exclusions `kits/**` and `tmp/**`) and FAILS on pre-existing debt only. `.memory/` was created by this session. Nothing is committed: all of `internal/`, `main.go`, `go.mod`, `docs/`, `.qlty/`, `.quality/`, `.memory/` are untracked (only `README.md`, `git.sh`, `.tickets/*.yaml`, `.vscode/` are tracked). | ||
| 6 | + | ||
| 7 | +## In flight | ||
| 8 | + | ||
| 9 | +Nothing on the `/new` feature itself. Agent "riker" was writing `docs/` at the same time; the pages this session edited in place (`docs/{en,fr}/reference/slash-commands.md`, `acp.md`, both `README.md`, both `explanation/architecture.md`) may be rewritten by riker — check that the `/new` rows, the "ACP mode" paragraph, the "Available commands" section and the `packages.drawio` link survived. | ||
| 10 | + | ||
| 11 | +## Next steps | ||
| 12 | + | ||
| 13 | +1. Decide the pre-existing test: `internal/skills/skills_test.go` `TestShippedSkillsHaveDescriptions` looks for `../../../demo/skills`; this repo has `./skills`. Changing the path to `../../skills` is a one-line fix, left to the user. | ||
| 14 | +2. Decide the quality debt: bump `go.mod` `go 1.25.0` → `1.26` (clears 47 of 55 osv findings), `go get -u` the indirect deps flagged (`jsonparser`, `otel`, `x/sys`), fix the 12 `errcheck` sites, and refactor `agent.Run` (complexity 121) — the last one needs tests around the REPL first. | ||
| 15 | +3. Verify in a real Zed that the slash menu shows `/new` (the notification is sent before the `session/new` response). | ||
| 16 | +4. Commit: propose one commit for code + tests + docs + `.qlty/` + `.quality/` + `.memory/` (see history entry). Not done — the skill forbids committing without an explicit request. | ||
| 17 | + | ||
| 18 | +## Open questions / blockers | ||
| 19 | + | ||
| 20 | +- Should `/compact` also be exposed over ACP? Not asked; the code deliberately advertises only what `session/prompt` intercepts. | ||
| 21 | +- Should `/new` over ACP also drop "allow always" grants? This session chose to keep them (same session id, same promise to the user). | ||
| 22 | + | ||
| 23 | +## Watch out for | ||
| 24 | + | ||
| 25 | +- `qlty init` only looks at git-tracked files: it detected no Go here and enabled no Go plugin. The Go plugins were added by hand in `.qlty/qlty.toml`; do not re-run `qlty init`. | ||
| 26 | +- qlty's default `exclude_patterns` contains `**/config/**`, which would silently hide `internal/config`. It was removed from the list; do not paste the defaults back. | ||
| 27 | +- Inside `internal/acp`, `mm/internal/session` is imported as `history` because the package has its own `session` struct. | ||
| 28 | +- Piped stdin to the REPL loses lines after the first (the `bufio.Reader` is re-created per iteration). Use the scripted JSON-RPC client approach (python `subprocess`, one JSON object per line) to smoke-test ACP, and expect only the first line of a piped REPL session to be read. | ||
| 29 | +- Run Go through `bash -l -c "..."` when `go` is not on `PATH`. | ||
| 30 | + | ||
| 31 | +## Addendum (same day, orchestrator) — step 3 done, root cause found | ||
| 32 | + | ||
| 33 | +The user tried it in Zed: "Available commands for mini-me: none". Cause: the notification went out before the `session/new` response, and Zed drops updates for a session id it does not know yet. Fixed by a `responseWriter` wrapper around the SDK's output that fires the notification after the response line is written (see `internal/acp/acp.go`, history entry of the same day). Verified on the wire with the real binary and with a pipe-driven test; still to be confirmed by the user inside Zed. If Zed still shows no commands, the next suspect is the `initialize` exchange (Zed may require `protocolVersion` ≥ 1 and reads `agentCapabilities`), not the ordering. | ||
| 34 | + | ||
| 35 | +## Addendum 2 (same day, orchestrator) — `/compact` and `/abort` over ACP | ||
| 36 | + | ||
| 37 | +User confirmed `/new` appears in Zed after the ordering fix. `/compact` and `/abort` were added the same way (history entry of the same day). Left for the user to confirm in Zed: the two new menu entries, and `/abort` while a generation runs — Zed may not send a prompt during a running turn, in which case `/abort` will only ever say "No generation in progress" and the stop button remains the way to cancel. If so, the honest move is to drop `/abort` from `availableCommands()` rather than advertise a no-op. | ||
| 38 | + | ||
| 39 | +## Addendum 3 — trap: sandbox builds clobber the Mac binary | ||
| 40 | + | ||
| 41 | +`go build -o mm .` in the sandbox writes a linux/arm64 ELF at the repo's `./mm`, the same path the user's `install.sh` builds and copies from. Do not run `install.sh` from the sandbox, and tell the user to rebuild natively after sandbox builds. Separately, `install.sh` copies over the installed binary in place; on Apple Silicon that can yield `Killed: 9` until the target is removed first — fix pending the user's go-ahead. | ||
added
.memory/handoffs/2026-09-15-skills-layout.md +25 -0 | new file mode 100644 | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +# Handoff — 2026-09-15 — skills layout and skillsDir resolution | |
| 2 | + | |
| 3 | +## Where it stopped | |
| 4 | + | |
| 5 | +Done and green: the loader accepts `<skillsDir>/<name>.md` and `<skillsDir>/<name>/SKILL.md`; a relative `skillsDir` is resolved from the directory `mm` is started in (terminal) or next to the config file (ACP with a config); the banner warns with the absolute path when nothing is found. `/abort` no longer exists over ACP (user's call: Escape in Zed already cancels). Nothing committed. | |
| 6 | + | |
| 7 | +## For the user to do on the Mac | |
| 8 | + | |
| 9 | +- `sudo rm /usr/local/bin/mm && ./install.sh` — remove-then-copy avoids the `Killed: 9` of an in-place `cp` on Apple Silicon (see history entry "mm killed"). | |
| 10 | +- Start `mm` from the repository root: the banner must say `skills: 3` and list `read_skill`. | |
| 11 | +- `agent.yaml` was renamed `agent.dmr.yaml`: a Zed `AGENT_CONFIG` still pointing at `agent.yaml` names a missing file, which is a hard config error. Point it at `agent.dmr.yaml` or `agent.llamacpp.yaml` (absolute path). | |
| 12 | + | |
| 13 | +## Traps | |
| 14 | + | |
| 15 | +- Never `go build -o mm .` from the sandbox: it clobbers the user's Mach-O at the same path. Build to `/tmp/…` for checks. | |
| 16 | +- `install.sh` still copies in place; making it `rm` first (or use `install`) is proposed, not applied. | |
| 17 | + | |
| 18 | +## Next increments (suggested) | |
| 19 | + | |
| 20 | +- In ACP mode without a config file, `skillsDir` follows the process cwd; using each session's `cwd` from `session/new` would let one `mm` serve several projects' skills. | |
| 21 | +- `install.sh`: remove the target before copying. | |
| 22 | + | |
| 23 | +## Addendum — `@path` notation (same day) | |
| 24 | + | |
| 25 | +Implemented in `internal/mention`, wired in both front ends, documented EN+FR (`how-to/attach-a-file.md`). Not verified: a real model turn reading the attached file (no server in the sandbox). Possible next steps if asked: quoted mentions for paths with spaces (`@"my file.md"`), a `@dir/` listing hint, or inlining small files when `editTools` is off. | |
| new file mode 100644 | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | +# Handoff — 2026-09-15 — skills layout and skillsDir resolution | ||
| 2 | + | ||
| 3 | +## Where it stopped | ||
| 4 | + | ||
| 5 | +Done and green: the loader accepts `<skillsDir>/<name>.md` and `<skillsDir>/<name>/SKILL.md`; a relative `skillsDir` is resolved from the directory `mm` is started in (terminal) or next to the config file (ACP with a config); the banner warns with the absolute path when nothing is found. `/abort` no longer exists over ACP (user's call: Escape in Zed already cancels). Nothing committed. | ||
| 6 | + | ||
| 7 | +## For the user to do on the Mac | ||
| 8 | + | ||
| 9 | +- `sudo rm /usr/local/bin/mm && ./install.sh` — remove-then-copy avoids the `Killed: 9` of an in-place `cp` on Apple Silicon (see history entry "mm killed"). | ||
| 10 | +- Start `mm` from the repository root: the banner must say `skills: 3` and list `read_skill`. | ||
| 11 | +- `agent.yaml` was renamed `agent.dmr.yaml`: a Zed `AGENT_CONFIG` still pointing at `agent.yaml` names a missing file, which is a hard config error. Point it at `agent.dmr.yaml` or `agent.llamacpp.yaml` (absolute path). | ||
| 12 | + | ||
| 13 | +## Traps | ||
| 14 | + | ||
| 15 | +- Never `go build -o mm .` from the sandbox: it clobbers the user's Mach-O at the same path. Build to `/tmp/…` for checks. | ||
| 16 | +- `install.sh` still copies in place; making it `rm` first (or use `install`) is proposed, not applied. | ||
| 17 | + | ||
| 18 | +## Next increments (suggested) | ||
| 19 | + | ||
| 20 | +- In ACP mode without a config file, `skillsDir` follows the process cwd; using each session's `cwd` from `session/new` would let one `mm` serve several projects' skills. | ||
| 21 | +- `install.sh`: remove the target before copying. | ||
| 22 | + | ||
| 23 | +## Addendum — `@path` notation (same day) | ||
| 24 | + | ||
| 25 | +Implemented in `internal/mention`, wired in both front ends, documented EN+FR (`how-to/attach-a-file.md`). Not verified: a real model turn reading the attached file (no server in the sandbox). Possible next steps if asked: quoted mentions for paths with spaces (`@"my file.md"`), a `@dir/` listing hint, or inlining small files when `editTools` is off. | ||
added
.memory/history.md +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +# History | |
| 2 | + | |
| 3 | +One entry per session, appended, never rewritten. | |
| 4 | + | |
| 5 | +## 2026-09-15 — Diátaxis documentation bootstrap (agent "riker") | |
| 6 | + | |
| 7 | +- **Asked:** `/diataxis-doc` — start documenting the project, excluding `./kits` and `./tmp`. Ran in parallel with another agent adding `/new`. | |
| 8 | +- **Changed:** created `docs/` (language selector) with full four-quadrant trees in `docs/en/` and `docs/fr/`, identical file names: 1 tutorial, 4 how-to guides, 6 reference pages, 3 explanations per language, plus both indexes. Linked the `/new` how-to page and `docs/diagrams/packages.drawio` written by the other agent. No Go code touched. | |
| 9 | +- **Decisions:** language "Both" taken from the methodical-dev convention (user unavailable, skill's mandatory question skipped and recorded here). Shipped skill layout defect documented in how-to/reference rather than fixed. | |
| 10 | +- **Verified:** `go build` OK; banner output for both YAML files captured verbatim; `go test ./...` fails on `mm/internal/skills` (test points at `../../../demo/skills`). All relative links in `docs/` resolve. | |
| 11 | +- **Docs:** everything under `docs/`. Created `.memory/` skeleton. | |
| 12 | + | |
| 13 | +## 2026-09-15 — `/new` slash command (terminal + ACP), quality gate set-up, first `.memory/` | |
| 14 | + | |
| 15 | +- **Goal**: add `/new` to the `mm` agent to clear the history and start a fresh session, in the terminal REPL and over ACP; exclude `./kits` and `./tmp` from the quality measurement. Run by agent "bob" via `methodical-dev`, autonomously (the user was not available for the skill's approval gates), while agent "riker" wrote the rest of `docs/` in parallel. | |
| 16 | +- **Changes**: new package `internal/session` (`NewCommand`, `NewCommandName`, `IsNewCommand`, `Fresh`, `Forgotten`) + tests; `detector.Reset()` + first tests for `internal/detector`; `internal/agent/agent.go` (`/new` handling, `startNewSession`, banner, `if/else` → `switch` for staticcheck QF1003, both front ends now start from `session.Fresh`); `internal/acp/acp.go` (`availableCommands`, `available_commands_update` in `NewSession`, `/new` interception in `Prompt`, `startNewSession`, `resetSession`) + tests; `main.go` package list comment; `.qlty/qlty.toml` (created); `.quality/` (2 runs); docs (below); `.memory/` created. | |
| 17 | +- **Decisions**: `/new` resets history + engine token count + loop detector (REPL); ACP keeps session id / cwd / allow-always; only `/new` advertised over ACP; notification sent inside `NewSession` before the response; the `session` import is aliased `history` inside `internal/acp` because that package already has a `session` type. Rejected: reusing `compactHistory`'s "keep the detector" rule for `/new` (different situation: the task changes); creating a git worktree (all files untracked); fixing the pre-existing `demo/skills` test path (layout decision for the user). | |
| 18 | +- **Tests**: `go test ./...` — all packages pass except the pre-existing `internal/skills` failure (`TestShippedSkillsHaveDescriptions`, path `../../../demo/skills`). `/new` smoke-tested end to end: piped REPL prints `🆕 new session: 0 message(s) forgotten`; scripted JSON-RPC client sees `available_commands_update` then the `session/new` response, and `/new` answered by an `agent_message_chunk` + `end_turn`. | |
| 19 | +- **Quality**: gate FAIL, 2 iterations: warnings 69 → 67, smells 18 → 18, complexity 620 → 618. Fixed: `ineffassign` in the new test, staticcheck QF1003 in `agent.go`. Remaining findings are all pre-existing (osv on `go.mod`, errcheck, complexity of `agent.Run` etc.) and out of this change's scope. Nothing was excluded beyond `kits/**` and `tmp/**`; no threshold changed. | |
| 20 | +- **Docs**: `docs/{en,fr}/how-to/start-a-new-session.md` (new); `/new` rows and ACP paragraphs in `docs/{en,fr}/reference/slash-commands.md` and `acp.md` (riker's pages, edited in place); links in both `README.md` indexes; `docs/diagrams/packages.drawio` (new, from `go list`) referenced from both `explanation/architecture.md`. | |
| 21 | + | |
| 22 | +## 2026-09-15 — Zed config fix in the editor how-to (orchestrator) | |
| 23 | + | |
| 24 | +- **Asked**: the user reported that the Zed `agent_servers` snippet in the docs is insufficient — Zed requires `"type": "custom"` in the entry. | |
| 25 | +- **Changes**: added `"type": "custom"` to the JSON block in `docs/en/how-to/use-from-an-editor.md` and `docs/fr/how-to/use-from-an-editor.md`; recorded the requirement under "Decisions in force" in `summary.md`. | |
| 26 | +- **Tests / quality**: docs-only change, none run. | |
| 27 | + | |
| 28 | +## 2026-09-15 — ACP: `/new` was invisible in Zed's slash menu (orchestrator) | |
| 29 | + | |
| 30 | +- **Asked**: the user reported that Zed lists commands for other ACP agents (docker agent) but shows "Available commands for mini-me: none" and rejects `/new` as unknown. | |
| 31 | +- **Cause**: `available_commands_update` was sent inside `NewSession`, i.e. before the SDK wrote the `session/new` response. Zed keys sessions on the id in that response and drops updates for an unknown session id. | |
| 32 | +- **Changes**: `internal/acp/acp.go` — `Run` now builds the front via `newFront(e, system, tools, w, r)`; the SDK writes to a `responseWriter` that runs a per-session hook once the response line carrying that session id has been written; `NewSession` registers the hook (`context.WithoutCancel(ctx)`, the request context dies with the method) instead of calling `SessionUpdate` directly. `acp_test.go` — `TestResponseSessionIDMatchesOnlySessionResponses`, `TestAvailableCommandsAreSentAfterTheSessionNewResponse` (drives the real SDK over pipes, reads the raw lines back). Docs: `available_commands_update` row in `docs/{en,fr}/reference/acp.md`; `summary.md` decision updated, the "Zed slash menu" unknown removed. | |
| 33 | +- **Rejected**: a goroutine with a `time.Sleep` after `NewSession` returns (a race, just an unlikely one); sending the list on the first `session/prompt` (too late for the menu before the first message). | |
| 34 | +- **Tests**: `go test -race ./internal/acp -count=5` ok; `go build` ok; real binary over stdin shows the response line before the notification line. | |
| 35 | +- **Not verified**: the menu in a real Zed (no Zed in the sandbox) — the user should retry. | |
| 36 | + | |
| 37 | +## 2026-09-15 — ACP: `/compact` and `/abort` alongside `/new` (orchestrator) | |
| 38 | + | |
| 39 | +- **Asked**: the user confirmed `/new` now shows in Zed and asked for the same for `/compact` and `/abort`. | |
| 40 | +- **Changes**: `internal/acp/acp.go` — `compactCommand`/`abortCommand` constants, `isCommand`, three advertised commands, `/abort` intercepted in `Prompt` before `turnMu` (`abortTurn` → shared `cancelTurn`, also used by `Cancel`), `/compact` after `turnMu` (`compactSession` → `compactHistory(ctx, s, cfg, summarize, explain) (line, compressed)`, engine-free and testable). `internal/compact/compact.go` — `Result.Report()` plus `kilo`/`seconds` moved here from `internal/agent/agent.go`, which now prints `res.Report()`. Tests: `TestAvailableCommandsMatchWhatPromptIntercepts`, `TestIsCommandTrimsAndRequiresExactMatch`, `TestCompactHistoryOutcomes` (3 sub-tests), `TestCancelTurnFiresOnlyWhenATurnIsInFlight`; the wire-order test now compares against `availableCommands()`. Docs EN+FR: `reference/slash-commands.md` (ACP section became a table), `reference/acp.md` (`session/new` row, three `session/prompt` rows, commands table), `how-to/use-from-an-editor.md` (cancel + compress/restart bullets), `how-to/manage-the-context-window.md`. | |
| 41 | +- **Decisions**: `/quit` stays terminal-only; `/compact` over ACP is always forced (no automatic compaction exists in the ACP front end, unchanged); `/abort` answers `⚠️ No generation in progress.` when idle rather than erroring. Rejected: handling `/abort` under `turnMu` (it could never reach a running turn). | |
| 42 | +- **Tests**: `go test -race` on acp/compact ok; full `go test ./...` still fails only on the pre-existing `internal/skills` path test. Real binary driven by a small JSON-RPC client: commands list `new, compact, abort`; `/abort` idle → `⚠️ No generation in progress.`; ` /compact\n` on an empty history → `🗜️ nothing to compact: 1 message(s), no turn older than the last 3`; both `end_turn`. | |
| 43 | +- **Not verified**: `/abort` against a real running generation (no model server in the sandbox) and the two new entries in Zed's menu. | |
| 44 | + | |
| 45 | +## 2026-09-15 — `mm` killed on the user's Mac (orchestrator, diagnosis only) | |
| 46 | + | |
| 47 | +- **Asked**: running `mm` on the Mac prints `[1] 55918 killed mm`. | |
| 48 | +- **Findings**: the repo's `./mm` is a Mach-O (magic `CF FA ED FE`), so the user rebuilt natively after the sandbox builds (the sandbox produced linux/arm64 ELF binaries at the same path in between — `mm` is gitignored, fine). SIGKILL of a freshly built Go binary on macOS is the invalid-signature kill; most likely cause is `install.sh` overwriting `/usr/local/bin/mm` IN PLACE with `cp` (the kernel keeps the old inode's signature cached → `Killed: 9`), possibly compounded by the repo living in a kDrive-synced folder. Not verifiable from the sandbox. | |
| 49 | +- **Proposed, not applied**: `sudo rm /usr/local/bin/mm && ./install.sh`; make `install.sh` remove the target (or use `install`) before copying; `codesign -vv /usr/local/bin/mm` to confirm. | |
| 50 | + | |
| 51 | +## 2026-09-15 — "the agent does not see the skills" (orchestrator, diagnosis only) | |
| 52 | + | |
| 53 | +- **Cause confirmed in code**: `skills.List` globs `<skillsDir>/*.md` and `skills.Read` opens `<skillsDir>/<name>.md`, while the shipped skills live in `skills/<name>/SKILL.md` — so `skills: 0` and `read_skill` is never declared. Two aggravating factors: in terminal mode a relative `skillsDir` resolves against the current directory, and `agent.yaml` has been renamed `agent.dmr.yaml` by the user (no `./agent.yaml` → built-in defaults; a Zed `AGENT_CONFIG` still pointing to `agent.yaml` names a missing file, which `config.Load` treats as an error). | |
| 54 | +- **Proposed, not applied**: make `List`/`Read` accept both layouts (`*.md` and `*/SKILL.md`, name = directory), fix the test path `../../../demo/skills` → `../../skills`. | |
| 55 | + | |
| 56 | +## 2026-09-15 — Skills layout fix, skillsDir resolution, `/abort` removed from ACP (orchestrator) | |
| 57 | + | |
| 58 | +- **Asked**: fix the skills loader; make an installed `mm` (`/usr/local/bin`) load the skills of the directory it is started from; drop `/abort` from ACP because Escape already cancels in Zed. | |
| 59 | +- **Changes**: `internal/skills/skills.go` — `List` globs both `<dir>/*.md` and `<dir>/*/SKILL.md`, `defaultName` uses the directory name for a `SKILL.md`, `Read` tries `<name>.md` then `<name>/SKILL.md` (flat wins); tests `TestListAcceptsFlatAndNestedLayouts`, `TestReadAcceptsFlatAndNestedLayouts`, shipped-skills test path → `../../skills`. `main.go` — `resolveSkillsDir(dir, configPath, acpMode)` makes a relative `skillsDir` absolute from the current directory (terminal, or ACP without config) or next to the config file (ACP with one); a `[warning: no skills found in <abs path> (expected <name>.md or <name>/SKILL.md there)]` line when the count is 0. `internal/acp/acp.go` — `/abort` handling, `abortTurn`, `cancelTurn` removed; `Cancel` back to its inline form; `availableCommands()` = `new`, `compact`; tests adjusted. Docs EN+FR: `how-to/add-a-skill.md`, `reference/skill-format.md`, `reference/tools.md`, `reference/configuration.md`, `reference/cli.md`, `how-to/use-from-an-editor.md`, `reference/slash-commands.md`, `reference/acp.md`. `summary.md`: known defects closed, skills decision added, ACP commands decision updated. | |
| 60 | +- **Decisions**: both layouts accepted rather than moving the shipped files (the `<name>/SKILL.md` form is the Agent Skills convention); flat file wins when both exist; the warning is printed whenever 0 skills are found, with the resolved absolute path, because "skills: 0" alone was the user's whole problem. | |
| 61 | +- **Tests**: `go test -race ./...` all green (first time `internal/skills` passes). Linux build to `/tmp/mm-linux` (NOT `./mm`, which is the user's Mach-O): from the repo root the banner lists `read_skill`; from `/tmp` it warns `no skills found in /tmp/skills`. | |
| 62 | +- **Not verified**: the Mac binary — the user must rebuild natively (`sudo rm /usr/local/bin/mm && ./install.sh`). | |
| 63 | + | |
| 64 | +## 2026-09-15 — `@path` notation in questions (orchestrator) | |
| 65 | + | |
| 66 | +- **Asked**: mini-me must understand `@path/file` in a question, without a picker. | |
| 67 | +- **Changes**: new `internal/mention` (`Expand(input, cwd) (string, []Attachment)`, `Attachment.Line()`): mentions start the input or follow a space/bracket/quote, end at a space, lose trailing punctuation; resolved against cwd, absolute, or `~/`; only existing paths attach, once each, as `[attached file: …]` / `[attached directory: …]` lines appended to the text (same shape as ACP `resource_link`). Wired in `internal/agent/agent.go` (cwd captured once in `Run`; `📎 <abs>` dim line per attachment) and `internal/acp/acp.go` (after `flatten`, against `s.cwd`; `[acp] session <id>: attached <path>` on stderr). Tests: 4 in `mention_test.go`. Docs EN+FR: new `how-to/attach-a-file.md`, README indexes, `reference/slash-commands.md` (new `@path` section), `reference/acp.md` (prompt content row), `how-to/use-from-an-editor.md`, `explanation/architecture.md` package table, `docs/diagrams/packages.drawio` (node + edges from agent and acp). | |
| 68 | +- **Decisions**: keep the sentence as typed and append the marker lines rather than rewriting the `@` token (mirrors what Zed sends: text + resource_link); no inlining of content (bash-first agent reads it); paths with spaces unsupported (documented); non-existing paths silently left alone rather than warned (a `@handle` in a sentence is normal text). | |
| 69 | +- **Tests**: `go test -race ./...` all green. Linux build in `/tmp`: typing `explain @main.go and @internal, not @nothing.go` prints two `📎` lines and none for the missing path. | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +# History | ||
| 2 | + | ||
| 3 | +One entry per session, appended, never rewritten. | ||
| 4 | + | ||
| 5 | +## 2026-09-15 — Diátaxis documentation bootstrap (agent "riker") | ||
| 6 | + | ||
| 7 | +- **Asked:** `/diataxis-doc` — start documenting the project, excluding `./kits` and `./tmp`. Ran in parallel with another agent adding `/new`. | ||
| 8 | +- **Changed:** created `docs/` (language selector) with full four-quadrant trees in `docs/en/` and `docs/fr/`, identical file names: 1 tutorial, 4 how-to guides, 6 reference pages, 3 explanations per language, plus both indexes. Linked the `/new` how-to page and `docs/diagrams/packages.drawio` written by the other agent. No Go code touched. | ||
| 9 | +- **Decisions:** language "Both" taken from the methodical-dev convention (user unavailable, skill's mandatory question skipped and recorded here). Shipped skill layout defect documented in how-to/reference rather than fixed. | ||
| 10 | +- **Verified:** `go build` OK; banner output for both YAML files captured verbatim; `go test ./...` fails on `mm/internal/skills` (test points at `../../../demo/skills`). All relative links in `docs/` resolve. | ||
| 11 | +- **Docs:** everything under `docs/`. Created `.memory/` skeleton. | ||
| 12 | + | ||
| 13 | +## 2026-09-15 — `/new` slash command (terminal + ACP), quality gate set-up, first `.memory/` | ||
| 14 | + | ||
| 15 | +- **Goal**: add `/new` to the `mm` agent to clear the history and start a fresh session, in the terminal REPL and over ACP; exclude `./kits` and `./tmp` from the quality measurement. Run by agent "bob" via `methodical-dev`, autonomously (the user was not available for the skill's approval gates), while agent "riker" wrote the rest of `docs/` in parallel. | ||
| 16 | +- **Changes**: new package `internal/session` (`NewCommand`, `NewCommandName`, `IsNewCommand`, `Fresh`, `Forgotten`) + tests; `detector.Reset()` + first tests for `internal/detector`; `internal/agent/agent.go` (`/new` handling, `startNewSession`, banner, `if/else` → `switch` for staticcheck QF1003, both front ends now start from `session.Fresh`); `internal/acp/acp.go` (`availableCommands`, `available_commands_update` in `NewSession`, `/new` interception in `Prompt`, `startNewSession`, `resetSession`) + tests; `main.go` package list comment; `.qlty/qlty.toml` (created); `.quality/` (2 runs); docs (below); `.memory/` created. | ||
| 17 | +- **Decisions**: `/new` resets history + engine token count + loop detector (REPL); ACP keeps session id / cwd / allow-always; only `/new` advertised over ACP; notification sent inside `NewSession` before the response; the `session` import is aliased `history` inside `internal/acp` because that package already has a `session` type. Rejected: reusing `compactHistory`'s "keep the detector" rule for `/new` (different situation: the task changes); creating a git worktree (all files untracked); fixing the pre-existing `demo/skills` test path (layout decision for the user). | ||
| 18 | +- **Tests**: `go test ./...` — all packages pass except the pre-existing `internal/skills` failure (`TestShippedSkillsHaveDescriptions`, path `../../../demo/skills`). `/new` smoke-tested end to end: piped REPL prints `🆕 new session: 0 message(s) forgotten`; scripted JSON-RPC client sees `available_commands_update` then the `session/new` response, and `/new` answered by an `agent_message_chunk` + `end_turn`. | ||
| 19 | +- **Quality**: gate FAIL, 2 iterations: warnings 69 → 67, smells 18 → 18, complexity 620 → 618. Fixed: `ineffassign` in the new test, staticcheck QF1003 in `agent.go`. Remaining findings are all pre-existing (osv on `go.mod`, errcheck, complexity of `agent.Run` etc.) and out of this change's scope. Nothing was excluded beyond `kits/**` and `tmp/**`; no threshold changed. | ||
| 20 | +- **Docs**: `docs/{en,fr}/how-to/start-a-new-session.md` (new); `/new` rows and ACP paragraphs in `docs/{en,fr}/reference/slash-commands.md` and `acp.md` (riker's pages, edited in place); links in both `README.md` indexes; `docs/diagrams/packages.drawio` (new, from `go list`) referenced from both `explanation/architecture.md`. | ||
| 21 | + | ||
| 22 | +## 2026-09-15 — Zed config fix in the editor how-to (orchestrator) | ||
| 23 | + | ||
| 24 | +- **Asked**: the user reported that the Zed `agent_servers` snippet in the docs is insufficient — Zed requires `"type": "custom"` in the entry. | ||
| 25 | +- **Changes**: added `"type": "custom"` to the JSON block in `docs/en/how-to/use-from-an-editor.md` and `docs/fr/how-to/use-from-an-editor.md`; recorded the requirement under "Decisions in force" in `summary.md`. | ||
| 26 | +- **Tests / quality**: docs-only change, none run. | ||
| 27 | + | ||
| 28 | +## 2026-09-15 — ACP: `/new` was invisible in Zed's slash menu (orchestrator) | ||
| 29 | + | ||
| 30 | +- **Asked**: the user reported that Zed lists commands for other ACP agents (docker agent) but shows "Available commands for mini-me: none" and rejects `/new` as unknown. | ||
| 31 | +- **Cause**: `available_commands_update` was sent inside `NewSession`, i.e. before the SDK wrote the `session/new` response. Zed keys sessions on the id in that response and drops updates for an unknown session id. | ||
| 32 | +- **Changes**: `internal/acp/acp.go` — `Run` now builds the front via `newFront(e, system, tools, w, r)`; the SDK writes to a `responseWriter` that runs a per-session hook once the response line carrying that session id has been written; `NewSession` registers the hook (`context.WithoutCancel(ctx)`, the request context dies with the method) instead of calling `SessionUpdate` directly. `acp_test.go` — `TestResponseSessionIDMatchesOnlySessionResponses`, `TestAvailableCommandsAreSentAfterTheSessionNewResponse` (drives the real SDK over pipes, reads the raw lines back). Docs: `available_commands_update` row in `docs/{en,fr}/reference/acp.md`; `summary.md` decision updated, the "Zed slash menu" unknown removed. | ||
| 33 | +- **Rejected**: a goroutine with a `time.Sleep` after `NewSession` returns (a race, just an unlikely one); sending the list on the first `session/prompt` (too late for the menu before the first message). | ||
| 34 | +- **Tests**: `go test -race ./internal/acp -count=5` ok; `go build` ok; real binary over stdin shows the response line before the notification line. | ||
| 35 | +- **Not verified**: the menu in a real Zed (no Zed in the sandbox) — the user should retry. | ||
| 36 | + | ||
| 37 | +## 2026-09-15 — ACP: `/compact` and `/abort` alongside `/new` (orchestrator) | ||
| 38 | + | ||
| 39 | +- **Asked**: the user confirmed `/new` now shows in Zed and asked for the same for `/compact` and `/abort`. | ||
| 40 | +- **Changes**: `internal/acp/acp.go` — `compactCommand`/`abortCommand` constants, `isCommand`, three advertised commands, `/abort` intercepted in `Prompt` before `turnMu` (`abortTurn` → shared `cancelTurn`, also used by `Cancel`), `/compact` after `turnMu` (`compactSession` → `compactHistory(ctx, s, cfg, summarize, explain) (line, compressed)`, engine-free and testable). `internal/compact/compact.go` — `Result.Report()` plus `kilo`/`seconds` moved here from `internal/agent/agent.go`, which now prints `res.Report()`. Tests: `TestAvailableCommandsMatchWhatPromptIntercepts`, `TestIsCommandTrimsAndRequiresExactMatch`, `TestCompactHistoryOutcomes` (3 sub-tests), `TestCancelTurnFiresOnlyWhenATurnIsInFlight`; the wire-order test now compares against `availableCommands()`. Docs EN+FR: `reference/slash-commands.md` (ACP section became a table), `reference/acp.md` (`session/new` row, three `session/prompt` rows, commands table), `how-to/use-from-an-editor.md` (cancel + compress/restart bullets), `how-to/manage-the-context-window.md`. | ||
| 41 | +- **Decisions**: `/quit` stays terminal-only; `/compact` over ACP is always forced (no automatic compaction exists in the ACP front end, unchanged); `/abort` answers `⚠️ No generation in progress.` when idle rather than erroring. Rejected: handling `/abort` under `turnMu` (it could never reach a running turn). | ||
| 42 | +- **Tests**: `go test -race` on acp/compact ok; full `go test ./...` still fails only on the pre-existing `internal/skills` path test. Real binary driven by a small JSON-RPC client: commands list `new, compact, abort`; `/abort` idle → `⚠️ No generation in progress.`; ` /compact\n` on an empty history → `🗜️ nothing to compact: 1 message(s), no turn older than the last 3`; both `end_turn`. | ||
| 43 | +- **Not verified**: `/abort` against a real running generation (no model server in the sandbox) and the two new entries in Zed's menu. | ||
| 44 | + | ||
| 45 | +## 2026-09-15 — `mm` killed on the user's Mac (orchestrator, diagnosis only) | ||
| 46 | + | ||
| 47 | +- **Asked**: running `mm` on the Mac prints `[1] 55918 killed mm`. | ||
| 48 | +- **Findings**: the repo's `./mm` is a Mach-O (magic `CF FA ED FE`), so the user rebuilt natively after the sandbox builds (the sandbox produced linux/arm64 ELF binaries at the same path in between — `mm` is gitignored, fine). SIGKILL of a freshly built Go binary on macOS is the invalid-signature kill; most likely cause is `install.sh` overwriting `/usr/local/bin/mm` IN PLACE with `cp` (the kernel keeps the old inode's signature cached → `Killed: 9`), possibly compounded by the repo living in a kDrive-synced folder. Not verifiable from the sandbox. | ||
| 49 | +- **Proposed, not applied**: `sudo rm /usr/local/bin/mm && ./install.sh`; make `install.sh` remove the target (or use `install`) before copying; `codesign -vv /usr/local/bin/mm` to confirm. | ||
| 50 | + | ||
| 51 | +## 2026-09-15 — "the agent does not see the skills" (orchestrator, diagnosis only) | ||
| 52 | + | ||
| 53 | +- **Cause confirmed in code**: `skills.List` globs `<skillsDir>/*.md` and `skills.Read` opens `<skillsDir>/<name>.md`, while the shipped skills live in `skills/<name>/SKILL.md` — so `skills: 0` and `read_skill` is never declared. Two aggravating factors: in terminal mode a relative `skillsDir` resolves against the current directory, and `agent.yaml` has been renamed `agent.dmr.yaml` by the user (no `./agent.yaml` → built-in defaults; a Zed `AGENT_CONFIG` still pointing to `agent.yaml` names a missing file, which `config.Load` treats as an error). | ||
| 54 | +- **Proposed, not applied**: make `List`/`Read` accept both layouts (`*.md` and `*/SKILL.md`, name = directory), fix the test path `../../../demo/skills` → `../../skills`. | ||
| 55 | + | ||
| 56 | +## 2026-09-15 — Skills layout fix, skillsDir resolution, `/abort` removed from ACP (orchestrator) | ||
| 57 | + | ||
| 58 | +- **Asked**: fix the skills loader; make an installed `mm` (`/usr/local/bin`) load the skills of the directory it is started from; drop `/abort` from ACP because Escape already cancels in Zed. | ||
| 59 | +- **Changes**: `internal/skills/skills.go` — `List` globs both `<dir>/*.md` and `<dir>/*/SKILL.md`, `defaultName` uses the directory name for a `SKILL.md`, `Read` tries `<name>.md` then `<name>/SKILL.md` (flat wins); tests `TestListAcceptsFlatAndNestedLayouts`, `TestReadAcceptsFlatAndNestedLayouts`, shipped-skills test path → `../../skills`. `main.go` — `resolveSkillsDir(dir, configPath, acpMode)` makes a relative `skillsDir` absolute from the current directory (terminal, or ACP without config) or next to the config file (ACP with one); a `[warning: no skills found in <abs path> (expected <name>.md or <name>/SKILL.md there)]` line when the count is 0. `internal/acp/acp.go` — `/abort` handling, `abortTurn`, `cancelTurn` removed; `Cancel` back to its inline form; `availableCommands()` = `new`, `compact`; tests adjusted. Docs EN+FR: `how-to/add-a-skill.md`, `reference/skill-format.md`, `reference/tools.md`, `reference/configuration.md`, `reference/cli.md`, `how-to/use-from-an-editor.md`, `reference/slash-commands.md`, `reference/acp.md`. `summary.md`: known defects closed, skills decision added, ACP commands decision updated. | ||
| 60 | +- **Decisions**: both layouts accepted rather than moving the shipped files (the `<name>/SKILL.md` form is the Agent Skills convention); flat file wins when both exist; the warning is printed whenever 0 skills are found, with the resolved absolute path, because "skills: 0" alone was the user's whole problem. | ||
| 61 | +- **Tests**: `go test -race ./...` all green (first time `internal/skills` passes). Linux build to `/tmp/mm-linux` (NOT `./mm`, which is the user's Mach-O): from the repo root the banner lists `read_skill`; from `/tmp` it warns `no skills found in /tmp/skills`. | ||
| 62 | +- **Not verified**: the Mac binary — the user must rebuild natively (`sudo rm /usr/local/bin/mm && ./install.sh`). | ||
| 63 | + | ||
| 64 | +## 2026-09-15 — `@path` notation in questions (orchestrator) | ||
| 65 | + | ||
| 66 | +- **Asked**: mini-me must understand `@path/file` in a question, without a picker. | ||
| 67 | +- **Changes**: new `internal/mention` (`Expand(input, cwd) (string, []Attachment)`, `Attachment.Line()`): mentions start the input or follow a space/bracket/quote, end at a space, lose trailing punctuation; resolved against cwd, absolute, or `~/`; only existing paths attach, once each, as `[attached file: …]` / `[attached directory: …]` lines appended to the text (same shape as ACP `resource_link`). Wired in `internal/agent/agent.go` (cwd captured once in `Run`; `📎 <abs>` dim line per attachment) and `internal/acp/acp.go` (after `flatten`, against `s.cwd`; `[acp] session <id>: attached <path>` on stderr). Tests: 4 in `mention_test.go`. Docs EN+FR: new `how-to/attach-a-file.md`, README indexes, `reference/slash-commands.md` (new `@path` section), `reference/acp.md` (prompt content row), `how-to/use-from-an-editor.md`, `explanation/architecture.md` package table, `docs/diagrams/packages.drawio` (node + edges from agent and acp). | ||
| 68 | +- **Decisions**: keep the sentence as typed and append the marker lines rather than rewriting the `@` token (mirrors what Zed sends: text + resource_link); no inlining of content (bash-first agent reads it); paths with spaces unsupported (documented); non-existing paths silently left alone rather than warned (a `@handle` in a sentence is normal text). | ||
| 69 | +- **Tests**: `go test -race ./...` all green. Linux build in `/tmp`: typing `explain @main.go and @internal, not @nothing.go` prints two `📎` lines and none for the missing path. | ||
added
.memory/summary.md +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +# mini-me (mm) — project summary | |
| 2 | + | |
| 3 | +## What it is | |
| 4 | + | |
| 5 | +A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Two front ends share one loop: a terminal REPL (`internal/agent`) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`. | |
| 6 | + | |
| 7 | +## Architecture (verified 2026-09-15) | |
| 8 | + | |
| 9 | +`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history). | |
| 10 | + | |
| 11 | +## Commands | |
| 12 | + | |
| 13 | +| Purpose | Command | | |
| 14 | +|---------|---------| | |
| 15 | +| Build | `go build -o mm .` | | |
| 16 | +| Install | `./install.sh` (builds, copies to `/usr/local/bin`) | | |
| 17 | +| Test | `go test ./...` | | |
| 18 | +| Run (DMR) | `./mm` (reads `./agent.yaml`) | | |
| 19 | +| Run (llama.cpp) | `./mm agent.llamacpp.yaml` | | |
| 20 | +| Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` | | |
| 21 | +| Quality gate | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` (report under `.quality/`, exit 0 = pass) | | |
| 22 | + | |
| 23 | +Use `bash -l -c "…"` in this sandbox so `go` is on the PATH. | |
| 24 | + | |
| 25 | +## Decisions in force | |
| 26 | + | |
| 27 | +- Tools return failures as text, never as Go errors (the model must read them). | |
| 28 | +- Full history is kept after a failed/cancelled turn; only an unanswered question is dropped. | |
| 29 | +- One provider type per wire protocol (`openaiCompat`); `dmr` and `llamacpp` are registry entries. | |
| 30 | +- API keys are named by environment variable in YAML, never stored in it. | |
| 31 | +- `contextWindow` is one top-level key shared by the banner and the compression trigger. | |
| 32 | +- Documentation follows Diátaxis, bilingual: `docs/en/` and `docs/fr/` with identical file names; `kits/` and `tmp/` are out of scope. | |
| 33 | +- Slash commands: terminal `/quit`, `/abort`, `/compact`, `/new`; ACP `/new`, `/compact` only (no `/quit`: the editor owns the process; no `/abort`: Escape in Zed cancels through `session/cancel` — user's decision 2026-09-15, an earlier `/abort` was removed), advertised through `available_commands_update` sent right AFTER the `session/new` response is written (a hook on the writer handed to the SDK, `responseWriter` in `internal/acp/acp.go`, fires when the response line carrying the session id goes out). Sending it before the response does not work: Zed drops updates for a session id it has not received yet (user report 2026-09-15; fix confirmed working in Zed by the user). Only what `session/prompt` intercepts is advertised. `/new` and `/compact` are intercepted under `turnMu`, so the history never moves under Genkit. The compaction report line is `compact.Result.Report()`, shared by both front ends. | |
| 34 | +- `/new` resets the history (system prompt alone), the engine's last input-token count, and — terminal only — the loop detector (`detector.Reset`): a new session is a new task, unlike `/compact` which keeps the detector. Over ACP it keeps the session id, `cwd` and "allow always" grants. Shared logic lives in `internal/session`, imported as `history` inside `internal/acp` (name clash with its `session` struct). | |
| 35 | +- Quality gate scope: `.qlty/qlty.toml` excludes `kits/**` and `tmp/**` (user's request, 2026-09-15), drops qlty's default `**/config/**` exclusion so `internal/config` is measured, and enables `gofmt`, `golangci-lint`, `osv-scanner` by hand (`qlty init` only saw tracked files, hence no Go). Do not re-run `qlty init`. | |
| 36 | +- Zed `agent_servers` entry for mm must carry `"type": "custom"` alongside `command`/`args`/`env`; without it Zed rejects the entry (user report, 2026-09-15). Both `how-to/use-from-an-editor.md` pages show the full block. | |
| 37 | +- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file. A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0. | |
| 38 | +- `@path` mentions (`internal/mention`, `Expand(input, cwd)`): a typed `@path` that exists (relative to the start directory / session cwd, absolute, or `~/`) appends `[attached file: <abs>]` / `[attached directory: <abs>]` to the message — the exact shape of an ACP `resource_link` — and is echoed (`📎` line in the terminal, `[acp] … attached` on stderr). No picker, no completion, content never inlined; a non-existing path or a `@` glued to a word is left alone. Used by both front ends. | |
| 39 | +- The package import graph is kept in `docs/diagrams/packages.drawio` (generated from `go list`); update it when a package is added, removed or re-wired. | |
| 40 | + | |
| 41 | +## Known defects (verified 2026-09-15, not fixed) | |
| 42 | + | |
| 43 | +- None open. (The skills-layout bug and the `demo/skills` test path were fixed on 2026-09-15; `go test ./...` passes.) | |
| 44 | + | |
| 45 | +## Quality gate state (2026-09-15, 2 runs in `.quality/history.jsonl`) | |
| 46 | + | |
| 47 | +FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code. | |
| 48 | + | |
| 49 | +## Repository oddities | |
| 50 | + | |
| 51 | +- Almost everything is untracked in git (only two commits, "Updated."). `.gitignore` exists. | |
| 52 | +- `.tickets/` (IssueSpec) holds three open issues: add a help command, add an about command, improve TUI. | |
| 53 | +- `agent.yaml` names the agent "Bob" in its system prompt; ACP `agentInfo.name` is also `bob`, version `0.11.0`. The binary is `mm`. | |
| 54 | + | |
| 55 | +## Not yet established | |
| 56 | + | |
| 57 | +- Whether a real DMR or llama-server run works end to end from this sandbox (no model server here; only the banner and `-h` were exercised). | |
| 58 | +- Whether the project wants `go.mod`'s `go 1.25.0` bumped to 1.26 and the flagged indirect deps upgraded to clear the osv-scanner findings. | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +# mini-me (mm) — project summary | ||
| 2 | + | ||
| 3 | +## What it is | ||
| 4 | + | ||
| 5 | +A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Two front ends share one loop: a terminal REPL (`internal/agent`) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`. | ||
| 6 | + | ||
| 7 | +## Architecture (verified 2026-09-15) | ||
| 8 | + | ||
| 9 | +`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history). | ||
| 10 | + | ||
| 11 | +## Commands | ||
| 12 | + | ||
| 13 | +| Purpose | Command | | ||
| 14 | +|---------|---------| | ||
| 15 | +| Build | `go build -o mm .` | | ||
| 16 | +| Install | `./install.sh` (builds, copies to `/usr/local/bin`) | | ||
| 17 | +| Test | `go test ./...` | | ||
| 18 | +| Run (DMR) | `./mm` (reads `./agent.yaml`) | | ||
| 19 | +| Run (llama.cpp) | `./mm agent.llamacpp.yaml` | | ||
| 20 | +| Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` | | ||
| 21 | +| Quality gate | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` (report under `.quality/`, exit 0 = pass) | | ||
| 22 | + | ||
| 23 | +Use `bash -l -c "…"` in this sandbox so `go` is on the PATH. | ||
| 24 | + | ||
| 25 | +## Decisions in force | ||
| 26 | + | ||
| 27 | +- Tools return failures as text, never as Go errors (the model must read them). | ||
| 28 | +- Full history is kept after a failed/cancelled turn; only an unanswered question is dropped. | ||
| 29 | +- One provider type per wire protocol (`openaiCompat`); `dmr` and `llamacpp` are registry entries. | ||
| 30 | +- API keys are named by environment variable in YAML, never stored in it. | ||
| 31 | +- `contextWindow` is one top-level key shared by the banner and the compression trigger. | ||
| 32 | +- Documentation follows Diátaxis, bilingual: `docs/en/` and `docs/fr/` with identical file names; `kits/` and `tmp/` are out of scope. | ||
| 33 | +- Slash commands: terminal `/quit`, `/abort`, `/compact`, `/new`; ACP `/new`, `/compact` only (no `/quit`: the editor owns the process; no `/abort`: Escape in Zed cancels through `session/cancel` — user's decision 2026-09-15, an earlier `/abort` was removed), advertised through `available_commands_update` sent right AFTER the `session/new` response is written (a hook on the writer handed to the SDK, `responseWriter` in `internal/acp/acp.go`, fires when the response line carrying the session id goes out). Sending it before the response does not work: Zed drops updates for a session id it has not received yet (user report 2026-09-15; fix confirmed working in Zed by the user). Only what `session/prompt` intercepts is advertised. `/new` and `/compact` are intercepted under `turnMu`, so the history never moves under Genkit. The compaction report line is `compact.Result.Report()`, shared by both front ends. | ||
| 34 | +- `/new` resets the history (system prompt alone), the engine's last input-token count, and — terminal only — the loop detector (`detector.Reset`): a new session is a new task, unlike `/compact` which keeps the detector. Over ACP it keeps the session id, `cwd` and "allow always" grants. Shared logic lives in `internal/session`, imported as `history` inside `internal/acp` (name clash with its `session` struct). | ||
| 35 | +- Quality gate scope: `.qlty/qlty.toml` excludes `kits/**` and `tmp/**` (user's request, 2026-09-15), drops qlty's default `**/config/**` exclusion so `internal/config` is measured, and enables `gofmt`, `golangci-lint`, `osv-scanner` by hand (`qlty init` only saw tracked files, hence no Go). Do not re-run `qlty init`. | ||
| 36 | +- Zed `agent_servers` entry for mm must carry `"type": "custom"` alongside `command`/`args`/`env`; without it Zed rejects the entry (user report, 2026-09-15). Both `how-to/use-from-an-editor.md` pages show the full block. | ||
| 37 | +- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file. A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0. | ||
| 38 | +- `@path` mentions (`internal/mention`, `Expand(input, cwd)`): a typed `@path` that exists (relative to the start directory / session cwd, absolute, or `~/`) appends `[attached file: <abs>]` / `[attached directory: <abs>]` to the message — the exact shape of an ACP `resource_link` — and is echoed (`📎` line in the terminal, `[acp] … attached` on stderr). No picker, no completion, content never inlined; a non-existing path or a `@` glued to a word is left alone. Used by both front ends. | ||
| 39 | +- The package import graph is kept in `docs/diagrams/packages.drawio` (generated from `go list`); update it when a package is added, removed or re-wired. | ||
| 40 | + | ||
| 41 | +## Known defects (verified 2026-09-15, not fixed) | ||
| 42 | + | ||
| 43 | +- None open. (The skills-layout bug and the `demo/skills` test path were fixed on 2026-09-15; `go test ./...` passes.) | ||
| 44 | + | ||
| 45 | +## Quality gate state (2026-09-15, 2 runs in `.quality/history.jsonl`) | ||
| 46 | + | ||
| 47 | +FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code. | ||
| 48 | + | ||
| 49 | +## Repository oddities | ||
| 50 | + | ||
| 51 | +- Almost everything is untracked in git (only two commits, "Updated."). `.gitignore` exists. | ||
| 52 | +- `.tickets/` (IssueSpec) holds three open issues: add a help command, add an about command, improve TUI. | ||
| 53 | +- `agent.yaml` names the agent "Bob" in its system prompt; ACP `agentInfo.name` is also `bob`, version `0.11.0`. The binary is `mm`. | ||
| 54 | + | ||
| 55 | +## Not yet established | ||
| 56 | + | ||
| 57 | +- Whether a real DMR or llama-server run works end to end from this sandbox (no model server here; only the banner and `-h` were exercised). | ||
| 58 | +- Whether the project wants `go.mod`'s `go 1.25.0` bumped to 1.26 and the flagged indirect deps upgraded to clear the osv-scanner findings. | ||
added
.qlty/.gitignore +7 -0 | new file mode 100644 | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +* | |
| 2 | +!configs | |
| 3 | +!configs/** | |
| 4 | +!hooks | |
| 5 | +!hooks/** | |
| 6 | +!qlty.toml | |
| 7 | +!.gitignore | |
| new file mode 100644 | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | +* | ||
| 2 | +!configs | ||
| 3 | +!configs/** | ||
| 4 | +!hooks | ||
| 5 | +!hooks/** | ||
| 6 | +!qlty.toml | ||
| 7 | +!.gitignore | ||
added
.qlty/configs/.shellcheckrc +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +source-path=SCRIPTDIR | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +source-path=SCRIPTDIR | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
.qlty/qlty.toml +94 -0 | new file mode 100644 | ||
| @@ -0,0 +1,94 @@ | ||
| 1 | +# This file was automatically generated by `qlty init`. | |
| 2 | +# You can modify it to suit your needs. | |
| 3 | +# We recommend you to commit this file to your repository. | |
| 4 | +# | |
| 5 | +# This configuration is used by both Qlty CLI and Qlty Cloud. | |
| 6 | +# | |
| 7 | +# Qlty CLI -- Code quality toolkit for developers | |
| 8 | +# Qlty Cloud -- Fully automated Code Health Platform | |
| 9 | +# | |
| 10 | +# Try Qlty Cloud: https://qlty.sh | |
| 11 | +# | |
| 12 | +# For a guide to configuration, visit https://qlty.sh/d/config | |
| 13 | +# Or for a full reference, visit https://qlty.sh/d/qlty-toml | |
| 14 | +config_version = "0" | |
| 15 | + | |
| 16 | +exclude_patterns = [ | |
| 17 | + "*_min.*", | |
| 18 | + "*-min.*", | |
| 19 | + "*.min.*", | |
| 20 | + "**/.yarn/**", | |
| 21 | + "**/*.d.ts", | |
| 22 | + "**/assets/**", | |
| 23 | + "**/bower_components/**", | |
| 24 | + "**/build/**", | |
| 25 | + "**/cache/**", | |
| 26 | + # "**/config/**" is a qlty default, removed here: internal/config is project | |
| 27 | + # code (the YAML settings loader) and must be measured like the rest. | |
| 28 | + "**/db/**", | |
| 29 | + "**/deps/**", | |
| 30 | + "**/dist/**", | |
| 31 | + "**/extern/**", | |
| 32 | + "**/external/**", | |
| 33 | + "**/generated/**", | |
| 34 | + "**/Godeps/**", | |
| 35 | + "**/gradlew/**", | |
| 36 | + "**/mvnw/**", | |
| 37 | + "**/node_modules/**", | |
| 38 | + "**/protos/**", | |
| 39 | + "**/seed/**", | |
| 40 | + "**/target/**", | |
| 41 | + "**/templates/**", | |
| 42 | + "**/testdata/**", | |
| 43 | + "**/vendor/**", | |
| 44 | + # Excluded at the user's request (2026-09-15): | |
| 45 | + # kits/ is the vendored dev-toolkit kit — the sources installed into | |
| 46 | + # ~/.claude/, including the quality skill's own quality_report.py, so leaving | |
| 47 | + # it in scope would measure the instrument with itself (its subprocess use is | |
| 48 | + # flagged by every Python security linter and cannot be fixed). | |
| 49 | + "kits/**", | |
| 50 | + # tmp/ holds a draft agent (tmp/draft-agent) and a cross-compile script | |
| 51 | + # (tmp/build.sh) that are not part of the mm binary or its tests. | |
| 52 | + "tmp/**", | |
| 53 | +] | |
| 54 | + | |
| 55 | +test_patterns = [ | |
| 56 | + "**/test/**", | |
| 57 | + "**/spec/**", | |
| 58 | + "**/*.test.*", | |
| 59 | + "**/*.spec.*", | |
| 60 | + "**/*_test.*", | |
| 61 | + "**/*_spec.*", | |
| 62 | + "**/test_*.*", | |
| 63 | + "**/spec_*.*", | |
| 64 | +] | |
| 65 | + | |
| 66 | +[smells] | |
| 67 | +mode = "comment" | |
| 68 | + | |
| 69 | +[[source]] | |
| 70 | +name = "default" | |
| 71 | +default = true | |
| 72 | + | |
| 73 | + | |
| 74 | +[[plugin]] | |
| 75 | +name = "ripgrep" | |
| 76 | +mode = "comment" | |
| 77 | + | |
| 78 | +[[plugin]] | |
| 79 | +name = "shellcheck" | |
| 80 | + | |
| 81 | +[[plugin]] | |
| 82 | +name = "trufflehog" | |
| 83 | + | |
| 84 | +# `qlty init` only looks at files git tracks, and at init time every Go file of | |
| 85 | +# this project was still untracked, so it enabled none of the Go plugins. These | |
| 86 | +# are the ones it enables when it does see Go code. | |
| 87 | +[[plugin]] | |
| 88 | +name = "gofmt" | |
| 89 | + | |
| 90 | +[[plugin]] | |
| 91 | +name = "golangci-lint" | |
| 92 | + | |
| 93 | +[[plugin]] | |
| 94 | +name = "osv-scanner" | |
| new file mode 100644 | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | +# This file was automatically generated by `qlty init`. | ||
| 2 | +# You can modify it to suit your needs. | ||
| 3 | +# We recommend you to commit this file to your repository. | ||
| 4 | +# | ||
| 5 | +# This configuration is used by both Qlty CLI and Qlty Cloud. | ||
| 6 | +# | ||
| 7 | +# Qlty CLI -- Code quality toolkit for developers | ||
| 8 | +# Qlty Cloud -- Fully automated Code Health Platform | ||
| 9 | +# | ||
| 10 | +# Try Qlty Cloud: https://qlty.sh | ||
| 11 | +# | ||
| 12 | +# For a guide to configuration, visit https://qlty.sh/d/config | ||
| 13 | +# Or for a full reference, visit https://qlty.sh/d/qlty-toml | ||
| 14 | +config_version = "0" | ||
| 15 | + | ||
| 16 | +exclude_patterns = [ | ||
| 17 | + "*_min.*", | ||
| 18 | + "*-min.*", | ||
| 19 | + "*.min.*", | ||
| 20 | + "**/.yarn/**", | ||
| 21 | + "**/*.d.ts", | ||
| 22 | + "**/assets/**", | ||
| 23 | + "**/bower_components/**", | ||
| 24 | + "**/build/**", | ||
| 25 | + "**/cache/**", | ||
| 26 | + # "**/config/**" is a qlty default, removed here: internal/config is project | ||
| 27 | + # code (the YAML settings loader) and must be measured like the rest. | ||
| 28 | + "**/db/**", | ||
| 29 | + "**/deps/**", | ||
| 30 | + "**/dist/**", | ||
| 31 | + "**/extern/**", | ||
| 32 | + "**/external/**", | ||
| 33 | + "**/generated/**", | ||
| 34 | + "**/Godeps/**", | ||
| 35 | + "**/gradlew/**", | ||
| 36 | + "**/mvnw/**", | ||
| 37 | + "**/node_modules/**", | ||
| 38 | + "**/protos/**", | ||
| 39 | + "**/seed/**", | ||
| 40 | + "**/target/**", | ||
| 41 | + "**/templates/**", | ||
| 42 | + "**/testdata/**", | ||
| 43 | + "**/vendor/**", | ||
| 44 | + # Excluded at the user's request (2026-09-15): | ||
| 45 | + # kits/ is the vendored dev-toolkit kit — the sources installed into | ||
| 46 | + # ~/.claude/, including the quality skill's own quality_report.py, so leaving | ||
| 47 | + # it in scope would measure the instrument with itself (its subprocess use is | ||
| 48 | + # flagged by every Python security linter and cannot be fixed). | ||
| 49 | + "kits/**", | ||
| 50 | + # tmp/ holds a draft agent (tmp/draft-agent) and a cross-compile script | ||
| 51 | + # (tmp/build.sh) that are not part of the mm binary or its tests. | ||
| 52 | + "tmp/**", | ||
| 53 | +] | ||
| 54 | + | ||
| 55 | +test_patterns = [ | ||
| 56 | + "**/test/**", | ||
| 57 | + "**/spec/**", | ||
| 58 | + "**/*.test.*", | ||
| 59 | + "**/*.spec.*", | ||
| 60 | + "**/*_test.*", | ||
| 61 | + "**/*_spec.*", | ||
| 62 | + "**/test_*.*", | ||
| 63 | + "**/spec_*.*", | ||
| 64 | +] | ||
| 65 | + | ||
| 66 | +[smells] | ||
| 67 | +mode = "comment" | ||
| 68 | + | ||
| 69 | +[[source]] | ||
| 70 | +name = "default" | ||
| 71 | +default = true | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +[[plugin]] | ||
| 75 | +name = "ripgrep" | ||
| 76 | +mode = "comment" | ||
| 77 | + | ||
| 78 | +[[plugin]] | ||
| 79 | +name = "shellcheck" | ||
| 80 | + | ||
| 81 | +[[plugin]] | ||
| 82 | +name = "trufflehog" | ||
| 83 | + | ||
| 84 | +# `qlty init` only looks at files git tracks, and at init time every Go file of | ||
| 85 | +# this project was still untracked, so it enabled none of the Go plugins. These | ||
| 86 | +# are the ones it enables when it does see Go code. | ||
| 87 | +[[plugin]] | ||
| 88 | +name = "gofmt" | ||
| 89 | + | ||
| 90 | +[[plugin]] | ||
| 91 | +name = "golangci-lint" | ||
| 92 | + | ||
| 93 | +[[plugin]] | ||
| 94 | +name = "osv-scanner" | ||
added
.quality/history.jsonl +2 -0 | new file mode 100644 | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 620, "cyclo": 994, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4718, "loc": 2954}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 1, "smells": 18, "timestamp": "2026-09-15T22:32:19Z"} | |
| 2 | +{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 618, "cyclo": 992, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4719, "loc": 2956}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 2, "smells": 18, "timestamp": "2026-09-15T22:33:07Z"} | |
| new file mode 100644 | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | +{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 620, "cyclo": 994, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4718, "loc": 2954}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 1, "smells": 18, "timestamp": "2026-09-15T22:32:19Z"} | ||
| 2 | +{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 618, "cyclo": 992, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4719, "loc": 2956}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 2, "smells": 18, "timestamp": "2026-09-15T22:33:07Z"} | ||
added
.quality/report-20260915T223219Z.md +114 -0 | new file mode 100644 | ||
| @@ -0,0 +1,114 @@ | ||
| 1 | +# Quality report — 2026-09-15T22:32:19Z | |
| 2 | + | |
| 3 | +- **Gate**: ❌ **FAIL** | |
| 4 | +- **Commit**: `79fc998` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #1 (first recorded run) | |
| 7 | + | |
| 8 | +## Gate violations | |
| 9 | + | |
| 10 | +- warning-level issues: 69 (max 0) | |
| 11 | +- code smells: 18 (max 0) | |
| 12 | + | |
| 13 | +## Lint issues (`qlty check`) | |
| 14 | + | |
| 15 | +| level | count | vs previous | | |
| 16 | +|---|---|---| | |
| 17 | +| warning | 69 | — | | |
| 18 | + | |
| 19 | +### Top rules | |
| 20 | + | |
| 21 | +| rule | count | | |
| 22 | +|---|---| | |
| 23 | +| golangci-lint:errcheck | 12 | | |
| 24 | +| osv-scanner:CVE-2026-32285 | 2 | | |
| 25 | +| osv-scanner:CVE-2026-24051 | 2 | | |
| 26 | +| osv-scanner:CVE-2026-39883 | 2 | | |
| 27 | +| osv-scanner:CVE-2026-29181 | 2 | | |
| 28 | +| osv-scanner:CVE-2026-39824 | 1 | | |
| 29 | +| osv-scanner:CVE-2025-47910 | 1 | | |
| 30 | +| osv-scanner:CVE-2025-47912 | 1 | | |
| 31 | +| osv-scanner:CVE-2025-58183 | 1 | | |
| 32 | +| osv-scanner:CVE-2025-58185 | 1 | | |
| 33 | +| osv-scanner:CVE-2025-58186 | 1 | | |
| 34 | +| osv-scanner:CVE-2025-58187 | 1 | | |
| 35 | +| osv-scanner:CVE-2025-58188 | 1 | | |
| 36 | +| osv-scanner:CVE-2025-58189 | 1 | | |
| 37 | +| osv-scanner:CVE-2025-61723 | 1 | | |
| 38 | + | |
| 39 | +### Most affected files | |
| 40 | + | |
| 41 | +| file | issues | | |
| 42 | +|---|---| | |
| 43 | +| go.mod | 55 | | |
| 44 | +| main.go | 5 | | |
| 45 | +| internal/engine/openai_compat.go | 2 | | |
| 46 | +| internal/fileedit/fileedit_test.go | 2 | | |
| 47 | +| internal/fileedit/textfile.go | 2 | | |
| 48 | +| internal/agent/agent.go | 1 | | |
| 49 | +| internal/engine/engine.go | 1 | | |
| 50 | +| internal/session/session_test.go | 1 | | |
| 51 | + | |
| 52 | +## Code smells (`qlty smells`) | |
| 53 | + | |
| 54 | +Total: **18** (vs previous: —) | |
| 55 | + | |
| 56 | +| smell | file | line | detail | | |
| 57 | +|---|---|---|---| | |
| 58 | +| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write | | |
| 59 | +| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) | | |
| 60 | +| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle | | |
| 61 | +| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run | | |
| 62 | +| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) | | |
| 63 | +| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) | | |
| 64 | +| qlty:nested-control-flow | internal/agent/agent.go | 278 | Deeply nested control flow (level = 5) | | |
| 65 | +| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 133) | | |
| 66 | +| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 121): Run | | |
| 67 | +| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate | | |
| 68 | +| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact | | |
| 69 | +| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid | | |
| 70 | +| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) | | |
| 71 | +| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid | | |
| 72 | +| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate | | |
| 73 | +| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) | | |
| 74 | +| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate | | |
| 75 | +| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain | | |
| 76 | + | |
| 77 | +## Metrics (`qlty metrics`) | |
| 78 | + | |
| 79 | +| metric | total | vs previous | | |
| 80 | +|---|---|---| | |
| 81 | +| funcs | 156 | — | | |
| 82 | +| classes | 38 | — | | |
| 83 | +| fields | 149 | — | | |
| 84 | +| cyclo | 994 | — | | |
| 85 | +| complex | 620 | — | | |
| 86 | +| lcom | 0 | — | | |
| 87 | +| lines | 4718 | — | | |
| 88 | +| loc | 2954 | — | | |
| 89 | + | |
| 90 | +### Most complex files | |
| 91 | + | |
| 92 | +| file | complex | cyclo | loc | | |
| 93 | +|---|---|---|---| | |
| 94 | +| internal/agent/agent.go | 133 | 99 | 268 | | |
| 95 | +| internal/engine/engine.go | 91 | 121 | 347 | | |
| 96 | +| internal/fileedit/diff.go | 89 | 177 | 271 | | |
| 97 | +| internal/compact/compact.go | 50 | 92 | 175 | | |
| 98 | +| internal/tools/files.go | 32 | 50 | 238 | | |
| 99 | +| internal/engine/openai_compat.go | 29 | 51 | 176 | | |
| 100 | +| internal/tools/tools.go | 28 | 47 | 191 | | |
| 101 | +| internal/fileedit/fileedit.go | 27 | 50 | 125 | | |
| 102 | +| internal/skills/skills.go | 20 | 28 | 79 | | |
| 103 | +| internal/fileedit/textfile.go | 15 | 27 | 76 | | |
| 104 | +| main.go | 15 | 31 | 92 | | |
| 105 | +| internal/fileedit/edits.go | 14 | 38 | 88 | | |
| 106 | +| internal/engine/explain.go | 13 | 31 | 58 | | |
| 107 | +| internal/spinner/spinner.go | 13 | 27 | 102 | | |
| 108 | +| internal/acp/acp.go | 11 | 28 | 183 | | |
| 109 | + | |
| 110 | +## Trend | |
| 111 | + | |
| 112 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 113 | +|---|---|---|---|---|---|---| | |
| 114 | +| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL | | |
| new file mode 100644 | |||
| @@ -0,0 +1,114 @@ | |||
| 1 | +# Quality report — 2026-09-15T22:32:19Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ❌ **FAIL** | ||
| 4 | +- **Commit**: `79fc998` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #1 (first recorded run) | ||
| 7 | + | ||
| 8 | +## Gate violations | ||
| 9 | + | ||
| 10 | +- warning-level issues: 69 (max 0) | ||
| 11 | +- code smells: 18 (max 0) | ||
| 12 | + | ||
| 13 | +## Lint issues (`qlty check`) | ||
| 14 | + | ||
| 15 | +| level | count | vs previous | | ||
| 16 | +|---|---|---| | ||
| 17 | +| warning | 69 | — | | ||
| 18 | + | ||
| 19 | +### Top rules | ||
| 20 | + | ||
| 21 | +| rule | count | | ||
| 22 | +|---|---| | ||
| 23 | +| golangci-lint:errcheck | 12 | | ||
| 24 | +| osv-scanner:CVE-2026-32285 | 2 | | ||
| 25 | +| osv-scanner:CVE-2026-24051 | 2 | | ||
| 26 | +| osv-scanner:CVE-2026-39883 | 2 | | ||
| 27 | +| osv-scanner:CVE-2026-29181 | 2 | | ||
| 28 | +| osv-scanner:CVE-2026-39824 | 1 | | ||
| 29 | +| osv-scanner:CVE-2025-47910 | 1 | | ||
| 30 | +| osv-scanner:CVE-2025-47912 | 1 | | ||
| 31 | +| osv-scanner:CVE-2025-58183 | 1 | | ||
| 32 | +| osv-scanner:CVE-2025-58185 | 1 | | ||
| 33 | +| osv-scanner:CVE-2025-58186 | 1 | | ||
| 34 | +| osv-scanner:CVE-2025-58187 | 1 | | ||
| 35 | +| osv-scanner:CVE-2025-58188 | 1 | | ||
| 36 | +| osv-scanner:CVE-2025-58189 | 1 | | ||
| 37 | +| osv-scanner:CVE-2025-61723 | 1 | | ||
| 38 | + | ||
| 39 | +### Most affected files | ||
| 40 | + | ||
| 41 | +| file | issues | | ||
| 42 | +|---|---| | ||
| 43 | +| go.mod | 55 | | ||
| 44 | +| main.go | 5 | | ||
| 45 | +| internal/engine/openai_compat.go | 2 | | ||
| 46 | +| internal/fileedit/fileedit_test.go | 2 | | ||
| 47 | +| internal/fileedit/textfile.go | 2 | | ||
| 48 | +| internal/agent/agent.go | 1 | | ||
| 49 | +| internal/engine/engine.go | 1 | | ||
| 50 | +| internal/session/session_test.go | 1 | | ||
| 51 | + | ||
| 52 | +## Code smells (`qlty smells`) | ||
| 53 | + | ||
| 54 | +Total: **18** (vs previous: —) | ||
| 55 | + | ||
| 56 | +| smell | file | line | detail | | ||
| 57 | +|---|---|---|---| | ||
| 58 | +| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write | | ||
| 59 | +| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) | | ||
| 60 | +| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle | | ||
| 61 | +| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run | | ||
| 62 | +| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) | | ||
| 63 | +| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) | | ||
| 64 | +| qlty:nested-control-flow | internal/agent/agent.go | 278 | Deeply nested control flow (level = 5) | | ||
| 65 | +| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 133) | | ||
| 66 | +| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 121): Run | | ||
| 67 | +| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate | | ||
| 68 | +| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact | | ||
| 69 | +| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid | | ||
| 70 | +| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) | | ||
| 71 | +| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid | | ||
| 72 | +| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate | | ||
| 73 | +| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) | | ||
| 74 | +| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate | | ||
| 75 | +| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain | | ||
| 76 | + | ||
| 77 | +## Metrics (`qlty metrics`) | ||
| 78 | + | ||
| 79 | +| metric | total | vs previous | | ||
| 80 | +|---|---|---| | ||
| 81 | +| funcs | 156 | — | | ||
| 82 | +| classes | 38 | — | | ||
| 83 | +| fields | 149 | — | | ||
| 84 | +| cyclo | 994 | — | | ||
| 85 | +| complex | 620 | — | | ||
| 86 | +| lcom | 0 | — | | ||
| 87 | +| lines | 4718 | — | | ||
| 88 | +| loc | 2954 | — | | ||
| 89 | + | ||
| 90 | +### Most complex files | ||
| 91 | + | ||
| 92 | +| file | complex | cyclo | loc | | ||
| 93 | +|---|---|---|---| | ||
| 94 | +| internal/agent/agent.go | 133 | 99 | 268 | | ||
| 95 | +| internal/engine/engine.go | 91 | 121 | 347 | | ||
| 96 | +| internal/fileedit/diff.go | 89 | 177 | 271 | | ||
| 97 | +| internal/compact/compact.go | 50 | 92 | 175 | | ||
| 98 | +| internal/tools/files.go | 32 | 50 | 238 | | ||
| 99 | +| internal/engine/openai_compat.go | 29 | 51 | 176 | | ||
| 100 | +| internal/tools/tools.go | 28 | 47 | 191 | | ||
| 101 | +| internal/fileedit/fileedit.go | 27 | 50 | 125 | | ||
| 102 | +| internal/skills/skills.go | 20 | 28 | 79 | | ||
| 103 | +| internal/fileedit/textfile.go | 15 | 27 | 76 | | ||
| 104 | +| main.go | 15 | 31 | 92 | | ||
| 105 | +| internal/fileedit/edits.go | 14 | 38 | 88 | | ||
| 106 | +| internal/engine/explain.go | 13 | 31 | 58 | | ||
| 107 | +| internal/spinner/spinner.go | 13 | 27 | 102 | | ||
| 108 | +| internal/acp/acp.go | 11 | 28 | 183 | | ||
| 109 | + | ||
| 110 | +## Trend | ||
| 111 | + | ||
| 112 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 113 | +|---|---|---|---|---|---|---| | ||
| 114 | +| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL | | ||
added
.quality/report-20260915T223307Z.md +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +# Quality report — 2026-09-15T22:33:07Z | |
| 2 | + | |
| 3 | +- **Gate**: ❌ **FAIL** | |
| 4 | +- **Commit**: `79fc998` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #2 (previous: 2026-09-15T22:32:19Z) | |
| 7 | + | |
| 8 | +## Gate violations | |
| 9 | + | |
| 10 | +- warning-level issues: 67 (max 0) | |
| 11 | +- code smells: 18 (max 0) | |
| 12 | + | |
| 13 | +## Lint issues (`qlty check`) | |
| 14 | + | |
| 15 | +| level | count | vs previous | | |
| 16 | +|---|---|---| | |
| 17 | +| warning | 67 | -2 | | |
| 18 | + | |
| 19 | +### Top rules | |
| 20 | + | |
| 21 | +| rule | count | | |
| 22 | +|---|---| | |
| 23 | +| golangci-lint:errcheck | 12 | | |
| 24 | +| osv-scanner:CVE-2026-32285 | 2 | | |
| 25 | +| osv-scanner:CVE-2026-24051 | 2 | | |
| 26 | +| osv-scanner:CVE-2026-39883 | 2 | | |
| 27 | +| osv-scanner:CVE-2026-29181 | 2 | | |
| 28 | +| osv-scanner:CVE-2026-39824 | 1 | | |
| 29 | +| osv-scanner:CVE-2025-47910 | 1 | | |
| 30 | +| osv-scanner:CVE-2025-47912 | 1 | | |
| 31 | +| osv-scanner:CVE-2025-58183 | 1 | | |
| 32 | +| osv-scanner:CVE-2025-58185 | 1 | | |
| 33 | +| osv-scanner:CVE-2025-58186 | 1 | | |
| 34 | +| osv-scanner:CVE-2025-58187 | 1 | | |
| 35 | +| osv-scanner:CVE-2025-58188 | 1 | | |
| 36 | +| osv-scanner:CVE-2025-58189 | 1 | | |
| 37 | +| osv-scanner:CVE-2025-61723 | 1 | | |
| 38 | + | |
| 39 | +### Most affected files | |
| 40 | + | |
| 41 | +| file | issues | | |
| 42 | +|---|---| | |
| 43 | +| go.mod | 55 | | |
| 44 | +| main.go | 5 | | |
| 45 | +| internal/engine/openai_compat.go | 2 | | |
| 46 | +| internal/fileedit/fileedit_test.go | 2 | | |
| 47 | +| internal/fileedit/textfile.go | 2 | | |
| 48 | +| internal/engine/engine.go | 1 | | |
| 49 | + | |
| 50 | +## Code smells (`qlty smells`) | |
| 51 | + | |
| 52 | +Total: **18** (vs previous: ±0) | |
| 53 | + | |
| 54 | +| smell | file | line | detail | | |
| 55 | +|---|---|---|---| | |
| 56 | +| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write | | |
| 57 | +| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) | | |
| 58 | +| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle | | |
| 59 | +| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run | | |
| 60 | +| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) | | |
| 61 | +| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) | | |
| 62 | +| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) | | |
| 63 | +| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) | | |
| 64 | +| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run | | |
| 65 | +| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate | | |
| 66 | +| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact | | |
| 67 | +| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid | | |
| 68 | +| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) | | |
| 69 | +| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid | | |
| 70 | +| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate | | |
| 71 | +| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) | | |
| 72 | +| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate | | |
| 73 | +| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain | | |
| 74 | + | |
| 75 | +## Metrics (`qlty metrics`) | |
| 76 | + | |
| 77 | +| metric | total | vs previous | | |
| 78 | +|---|---|---| | |
| 79 | +| funcs | 156 | ±0 | | |
| 80 | +| classes | 38 | ±0 | | |
| 81 | +| fields | 149 | ±0 | | |
| 82 | +| cyclo | 992 | -2 | | |
| 83 | +| complex | 618 | -2 | | |
| 84 | +| lcom | 0 | ±0 | | |
| 85 | +| lines | 4719 | +1 | | |
| 86 | +| loc | 2956 | +2 | | |
| 87 | + | |
| 88 | +### Most complex files | |
| 89 | + | |
| 90 | +| file | complex | cyclo | loc | | |
| 91 | +|---|---|---|---| | |
| 92 | +| internal/agent/agent.go | 131 | 97 | 270 | | |
| 93 | +| internal/engine/engine.go | 91 | 121 | 347 | | |
| 94 | +| internal/fileedit/diff.go | 89 | 177 | 271 | | |
| 95 | +| internal/compact/compact.go | 50 | 92 | 175 | | |
| 96 | +| internal/tools/files.go | 32 | 50 | 238 | | |
| 97 | +| internal/engine/openai_compat.go | 29 | 51 | 176 | | |
| 98 | +| internal/tools/tools.go | 28 | 47 | 191 | | |
| 99 | +| internal/fileedit/fileedit.go | 27 | 50 | 125 | | |
| 100 | +| internal/skills/skills.go | 20 | 28 | 79 | | |
| 101 | +| internal/fileedit/textfile.go | 15 | 27 | 76 | | |
| 102 | +| main.go | 15 | 31 | 92 | | |
| 103 | +| internal/fileedit/edits.go | 14 | 38 | 88 | | |
| 104 | +| internal/engine/explain.go | 13 | 31 | 58 | | |
| 105 | +| internal/spinner/spinner.go | 13 | 27 | 102 | | |
| 106 | +| internal/acp/acp.go | 11 | 28 | 183 | | |
| 107 | + | |
| 108 | +## Trend | |
| 109 | + | |
| 110 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 111 | +|---|---|---|---|---|---|---| | |
| 112 | +| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL | | |
| 113 | +| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL | | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +# Quality report — 2026-09-15T22:33:07Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ❌ **FAIL** | ||
| 4 | +- **Commit**: `79fc998` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #2 (previous: 2026-09-15T22:32:19Z) | ||
| 7 | + | ||
| 8 | +## Gate violations | ||
| 9 | + | ||
| 10 | +- warning-level issues: 67 (max 0) | ||
| 11 | +- code smells: 18 (max 0) | ||
| 12 | + | ||
| 13 | +## Lint issues (`qlty check`) | ||
| 14 | + | ||
| 15 | +| level | count | vs previous | | ||
| 16 | +|---|---|---| | ||
| 17 | +| warning | 67 | -2 | | ||
| 18 | + | ||
| 19 | +### Top rules | ||
| 20 | + | ||
| 21 | +| rule | count | | ||
| 22 | +|---|---| | ||
| 23 | +| golangci-lint:errcheck | 12 | | ||
| 24 | +| osv-scanner:CVE-2026-32285 | 2 | | ||
| 25 | +| osv-scanner:CVE-2026-24051 | 2 | | ||
| 26 | +| osv-scanner:CVE-2026-39883 | 2 | | ||
| 27 | +| osv-scanner:CVE-2026-29181 | 2 | | ||
| 28 | +| osv-scanner:CVE-2026-39824 | 1 | | ||
| 29 | +| osv-scanner:CVE-2025-47910 | 1 | | ||
| 30 | +| osv-scanner:CVE-2025-47912 | 1 | | ||
| 31 | +| osv-scanner:CVE-2025-58183 | 1 | | ||
| 32 | +| osv-scanner:CVE-2025-58185 | 1 | | ||
| 33 | +| osv-scanner:CVE-2025-58186 | 1 | | ||
| 34 | +| osv-scanner:CVE-2025-58187 | 1 | | ||
| 35 | +| osv-scanner:CVE-2025-58188 | 1 | | ||
| 36 | +| osv-scanner:CVE-2025-58189 | 1 | | ||
| 37 | +| osv-scanner:CVE-2025-61723 | 1 | | ||
| 38 | + | ||
| 39 | +### Most affected files | ||
| 40 | + | ||
| 41 | +| file | issues | | ||
| 42 | +|---|---| | ||
| 43 | +| go.mod | 55 | | ||
| 44 | +| main.go | 5 | | ||
| 45 | +| internal/engine/openai_compat.go | 2 | | ||
| 46 | +| internal/fileedit/fileedit_test.go | 2 | | ||
| 47 | +| internal/fileedit/textfile.go | 2 | | ||
| 48 | +| internal/engine/engine.go | 1 | | ||
| 49 | + | ||
| 50 | +## Code smells (`qlty smells`) | ||
| 51 | + | ||
| 52 | +Total: **18** (vs previous: ±0) | ||
| 53 | + | ||
| 54 | +| smell | file | line | detail | | ||
| 55 | +|---|---|---|---| | ||
| 56 | +| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write | | ||
| 57 | +| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) | | ||
| 58 | +| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle | | ||
| 59 | +| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run | | ||
| 60 | +| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) | | ||
| 61 | +| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) | | ||
| 62 | +| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) | | ||
| 63 | +| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) | | ||
| 64 | +| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run | | ||
| 65 | +| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate | | ||
| 66 | +| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact | | ||
| 67 | +| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid | | ||
| 68 | +| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) | | ||
| 69 | +| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid | | ||
| 70 | +| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate | | ||
| 71 | +| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) | | ||
| 72 | +| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate | | ||
| 73 | +| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain | | ||
| 74 | + | ||
| 75 | +## Metrics (`qlty metrics`) | ||
| 76 | + | ||
| 77 | +| metric | total | vs previous | | ||
| 78 | +|---|---|---| | ||
| 79 | +| funcs | 156 | ±0 | | ||
| 80 | +| classes | 38 | ±0 | | ||
| 81 | +| fields | 149 | ±0 | | ||
| 82 | +| cyclo | 992 | -2 | | ||
| 83 | +| complex | 618 | -2 | | ||
| 84 | +| lcom | 0 | ±0 | | ||
| 85 | +| lines | 4719 | +1 | | ||
| 86 | +| loc | 2956 | +2 | | ||
| 87 | + | ||
| 88 | +### Most complex files | ||
| 89 | + | ||
| 90 | +| file | complex | cyclo | loc | | ||
| 91 | +|---|---|---|---| | ||
| 92 | +| internal/agent/agent.go | 131 | 97 | 270 | | ||
| 93 | +| internal/engine/engine.go | 91 | 121 | 347 | | ||
| 94 | +| internal/fileedit/diff.go | 89 | 177 | 271 | | ||
| 95 | +| internal/compact/compact.go | 50 | 92 | 175 | | ||
| 96 | +| internal/tools/files.go | 32 | 50 | 238 | | ||
| 97 | +| internal/engine/openai_compat.go | 29 | 51 | 176 | | ||
| 98 | +| internal/tools/tools.go | 28 | 47 | 191 | | ||
| 99 | +| internal/fileedit/fileedit.go | 27 | 50 | 125 | | ||
| 100 | +| internal/skills/skills.go | 20 | 28 | 79 | | ||
| 101 | +| internal/fileedit/textfile.go | 15 | 27 | 76 | | ||
| 102 | +| main.go | 15 | 31 | 92 | | ||
| 103 | +| internal/fileedit/edits.go | 14 | 38 | 88 | | ||
| 104 | +| internal/engine/explain.go | 13 | 31 | 58 | | ||
| 105 | +| internal/spinner/spinner.go | 13 | 27 | 102 | | ||
| 106 | +| internal/acp/acp.go | 11 | 28 | 183 | | ||
| 107 | + | ||
| 108 | +## Trend | ||
| 109 | + | ||
| 110 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 111 | +|---|---|---|---|---|---|---| | ||
| 112 | +| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL | | ||
| 113 | +| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL | | ||
added
.quality/report-latest.md +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +# Quality report — 2026-09-15T22:33:07Z | |
| 2 | + | |
| 3 | +- **Gate**: ❌ **FAIL** | |
| 4 | +- **Commit**: `79fc998` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #2 (previous: 2026-09-15T22:32:19Z) | |
| 7 | + | |
| 8 | +## Gate violations | |
| 9 | + | |
| 10 | +- warning-level issues: 67 (max 0) | |
| 11 | +- code smells: 18 (max 0) | |
| 12 | + | |
| 13 | +## Lint issues (`qlty check`) | |
| 14 | + | |
| 15 | +| level | count | vs previous | | |
| 16 | +|---|---|---| | |
| 17 | +| warning | 67 | -2 | | |
| 18 | + | |
| 19 | +### Top rules | |
| 20 | + | |
| 21 | +| rule | count | | |
| 22 | +|---|---| | |
| 23 | +| golangci-lint:errcheck | 12 | | |
| 24 | +| osv-scanner:CVE-2026-32285 | 2 | | |
| 25 | +| osv-scanner:CVE-2026-24051 | 2 | | |
| 26 | +| osv-scanner:CVE-2026-39883 | 2 | | |
| 27 | +| osv-scanner:CVE-2026-29181 | 2 | | |
| 28 | +| osv-scanner:CVE-2026-39824 | 1 | | |
| 29 | +| osv-scanner:CVE-2025-47910 | 1 | | |
| 30 | +| osv-scanner:CVE-2025-47912 | 1 | | |
| 31 | +| osv-scanner:CVE-2025-58183 | 1 | | |
| 32 | +| osv-scanner:CVE-2025-58185 | 1 | | |
| 33 | +| osv-scanner:CVE-2025-58186 | 1 | | |
| 34 | +| osv-scanner:CVE-2025-58187 | 1 | | |
| 35 | +| osv-scanner:CVE-2025-58188 | 1 | | |
| 36 | +| osv-scanner:CVE-2025-58189 | 1 | | |
| 37 | +| osv-scanner:CVE-2025-61723 | 1 | | |
| 38 | + | |
| 39 | +### Most affected files | |
| 40 | + | |
| 41 | +| file | issues | | |
| 42 | +|---|---| | |
| 43 | +| go.mod | 55 | | |
| 44 | +| main.go | 5 | | |
| 45 | +| internal/engine/openai_compat.go | 2 | | |
| 46 | +| internal/fileedit/fileedit_test.go | 2 | | |
| 47 | +| internal/fileedit/textfile.go | 2 | | |
| 48 | +| internal/engine/engine.go | 1 | | |
| 49 | + | |
| 50 | +## Code smells (`qlty smells`) | |
| 51 | + | |
| 52 | +Total: **18** (vs previous: ±0) | |
| 53 | + | |
| 54 | +| smell | file | line | detail | | |
| 55 | +|---|---|---|---| | |
| 56 | +| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write | | |
| 57 | +| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) | | |
| 58 | +| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle | | |
| 59 | +| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run | | |
| 60 | +| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) | | |
| 61 | +| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) | | |
| 62 | +| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) | | |
| 63 | +| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) | | |
| 64 | +| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run | | |
| 65 | +| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate | | |
| 66 | +| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact | | |
| 67 | +| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid | | |
| 68 | +| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) | | |
| 69 | +| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid | | |
| 70 | +| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate | | |
| 71 | +| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) | | |
| 72 | +| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate | | |
| 73 | +| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain | | |
| 74 | + | |
| 75 | +## Metrics (`qlty metrics`) | |
| 76 | + | |
| 77 | +| metric | total | vs previous | | |
| 78 | +|---|---|---| | |
| 79 | +| funcs | 156 | ±0 | | |
| 80 | +| classes | 38 | ±0 | | |
| 81 | +| fields | 149 | ±0 | | |
| 82 | +| cyclo | 992 | -2 | | |
| 83 | +| complex | 618 | -2 | | |
| 84 | +| lcom | 0 | ±0 | | |
| 85 | +| lines | 4719 | +1 | | |
| 86 | +| loc | 2956 | +2 | | |
| 87 | + | |
| 88 | +### Most complex files | |
| 89 | + | |
| 90 | +| file | complex | cyclo | loc | | |
| 91 | +|---|---|---|---| | |
| 92 | +| internal/agent/agent.go | 131 | 97 | 270 | | |
| 93 | +| internal/engine/engine.go | 91 | 121 | 347 | | |
| 94 | +| internal/fileedit/diff.go | 89 | 177 | 271 | | |
| 95 | +| internal/compact/compact.go | 50 | 92 | 175 | | |
| 96 | +| internal/tools/files.go | 32 | 50 | 238 | | |
| 97 | +| internal/engine/openai_compat.go | 29 | 51 | 176 | | |
| 98 | +| internal/tools/tools.go | 28 | 47 | 191 | | |
| 99 | +| internal/fileedit/fileedit.go | 27 | 50 | 125 | | |
| 100 | +| internal/skills/skills.go | 20 | 28 | 79 | | |
| 101 | +| internal/fileedit/textfile.go | 15 | 27 | 76 | | |
| 102 | +| main.go | 15 | 31 | 92 | | |
| 103 | +| internal/fileedit/edits.go | 14 | 38 | 88 | | |
| 104 | +| internal/engine/explain.go | 13 | 31 | 58 | | |
| 105 | +| internal/spinner/spinner.go | 13 | 27 | 102 | | |
| 106 | +| internal/acp/acp.go | 11 | 28 | 183 | | |
| 107 | + | |
| 108 | +## Trend | |
| 109 | + | |
| 110 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 111 | +|---|---|---|---|---|---|---| | |
| 112 | +| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL | | |
| 113 | +| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL | | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +# Quality report — 2026-09-15T22:33:07Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ❌ **FAIL** | ||
| 4 | +- **Commit**: `79fc998` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #2 (previous: 2026-09-15T22:32:19Z) | ||
| 7 | + | ||
| 8 | +## Gate violations | ||
| 9 | + | ||
| 10 | +- warning-level issues: 67 (max 0) | ||
| 11 | +- code smells: 18 (max 0) | ||
| 12 | + | ||
| 13 | +## Lint issues (`qlty check`) | ||
| 14 | + | ||
| 15 | +| level | count | vs previous | | ||
| 16 | +|---|---|---| | ||
| 17 | +| warning | 67 | -2 | | ||
| 18 | + | ||
| 19 | +### Top rules | ||
| 20 | + | ||
| 21 | +| rule | count | | ||
| 22 | +|---|---| | ||
| 23 | +| golangci-lint:errcheck | 12 | | ||
| 24 | +| osv-scanner:CVE-2026-32285 | 2 | | ||
| 25 | +| osv-scanner:CVE-2026-24051 | 2 | | ||
| 26 | +| osv-scanner:CVE-2026-39883 | 2 | | ||
| 27 | +| osv-scanner:CVE-2026-29181 | 2 | | ||
| 28 | +| osv-scanner:CVE-2026-39824 | 1 | | ||
| 29 | +| osv-scanner:CVE-2025-47910 | 1 | | ||
| 30 | +| osv-scanner:CVE-2025-47912 | 1 | | ||
| 31 | +| osv-scanner:CVE-2025-58183 | 1 | | ||
| 32 | +| osv-scanner:CVE-2025-58185 | 1 | | ||
| 33 | +| osv-scanner:CVE-2025-58186 | 1 | | ||
| 34 | +| osv-scanner:CVE-2025-58187 | 1 | | ||
| 35 | +| osv-scanner:CVE-2025-58188 | 1 | | ||
| 36 | +| osv-scanner:CVE-2025-58189 | 1 | | ||
| 37 | +| osv-scanner:CVE-2025-61723 | 1 | | ||
| 38 | + | ||
| 39 | +### Most affected files | ||
| 40 | + | ||
| 41 | +| file | issues | | ||
| 42 | +|---|---| | ||
| 43 | +| go.mod | 55 | | ||
| 44 | +| main.go | 5 | | ||
| 45 | +| internal/engine/openai_compat.go | 2 | | ||
| 46 | +| internal/fileedit/fileedit_test.go | 2 | | ||
| 47 | +| internal/fileedit/textfile.go | 2 | | ||
| 48 | +| internal/engine/engine.go | 1 | | ||
| 49 | + | ||
| 50 | +## Code smells (`qlty smells`) | ||
| 51 | + | ||
| 52 | +Total: **18** (vs previous: ±0) | ||
| 53 | + | ||
| 54 | +| smell | file | line | detail | | ||
| 55 | +|---|---|---|---| | ||
| 56 | +| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write | | ||
| 57 | +| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) | | ||
| 58 | +| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle | | ||
| 59 | +| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run | | ||
| 60 | +| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) | | ||
| 61 | +| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) | | ||
| 62 | +| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) | | ||
| 63 | +| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) | | ||
| 64 | +| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run | | ||
| 65 | +| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate | | ||
| 66 | +| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact | | ||
| 67 | +| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid | | ||
| 68 | +| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) | | ||
| 69 | +| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid | | ||
| 70 | +| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate | | ||
| 71 | +| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) | | ||
| 72 | +| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate | | ||
| 73 | +| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain | | ||
| 74 | + | ||
| 75 | +## Metrics (`qlty metrics`) | ||
| 76 | + | ||
| 77 | +| metric | total | vs previous | | ||
| 78 | +|---|---|---| | ||
| 79 | +| funcs | 156 | ±0 | | ||
| 80 | +| classes | 38 | ±0 | | ||
| 81 | +| fields | 149 | ±0 | | ||
| 82 | +| cyclo | 992 | -2 | | ||
| 83 | +| complex | 618 | -2 | | ||
| 84 | +| lcom | 0 | ±0 | | ||
| 85 | +| lines | 4719 | +1 | | ||
| 86 | +| loc | 2956 | +2 | | ||
| 87 | + | ||
| 88 | +### Most complex files | ||
| 89 | + | ||
| 90 | +| file | complex | cyclo | loc | | ||
| 91 | +|---|---|---|---| | ||
| 92 | +| internal/agent/agent.go | 131 | 97 | 270 | | ||
| 93 | +| internal/engine/engine.go | 91 | 121 | 347 | | ||
| 94 | +| internal/fileedit/diff.go | 89 | 177 | 271 | | ||
| 95 | +| internal/compact/compact.go | 50 | 92 | 175 | | ||
| 96 | +| internal/tools/files.go | 32 | 50 | 238 | | ||
| 97 | +| internal/engine/openai_compat.go | 29 | 51 | 176 | | ||
| 98 | +| internal/tools/tools.go | 28 | 47 | 191 | | ||
| 99 | +| internal/fileedit/fileedit.go | 27 | 50 | 125 | | ||
| 100 | +| internal/skills/skills.go | 20 | 28 | 79 | | ||
| 101 | +| internal/fileedit/textfile.go | 15 | 27 | 76 | | ||
| 102 | +| main.go | 15 | 31 | 92 | | ||
| 103 | +| internal/fileedit/edits.go | 14 | 38 | 88 | | ||
| 104 | +| internal/engine/explain.go | 13 | 31 | 58 | | ||
| 105 | +| internal/spinner/spinner.go | 13 | 27 | 102 | | ||
| 106 | +| internal/acp/acp.go | 11 | 28 | 183 | | ||
| 107 | + | ||
| 108 | +## Trend | ||
| 109 | + | ||
| 110 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 111 | +|---|---|---|---|---|---|---| | ||
| 112 | +| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL | | ||
| 113 | +| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL | | ||
added
.tickets/issues/0001-add-a-help-command.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 1 | |
| 3 | +title: Add a help command | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T22:22:54.491Z | |
| 9 | +updatedAt: 2026-09-15T22:22:54.491Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 1 | ||
| 3 | +title: Add a help command | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T22:22:54.491Z | ||
| 9 | +updatedAt: 2026-09-15T22:22:54.491Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
added
.tickets/issues/0002-add-a-about-command.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 2 | |
| 3 | +title: Add a about command | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T22:23:07.065Z | |
| 9 | +updatedAt: 2026-09-15T22:23:07.065Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 2 | ||
| 3 | +title: Add a about command | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T22:23:07.065Z | ||
| 9 | +updatedAt: 2026-09-15T22:23:07.065Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
added
.tickets/issues/0003-improve-tui.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 3 | |
| 3 | +title: Improve TUI | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T22:25:16.449Z | |
| 9 | +updatedAt: 2026-09-15T22:25:16.449Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 3 | ||
| 3 | +title: Improve TUI | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T22:25:16.449Z | ||
| 9 | +updatedAt: 2026-09-15T22:25:16.449Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
added
.tickets/issues/0004-do-we-need-mcp.yaml +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 4 | |
| 3 | +title: Do we need MCP? | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T22:38:45.129Z | |
| 9 | +updatedAt: 2026-09-15T22:39:03.084Z | |
| 10 | +labels: [] | |
| 11 | +body: | | |
| 12 | + I would say no - use a CLI | |
| 13 | +tasks: [] | |
| 14 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 4 | ||
| 3 | +title: Do we need MCP? | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T22:38:45.129Z | ||
| 9 | +updatedAt: 2026-09-15T22:39:03.084Z | ||
| 10 | +labels: [] | ||
| 11 | +body: | | ||
| 12 | + I would say no - use a CLI | ||
| 13 | +tasks: [] | ||
| 14 | +comments: [] | ||
added
.tickets/issues/0005-follow-token-consumption.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 5 | |
| 3 | +title: Follow token consumption | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T23:04:05.027Z | |
| 9 | +updatedAt: 2026-09-15T23:04:05.027Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 5 | ||
| 3 | +title: Follow token consumption | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T23:04:05.027Z | ||
| 9 | +updatedAt: 2026-09-15T23:04:05.027Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
added
.tickets/issues/0006-add-acp-command-for-token-consumption.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 6 | |
| 3 | +title: Add ACP command for token consumption | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T23:04:31.606Z | |
| 9 | +updatedAt: 2026-09-15T23:04:31.606Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 6 | ||
| 3 | +title: Add ACP command for token consumption | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T23:04:31.606Z | ||
| 9 | +updatedAt: 2026-09-15T23:04:31.606Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
added
.tickets/issues/0007-skills-are-read-not-executed.yaml +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 7 | |
| 3 | +title: Skills are read, not executed | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T23:32:00.224Z | |
| 9 | +updatedAt: 2026-09-15T23:32:11.894Z | |
| 10 | +labels: | |
| 11 | + - priority::high | |
| 12 | + - kind::bug | |
| 13 | +body: "" | |
| 14 | +tasks: [] | |
| 15 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 7 | ||
| 3 | +title: Skills are read, not executed | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T23:32:00.224Z | ||
| 9 | +updatedAt: 2026-09-15T23:32:11.894Z | ||
| 10 | +labels: | ||
| 11 | + - priority::high | ||
| 12 | + - kind::bug | ||
| 13 | +body: "" | ||
| 14 | +tasks: [] | ||
| 15 | +comments: [] | ||
added
.tickets/issues/0008-why-in-zed-i-cannot-relaoad-the-agent.yaml +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 8 | |
| 3 | +title: Why in Zed I cannot relaoad the agent? | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T23:34:42.762Z | |
| 9 | +updatedAt: 2026-09-15T23:34:54.131Z | |
| 10 | +labels: | |
| 11 | + - priority::medium | |
| 12 | + - kind::bug | |
| 13 | +body: "" | |
| 14 | +tasks: [] | |
| 15 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 8 | ||
| 3 | +title: Why in Zed I cannot relaoad the agent? | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T23:34:42.762Z | ||
| 9 | +updatedAt: 2026-09-15T23:34:54.131Z | ||
| 10 | +labels: | ||
| 11 | + - priority::medium | ||
| 12 | + - kind::bug | ||
| 13 | +body: "" | ||
| 14 | +tasks: [] | ||
| 15 | +comments: [] | ||
added
.tickets/issues/0009-create-release-process.yaml +13 -0 | new file mode 100644 | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 9 | |
| 3 | +title: Create release process | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-15T23:51:39.464Z | |
| 9 | +updatedAt: 2026-09-15T23:51:39.464Z | |
| 10 | +labels: [] | |
| 11 | +body: "" | |
| 12 | +tasks: [] | |
| 13 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 9 | ||
| 3 | +title: Create release process | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-15T23:51:39.464Z | ||
| 9 | +updatedAt: 2026-09-15T23:51:39.464Z | ||
| 10 | +labels: [] | ||
| 11 | +body: "" | ||
| 12 | +tasks: [] | ||
| 13 | +comments: [] | ||
modified
README.md +3 -0 | @@ -1 +1,4 @@ | ||
| 1 | 1 | # Mini Me |
| 2 | + | |
| 3 | +## ACP with Mini Me and Zed | |
| 4 | + | |
| @@ -1 +1,4 @@ | |||
| 1 | # Mini Me | 1 | # Mini Me |
| 2 | + | ||
| 3 | +## ACP with Mini Me and Zed | ||
| 4 | + | ||
added
agent.dmr.yaml +208 -0 | new file mode 100644 | ||
| @@ -0,0 +1,208 @@ | ||
| 1 | +# Agent settings. | |
| 2 | +# | |
| 3 | +# Every key is optional: what you leave out keeps its built-in default | |
| 4 | +# (see internal/config/config.go). Nothing here requires recompiling — | |
| 5 | +# `go run .` picks the file up at startup. | |
| 6 | +# | |
| 7 | +# Use another file with: AGENT_CONFIG=./fast.yaml go run . | |
| 8 | + | |
| 9 | +# Which server is behind the URL. Left out, it means `dmr`: every agent.yaml | |
| 10 | +# written before this key existed keeps working unchanged. `llamacpp` speaks | |
| 11 | +# the same OpenAI-compatible protocol, but with its own port, its own way of | |
| 12 | +# refusing tool calls (no --jinja) and its own words in error messages — the | |
| 13 | +# provider is what turns those into one useful line. See agent.llamacpp.yaml. | |
| 14 | +# AGENT_PROVIDER overrides this key for one run. | |
| 15 | +provider: dmr | |
| 16 | + | |
| 17 | +# The chat model, in the provider's own naming (DMR: `ai/…`, `hf.co/…`). It | |
| 18 | +# MUST be able to emit tool_calls, otherwise the agent never runs a single | |
| 19 | +# command. AGENT_MODEL overrides it. | |
| 20 | +model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 21 | + | |
| 22 | +# Endpoint. `baseUrl` is tried first; if it does not answer, `fallback` is used | |
| 23 | +# — handy from a container or a sandbox, where the host is reachable as | |
| 24 | +# host.docker.internal. Both are LEFT OUT here on purpose: each provider has its | |
| 25 | +# defaults, and an explicit value is always respected — so with DMR's URL | |
| 26 | +# written in this file, `AGENT_PROVIDER=llamacpp ./bob agent.yaml` still talked | |
| 27 | +# to port 12434. Leaving them out keeps the file provider-neutral. These are the | |
| 28 | +# values the `dmr` provider fills in: | |
| 29 | +# baseUrl: http://localhost:12434/engines/v1 | |
| 30 | +# fallback: http://host.docker.internal:12434/engines/v1 | |
| 31 | +# AGENT_BASE_URL — or, for dmr, the older DMR_BASE_URL — overrides both without | |
| 32 | +# probing. `fallback: ""` (an explicit empty string) disables the fallback. | |
| 33 | + | |
| 34 | +# The NAME of the environment variable holding the API key — never the key | |
| 35 | +# itself, so this file can be committed and shown on screen. DMR ignores the | |
| 36 | +# key, so nothing is needed here; llama-server checks one only when started | |
| 37 | +# with --api-key (default variable: LLAMA_API_KEY). | |
| 38 | +# apiKeyEnv: LLAMA_API_KEY | |
| 39 | + | |
| 40 | +# How many tokens the server actually serves. The model does not know this: | |
| 41 | +# it is what the operator started the server with (`llama-server -c 32768`, | |
| 42 | +# `docker model configure`). Shown at start-up next to its origin, because a | |
| 43 | +# number without an origin never gets corrected. 0 = ask the server | |
| 44 | +# (llama-server tells its n_ctx on /props), else "unknown". | |
| 45 | +contextWindow: 0 | |
| 46 | + | |
| 47 | +# Max number of characters a tool returns to the model. Context safeguard: | |
| 48 | +# beyond that, the output is truncated (beginning + end kept). | |
| 49 | +maxOutput: 16000 | |
| 50 | + | |
| 51 | +# Max number of model ↔ tools round trips for a single question. Raise it for | |
| 52 | +# tasks that need long chains of commands. | |
| 53 | +maxTurns: 40 | |
| 54 | + | |
| 55 | +# The built-in file tools: read_file, write_file, edit_file. This is the switch | |
| 56 | +# this part exists for. `true`: the model edits files through tools it can SEE | |
| 57 | +# in its tool list. `false`: the agent is part 09 again — bash and read_skill — | |
| 58 | +# and edits files through the `edit` CLI if it is on the PATH. Same binary, | |
| 59 | +# same prompts, two set-ups: measured side by side, that is the comparison | |
| 60 | +# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue | |
| 61 | +# in the prompt plus `cat` managed 1/11). Paths are relative to the current | |
| 62 | +# directory and are not confined to it. | |
| 63 | +editTools: true | |
| 64 | + | |
| 65 | +# Where the `read_skill` tool looks for its markdown procedures. The path is | |
| 66 | +# relative to the CURRENT DIRECTORY, so run the agent from the directory that | |
| 67 | +# holds skills/ — not from src/. When the directory has no *.md file, the tool | |
| 68 | +# is not declared at all. | |
| 69 | +skillsDir: skills | |
| 70 | + | |
| 71 | +# How many lines of a command's output are echoed to the terminal. "Show me | |
| 72 | +# that file" means show it: running `cat` is not displaying it. 0 disables it. | |
| 73 | +previewLines: 20 | |
| 74 | + | |
| 75 | +# The system prompt: what the agent is, and what it is allowed to do. | |
| 76 | +# This is the knob to play with — it is the shortest path to changing behaviour. | |
| 77 | +system: | | |
| 78 | + Your name is Bob. | |
| 79 | + You are a coding agent working in a terminal. | |
| 80 | + You have a "bash" tool to run shell commands. | |
| 81 | + Use it to explore files, run tests, inspect the repository, etc. | |
| 82 | + Chain several commands if needed, then answer clearly in English. | |
| 83 | + | |
| 84 | + A request often mixes things you answer from yourself ("say hello") with | |
| 85 | + things only a command can answer ("list the files"). Handle every part, in | |
| 86 | + the order asked, and run a command for each part that needs one. | |
| 87 | + Never state the contents of a file, the output of a command, or the state of | |
| 88 | + the repository unless a command in THIS answer returned it. What you did not | |
| 89 | + read, you do not know: run the command instead of recalling it. | |
| 90 | + | |
| 91 | + SKILLS | |
| 92 | + You have a second tool, `read_skill`. Its description lists the procedures | |
| 93 | + available for this project — one per kind of task. | |
| 94 | + | |
| 95 | + Any request to DO something to a Go project is a skill, not a shell command | |
| 96 | + you invent. Match the request against that list, call `read_skill` FIRST, | |
| 97 | + before any bash command, and then follow what it says step by step. | |
| 98 | + | |
| 99 | + FILE EDITING | |
| 100 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | |
| 101 | + They are how a file gets read and changed here: each change is exact, | |
| 102 | + checked before it is written, and comes back as a diff with line numbers. | |
| 103 | + bash is for running things — building, testing, listing, searching. | |
| 104 | + | |
| 105 | + - Read before you write: call `read_file` on the file (numbered=true when | |
| 106 | + you need line numbers). You cannot target text you have not seen; never | |
| 107 | + rely on what you think you remember about a file. | |
| 108 | + - To change an existing file, call `edit_file` with one or more {old, new} | |
| 109 | + pairs. `old` is copied from the file character for character — same | |
| 110 | + spaces, same indentation, same line breaks — and appears exactly once: | |
| 111 | + add the surrounding lines until it is unique. Several pairs are applied | |
| 112 | + together, against the original file. An empty `new` deletes the text. | |
| 113 | + - Call `write_file` only to create a file, or to rewrite one entirely and | |
| 114 | + on purpose. On an existing file it replaces everything, including what | |
| 115 | + you did not intend to touch. | |
| 116 | + - Read the diff the tool returns: it says exactly what changed and on which | |
| 117 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | |
| 118 | + edits — read the file again and fix `old`. Do not fall back to | |
| 119 | + `write_file` to force the change through. | |
| 120 | + - After editing code, run the narrowest check with bash: the formatter, the | |
| 121 | + compiler, or the test covering that file. | |
| 122 | + | |
| 123 | + RULES | |
| 124 | + - Keep everything the file already does, unless the user asked to remove it. | |
| 125 | + - Touch only the files the request is about. Do not add tests, files or | |
| 126 | + features that were not asked for. | |
| 127 | + - Never run a git command unless the user says git, commit or push. | |
| 128 | + - Never move, rename or delete a file unless the user asked for it. | |
| 129 | + - Then answer in English, in a few lines. | |
| 130 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | |
| 131 | + to understand the options, then run the command. | |
| 132 | + | |
| 133 | + BACKGROUND JOBS | |
| 134 | + Never let a command block the answer. Anything that serves, watches or runs | |
| 135 | + long goes to the background, with BOTH streams redirected and its pid kept: | |
| 136 | + | |
| 137 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | |
| 138 | + | |
| 139 | + Redirecting only stdout still blocks until the process exits. Read the | |
| 140 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | |
| 141 | + rule you cannot guess. Stop every job you started before you finish, and say | |
| 142 | + which ones you left running. | |
| 143 | + | |
| 144 | +# Generation settings (OpenAI API keys). Low temperature for a coding agent: | |
| 145 | +# we want precise and reproducible answers, not creativity. | |
| 146 | +sampling: | |
| 147 | + temperature: 0.0 | |
| 148 | + # Une seule commande par tour. Sans ça, le modèle peut en demander | |
| 149 | + # plusieurs d'un coup : Genkit les exécute EN PARALLÈLE, dans le même | |
| 150 | + # répertoire, et on ne sait plus quelle sortie appartient à quelle | |
| 151 | + # commande. Pour le détecteur de boucle, c'est pire : deux appels du même | |
| 152 | + # tour s'enregistrent dans un ordre indéterminé, et « la même action deux | |
| 153 | + # fois de suite » perd son sens. Mettre `true` pour retrouver le | |
| 154 | + # comportement par défaut de l'API. | |
| 155 | + parallel_tool_calls: false | |
| 156 | + top_p: 0.9 | |
| 157 | + max_tokens: 4096 | |
| 158 | + | |
| 159 | +watchdogTimeout: 30s | |
| 160 | + | |
| 161 | +# Context compression, carried over from part 08 (its CONTEXT_WINDOW.md has the | |
| 162 | +# reasoning). OFF by default: with `enabled: false` the agent behaves exactly | |
| 163 | +# as before. Why it exists: nothing in the agent ever shortens the history — | |
| 164 | +# measured with the fake engine of part 03, the messages sent to the model grew | |
| 165 | +# 2 → 5 → 7 → 9 over four requests, and every `bash` output (up to maxOutput | |
| 166 | +# characters) and every skill read stays until /quit. On a local model the | |
| 167 | +# window is fixed at load time and the prefill runs on the laptop, so a long | |
| 168 | +# session first shows up as a false "[watchdog: connection hang]", then as an | |
| 169 | +# error from the server. | |
| 170 | +# | |
| 171 | +# The window it measures against is the top-level `contextWindow` above — ONE | |
| 172 | +# key for the banner and for the trigger, so the two cannot disagree. When | |
| 173 | +# that key is 0, the value the provider's probe learned from the server is | |
| 174 | +# used (llama-server tells its n_ctx on /props; DMR tells nothing). When both | |
| 175 | +# are unknown, only `maxMessages` can trigger, and the agent says so at | |
| 176 | +# start-up. | |
| 177 | +context: | |
| 178 | + enabled: false | |
| 179 | + | |
| 180 | + # Compress when the history reaches this share of the window. 75 leaves a | |
| 181 | + # quarter for the next question, the outputs of its turns and the answer — | |
| 182 | + # a single `bash` output can be maxOutput characters, about 4-5k tokens. | |
| 183 | + threshold: 75 | |
| 184 | + | |
| 185 | + # Fallback on the message count, for when the window is unknown or the | |
| 186 | + # estimate is off. One command costs 2 messages (call + response), so 80 is | |
| 187 | + # roughly 30 commands of history. | |
| 188 | + maxMessages: 80 | |
| 189 | + | |
| 190 | + # Question turns kept raw at the end. The recent turns are where the model | |
| 191 | + # works; summarising them makes it re-run what it just did. Three covers | |
| 192 | + # "do X", "now fix it", "and test it" — the usual shape of a demo. | |
| 193 | + keepLastTurns: 3 | |
| 194 | + | |
| 195 | + # max_tokens of the summary request. Seven sections of one line per item | |
| 196 | + # fit in far less; the cap stops a runaway model from filling the window it | |
| 197 | + # was asked to empty. | |
| 198 | + summaryMaxTokens: 1200 | |
| 199 | + | |
| 200 | + # Replaces the built-in summary prompt (08-context-compression/CONTEXT_WINDOW.md | |
| 201 | + # § 4.1) when set. Empty = built-in. | |
| 202 | + prompt: "" | |
| 203 | + | |
| 204 | + # One line after each compression: what was replaced, what was kept, | |
| 205 | + # tokens before → after. Worth showing on a screen, noise in a log. | |
| 206 | + showStats: true | |
| 207 | + | |
| 208 | +displayCommands: true | |
| new file mode 100644 | |||
| @@ -0,0 +1,208 @@ | |||
| 1 | +# Agent settings. | ||
| 2 | +# | ||
| 3 | +# Every key is optional: what you leave out keeps its built-in default | ||
| 4 | +# (see internal/config/config.go). Nothing here requires recompiling — | ||
| 5 | +# `go run .` picks the file up at startup. | ||
| 6 | +# | ||
| 7 | +# Use another file with: AGENT_CONFIG=./fast.yaml go run . | ||
| 8 | + | ||
| 9 | +# Which server is behind the URL. Left out, it means `dmr`: every agent.yaml | ||
| 10 | +# written before this key existed keeps working unchanged. `llamacpp` speaks | ||
| 11 | +# the same OpenAI-compatible protocol, but with its own port, its own way of | ||
| 12 | +# refusing tool calls (no --jinja) and its own words in error messages — the | ||
| 13 | +# provider is what turns those into one useful line. See agent.llamacpp.yaml. | ||
| 14 | +# AGENT_PROVIDER overrides this key for one run. | ||
| 15 | +provider: dmr | ||
| 16 | + | ||
| 17 | +# The chat model, in the provider's own naming (DMR: `ai/…`, `hf.co/…`). It | ||
| 18 | +# MUST be able to emit tool_calls, otherwise the agent never runs a single | ||
| 19 | +# command. AGENT_MODEL overrides it. | ||
| 20 | +model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 21 | + | ||
| 22 | +# Endpoint. `baseUrl` is tried first; if it does not answer, `fallback` is used | ||
| 23 | +# — handy from a container or a sandbox, where the host is reachable as | ||
| 24 | +# host.docker.internal. Both are LEFT OUT here on purpose: each provider has its | ||
| 25 | +# defaults, and an explicit value is always respected — so with DMR's URL | ||
| 26 | +# written in this file, `AGENT_PROVIDER=llamacpp ./bob agent.yaml` still talked | ||
| 27 | +# to port 12434. Leaving them out keeps the file provider-neutral. These are the | ||
| 28 | +# values the `dmr` provider fills in: | ||
| 29 | +# baseUrl: http://localhost:12434/engines/v1 | ||
| 30 | +# fallback: http://host.docker.internal:12434/engines/v1 | ||
| 31 | +# AGENT_BASE_URL — or, for dmr, the older DMR_BASE_URL — overrides both without | ||
| 32 | +# probing. `fallback: ""` (an explicit empty string) disables the fallback. | ||
| 33 | + | ||
| 34 | +# The NAME of the environment variable holding the API key — never the key | ||
| 35 | +# itself, so this file can be committed and shown on screen. DMR ignores the | ||
| 36 | +# key, so nothing is needed here; llama-server checks one only when started | ||
| 37 | +# with --api-key (default variable: LLAMA_API_KEY). | ||
| 38 | +# apiKeyEnv: LLAMA_API_KEY | ||
| 39 | + | ||
| 40 | +# How many tokens the server actually serves. The model does not know this: | ||
| 41 | +# it is what the operator started the server with (`llama-server -c 32768`, | ||
| 42 | +# `docker model configure`). Shown at start-up next to its origin, because a | ||
| 43 | +# number without an origin never gets corrected. 0 = ask the server | ||
| 44 | +# (llama-server tells its n_ctx on /props), else "unknown". | ||
| 45 | +contextWindow: 0 | ||
| 46 | + | ||
| 47 | +# Max number of characters a tool returns to the model. Context safeguard: | ||
| 48 | +# beyond that, the output is truncated (beginning + end kept). | ||
| 49 | +maxOutput: 16000 | ||
| 50 | + | ||
| 51 | +# Max number of model ↔ tools round trips for a single question. Raise it for | ||
| 52 | +# tasks that need long chains of commands. | ||
| 53 | +maxTurns: 40 | ||
| 54 | + | ||
| 55 | +# The built-in file tools: read_file, write_file, edit_file. This is the switch | ||
| 56 | +# this part exists for. `true`: the model edits files through tools it can SEE | ||
| 57 | +# in its tool list. `false`: the agent is part 09 again — bash and read_skill — | ||
| 58 | +# and edits files through the `edit` CLI if it is on the PATH. Same binary, | ||
| 59 | +# same prompts, two set-ups: measured side by side, that is the comparison | ||
| 60 | +# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue | ||
| 61 | +# in the prompt plus `cat` managed 1/11). Paths are relative to the current | ||
| 62 | +# directory and are not confined to it. | ||
| 63 | +editTools: true | ||
| 64 | + | ||
| 65 | +# Where the `read_skill` tool looks for its markdown procedures. The path is | ||
| 66 | +# relative to the CURRENT DIRECTORY, so run the agent from the directory that | ||
| 67 | +# holds skills/ — not from src/. When the directory has no *.md file, the tool | ||
| 68 | +# is not declared at all. | ||
| 69 | +skillsDir: skills | ||
| 70 | + | ||
| 71 | +# How many lines of a command's output are echoed to the terminal. "Show me | ||
| 72 | +# that file" means show it: running `cat` is not displaying it. 0 disables it. | ||
| 73 | +previewLines: 20 | ||
| 74 | + | ||
| 75 | +# The system prompt: what the agent is, and what it is allowed to do. | ||
| 76 | +# This is the knob to play with — it is the shortest path to changing behaviour. | ||
| 77 | +system: | | ||
| 78 | + Your name is Bob. | ||
| 79 | + You are a coding agent working in a terminal. | ||
| 80 | + You have a "bash" tool to run shell commands. | ||
| 81 | + Use it to explore files, run tests, inspect the repository, etc. | ||
| 82 | + Chain several commands if needed, then answer clearly in English. | ||
| 83 | + | ||
| 84 | + A request often mixes things you answer from yourself ("say hello") with | ||
| 85 | + things only a command can answer ("list the files"). Handle every part, in | ||
| 86 | + the order asked, and run a command for each part that needs one. | ||
| 87 | + Never state the contents of a file, the output of a command, or the state of | ||
| 88 | + the repository unless a command in THIS answer returned it. What you did not | ||
| 89 | + read, you do not know: run the command instead of recalling it. | ||
| 90 | + | ||
| 91 | + SKILLS | ||
| 92 | + You have a second tool, `read_skill`. Its description lists the procedures | ||
| 93 | + available for this project — one per kind of task. | ||
| 94 | + | ||
| 95 | + Any request to DO something to a Go project is a skill, not a shell command | ||
| 96 | + you invent. Match the request against that list, call `read_skill` FIRST, | ||
| 97 | + before any bash command, and then follow what it says step by step. | ||
| 98 | + | ||
| 99 | + FILE EDITING | ||
| 100 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | ||
| 101 | + They are how a file gets read and changed here: each change is exact, | ||
| 102 | + checked before it is written, and comes back as a diff with line numbers. | ||
| 103 | + bash is for running things — building, testing, listing, searching. | ||
| 104 | + | ||
| 105 | + - Read before you write: call `read_file` on the file (numbered=true when | ||
| 106 | + you need line numbers). You cannot target text you have not seen; never | ||
| 107 | + rely on what you think you remember about a file. | ||
| 108 | + - To change an existing file, call `edit_file` with one or more {old, new} | ||
| 109 | + pairs. `old` is copied from the file character for character — same | ||
| 110 | + spaces, same indentation, same line breaks — and appears exactly once: | ||
| 111 | + add the surrounding lines until it is unique. Several pairs are applied | ||
| 112 | + together, against the original file. An empty `new` deletes the text. | ||
| 113 | + - Call `write_file` only to create a file, or to rewrite one entirely and | ||
| 114 | + on purpose. On an existing file it replaces everything, including what | ||
| 115 | + you did not intend to touch. | ||
| 116 | + - Read the diff the tool returns: it says exactly what changed and on which | ||
| 117 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | ||
| 118 | + edits — read the file again and fix `old`. Do not fall back to | ||
| 119 | + `write_file` to force the change through. | ||
| 120 | + - After editing code, run the narrowest check with bash: the formatter, the | ||
| 121 | + compiler, or the test covering that file. | ||
| 122 | + | ||
| 123 | + RULES | ||
| 124 | + - Keep everything the file already does, unless the user asked to remove it. | ||
| 125 | + - Touch only the files the request is about. Do not add tests, files or | ||
| 126 | + features that were not asked for. | ||
| 127 | + - Never run a git command unless the user says git, commit or push. | ||
| 128 | + - Never move, rename or delete a file unless the user asked for it. | ||
| 129 | + - Then answer in English, in a few lines. | ||
| 130 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | ||
| 131 | + to understand the options, then run the command. | ||
| 132 | + | ||
| 133 | + BACKGROUND JOBS | ||
| 134 | + Never let a command block the answer. Anything that serves, watches or runs | ||
| 135 | + long goes to the background, with BOTH streams redirected and its pid kept: | ||
| 136 | + | ||
| 137 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | ||
| 138 | + | ||
| 139 | + Redirecting only stdout still blocks until the process exits. Read the | ||
| 140 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | ||
| 141 | + rule you cannot guess. Stop every job you started before you finish, and say | ||
| 142 | + which ones you left running. | ||
| 143 | + | ||
| 144 | +# Generation settings (OpenAI API keys). Low temperature for a coding agent: | ||
| 145 | +# we want precise and reproducible answers, not creativity. | ||
| 146 | +sampling: | ||
| 147 | + temperature: 0.0 | ||
| 148 | + # Une seule commande par tour. Sans ça, le modèle peut en demander | ||
| 149 | + # plusieurs d'un coup : Genkit les exécute EN PARALLÈLE, dans le même | ||
| 150 | + # répertoire, et on ne sait plus quelle sortie appartient à quelle | ||
| 151 | + # commande. Pour le détecteur de boucle, c'est pire : deux appels du même | ||
| 152 | + # tour s'enregistrent dans un ordre indéterminé, et « la même action deux | ||
| 153 | + # fois de suite » perd son sens. Mettre `true` pour retrouver le | ||
| 154 | + # comportement par défaut de l'API. | ||
| 155 | + parallel_tool_calls: false | ||
| 156 | + top_p: 0.9 | ||
| 157 | + max_tokens: 4096 | ||
| 158 | + | ||
| 159 | +watchdogTimeout: 30s | ||
| 160 | + | ||
| 161 | +# Context compression, carried over from part 08 (its CONTEXT_WINDOW.md has the | ||
| 162 | +# reasoning). OFF by default: with `enabled: false` the agent behaves exactly | ||
| 163 | +# as before. Why it exists: nothing in the agent ever shortens the history — | ||
| 164 | +# measured with the fake engine of part 03, the messages sent to the model grew | ||
| 165 | +# 2 → 5 → 7 → 9 over four requests, and every `bash` output (up to maxOutput | ||
| 166 | +# characters) and every skill read stays until /quit. On a local model the | ||
| 167 | +# window is fixed at load time and the prefill runs on the laptop, so a long | ||
| 168 | +# session first shows up as a false "[watchdog: connection hang]", then as an | ||
| 169 | +# error from the server. | ||
| 170 | +# | ||
| 171 | +# The window it measures against is the top-level `contextWindow` above — ONE | ||
| 172 | +# key for the banner and for the trigger, so the two cannot disagree. When | ||
| 173 | +# that key is 0, the value the provider's probe learned from the server is | ||
| 174 | +# used (llama-server tells its n_ctx on /props; DMR tells nothing). When both | ||
| 175 | +# are unknown, only `maxMessages` can trigger, and the agent says so at | ||
| 176 | +# start-up. | ||
| 177 | +context: | ||
| 178 | + enabled: false | ||
| 179 | + | ||
| 180 | + # Compress when the history reaches this share of the window. 75 leaves a | ||
| 181 | + # quarter for the next question, the outputs of its turns and the answer — | ||
| 182 | + # a single `bash` output can be maxOutput characters, about 4-5k tokens. | ||
| 183 | + threshold: 75 | ||
| 184 | + | ||
| 185 | + # Fallback on the message count, for when the window is unknown or the | ||
| 186 | + # estimate is off. One command costs 2 messages (call + response), so 80 is | ||
| 187 | + # roughly 30 commands of history. | ||
| 188 | + maxMessages: 80 | ||
| 189 | + | ||
| 190 | + # Question turns kept raw at the end. The recent turns are where the model | ||
| 191 | + # works; summarising them makes it re-run what it just did. Three covers | ||
| 192 | + # "do X", "now fix it", "and test it" — the usual shape of a demo. | ||
| 193 | + keepLastTurns: 3 | ||
| 194 | + | ||
| 195 | + # max_tokens of the summary request. Seven sections of one line per item | ||
| 196 | + # fit in far less; the cap stops a runaway model from filling the window it | ||
| 197 | + # was asked to empty. | ||
| 198 | + summaryMaxTokens: 1200 | ||
| 199 | + | ||
| 200 | + # Replaces the built-in summary prompt (08-context-compression/CONTEXT_WINDOW.md | ||
| 201 | + # § 4.1) when set. Empty = built-in. | ||
| 202 | + prompt: "" | ||
| 203 | + | ||
| 204 | + # One line after each compression: what was replaced, what was kept, | ||
| 205 | + # tokens before → after. Worth showing on a screen, noise in a log. | ||
| 206 | + showStats: true | ||
| 207 | + | ||
| 208 | +displayCommands: true | ||
added
agent.llamacpp.yaml +155 -0 | new file mode 100644 | ||
| @@ -0,0 +1,155 @@ | ||
| 1 | +# Same agent, served by llama.cpp's `llama-server` instead of Docker Model Runner. | |
| 2 | +# | |
| 3 | +# llama-server -hf poolside/Laguna-XS-2.1-GGUF:Q4_K_M \ | |
| 4 | +# -a poolside/Laguna-XS-2.1-GGUF:Q4_K_M --jinja -c 32768 --port 8080 | |
| 5 | +# ./bob agent.llamacpp.yaml # from demo/, so that skills/ is found | |
| 6 | +# | |
| 7 | +# `-hf <user>/<repo>:<quant>` downloads the GGUF from Hugging Face (the quant tag | |
| 8 | +# is optional and defaults to Q4_K_M; here the repo has exactly one such file, | |
| 9 | +# Laguna-XS-2.1-Q4_K_M.gguf, 20.3 GB). `-a` gives the served model the SAME name | |
| 10 | +# as the `model:` key below: llama-server routes requests on the `model` field, | |
| 11 | +# and without an alias it exposes the file's name on /v1/models, not the repo's. | |
| 12 | +# `--jinja` is not optional: without it llama-server refuses the `tools` | |
| 13 | +# parameter, and this agent is nothing but tool calls. The agent recognises that | |
| 14 | +# refusal and says so in one line instead of printing the server's stack. | |
| 15 | +# `-c` is the context the server SERVES (the model accepts up to 262,144); it is | |
| 16 | +# what shows up under `ctx:` at start-up, read from GET /props. | |
| 17 | +# Laguna XS 2.1 is a 33B MoE with 3B active parameters, built for coding; its | |
| 18 | +# card says the chat template does tool calling under --jinja, and that it | |
| 19 | +# needs a recent llama.cpp (the card points at PR #25165 — check it is merged | |
| 20 | +# in your build, or build that branch). | |
| 21 | + | |
| 22 | +provider: llamacpp | |
| 23 | + | |
| 24 | +# Must match what GET /v1/models returns — the `-a` alias above. llama-server | |
| 25 | +# routes on this field; a name it does not serve is an error, not a fallback. | |
| 26 | +#model: poolside/Laguna-XS-2.1-GGUF:Q4_K_M | |
| 27 | +#model: unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M | |
| 28 | +model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | |
| 29 | + | |
| 30 | +# llama-server's default is 127.0.0.1:8080; the OpenAI routes sit under /v1. | |
| 31 | +# Leave it out to get exactly this value. | |
| 32 | +baseUrl: http://127.0.0.1:8080/v1 | |
| 33 | + | |
| 34 | +# No fallback here: llama-server is wherever you started it. | |
| 35 | +fallback: "" | |
| 36 | + | |
| 37 | +# Only when llama-server was started with --api-key; unset otherwise. | |
| 38 | +# apiKeyEnv: LLAMA_API_KEY | |
| 39 | + | |
| 40 | +# The built-in file tools: read_file, write_file, edit_file. This is the switch | |
| 41 | +# this part exists for. `true`: the model edits files through tools it can SEE | |
| 42 | +# in its tool list. `false`: the agent is part 09 again — bash and read_skill — | |
| 43 | +# and edits files through the `edit` CLI if it is on the PATH. Same binary, | |
| 44 | +# same prompts, two set-ups: measured side by side, that is the comparison | |
| 45 | +# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue | |
| 46 | +# in the prompt plus `cat` managed 1/11). Paths are relative to the current | |
| 47 | +# directory and are not confined to it. | |
| 48 | +editTools: true | |
| 49 | + | |
| 50 | +# 0 = read the served size from /props. Set it when the server hides /props | |
| 51 | +# (a reverse proxy) or when you know better. | |
| 52 | +contextWindow: 0 | |
| 53 | + | |
| 54 | +maxOutput: 16000 | |
| 55 | +maxTurns: 40 | |
| 56 | +skillsDir: skills | |
| 57 | +previewLines: 20 | |
| 58 | +displayCommands: true | |
| 59 | + | |
| 60 | +system: | | |
| 61 | + Your name is Bob. | |
| 62 | + You are a coding agent working in a terminal. | |
| 63 | + You have a "bash" tool to run shell commands. | |
| 64 | + Use it to explore files, run tests, inspect the repository, etc. | |
| 65 | + Chain several commands if needed, then answer clearly in English. | |
| 66 | + | |
| 67 | + A request often mixes things you answer from yourself ("say hello") with | |
| 68 | + things only a command can answer ("list the files"). Handle every part, in | |
| 69 | + the order asked, and run a command for each part that needs one. | |
| 70 | + Never state the contents of a file, the output of a command, or the state of | |
| 71 | + the repository unless a command in THIS answer returned it. What you did not | |
| 72 | + read, you do not know: run the command instead of recalling it. | |
| 73 | + | |
| 74 | + SKILLS | |
| 75 | + You have a second tool, `read_skill`. Its description lists the procedures | |
| 76 | + available for this project — one per kind of task. | |
| 77 | + | |
| 78 | + Any request to DO something to a Go project is a skill, not a shell command | |
| 79 | + you invent. Match the request against that list, call `read_skill` FIRST, | |
| 80 | + before any bash command, and then follow what it says step by step. | |
| 81 | + | |
| 82 | + FILE EDITING | |
| 83 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | |
| 84 | + They are how a file gets read and changed here: each change is exact, | |
| 85 | + checked before it is written, and comes back as a diff with line numbers. | |
| 86 | + bash is for running things — building, testing, listing, searching. | |
| 87 | + | |
| 88 | + - Read before you write: call `read_file` on the file (numbered=true when | |
| 89 | + you need line numbers). You cannot target text you have not seen; never | |
| 90 | + rely on what you think you remember about a file. | |
| 91 | + - To change an existing file, call `edit_file` with one or more {old, new} | |
| 92 | + pairs. `old` is copied from the file character for character — same | |
| 93 | + spaces, same indentation, same line breaks — and appears exactly once: | |
| 94 | + add the surrounding lines until it is unique. Several pairs are applied | |
| 95 | + together, against the original file. An empty `new` deletes the text. | |
| 96 | + - Call `write_file` only to create a file, or to rewrite one entirely and | |
| 97 | + on purpose. On an existing file it replaces everything, including what | |
| 98 | + you did not intend to touch. | |
| 99 | + - Read the diff the tool returns: it says exactly what changed and on which | |
| 100 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | |
| 101 | + edits — read the file again and fix `old`. Do not fall back to | |
| 102 | + `write_file` to force the change through. | |
| 103 | + - After editing code, run the narrowest check with bash: the formatter, the | |
| 104 | + compiler, or the test covering that file. | |
| 105 | + | |
| 106 | + RULES | |
| 107 | + - Keep everything the file already does, unless the user asked to remove it. | |
| 108 | + - Touch only the files the request is about. Do not add tests, files or | |
| 109 | + features that were not asked for. | |
| 110 | + - Never run a git command unless the user says git, commit or push. | |
| 111 | + - Never move, rename or delete a file unless the user asked for it. | |
| 112 | + - Then answer in English, in a few lines. | |
| 113 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | |
| 114 | + to understand the options, then run the command. | |
| 115 | + | |
| 116 | + BACKGROUND JOBS | |
| 117 | + Never let a command block the answer. Anything that serves, watches or runs | |
| 118 | + long goes to the background, with BOTH streams redirected and its pid kept: | |
| 119 | + | |
| 120 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | |
| 121 | + | |
| 122 | + Redirecting only stdout still blocks until the process exits. Read the | |
| 123 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | |
| 124 | + rule you cannot guess. Stop every job you started before you finish, and say | |
| 125 | + which ones you left running. | |
| 126 | + | |
| 127 | +# Same sampling as the DMR file. llama-server honours `parallel_tool_calls` | |
| 128 | +# (off by default on its side too) and `max_tokens`. | |
| 129 | +sampling: | |
| 130 | + temperature: 0.0 | |
| 131 | + parallel_tool_calls: false | |
| 132 | + top_p: 0.9 | |
| 133 | + max_tokens: 4096 | |
| 134 | + | |
| 135 | +# llama-server processes the whole prompt before the first token; on a laptop a | |
| 136 | +# 32k context can take a while. Same watchdog as DMR, raise it if it fires. | |
| 137 | +watchdogTimeout: 30s | |
| 138 | + | |
| 139 | +# Context compression (from part 08). ON here, because this is the one set-up | |
| 140 | +# where the agent knows the window without being told: `contextWindow: 0` | |
| 141 | +# above means "read n_ctx from /props", and that number is what `threshold` | |
| 142 | +# applies to — so the banner's `ctx: 32768 (/props)` and the trigger agree by | |
| 143 | +# construction. With a 32k window and a 33B MoE, the whole history is | |
| 144 | +# re-read at every turn; compressing at 75 % keeps the prefill — and the | |
| 145 | +# watchdog — inside the 30 s above. Set `enabled: false` to get the exact | |
| 146 | +# part-07 behaviour back; `/compact` still works. | |
| 147 | +context: | |
| 148 | + enabled: true | |
| 149 | + threshold: 75 | |
| 150 | + # Kept as a net for a reverse proxy that hides /props: then the window is | |
| 151 | + # unknown and only this can trigger. | |
| 152 | + maxMessages: 80 | |
| 153 | + keepLastTurns: 3 | |
| 154 | + summaryMaxTokens: 1200 | |
| 155 | + showStats: true | |
| new file mode 100644 | |||
| @@ -0,0 +1,155 @@ | |||
| 1 | +# Same agent, served by llama.cpp's `llama-server` instead of Docker Model Runner. | ||
| 2 | +# | ||
| 3 | +# llama-server -hf poolside/Laguna-XS-2.1-GGUF:Q4_K_M \ | ||
| 4 | +# -a poolside/Laguna-XS-2.1-GGUF:Q4_K_M --jinja -c 32768 --port 8080 | ||
| 5 | +# ./bob agent.llamacpp.yaml # from demo/, so that skills/ is found | ||
| 6 | +# | ||
| 7 | +# `-hf <user>/<repo>:<quant>` downloads the GGUF from Hugging Face (the quant tag | ||
| 8 | +# is optional and defaults to Q4_K_M; here the repo has exactly one such file, | ||
| 9 | +# Laguna-XS-2.1-Q4_K_M.gguf, 20.3 GB). `-a` gives the served model the SAME name | ||
| 10 | +# as the `model:` key below: llama-server routes requests on the `model` field, | ||
| 11 | +# and without an alias it exposes the file's name on /v1/models, not the repo's. | ||
| 12 | +# `--jinja` is not optional: without it llama-server refuses the `tools` | ||
| 13 | +# parameter, and this agent is nothing but tool calls. The agent recognises that | ||
| 14 | +# refusal and says so in one line instead of printing the server's stack. | ||
| 15 | +# `-c` is the context the server SERVES (the model accepts up to 262,144); it is | ||
| 16 | +# what shows up under `ctx:` at start-up, read from GET /props. | ||
| 17 | +# Laguna XS 2.1 is a 33B MoE with 3B active parameters, built for coding; its | ||
| 18 | +# card says the chat template does tool calling under --jinja, and that it | ||
| 19 | +# needs a recent llama.cpp (the card points at PR #25165 — check it is merged | ||
| 20 | +# in your build, or build that branch). | ||
| 21 | + | ||
| 22 | +provider: llamacpp | ||
| 23 | + | ||
| 24 | +# Must match what GET /v1/models returns — the `-a` alias above. llama-server | ||
| 25 | +# routes on this field; a name it does not serve is an error, not a fallback. | ||
| 26 | +#model: poolside/Laguna-XS-2.1-GGUF:Q4_K_M | ||
| 27 | +#model: unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M | ||
| 28 | +model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ||
| 29 | + | ||
| 30 | +# llama-server's default is 127.0.0.1:8080; the OpenAI routes sit under /v1. | ||
| 31 | +# Leave it out to get exactly this value. | ||
| 32 | +baseUrl: http://127.0.0.1:8080/v1 | ||
| 33 | + | ||
| 34 | +# No fallback here: llama-server is wherever you started it. | ||
| 35 | +fallback: "" | ||
| 36 | + | ||
| 37 | +# Only when llama-server was started with --api-key; unset otherwise. | ||
| 38 | +# apiKeyEnv: LLAMA_API_KEY | ||
| 39 | + | ||
| 40 | +# The built-in file tools: read_file, write_file, edit_file. This is the switch | ||
| 41 | +# this part exists for. `true`: the model edits files through tools it can SEE | ||
| 42 | +# in its tool list. `false`: the agent is part 09 again — bash and read_skill — | ||
| 43 | +# and edits files through the `edit` CLI if it is on the PATH. Same binary, | ||
| 44 | +# same prompts, two set-ups: measured side by side, that is the comparison | ||
| 45 | +# (in part 07, `read_skill` as a tool was loaded 3/3 times where a catalogue | ||
| 46 | +# in the prompt plus `cat` managed 1/11). Paths are relative to the current | ||
| 47 | +# directory and are not confined to it. | ||
| 48 | +editTools: true | ||
| 49 | + | ||
| 50 | +# 0 = read the served size from /props. Set it when the server hides /props | ||
| 51 | +# (a reverse proxy) or when you know better. | ||
| 52 | +contextWindow: 0 | ||
| 53 | + | ||
| 54 | +maxOutput: 16000 | ||
| 55 | +maxTurns: 40 | ||
| 56 | +skillsDir: skills | ||
| 57 | +previewLines: 20 | ||
| 58 | +displayCommands: true | ||
| 59 | + | ||
| 60 | +system: | | ||
| 61 | + Your name is Bob. | ||
| 62 | + You are a coding agent working in a terminal. | ||
| 63 | + You have a "bash" tool to run shell commands. | ||
| 64 | + Use it to explore files, run tests, inspect the repository, etc. | ||
| 65 | + Chain several commands if needed, then answer clearly in English. | ||
| 66 | + | ||
| 67 | + A request often mixes things you answer from yourself ("say hello") with | ||
| 68 | + things only a command can answer ("list the files"). Handle every part, in | ||
| 69 | + the order asked, and run a command for each part that needs one. | ||
| 70 | + Never state the contents of a file, the output of a command, or the state of | ||
| 71 | + the repository unless a command in THIS answer returned it. What you did not | ||
| 72 | + read, you do not know: run the command instead of recalling it. | ||
| 73 | + | ||
| 74 | + SKILLS | ||
| 75 | + You have a second tool, `read_skill`. Its description lists the procedures | ||
| 76 | + available for this project — one per kind of task. | ||
| 77 | + | ||
| 78 | + Any request to DO something to a Go project is a skill, not a shell command | ||
| 79 | + you invent. Match the request against that list, call `read_skill` FIRST, | ||
| 80 | + before any bash command, and then follow what it says step by step. | ||
| 81 | + | ||
| 82 | + FILE EDITING | ||
| 83 | + You have three tools for files: `read_file`, `edit_file` and `write_file`. | ||
| 84 | + They are how a file gets read and changed here: each change is exact, | ||
| 85 | + checked before it is written, and comes back as a diff with line numbers. | ||
| 86 | + bash is for running things — building, testing, listing, searching. | ||
| 87 | + | ||
| 88 | + - Read before you write: call `read_file` on the file (numbered=true when | ||
| 89 | + you need line numbers). You cannot target text you have not seen; never | ||
| 90 | + rely on what you think you remember about a file. | ||
| 91 | + - To change an existing file, call `edit_file` with one or more {old, new} | ||
| 92 | + pairs. `old` is copied from the file character for character — same | ||
| 93 | + spaces, same indentation, same line breaks — and appears exactly once: | ||
| 94 | + add the surrounding lines until it is unique. Several pairs are applied | ||
| 95 | + together, against the original file. An empty `new` deletes the text. | ||
| 96 | + - Call `write_file` only to create a file, or to rewrite one entirely and | ||
| 97 | + on purpose. On an existing file it replaces everything, including what | ||
| 98 | + you did not intend to touch. | ||
| 99 | + - Read the diff the tool returns: it says exactly what changed and on which | ||
| 100 | + line. If `edit_file` refuses — text not found, ambiguous, overlapping | ||
| 101 | + edits — read the file again and fix `old`. Do not fall back to | ||
| 102 | + `write_file` to force the change through. | ||
| 103 | + - After editing code, run the narrowest check with bash: the formatter, the | ||
| 104 | + compiler, or the test covering that file. | ||
| 105 | + | ||
| 106 | + RULES | ||
| 107 | + - Keep everything the file already does, unless the user asked to remove it. | ||
| 108 | + - Touch only the files the request is about. Do not add tests, files or | ||
| 109 | + features that were not asked for. | ||
| 110 | + - Never run a git command unless the user says git, commit or push. | ||
| 111 | + - Never move, rename or delete a file unless the user asked for it. | ||
| 112 | + - Then answer in English, in a few lines. | ||
| 113 | + - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`) | ||
| 114 | + to understand the options, then run the command. | ||
| 115 | + | ||
| 116 | + BACKGROUND JOBS | ||
| 117 | + Never let a command block the answer. Anything that serves, watches or runs | ||
| 118 | + long goes to the background, with BOTH streams redirected and its pid kept: | ||
| 119 | + | ||
| 120 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | ||
| 121 | + | ||
| 122 | + Redirecting only stdout still blocks until the process exits. Read the | ||
| 123 | + `bg-jobs` skill before you wait on, inspect or stop such a job — each has a | ||
| 124 | + rule you cannot guess. Stop every job you started before you finish, and say | ||
| 125 | + which ones you left running. | ||
| 126 | + | ||
| 127 | +# Same sampling as the DMR file. llama-server honours `parallel_tool_calls` | ||
| 128 | +# (off by default on its side too) and `max_tokens`. | ||
| 129 | +sampling: | ||
| 130 | + temperature: 0.0 | ||
| 131 | + parallel_tool_calls: false | ||
| 132 | + top_p: 0.9 | ||
| 133 | + max_tokens: 4096 | ||
| 134 | + | ||
| 135 | +# llama-server processes the whole prompt before the first token; on a laptop a | ||
| 136 | +# 32k context can take a while. Same watchdog as DMR, raise it if it fires. | ||
| 137 | +watchdogTimeout: 30s | ||
| 138 | + | ||
| 139 | +# Context compression (from part 08). ON here, because this is the one set-up | ||
| 140 | +# where the agent knows the window without being told: `contextWindow: 0` | ||
| 141 | +# above means "read n_ctx from /props", and that number is what `threshold` | ||
| 142 | +# applies to — so the banner's `ctx: 32768 (/props)` and the trigger agree by | ||
| 143 | +# construction. With a 32k window and a 33B MoE, the whole history is | ||
| 144 | +# re-read at every turn; compressing at 75 % keeps the prefill — and the | ||
| 145 | +# watchdog — inside the 30 s above. Set `enabled: false` to get the exact | ||
| 146 | +# part-07 behaviour back; `/compact` still works. | ||
| 147 | +context: | ||
| 148 | + enabled: true | ||
| 149 | + threshold: 75 | ||
| 150 | + # Kept as a net for a reverse proxy that hides /props: then the window is | ||
| 151 | + # unknown and only this can trigger. | ||
| 152 | + maxMessages: 80 | ||
| 153 | + keepLastTurns: 3 | ||
| 154 | + summaryMaxTokens: 1200 | ||
| 155 | + showStats: true | ||
added
docs/README.md +10 -0 | new file mode 100644 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +# mini-me documentation | |
| 2 | + | |
| 3 | +Documentation of **mini-me** (`mm`), a minimal terminal coding agent written in Go that drives a local LLM through the OpenAI-compatible API. | |
| 4 | + | |
| 5 | +Choose a language: | |
| 6 | + | |
| 7 | +- [English](en/README.md) | |
| 8 | +- [Français](fr/README.md) | |
| 9 | + | |
| 10 | +Both trees follow the [Diátaxis](https://diataxis.fr) layout: `tutorials/` to learn, `how-to/` to get a task done, `reference/` to look something up, `explanation/` to understand. | |
| new file mode 100644 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +# mini-me documentation | ||
| 2 | + | ||
| 3 | +Documentation of **mini-me** (`mm`), a minimal terminal coding agent written in Go that drives a local LLM through the OpenAI-compatible API. | ||
| 4 | + | ||
| 5 | +Choose a language: | ||
| 6 | + | ||
| 7 | +- [English](en/README.md) | ||
| 8 | +- [Français](fr/README.md) | ||
| 9 | + | ||
| 10 | +Both trees follow the [Diátaxis](https://diataxis.fr) layout: `tutorials/` to learn, `how-to/` to get a task done, `reference/` to look something up, `explanation/` to understand. | ||
added
docs/diagrams/packages.drawio +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +<mxfile host="app.diagrams.net" modified="2026-09-15T00: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</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="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="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></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +<mxfile host="app.diagrams.net" modified="2026-09-15T00: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</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="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="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></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
docs/en/README.md +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +# mini-me — documentation (English) | |
| 2 | + | |
| 3 | +`mm` is a coding agent that runs in your terminal, or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`. | |
| 4 | + | |
| 5 | +This documentation is organised along the four [Diátaxis](https://diataxis.fr) quadrants. Pick the one that matches what you need right now. | |
| 6 | + | |
| 7 | +## Tutorials — learn by doing | |
| 8 | + | |
| 9 | +- [Your first session with mini-me](tutorials/getting-started.md) | |
| 10 | + | |
| 11 | +## How-to guides — get a task done | |
| 12 | + | |
| 13 | +- [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) | |
| 14 | +- [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) | |
| 15 | +- [Add a skill](how-to/add-a-skill.md) | |
| 16 | +- [Keep a long session inside the context window](how-to/manage-the-context-window.md) | |
| 17 | +- [Start a new session without restarting](how-to/start-a-new-session.md) | |
| 18 | +- [Point the model at a file with `@path`](how-to/attach-a-file.md) | |
| 19 | + | |
| 20 | +## Reference — look it up | |
| 21 | + | |
| 22 | +- [Command line](reference/cli.md) — flags, environment variables, start-up banner | |
| 23 | +- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, keyboard shortcuts | |
| 24 | +- [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule | |
| 25 | +- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file` | |
| 26 | +- [Skill file format](reference/skill-format.md) | |
| 27 | +- [Agent Client Protocol surface](reference/acp.md) | |
| 28 | + | |
| 29 | +## Explanation — understand | |
| 30 | + | |
| 31 | +- [Architecture: one loop, two front ends](explanation/architecture.md) | |
| 32 | +- [Providers: one implementation per protocol](explanation/providers.md) | |
| 33 | +- [Context compression](explanation/context-compression.md) | |
| 34 | +- Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | |
| 35 | + | |
| 36 | +## Scope | |
| 37 | + | |
| 38 | +This documentation covers the `mm` binary and the packages under `internal/`. The `kits/` and `tmp/` directories at the repository root are not part of the project and are not documented here. | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +# mini-me — documentation (English) | ||
| 2 | + | ||
| 3 | +`mm` is a coding agent that runs in your terminal, or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`. | ||
| 4 | + | ||
| 5 | +This documentation is organised along the four [Diátaxis](https://diataxis.fr) quadrants. Pick the one that matches what you need right now. | ||
| 6 | + | ||
| 7 | +## Tutorials — learn by doing | ||
| 8 | + | ||
| 9 | +- [Your first session with mini-me](tutorials/getting-started.md) | ||
| 10 | + | ||
| 11 | +## How-to guides — get a task done | ||
| 12 | + | ||
| 13 | +- [Run mini-me on llama.cpp instead of Docker Model Runner](how-to/run-with-llama-cpp.md) | ||
| 14 | +- [Use mini-me from an editor (ACP)](how-to/use-from-an-editor.md) | ||
| 15 | +- [Add a skill](how-to/add-a-skill.md) | ||
| 16 | +- [Keep a long session inside the context window](how-to/manage-the-context-window.md) | ||
| 17 | +- [Start a new session without restarting](how-to/start-a-new-session.md) | ||
| 18 | +- [Point the model at a file with `@path`](how-to/attach-a-file.md) | ||
| 19 | + | ||
| 20 | +## Reference — look it up | ||
| 21 | + | ||
| 22 | +- [Command line](reference/cli.md) — flags, environment variables, start-up banner | ||
| 23 | +- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, keyboard shortcuts | ||
| 24 | +- [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule | ||
| 25 | +- [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file` | ||
| 26 | +- [Skill file format](reference/skill-format.md) | ||
| 27 | +- [Agent Client Protocol surface](reference/acp.md) | ||
| 28 | + | ||
| 29 | +## Explanation — understand | ||
| 30 | + | ||
| 31 | +- [Architecture: one loop, two front ends](explanation/architecture.md) | ||
| 32 | +- [Providers: one implementation per protocol](explanation/providers.md) | ||
| 33 | +- [Context compression](explanation/context-compression.md) | ||
| 34 | +- Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | ||
| 35 | + | ||
| 36 | +## Scope | ||
| 37 | + | ||
| 38 | +This documentation covers the `mm` binary and the packages under `internal/`. The `kits/` and `tmp/` directories at the repository root are not part of the project and are not documented here. | ||
added
docs/en/explanation/architecture.md +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +# Architecture: one loop, two front ends — explanation | |
| 2 | + | |
| 3 | +## What is this about? | |
| 4 | + | |
| 5 | +`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal and from an editor without duplicating anything. | |
| 6 | + | |
| 7 | +The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of two front ends. Each concern lives in its own package under `internal/`: | |
| 8 | + | |
| 9 | +| Package | Responsibility | | |
| 10 | +|---------|----------------| | |
| 11 | +| `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. | | |
| 12 | +| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. | | |
| 13 | +| `tools` | The built-in tools the model can call. | | |
| 14 | +| `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. | | |
| 15 | +| `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. | | |
| 16 | +| `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. | | |
| 17 | +| `detector` | Detection of the same action repeated with the same result. | | |
| 18 | +| `compact` | Compression of the history into a model-written summary. | | |
| 19 | +| `spinner` | The "still working" indicator on a terminal. | | |
| 20 | +| `ui` | Where human-facing output goes, and the event sink the two front ends share. | | |
| 21 | +| `agent` | The terminal REPL. | | |
| 22 | +| `acp` | The Agent Client Protocol façade. | | |
| 23 | + | |
| 24 | +A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio). | |
| 25 | + | |
| 26 | +## Why it is designed this way | |
| 27 | + | |
| 28 | +**Tools report failures as text, never as errors.** A command that exits non-zero, a file that does not exist, an edit that is ambiguous: all of it goes back to the model as the tool's output. The model is the one that must react, so it is the one that must read the message. A Go error would end the turn instead. | |
| 29 | + | |
| 30 | +**The full history is kept even when a turn fails.** Genkit only returns the last message; the engine returns the whole conversation including tool calls and results. The front ends keep that history after an abort, a watchdog cut or a `maxTurns` overrun, because the commands that already ran are facts the model must not re-run or invent on the next question. Only a question that got no answer at all is dropped. | |
| 31 | + | |
| 32 | +**Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing. | |
| 33 | + | |
| 34 | +**One loop, two front ends.** The terminal REPL and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests. | |
| 35 | + | |
| 36 | +**Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session. | |
| 37 | + | |
| 38 | +**Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway. | |
| 39 | + | |
| 40 | +**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new. | |
| 41 | + | |
| 42 | +## Rejected alternatives | |
| 43 | + | |
| 44 | +- **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible. | |
| 45 | +- **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout. | |
| 46 | +- **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover. | |
| 47 | + | |
| 48 | +## How it relates to the rest | |
| 49 | + | |
| 50 | +- The provider registry that the engine uses is described in [providers](providers.md). | |
| 51 | +- Why and how the history is shortened is in [context compression](context-compression.md). | |
| 52 | +- The exact protocol surface is in the [ACP reference](../reference/acp.md); the tools' contract is in the [tools reference](../reference/tools.md). | |
| 53 | + | |
| 54 | +## Package map | |
| 55 | + | |
| 56 | +The import graph of every package, with a one-line purpose on each, is kept as a draw.io diagram at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (opens in diagrams.net or the VS Code Draw.io extension). It is regenerated from `go list` whenever a package is added, removed or re-wired. | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +# Architecture: one loop, two front ends — explanation | ||
| 2 | + | ||
| 3 | +## What is this about? | ||
| 4 | + | ||
| 5 | +`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal and from an editor without duplicating anything. | ||
| 6 | + | ||
| 7 | +The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of two front ends. Each concern lives in its own package under `internal/`: | ||
| 8 | + | ||
| 9 | +| Package | Responsibility | | ||
| 10 | +|---------|----------------| | ||
| 11 | +| `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. | | ||
| 12 | +| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. | | ||
| 13 | +| `tools` | The built-in tools the model can call. | | ||
| 14 | +| `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. | | ||
| 15 | +| `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. | | ||
| 16 | +| `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. | | ||
| 17 | +| `detector` | Detection of the same action repeated with the same result. | | ||
| 18 | +| `compact` | Compression of the history into a model-written summary. | | ||
| 19 | +| `spinner` | The "still working" indicator on a terminal. | | ||
| 20 | +| `ui` | Where human-facing output goes, and the event sink the two front ends share. | | ||
| 21 | +| `agent` | The terminal REPL. | | ||
| 22 | +| `acp` | The Agent Client Protocol façade. | | ||
| 23 | + | ||
| 24 | +A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio). | ||
| 25 | + | ||
| 26 | +## Why it is designed this way | ||
| 27 | + | ||
| 28 | +**Tools report failures as text, never as errors.** A command that exits non-zero, a file that does not exist, an edit that is ambiguous: all of it goes back to the model as the tool's output. The model is the one that must react, so it is the one that must read the message. A Go error would end the turn instead. | ||
| 29 | + | ||
| 30 | +**The full history is kept even when a turn fails.** Genkit only returns the last message; the engine returns the whole conversation including tool calls and results. The front ends keep that history after an abort, a watchdog cut or a `maxTurns` overrun, because the commands that already ran are facts the model must not re-run or invent on the next question. Only a question that got no answer at all is dropped. | ||
| 31 | + | ||
| 32 | +**Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing. | ||
| 33 | + | ||
| 34 | +**One loop, two front ends.** The terminal REPL and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests. | ||
| 35 | + | ||
| 36 | +**Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session. | ||
| 37 | + | ||
| 38 | +**Turns are serialised in ACP mode.** The active sink is process-global and the engine was never built for concurrent generations, so a second `session/prompt` waits for the first to finish. The protocol leaves that choice to the agent, and editors do not send overlapping prompts anyway. | ||
| 39 | + | ||
| 40 | +**The loop detector is separate from the history.** It records tool, input and output triples; the same triple three times in a row makes the tool append an instruction to change approach. It is deliberately not reset by `/compact`: forgetting a conversation does not make a repeated command new. | ||
| 41 | + | ||
| 42 | +## Rejected alternatives | ||
| 43 | + | ||
| 44 | +- **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible. | ||
| 45 | +- **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout. | ||
| 46 | +- **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover. | ||
| 47 | + | ||
| 48 | +## How it relates to the rest | ||
| 49 | + | ||
| 50 | +- The provider registry that the engine uses is described in [providers](providers.md). | ||
| 51 | +- Why and how the history is shortened is in [context compression](context-compression.md). | ||
| 52 | +- The exact protocol surface is in the [ACP reference](../reference/acp.md); the tools' contract is in the [tools reference](../reference/tools.md). | ||
| 53 | + | ||
| 54 | +## Package map | ||
| 55 | + | ||
| 56 | +The import graph of every package, with a one-line purpose on each, is kept as a draw.io diagram at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (opens in diagrams.net or the VS Code Draw.io extension). It is regenerated from `go list` whenever a package is added, removed or re-wired. | ||
added
docs/en/explanation/context-compression.md +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +# Context compression — explanation | |
| 2 | + | |
| 3 | +## What is this about? | |
| 4 | + | |
| 5 | +Nothing in an agent loop ever shortens the history. Measured against a fake engine, the messages sent to the model grew 2 → 5 → 7 → 9 over four requests, and every `bash` output, up to `maxOutput` characters, and every loaded skill stay until `/quit`. On a local model the context window is fixed when the server loads, and the whole history is re-read at every turn on the laptop's hardware. A long session therefore first shows up as a false watchdog stall, then as an error from the server. | |
| 6 | + | |
| 7 | +The `compact` package replaces the old question turns by one summary written by the model itself, and keeps the last few turns raw. | |
| 8 | + | |
| 9 | +## Why it is designed this way | |
| 10 | + | |
| 11 | +**A hybrid: summary plus raw recent turns.** The recent turns are where the model is working; summarising them makes it re-run what it just did. The default keeps three question turns, enough for "do X", "now fix it", "and test it". Everything older becomes a pair of messages inserted right after the system prompt: a user message carrying the notes, and a short model acknowledgement. | |
| 12 | + | |
| 13 | +**The cut falls on a turn boundary.** A tool call and its result must never be split; reusing whole turns guarantees it. | |
| 14 | + | |
| 15 | +**Two triggers, because the window is not always known.** The primary trigger is a share of the context window, 75 % by default, leaving a quarter for the next question, its tool outputs and the answer. The server's own input-token count from the last call is preferred to the local estimate when it is larger, because it sees the tools' JSON and the chat template. When the window is unknown, Docker Model Runner tells nothing and `contextWindow` may be `0`, only the message-count trigger can fire, and the agent says so at start-up. | |
| 16 | + | |
| 17 | +**One key for the window.** The banner and the trigger read the same `contextWindow`; two keys for the same number would drift apart. | |
| 18 | + | |
| 19 | +**Earlier summaries are merged, not re-summarised.** The two inserted messages carry a marker; a later compression recognises them and folds the new old turns into the existing summary, because summarising a summary loses detail exponentially. | |
| 20 | + | |
| 21 | +**A fixed-shape summary.** The built-in prompt asks for seven sections with exact paths, command lines and error messages, and forbids quoting file contents: a file may have changed since it was read, and the system prompt already forbids stating what a command in this answer did not return. The summary request replaces the agent's own system prompt with a note-taker's, because "you are a coding agent, run commands" sent with a history full of tool calls invites the model to keep acting. | |
| 22 | + | |
| 23 | +**Failure costs nothing.** If the summary request fails, server down, watchdog, empty result, the history is left untouched and the next question goes out as before. The compression exists to keep the next question possible; it must never cost one. | |
| 24 | + | |
| 25 | +**The watchdog is twice as patient here.** The summary is the longest prefill of the session and nothing streams while it is written. | |
| 26 | + | |
| 27 | +**`/compact` skips the threshold, not the boundary.** It still keeps the last turns and says so when nothing is older. Compression only ever runs between two generations, because Genkit holds its own copy of the conversation while a turn runs. | |
| 28 | + | |
| 29 | +## Rejected alternatives | |
| 30 | + | |
| 31 | +- **Truncating the oldest messages.** Rejected: it would drop tool results whose calls remain, and lose the goal of the session. | |
| 32 | +- **Summarising everything, recent turns included.** Rejected: the model then redoes its last steps. | |
| 33 | +- **A separate `context.contextWindow` key.** Rejected in favour of the single top-level key. | |
| 34 | + | |
| 35 | +## How it relates to the rest | |
| 36 | + | |
| 37 | +- Turning it on and reading the report: [how to keep a long session inside the context window](../how-to/manage-the-context-window.md). | |
| 38 | +- The `context.*` keys: [configuration reference](../reference/configuration.md). | |
| 39 | +- Where the window comes from: [providers](providers.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +# Context compression — explanation | ||
| 2 | + | ||
| 3 | +## What is this about? | ||
| 4 | + | ||
| 5 | +Nothing in an agent loop ever shortens the history. Measured against a fake engine, the messages sent to the model grew 2 → 5 → 7 → 9 over four requests, and every `bash` output, up to `maxOutput` characters, and every loaded skill stay until `/quit`. On a local model the context window is fixed when the server loads, and the whole history is re-read at every turn on the laptop's hardware. A long session therefore first shows up as a false watchdog stall, then as an error from the server. | ||
| 6 | + | ||
| 7 | +The `compact` package replaces the old question turns by one summary written by the model itself, and keeps the last few turns raw. | ||
| 8 | + | ||
| 9 | +## Why it is designed this way | ||
| 10 | + | ||
| 11 | +**A hybrid: summary plus raw recent turns.** The recent turns are where the model is working; summarising them makes it re-run what it just did. The default keeps three question turns, enough for "do X", "now fix it", "and test it". Everything older becomes a pair of messages inserted right after the system prompt: a user message carrying the notes, and a short model acknowledgement. | ||
| 12 | + | ||
| 13 | +**The cut falls on a turn boundary.** A tool call and its result must never be split; reusing whole turns guarantees it. | ||
| 14 | + | ||
| 15 | +**Two triggers, because the window is not always known.** The primary trigger is a share of the context window, 75 % by default, leaving a quarter for the next question, its tool outputs and the answer. The server's own input-token count from the last call is preferred to the local estimate when it is larger, because it sees the tools' JSON and the chat template. When the window is unknown, Docker Model Runner tells nothing and `contextWindow` may be `0`, only the message-count trigger can fire, and the agent says so at start-up. | ||
| 16 | + | ||
| 17 | +**One key for the window.** The banner and the trigger read the same `contextWindow`; two keys for the same number would drift apart. | ||
| 18 | + | ||
| 19 | +**Earlier summaries are merged, not re-summarised.** The two inserted messages carry a marker; a later compression recognises them and folds the new old turns into the existing summary, because summarising a summary loses detail exponentially. | ||
| 20 | + | ||
| 21 | +**A fixed-shape summary.** The built-in prompt asks for seven sections with exact paths, command lines and error messages, and forbids quoting file contents: a file may have changed since it was read, and the system prompt already forbids stating what a command in this answer did not return. The summary request replaces the agent's own system prompt with a note-taker's, because "you are a coding agent, run commands" sent with a history full of tool calls invites the model to keep acting. | ||
| 22 | + | ||
| 23 | +**Failure costs nothing.** If the summary request fails, server down, watchdog, empty result, the history is left untouched and the next question goes out as before. The compression exists to keep the next question possible; it must never cost one. | ||
| 24 | + | ||
| 25 | +**The watchdog is twice as patient here.** The summary is the longest prefill of the session and nothing streams while it is written. | ||
| 26 | + | ||
| 27 | +**`/compact` skips the threshold, not the boundary.** It still keeps the last turns and says so when nothing is older. Compression only ever runs between two generations, because Genkit holds its own copy of the conversation while a turn runs. | ||
| 28 | + | ||
| 29 | +## Rejected alternatives | ||
| 30 | + | ||
| 31 | +- **Truncating the oldest messages.** Rejected: it would drop tool results whose calls remain, and lose the goal of the session. | ||
| 32 | +- **Summarising everything, recent turns included.** Rejected: the model then redoes its last steps. | ||
| 33 | +- **A separate `context.contextWindow` key.** Rejected in favour of the single top-level key. | ||
| 34 | + | ||
| 35 | +## How it relates to the rest | ||
| 36 | + | ||
| 37 | +- Turning it on and reading the report: [how to keep a long session inside the context window](../how-to/manage-the-context-window.md). | ||
| 38 | +- The `context.*` keys: [configuration reference](../reference/configuration.md). | ||
| 39 | +- Where the window comes from: [providers](providers.md). | ||
added
docs/en/explanation/providers.md +31 -0 | new file mode 100644 | ||
| @@ -0,0 +1,31 @@ | ||
| 1 | +# Providers: one implementation per protocol — explanation | |
| 2 | + | |
| 3 | +## What is this about? | |
| 4 | + | |
| 5 | +`mm` talks to a local model server through the OpenAI chat-completions API. Two servers are supported out of the box, Docker Model Runner and llama.cpp's `llama-server`, under the names `dmr` and `llamacpp`. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it. | |
| 6 | + | |
| 7 | +## Why it is designed this way | |
| 8 | + | |
| 9 | +**One provider type per wire protocol, not per vendor.** When the engine was split out, exactly one function and two string constants were specific to Docker Model Runner. Everything else, the streaming, the watchdog, the retry, the command counter, behaved the same against a fake engine that is not DMR either. So `dmr` and `llamacpp` are two data entries of a single `openaiCompat` type: a default base URL, an optional fallback URL, a key policy, and the words used in error messages. Adding another OpenAI-compatible server is a registry entry; adding a different protocol would be a new type. | |
| 10 | + | |
| 11 | +**A provider owns four things.** It resolves the configuration into a concrete backend (URL after environment overrides and fallback probe, key read from the environment, model name); it opens Genkit with its plugin; it probes the server before the first question; and it explains a transport error in one line. | |
| 12 | + | |
| 13 | +**The fallback exists for containers.** On the host, Docker Model Runner listens on `localhost:12434`; from inside a container or a sandbox the same server is reachable as `host.docker.internal`. If the primary URL does not answer `/models` within two seconds, the fallback is used. An explicit environment override skips the probe: whoever set it knows where the server is. An explicit empty `fallback: ""` disables it, which is what a test configuration needs to stay pinned to one URL. | |
| 14 | + | |
| 15 | +**The key never goes in the YAML.** The file names the environment variable that holds the key, so it can be committed and shown on screen. Servers that ignore the header get a placeholder, because the OpenAI client wants something there. A 401 is then explained by naming the variable the provider actually read, or, when none is configured, by pointing at the URL. | |
| 16 | + | |
| 17 | +**The probe is advice, never a failure.** On a demo machine the server is often started after the agent. The probe checks reachability, warns when the configured model is not in DMR's list, and on llama.cpp reads the served context size from `/props`, one level above `/v1`, because that endpoint reports what the server serves, not what the model was trained with. When the window is still unknown and compression is on, the REPL probes again before the first question. | |
| 18 | + | |
| 19 | +**Errors are rewritten in the server's own words.** A stack of wrapped messages is noise; "tool calls need llama-server started with --jinja" is the whole diagnosis. The provider recognises connection refusals, the missing `--jinja` flag, 401/403, 404 with a pull or alias hint, 429 and 5xx. Unknown errors come back unchanged: a raw message beats a wrong hint. | |
| 20 | + | |
| 21 | +## Rejected alternatives | |
| 22 | + | |
| 23 | +- **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers. | |
| 24 | +- **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown. | |
| 25 | +- **Failing at start-up when the server is down.** Rejected because starting the server second is the normal demo situation. | |
| 26 | + | |
| 27 | +## How it relates to the rest | |
| 28 | + | |
| 29 | +- The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md). | |
| 30 | +- The window the probe learns is what [context compression](context-compression.md) measures against. | |
| 31 | +- Switching servers for one run is covered in [how to run on llama.cpp](../how-to/run-with-llama-cpp.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | +# Providers: one implementation per protocol — explanation | ||
| 2 | + | ||
| 3 | +## What is this about? | ||
| 4 | + | ||
| 5 | +`mm` talks to a local model server through the OpenAI chat-completions API. Two servers are supported out of the box, Docker Model Runner and llama.cpp's `llama-server`, under the names `dmr` and `llamacpp`. The engine package used to be tied to Docker Model Runner; the provider abstraction is what generalised it. | ||
| 6 | + | ||
| 7 | +## Why it is designed this way | ||
| 8 | + | ||
| 9 | +**One provider type per wire protocol, not per vendor.** When the engine was split out, exactly one function and two string constants were specific to Docker Model Runner. Everything else, the streaming, the watchdog, the retry, the command counter, behaved the same against a fake engine that is not DMR either. So `dmr` and `llamacpp` are two data entries of a single `openaiCompat` type: a default base URL, an optional fallback URL, a key policy, and the words used in error messages. Adding another OpenAI-compatible server is a registry entry; adding a different protocol would be a new type. | ||
| 10 | + | ||
| 11 | +**A provider owns four things.** It resolves the configuration into a concrete backend (URL after environment overrides and fallback probe, key read from the environment, model name); it opens Genkit with its plugin; it probes the server before the first question; and it explains a transport error in one line. | ||
| 12 | + | ||
| 13 | +**The fallback exists for containers.** On the host, Docker Model Runner listens on `localhost:12434`; from inside a container or a sandbox the same server is reachable as `host.docker.internal`. If the primary URL does not answer `/models` within two seconds, the fallback is used. An explicit environment override skips the probe: whoever set it knows where the server is. An explicit empty `fallback: ""` disables it, which is what a test configuration needs to stay pinned to one URL. | ||
| 14 | + | ||
| 15 | +**The key never goes in the YAML.** The file names the environment variable that holds the key, so it can be committed and shown on screen. Servers that ignore the header get a placeholder, because the OpenAI client wants something there. A 401 is then explained by naming the variable the provider actually read, or, when none is configured, by pointing at the URL. | ||
| 16 | + | ||
| 17 | +**The probe is advice, never a failure.** On a demo machine the server is often started after the agent. The probe checks reachability, warns when the configured model is not in DMR's list, and on llama.cpp reads the served context size from `/props`, one level above `/v1`, because that endpoint reports what the server serves, not what the model was trained with. When the window is still unknown and compression is on, the REPL probes again before the first question. | ||
| 18 | + | ||
| 19 | +**Errors are rewritten in the server's own words.** A stack of wrapped messages is noise; "tool calls need llama-server started with --jinja" is the whole diagnosis. The provider recognises connection refusals, the missing `--jinja` flag, 401/403, 404 with a pull or alias hint, 429 and 5xx. Unknown errors come back unchanged: a raw message beats a wrong hint. | ||
| 20 | + | ||
| 21 | +## Rejected alternatives | ||
| 22 | + | ||
| 23 | +- **A provider per vendor with duplicated engine code.** Rejected: measured, the vendor-specific part was three identifiers. | ||
| 24 | +- **Putting the API key in the configuration file.** Rejected because the file is meant to be committed and shown. | ||
| 25 | +- **Failing at start-up when the server is down.** Rejected because starting the server second is the normal demo situation. | ||
| 26 | + | ||
| 27 | +## How it relates to the rest | ||
| 28 | + | ||
| 29 | +- The two configuration files that parametrise each provider are listed in the [configuration reference](../reference/configuration.md). | ||
| 30 | +- The window the probe learns is what [context compression](context-compression.md) measures against. | ||
| 31 | +- Switching servers for one run is covered in [how to run on llama.cpp](../how-to/run-with-llama-cpp.md). | ||
added
docs/en/how-to/add-a-skill.md +36 -0 | new file mode 100644 | ||
| @@ -0,0 +1,36 @@ | ||
| 1 | +# How to add a skill | |
| 2 | + | |
| 3 | +This guide shows how to give the agent a new markdown procedure that the model can load with `read_skill`. It assumes you know what task the procedure should cover. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Create the skill in the skills directory (`skills/` by default, see `skillsDir`), in either layout: a flat file `skills/<name>.md`, or a directory per skill `skills/<name>/SKILL.md` (the layout of the shipped examples). The two can be mixed; any other file in a skill's directory is ignored. | |
| 8 | + | |
| 9 | + ```bash | |
| 10 | + cat > skills/go-test.md <<'EOF2' | |
| 11 | + --- | |
| 12 | + name: go-test | |
| 13 | + description: run the Go tests of the current module and report failures | |
| 14 | + --- | |
| 15 | + # Run the Go tests | |
| 16 | + | |
| 17 | + Run `go test ./...` with your `bash` tool, then report the failing packages, if any. | |
| 18 | + EOF2 | |
| 19 | + ``` | |
| 20 | + | |
| 21 | +2. Give it a `name:` and a one-line `description:` in the front matter. The description is what the model reads in the `read_skill` tool description to pick a skill; without one it chooses blindly. | |
| 22 | + | |
| 23 | +3. Restart `mm`. The catalogue is built once at start-up. The banner's `skills: N` counter goes up by one, and `read_skill` appears in the `tools:` list if it was absent. | |
| 24 | + | |
| 25 | +4. Ask for the task in the terminal. The system prompt in `agent.yaml` tells the model to call `read_skill` first for any request that matches the catalogue; a `📖 read_skill: go-test` line confirms it did. | |
| 26 | + | |
| 27 | +## Variants | |
| 28 | + | |
| 29 | +- **Another directory.** Set `skillsDir` in the YAML. In the terminal a relative path is resolved from the directory you start `mm` in, so an installed `mm` run inside a project loads that project's skills; in ACP mode with a config file it is resolved next to that file. The banner prints `[warning: no skills found in <absolute path> …]` when the resolved directory yields nothing. | |
| 30 | +- **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it. | |
| 31 | +- **The shipped examples.** The repository ships `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` and `skills/what-time-is-it/SKILL.md`. Start `mm` from the repository root and the banner shows `skills: 3`. | |
| 32 | + | |
| 33 | +## See also | |
| 34 | + | |
| 35 | +- The fields the front matter accepts: [skill file format](../reference/skill-format.md) | |
| 36 | +- What `read_skill` returns to the model: [built-in tools](../reference/tools.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,36 @@ | |||
| 1 | +# How to add a skill | ||
| 2 | + | ||
| 3 | +This guide shows how to give the agent a new markdown procedure that the model can load with `read_skill`. It assumes you know what task the procedure should cover. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Create the skill in the skills directory (`skills/` by default, see `skillsDir`), in either layout: a flat file `skills/<name>.md`, or a directory per skill `skills/<name>/SKILL.md` (the layout of the shipped examples). The two can be mixed; any other file in a skill's directory is ignored. | ||
| 8 | + | ||
| 9 | + ```bash | ||
| 10 | + cat > skills/go-test.md <<'EOF2' | ||
| 11 | + --- | ||
| 12 | + name: go-test | ||
| 13 | + description: run the Go tests of the current module and report failures | ||
| 14 | + --- | ||
| 15 | + # Run the Go tests | ||
| 16 | + | ||
| 17 | + Run `go test ./...` with your `bash` tool, then report the failing packages, if any. | ||
| 18 | + EOF2 | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | +2. Give it a `name:` and a one-line `description:` in the front matter. The description is what the model reads in the `read_skill` tool description to pick a skill; without one it chooses blindly. | ||
| 22 | + | ||
| 23 | +3. Restart `mm`. The catalogue is built once at start-up. The banner's `skills: N` counter goes up by one, and `read_skill` appears in the `tools:` list if it was absent. | ||
| 24 | + | ||
| 25 | +4. Ask for the task in the terminal. The system prompt in `agent.yaml` tells the model to call `read_skill` first for any request that matches the catalogue; a `📖 read_skill: go-test` line confirms it did. | ||
| 26 | + | ||
| 27 | +## Variants | ||
| 28 | + | ||
| 29 | +- **Another directory.** Set `skillsDir` in the YAML. In the terminal a relative path is resolved from the directory you start `mm` in, so an installed `mm` run inside a project loads that project's skills; in ACP mode with a config file it is resolved next to that file. The banner prints `[warning: no skills found in <absolute path> …]` when the resolved directory yields nothing. | ||
| 30 | +- **No skills at all.** Leave the directory empty or absent: `read_skill` is then not declared, and the model never sees it. | ||
| 31 | +- **The shipped examples.** The repository ships `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` and `skills/what-time-is-it/SKILL.md`. Start `mm` from the repository root and the banner shows `skills: 3`. | ||
| 32 | + | ||
| 33 | +## See also | ||
| 34 | + | ||
| 35 | +- The fields the front matter accepts: [skill file format](../reference/skill-format.md) | ||
| 36 | +- What `read_skill` returns to the model: [built-in tools](../reference/tools.md) | ||
added
docs/en/how-to/attach-a-file.md +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +# Point the model at a file with `@path` | |
| 2 | + | |
| 3 | +Name a file or a directory in your question with `@` followed by its path. mini-me checks that the path exists, tells the model where it is, and the model reads it with its tools. There is no picker or completion: you type the path. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Start `mm` from the project directory. Relative paths are resolved from there. | |
| 8 | +2. Write the path in the question, glued to `@`: | |
| 9 | + | |
| 10 | + ``` | |
| 11 | + > explain what @internal/acp/acp.go does with @internal/session | |
| 12 | + 📎 /work/mini-me/internal/acp/acp.go | |
| 13 | + 📎 /work/mini-me/internal/session | |
| 14 | + ``` | |
| 15 | + | |
| 16 | + One `📎` line per attached path confirms the notation was understood. The model receives your sentence as typed, followed by one line per path: | |
| 17 | + | |
| 18 | + ``` | |
| 19 | + [attached file: /work/mini-me/internal/acp/acp.go] | |
| 20 | + [attached directory: /work/mini-me/internal/session] | |
| 21 | + ``` | |
| 22 | + | |
| 23 | +3. Ask away. The content is not copied into the prompt: the model runs `cat`, `read_file` or `ls` on the path when it needs to. | |
| 24 | + | |
| 25 | +## Rules | |
| 26 | + | |
| 27 | +| Case | Result | | |
| 28 | +|------|--------| | |
| 29 | +| `@internal/acp/acp.go` | Relative to the directory `mm` was started in (the session's `cwd` in ACP mode). | | |
| 30 | +| `@/abs/path`, `@~/file` | Absolute paths and `~` work. | | |
| 31 | +| `@main.go,` `(@main.go)` `"@main.go"` | Sentence punctuation and brackets around the mention are ignored. | | |
| 32 | +| `@bob`, `@does/not/exist` | Nothing on disk: left in the sentence as plain text, no attachment. | | |
| 33 | +| `k33g@example.com` | A `@` glued to a word is not a mention. | | |
| 34 | +| Same path twice | Attached once. | | |
| 35 | +| Path with spaces | Not supported: a mention ends at the first space. | | |
| 36 | + | |
| 37 | +## In an editor (ACP) | |
| 38 | + | |
| 39 | +The editor's own `@` picker sends the file as a `resource_link`, which mini-me turns into the same `[attached file: …]` line. A path typed by hand, without the picker, is understood the same way, relative to the project the editor opened. Attached paths are logged on stderr as `[acp] session <id>: attached <path>`. | |
| 40 | + | |
| 41 | +## See also | |
| 42 | + | |
| 43 | +- [Built-in tools](../reference/tools.md) — what the model reads the file with | |
| 44 | +- [Use mini-me from an editor](use-from-an-editor.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +# Point the model at a file with `@path` | ||
| 2 | + | ||
| 3 | +Name a file or a directory in your question with `@` followed by its path. mini-me checks that the path exists, tells the model where it is, and the model reads it with its tools. There is no picker or completion: you type the path. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Start `mm` from the project directory. Relative paths are resolved from there. | ||
| 8 | +2. Write the path in the question, glued to `@`: | ||
| 9 | + | ||
| 10 | + ``` | ||
| 11 | + > explain what @internal/acp/acp.go does with @internal/session | ||
| 12 | + 📎 /work/mini-me/internal/acp/acp.go | ||
| 13 | + 📎 /work/mini-me/internal/session | ||
| 14 | + ``` | ||
| 15 | + | ||
| 16 | + One `📎` line per attached path confirms the notation was understood. The model receives your sentence as typed, followed by one line per path: | ||
| 17 | + | ||
| 18 | + ``` | ||
| 19 | + [attached file: /work/mini-me/internal/acp/acp.go] | ||
| 20 | + [attached directory: /work/mini-me/internal/session] | ||
| 21 | + ``` | ||
| 22 | + | ||
| 23 | +3. Ask away. The content is not copied into the prompt: the model runs `cat`, `read_file` or `ls` on the path when it needs to. | ||
| 24 | + | ||
| 25 | +## Rules | ||
| 26 | + | ||
| 27 | +| Case | Result | | ||
| 28 | +|------|--------| | ||
| 29 | +| `@internal/acp/acp.go` | Relative to the directory `mm` was started in (the session's `cwd` in ACP mode). | | ||
| 30 | +| `@/abs/path`, `@~/file` | Absolute paths and `~` work. | | ||
| 31 | +| `@main.go,` `(@main.go)` `"@main.go"` | Sentence punctuation and brackets around the mention are ignored. | | ||
| 32 | +| `@bob`, `@does/not/exist` | Nothing on disk: left in the sentence as plain text, no attachment. | | ||
| 33 | +| `k33g@example.com` | A `@` glued to a word is not a mention. | | ||
| 34 | +| Same path twice | Attached once. | | ||
| 35 | +| Path with spaces | Not supported: a mention ends at the first space. | | ||
| 36 | + | ||
| 37 | +## In an editor (ACP) | ||
| 38 | + | ||
| 39 | +The editor's own `@` picker sends the file as a `resource_link`, which mini-me turns into the same `[attached file: …]` line. A path typed by hand, without the picker, is understood the same way, relative to the project the editor opened. Attached paths are logged on stderr as `[acp] session <id>: attached <path>`. | ||
| 40 | + | ||
| 41 | +## See also | ||
| 42 | + | ||
| 43 | +- [Built-in tools](../reference/tools.md) — what the model reads the file with | ||
| 44 | +- [Use mini-me from an editor](use-from-an-editor.md) | ||
added
docs/en/how-to/manage-the-context-window.md +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +# How to keep a long session inside the context window | |
| 2 | + | |
| 3 | +This guide shows how to stop a long session from overflowing the model's context. It assumes you already run `mm` and know which server serves your model. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Tell the agent the size of the window. Either set `contextWindow` in the YAML to the value you started the server with, or leave it at `0` on llama.cpp, where the agent reads `n_ctx` from `/props`. The banner shows the result as `ctx: <size> (config)` or `ctx: <size> (/props)`. | |
| 8 | + | |
| 9 | +2. Turn the automatic compression on: | |
| 10 | + | |
| 11 | + ```yaml | |
| 12 | + context: | |
| 13 | + enabled: true | |
| 14 | + threshold: 75 | |
| 15 | + keepLastTurns: 3 | |
| 16 | + ``` | |
| 17 | + | |
| 18 | + Before each question, when the history reaches 75 % of the window, the old turns are replaced by one model-written summary and the last three question turns are kept as they are. | |
| 19 | + | |
| 20 | +3. Watch the report. With `showStats: true` (the default) each compression prints one dimmed line: | |
| 21 | + | |
| 22 | + ``` | |
| 23 | + 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s) | |
| 24 | + ``` | |
| 25 | + | |
| 26 | +## Variants | |
| 27 | + | |
| 28 | +- **The window stays unknown** (Docker Model Runner tells nothing, and `contextWindow` is `0`). Only the `maxMessages` trigger can fire; the agent says so in a start-up warning. Set `maxMessages` to a message count (one command costs two messages) or set `contextWindow`. | |
| 29 | +- **Compress by hand.** Type `/compact` at the prompt, or send it from the editor in ACP mode. It ignores the threshold but still keeps the last `keepLastTurns` turns, and says `🗜️ nothing to compact` when there is nothing older. | |
| 30 | +- **The server was started after the agent.** The window is re-probed before the first question while it is still unknown and compression is on; you do not need to restart. | |
| 31 | +- **Your own summary prompt.** Set `context.prompt` to replace the built-in seven-section prompt. | |
| 32 | +- **A compression that fails** (server down, watchdog) leaves the history unchanged and prints `[compact: failed, history kept: …]`. | |
| 33 | + | |
| 34 | +## See also | |
| 35 | + | |
| 36 | +- Every `context.*` key and its default: [configuration reference](../reference/configuration.md) | |
| 37 | +- Why the summary keeps recent turns raw and merges earlier summaries: [context compression](../explanation/context-compression.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +# How to keep a long session inside the context window | ||
| 2 | + | ||
| 3 | +This guide shows how to stop a long session from overflowing the model's context. It assumes you already run `mm` and know which server serves your model. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Tell the agent the size of the window. Either set `contextWindow` in the YAML to the value you started the server with, or leave it at `0` on llama.cpp, where the agent reads `n_ctx` from `/props`. The banner shows the result as `ctx: <size> (config)` or `ctx: <size> (/props)`. | ||
| 8 | + | ||
| 9 | +2. Turn the automatic compression on: | ||
| 10 | + | ||
| 11 | + ```yaml | ||
| 12 | + context: | ||
| 13 | + enabled: true | ||
| 14 | + threshold: 75 | ||
| 15 | + keepLastTurns: 3 | ||
| 16 | + ``` | ||
| 17 | + | ||
| 18 | + Before each question, when the history reaches 75 % of the window, the old turns are replaced by one model-written summary and the last three question turns are kept as they are. | ||
| 19 | + | ||
| 20 | +3. Watch the report. With `showStats: true` (the default) each compression prints one dimmed line: | ||
| 21 | + | ||
| 22 | + ``` | ||
| 23 | + 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s) | ||
| 24 | + ``` | ||
| 25 | + | ||
| 26 | +## Variants | ||
| 27 | + | ||
| 28 | +- **The window stays unknown** (Docker Model Runner tells nothing, and `contextWindow` is `0`). Only the `maxMessages` trigger can fire; the agent says so in a start-up warning. Set `maxMessages` to a message count (one command costs two messages) or set `contextWindow`. | ||
| 29 | +- **Compress by hand.** Type `/compact` at the prompt, or send it from the editor in ACP mode. It ignores the threshold but still keeps the last `keepLastTurns` turns, and says `🗜️ nothing to compact` when there is nothing older. | ||
| 30 | +- **The server was started after the agent.** The window is re-probed before the first question while it is still unknown and compression is on; you do not need to restart. | ||
| 31 | +- **Your own summary prompt.** Set `context.prompt` to replace the built-in seven-section prompt. | ||
| 32 | +- **A compression that fails** (server down, watchdog) leaves the history unchanged and prints `[compact: failed, history kept: …]`. | ||
| 33 | + | ||
| 34 | +## See also | ||
| 35 | + | ||
| 36 | +- Every `context.*` key and its default: [configuration reference](../reference/configuration.md) | ||
| 37 | +- Why the summary keeps recent turns raw and merges earlier summaries: [context compression](../explanation/context-compression.md) | ||
added
docs/en/how-to/run-with-llama-cpp.md +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +# How to run mini-me on llama.cpp instead of Docker Model Runner | |
| 2 | + | |
| 3 | +This guide shows how to point `mm` at a `llama-server` you start yourself. It assumes you already know how to build `mm` and how to obtain a GGUF model. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Start `llama-server` with a chat template and tool calling enabled. `--jinja` is mandatory: without it the server refuses the `tools` parameter and the agent cannot run a single command. Give the served model an alias with `-a`, because `llama-server` routes requests on the `model` field: | |
| 8 | + | |
| 9 | + ```bash | |
| 10 | + llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | |
| 11 | + -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | |
| 12 | + --jinja -c 32768 --port 8080 | |
| 13 | + ``` | |
| 14 | + | |
| 15 | +2. Use the shipped llama.cpp configuration file. Its `model:` key must match the `-a` alias above: | |
| 16 | + | |
| 17 | + ```bash | |
| 18 | + ./mm agent.llamacpp.yaml | |
| 19 | + ``` | |
| 20 | + | |
| 21 | +3. Check the banner. `provider: llamacpp` confirms the provider, and `ctx: 32768 (/props)` confirms the agent read the served context size from the server's `/props` endpoint. | |
| 22 | + | |
| 23 | +## Variants | |
| 24 | + | |
| 25 | +- **Different port or host.** Set `baseUrl` in the YAML to `http://<host>:<port>/v1`, or override it for one run with `AGENT_BASE_URL=http://<host>:<port>/v1 ./mm agent.llamacpp.yaml`. An environment override is taken as-is, without the fallback probe. | |
| 26 | +- **Server started with `--api-key`.** Export the key in `LLAMA_API_KEY` (the provider's default variable) or name another variable under `apiKeyEnv`. The key itself never goes in the YAML. | |
| 27 | +- **Keep `agent.yaml` and switch provider for one run.** `AGENT_PROVIDER=llamacpp ./mm` works as long as `agent.yaml` leaves `baseUrl` empty; an explicit DMR URL in the file would still be honoured and point at port 12434. | |
| 28 | +- **The server hides `/props`** (a reverse proxy, for instance). Set `contextWindow` to the value you started the server with, otherwise the banner shows `ctx: unknown`. | |
| 29 | + | |
| 30 | +## See also | |
| 31 | + | |
| 32 | +- Every key of the file: [configuration reference](../reference/configuration.md) | |
| 33 | +- Why one provider implementation serves both servers: [providers](../explanation/providers.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +# How to run mini-me on llama.cpp instead of Docker Model Runner | ||
| 2 | + | ||
| 3 | +This guide shows how to point `mm` at a `llama-server` you start yourself. It assumes you already know how to build `mm` and how to obtain a GGUF model. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Start `llama-server` with a chat template and tool calling enabled. `--jinja` is mandatory: without it the server refuses the `tools` parameter and the agent cannot run a single command. Give the served model an alias with `-a`, because `llama-server` routes requests on the `model` field: | ||
| 8 | + | ||
| 9 | + ```bash | ||
| 10 | + llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | ||
| 11 | + -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | ||
| 12 | + --jinja -c 32768 --port 8080 | ||
| 13 | + ``` | ||
| 14 | + | ||
| 15 | +2. Use the shipped llama.cpp configuration file. Its `model:` key must match the `-a` alias above: | ||
| 16 | + | ||
| 17 | + ```bash | ||
| 18 | + ./mm agent.llamacpp.yaml | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | +3. Check the banner. `provider: llamacpp` confirms the provider, and `ctx: 32768 (/props)` confirms the agent read the served context size from the server's `/props` endpoint. | ||
| 22 | + | ||
| 23 | +## Variants | ||
| 24 | + | ||
| 25 | +- **Different port or host.** Set `baseUrl` in the YAML to `http://<host>:<port>/v1`, or override it for one run with `AGENT_BASE_URL=http://<host>:<port>/v1 ./mm agent.llamacpp.yaml`. An environment override is taken as-is, without the fallback probe. | ||
| 26 | +- **Server started with `--api-key`.** Export the key in `LLAMA_API_KEY` (the provider's default variable) or name another variable under `apiKeyEnv`. The key itself never goes in the YAML. | ||
| 27 | +- **Keep `agent.yaml` and switch provider for one run.** `AGENT_PROVIDER=llamacpp ./mm` works as long as `agent.yaml` leaves `baseUrl` empty; an explicit DMR URL in the file would still be honoured and point at port 12434. | ||
| 28 | +- **The server hides `/props`** (a reverse proxy, for instance). Set `contextWindow` to the value you started the server with, otherwise the banner shows `ctx: unknown`. | ||
| 29 | + | ||
| 30 | +## See also | ||
| 31 | + | ||
| 32 | +- Every key of the file: [configuration reference](../reference/configuration.md) | ||
| 33 | +- Why one provider implementation serves both servers: [providers](../explanation/providers.md) | ||
added
docs/en/how-to/start-a-new-session.md +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +# How to start a new session without restarting `mm` | |
| 2 | + | |
| 3 | +This guide shows how to wipe the conversation and begin again from a clean history, in the terminal and from an editor. It assumes `mm` is running and has already answered at least one question. | |
| 4 | + | |
| 5 | +## In the terminal | |
| 6 | + | |
| 7 | +1. Wait for the prompt (`> `). The command only acts between two generations; typed while the model is working, it first cancels that generation, then applies. | |
| 8 | + | |
| 9 | +2. Type the command: | |
| 10 | + | |
| 11 | + ```text | |
| 12 | + > /new | |
| 13 | + 🆕 new session: 12 message(s) forgotten | |
| 14 | + ``` | |
| 15 | + | |
| 16 | + The count is every message except the system prompt: your questions, the model's answers and the tool turns in between. On an already-empty session it reads `0 message(s) forgotten`. | |
| 17 | + | |
| 18 | +3. Ask your next question. The model only sees the system prompt and that question; nothing from before `/new` is sent to it. | |
| 19 | + | |
| 20 | +## From an editor (ACP) | |
| 21 | + | |
| 22 | +1. Type `/new` in the agent panel. Editors that list the agent's commands offer it in the slash-command menu with the description *Clear the history and start a new session*; typing it by hand works as well. | |
| 23 | + | |
| 24 | +2. Read the confirmation in the thread: `🆕 New session: 12 message(s) forgotten.` The turn ends there, the model is not consulted. | |
| 25 | + | |
| 26 | +3. Keep working in the same thread. The session keeps its id, its working directory and the tools you already allowed "for this session"; only the history is gone. | |
| 27 | + | |
| 28 | +## What `/new` resets, and what it keeps | |
| 29 | + | |
| 30 | +| Reset | Kept | | |
| 31 | +|-------|------| | |
| 32 | +| The conversation history (back to the system prompt alone). | The configuration, the model, the tools. | | |
| 33 | +| The loop detector's memory of repeated commands (terminal). | The working directory. | | |
| 34 | +| The server's token count of the last context, used to decide automatic compression. | In ACP mode, the session id and the "allow always" permission grants. | | |
| 35 | + | |
| 36 | +## Related | |
| 37 | + | |
| 38 | +- [Slash commands reference](../reference/slash-commands.md) — every command, at the prompt and during a generation. | |
| 39 | +- [ACP reference](../reference/acp.md) — how the command is advertised and handled over the protocol. | |
| 40 | +- [Keep a long session inside the context window](manage-the-context-window.md) — `/compact` shortens the history without forgetting it; `/new` forgets it. | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +# How to start a new session without restarting `mm` | ||
| 2 | + | ||
| 3 | +This guide shows how to wipe the conversation and begin again from a clean history, in the terminal and from an editor. It assumes `mm` is running and has already answered at least one question. | ||
| 4 | + | ||
| 5 | +## In the terminal | ||
| 6 | + | ||
| 7 | +1. Wait for the prompt (`> `). The command only acts between two generations; typed while the model is working, it first cancels that generation, then applies. | ||
| 8 | + | ||
| 9 | +2. Type the command: | ||
| 10 | + | ||
| 11 | + ```text | ||
| 12 | + > /new | ||
| 13 | + 🆕 new session: 12 message(s) forgotten | ||
| 14 | + ``` | ||
| 15 | + | ||
| 16 | + The count is every message except the system prompt: your questions, the model's answers and the tool turns in between. On an already-empty session it reads `0 message(s) forgotten`. | ||
| 17 | + | ||
| 18 | +3. Ask your next question. The model only sees the system prompt and that question; nothing from before `/new` is sent to it. | ||
| 19 | + | ||
| 20 | +## From an editor (ACP) | ||
| 21 | + | ||
| 22 | +1. Type `/new` in the agent panel. Editors that list the agent's commands offer it in the slash-command menu with the description *Clear the history and start a new session*; typing it by hand works as well. | ||
| 23 | + | ||
| 24 | +2. Read the confirmation in the thread: `🆕 New session: 12 message(s) forgotten.` The turn ends there, the model is not consulted. | ||
| 25 | + | ||
| 26 | +3. Keep working in the same thread. The session keeps its id, its working directory and the tools you already allowed "for this session"; only the history is gone. | ||
| 27 | + | ||
| 28 | +## What `/new` resets, and what it keeps | ||
| 29 | + | ||
| 30 | +| Reset | Kept | | ||
| 31 | +|-------|------| | ||
| 32 | +| The conversation history (back to the system prompt alone). | The configuration, the model, the tools. | | ||
| 33 | +| The loop detector's memory of repeated commands (terminal). | The working directory. | | ||
| 34 | +| The server's token count of the last context, used to decide automatic compression. | In ACP mode, the session id and the "allow always" permission grants. | | ||
| 35 | + | ||
| 36 | +## Related | ||
| 37 | + | ||
| 38 | +- [Slash commands reference](../reference/slash-commands.md) — every command, at the prompt and during a generation. | ||
| 39 | +- [ACP reference](../reference/acp.md) — how the command is advertised and handled over the protocol. | ||
| 40 | +- [Keep a long session inside the context window](manage-the-context-window.md) — `/compact` shortens the history without forgetting it; `/new` forgets it. | ||
added
docs/en/how-to/use-from-an-editor.md +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +# How to use mini-me from an editor (ACP) | |
| 2 | + | |
| 3 | +This guide shows how to host `mm` inside an editor that speaks the Agent Client Protocol, such as Zed. It assumes `mm` is built and installed on your `PATH` (`./install.sh` copies it to `/usr/local/bin`), and that your model server is running. | |
| 4 | + | |
| 5 | +## Steps | |
| 6 | + | |
| 7 | +1. Register `mm` as an agent server in the editor, with the `-acp` flag. In Zed, add to `settings.json`: | |
| 8 | + | |
| 9 | + ```json | |
| 10 | + { | |
| 11 | + "agent_servers": { | |
| 12 | + "mini-me": { | |
| 13 | + "type": "custom", | |
| 14 | + "command": "mm", | |
| 15 | + "args": ["-acp"], | |
| 16 | + "env": { "AGENT_CONFIG": "/absolute/path/to/agent.yaml" } | |
| 17 | + } | |
| 18 | + } | |
| 19 | + } | |
| 20 | + ``` | |
| 21 | + | |
| 22 | +2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file, not next to the editor's working directory. | |
| 23 | + | |
| 24 | +3. Open the agent panel and start a conversation. Each `bash`, `read_file`, `write_file` and `edit_file` call appears as a tool call; the editor asks you to allow it before it runs. `read_skill` runs without a dialog. | |
| 25 | + | |
| 26 | +4. Read the logs when something looks wrong. In ACP mode nothing human-readable goes to stdout: the banner, the warnings and a `[acp] …` trail go to stderr, which Zed shows under `dev: open acp logs`. | |
| 27 | + | |
| 28 | +## Variants | |
| 29 | + | |
| 30 | +- **Allow a tool for the whole session.** Pick "Allow bash for this session" in the permission dialog; the choice is remembered per tool until the session ends. | |
| 31 | +- **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP. | |
| 32 | +- **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode). | |
| 33 | +- **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools. | |
| 34 | +- **Run from a terminal to debug the protocol.** `mm -acp` reads JSON-RPC on stdin and writes it on stdout, one message per line. | |
| 35 | + | |
| 36 | +## See also | |
| 37 | + | |
| 38 | +- The exact methods and capabilities exposed: [ACP reference](../reference/acp.md) | |
| 39 | +- Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +# How to use mini-me from an editor (ACP) | ||
| 2 | + | ||
| 3 | +This guide shows how to host `mm` inside an editor that speaks the Agent Client Protocol, such as Zed. It assumes `mm` is built and installed on your `PATH` (`./install.sh` copies it to `/usr/local/bin`), and that your model server is running. | ||
| 4 | + | ||
| 5 | +## Steps | ||
| 6 | + | ||
| 7 | +1. Register `mm` as an agent server in the editor, with the `-acp` flag. In Zed, add to `settings.json`: | ||
| 8 | + | ||
| 9 | + ```json | ||
| 10 | + { | ||
| 11 | + "agent_servers": { | ||
| 12 | + "mini-me": { | ||
| 13 | + "type": "custom", | ||
| 14 | + "command": "mm", | ||
| 15 | + "args": ["-acp"], | ||
| 16 | + "env": { "AGENT_CONFIG": "/absolute/path/to/agent.yaml" } | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + ``` | ||
| 21 | + | ||
| 22 | +2. Point `AGENT_CONFIG` at an absolute path. The editor launches `mm` with the project's directory as working directory, so a relative `./agent.yaml` would not be found. In ACP mode a relative `skillsDir` in that file is resolved next to the file, not next to the editor's working directory. | ||
| 23 | + | ||
| 24 | +3. Open the agent panel and start a conversation. Each `bash`, `read_file`, `write_file` and `edit_file` call appears as a tool call; the editor asks you to allow it before it runs. `read_skill` runs without a dialog. | ||
| 25 | + | ||
| 26 | +4. Read the logs when something looks wrong. In ACP mode nothing human-readable goes to stdout: the banner, the warnings and a `[acp] …` trail go to stderr, which Zed shows under `dev: open acp logs`. | ||
| 27 | + | ||
| 28 | +## Variants | ||
| 29 | + | ||
| 30 | +- **Allow a tool for the whole session.** Pick "Allow bash for this session" in the permission dialog; the choice is remembered per tool until the session ends. | ||
| 31 | +- **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP. | ||
| 32 | +- **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode). | ||
| 33 | +- **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools. | ||
| 34 | +- **Run from a terminal to debug the protocol.** `mm -acp` reads JSON-RPC on stdin and writes it on stdout, one message per line. | ||
| 35 | + | ||
| 36 | +## See also | ||
| 37 | + | ||
| 38 | +- The exact methods and capabilities exposed: [ACP reference](../reference/acp.md) | ||
| 39 | +- Why the terminal and the editor share one loop: [architecture](../explanation/architecture.md) | ||
added
docs/en/reference/acp.md +92 -0 | new file mode 100644 | ||
| @@ -0,0 +1,92 @@ | ||
| 1 | +# Reference: Agent Client Protocol surface | |
| 2 | + | |
| 3 | +> Neutral description of what `mm -acp` exposes over JSON-RPC 2.0 on stdio, through `github.com/coder/acp-go-sdk` v0.13.5. | |
| 4 | + | |
| 5 | +## Transport | |
| 6 | + | |
| 7 | +| Aspect | Value | | |
| 8 | +|--------|-------| | |
| 9 | +| Input | stdin, one JSON-RPC message per line. | | |
| 10 | +| Output | stdout, JSON-RPC messages only. | | |
| 11 | +| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. | | |
| 12 | +| Lifetime | Until the client closes stdin or the process context is cancelled. | | |
| 13 | + | |
| 14 | +## `initialize` response | |
| 15 | + | |
| 16 | +| Field | Value | | |
| 17 | +|-------|-------| | |
| 18 | +| `protocolVersion` | `1` | | |
| 19 | +| `agentCapabilities` | All absent (false): no `loadSession`, no image or audio prompts, no HTTP MCP. | | |
| 20 | +| `agentInfo.name` | `bob` | | |
| 21 | +| `agentInfo.title` | `Bob (bash-first agent)` | | |
| 22 | +| `agentInfo.version` | `0.11.0` | | |
| 23 | +| `authMethods` | `[]` | | |
| 24 | + | |
| 25 | +## Methods | |
| 26 | + | |
| 27 | +| Method | Behaviour | | |
| 28 | +|--------|-----------| | |
| 29 | +| `initialize` | Logs the client name and protocol version on stderr; returns the values above. | | |
| 30 | +| `authenticate` | Returns an empty response. | | |
| 31 | +| `session/new` | Creates a session `bob-<pid>-<n>` with the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). | | |
| 32 | +| `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | | |
| 33 | +| `session/cancel` | Cancels the turn in flight for that session. | | |
| 34 | +| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | | |
| 35 | + | |
| 36 | +## `session/prompt` | |
| 37 | + | |
| 38 | +| Aspect | Behaviour | | |
| 39 | +|--------|-----------| | |
| 40 | +| Unknown `sessionId` | `invalid params` error carrying the `sessionId`. | | |
| 41 | +| Prompt content | `text` blocks concatenated; a `resource_link` becomes `\n[attached file: <path without file://>]`; other block types are ignored. Then every `@path` in the text that names an existing file or directory under the session's `cwd` (or an absolute / `~` path) appends `\n[attached file: <absolute path>]` or `\n[attached directory: …]`, once per path, and is logged as `[acp] session <id>: attached <path>`. | | |
| 42 | +| Slash command | When the flattened text, trimmed, is exactly `/new` or `/compact`, no generation runs; the command answers with one `agent_message_chunk` and `stopReason: end_turn`. | | |
| 43 | +| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. Logged as `[acp] session <id>: new session, N message(s) forgotten`. | | |
| 44 | +| `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. | | |
| 45 | +| History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. | | |
| 46 | +| Success | `stopReason: end_turn`. | | |
| 47 | +| Cancelled | `stopReason: cancelled`. | | |
| 48 | +| Failure | `internal error` with `error: <one-line explanation>`. | | |
| 49 | +| Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. | | |
| 50 | + | |
| 51 | +## `session/update` notifications sent | |
| 52 | + | |
| 53 | +| Update | When | | |
| 54 | +|--------|------| | |
| 55 | +| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. | | |
| 56 | +| `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. | | |
| 57 | +| `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | | |
| 58 | +| `tool_call_update` status `in_progress` | Permission granted, tool executing. | | |
| 59 | +| `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. | | |
| 60 | + | |
| 61 | +## Available commands | |
| 62 | + | |
| 63 | +| `name` | `description` | `input` | | |
| 64 | +|--------|---------------|---------| | |
| 65 | +| `new` | `Clear the history and start a new session` | none | | |
| 66 | +| `compact` | `Compress the history now, keeping the last turns` | none | | |
| 67 | + | |
| 68 | +Clients display the name with a leading slash and send it back as the text of a `session/prompt`. | |
| 69 | + | |
| 70 | +## `session/request_permission` requests sent | |
| 71 | + | |
| 72 | +Sent before `bash`, `read_file`, `write_file` and `edit_file` run; never for `read_skill`. | |
| 73 | + | |
| 74 | +| Option id | Kind | Effect | | |
| 75 | +|-----------|------|--------| | |
| 76 | +| `allow` | `allow_once` | Runs the tool. | | |
| 77 | +| `allow_always` | `allow_always` | Runs the tool and skips the dialog for that tool name for the rest of the session. | | |
| 78 | +| `reject` | `reject_once` | Does not run; the model receives `[command rejected by the user]`. | | |
| 79 | + | |
| 80 | +A transport error or a `cancelled` outcome counts as a rejection. | |
| 81 | + | |
| 82 | +## Tool kinds | |
| 83 | + | |
| 84 | +| `mm` kind | ACP `ToolKind` | Tools | | |
| 85 | +|-----------|----------------|-------| | |
| 86 | +| `execute` | `execute` | `bash` (and any unknown kind) | | |
| 87 | +| `read` | `read` | `read_skill`, `read_file` | | |
| 88 | +| `edit` | `edit` | `write_file`, `edit_file` | | |
| 89 | + | |
| 90 | +## Tool call identifiers | |
| 91 | + | |
| 92 | +`call_1`, `call_2`, … numbered per process and never reset. | |
| new file mode 100644 | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | +# Reference: Agent Client Protocol surface | ||
| 2 | + | ||
| 3 | +> Neutral description of what `mm -acp` exposes over JSON-RPC 2.0 on stdio, through `github.com/coder/acp-go-sdk` v0.13.5. | ||
| 4 | + | ||
| 5 | +## Transport | ||
| 6 | + | ||
| 7 | +| Aspect | Value | | ||
| 8 | +|--------|-------| | ||
| 9 | +| Input | stdin, one JSON-RPC message per line. | | ||
| 10 | +| Output | stdout, JSON-RPC messages only. | | ||
| 11 | +| Logs | stderr: banner, warnings, `[acp] …` lines, SDK logger. | | ||
| 12 | +| Lifetime | Until the client closes stdin or the process context is cancelled. | | ||
| 13 | + | ||
| 14 | +## `initialize` response | ||
| 15 | + | ||
| 16 | +| Field | Value | | ||
| 17 | +|-------|-------| | ||
| 18 | +| `protocolVersion` | `1` | | ||
| 19 | +| `agentCapabilities` | All absent (false): no `loadSession`, no image or audio prompts, no HTTP MCP. | | ||
| 20 | +| `agentInfo.name` | `bob` | | ||
| 21 | +| `agentInfo.title` | `Bob (bash-first agent)` | | ||
| 22 | +| `agentInfo.version` | `0.11.0` | | ||
| 23 | +| `authMethods` | `[]` | | ||
| 24 | + | ||
| 25 | +## Methods | ||
| 26 | + | ||
| 27 | +| Method | Behaviour | | ||
| 28 | +|--------|-----------| | ||
| 29 | +| `initialize` | Logs the client name and protocol version on stderr; returns the values above. | | ||
| 30 | +| `authenticate` | Returns an empty response. | | ||
| 31 | +| `session/new` | Creates a session `bob-<pid>-<n>` with the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). | | ||
| 32 | +| `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. | | ||
| 33 | +| `session/cancel` | Cancels the turn in flight for that session. | | ||
| 34 | +| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | | ||
| 35 | + | ||
| 36 | +## `session/prompt` | ||
| 37 | + | ||
| 38 | +| Aspect | Behaviour | | ||
| 39 | +|--------|-----------| | ||
| 40 | +| Unknown `sessionId` | `invalid params` error carrying the `sessionId`. | | ||
| 41 | +| Prompt content | `text` blocks concatenated; a `resource_link` becomes `\n[attached file: <path without file://>]`; other block types are ignored. Then every `@path` in the text that names an existing file or directory under the session's `cwd` (or an absolute / `~` path) appends `\n[attached file: <absolute path>]` or `\n[attached directory: …]`, once per path, and is logged as `[acp] session <id>: attached <path>`. | | ||
| 42 | +| Slash command | When the flattened text, trimmed, is exactly `/new` or `/compact`, no generation runs; the command answers with one `agent_message_chunk` and `stopReason: end_turn`. | | ||
| 43 | +| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. Logged as `[acp] session <id>: new session, N message(s) forgotten`. | | ||
| 44 | +| `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. | | ||
| 45 | +| History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. | | ||
| 46 | +| Success | `stopReason: end_turn`. | | ||
| 47 | +| Cancelled | `stopReason: cancelled`. | | ||
| 48 | +| Failure | `internal error` with `error: <one-line explanation>`. | | ||
| 49 | +| Log line | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` on stderr. | | ||
| 50 | + | ||
| 51 | +## `session/update` notifications sent | ||
| 52 | + | ||
| 53 | +| Update | When | | ||
| 54 | +|--------|------| | ||
| 55 | +| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. | | ||
| 56 | +| `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. | | ||
| 57 | +| `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. | | ||
| 58 | +| `tool_call_update` status `in_progress` | Permission granted, tool executing. | | ||
| 59 | +| `tool_call_update` status `completed` or `failed` | Tool finished; `content` is a `diff` for file changes, else a `text` block with the output; `rawOutput.output` always carries the output. | | ||
| 60 | + | ||
| 61 | +## Available commands | ||
| 62 | + | ||
| 63 | +| `name` | `description` | `input` | | ||
| 64 | +|--------|---------------|---------| | ||
| 65 | +| `new` | `Clear the history and start a new session` | none | | ||
| 66 | +| `compact` | `Compress the history now, keeping the last turns` | none | | ||
| 67 | + | ||
| 68 | +Clients display the name with a leading slash and send it back as the text of a `session/prompt`. | ||
| 69 | + | ||
| 70 | +## `session/request_permission` requests sent | ||
| 71 | + | ||
| 72 | +Sent before `bash`, `read_file`, `write_file` and `edit_file` run; never for `read_skill`. | ||
| 73 | + | ||
| 74 | +| Option id | Kind | Effect | | ||
| 75 | +|-----------|------|--------| | ||
| 76 | +| `allow` | `allow_once` | Runs the tool. | | ||
| 77 | +| `allow_always` | `allow_always` | Runs the tool and skips the dialog for that tool name for the rest of the session. | | ||
| 78 | +| `reject` | `reject_once` | Does not run; the model receives `[command rejected by the user]`. | | ||
| 79 | + | ||
| 80 | +A transport error or a `cancelled` outcome counts as a rejection. | ||
| 81 | + | ||
| 82 | +## Tool kinds | ||
| 83 | + | ||
| 84 | +| `mm` kind | ACP `ToolKind` | Tools | | ||
| 85 | +|-----------|----------------|-------| | ||
| 86 | +| `execute` | `execute` | `bash` (and any unknown kind) | | ||
| 87 | +| `read` | `read` | `read_skill`, `read_file` | | ||
| 88 | +| `edit` | `edit` | `write_file`, `edit_file` | | ||
| 89 | + | ||
| 90 | +## Tool call identifiers | ||
| 91 | + | ||
| 92 | +`call_1`, `call_2`, … numbered per process and never reset. | ||
added
docs/en/reference/cli.md +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +# Reference: command line | |
| 2 | + | |
| 3 | +> Neutral description of the `mm` executable: flags, positional argument, environment variables, start-up output and exit codes. | |
| 4 | + | |
| 5 | +## Invocation | |
| 6 | + | |
| 7 | +``` | |
| 8 | +mm [-config <path>] [-acp] [<path>] | |
| 9 | +``` | |
| 10 | + | |
| 11 | +| Element | Type | Default | Description | | |
| 12 | +|---------|------|---------|-------------| | |
| 13 | +| `-config <path>` | string | `""` | Path of the YAML configuration file. | | |
| 14 | +| `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. | | |
| 15 | +| `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. | | |
| 16 | +| `-h` | — | — | Print the flag usage and exit. | | |
| 17 | + | |
| 18 | +## Configuration file resolution | |
| 19 | + | |
| 20 | +The first existing source wins. | |
| 21 | + | |
| 22 | +| Order | Source | Missing file | | |
| 23 | +|-------|--------|--------------| | |
| 24 | +| 1 | `-config` flag or positional argument | Error, exit code 1 | | |
| 25 | +| 2 | `AGENT_CONFIG` environment variable | Error, exit code 1 | | |
| 26 | +| 3 | `./agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` | | |
| 27 | + | |
| 28 | +## Environment variables | |
| 29 | + | |
| 30 | +| Variable | Effect | | |
| 31 | +|----------|--------| | |
| 32 | +| `AGENT_CONFIG` | Path of the configuration file (see above). | | |
| 33 | +| `AGENT_PROVIDER` | Overrides the `provider` key. | | |
| 34 | +| `AGENT_MODEL` | Overrides the `model` key. | | |
| 35 | +| `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. | | |
| 36 | +| `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. | | |
| 37 | +| Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`. | | |
| 38 | + | |
| 39 | +## Start-up output | |
| 40 | + | |
| 41 | +Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is a terminal. | |
| 42 | + | |
| 43 | +| Line | Content | | |
| 44 | +|------|---------| | |
| 45 | +| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger. | | |
| 46 | +| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. | | |
| 47 | +| `Agent ready. Commands: …` | Terminal mode only. | | |
| 48 | +| `Shortcut: Ctrl+C …` | Terminal mode only. | | |
| 49 | + | |
| 50 | +### Example | |
| 51 | + | |
| 52 | +``` | |
| 53 | +[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080] | |
| 54 | +config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | |
| 55 | +``` | |
| 56 | + | |
| 57 | +## ACP mode specifics | |
| 58 | + | |
| 59 | +| Aspect | Behaviour | | |
| 60 | +|--------|-----------| | |
| 61 | +| stdout | JSON-RPC 2.0 messages only, one per line. | | |
| 62 | +| stderr | Banner, warnings, `[acp] …` trail, SDK logs. | | |
| 63 | +| Spinner | Disabled. | | |
| 64 | +| Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. | | |
| 65 | + | |
| 66 | +## Exit codes | |
| 67 | + | |
| 68 | +| Code | Cause | | |
| 69 | +|------|-------| | |
| 70 | +| 0 | `/quit`, `Ctrl+C` at the prompt, end of stdin, or the ACP client closed the connection. | | |
| 71 | +| 1 | More than one positional argument; configuration load error; engine initialisation error (unknown provider, required key not set). | | |
| 72 | + | |
| 73 | +## Build and install | |
| 74 | + | |
| 75 | +| Command | Effect | | |
| 76 | +|---------|--------| | |
| 77 | +| `go build -o mm .` | Builds the binary in the current directory. | | |
| 78 | +| `./install.sh` | Builds `mm` and copies it to `/usr/local/bin`, with `sudo` when the directory is not writable. | | |
| 79 | +| `go test ./...` | Runs the unit tests. | | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +# Reference: command line | ||
| 2 | + | ||
| 3 | +> Neutral description of the `mm` executable: flags, positional argument, environment variables, start-up output and exit codes. | ||
| 4 | + | ||
| 5 | +## Invocation | ||
| 6 | + | ||
| 7 | +``` | ||
| 8 | +mm [-config <path>] [-acp] [<path>] | ||
| 9 | +``` | ||
| 10 | + | ||
| 11 | +| Element | Type | Default | Description | | ||
| 12 | +|---------|------|---------|-------------| | ||
| 13 | +| `-config <path>` | string | `""` | Path of the YAML configuration file. | | ||
| 14 | +| `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. | | ||
| 15 | +| `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. | | ||
| 16 | +| `-h` | — | — | Print the flag usage and exit. | | ||
| 17 | + | ||
| 18 | +## Configuration file resolution | ||
| 19 | + | ||
| 20 | +The first existing source wins. | ||
| 21 | + | ||
| 22 | +| Order | Source | Missing file | | ||
| 23 | +|-------|--------|--------------| | ||
| 24 | +| 1 | `-config` flag or positional argument | Error, exit code 1 | | ||
| 25 | +| 2 | `AGENT_CONFIG` environment variable | Error, exit code 1 | | ||
| 26 | +| 3 | `./agent.yaml` | Not an error: built-in defaults are used, the banner shows `config: built-in defaults` | | ||
| 27 | + | ||
| 28 | +## Environment variables | ||
| 29 | + | ||
| 30 | +| Variable | Effect | | ||
| 31 | +|----------|--------| | ||
| 32 | +| `AGENT_CONFIG` | Path of the configuration file (see above). | | ||
| 33 | +| `AGENT_PROVIDER` | Overrides the `provider` key. | | ||
| 34 | +| `AGENT_MODEL` | Overrides the `model` key. | | ||
| 35 | +| `AGENT_BASE_URL` | Overrides `baseUrl` and disables the fallback probe. | | ||
| 36 | +| `DMR_BASE_URL` | Same as `AGENT_BASE_URL`, for the `dmr` provider only; `AGENT_BASE_URL` wins when both are set. | | ||
| 37 | +| Variable named by `apiKeyEnv` | Holds the API key. Default name: none for `dmr`, `LLAMA_API_KEY` for `llamacpp`. | | ||
| 38 | + | ||
| 39 | +## Start-up output | ||
| 40 | + | ||
| 41 | +Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is a terminal. | ||
| 42 | + | ||
| 43 | +| Line | Content | | ||
| 44 | +|------|---------| | ||
| 45 | +| `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger. | | ||
| 46 | +| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. | | ||
| 47 | +| `Agent ready. Commands: …` | Terminal mode only. | | ||
| 48 | +| `Shortcut: Ctrl+C …` | Terminal mode only. | | ||
| 49 | + | ||
| 50 | +### Example | ||
| 51 | + | ||
| 52 | +``` | ||
| 53 | +[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080] | ||
| 54 | +config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +## ACP mode specifics | ||
| 58 | + | ||
| 59 | +| Aspect | Behaviour | | ||
| 60 | +|--------|-----------| | ||
| 61 | +| stdout | JSON-RPC 2.0 messages only, one per line. | | ||
| 62 | +| stderr | Banner, warnings, `[acp] …` trail, SDK logs. | | ||
| 63 | +| Spinner | Disabled. | | ||
| 64 | +| Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. | | ||
| 65 | + | ||
| 66 | +## Exit codes | ||
| 67 | + | ||
| 68 | +| Code | Cause | | ||
| 69 | +|------|-------| | ||
| 70 | +| 0 | `/quit`, `Ctrl+C` at the prompt, end of stdin, or the ACP client closed the connection. | | ||
| 71 | +| 1 | More than one positional argument; configuration load error; engine initialisation error (unknown provider, required key not set). | | ||
| 72 | + | ||
| 73 | +## Build and install | ||
| 74 | + | ||
| 75 | +| Command | Effect | | ||
| 76 | +|---------|--------| | ||
| 77 | +| `go build -o mm .` | Builds the binary in the current directory. | | ||
| 78 | +| `./install.sh` | Builds `mm` and copies it to `/usr/local/bin`, with `sudo` when the directory is not writable. | | ||
| 79 | +| `go test ./...` | Runs the unit tests. | | ||
added
docs/en/reference/configuration.md +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +# Reference: configuration file | |
| 2 | + | |
| 3 | +> Neutral, exhaustive description of the YAML file read by `mm`. Every key is optional; a key left out keeps its built-in default. The file is decoded on top of the defaults, so unknown keys are ignored. | |
| 4 | + | |
| 5 | +## Top-level keys | |
| 6 | + | |
| 7 | +| Key | Type | Default | Description | | |
| 8 | +|-----|------|---------|-------------| | |
| 9 | +| `provider` | string | `dmr` | Registry key of the LLM server: `dmr` or `llamacpp`. Overridden by `AGENT_PROVIDER`. | | |
| 10 | +| `model` | string | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Model identifier in the provider's naming. Overridden by `AGENT_MODEL`. | | |
| 11 | +| `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. | | |
| 12 | +| `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. | | |
| 13 | +| `apiKeyEnv` | string | `""` (provider default) | Name of the environment variable holding the API key. | | |
| 14 | +| `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. | | |
| 15 | +| `maxOutput` | int | `16000` | Maximum characters a tool returns to the model; longer output keeps its head and up to 2000 characters of tail. Must be > 0. | | |
| 16 | +| `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. | | |
| 17 | +| `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. | | |
| 18 | +| `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. | | |
| 19 | +| `system` | string | built-in prompt | The system prompt, first message of every history. | | |
| 20 | +| `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. | | |
| 21 | +| `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. | | |
| 22 | +| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. | | |
| 23 | +| `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | | |
| 24 | +| `context` | map | see below | Automatic history compression. | | |
| 25 | + | |
| 26 | +## `context` keys | |
| 27 | + | |
| 28 | +| Key | Type | Default | Description | | |
| 29 | +|-----|------|---------|-------------| | |
| 30 | +| `enabled` | bool | `false` | Turn the automatic compression on. `/compact` works regardless. | | |
| 31 | +| `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. | | |
| 32 | +| `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. | | |
| 33 | +| `keepLastTurns` | int | `3` | Question turns kept raw at the end of the history. Must be ≥ 1. | | |
| 34 | +| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. | | |
| 35 | +| `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. | | |
| 36 | +| `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. | | |
| 37 | + | |
| 38 | +## Provider defaults | |
| 39 | + | |
| 40 | +| Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required | | |
| 41 | +|----------|-----------|------------|-------------|--------------| | |
| 42 | +| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | none | no | | |
| 43 | +| `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no | | |
| 44 | + | |
| 45 | +## Validation errors | |
| 46 | + | |
| 47 | +Reported as `[config error: <path>: <message>]`, exit code 1. | |
| 48 | + | |
| 49 | +| Message | Cause | | |
| 50 | +|---------|-------| | |
| 51 | +| `provider must not be empty` | `provider: ""` | | |
| 52 | +| `model must not be empty` | `model: ""` | | |
| 53 | +| `contextWindow must be >= 0` | negative value | | |
| 54 | +| `maxOutput must be > 0` | zero or negative | | |
| 55 | +| `maxTurns must be > 0` | zero or negative | | |
| 56 | +| `previewLines must be >= 0` | negative value | | |
| 57 | +| `context.threshold must be between 1 and 100` | out of range | | |
| 58 | +| `context.keepLastTurns must be >= 1` | zero or negative | | |
| 59 | +| `context.summaryMaxTokens must be > 0` | zero or negative | | |
| 60 | +| `context.maxMessages must be >= 0` | negative value | | |
| 61 | +| `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. | | |
| 62 | + | |
| 63 | +A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`. | |
| 64 | + | |
| 65 | +## Shipped files | |
| 66 | + | |
| 67 | +| File | Purpose | | |
| 68 | +|------|---------| | |
| 69 | +| `agent.yaml` | Docker Model Runner set-up: `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression off, a system prompt named "Bob" that describes the skills and file-editing rules. | | |
| 70 | +| `agent.llamacpp.yaml` | llama.cpp set-up: `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression on. | | |
| 71 | + | |
| 72 | +### Example | |
| 73 | + | |
| 74 | +```yaml | |
| 75 | +provider: llamacpp | |
| 76 | +model: my-alias | |
| 77 | +baseUrl: http://127.0.0.1:8080/v1 | |
| 78 | +fallback: "" | |
| 79 | +maxTurns: 40 | |
| 80 | +context: | |
| 81 | + enabled: true | |
| 82 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +# Reference: configuration file | ||
| 2 | + | ||
| 3 | +> Neutral, exhaustive description of the YAML file read by `mm`. Every key is optional; a key left out keeps its built-in default. The file is decoded on top of the defaults, so unknown keys are ignored. | ||
| 4 | + | ||
| 5 | +## Top-level keys | ||
| 6 | + | ||
| 7 | +| Key | Type | Default | Description | | ||
| 8 | +|-----|------|---------|-------------| | ||
| 9 | +| `provider` | string | `dmr` | Registry key of the LLM server: `dmr` or `llamacpp`. Overridden by `AGENT_PROVIDER`. | | ||
| 10 | +| `model` | string | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Model identifier in the provider's naming. Overridden by `AGENT_MODEL`. | | ||
| 11 | +| `baseUrl` | string | `""` (provider default) | OpenAI-compatible endpoint tried first. Overridden by `AGENT_BASE_URL`. | | ||
| 12 | +| `fallback` | string or absent | absent (provider default) | Endpoint used when `baseUrl` does not answer `/models` within 2 s. Absent = provider default; `""` = no fallback. | | ||
| 13 | +| `apiKeyEnv` | string | `""` (provider default) | Name of the environment variable holding the API key. | | ||
| 14 | +| `contextWindow` | int | `0` | Tokens the server serves. `0` = ask the server, else unknown. Must be ≥ 0. | | ||
| 15 | +| `maxOutput` | int | `16000` | Maximum characters a tool returns to the model; longer output keeps its head and up to 2000 characters of tail. Must be > 0. | | ||
| 16 | +| `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. | | ||
| 17 | +| `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. | | ||
| 18 | +| `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. | | ||
| 19 | +| `system` | string | built-in prompt | The system prompt, first message of every history. | | ||
| 20 | +| `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. | | ||
| 21 | +| `editTools` | bool | `true` | Declare `read_file`, `write_file`, `edit_file`. `false` leaves `bash` and `read_skill` only. | | ||
| 22 | +| `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. | | ||
| 23 | +| `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | | ||
| 24 | +| `context` | map | see below | Automatic history compression. | | ||
| 25 | + | ||
| 26 | +## `context` keys | ||
| 27 | + | ||
| 28 | +| Key | Type | Default | Description | | ||
| 29 | +|-----|------|---------|-------------| | ||
| 30 | +| `enabled` | bool | `false` | Turn the automatic compression on. `/compact` works regardless. | | ||
| 31 | +| `threshold` | int | `75` | Percentage of the context window beyond which the history is compressed before the next question. Between 1 and 100. | | ||
| 32 | +| `maxMessages` | int | `80` | Message count that triggers a compression when the window is unknown or the estimate is low. `0` disables. Must be ≥ 0. | | ||
| 33 | +| `keepLastTurns` | int | `3` | Question turns kept raw at the end of the history. Must be ≥ 1. | | ||
| 34 | +| `summaryMaxTokens` | int | `1200` | `max_tokens` of the summary request. Must be > 0. | | ||
| 35 | +| `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. | | ||
| 36 | +| `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. | | ||
| 37 | + | ||
| 38 | +## Provider defaults | ||
| 39 | + | ||
| 40 | +| Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required | | ||
| 41 | +|----------|-----------|------------|-------------|--------------| | ||
| 42 | +| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | none | no | | ||
| 43 | +| `llamacpp` | `http://127.0.0.1:8080/v1` | none | `LLAMA_API_KEY` | no | | ||
| 44 | + | ||
| 45 | +## Validation errors | ||
| 46 | + | ||
| 47 | +Reported as `[config error: <path>: <message>]`, exit code 1. | ||
| 48 | + | ||
| 49 | +| Message | Cause | | ||
| 50 | +|---------|-------| | ||
| 51 | +| `provider must not be empty` | `provider: ""` | | ||
| 52 | +| `model must not be empty` | `model: ""` | | ||
| 53 | +| `contextWindow must be >= 0` | negative value | | ||
| 54 | +| `maxOutput must be > 0` | zero or negative | | ||
| 55 | +| `maxTurns must be > 0` | zero or negative | | ||
| 56 | +| `previewLines must be >= 0` | negative value | | ||
| 57 | +| `context.threshold must be between 1 and 100` | out of range | | ||
| 58 | +| `context.keepLastTurns must be >= 1` | zero or negative | | ||
| 59 | +| `context.summaryMaxTokens must be > 0` | zero or negative | | ||
| 60 | +| `context.maxMessages must be >= 0` | negative value | | ||
| 61 | +| `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. | | ||
| 62 | + | ||
| 63 | +A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`. | ||
| 64 | + | ||
| 65 | +## Shipped files | ||
| 66 | + | ||
| 67 | +| File | Purpose | | ||
| 68 | +|------|---------| | ||
| 69 | +| `agent.yaml` | Docker Model Runner set-up: `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression off, a system prompt named "Bob" that describes the skills and file-editing rules. | | ||
| 70 | +| `agent.llamacpp.yaml` | llama.cpp set-up: `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression on. | | ||
| 71 | + | ||
| 72 | +### Example | ||
| 73 | + | ||
| 74 | +```yaml | ||
| 75 | +provider: llamacpp | ||
| 76 | +model: my-alias | ||
| 77 | +baseUrl: http://127.0.0.1:8080/v1 | ||
| 78 | +fallback: "" | ||
| 79 | +maxTurns: 40 | ||
| 80 | +context: | ||
| 81 | + enabled: true | ||
| 82 | +``` | ||
added
docs/en/reference/skill-format.md +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +# Reference: skill file format | |
| 2 | + | |
| 3 | +> Neutral description of the markdown files the `read_skill` tool serves. | |
| 4 | + | |
| 5 | +## Location | |
| 6 | + | |
| 7 | +| Rule | Value | | |
| 8 | +|------|-------| | |
| 9 | +| Directory | `skillsDir` (default `skills`). | | |
| 10 | +| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. | | |
| 11 | +| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. | | |
| 12 | +| Both forms for one name | `read_skill` returns the flat file. | | |
| 13 | +| Missing directory | Not an error: zero skills, `read_skill` not declared. | | |
| 14 | + | |
| 15 | +## Front matter | |
| 16 | + | |
| 17 | +Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only two keys are read; the others are ignored. | |
| 18 | + | |
| 19 | +| Key | Type | Default | Description | | |
| 20 | +|-----|------|---------|-------------| | |
| 21 | +| `name` | string | file name without `.md` | Name shown in the catalogue and accepted by `read_skill`. An empty value keeps the default. | | |
| 22 | +| `description` | string | `""` | One-line summary shown in the catalogue. | | |
| 23 | + | |
| 24 | +A `description:` line in the body, after the closing `---`, is not metadata. | |
| 25 | + | |
| 26 | +## Catalogue rendering | |
| 27 | + | |
| 28 | +The tool description is: | |
| 29 | + | |
| 30 | +``` | |
| 31 | +Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills: | |
| 32 | + <name> — <description> | |
| 33 | + <name> | |
| 34 | +``` | |
| 35 | + | |
| 36 | +Entries are sorted by name; the ` — <description>` part is omitted when the description is empty. | |
| 37 | + | |
| 38 | +### Example | |
| 39 | + | |
| 40 | +```markdown | |
| 41 | +--- | |
| 42 | +name: what-time-is-it | |
| 43 | +description: Give the current date and time. | |
| 44 | +keywords: time, date, clock | |
| 45 | +--- | |
| 46 | + | |
| 47 | +# What time is it? | |
| 48 | + | |
| 49 | +Run: | |
| 50 | + | |
| 51 | +```bash | |
| 52 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | |
| 53 | +``` | |
| 54 | +``` | |
| 55 | + | |
| 56 | +The `keywords` key is accepted in the file and ignored by the loader. | |
| 57 | + | |
| 58 | +## Shipped examples | |
| 59 | + | |
| 60 | +| Path | `name` | `description` | | |
| 61 | +|------|--------|---------------| | |
| 62 | +| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. | | |
| 63 | +| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. | | |
| 64 | +| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. | | |
| 65 | + | |
| 66 | +They use the directory-per-skill layout; started from the repository root, `mm` reports `skills: 3`. | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +# Reference: skill file format | ||
| 2 | + | ||
| 3 | +> Neutral description of the markdown files the `read_skill` tool serves. | ||
| 4 | + | ||
| 5 | +## Location | ||
| 6 | + | ||
| 7 | +| Rule | Value | | ||
| 8 | +|------|-------| | ||
| 9 | +| Directory | `skillsDir` (default `skills`). | | ||
| 10 | +| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. | | ||
| 11 | +| Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. | | ||
| 12 | +| Both forms for one name | `read_skill` returns the flat file. | | ||
| 13 | +| Missing directory | Not an error: zero skills, `read_skill` not declared. | | ||
| 14 | + | ||
| 15 | +## Front matter | ||
| 16 | + | ||
| 17 | +Optional. When present, the file starts with a line `---` and the header ends at the next `---` line. Only two keys are read; the others are ignored. | ||
| 18 | + | ||
| 19 | +| Key | Type | Default | Description | | ||
| 20 | +|-----|------|---------|-------------| | ||
| 21 | +| `name` | string | file name without `.md` | Name shown in the catalogue and accepted by `read_skill`. An empty value keeps the default. | | ||
| 22 | +| `description` | string | `""` | One-line summary shown in the catalogue. | | ||
| 23 | + | ||
| 24 | +A `description:` line in the body, after the closing `---`, is not metadata. | ||
| 25 | + | ||
| 26 | +## Catalogue rendering | ||
| 27 | + | ||
| 28 | +The tool description is: | ||
| 29 | + | ||
| 30 | +``` | ||
| 31 | +Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills: | ||
| 32 | + <name> — <description> | ||
| 33 | + <name> | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +Entries are sorted by name; the ` — <description>` part is omitted when the description is empty. | ||
| 37 | + | ||
| 38 | +### Example | ||
| 39 | + | ||
| 40 | +```markdown | ||
| 41 | +--- | ||
| 42 | +name: what-time-is-it | ||
| 43 | +description: Give the current date and time. | ||
| 44 | +keywords: time, date, clock | ||
| 45 | +--- | ||
| 46 | + | ||
| 47 | +# What time is it? | ||
| 48 | + | ||
| 49 | +Run: | ||
| 50 | + | ||
| 51 | +```bash | ||
| 52 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | ||
| 53 | +``` | ||
| 54 | +``` | ||
| 55 | + | ||
| 56 | +The `keywords` key is accepted in the file and ignored by the loader. | ||
| 57 | + | ||
| 58 | +## Shipped examples | ||
| 59 | + | ||
| 60 | +| Path | `name` | `description` | | ||
| 61 | +|------|--------|---------------| | ||
| 62 | +| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. | | ||
| 63 | +| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. | | ||
| 64 | +| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. | | ||
| 65 | + | ||
| 66 | +They use the directory-per-skill layout; started from the repository root, `mm` reports `skills: 3`. | ||
added
docs/en/reference/slash-commands.md +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +# Reference: slash commands and keyboard shortcuts | |
| 2 | + | |
| 3 | +> Neutral description of the commands recognised at the terminal prompt of `mm`. They are matched on the whole trimmed line; anything else is sent to the model. | |
| 4 | + | |
| 5 | +## Commands | |
| 6 | + | |
| 7 | +| Command | At the prompt | During a generation | | |
| 8 | +|---------|---------------|---------------------| | |
| 9 | +| `/quit` | Exits the program. | Cancels the generation, prints `Goodbye.`, exits. | | |
| 10 | +| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. | | |
| 11 | +| `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). | | |
| 12 | +| `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. | | |
| 13 | + | |
| 14 | +An empty line at the prompt is ignored. | |
| 15 | + | |
| 16 | +## `@path` in a question | |
| 17 | + | |
| 18 | +| Input | Effect | | |
| 19 | +|-------|--------| | |
| 20 | +| `@<path>` at the start or after a space, bracket or quote | If the path exists (relative to the start directory, absolute, or `~/…`), a dim `📎 <absolute path>` line is printed and `[attached file: <absolute path>]` (or `[attached directory: …]`) is appended to the message the model receives. Trailing punctuation is ignored; each path is attached once. | | |
| 21 | +| A `@<path>` that names nothing on disk, or `@` glued to a word | Left as typed. | | |
| 22 | + | |
| 23 | +Details and examples: [Point the model at a file with `@path`](../how-to/attach-a-file.md). | |
| 24 | + | |
| 25 | +## Any other text during a generation | |
| 26 | + | |
| 27 | +| Input | Behaviour | | |
| 28 | +|-------|-----------| | |
| 29 | +| A non-command line | Prints `🔄 New command received. Cancelling current task...`, cancels the current generation, keeps its partial history, then submits the new line as the next question. | | |
| 30 | + | |
| 31 | +## Keyboard shortcuts | |
| 32 | + | |
| 33 | +| Key | At the prompt | During a generation | | |
| 34 | +|-----|---------------|---------------------| | |
| 35 | +| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. | | |
| 36 | +| `Ctrl+D` (end of input) | Exits. | — | | |
| 37 | + | |
| 38 | +## Messages printed at the end of a turn | |
| 39 | + | |
| 40 | +| Message | Meaning | | |
| 41 | +|---------|---------| | |
| 42 | +| `🛑 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. | | |
| 43 | +| `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. | | |
| 44 | +| `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. | | |
| 45 | +| `· 📝 N file op(s)` | Appended when file tools ran. | | |
| 46 | +| `· 📖 N skill(s)` | Appended when skills were loaded. | | |
| 47 | +| Numbered green list | The commands and file operations of the turn, one per line, when `displayCommands: true`. | | |
| 48 | + | |
| 49 | +## ACP mode | |
| 50 | + | |
| 51 | +Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command. Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted. | |
| 52 | + | |
| 53 | +| Command | Over ACP | | |
| 54 | +|---------|----------| | |
| 55 | +| `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | | |
| 56 | +| `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | | |
| 57 | + | |
| 58 | +`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. See the [ACP reference](acp.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +# Reference: slash commands and keyboard shortcuts | ||
| 2 | + | ||
| 3 | +> Neutral description of the commands recognised at the terminal prompt of `mm`. They are matched on the whole trimmed line; anything else is sent to the model. | ||
| 4 | + | ||
| 5 | +## Commands | ||
| 6 | + | ||
| 7 | +| Command | At the prompt | During a generation | | ||
| 8 | +|---------|---------------|---------------------| | ||
| 9 | +| `/quit` | Exits the program. | Cancels the generation, prints `Goodbye.`, exits. | | ||
| 10 | +| `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. | | ||
| 11 | +| `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). | | ||
| 12 | +| `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. | | ||
| 13 | + | ||
| 14 | +An empty line at the prompt is ignored. | ||
| 15 | + | ||
| 16 | +## `@path` in a question | ||
| 17 | + | ||
| 18 | +| Input | Effect | | ||
| 19 | +|-------|--------| | ||
| 20 | +| `@<path>` at the start or after a space, bracket or quote | If the path exists (relative to the start directory, absolute, or `~/…`), a dim `📎 <absolute path>` line is printed and `[attached file: <absolute path>]` (or `[attached directory: …]`) is appended to the message the model receives. Trailing punctuation is ignored; each path is attached once. | | ||
| 21 | +| A `@<path>` that names nothing on disk, or `@` glued to a word | Left as typed. | | ||
| 22 | + | ||
| 23 | +Details and examples: [Point the model at a file with `@path`](../how-to/attach-a-file.md). | ||
| 24 | + | ||
| 25 | +## Any other text during a generation | ||
| 26 | + | ||
| 27 | +| Input | Behaviour | | ||
| 28 | +|-------|-----------| | ||
| 29 | +| A non-command line | Prints `🔄 New command received. Cancelling current task...`, cancels the current generation, keeps its partial history, then submits the new line as the next question. | | ||
| 30 | + | ||
| 31 | +## Keyboard shortcuts | ||
| 32 | + | ||
| 33 | +| Key | At the prompt | During a generation | | ||
| 34 | +|-----|---------------|---------------------| | ||
| 35 | +| `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. | | ||
| 36 | +| `Ctrl+D` (end of input) | Exits. | — | | ||
| 37 | + | ||
| 38 | +## Messages printed at the end of a turn | ||
| 39 | + | ||
| 40 | +| Message | Meaning | | ||
| 41 | +|---------|---------| | ||
| 42 | +| `🛑 Generation aborted.` | The turn was cancelled (`/abort`, `Ctrl+C`). The question and any completed tool turns stay in the history. | | ||
| 43 | +| `[error: …]` | The provider explained a failure in one line; a question that got no answer is removed from the history. | | ||
| 44 | +| `⚙ N command(s)` | Number of `bash` commands that actually ran in the turn. | | ||
| 45 | +| `· 📝 N file op(s)` | Appended when file tools ran. | | ||
| 46 | +| `· 📖 N skill(s)` | Appended when skills were loaded. | | ||
| 47 | +| Numbered green list | The commands and file operations of the turn, one per line, when `displayCommands: true`. | | ||
| 48 | + | ||
| 49 | +## ACP mode | ||
| 50 | + | ||
| 51 | +Two commands exist in ACP mode: `/new` and `/compact`. They are advertised to the editor through an `available_commands_update` notification and intercepted by `session/prompt` when the prompt text, trimmed, is exactly the command. Each answers with one `agent_message_chunk` carrying the line the terminal would print, then `stopReason: end_turn`; the model is not consulted. | ||
| 52 | + | ||
| 53 | +| Command | Over ACP | | ||
| 54 | +|---------|----------| | ||
| 55 | +| `/new` | Same as in the terminal: history back to the system prompt, token count forgotten; the session id, `cwd` and allow-always grants are kept. | | ||
| 56 | +| `/compact` | Same as in the terminal, always forced: `🗜️ compressed …`, `🗜️ nothing to compact: …` or `[compact: failed, history kept: …]`. Waits for a running turn to finish first. | | ||
| 57 | + | ||
| 58 | +`/quit` and `/abort` do not exist in ACP mode: the editor owns the process and ends it by closing stdin, and cancels a turn with its own stop key through `session/cancel`. See the [ACP reference](acp.md). | ||
added
docs/en/reference/tools.md +103 -0 | new file mode 100644 | ||
| @@ -0,0 +1,103 @@ | ||
| 1 | +# Reference: built-in tools | |
| 2 | + | |
| 3 | +> Neutral description of the tools declared to the model. A tool never returns a Go error for an ordinary failure: the failure text is returned to the model as the tool's output. Every tool records its call in the loop detector; the same tool, input and output three times in a row appends `[LOOP_DETECTED: …]` to the output. | |
| 4 | + | |
| 5 | +## `bash` | |
| 6 | + | |
| 7 | +Always declared. | |
| 8 | + | |
| 9 | +| Field | Type | Description | | |
| 10 | +|-------|------|-------------| | |
| 11 | +| `command` | string | The shell command to run. | | |
| 12 | + | |
| 13 | +| Aspect | Behaviour | | |
| 14 | +|--------|-----------| | |
| 15 | +| Shell | `bash -c <command>`, a new shell per call, no stdin. | | |
| 16 | +| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. | | |
| 17 | +| Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. | | |
| 18 | +| Output | stdout and stderr merged, truncated to `maxOutput` characters. | | |
| 19 | +| Non-zero exit | Output ends with `[exit code: exit status N]`. | | |
| 20 | +| Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. | | |
| 21 | +| ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. | | |
| 22 | + | |
| 23 | +## `read_skill` | |
| 24 | + | |
| 25 | +Declared only when `skillsDir` holds at least one skill (`<name>.md` or `<name>/SKILL.md`). | |
| 26 | + | |
| 27 | +| Field | Type | Description | | |
| 28 | +|-------|------|-------------| | |
| 29 | +| `name` | string | Skill name, exactly as listed in the tool description. | | |
| 30 | + | |
| 31 | +| Aspect | Behaviour | | |
| 32 | +|--------|-----------| | |
| 33 | +| Description | Fixed sentence followed by the catalogue: one ` <name> — <description>` line per skill, sorted by name. | | |
| 34 | +| Output | The full markdown of `<skillsDir>/<name>.md`, or of `<skillsDir>/<name>/SKILL.md` when there is no flat file, truncated to `maxOutput`. | | |
| 35 | +| Unknown name | `No skill named "x". Available skills: a, b, c` | | |
| 36 | +| Path safety | Only the base name of `name` is used; no directory traversal. | | |
| 37 | +| Terminal display | `📖 read_skill: <name>`; the content is not echoed. | | |
| 38 | +| ACP | Tool call of kind `read`; no permission dialog. | | |
| 39 | + | |
| 40 | +## `read_file` | |
| 41 | + | |
| 42 | +Declared when `editTools: true`. | |
| 43 | + | |
| 44 | +| Field | Type | Default | Description | | |
| 45 | +|-------|------|---------|-------------| | |
| 46 | +| `path` | string | — | File path, relative to the working directory. | | |
| 47 | +| `start` | int | `0` | First line (1-based); `0` = from the start. | | |
| 48 | +| `end` | int | `0` | Last line (1-based, inclusive); `0` = to the end. | | |
| 49 | +| `numbered` | bool | `false` | Prefix each line with its number. | | |
| 50 | + | |
| 51 | +| Aspect | Behaviour | | |
| 52 | +|--------|-----------| | |
| 53 | +| Missing file | `<path> does not exist. Create it with write_file` | | |
| 54 | +| Output | The lines, truncated to `maxOutput`; the final newline is kept only when the end of the file is shown. | | |
| 55 | +| ACP | Kind `read`, with the absolute path as location. | | |
| 56 | + | |
| 57 | +## `write_file` | |
| 58 | + | |
| 59 | +Declared when `editTools: true`. | |
| 60 | + | |
| 61 | +| Field | Type | Description | | |
| 62 | +|-------|------|-------------| | |
| 63 | +| `path` | string | File path, relative to the working directory; parent directories are created. | | |
| 64 | +| `content` | string | The whole content of the file. | | |
| 65 | + | |
| 66 | +| Aspect | Behaviour | | |
| 67 | +|--------|-----------| | |
| 68 | +| Existing file | Entirely replaced. | | |
| 69 | +| Identical content | Nothing is written; the result says so. | | |
| 70 | +| Output | A one-line headline and the diff against the previous content. | | |
| 71 | +| ACP | Kind `edit`; permission requested; the change travels as a diff (new-file diff when created). | | |
| 72 | + | |
| 73 | +## `edit_file` | |
| 74 | + | |
| 75 | +Declared when `editTools: true`. | |
| 76 | + | |
| 77 | +| Field | Type | Default | Description | | |
| 78 | +|-------|------|---------|-------------| | |
| 79 | +| `path` | string | — | Path of an existing file. | | |
| 80 | +| `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. | | |
| 81 | +| `edits[].old` | string | — | Exact text to find, present exactly once. | | |
| 82 | +| `edits[].new` | string | — | Replacement; empty deletes the matched text. | | |
| 83 | +| `dry_run` | bool | `false` | Report what would change, write nothing. | | |
| 84 | + | |
| 85 | +| Rule | Consequence when broken | | |
| 86 | +|------|-------------------------| | |
| 87 | +| `old` appears exactly once | Refused: not found, or ambiguous. | | |
| 88 | +| Two edits must not overlap, nest or repeat | Refused. | | |
| 89 | +| Edits are resolved against the original text, never in cascade | — | | |
| 90 | +| Any refused edit | Nothing is written. | | |
| 91 | + | |
| 92 | +| Aspect | Behaviour | | |
| 93 | +|--------|-----------| | |
| 94 | +| Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. | | |
| 95 | +| ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. | | |
| 96 | + | |
| 97 | +## Counters shown after a turn | |
| 98 | + | |
| 99 | +| Counter | Counts | | |
| 100 | +|---------|--------| | |
| 101 | +| `⚙ N command(s)` | `bash` tool responses in the turn. | | |
| 102 | +| `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. | | |
| 103 | +| `📖 N skill(s)` | `read_skill` responses. | | |
| new file mode 100644 | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | +# Reference: built-in tools | ||
| 2 | + | ||
| 3 | +> Neutral description of the tools declared to the model. A tool never returns a Go error for an ordinary failure: the failure text is returned to the model as the tool's output. Every tool records its call in the loop detector; the same tool, input and output three times in a row appends `[LOOP_DETECTED: …]` to the output. | ||
| 4 | + | ||
| 5 | +## `bash` | ||
| 6 | + | ||
| 7 | +Always declared. | ||
| 8 | + | ||
| 9 | +| Field | Type | Description | | ||
| 10 | +|-------|------|-------------| | ||
| 11 | +| `command` | string | The shell command to run. | | ||
| 12 | + | ||
| 13 | +| Aspect | Behaviour | | ||
| 14 | +|--------|-----------| | ||
| 15 | +| Shell | `bash -c <command>`, a new shell per call, no stdin. | | ||
| 16 | +| Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. | | ||
| 17 | +| Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. | | ||
| 18 | +| Output | stdout and stderr merged, truncated to `maxOutput` characters. | | ||
| 19 | +| Non-zero exit | Output ends with `[exit code: exit status N]`. | | ||
| 20 | +| Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. | | ||
| 21 | +| ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. | | ||
| 22 | + | ||
| 23 | +## `read_skill` | ||
| 24 | + | ||
| 25 | +Declared only when `skillsDir` holds at least one skill (`<name>.md` or `<name>/SKILL.md`). | ||
| 26 | + | ||
| 27 | +| Field | Type | Description | | ||
| 28 | +|-------|------|-------------| | ||
| 29 | +| `name` | string | Skill name, exactly as listed in the tool description. | | ||
| 30 | + | ||
| 31 | +| Aspect | Behaviour | | ||
| 32 | +|--------|-----------| | ||
| 33 | +| Description | Fixed sentence followed by the catalogue: one ` <name> — <description>` line per skill, sorted by name. | | ||
| 34 | +| Output | The full markdown of `<skillsDir>/<name>.md`, or of `<skillsDir>/<name>/SKILL.md` when there is no flat file, truncated to `maxOutput`. | | ||
| 35 | +| Unknown name | `No skill named "x". Available skills: a, b, c` | | ||
| 36 | +| Path safety | Only the base name of `name` is used; no directory traversal. | | ||
| 37 | +| Terminal display | `📖 read_skill: <name>`; the content is not echoed. | | ||
| 38 | +| ACP | Tool call of kind `read`; no permission dialog. | | ||
| 39 | + | ||
| 40 | +## `read_file` | ||
| 41 | + | ||
| 42 | +Declared when `editTools: true`. | ||
| 43 | + | ||
| 44 | +| Field | Type | Default | Description | | ||
| 45 | +|-------|------|---------|-------------| | ||
| 46 | +| `path` | string | — | File path, relative to the working directory. | | ||
| 47 | +| `start` | int | `0` | First line (1-based); `0` = from the start. | | ||
| 48 | +| `end` | int | `0` | Last line (1-based, inclusive); `0` = to the end. | | ||
| 49 | +| `numbered` | bool | `false` | Prefix each line with its number. | | ||
| 50 | + | ||
| 51 | +| Aspect | Behaviour | | ||
| 52 | +|--------|-----------| | ||
| 53 | +| Missing file | `<path> does not exist. Create it with write_file` | | ||
| 54 | +| Output | The lines, truncated to `maxOutput`; the final newline is kept only when the end of the file is shown. | | ||
| 55 | +| ACP | Kind `read`, with the absolute path as location. | | ||
| 56 | + | ||
| 57 | +## `write_file` | ||
| 58 | + | ||
| 59 | +Declared when `editTools: true`. | ||
| 60 | + | ||
| 61 | +| Field | Type | Description | | ||
| 62 | +|-------|------|-------------| | ||
| 63 | +| `path` | string | File path, relative to the working directory; parent directories are created. | | ||
| 64 | +| `content` | string | The whole content of the file. | | ||
| 65 | + | ||
| 66 | +| Aspect | Behaviour | | ||
| 67 | +|--------|-----------| | ||
| 68 | +| Existing file | Entirely replaced. | | ||
| 69 | +| Identical content | Nothing is written; the result says so. | | ||
| 70 | +| Output | A one-line headline and the diff against the previous content. | | ||
| 71 | +| ACP | Kind `edit`; permission requested; the change travels as a diff (new-file diff when created). | | ||
| 72 | + | ||
| 73 | +## `edit_file` | ||
| 74 | + | ||
| 75 | +Declared when `editTools: true`. | ||
| 76 | + | ||
| 77 | +| Field | Type | Default | Description | | ||
| 78 | +|-------|------|---------|-------------| | ||
| 79 | +| `path` | string | — | Path of an existing file. | | ||
| 80 | +| `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. | | ||
| 81 | +| `edits[].old` | string | — | Exact text to find, present exactly once. | | ||
| 82 | +| `edits[].new` | string | — | Replacement; empty deletes the matched text. | | ||
| 83 | +| `dry_run` | bool | `false` | Report what would change, write nothing. | | ||
| 84 | + | ||
| 85 | +| Rule | Consequence when broken | | ||
| 86 | +|------|-------------------------| | ||
| 87 | +| `old` appears exactly once | Refused: not found, or ambiguous. | | ||
| 88 | +| Two edits must not overlap, nest or repeat | Refused. | | ||
| 89 | +| Edits are resolved against the original text, never in cascade | — | | ||
| 90 | +| Any refused edit | Nothing is written. | | ||
| 91 | + | ||
| 92 | +| Aspect | Behaviour | | ||
| 93 | +|--------|-----------| | ||
| 94 | +| Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. | | ||
| 95 | +| ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. | | ||
| 96 | + | ||
| 97 | +## Counters shown after a turn | ||
| 98 | + | ||
| 99 | +| Counter | Counts | | ||
| 100 | +|---------|--------| | ||
| 101 | +| `⚙ N command(s)` | `bash` tool responses in the turn. | | ||
| 102 | +| `📝 N file op(s)` | `read_file`, `write_file`, `edit_file` responses. | | ||
| 103 | +| `📖 N skill(s)` | `read_skill` responses. | | ||
added
docs/en/tutorials/getting-started.md +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +# Tutorial: your first session with mini-me | |
| 2 | + | |
| 3 | +By the end of this tutorial, you will have built the `mm` binary, started it against Docker Model Runner, asked it one question that makes it run a shell command, and left cleanly. No prior knowledge of the project is required. | |
| 4 | + | |
| 5 | +## Prerequisites | |
| 6 | + | |
| 7 | +- Go 1.25 or later on your `PATH` (`go version` answers). | |
| 8 | +- Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`. | |
| 9 | +- The model named in `agent.yaml` pulled locally. The file shipped with the project names `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`; pull it with `docker model pull <that name>`. | |
| 10 | +- A terminal open at the root of the repository (the directory that holds `main.go` and `agent.yaml`). | |
| 11 | + | |
| 12 | +## Step 1 — Build the binary | |
| 13 | + | |
| 14 | +Type: | |
| 15 | + | |
| 16 | +```bash | |
| 17 | +go build -o mm . | |
| 18 | +``` | |
| 19 | + | |
| 20 | +You should see no output at all. A file named `mm` now sits in the current directory. | |
| 21 | + | |
| 22 | +We have just compiled the whole agent into a single executable. | |
| 23 | + | |
| 24 | +## Step 2 — Start the agent | |
| 25 | + | |
| 26 | +Type: | |
| 27 | + | |
| 28 | +```bash | |
| 29 | +./mm | |
| 30 | +``` | |
| 31 | + | |
| 32 | +You should see a banner like this one (the model name is the one from `agent.yaml`): | |
| 33 | + | |
| 34 | +``` | |
| 35 | +config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | |
| 36 | +Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session. | |
| 37 | +Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. | |
| 38 | + | |
| 39 | +> | |
| 40 | +``` | |
| 41 | + | |
| 42 | +If a dimmed `[warning: dmr: nothing answers at …]` line appears above the banner, Docker Model Runner is not running: start it, then run `./mm` again. The agent starts anyway, but the next step would fail. | |
| 43 | + | |
| 44 | +We have just loaded the configuration, connected to the model server, and declared the tools the model may call. | |
| 45 | + | |
| 46 | +## Step 3 — Ask a question that needs a command | |
| 47 | + | |
| 48 | +At the `>` prompt, type: | |
| 49 | + | |
| 50 | +``` | |
| 51 | +list the files in this directory | |
| 52 | +``` | |
| 53 | + | |
| 54 | +You should see, in order: a spinner while the model thinks, a line starting with `🛠️ bash:` showing the command the model chose (typically `ls` or `ls -la`), the first lines of that command's output indented and dimmed, then the model's answer in plain text. The turn ends with a dimmed recap such as: | |
| 55 | + | |
| 56 | +``` | |
| 57 | +⚙ 1 command(s) | |
| 58 | +``` | |
| 59 | + | |
| 60 | +followed by the numbered list of the commands that ran, in green. | |
| 61 | + | |
| 62 | +We have just watched the agent loop once: the model asked for a tool, the tool ran, its output went back to the model, and the model answered. | |
| 63 | + | |
| 64 | +## Step 4 — Leave | |
| 65 | + | |
| 66 | +At the `>` prompt, type: | |
| 67 | + | |
| 68 | +``` | |
| 69 | +/quit | |
| 70 | +``` | |
| 71 | + | |
| 72 | +The program exits. Pressing `Ctrl+C` at the prompt does the same and prints `Goodbye.` first. | |
| 73 | + | |
| 74 | +## What now? | |
| 75 | + | |
| 76 | +You have built `mm`, run one tool-calling turn, and exited. To go further: | |
| 77 | + | |
| 78 | +- To run it on llama.cpp, host it in an editor, or add skills → see the [how-to guides](../how-to/) | |
| 79 | +- To look up a flag, a YAML key or a slash command → see the [reference](../reference/) | |
| 80 | +- To understand how the loop and the two front ends fit together → see the [explanation](../explanation/) | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +# Tutorial: your first session with mini-me | ||
| 2 | + | ||
| 3 | +By the end of this tutorial, you will have built the `mm` binary, started it against Docker Model Runner, asked it one question that makes it run a shell command, and left cleanly. No prior knowledge of the project is required. | ||
| 4 | + | ||
| 5 | +## Prerequisites | ||
| 6 | + | ||
| 7 | +- Go 1.25 or later on your `PATH` (`go version` answers). | ||
| 8 | +- Docker Desktop with Docker Model Runner enabled and reachable on `http://localhost:12434`. | ||
| 9 | +- The model named in `agent.yaml` pulled locally. The file shipped with the project names `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M`; pull it with `docker model pull <that name>`. | ||
| 10 | +- A terminal open at the root of the repository (the directory that holds `main.go` and `agent.yaml`). | ||
| 11 | + | ||
| 12 | +## Step 1 — Build the binary | ||
| 13 | + | ||
| 14 | +Type: | ||
| 15 | + | ||
| 16 | +```bash | ||
| 17 | +go build -o mm . | ||
| 18 | +``` | ||
| 19 | + | ||
| 20 | +You should see no output at all. A file named `mm` now sits in the current directory. | ||
| 21 | + | ||
| 22 | +We have just compiled the whole agent into a single executable. | ||
| 23 | + | ||
| 24 | +## Step 2 — Start the agent | ||
| 25 | + | ||
| 26 | +Type: | ||
| 27 | + | ||
| 28 | +```bash | ||
| 29 | +./mm | ||
| 30 | +``` | ||
| 31 | + | ||
| 32 | +You should see a banner like this one (the model name is the one from `agent.yaml`): | ||
| 33 | + | ||
| 34 | +``` | ||
| 35 | +config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | ||
| 36 | +Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session. | ||
| 37 | +Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. | ||
| 38 | + | ||
| 39 | +> | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +If a dimmed `[warning: dmr: nothing answers at …]` line appears above the banner, Docker Model Runner is not running: start it, then run `./mm` again. The agent starts anyway, but the next step would fail. | ||
| 43 | + | ||
| 44 | +We have just loaded the configuration, connected to the model server, and declared the tools the model may call. | ||
| 45 | + | ||
| 46 | +## Step 3 — Ask a question that needs a command | ||
| 47 | + | ||
| 48 | +At the `>` prompt, type: | ||
| 49 | + | ||
| 50 | +``` | ||
| 51 | +list the files in this directory | ||
| 52 | +``` | ||
| 53 | + | ||
| 54 | +You should see, in order: a spinner while the model thinks, a line starting with `🛠️ bash:` showing the command the model chose (typically `ls` or `ls -la`), the first lines of that command's output indented and dimmed, then the model's answer in plain text. The turn ends with a dimmed recap such as: | ||
| 55 | + | ||
| 56 | +``` | ||
| 57 | +⚙ 1 command(s) | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +followed by the numbered list of the commands that ran, in green. | ||
| 61 | + | ||
| 62 | +We have just watched the agent loop once: the model asked for a tool, the tool ran, its output went back to the model, and the model answered. | ||
| 63 | + | ||
| 64 | +## Step 4 — Leave | ||
| 65 | + | ||
| 66 | +At the `>` prompt, type: | ||
| 67 | + | ||
| 68 | +``` | ||
| 69 | +/quit | ||
| 70 | +``` | ||
| 71 | + | ||
| 72 | +The program exits. Pressing `Ctrl+C` at the prompt does the same and prints `Goodbye.` first. | ||
| 73 | + | ||
| 74 | +## What now? | ||
| 75 | + | ||
| 76 | +You have built `mm`, run one tool-calling turn, and exited. To go further: | ||
| 77 | + | ||
| 78 | +- To run it on llama.cpp, host it in an editor, or add skills → see the [how-to guides](../how-to/) | ||
| 79 | +- To look up a flag, a YAML key or a slash command → see the [reference](../reference/) | ||
| 80 | +- To understand how the loop and the two front ends fit together → see the [explanation](../explanation/) | ||
added
docs/fr/README.md +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +# mini-me — documentation (français) | |
| 2 | + | |
| 3 | +`mm` est un agent de code qui tourne dans votre terminal, ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`. | |
| 4 | + | |
| 5 | +Cette documentation est organisée selon les quatre quadrants [Diátaxis](https://diataxis.fr). Choisissez celui qui correspond à votre besoin du moment. | |
| 6 | + | |
| 7 | +## Tutoriels — apprendre en faisant | |
| 8 | + | |
| 9 | +- [Votre première session avec mini-me](tutorials/getting-started.md) | |
| 10 | + | |
| 11 | +## Guides pratiques — accomplir une tâche | |
| 12 | + | |
| 13 | +- [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) | |
| 14 | +- [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) | |
| 15 | +- [Ajouter un skill](how-to/add-a-skill.md) | |
| 16 | +- [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) | |
| 17 | +- [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) | |
| 18 | +- [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md) | |
| 19 | + | |
| 20 | +## Référence — consulter | |
| 21 | + | |
| 22 | +- [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage | |
| 23 | +- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, raccourcis clavier | |
| 24 | +- [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation | |
| 25 | +- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file` | |
| 26 | +- [Format d'un fichier de skill](reference/skill-format.md) | |
| 27 | +- [Surface Agent Client Protocol](reference/acp.md) | |
| 28 | + | |
| 29 | +## Explication — comprendre | |
| 30 | + | |
| 31 | +- [Architecture : une boucle, deux façades](explanation/architecture.md) | |
| 32 | +- [Fournisseurs : une implémentation par protocole](explanation/providers.md) | |
| 33 | +- [Compression du contexte](explanation/context-compression.md) | |
| 34 | +- Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | |
| 35 | + | |
| 36 | +## Périmètre | |
| 37 | + | |
| 38 | +Cette documentation couvre le binaire `mm` et les paquets sous `internal/`. Les répertoires `kits/` et `tmp/` à la racine du dépôt ne font pas partie du projet et ne sont pas documentés ici. | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +# mini-me — documentation (français) | ||
| 2 | + | ||
| 3 | +`mm` est un agent de code qui tourne dans votre terminal, ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`. | ||
| 4 | + | ||
| 5 | +Cette documentation est organisée selon les quatre quadrants [Diátaxis](https://diataxis.fr). Choisissez celui qui correspond à votre besoin du moment. | ||
| 6 | + | ||
| 7 | +## Tutoriels — apprendre en faisant | ||
| 8 | + | ||
| 9 | +- [Votre première session avec mini-me](tutorials/getting-started.md) | ||
| 10 | + | ||
| 11 | +## Guides pratiques — accomplir une tâche | ||
| 12 | + | ||
| 13 | +- [Faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner](how-to/run-with-llama-cpp.md) | ||
| 14 | +- [Utiliser mini-me depuis un éditeur (ACP)](how-to/use-from-an-editor.md) | ||
| 15 | +- [Ajouter un skill](how-to/add-a-skill.md) | ||
| 16 | +- [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) | ||
| 17 | +- [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) | ||
| 18 | +- [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md) | ||
| 19 | + | ||
| 20 | +## Référence — consulter | ||
| 21 | + | ||
| 22 | +- [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage | ||
| 23 | +- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, raccourcis clavier | ||
| 24 | +- [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation | ||
| 25 | +- [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file` | ||
| 26 | +- [Format d'un fichier de skill](reference/skill-format.md) | ||
| 27 | +- [Surface Agent Client Protocol](reference/acp.md) | ||
| 28 | + | ||
| 29 | +## Explication — comprendre | ||
| 30 | + | ||
| 31 | +- [Architecture : une boucle, deux façades](explanation/architecture.md) | ||
| 32 | +- [Fournisseurs : une implémentation par protocole](explanation/providers.md) | ||
| 33 | +- [Compression du contexte](explanation/context-compression.md) | ||
| 34 | +- Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | ||
| 35 | + | ||
| 36 | +## Périmètre | ||
| 37 | + | ||
| 38 | +Cette documentation couvre le binaire `mm` et les paquets sous `internal/`. Les répertoires `kits/` et `tmp/` à la racine du dépôt ne font pas partie du projet et ne sont pas documentés ici. | ||
added
docs/fr/explanation/architecture.md +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +# Architecture : une boucle, deux façades — explication | |
| 2 | + | |
| 3 | +## De quoi s'agit-il ? | |
| 4 | + | |
| 5 | +`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal et depuis un éditeur sans rien dupliquer. | |
| 6 | + | |
| 7 | +Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des deux façades. Chaque préoccupation vit dans son propre paquet sous `internal/` : | |
| 8 | + | |
| 9 | +| Paquet | Responsabilité | | |
| 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. | | |
| 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é, et le registre des fournisseurs. | | |
| 13 | +| `tools` | Les outils intégrés que le modèle peut appeler. | | |
| 14 | +| `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. | | |
| 15 | +| `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. | | |
| 16 | +| `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. | | |
| 17 | +| `detector` | La détection d'une même action répétée avec le même résultat. | | |
| 18 | +| `compact` | La compression de l'historique en un résumé écrit par le modèle. | | |
| 19 | +| `spinner` | L'indicateur « toujours en cours » sur un terminal. | | |
| 20 | +| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les deux façades partagent. | | |
| 21 | +| `agent` | Le REPL du terminal. | | |
| 22 | +| `acp` | La façade Agent Client Protocol. | | |
| 23 | + | |
| 24 | +Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio). | |
| 25 | + | |
| 26 | +## Pourquoi c'est conçu ainsi | |
| 27 | + | |
| 28 | +**Les outils rapportent les échecs comme du texte, jamais comme des erreurs.** Une commande qui sort avec un code non nul, un fichier qui n'existe pas, une édition ambiguë : tout revient au modèle comme sortie de l'outil. C'est le modèle qui doit réagir, donc c'est lui qui doit lire le message. Une erreur Go terminerait le tour à sa place. | |
| 29 | + | |
| 30 | +**L'historique complet est conservé même quand un tour échoue.** Genkit ne renvoie que le dernier message ; le moteur renvoie toute la conversation, appels d'outils et résultats compris. Les façades gardent cet historique après une interruption, une coupure du watchdog ou un dépassement de `maxTurns`, parce que les commandes déjà exécutées sont des faits que le modèle ne doit ni rejouer ni inventer à la question suivante. Seule une question restée sans aucune réponse est retirée. | |
| 31 | + | |
| 32 | +**La sortie est montrée avant que le modèle la voie.** Dans le terminal, `bash` affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien. | |
| 33 | + | |
| 34 | +**Une boucle, deux façades.** Le REPL du terminal et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission. | |
| 35 | + | |
| 36 | +**La permission vit dans le puits, pas dans les outils.** Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session. | |
| 37 | + | |
| 38 | +**Les tours sont sérialisés en mode ACP.** Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second `session/prompt` attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent. | |
| 39 | + | |
| 40 | +**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée. | |
| 41 | + | |
| 42 | +## Alternatives écartées | |
| 43 | + | |
| 44 | +- **Un binaire par façade.** Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans `main.go` est ce qui le rend visible. | |
| 45 | +- **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout. | |
| 46 | +- **Renvoyer des erreurs Go depuis les outils.** Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper. | |
| 47 | + | |
| 48 | +## Liens avec le reste | |
| 49 | + | |
| 50 | +- Le registre de fournisseurs utilisé par le moteur est décrit dans [fournisseurs](providers.md). | |
| 51 | +- Pourquoi et comment l'historique est raccourci est dans [compression du contexte](context-compression.md). | |
| 52 | +- La surface exacte du protocole est dans la [référence ACP](../reference/acp.md) ; le contrat des outils est dans la [référence des outils](../reference/tools.md). | |
| 53 | + | |
| 54 | +## Carte des paquets | |
| 55 | + | |
| 56 | +Le graphe d'import de tous les paquets, avec une ligne de rôle sur chacun, est tenu sous forme de diagramme draw.io dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (s'ouvre dans diagrams.net ou l'extension Draw.io de VS Code). Il est regénéré à partir de `go list` chaque fois qu'un paquet est ajouté, retiré ou recâblé. | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +# Architecture : une boucle, deux façades — explication | ||
| 2 | + | ||
| 3 | +## De quoi s'agit-il ? | ||
| 4 | + | ||
| 5 | +`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal et depuis un éditeur sans rien dupliquer. | ||
| 6 | + | ||
| 7 | +Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des deux façades. Chaque préoccupation vit dans son propre paquet sous `internal/` : | ||
| 8 | + | ||
| 9 | +| Paquet | Responsabilité | | ||
| 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. | | ||
| 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é, et le registre des fournisseurs. | | ||
| 13 | +| `tools` | Les outils intégrés que le modèle peut appeler. | | ||
| 14 | +| `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. | | ||
| 15 | +| `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. | | ||
| 16 | +| `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. | | ||
| 17 | +| `detector` | La détection d'une même action répétée avec le même résultat. | | ||
| 18 | +| `compact` | La compression de l'historique en un résumé écrit par le modèle. | | ||
| 19 | +| `spinner` | L'indicateur « toujours en cours » sur un terminal. | | ||
| 20 | +| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les deux façades partagent. | | ||
| 21 | +| `agent` | Le REPL du terminal. | | ||
| 22 | +| `acp` | La façade Agent Client Protocol. | | ||
| 23 | + | ||
| 24 | +Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio). | ||
| 25 | + | ||
| 26 | +## Pourquoi c'est conçu ainsi | ||
| 27 | + | ||
| 28 | +**Les outils rapportent les échecs comme du texte, jamais comme des erreurs.** Une commande qui sort avec un code non nul, un fichier qui n'existe pas, une édition ambiguë : tout revient au modèle comme sortie de l'outil. C'est le modèle qui doit réagir, donc c'est lui qui doit lire le message. Une erreur Go terminerait le tour à sa place. | ||
| 29 | + | ||
| 30 | +**L'historique complet est conservé même quand un tour échoue.** Genkit ne renvoie que le dernier message ; le moteur renvoie toute la conversation, appels d'outils et résultats compris. Les façades gardent cet historique après une interruption, une coupure du watchdog ou un dépassement de `maxTurns`, parce que les commandes déjà exécutées sont des faits que le modèle ne doit ni rejouer ni inventer à la question suivante. Seule une question restée sans aucune réponse est retirée. | ||
| 31 | + | ||
| 32 | +**La sortie est montrée avant que le modèle la voie.** Dans le terminal, `bash` affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien. | ||
| 33 | + | ||
| 34 | +**Une boucle, deux façades.** Le REPL du terminal et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission. | ||
| 35 | + | ||
| 36 | +**La permission vit dans le puits, pas dans les outils.** Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session. | ||
| 37 | + | ||
| 38 | +**Les tours sont sérialisés en mode ACP.** Le puits actif est global au processus et le moteur n'a jamais été conçu pour des générations concurrentes, donc un second `session/prompt` attend la fin du premier. Le protocole laisse ce choix à l'agent, et les éditeurs n'envoient de toute façon pas de prompts qui se chevauchent. | ||
| 39 | + | ||
| 40 | +**Le détecteur de boucle est séparé de l'historique.** Il enregistre des triplets outil, entrée, sortie ; le même triplet trois fois de suite fait ajouter par l'outil une instruction de changer d'approche. Il n'est volontairement pas remis à zéro par `/compact` : oublier une conversation ne rend pas nouvelle une commande répétée. | ||
| 41 | + | ||
| 42 | +## Alternatives écartées | ||
| 43 | + | ||
| 44 | +- **Un binaire par façade.** Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans `main.go` est ce qui le rend visible. | ||
| 45 | +- **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout. | ||
| 46 | +- **Renvoyer des erreurs Go depuis les outils.** Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper. | ||
| 47 | + | ||
| 48 | +## Liens avec le reste | ||
| 49 | + | ||
| 50 | +- Le registre de fournisseurs utilisé par le moteur est décrit dans [fournisseurs](providers.md). | ||
| 51 | +- Pourquoi et comment l'historique est raccourci est dans [compression du contexte](context-compression.md). | ||
| 52 | +- La surface exacte du protocole est dans la [référence ACP](../reference/acp.md) ; le contrat des outils est dans la [référence des outils](../reference/tools.md). | ||
| 53 | + | ||
| 54 | +## Carte des paquets | ||
| 55 | + | ||
| 56 | +Le graphe d'import de tous les paquets, avec une ligne de rôle sur chacun, est tenu sous forme de diagramme draw.io dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio) (s'ouvre dans diagrams.net ou l'extension Draw.io de VS Code). Il est regénéré à partir de `go list` chaque fois qu'un paquet est ajouté, retiré ou recâblé. | ||
added
docs/fr/explanation/context-compression.md +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +# Compression du contexte — explication | |
| 2 | + | |
| 3 | +## De quoi s'agit-il ? | |
| 4 | + | |
| 5 | +Rien dans une boucle d'agent ne raccourcit jamais l'historique. Mesurés face à un faux moteur, les messages envoyés au modèle ont grossi 2 → 5 → 7 → 9 sur quatre requêtes, et chaque sortie de `bash`, jusqu'à `maxOutput` caractères, et chaque skill chargé restent jusqu'au `/quit`. Sur un modèle local, la fenêtre de contexte est fixée au chargement du serveur, et tout l'historique est relu à chaque tour sur le matériel du portable. Une longue session se manifeste donc d'abord par un faux blocage du watchdog, puis par une erreur du serveur. | |
| 6 | + | |
| 7 | +Le paquet `compact` remplace les anciens tours de question par un résumé écrit par le modèle lui-même, et garde les quelques derniers tours bruts. | |
| 8 | + | |
| 9 | +## Pourquoi c'est conçu ainsi | |
| 10 | + | |
| 11 | +**Un hybride : résumé plus tours récents bruts.** Les tours récents sont là où le modèle travaille ; les résumer lui fait rejouer ce qu'il vient de faire. Le défaut garde trois tours de question, assez pour « fais X », « maintenant corrige », « et teste ». Tout ce qui est plus ancien devient une paire de messages insérée juste après le prompt système : un message utilisateur qui porte les notes, et un court accusé de réception du modèle. | |
| 12 | + | |
| 13 | +**La coupure tombe sur une frontière de tour.** Un appel d'outil et son résultat ne doivent jamais être séparés ; réutiliser des tours entiers le garantit. | |
| 14 | + | |
| 15 | +**Deux déclencheurs, parce que la fenêtre n'est pas toujours connue.** Le déclencheur principal est une part de la fenêtre de contexte, 75 % par défaut, qui laisse un quart pour la question suivante, ses sorties d'outils et la réponse. Le compte de tokens d'entrée rapporté par le serveur au dernier appel est préféré à l'estimation locale quand il est plus grand, parce qu'il voit le JSON des outils et le template de chat. Quand la fenêtre est inconnue, Docker Model Runner ne dit rien et `contextWindow` peut valoir `0`, seul le déclencheur sur le nombre de messages peut jouer, et l'agent le dit au démarrage. | |
| 16 | + | |
| 17 | +**Une seule clé pour la fenêtre.** La bannière et le déclencheur lisent le même `contextWindow` ; deux clés pour le même nombre finiraient par diverger. | |
| 18 | + | |
| 19 | +**Les résumés antérieurs sont fusionnés, pas re-résumés.** Les deux messages insérés portent un marqueur ; une compression ultérieure les reconnaît et replie les nouveaux tours anciens dans le résumé existant, parce que résumer un résumé perd du détail de façon exponentielle. | |
| 20 | + | |
| 21 | +**Un résumé de forme fixe.** Le prompt intégré demande sept sections avec chemins, lignes de commande et messages d'erreur exacts, et interdit de citer le contenu des fichiers : un fichier peut avoir changé depuis sa lecture, et le prompt système interdit déjà d'affirmer ce qu'une commande de cette réponse n'a pas renvoyé. La requête de résumé remplace le prompt système de l'agent par celui d'un preneur de notes, parce que « tu es un agent de code, exécute des commandes » envoyé avec un historique plein d'appels d'outils invite le modèle à continuer d'agir. | |
| 22 | + | |
| 23 | +**L'échec ne coûte rien.** Si la requête de résumé échoue, serveur arrêté, watchdog, résultat vide, l'historique est laissé intact et la question suivante part comme avant. La compression existe pour rendre la question suivante possible ; elle ne doit jamais en coûter une. | |
| 24 | + | |
| 25 | +**Le watchdog est deux fois plus patient ici.** Le résumé est le plus long préremplissage de la session et rien ne s'affiche pendant qu'il s'écrit. | |
| 26 | + | |
| 27 | +**`/compact` ignore le seuil, pas la frontière.** La commande garde toujours les derniers tours et le dit quand rien n'est plus ancien. La compression ne s'exécute jamais qu'entre deux générations, parce que Genkit détient sa propre copie de la conversation pendant qu'un tour tourne. | |
| 28 | + | |
| 29 | +## Alternatives écartées | |
| 30 | + | |
| 31 | +- **Tronquer les messages les plus anciens.** Écarté : on perdrait des résultats d'outils dont les appels resteraient, et l'objectif de la session. | |
| 32 | +- **Tout résumer, tours récents compris.** Écarté : le modèle refait alors ses dernières étapes. | |
| 33 | +- **Une clé `context.contextWindow` séparée.** Écartée au profit de la seule clé de premier niveau. | |
| 34 | + | |
| 35 | +## Liens avec le reste | |
| 36 | + | |
| 37 | +- L'activer et lire le rapport : [comment garder une longue session dans la fenêtre de contexte](../how-to/manage-the-context-window.md). | |
| 38 | +- Les clés `context.*` : [référence de la configuration](../reference/configuration.md). | |
| 39 | +- D'où vient la fenêtre : [fournisseurs](providers.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +# Compression du contexte — explication | ||
| 2 | + | ||
| 3 | +## De quoi s'agit-il ? | ||
| 4 | + | ||
| 5 | +Rien dans une boucle d'agent ne raccourcit jamais l'historique. Mesurés face à un faux moteur, les messages envoyés au modèle ont grossi 2 → 5 → 7 → 9 sur quatre requêtes, et chaque sortie de `bash`, jusqu'à `maxOutput` caractères, et chaque skill chargé restent jusqu'au `/quit`. Sur un modèle local, la fenêtre de contexte est fixée au chargement du serveur, et tout l'historique est relu à chaque tour sur le matériel du portable. Une longue session se manifeste donc d'abord par un faux blocage du watchdog, puis par une erreur du serveur. | ||
| 6 | + | ||
| 7 | +Le paquet `compact` remplace les anciens tours de question par un résumé écrit par le modèle lui-même, et garde les quelques derniers tours bruts. | ||
| 8 | + | ||
| 9 | +## Pourquoi c'est conçu ainsi | ||
| 10 | + | ||
| 11 | +**Un hybride : résumé plus tours récents bruts.** Les tours récents sont là où le modèle travaille ; les résumer lui fait rejouer ce qu'il vient de faire. Le défaut garde trois tours de question, assez pour « fais X », « maintenant corrige », « et teste ». Tout ce qui est plus ancien devient une paire de messages insérée juste après le prompt système : un message utilisateur qui porte les notes, et un court accusé de réception du modèle. | ||
| 12 | + | ||
| 13 | +**La coupure tombe sur une frontière de tour.** Un appel d'outil et son résultat ne doivent jamais être séparés ; réutiliser des tours entiers le garantit. | ||
| 14 | + | ||
| 15 | +**Deux déclencheurs, parce que la fenêtre n'est pas toujours connue.** Le déclencheur principal est une part de la fenêtre de contexte, 75 % par défaut, qui laisse un quart pour la question suivante, ses sorties d'outils et la réponse. Le compte de tokens d'entrée rapporté par le serveur au dernier appel est préféré à l'estimation locale quand il est plus grand, parce qu'il voit le JSON des outils et le template de chat. Quand la fenêtre est inconnue, Docker Model Runner ne dit rien et `contextWindow` peut valoir `0`, seul le déclencheur sur le nombre de messages peut jouer, et l'agent le dit au démarrage. | ||
| 16 | + | ||
| 17 | +**Une seule clé pour la fenêtre.** La bannière et le déclencheur lisent le même `contextWindow` ; deux clés pour le même nombre finiraient par diverger. | ||
| 18 | + | ||
| 19 | +**Les résumés antérieurs sont fusionnés, pas re-résumés.** Les deux messages insérés portent un marqueur ; une compression ultérieure les reconnaît et replie les nouveaux tours anciens dans le résumé existant, parce que résumer un résumé perd du détail de façon exponentielle. | ||
| 20 | + | ||
| 21 | +**Un résumé de forme fixe.** Le prompt intégré demande sept sections avec chemins, lignes de commande et messages d'erreur exacts, et interdit de citer le contenu des fichiers : un fichier peut avoir changé depuis sa lecture, et le prompt système interdit déjà d'affirmer ce qu'une commande de cette réponse n'a pas renvoyé. La requête de résumé remplace le prompt système de l'agent par celui d'un preneur de notes, parce que « tu es un agent de code, exécute des commandes » envoyé avec un historique plein d'appels d'outils invite le modèle à continuer d'agir. | ||
| 22 | + | ||
| 23 | +**L'échec ne coûte rien.** Si la requête de résumé échoue, serveur arrêté, watchdog, résultat vide, l'historique est laissé intact et la question suivante part comme avant. La compression existe pour rendre la question suivante possible ; elle ne doit jamais en coûter une. | ||
| 24 | + | ||
| 25 | +**Le watchdog est deux fois plus patient ici.** Le résumé est le plus long préremplissage de la session et rien ne s'affiche pendant qu'il s'écrit. | ||
| 26 | + | ||
| 27 | +**`/compact` ignore le seuil, pas la frontière.** La commande garde toujours les derniers tours et le dit quand rien n'est plus ancien. La compression ne s'exécute jamais qu'entre deux générations, parce que Genkit détient sa propre copie de la conversation pendant qu'un tour tourne. | ||
| 28 | + | ||
| 29 | +## Alternatives écartées | ||
| 30 | + | ||
| 31 | +- **Tronquer les messages les plus anciens.** Écarté : on perdrait des résultats d'outils dont les appels resteraient, et l'objectif de la session. | ||
| 32 | +- **Tout résumer, tours récents compris.** Écarté : le modèle refait alors ses dernières étapes. | ||
| 33 | +- **Une clé `context.contextWindow` séparée.** Écartée au profit de la seule clé de premier niveau. | ||
| 34 | + | ||
| 35 | +## Liens avec le reste | ||
| 36 | + | ||
| 37 | +- L'activer et lire le rapport : [comment garder une longue session dans la fenêtre de contexte](../how-to/manage-the-context-window.md). | ||
| 38 | +- Les clés `context.*` : [référence de la configuration](../reference/configuration.md). | ||
| 39 | +- D'où vient la fenêtre : [fournisseurs](providers.md). | ||
added
docs/fr/explanation/providers.md +31 -0 | new file mode 100644 | ||
| @@ -0,0 +1,31 @@ | ||
| 1 | +# Fournisseurs : une implémentation par protocole — explication | |
| 2 | + | |
| 3 | +## De quoi s'agit-il ? | |
| 4 | + | |
| 5 | +`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Deux serveurs sont pris en charge d'origine, Docker Model Runner et `llama-server` de llama.cpp, sous les noms `dmr` et `llamacpp`. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé. | |
| 6 | + | |
| 7 | +## Pourquoi c'est conçu ainsi | |
| 8 | + | |
| 9 | +**Un type de fournisseur par protocole réseau, pas par éditeur.** Quand le moteur a été extrait, exactement une fonction et deux constantes de chaîne étaient spécifiques à Docker Model Runner. Tout le reste, le streaming, le watchdog, la nouvelle tentative, le compteur de commandes, se comportait de la même façon face à un faux moteur qui n'est pas DMR non plus. Donc `dmr` et `llamacpp` sont deux entrées de données d'un seul type `openaiCompat` : une URL de base par défaut, une URL de repli optionnelle, une politique de clé, et les mots employés dans les messages d'erreur. Ajouter un autre serveur compatible OpenAI est une entrée de registre ; ajouter un protocole différent serait un nouveau type. | |
| 10 | + | |
| 11 | +**Un fournisseur possède quatre choses.** Il résout la configuration en un backend concret (URL après surcharges d'environnement et sonde de repli, clé lue dans l'environnement, nom du modèle) ; il ouvre Genkit avec son plugin ; il sonde le serveur avant la première question ; et il explique une erreur de transport en une ligne. | |
| 12 | + | |
| 13 | +**Le repli existe pour les conteneurs.** Sur l'hôte, Docker Model Runner écoute sur `localhost:12434` ; depuis un conteneur ou une sandbox, le même serveur est joignable en `host.docker.internal`. Si l'URL principale ne répond pas sur `/models` en deux secondes, le repli est utilisé. Une surcharge explicite par l'environnement saute la sonde : qui l'a posée sait où est le serveur. Un `fallback: ""` explicite le désactive, ce dont une configuration de test a besoin pour rester épinglée à une seule URL. | |
| 14 | + | |
| 15 | +**La clé ne va jamais dans le YAML.** Le fichier nomme la variable d'environnement qui contient la clé, ce qui permet de le committer et de le montrer à l'écran. Les serveurs qui ignorent l'en-tête reçoivent une valeur factice, parce que le client OpenAI veut quelque chose à cet endroit. Un 401 est alors expliqué en nommant la variable que le fournisseur a réellement lue, ou, quand aucune n'est configurée, en pointant l'URL. | |
| 16 | + | |
| 17 | +**La sonde est un conseil, jamais un échec.** Sur une machine de démo, le serveur est souvent démarré après l'agent. La sonde vérifie la joignabilité, avertit quand le modèle configuré n'est pas dans la liste de DMR, et sur llama.cpp lit la taille de contexte servie sur `/props`, un niveau au-dessus de `/v1`, parce que ce point d'accès rapporte ce que le serveur sert, pas ce sur quoi le modèle a été entraîné. Quand la fenêtre est encore inconnue et que la compression est activée, le REPL sonde à nouveau avant la première question. | |
| 18 | + | |
| 19 | +**Les erreurs sont réécrites dans les mots du serveur.** Une pile de messages imbriqués est du bruit ; « tool calls need llama-server started with --jinja » est tout le diagnostic. Le fournisseur reconnaît les refus de connexion, l'option `--jinja` manquante, les 401/403, les 404 avec une indication de pull ou d'alias, les 429 et les 5xx. Les erreurs inconnues reviennent inchangées : un message brut vaut mieux qu'une mauvaise piste. | |
| 20 | + | |
| 21 | +## Alternatives écartées | |
| 22 | + | |
| 23 | +- **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants. | |
| 24 | +- **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré. | |
| 25 | +- **Échouer au démarrage quand le serveur est arrêté.** Écarté parce que démarrer le serveur en second est la situation normale d'une démo. | |
| 26 | + | |
| 27 | +## Liens avec le reste | |
| 28 | + | |
| 29 | +- Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md). | |
| 30 | +- La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure. | |
| 31 | +- Changer de serveur pour une exécution est couvert dans [comment faire tourner sur llama.cpp](../how-to/run-with-llama-cpp.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | +# Fournisseurs : une implémentation par protocole — explication | ||
| 2 | + | ||
| 3 | +## De quoi s'agit-il ? | ||
| 4 | + | ||
| 5 | +`mm` dialogue avec un serveur de modèle local via l'API chat-completions d'OpenAI. Deux serveurs sont pris en charge d'origine, Docker Model Runner et `llama-server` de llama.cpp, sous les noms `dmr` et `llamacpp`. Le paquet moteur était autrefois lié à Docker Model Runner ; l'abstraction de fournisseur est ce qui l'a généralisé. | ||
| 6 | + | ||
| 7 | +## Pourquoi c'est conçu ainsi | ||
| 8 | + | ||
| 9 | +**Un type de fournisseur par protocole réseau, pas par éditeur.** Quand le moteur a été extrait, exactement une fonction et deux constantes de chaîne étaient spécifiques à Docker Model Runner. Tout le reste, le streaming, le watchdog, la nouvelle tentative, le compteur de commandes, se comportait de la même façon face à un faux moteur qui n'est pas DMR non plus. Donc `dmr` et `llamacpp` sont deux entrées de données d'un seul type `openaiCompat` : une URL de base par défaut, une URL de repli optionnelle, une politique de clé, et les mots employés dans les messages d'erreur. Ajouter un autre serveur compatible OpenAI est une entrée de registre ; ajouter un protocole différent serait un nouveau type. | ||
| 10 | + | ||
| 11 | +**Un fournisseur possède quatre choses.** Il résout la configuration en un backend concret (URL après surcharges d'environnement et sonde de repli, clé lue dans l'environnement, nom du modèle) ; il ouvre Genkit avec son plugin ; il sonde le serveur avant la première question ; et il explique une erreur de transport en une ligne. | ||
| 12 | + | ||
| 13 | +**Le repli existe pour les conteneurs.** Sur l'hôte, Docker Model Runner écoute sur `localhost:12434` ; depuis un conteneur ou une sandbox, le même serveur est joignable en `host.docker.internal`. Si l'URL principale ne répond pas sur `/models` en deux secondes, le repli est utilisé. Une surcharge explicite par l'environnement saute la sonde : qui l'a posée sait où est le serveur. Un `fallback: ""` explicite le désactive, ce dont une configuration de test a besoin pour rester épinglée à une seule URL. | ||
| 14 | + | ||
| 15 | +**La clé ne va jamais dans le YAML.** Le fichier nomme la variable d'environnement qui contient la clé, ce qui permet de le committer et de le montrer à l'écran. Les serveurs qui ignorent l'en-tête reçoivent une valeur factice, parce que le client OpenAI veut quelque chose à cet endroit. Un 401 est alors expliqué en nommant la variable que le fournisseur a réellement lue, ou, quand aucune n'est configurée, en pointant l'URL. | ||
| 16 | + | ||
| 17 | +**La sonde est un conseil, jamais un échec.** Sur une machine de démo, le serveur est souvent démarré après l'agent. La sonde vérifie la joignabilité, avertit quand le modèle configuré n'est pas dans la liste de DMR, et sur llama.cpp lit la taille de contexte servie sur `/props`, un niveau au-dessus de `/v1`, parce que ce point d'accès rapporte ce que le serveur sert, pas ce sur quoi le modèle a été entraîné. Quand la fenêtre est encore inconnue et que la compression est activée, le REPL sonde à nouveau avant la première question. | ||
| 18 | + | ||
| 19 | +**Les erreurs sont réécrites dans les mots du serveur.** Une pile de messages imbriqués est du bruit ; « tool calls need llama-server started with --jinja » est tout le diagnostic. Le fournisseur reconnaît les refus de connexion, l'option `--jinja` manquante, les 401/403, les 404 avec une indication de pull ou d'alias, les 429 et les 5xx. Les erreurs inconnues reviennent inchangées : un message brut vaut mieux qu'une mauvaise piste. | ||
| 20 | + | ||
| 21 | +## Alternatives écartées | ||
| 22 | + | ||
| 23 | +- **Un fournisseur par éditeur avec du code moteur dupliqué.** Écarté : mesurée, la partie spécifique à l'éditeur tenait en trois identifiants. | ||
| 24 | +- **Mettre la clé d'API dans le fichier de configuration.** Écarté parce que le fichier est fait pour être committé et montré. | ||
| 25 | +- **Échouer au démarrage quand le serveur est arrêté.** Écarté parce que démarrer le serveur en second est la situation normale d'une démo. | ||
| 26 | + | ||
| 27 | +## Liens avec le reste | ||
| 28 | + | ||
| 29 | +- Les deux fichiers de configuration qui paramètrent chaque fournisseur sont listés dans la [référence de la configuration](../reference/configuration.md). | ||
| 30 | +- La fenêtre apprise par la sonde est ce contre quoi la [compression du contexte](context-compression.md) mesure. | ||
| 31 | +- Changer de serveur pour une exécution est couvert dans [comment faire tourner sur llama.cpp](../how-to/run-with-llama-cpp.md). | ||
added
docs/fr/how-to/add-a-skill.md +36 -0 | new file mode 100644 | ||
| @@ -0,0 +1,36 @@ | ||
| 1 | +# Comment ajouter un skill | |
| 2 | + | |
| 3 | +Ce guide montre comment donner à l'agent une nouvelle procédure en markdown que le modèle peut charger avec `read_skill`. Il suppose que vous savez quelle tâche la procédure doit couvrir. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Créez le skill dans le répertoire des skills (`skills/` par défaut, voir `skillsDir`), dans l'une des deux dispositions : un fichier à plat `skills/<nom>.md`, ou un répertoire par skill `skills/<nom>/SKILL.md` (la disposition des exemples livrés). Les deux peuvent se mélanger ; tout autre fichier dans le répertoire d'un skill est ignoré. | |
| 8 | + | |
| 9 | + ```bash | |
| 10 | + cat > skills/go-test.md <<'EOF2' | |
| 11 | + --- | |
| 12 | + name: go-test | |
| 13 | + description: run the Go tests of the current module and report failures | |
| 14 | + --- | |
| 15 | + # Run the Go tests | |
| 16 | + | |
| 17 | + Run `go test ./...` with your `bash` tool, then report the failing packages, if any. | |
| 18 | + EOF2 | |
| 19 | + ``` | |
| 20 | + | |
| 21 | +2. Donnez-lui un `name:` et une `description:` d'une ligne dans l'en-tête. La description est ce que le modèle lit dans la description de l'outil `read_skill` pour choisir un skill ; sans elle, il choisit à l'aveugle. | |
| 22 | + | |
| 23 | +3. Relancez `mm`. Le catalogue est construit une fois au démarrage. Le compteur `skills: N` de la bannière augmente de un, et `read_skill` apparaît dans la liste `tools:` s'il en était absent. | |
| 24 | + | |
| 25 | +4. Demandez la tâche dans le terminal. Le prompt système d'`agent.yaml` dit au modèle d'appeler `read_skill` d'abord pour toute demande qui correspond au catalogue ; une ligne `📖 read_skill: go-test` confirme qu'il l'a fait. | |
| 26 | + | |
| 27 | +## Variantes | |
| 28 | + | |
| 29 | +- **Un autre répertoire.** Fixez `skillsDir` dans le YAML. Dans le terminal, un chemin relatif est résolu depuis le répertoire d'où vous lancez `mm`, donc un `mm` installé et lancé dans un projet charge les skills de ce projet ; en mode ACP avec un fichier de configuration, il est résolu à côté de ce fichier. La bannière affiche `[warning: no skills found in <chemin absolu> …]` quand le répertoire résolu ne donne rien. | |
| 30 | +- **Aucun skill.** Laissez le répertoire vide ou absent : `read_skill` n'est alors pas déclaré, et le modèle ne le voit jamais. | |
| 31 | +- **Les exemples livrés.** Le dépôt livre `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` et `skills/what-time-is-it/SKILL.md`. Lancez `mm` depuis la racine du dépôt et la bannière affiche `skills: 3`. | |
| 32 | + | |
| 33 | +## Voir aussi | |
| 34 | + | |
| 35 | +- Les champs acceptés dans l'en-tête : [format d'un fichier de skill](../reference/skill-format.md) | |
| 36 | +- Ce que `read_skill` renvoie au modèle : [outils intégrés](../reference/tools.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,36 @@ | |||
| 1 | +# Comment ajouter un skill | ||
| 2 | + | ||
| 3 | +Ce guide montre comment donner à l'agent une nouvelle procédure en markdown que le modèle peut charger avec `read_skill`. Il suppose que vous savez quelle tâche la procédure doit couvrir. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Créez le skill dans le répertoire des skills (`skills/` par défaut, voir `skillsDir`), dans l'une des deux dispositions : un fichier à plat `skills/<nom>.md`, ou un répertoire par skill `skills/<nom>/SKILL.md` (la disposition des exemples livrés). Les deux peuvent se mélanger ; tout autre fichier dans le répertoire d'un skill est ignoré. | ||
| 8 | + | ||
| 9 | + ```bash | ||
| 10 | + cat > skills/go-test.md <<'EOF2' | ||
| 11 | + --- | ||
| 12 | + name: go-test | ||
| 13 | + description: run the Go tests of the current module and report failures | ||
| 14 | + --- | ||
| 15 | + # Run the Go tests | ||
| 16 | + | ||
| 17 | + Run `go test ./...` with your `bash` tool, then report the failing packages, if any. | ||
| 18 | + EOF2 | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | +2. Donnez-lui un `name:` et une `description:` d'une ligne dans l'en-tête. La description est ce que le modèle lit dans la description de l'outil `read_skill` pour choisir un skill ; sans elle, il choisit à l'aveugle. | ||
| 22 | + | ||
| 23 | +3. Relancez `mm`. Le catalogue est construit une fois au démarrage. Le compteur `skills: N` de la bannière augmente de un, et `read_skill` apparaît dans la liste `tools:` s'il en était absent. | ||
| 24 | + | ||
| 25 | +4. Demandez la tâche dans le terminal. Le prompt système d'`agent.yaml` dit au modèle d'appeler `read_skill` d'abord pour toute demande qui correspond au catalogue ; une ligne `📖 read_skill: go-test` confirme qu'il l'a fait. | ||
| 26 | + | ||
| 27 | +## Variantes | ||
| 28 | + | ||
| 29 | +- **Un autre répertoire.** Fixez `skillsDir` dans le YAML. Dans le terminal, un chemin relatif est résolu depuis le répertoire d'où vous lancez `mm`, donc un `mm` installé et lancé dans un projet charge les skills de ce projet ; en mode ACP avec un fichier de configuration, il est résolu à côté de ce fichier. La bannière affiche `[warning: no skills found in <chemin absolu> …]` quand le répertoire résolu ne donne rien. | ||
| 30 | +- **Aucun skill.** Laissez le répertoire vide ou absent : `read_skill` n'est alors pas déclaré, et le modèle ne le voit jamais. | ||
| 31 | +- **Les exemples livrés.** Le dépôt livre `skills/greetings/SKILL.md`, `skills/vulcan-salute/SKILL.md` et `skills/what-time-is-it/SKILL.md`. Lancez `mm` depuis la racine du dépôt et la bannière affiche `skills: 3`. | ||
| 32 | + | ||
| 33 | +## Voir aussi | ||
| 34 | + | ||
| 35 | +- Les champs acceptés dans l'en-tête : [format d'un fichier de skill](../reference/skill-format.md) | ||
| 36 | +- Ce que `read_skill` renvoie au modèle : [outils intégrés](../reference/tools.md) | ||
added
docs/fr/how-to/attach-a-file.md +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +# Désigner un fichier au modèle avec `@chemin` | |
| 2 | + | |
| 3 | +Nommez un fichier ou un répertoire dans votre question avec `@` suivi de son chemin. mini-me vérifie que le chemin existe, indique au modèle où il se trouve, et le modèle le lit avec ses outils. Il n'y a ni sélecteur ni complétion : vous tapez le chemin. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Lancez `mm` depuis le répertoire du projet. Les chemins relatifs sont résolus depuis là. | |
| 8 | +2. Écrivez le chemin dans la question, collé à `@` : | |
| 9 | + | |
| 10 | + ``` | |
| 11 | + > explique ce que fait @internal/acp/acp.go avec @internal/session | |
| 12 | + 📎 /work/mini-me/internal/acp/acp.go | |
| 13 | + 📎 /work/mini-me/internal/session | |
| 14 | + ``` | |
| 15 | + | |
| 16 | + Une ligne `📎` par chemin attaché confirme que la notation a été comprise. Le modèle reçoit votre phrase telle que tapée, suivie d'une ligne par chemin : | |
| 17 | + | |
| 18 | + ``` | |
| 19 | + [attached file: /work/mini-me/internal/acp/acp.go] | |
| 20 | + [attached directory: /work/mini-me/internal/session] | |
| 21 | + ``` | |
| 22 | + | |
| 23 | +3. Posez votre question. Le contenu n'est pas copié dans le prompt : le modèle exécute `cat`, `read_file` ou `ls` sur le chemin quand il en a besoin. | |
| 24 | + | |
| 25 | +## Règles | |
| 26 | + | |
| 27 | +| Cas | Résultat | | |
| 28 | +|-----|----------| | |
| 29 | +| `@internal/acp/acp.go` | Relatif au répertoire de lancement de `mm` (le `cwd` de la session en mode ACP). | | |
| 30 | +| `@/chemin/absolu`, `@~/fichier` | Les chemins absolus et `~` fonctionnent. | | |
| 31 | +| `@main.go,` `(@main.go)` `"@main.go"` | La ponctuation et les parenthèses autour de la mention sont ignorées. | | |
| 32 | +| `@bob`, `@n/existe/pas` | Rien sur le disque : laissé tel quel dans la phrase, aucune pièce jointe. | | |
| 33 | +| `k33g@example.com` | Un `@` collé à un mot n'est pas une mention. | | |
| 34 | +| Même chemin deux fois | Attaché une seule fois. | | |
| 35 | +| Chemin avec espaces | Non pris en charge : une mention s'arrête au premier espace. | | |
| 36 | + | |
| 37 | +## Dans un éditeur (ACP) | |
| 38 | + | |
| 39 | +Le sélecteur `@` de l'éditeur envoie le fichier comme `resource_link`, que mini-me transforme en la même ligne `[attached file: …]`. Un chemin tapé à la main, sans le sélecteur, est compris de la même façon, relativement au projet ouvert dans l'éditeur. Les chemins attachés sont journalisés sur stderr sous la forme `[acp] session <id>: attached <chemin>`. | |
| 40 | + | |
| 41 | +## Voir aussi | |
| 42 | + | |
| 43 | +- [Outils intégrés](../reference/tools.md) — ce avec quoi le modèle lit le fichier | |
| 44 | +- [Utiliser mini-me depuis un éditeur](use-from-an-editor.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +# Désigner un fichier au modèle avec `@chemin` | ||
| 2 | + | ||
| 3 | +Nommez un fichier ou un répertoire dans votre question avec `@` suivi de son chemin. mini-me vérifie que le chemin existe, indique au modèle où il se trouve, et le modèle le lit avec ses outils. Il n'y a ni sélecteur ni complétion : vous tapez le chemin. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Lancez `mm` depuis le répertoire du projet. Les chemins relatifs sont résolus depuis là. | ||
| 8 | +2. Écrivez le chemin dans la question, collé à `@` : | ||
| 9 | + | ||
| 10 | + ``` | ||
| 11 | + > explique ce que fait @internal/acp/acp.go avec @internal/session | ||
| 12 | + 📎 /work/mini-me/internal/acp/acp.go | ||
| 13 | + 📎 /work/mini-me/internal/session | ||
| 14 | + ``` | ||
| 15 | + | ||
| 16 | + Une ligne `📎` par chemin attaché confirme que la notation a été comprise. Le modèle reçoit votre phrase telle que tapée, suivie d'une ligne par chemin : | ||
| 17 | + | ||
| 18 | + ``` | ||
| 19 | + [attached file: /work/mini-me/internal/acp/acp.go] | ||
| 20 | + [attached directory: /work/mini-me/internal/session] | ||
| 21 | + ``` | ||
| 22 | + | ||
| 23 | +3. Posez votre question. Le contenu n'est pas copié dans le prompt : le modèle exécute `cat`, `read_file` ou `ls` sur le chemin quand il en a besoin. | ||
| 24 | + | ||
| 25 | +## Règles | ||
| 26 | + | ||
| 27 | +| Cas | Résultat | | ||
| 28 | +|-----|----------| | ||
| 29 | +| `@internal/acp/acp.go` | Relatif au répertoire de lancement de `mm` (le `cwd` de la session en mode ACP). | | ||
| 30 | +| `@/chemin/absolu`, `@~/fichier` | Les chemins absolus et `~` fonctionnent. | | ||
| 31 | +| `@main.go,` `(@main.go)` `"@main.go"` | La ponctuation et les parenthèses autour de la mention sont ignorées. | | ||
| 32 | +| `@bob`, `@n/existe/pas` | Rien sur le disque : laissé tel quel dans la phrase, aucune pièce jointe. | | ||
| 33 | +| `k33g@example.com` | Un `@` collé à un mot n'est pas une mention. | | ||
| 34 | +| Même chemin deux fois | Attaché une seule fois. | | ||
| 35 | +| Chemin avec espaces | Non pris en charge : une mention s'arrête au premier espace. | | ||
| 36 | + | ||
| 37 | +## Dans un éditeur (ACP) | ||
| 38 | + | ||
| 39 | +Le sélecteur `@` de l'éditeur envoie le fichier comme `resource_link`, que mini-me transforme en la même ligne `[attached file: …]`. Un chemin tapé à la main, sans le sélecteur, est compris de la même façon, relativement au projet ouvert dans l'éditeur. Les chemins attachés sont journalisés sur stderr sous la forme `[acp] session <id>: attached <chemin>`. | ||
| 40 | + | ||
| 41 | +## Voir aussi | ||
| 42 | + | ||
| 43 | +- [Outils intégrés](../reference/tools.md) — ce avec quoi le modèle lit le fichier | ||
| 44 | +- [Utiliser mini-me depuis un éditeur](use-from-an-editor.md) | ||
added
docs/fr/how-to/manage-the-context-window.md +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +# Comment garder une longue session dans la fenêtre de contexte | |
| 2 | + | |
| 3 | +Ce guide montre comment empêcher une longue session de déborder du contexte du modèle. Il suppose que vous faites déjà tourner `mm` et que vous savez quel serveur sert votre modèle. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Indiquez à l'agent la taille de la fenêtre. Soit vous fixez `contextWindow` dans le YAML à la valeur avec laquelle vous avez démarré le serveur, soit vous le laissez à `0` sur llama.cpp, où l'agent lit `n_ctx` sur `/props`. La bannière affiche le résultat sous la forme `ctx: <taille> (config)` ou `ctx: <taille> (/props)`. | |
| 8 | + | |
| 9 | +2. Activez la compression automatique : | |
| 10 | + | |
| 11 | + ```yaml | |
| 12 | + context: | |
| 13 | + enabled: true | |
| 14 | + threshold: 75 | |
| 15 | + keepLastTurns: 3 | |
| 16 | + ``` | |
| 17 | + | |
| 18 | + Avant chaque question, quand l'historique atteint 75 % de la fenêtre, les anciens tours sont remplacés par un résumé écrit par le modèle et les trois derniers tours de question sont conservés tels quels. | |
| 19 | + | |
| 20 | +3. Observez le rapport. Avec `showStats: true` (la valeur par défaut), chaque compression affiche une ligne grisée : | |
| 21 | + | |
| 22 | + ``` | |
| 23 | + 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s) | |
| 24 | + ``` | |
| 25 | + | |
| 26 | +## Variantes | |
| 27 | + | |
| 28 | +- **La fenêtre reste inconnue** (Docker Model Runner ne dit rien, et `contextWindow` vaut `0`). Seul le déclencheur `maxMessages` peut se déclencher ; l'agent le signale par un avertissement au démarrage. Fixez `maxMessages` à un nombre de messages (une commande coûte deux messages) ou fixez `contextWindow`. | |
| 29 | +- **Compresser à la main.** Tapez `/compact` à l'invite, ou envoyez-la depuis l'éditeur en mode ACP. La commande ignore le seuil mais conserve toujours les `keepLastTurns` derniers tours, et affiche `🗜️ nothing to compact` quand rien n'est plus ancien. | |
| 30 | +- **Le serveur a été démarré après l'agent.** La fenêtre est re-sondée avant la première question tant qu'elle est inconnue et que la compression est activée ; pas besoin de relancer. | |
| 31 | +- **Votre propre prompt de résumé.** Fixez `context.prompt` pour remplacer le prompt intégré en sept sections. | |
| 32 | +- **Une compression qui échoue** (serveur arrêté, watchdog) laisse l'historique inchangé et affiche `[compact: failed, history kept: …]`. | |
| 33 | + | |
| 34 | +## Voir aussi | |
| 35 | + | |
| 36 | +- Chaque clé `context.*` et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) | |
| 37 | +- Pourquoi le résumé garde les tours récents bruts et fusionne les résumés antérieurs : [compression du contexte](../explanation/context-compression.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +# Comment garder une longue session dans la fenêtre de contexte | ||
| 2 | + | ||
| 3 | +Ce guide montre comment empêcher une longue session de déborder du contexte du modèle. Il suppose que vous faites déjà tourner `mm` et que vous savez quel serveur sert votre modèle. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Indiquez à l'agent la taille de la fenêtre. Soit vous fixez `contextWindow` dans le YAML à la valeur avec laquelle vous avez démarré le serveur, soit vous le laissez à `0` sur llama.cpp, où l'agent lit `n_ctx` sur `/props`. La bannière affiche le résultat sous la forme `ctx: <taille> (config)` ou `ctx: <taille> (/props)`. | ||
| 8 | + | ||
| 9 | +2. Activez la compression automatique : | ||
| 10 | + | ||
| 11 | + ```yaml | ||
| 12 | + context: | ||
| 13 | + enabled: true | ||
| 14 | + threshold: 75 | ||
| 15 | + keepLastTurns: 3 | ||
| 16 | + ``` | ||
| 17 | + | ||
| 18 | + Avant chaque question, quand l'historique atteint 75 % de la fenêtre, les anciens tours sont remplacés par un résumé écrit par le modèle et les trois derniers tours de question sont conservés tels quels. | ||
| 19 | + | ||
| 20 | +3. Observez le rapport. Avec `showStats: true` (la valeur par défaut), chaque compression affiche une ligne grisée : | ||
| 21 | + | ||
| 22 | + ``` | ||
| 23 | + 🗜️ compressed 14 messages → 1 summary + 7 kept (18.2k → 4.1k tokens, 6.3s) | ||
| 24 | + ``` | ||
| 25 | + | ||
| 26 | +## Variantes | ||
| 27 | + | ||
| 28 | +- **La fenêtre reste inconnue** (Docker Model Runner ne dit rien, et `contextWindow` vaut `0`). Seul le déclencheur `maxMessages` peut se déclencher ; l'agent le signale par un avertissement au démarrage. Fixez `maxMessages` à un nombre de messages (une commande coûte deux messages) ou fixez `contextWindow`. | ||
| 29 | +- **Compresser à la main.** Tapez `/compact` à l'invite, ou envoyez-la depuis l'éditeur en mode ACP. La commande ignore le seuil mais conserve toujours les `keepLastTurns` derniers tours, et affiche `🗜️ nothing to compact` quand rien n'est plus ancien. | ||
| 30 | +- **Le serveur a été démarré après l'agent.** La fenêtre est re-sondée avant la première question tant qu'elle est inconnue et que la compression est activée ; pas besoin de relancer. | ||
| 31 | +- **Votre propre prompt de résumé.** Fixez `context.prompt` pour remplacer le prompt intégré en sept sections. | ||
| 32 | +- **Une compression qui échoue** (serveur arrêté, watchdog) laisse l'historique inchangé et affiche `[compact: failed, history kept: …]`. | ||
| 33 | + | ||
| 34 | +## Voir aussi | ||
| 35 | + | ||
| 36 | +- Chaque clé `context.*` et sa valeur par défaut : [référence de la configuration](../reference/configuration.md) | ||
| 37 | +- Pourquoi le résumé garde les tours récents bruts et fusionne les résumés antérieurs : [compression du contexte](../explanation/context-compression.md) | ||
added
docs/fr/how-to/run-with-llama-cpp.md +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +# Comment faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner | |
| 2 | + | |
| 3 | +Ce guide montre comment pointer `mm` vers un `llama-server` que vous démarrez vous-même. Il suppose que vous savez déjà compiler `mm` et obtenir un modèle GGUF. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Démarrez `llama-server` avec un template de chat et l'appel d'outils activé. `--jinja` est obligatoire : sans lui, le serveur refuse le paramètre `tools` et l'agent ne peut exécuter aucune commande. Donnez au modèle servi un alias avec `-a`, car `llama-server` route les requêtes sur le champ `model` : | |
| 8 | + | |
| 9 | + ```bash | |
| 10 | + llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | |
| 11 | + -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | |
| 12 | + --jinja -c 32768 --port 8080 | |
| 13 | + ``` | |
| 14 | + | |
| 15 | +2. Utilisez le fichier de configuration llama.cpp livré. Sa clé `model:` doit correspondre à l'alias `-a` ci-dessus : | |
| 16 | + | |
| 17 | + ```bash | |
| 18 | + ./mm agent.llamacpp.yaml | |
| 19 | + ``` | |
| 20 | + | |
| 21 | +3. Vérifiez la bannière. `provider: llamacpp` confirme le fournisseur, et `ctx: 32768 (/props)` confirme que l'agent a lu la taille de contexte servie sur le point d'accès `/props` du serveur. | |
| 22 | + | |
| 23 | +## Variantes | |
| 24 | + | |
| 25 | +- **Autre port ou autre hôte.** Mettez `baseUrl` à `http://<hôte>:<port>/v1` dans le YAML, ou surchargez pour une seule exécution avec `AGENT_BASE_URL=http://<hôte>:<port>/v1 ./mm agent.llamacpp.yaml`. Une surcharge par l'environnement est prise telle quelle, sans sonde de repli. | |
| 26 | +- **Serveur démarré avec `--api-key`.** Exportez la clé dans `LLAMA_API_KEY` (la variable par défaut du fournisseur) ou nommez une autre variable dans `apiKeyEnv`. La clé elle-même ne va jamais dans le YAML. | |
| 27 | +- **Garder `agent.yaml` et changer de fournisseur pour une exécution.** `AGENT_PROVIDER=llamacpp ./mm` fonctionne tant qu'`agent.yaml` laisse `baseUrl` vide ; une URL DMR explicite dans le fichier serait respectée et pointerait toujours sur le port 12434. | |
| 28 | +- **Le serveur masque `/props`** (un reverse proxy, par exemple). Fixez `contextWindow` à la valeur avec laquelle vous avez démarré le serveur, sinon la bannière affiche `ctx: unknown`. | |
| 29 | + | |
| 30 | +## Voir aussi | |
| 31 | + | |
| 32 | +- Chaque clé du fichier : [référence de la configuration](../reference/configuration.md) | |
| 33 | +- Pourquoi une seule implémentation sert les deux serveurs : [fournisseurs](../explanation/providers.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +# Comment faire tourner mini-me sur llama.cpp plutôt que Docker Model Runner | ||
| 2 | + | ||
| 3 | +Ce guide montre comment pointer `mm` vers un `llama-server` que vous démarrez vous-même. Il suppose que vous savez déjà compiler `mm` et obtenir un modèle GGUF. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Démarrez `llama-server` avec un template de chat et l'appel d'outils activé. `--jinja` est obligatoire : sans lui, le serveur refuse le paramètre `tools` et l'agent ne peut exécuter aucune commande. Donnez au modèle servi un alias avec `-a`, car `llama-server` route les requêtes sur le champ `model` : | ||
| 8 | + | ||
| 9 | + ```bash | ||
| 10 | + llama-server -hf jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | ||
| 11 | + -a jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M \ | ||
| 12 | + --jinja -c 32768 --port 8080 | ||
| 13 | + ``` | ||
| 14 | + | ||
| 15 | +2. Utilisez le fichier de configuration llama.cpp livré. Sa clé `model:` doit correspondre à l'alias `-a` ci-dessus : | ||
| 16 | + | ||
| 17 | + ```bash | ||
| 18 | + ./mm agent.llamacpp.yaml | ||
| 19 | + ``` | ||
| 20 | + | ||
| 21 | +3. Vérifiez la bannière. `provider: llamacpp` confirme le fournisseur, et `ctx: 32768 (/props)` confirme que l'agent a lu la taille de contexte servie sur le point d'accès `/props` du serveur. | ||
| 22 | + | ||
| 23 | +## Variantes | ||
| 24 | + | ||
| 25 | +- **Autre port ou autre hôte.** Mettez `baseUrl` à `http://<hôte>:<port>/v1` dans le YAML, ou surchargez pour une seule exécution avec `AGENT_BASE_URL=http://<hôte>:<port>/v1 ./mm agent.llamacpp.yaml`. Une surcharge par l'environnement est prise telle quelle, sans sonde de repli. | ||
| 26 | +- **Serveur démarré avec `--api-key`.** Exportez la clé dans `LLAMA_API_KEY` (la variable par défaut du fournisseur) ou nommez une autre variable dans `apiKeyEnv`. La clé elle-même ne va jamais dans le YAML. | ||
| 27 | +- **Garder `agent.yaml` et changer de fournisseur pour une exécution.** `AGENT_PROVIDER=llamacpp ./mm` fonctionne tant qu'`agent.yaml` laisse `baseUrl` vide ; une URL DMR explicite dans le fichier serait respectée et pointerait toujours sur le port 12434. | ||
| 28 | +- **Le serveur masque `/props`** (un reverse proxy, par exemple). Fixez `contextWindow` à la valeur avec laquelle vous avez démarré le serveur, sinon la bannière affiche `ctx: unknown`. | ||
| 29 | + | ||
| 30 | +## Voir aussi | ||
| 31 | + | ||
| 32 | +- Chaque clé du fichier : [référence de la configuration](../reference/configuration.md) | ||
| 33 | +- Pourquoi une seule implémentation sert les deux serveurs : [fournisseurs](../explanation/providers.md) | ||
added
docs/fr/how-to/start-a-new-session.md +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +# Comment démarrer une nouvelle session sans relancer `mm` | |
| 2 | + | |
| 3 | +Ce guide montre comment effacer la conversation et repartir d'un historique vide, dans le terminal et depuis un éditeur. Il suppose que `mm` tourne et a déjà répondu à au moins une question. | |
| 4 | + | |
| 5 | +## Dans le terminal | |
| 6 | + | |
| 7 | +1. Attendez l'invite (`> `). La commande n'agit qu'entre deux générations ; tapée pendant que le modèle travaille, elle annule d'abord cette génération, puis s'applique. | |
| 8 | + | |
| 9 | +2. Tapez la commande : | |
| 10 | + | |
| 11 | + ```text | |
| 12 | + > /new | |
| 13 | + 🆕 new session: 12 message(s) forgotten | |
| 14 | + ``` | |
| 15 | + | |
| 16 | + Le compte porte sur tous les messages sauf le prompt système : vos questions, les réponses du modèle et les tours d'outils entre les deux. Sur une session déjà vide, il affiche `0 message(s) forgotten`. | |
| 17 | + | |
| 18 | +3. Posez votre question suivante. Le modèle ne voit que le prompt système et cette question ; rien d'antérieur à `/new` ne lui est envoyé. | |
| 19 | + | |
| 20 | +## Depuis un éditeur (ACP) | |
| 21 | + | |
| 22 | +1. Tapez `/new` dans le panneau de l'agent. Les éditeurs qui listent les commandes de l'agent la proposent dans le menu des commandes slash avec la description *Clear the history and start a new session* ; la taper à la main fonctionne aussi. | |
| 23 | + | |
| 24 | +2. Lisez la confirmation dans le fil : `🆕 New session: 12 message(s) forgotten.` Le tour s'arrête là, le modèle n'est pas consulté. | |
| 25 | + | |
| 26 | +3. Continuez à travailler dans le même fil. La session garde son identifiant, son répertoire de travail et les outils que vous avez déjà autorisés « pour cette session » ; seul l'historique disparaît. | |
| 27 | + | |
| 28 | +## Ce que `/new` réinitialise, et ce qu'il conserve | |
| 29 | + | |
| 30 | +| Réinitialisé | Conservé | | |
| 31 | +|--------------|----------| | |
| 32 | +| L'historique de la conversation (retour au seul prompt système). | La configuration, le modèle, les outils. | | |
| 33 | +| La mémoire du détecteur de boucles sur les commandes répétées (terminal). | Le répertoire de travail. | | |
| 34 | +| Le compte de tokens du dernier contexte mesuré par le serveur, qui sert à décider la compression automatique. | En mode ACP, l'identifiant de session et les autorisations « toujours autoriser ». | | |
| 35 | + | |
| 36 | +## Voir aussi | |
| 37 | + | |
| 38 | +- [Référence des commandes slash](../reference/slash-commands.md) — chaque commande, à l'invite et pendant une génération. | |
| 39 | +- [Référence ACP](../reference/acp.md) — comment la commande est annoncée et traitée par le protocole. | |
| 40 | +- [Garder une longue session dans la fenêtre de contexte](manage-the-context-window.md) — `/compact` raccourcit l'historique sans l'oublier ; `/new` l'oublie. | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +# Comment démarrer une nouvelle session sans relancer `mm` | ||
| 2 | + | ||
| 3 | +Ce guide montre comment effacer la conversation et repartir d'un historique vide, dans le terminal et depuis un éditeur. Il suppose que `mm` tourne et a déjà répondu à au moins une question. | ||
| 4 | + | ||
| 5 | +## Dans le terminal | ||
| 6 | + | ||
| 7 | +1. Attendez l'invite (`> `). La commande n'agit qu'entre deux générations ; tapée pendant que le modèle travaille, elle annule d'abord cette génération, puis s'applique. | ||
| 8 | + | ||
| 9 | +2. Tapez la commande : | ||
| 10 | + | ||
| 11 | + ```text | ||
| 12 | + > /new | ||
| 13 | + 🆕 new session: 12 message(s) forgotten | ||
| 14 | + ``` | ||
| 15 | + | ||
| 16 | + Le compte porte sur tous les messages sauf le prompt système : vos questions, les réponses du modèle et les tours d'outils entre les deux. Sur une session déjà vide, il affiche `0 message(s) forgotten`. | ||
| 17 | + | ||
| 18 | +3. Posez votre question suivante. Le modèle ne voit que le prompt système et cette question ; rien d'antérieur à `/new` ne lui est envoyé. | ||
| 19 | + | ||
| 20 | +## Depuis un éditeur (ACP) | ||
| 21 | + | ||
| 22 | +1. Tapez `/new` dans le panneau de l'agent. Les éditeurs qui listent les commandes de l'agent la proposent dans le menu des commandes slash avec la description *Clear the history and start a new session* ; la taper à la main fonctionne aussi. | ||
| 23 | + | ||
| 24 | +2. Lisez la confirmation dans le fil : `🆕 New session: 12 message(s) forgotten.` Le tour s'arrête là, le modèle n'est pas consulté. | ||
| 25 | + | ||
| 26 | +3. Continuez à travailler dans le même fil. La session garde son identifiant, son répertoire de travail et les outils que vous avez déjà autorisés « pour cette session » ; seul l'historique disparaît. | ||
| 27 | + | ||
| 28 | +## Ce que `/new` réinitialise, et ce qu'il conserve | ||
| 29 | + | ||
| 30 | +| Réinitialisé | Conservé | | ||
| 31 | +|--------------|----------| | ||
| 32 | +| L'historique de la conversation (retour au seul prompt système). | La configuration, le modèle, les outils. | | ||
| 33 | +| La mémoire du détecteur de boucles sur les commandes répétées (terminal). | Le répertoire de travail. | | ||
| 34 | +| Le compte de tokens du dernier contexte mesuré par le serveur, qui sert à décider la compression automatique. | En mode ACP, l'identifiant de session et les autorisations « toujours autoriser ». | | ||
| 35 | + | ||
| 36 | +## Voir aussi | ||
| 37 | + | ||
| 38 | +- [Référence des commandes slash](../reference/slash-commands.md) — chaque commande, à l'invite et pendant une génération. | ||
| 39 | +- [Référence ACP](../reference/acp.md) — comment la commande est annoncée et traitée par le protocole. | ||
| 40 | +- [Garder une longue session dans la fenêtre de contexte](manage-the-context-window.md) — `/compact` raccourcit l'historique sans l'oublier ; `/new` l'oublie. | ||
added
docs/fr/how-to/use-from-an-editor.md +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +# Comment utiliser mini-me depuis un éditeur (ACP) | |
| 2 | + | |
| 3 | +Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client Protocol, comme Zed. Il suppose que `mm` est compilé et installé dans votre `PATH` (`./install.sh` le copie dans `/usr/local/bin`), et que votre serveur de modèle tourne. | |
| 4 | + | |
| 5 | +## Étapes | |
| 6 | + | |
| 7 | +1. Déclarez `mm` comme serveur d'agent dans l'éditeur, avec l'option `-acp`. Dans Zed, ajoutez à `settings.json` : | |
| 8 | + | |
| 9 | + ```json | |
| 10 | + { | |
| 11 | + "agent_servers": { | |
| 12 | + "mini-me": { | |
| 13 | + "type": "custom", | |
| 14 | + "command": "mm", | |
| 15 | + "args": ["-acp"], | |
| 16 | + "env": { "AGENT_CONFIG": "/chemin/absolu/vers/agent.yaml" } | |
| 17 | + } | |
| 18 | + } | |
| 19 | + } | |
| 20 | + ``` | |
| 21 | + | |
| 22 | +2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier, pas à côté du répertoire de travail de l'éditeur. | |
| 23 | + | |
| 24 | +3. Ouvrez le panneau d'agent et démarrez une conversation. Chaque appel à `bash`, `read_file`, `write_file` et `edit_file` apparaît comme un appel d'outil ; l'éditeur vous demande de l'autoriser avant exécution. `read_skill` s'exécute sans dialogue. | |
| 25 | + | |
| 26 | +4. Lisez les journaux quand quelque chose semble anormal. En mode ACP, rien de lisible par un humain ne va sur stdout : la bannière, les avertissements et une trace `[acp] …` vont sur stderr, que Zed affiche via `dev: open acp logs`. | |
| 27 | + | |
| 28 | +## Variantes | |
| 29 | + | |
| 30 | +- **Autoriser un outil pour toute la session.** Choisissez « Allow bash for this session » dans le dialogue de permission ; le choix est mémorisé par nom d'outil jusqu'à la fin de la session. | |
| 31 | +- **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP. | |
| 32 | +- **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp). | |
| 33 | +- **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils. | |
| 34 | +- **Lancer depuis un terminal pour déboguer le protocole.** `mm -acp` lit du JSON-RPC sur stdin et l'écrit sur stdout, un message par ligne. | |
| 35 | + | |
| 36 | +## Voir aussi | |
| 37 | + | |
| 38 | +- Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md) | |
| 39 | +- Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md) | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +# Comment utiliser mini-me depuis un éditeur (ACP) | ||
| 2 | + | ||
| 3 | +Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client Protocol, comme Zed. Il suppose que `mm` est compilé et installé dans votre `PATH` (`./install.sh` le copie dans `/usr/local/bin`), et que votre serveur de modèle tourne. | ||
| 4 | + | ||
| 5 | +## Étapes | ||
| 6 | + | ||
| 7 | +1. Déclarez `mm` comme serveur d'agent dans l'éditeur, avec l'option `-acp`. Dans Zed, ajoutez à `settings.json` : | ||
| 8 | + | ||
| 9 | + ```json | ||
| 10 | + { | ||
| 11 | + "agent_servers": { | ||
| 12 | + "mini-me": { | ||
| 13 | + "type": "custom", | ||
| 14 | + "command": "mm", | ||
| 15 | + "args": ["-acp"], | ||
| 16 | + "env": { "AGENT_CONFIG": "/chemin/absolu/vers/agent.yaml" } | ||
| 17 | + } | ||
| 18 | + } | ||
| 19 | + } | ||
| 20 | + ``` | ||
| 21 | + | ||
| 22 | +2. Donnez à `AGENT_CONFIG` un chemin absolu. L'éditeur lance `mm` avec le répertoire du projet comme répertoire de travail, donc un `./agent.yaml` relatif ne serait pas trouvé. En mode ACP, un `skillsDir` relatif dans ce fichier est résolu à côté du fichier, pas à côté du répertoire de travail de l'éditeur. | ||
| 23 | + | ||
| 24 | +3. Ouvrez le panneau d'agent et démarrez une conversation. Chaque appel à `bash`, `read_file`, `write_file` et `edit_file` apparaît comme un appel d'outil ; l'éditeur vous demande de l'autoriser avant exécution. `read_skill` s'exécute sans dialogue. | ||
| 25 | + | ||
| 26 | +4. Lisez les journaux quand quelque chose semble anormal. En mode ACP, rien de lisible par un humain ne va sur stdout : la bannière, les avertissements et une trace `[acp] …` vont sur stderr, que Zed affiche via `dev: open acp logs`. | ||
| 27 | + | ||
| 28 | +## Variantes | ||
| 29 | + | ||
| 30 | +- **Autoriser un outil pour toute la session.** Choisissez « Allow bash for this session » dans le dialogue de permission ; le choix est mémorisé par nom d'outil jusqu'à la fin de la session. | ||
| 31 | +- **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP. | ||
| 32 | +- **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp). | ||
| 33 | +- **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils. | ||
| 34 | +- **Lancer depuis un terminal pour déboguer le protocole.** `mm -acp` lit du JSON-RPC sur stdin et l'écrit sur stdout, un message par ligne. | ||
| 35 | + | ||
| 36 | +## Voir aussi | ||
| 37 | + | ||
| 38 | +- Les méthodes et capacités exactement exposées : [référence ACP](../reference/acp.md) | ||
| 39 | +- Pourquoi le terminal et l'éditeur partagent une seule boucle : [architecture](../explanation/architecture.md) | ||
added
docs/fr/reference/acp.md +92 -0 | new file mode 100644 | ||
| @@ -0,0 +1,92 @@ | ||
| 1 | +# Référence : surface Agent Client Protocol | |
| 2 | + | |
| 3 | +> Description neutre de ce que `mm -acp` expose en JSON-RPC 2.0 sur stdio, via `github.com/coder/acp-go-sdk` v0.13.5. | |
| 4 | + | |
| 5 | +## Transport | |
| 6 | + | |
| 7 | +| Aspect | Valeur | | |
| 8 | +|--------|--------| | |
| 9 | +| Entrée | stdin, un message JSON-RPC par ligne. | | |
| 10 | +| Sortie | stdout, messages JSON-RPC uniquement. | | |
| 11 | +| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. | | |
| 12 | +| Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. | | |
| 13 | + | |
| 14 | +## Réponse à `initialize` | |
| 15 | + | |
| 16 | +| Champ | Valeur | | |
| 17 | +|-------|--------| | |
| 18 | +| `protocolVersion` | `1` | | |
| 19 | +| `agentCapabilities` | Toutes absentes (false) : pas de `loadSession`, pas de prompts image ou audio, pas de MCP HTTP. | | |
| 20 | +| `agentInfo.name` | `bob` | | |
| 21 | +| `agentInfo.title` | `Bob (bash-first agent)` | | |
| 22 | +| `agentInfo.version` | `0.11.0` | | |
| 23 | +| `authMethods` | `[]` | | |
| 24 | + | |
| 25 | +## Méthodes | |
| 26 | + | |
| 27 | +| Méthode | Comportement | | |
| 28 | +|---------|--------------| | |
| 29 | +| `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. | | |
| 30 | +| `authenticate` | Renvoie une réponse vide. | | |
| 31 | +| `session/new` | Crée une session `bob-<pid>-<n>` avec le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). | | |
| 32 | +| `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | | |
| 33 | +| `session/cancel` | Annule le tour en cours pour cette session. | | |
| 34 | +| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | | |
| 35 | + | |
| 36 | +## `session/prompt` | |
| 37 | + | |
| 38 | +| Aspect | Comportement | | |
| 39 | +|--------|--------------| | |
| 40 | +| `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. | | |
| 41 | +| Contenu du prompt | Blocs `text` concaténés ; un `resource_link` devient `\n[attached file: <chemin sans file://>]` ; les autres types de blocs sont ignorés. Ensuite, chaque `@chemin` du texte qui désigne un fichier ou un répertoire existant sous le `cwd` de la session (ou un chemin absolu / `~`) ajoute `\n[attached file: <chemin absolu>]` ou `\n[attached directory: …]`, une fois par chemin, et est journalisé comme `[acp] session <id>: attached <chemin>`. | | |
| 42 | +| Commande slash | Quand le texte aplati, débarrassé de ses espaces, est exactement `/new` ou `/compact`, aucune génération n'a lieu ; la commande répond par un `agent_message_chunk` et `stopReason: end_turn`. | | |
| 43 | +| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. | | |
| 44 | +| `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. | | |
| 45 | +| Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. | | |
| 46 | +| Succès | `stopReason: end_turn`. | | |
| 47 | +| Annulé | `stopReason: cancelled`. | | |
| 48 | +| Échec | `internal error` avec `error: <explication en une ligne>`. | | |
| 49 | +| Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. | | |
| 50 | + | |
| 51 | +## Notifications `session/update` envoyées | |
| 52 | + | |
| 53 | +| Mise à jour | Quand | | |
| 54 | +|-------------|-------| | |
| 55 | +| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. | | |
| 56 | +| `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. | | |
| 57 | +| `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | | |
| 58 | +| `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | | |
| 59 | +| `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. | | |
| 60 | + | |
| 61 | +## Commandes disponibles | |
| 62 | + | |
| 63 | +| `name` | `description` | `input` | | |
| 64 | +|--------|---------------|---------| | |
| 65 | +| `new` | `Clear the history and start a new session` | aucun | | |
| 66 | +| `compact` | `Compress the history now, keeping the last turns` | aucun | | |
| 67 | + | |
| 68 | +Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. | |
| 69 | + | |
| 70 | +## Requêtes `session/request_permission` envoyées | |
| 71 | + | |
| 72 | +Envoyées avant l'exécution de `bash`, `read_file`, `write_file` et `edit_file` ; jamais pour `read_skill`. | |
| 73 | + | |
| 74 | +| Identifiant d'option | Genre | Effet | | |
| 75 | +|----------------------|-------|-------| | |
| 76 | +| `allow` | `allow_once` | Exécute l'outil. | | |
| 77 | +| `allow_always` | `allow_always` | Exécute l'outil et supprime le dialogue pour ce nom d'outil jusqu'à la fin de la session. | | |
| 78 | +| `reject` | `reject_once` | N'exécute pas ; le modèle reçoit `[command rejected by the user]`. | | |
| 79 | + | |
| 80 | +Une erreur de transport ou un résultat `cancelled` compte comme un refus. | |
| 81 | + | |
| 82 | +## Genres d'outils | |
| 83 | + | |
| 84 | +| Genre `mm` | `ToolKind` ACP | Outils | | |
| 85 | +|------------|----------------|--------| | |
| 86 | +| `execute` | `execute` | `bash` (et tout genre inconnu) | | |
| 87 | +| `read` | `read` | `read_skill`, `read_file` | | |
| 88 | +| `edit` | `edit` | `write_file`, `edit_file` | | |
| 89 | + | |
| 90 | +## Identifiants d'appel d'outil | |
| 91 | + | |
| 92 | +`call_1`, `call_2`, … numérotés par processus et jamais remis à zéro. | |
| new file mode 100644 | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | +# Référence : surface Agent Client Protocol | ||
| 2 | + | ||
| 3 | +> Description neutre de ce que `mm -acp` expose en JSON-RPC 2.0 sur stdio, via `github.com/coder/acp-go-sdk` v0.13.5. | ||
| 4 | + | ||
| 5 | +## Transport | ||
| 6 | + | ||
| 7 | +| Aspect | Valeur | | ||
| 8 | +|--------|--------| | ||
| 9 | +| Entrée | stdin, un message JSON-RPC par ligne. | | ||
| 10 | +| Sortie | stdout, messages JSON-RPC uniquement. | | ||
| 11 | +| Journaux | stderr : bannière, avertissements, lignes `[acp] …`, logger du SDK. | | ||
| 12 | +| Durée de vie | Jusqu'à ce que le client ferme stdin ou que le contexte du processus soit annulé. | | ||
| 13 | + | ||
| 14 | +## Réponse à `initialize` | ||
| 15 | + | ||
| 16 | +| Champ | Valeur | | ||
| 17 | +|-------|--------| | ||
| 18 | +| `protocolVersion` | `1` | | ||
| 19 | +| `agentCapabilities` | Toutes absentes (false) : pas de `loadSession`, pas de prompts image ou audio, pas de MCP HTTP. | | ||
| 20 | +| `agentInfo.name` | `bob` | | ||
| 21 | +| `agentInfo.title` | `Bob (bash-first agent)` | | ||
| 22 | +| `agentInfo.version` | `0.11.0` | | ||
| 23 | +| `authMethods` | `[]` | | ||
| 24 | + | ||
| 25 | +## Méthodes | ||
| 26 | + | ||
| 27 | +| Méthode | Comportement | | ||
| 28 | +|---------|--------------| | ||
| 29 | +| `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. | | ||
| 30 | +| `authenticate` | Renvoie une réponse vide. | | ||
| 31 | +| `session/new` | Crée une session `bob-<pid>-<n>` avec le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). | | ||
| 32 | +| `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. | | ||
| 33 | +| `session/cancel` | Annule le tour en cours pour cette session. | | ||
| 34 | +| `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. | | ||
| 35 | + | ||
| 36 | +## `session/prompt` | ||
| 37 | + | ||
| 38 | +| Aspect | Comportement | | ||
| 39 | +|--------|--------------| | ||
| 40 | +| `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. | | ||
| 41 | +| Contenu du prompt | Blocs `text` concaténés ; un `resource_link` devient `\n[attached file: <chemin sans file://>]` ; les autres types de blocs sont ignorés. Ensuite, chaque `@chemin` du texte qui désigne un fichier ou un répertoire existant sous le `cwd` de la session (ou un chemin absolu / `~`) ajoute `\n[attached file: <chemin absolu>]` ou `\n[attached directory: …]`, une fois par chemin, et est journalisé comme `[acp] session <id>: attached <chemin>`. | | ||
| 42 | +| Commande slash | Quand le texte aplati, débarrassé de ses espaces, est exactement `/new` ou `/compact`, aucune génération n'a lieu ; la commande répond par un `agent_message_chunk` et `stopReason: end_turn`. | | ||
| 43 | +| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. | | ||
| 44 | +| `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. | | ||
| 45 | +| Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. | | ||
| 46 | +| Succès | `stopReason: end_turn`. | | ||
| 47 | +| Annulé | `stopReason: cancelled`. | | ||
| 48 | +| Échec | `internal error` avec `error: <explication en une ligne>`. | | ||
| 49 | +| Ligne de journal | `[acp] turn done: N command(s) · N file op(s) · N skill(s)` sur stderr. | | ||
| 50 | + | ||
| 51 | +## Notifications `session/update` envoyées | ||
| 52 | + | ||
| 53 | +| Mise à jour | Quand | | ||
| 54 | +|-------------|-------| | ||
| 55 | +| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. | | ||
| 56 | +| `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. | | ||
| 57 | +| `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. | | ||
| 58 | +| `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. | | ||
| 59 | +| `tool_call_update` statut `completed` ou `failed` | Outil terminé ; `content` est un `diff` pour les changements de fichier, sinon un bloc `text` avec la sortie ; `rawOutput.output` porte toujours la sortie. | | ||
| 60 | + | ||
| 61 | +## Commandes disponibles | ||
| 62 | + | ||
| 63 | +| `name` | `description` | `input` | | ||
| 64 | +|--------|---------------|---------| | ||
| 65 | +| `new` | `Clear the history and start a new session` | aucun | | ||
| 66 | +| `compact` | `Compress the history now, keeping the last turns` | aucun | | ||
| 67 | + | ||
| 68 | +Les clients affichent le nom précédé d'une barre oblique et le renvoient comme texte d'un `session/prompt`. | ||
| 69 | + | ||
| 70 | +## Requêtes `session/request_permission` envoyées | ||
| 71 | + | ||
| 72 | +Envoyées avant l'exécution de `bash`, `read_file`, `write_file` et `edit_file` ; jamais pour `read_skill`. | ||
| 73 | + | ||
| 74 | +| Identifiant d'option | Genre | Effet | | ||
| 75 | +|----------------------|-------|-------| | ||
| 76 | +| `allow` | `allow_once` | Exécute l'outil. | | ||
| 77 | +| `allow_always` | `allow_always` | Exécute l'outil et supprime le dialogue pour ce nom d'outil jusqu'à la fin de la session. | | ||
| 78 | +| `reject` | `reject_once` | N'exécute pas ; le modèle reçoit `[command rejected by the user]`. | | ||
| 79 | + | ||
| 80 | +Une erreur de transport ou un résultat `cancelled` compte comme un refus. | ||
| 81 | + | ||
| 82 | +## Genres d'outils | ||
| 83 | + | ||
| 84 | +| Genre `mm` | `ToolKind` ACP | Outils | | ||
| 85 | +|------------|----------------|--------| | ||
| 86 | +| `execute` | `execute` | `bash` (et tout genre inconnu) | | ||
| 87 | +| `read` | `read` | `read_skill`, `read_file` | | ||
| 88 | +| `edit` | `edit` | `write_file`, `edit_file` | | ||
| 89 | + | ||
| 90 | +## Identifiants d'appel d'outil | ||
| 91 | + | ||
| 92 | +`call_1`, `call_2`, … numérotés par processus et jamais remis à zéro. | ||
added
docs/fr/reference/cli.md +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +# Référence : ligne de commande | |
| 2 | + | |
| 3 | +> Description neutre de l'exécutable `mm` : options, argument positionnel, variables d'environnement, sortie de démarrage et codes de sortie. | |
| 4 | + | |
| 5 | +## Invocation | |
| 6 | + | |
| 7 | +``` | |
| 8 | +mm [-config <chemin>] [-acp] [<chemin>] | |
| 9 | +``` | |
| 10 | + | |
| 11 | +| Élément | Type | Défaut | Description | | |
| 12 | +|---------|------|--------|-------------| | |
| 13 | +| `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. | | |
| 14 | +| `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. | | |
| 15 | +| `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. | | |
| 16 | +| `-h` | — | — | Affiche l'usage des options et quitte. | | |
| 17 | + | |
| 18 | +## Résolution du fichier de configuration | |
| 19 | + | |
| 20 | +La première source existante gagne. | |
| 21 | + | |
| 22 | +| Ordre | Source | Fichier absent | | |
| 23 | +|-------|--------|----------------| | |
| 24 | +| 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 | | |
| 25 | +| 2 | Variable d'environnement `AGENT_CONFIG` | Erreur, code de sortie 1 | | |
| 26 | +| 3 | `./agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` | | |
| 27 | + | |
| 28 | +## Variables d'environnement | |
| 29 | + | |
| 30 | +| Variable | Effet | | |
| 31 | +|----------|-------| | |
| 32 | +| `AGENT_CONFIG` | Chemin du fichier de configuration (voir ci-dessus). | | |
| 33 | +| `AGENT_PROVIDER` | Surcharge la clé `provider`. | | |
| 34 | +| `AGENT_MODEL` | Surcharge la clé `model`. | | |
| 35 | +| `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. | | |
| 36 | +| `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. | | |
| 37 | +| Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`. | | |
| 38 | + | |
| 39 | +## Sortie de démarrage | |
| 40 | + | |
| 41 | +Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand stdout est un terminal. | |
| 42 | + | |
| 43 | +| Ligne | Contenu | | |
| 44 | +|-------|---------| | |
| 45 | +| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher. | | |
| 46 | +| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. | | |
| 47 | +| `Agent ready. Commands: …` | Mode terminal seulement. | | |
| 48 | +| `Shortcut: Ctrl+C …` | Mode terminal seulement. | | |
| 49 | + | |
| 50 | +### Exemple | |
| 51 | + | |
| 52 | +``` | |
| 53 | +[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080] | |
| 54 | +config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | |
| 55 | +``` | |
| 56 | + | |
| 57 | +## Spécificités du mode ACP | |
| 58 | + | |
| 59 | +| Aspect | Comportement | | |
| 60 | +|--------|--------------| | |
| 61 | +| stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. | | |
| 62 | +| stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. | | |
| 63 | +| Spinner | Désactivé. | | |
| 64 | +| `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. | | |
| 65 | + | |
| 66 | +## Codes de sortie | |
| 67 | + | |
| 68 | +| Code | Cause | | |
| 69 | +|------|-------| | |
| 70 | +| 0 | `/quit`, `Ctrl+C` à l'invite, fin de stdin, ou le client ACP a fermé la connexion. | | |
| 71 | +| 1 | Plus d'un argument positionnel ; erreur de chargement de la configuration ; erreur d'initialisation du moteur (fournisseur inconnu, clé requise absente). | | |
| 72 | + | |
| 73 | +## Compilation et installation | |
| 74 | + | |
| 75 | +| Commande | Effet | | |
| 76 | +|----------|-------| | |
| 77 | +| `go build -o mm .` | Compile le binaire dans le répertoire courant. | | |
| 78 | +| `./install.sh` | Compile `mm` et le copie dans `/usr/local/bin`, avec `sudo` si le répertoire n'est pas accessible en écriture. | | |
| 79 | +| `go test ./...` | Lance les tests unitaires. | | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +# Référence : ligne de commande | ||
| 2 | + | ||
| 3 | +> Description neutre de l'exécutable `mm` : options, argument positionnel, variables d'environnement, sortie de démarrage et codes de sortie. | ||
| 4 | + | ||
| 5 | +## Invocation | ||
| 6 | + | ||
| 7 | +``` | ||
| 8 | +mm [-config <chemin>] [-acp] [<chemin>] | ||
| 9 | +``` | ||
| 10 | + | ||
| 11 | +| Élément | Type | Défaut | Description | | ||
| 12 | +|---------|------|--------|-------------| | ||
| 13 | +| `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. | | ||
| 14 | +| `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. | | ||
| 15 | +| `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. | | ||
| 16 | +| `-h` | — | — | Affiche l'usage des options et quitte. | | ||
| 17 | + | ||
| 18 | +## Résolution du fichier de configuration | ||
| 19 | + | ||
| 20 | +La première source existante gagne. | ||
| 21 | + | ||
| 22 | +| Ordre | Source | Fichier absent | | ||
| 23 | +|-------|--------|----------------| | ||
| 24 | +| 1 | Option `-config` ou argument positionnel | Erreur, code de sortie 1 | | ||
| 25 | +| 2 | Variable d'environnement `AGENT_CONFIG` | Erreur, code de sortie 1 | | ||
| 26 | +| 3 | `./agent.yaml` | Pas une erreur : les valeurs par défaut intégrées sont utilisées, la bannière affiche `config: built-in defaults` | | ||
| 27 | + | ||
| 28 | +## Variables d'environnement | ||
| 29 | + | ||
| 30 | +| Variable | Effet | | ||
| 31 | +|----------|-------| | ||
| 32 | +| `AGENT_CONFIG` | Chemin du fichier de configuration (voir ci-dessus). | | ||
| 33 | +| `AGENT_PROVIDER` | Surcharge la clé `provider`. | | ||
| 34 | +| `AGENT_MODEL` | Surcharge la clé `model`. | | ||
| 35 | +| `AGENT_BASE_URL` | Surcharge `baseUrl` et désactive la sonde de repli. | | ||
| 36 | +| `DMR_BASE_URL` | Comme `AGENT_BASE_URL`, pour le fournisseur `dmr` seulement ; `AGENT_BASE_URL` gagne quand les deux sont définies. | | ||
| 37 | +| Variable nommée par `apiKeyEnv` | Contient la clé d'API. Nom par défaut : aucun pour `dmr`, `LLAMA_API_KEY` pour `llamacpp`. | | ||
| 38 | + | ||
| 39 | +## Sortie de démarrage | ||
| 40 | + | ||
| 41 | +Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand stdout est un terminal. | ||
| 42 | + | ||
| 43 | +| Ligne | Contenu | | ||
| 44 | +|-------|---------| | ||
| 45 | +| `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher. | | ||
| 46 | +| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. | | ||
| 47 | +| `Agent ready. Commands: …` | Mode terminal seulement. | | ||
| 48 | +| `Shortcut: Ctrl+C …` | Mode terminal seulement. | | ||
| 49 | + | ||
| 50 | +### Exemple | ||
| 51 | + | ||
| 52 | +``` | ||
| 53 | +[warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080] | ||
| 54 | +config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +## Spécificités du mode ACP | ||
| 58 | + | ||
| 59 | +| Aspect | Comportement | | ||
| 60 | +|--------|--------------| | ||
| 61 | +| stdout | Messages JSON-RPC 2.0 uniquement, un par ligne. | | ||
| 62 | +| stderr | Bannière, avertissements, trace `[acp] …`, journaux du SDK. | | ||
| 63 | +| Spinner | Désactivé. | | ||
| 64 | +| `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. | | ||
| 65 | + | ||
| 66 | +## Codes de sortie | ||
| 67 | + | ||
| 68 | +| Code | Cause | | ||
| 69 | +|------|-------| | ||
| 70 | +| 0 | `/quit`, `Ctrl+C` à l'invite, fin de stdin, ou le client ACP a fermé la connexion. | | ||
| 71 | +| 1 | Plus d'un argument positionnel ; erreur de chargement de la configuration ; erreur d'initialisation du moteur (fournisseur inconnu, clé requise absente). | | ||
| 72 | + | ||
| 73 | +## Compilation et installation | ||
| 74 | + | ||
| 75 | +| Commande | Effet | | ||
| 76 | +|----------|-------| | ||
| 77 | +| `go build -o mm .` | Compile le binaire dans le répertoire courant. | | ||
| 78 | +| `./install.sh` | Compile `mm` et le copie dans `/usr/local/bin`, avec `sudo` si le répertoire n'est pas accessible en écriture. | | ||
| 79 | +| `go test ./...` | Lance les tests unitaires. | | ||
added
docs/fr/reference/configuration.md +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +# Référence : fichier de configuration | |
| 2 | + | |
| 3 | +> Description neutre et exhaustive du fichier YAML lu par `mm`. Chaque clé est optionnelle ; une clé omise garde sa valeur par défaut intégrée. Le fichier est décodé par-dessus les valeurs par défaut, donc les clés inconnues sont ignorées. | |
| 4 | + | |
| 5 | +## Clés de premier niveau | |
| 6 | + | |
| 7 | +| Clé | Type | Défaut | Description | | |
| 8 | +|-----|------|--------|-------------| | |
| 9 | +| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr` ou `llamacpp`. Surchargée par `AGENT_PROVIDER`. | | |
| 10 | +| `model` | chaîne | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Identifiant du modèle dans la nomenclature du fournisseur. Surchargé par `AGENT_MODEL`. | | |
| 11 | +| `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. | | |
| 12 | +| `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. | | |
| 13 | +| `apiKeyEnv` | chaîne | `""` (défaut du fournisseur) | Nom de la variable d'environnement qui contient la clé d'API. | | |
| 14 | +| `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. | | |
| 15 | +| `maxOutput` | entier | `16000` | Nombre maximal de caractères qu'un outil renvoie au modèle ; au-delà, la sortie garde son début et jusqu'à 2000 caractères de fin. Doit être > 0. | | |
| 16 | +| `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. | | |
| 17 | +| `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. | | |
| 18 | +| `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. | | |
| 19 | +| `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. | | |
| 20 | +| `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é. | | |
| 21 | +| `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. | | |
| 22 | +| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. | | |
| 23 | +| `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. | | |
| 24 | +| `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. | | |
| 25 | + | |
| 26 | +## Clés de `context` | |
| 27 | + | |
| 28 | +| Clé | Type | Défaut | Description | | |
| 29 | +|-----|------|--------|-------------| | |
| 30 | +| `enabled` | booléen | `false` | Active la compression automatique. `/compact` fonctionne dans tous les cas. | | |
| 31 | +| `threshold` | entier | `75` | Pourcentage de la fenêtre de contexte au-delà duquel l'historique est compressé avant la question suivante. Entre 1 et 100. | | |
| 32 | +| `maxMessages` | entier | `80` | Nombre de messages qui déclenche une compression quand la fenêtre est inconnue ou l'estimation trop basse. `0` désactive. Doit être ≥ 0. | | |
| 33 | +| `keepLastTurns` | entier | `3` | Tours de question conservés bruts en fin d'historique. Doit être ≥ 1. | | |
| 34 | +| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. | | |
| 35 | +| `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. | | |
| 36 | +| `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. | | |
| 37 | + | |
| 38 | +## Valeurs par défaut des fournisseurs | |
| 39 | + | |
| 40 | +| Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise | | |
| 41 | +|-------------|-----------|------------|-------------|-------------| | |
| 42 | +| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | aucune | non | | |
| 43 | +| `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non | | |
| 44 | + | |
| 45 | +## Erreurs de validation | |
| 46 | + | |
| 47 | +Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1. | |
| 48 | + | |
| 49 | +| Message | Cause | | |
| 50 | +|---------|-------| | |
| 51 | +| `provider must not be empty` | `provider: ""` | | |
| 52 | +| `model must not be empty` | `model: ""` | | |
| 53 | +| `contextWindow must be >= 0` | valeur négative | | |
| 54 | +| `maxOutput must be > 0` | zéro ou négatif | | |
| 55 | +| `maxTurns must be > 0` | zéro ou négatif | | |
| 56 | +| `previewLines must be >= 0` | valeur négative | | |
| 57 | +| `context.threshold must be between 1 and 100` | hors intervalle | | |
| 58 | +| `context.keepLastTurns must be >= 1` | zéro ou négatif | | |
| 59 | +| `context.summaryMaxTokens must be > 0` | zéro ou négatif | | |
| 60 | +| `context.maxMessages must be >= 0` | valeur négative | | |
| 61 | +| `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. | | |
| 62 | + | |
| 63 | +Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`. | |
| 64 | + | |
| 65 | +## Fichiers livrés | |
| 66 | + | |
| 67 | +| Fichier | Rôle | | |
| 68 | +|---------|------| | |
| 69 | +| `agent.yaml` | Configuration Docker Model Runner : `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression désactivée, un prompt système nommé « Bob » qui décrit les skills et les règles d'édition de fichiers. | | |
| 70 | +| `agent.llamacpp.yaml` | Configuration llama.cpp : `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression activée. | | |
| 71 | + | |
| 72 | +### Exemple | |
| 73 | + | |
| 74 | +```yaml | |
| 75 | +provider: llamacpp | |
| 76 | +model: mon-alias | |
| 77 | +baseUrl: http://127.0.0.1:8080/v1 | |
| 78 | +fallback: "" | |
| 79 | +maxTurns: 40 | |
| 80 | +context: | |
| 81 | + enabled: true | |
| 82 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +# Référence : fichier de configuration | ||
| 2 | + | ||
| 3 | +> Description neutre et exhaustive du fichier YAML lu par `mm`. Chaque clé est optionnelle ; une clé omise garde sa valeur par défaut intégrée. Le fichier est décodé par-dessus les valeurs par défaut, donc les clés inconnues sont ignorées. | ||
| 4 | + | ||
| 5 | +## Clés de premier niveau | ||
| 6 | + | ||
| 7 | +| Clé | Type | Défaut | Description | | ||
| 8 | +|-----|------|--------|-------------| | ||
| 9 | +| `provider` | chaîne | `dmr` | Clé du registre du serveur LLM : `dmr` ou `llamacpp`. Surchargée par `AGENT_PROVIDER`. | | ||
| 10 | +| `model` | chaîne | `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` | Identifiant du modèle dans la nomenclature du fournisseur. Surchargé par `AGENT_MODEL`. | | ||
| 11 | +| `baseUrl` | chaîne | `""` (défaut du fournisseur) | Point d'accès compatible OpenAI essayé en premier. Surchargé par `AGENT_BASE_URL`. | | ||
| 12 | +| `fallback` | chaîne ou absente | absente (défaut du fournisseur) | Point d'accès utilisé quand `baseUrl` ne répond pas sur `/models` en 2 s. Absente = défaut du fournisseur ; `""` = pas de repli. | | ||
| 13 | +| `apiKeyEnv` | chaîne | `""` (défaut du fournisseur) | Nom de la variable d'environnement qui contient la clé d'API. | | ||
| 14 | +| `contextWindow` | entier | `0` | Tokens servis par le serveur. `0` = demander au serveur, sinon inconnu. Doit être ≥ 0. | | ||
| 15 | +| `maxOutput` | entier | `16000` | Nombre maximal de caractères qu'un outil renvoie au modèle ; au-delà, la sortie garde son début et jusqu'à 2000 caractères de fin. Doit être > 0. | | ||
| 16 | +| `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. | | ||
| 17 | +| `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. | | ||
| 18 | +| `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. | | ||
| 19 | +| `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. | | ||
| 20 | +| `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é. | | ||
| 21 | +| `editTools` | booléen | `true` | Déclare `read_file`, `write_file`, `edit_file`. `false` ne laisse que `bash` et `read_skill`. | | ||
| 22 | +| `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. | | ||
| 23 | +| `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. | | ||
| 24 | +| `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. | | ||
| 25 | + | ||
| 26 | +## Clés de `context` | ||
| 27 | + | ||
| 28 | +| Clé | Type | Défaut | Description | | ||
| 29 | +|-----|------|--------|-------------| | ||
| 30 | +| `enabled` | booléen | `false` | Active la compression automatique. `/compact` fonctionne dans tous les cas. | | ||
| 31 | +| `threshold` | entier | `75` | Pourcentage de la fenêtre de contexte au-delà duquel l'historique est compressé avant la question suivante. Entre 1 et 100. | | ||
| 32 | +| `maxMessages` | entier | `80` | Nombre de messages qui déclenche une compression quand la fenêtre est inconnue ou l'estimation trop basse. `0` désactive. Doit être ≥ 0. | | ||
| 33 | +| `keepLastTurns` | entier | `3` | Tours de question conservés bruts en fin d'historique. Doit être ≥ 1. | | ||
| 34 | +| `summaryMaxTokens` | entier | `1200` | `max_tokens` de la requête de résumé. Doit être > 0. | | ||
| 35 | +| `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. | | ||
| 36 | +| `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. | | ||
| 37 | + | ||
| 38 | +## Valeurs par défaut des fournisseurs | ||
| 39 | + | ||
| 40 | +| Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise | | ||
| 41 | +|-------------|-----------|------------|-------------|-------------| | ||
| 42 | +| `dmr` | `http://localhost:12434/engines/v1` | `http://host.docker.internal:12434/engines/v1` | aucune | non | | ||
| 43 | +| `llamacpp` | `http://127.0.0.1:8080/v1` | aucun | `LLAMA_API_KEY` | non | | ||
| 44 | + | ||
| 45 | +## Erreurs de validation | ||
| 46 | + | ||
| 47 | +Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1. | ||
| 48 | + | ||
| 49 | +| Message | Cause | | ||
| 50 | +|---------|-------| | ||
| 51 | +| `provider must not be empty` | `provider: ""` | | ||
| 52 | +| `model must not be empty` | `model: ""` | | ||
| 53 | +| `contextWindow must be >= 0` | valeur négative | | ||
| 54 | +| `maxOutput must be > 0` | zéro ou négatif | | ||
| 55 | +| `maxTurns must be > 0` | zéro ou négatif | | ||
| 56 | +| `previewLines must be >= 0` | valeur négative | | ||
| 57 | +| `context.threshold must be between 1 and 100` | hors intervalle | | ||
| 58 | +| `context.keepLastTurns must be >= 1` | zéro ou négatif | | ||
| 59 | +| `context.summaryMaxTokens must be > 0` | zéro ou négatif | | ||
| 60 | +| `context.maxMessages must be >= 0` | valeur négative | | ||
| 61 | +| `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. | | ||
| 62 | + | ||
| 63 | +Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`. | ||
| 64 | + | ||
| 65 | +## Fichiers livrés | ||
| 66 | + | ||
| 67 | +| Fichier | Rôle | | ||
| 68 | +|---------|------| | ||
| 69 | +| `agent.yaml` | Configuration Docker Model Runner : `provider: dmr`, `maxTurns: 40`, `displayCommands: true`, compression désactivée, un prompt système nommé « Bob » qui décrit les skills et les règles d'édition de fichiers. | | ||
| 70 | +| `agent.llamacpp.yaml` | Configuration llama.cpp : `provider: llamacpp`, `baseUrl: http://127.0.0.1:8080/v1`, `fallback: ""`, compression activée. | | ||
| 71 | + | ||
| 72 | +### Exemple | ||
| 73 | + | ||
| 74 | +```yaml | ||
| 75 | +provider: llamacpp | ||
| 76 | +model: mon-alias | ||
| 77 | +baseUrl: http://127.0.0.1:8080/v1 | ||
| 78 | +fallback: "" | ||
| 79 | +maxTurns: 40 | ||
| 80 | +context: | ||
| 81 | + enabled: true | ||
| 82 | +``` | ||
added
docs/fr/reference/skill-format.md +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +# Référence : format d'un fichier de skill | |
| 2 | + | |
| 3 | +> Description neutre des fichiers markdown servis par l'outil `read_skill`. | |
| 4 | + | |
| 5 | +## Emplacement | |
| 6 | + | |
| 7 | +| Règle | Valeur | | |
| 8 | +|-------|--------| | |
| 9 | +| Répertoire | `skillsDir` (défaut `skills`). | | |
| 10 | +| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. | | |
| 11 | +| Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. | | |
| 12 | +| Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. | | |
| 13 | +| Répertoire absent | Pas une erreur : zéro skill, `read_skill` non déclaré. | | |
| 14 | + | |
| 15 | +## En-tête (front matter) | |
| 16 | + | |
| 17 | +Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules deux clés sont lues ; les autres sont ignorées. | |
| 18 | + | |
| 19 | +| Clé | Type | Défaut | Description | | |
| 20 | +|-----|------|--------|-------------| | |
| 21 | +| `name` | chaîne | nom du fichier sans `.md` | Nom affiché dans le catalogue et accepté par `read_skill`. Une valeur vide garde le défaut. | | |
| 22 | +| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. | | |
| 23 | + | |
| 24 | +Une ligne `description:` dans le corps, après le `---` de fermeture, n'est pas une métadonnée. | |
| 25 | + | |
| 26 | +## Rendu du catalogue | |
| 27 | + | |
| 28 | +La description de l'outil est : | |
| 29 | + | |
| 30 | +``` | |
| 31 | +Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills: | |
| 32 | + <name> — <description> | |
| 33 | + <name> | |
| 34 | +``` | |
| 35 | + | |
| 36 | +Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide. | |
| 37 | + | |
| 38 | +### Exemple | |
| 39 | + | |
| 40 | +```markdown | |
| 41 | +--- | |
| 42 | +name: what-time-is-it | |
| 43 | +description: Give the current date and time. | |
| 44 | +keywords: time, date, clock | |
| 45 | +--- | |
| 46 | + | |
| 47 | +# What time is it? | |
| 48 | + | |
| 49 | +Run: | |
| 50 | + | |
| 51 | +```bash | |
| 52 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | |
| 53 | +``` | |
| 54 | +``` | |
| 55 | + | |
| 56 | +La clé `keywords` est acceptée dans le fichier et ignorée par le chargeur. | |
| 57 | + | |
| 58 | +## Exemples livrés | |
| 59 | + | |
| 60 | +| Chemin | `name` | `description` | | |
| 61 | +|--------|--------|---------------| | |
| 62 | +| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. | | |
| 63 | +| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. | | |
| 64 | +| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. | | |
| 65 | + | |
| 66 | +Ils utilisent la disposition un répertoire par skill ; lancé depuis la racine du dépôt, `mm` indique `skills: 3`. | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +# Référence : format d'un fichier de skill | ||
| 2 | + | ||
| 3 | +> Description neutre des fichiers markdown servis par l'outil `read_skill`. | ||
| 4 | + | ||
| 5 | +## Emplacement | ||
| 6 | + | ||
| 7 | +| Règle | Valeur | | ||
| 8 | +|-------|--------| | ||
| 9 | +| Répertoire | `skillsDir` (défaut `skills`). | | ||
| 10 | +| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. | | ||
| 11 | +| Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. | | ||
| 12 | +| Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. | | ||
| 13 | +| Répertoire absent | Pas une erreur : zéro skill, `read_skill` non déclaré. | | ||
| 14 | + | ||
| 15 | +## En-tête (front matter) | ||
| 16 | + | ||
| 17 | +Optionnel. Quand il est présent, le fichier commence par une ligne `---` et l'en-tête se termine à la ligne `---` suivante. Seules deux clés sont lues ; les autres sont ignorées. | ||
| 18 | + | ||
| 19 | +| Clé | Type | Défaut | Description | | ||
| 20 | +|-----|------|--------|-------------| | ||
| 21 | +| `name` | chaîne | nom du fichier sans `.md` | Nom affiché dans le catalogue et accepté par `read_skill`. Une valeur vide garde le défaut. | | ||
| 22 | +| `description` | chaîne | `""` | Résumé d'une ligne affiché dans le catalogue. | | ||
| 23 | + | ||
| 24 | +Une ligne `description:` dans le corps, après le `---` de fermeture, n'est pas une métadonnée. | ||
| 25 | + | ||
| 26 | +## Rendu du catalogue | ||
| 27 | + | ||
| 28 | +La description de l'outil est : | ||
| 29 | + | ||
| 30 | +``` | ||
| 31 | +Load a skill: the step-by-step procedure to follow for this kind of task. Call it BEFORE doing the work, with the name of the matching skill, and then follow what it says. Available skills: | ||
| 32 | + <name> — <description> | ||
| 33 | + <name> | ||
| 34 | +``` | ||
| 35 | + | ||
| 36 | +Les entrées sont triées par nom ; la partie ` — <description>` est omise quand la description est vide. | ||
| 37 | + | ||
| 38 | +### Exemple | ||
| 39 | + | ||
| 40 | +```markdown | ||
| 41 | +--- | ||
| 42 | +name: what-time-is-it | ||
| 43 | +description: Give the current date and time. | ||
| 44 | +keywords: time, date, clock | ||
| 45 | +--- | ||
| 46 | + | ||
| 47 | +# What time is it? | ||
| 48 | + | ||
| 49 | +Run: | ||
| 50 | + | ||
| 51 | +```bash | ||
| 52 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | ||
| 53 | +``` | ||
| 54 | +``` | ||
| 55 | + | ||
| 56 | +La clé `keywords` est acceptée dans le fichier et ignorée par le chargeur. | ||
| 57 | + | ||
| 58 | +## Exemples livrés | ||
| 59 | + | ||
| 60 | +| Chemin | `name` | `description` | | ||
| 61 | +|--------|--------|---------------| | ||
| 62 | +| `skills/greetings/SKILL.md` | `greetings` | Greet somebody with a festive message. | | ||
| 63 | +| `skills/vulcan-salute/SKILL.md` | `vulcan-salute` | Send a vulcan salute to somebody. | | ||
| 64 | +| `skills/what-time-is-it/SKILL.md` | `what-time-is-it` | Give the current date and time. | | ||
| 65 | + | ||
| 66 | +Ils utilisent la disposition un répertoire par skill ; lancé depuis la racine du dépôt, `mm` indique `skills: 3`. | ||
added
docs/fr/reference/slash-commands.md +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +# Référence : commandes slash et raccourcis clavier | |
| 2 | + | |
| 3 | +> Description neutre des commandes reconnues à l'invite du terminal de `mm`. Elles sont comparées à la ligne entière, débarrassée de ses espaces ; tout autre texte est envoyé au modèle. | |
| 4 | + | |
| 5 | +## Commandes | |
| 6 | + | |
| 7 | +| Commande | À l'invite | Pendant une génération | | |
| 8 | +|----------|------------|------------------------| | |
| 9 | +| `/quit` | Quitte le programme. | Annule la génération, affiche `Goodbye.`, quitte. | | |
| 10 | +| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. | | |
| 11 | +| `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). | | |
| 12 | +| `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. | | |
| 13 | + | |
| 14 | +Une ligne vide à l'invite est ignorée. | |
| 15 | + | |
| 16 | +## `@chemin` dans une question | |
| 17 | + | |
| 18 | +| Saisie | Effet | | |
| 19 | +|--------|-------| | |
| 20 | +| `@<chemin>` en début de ligne ou après un espace, une parenthèse ou un guillemet | Si le chemin existe (relatif au répertoire de lancement, absolu, ou `~/…`), une ligne atténuée `📎 <chemin absolu>` est affichée et `[attached file: <chemin absolu>]` (ou `[attached directory: …]`) est ajouté au message que reçoit le modèle. La ponctuation finale est ignorée ; chaque chemin est attaché une fois. | | |
| 21 | +| Un `@<chemin>` qui ne désigne rien sur le disque, ou `@` collé à un mot | Laissé tel quel. | | |
| 22 | + | |
| 23 | +Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-to/attach-a-file.md). | |
| 24 | + | |
| 25 | +## Tout autre texte pendant une génération | |
| 26 | + | |
| 27 | +| Saisie | Comportement | | |
| 28 | +|--------|--------------| | |
| 29 | +| Une ligne qui n'est pas une commande | Affiche `🔄 New command received. Cancelling current task...`, annule la génération en cours, conserve son historique partiel, puis soumet la nouvelle ligne comme question suivante. | | |
| 30 | + | |
| 31 | +## Raccourcis clavier | |
| 32 | + | |
| 33 | +| Touche | À l'invite | Pendant une génération | | |
| 34 | +|--------|------------|------------------------| | |
| 35 | +| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. | | |
| 36 | +| `Ctrl+D` (fin d'entrée) | Quitte. | — | | |
| 37 | + | |
| 38 | +## Messages affichés en fin de tour | |
| 39 | + | |
| 40 | +| Message | Signification | | |
| 41 | +|---------|---------------| | |
| 42 | +| `🛑 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. | | |
| 43 | +| `[error: …]` | Le fournisseur a expliqué un échec en une ligne ; une question restée sans réponse est retirée de l'historique. | | |
| 44 | +| `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. | | |
| 45 | +| `· 📝 N file op(s)` | Ajouté quand des outils de fichiers ont tourné. | | |
| 46 | +| `· 📖 N skill(s)` | Ajouté quand des skills ont été chargés. | | |
| 47 | +| Liste numérotée en vert | Les commandes et opérations sur fichiers du tour, une par ligne, quand `displayCommands: true`. | | |
| 48 | + | |
| 49 | +## Mode ACP | |
| 50 | + | |
| 51 | +Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande. Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté. | |
| 52 | + | |
| 53 | +| Commande | En ACP | | |
| 54 | +|----------|--------| | |
| 55 | +| `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. | | |
| 56 | +| `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. | | |
| 57 | + | |
| 58 | +`/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +# Référence : commandes slash et raccourcis clavier | ||
| 2 | + | ||
| 3 | +> Description neutre des commandes reconnues à l'invite du terminal de `mm`. Elles sont comparées à la ligne entière, débarrassée de ses espaces ; tout autre texte est envoyé au modèle. | ||
| 4 | + | ||
| 5 | +## Commandes | ||
| 6 | + | ||
| 7 | +| Commande | À l'invite | Pendant une génération | | ||
| 8 | +|----------|------------|------------------------| | ||
| 9 | +| `/quit` | Quitte le programme. | Annule la génération, affiche `Goodbye.`, quitte. | | ||
| 10 | +| `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. | | ||
| 11 | +| `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). | | ||
| 12 | +| `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. | | ||
| 13 | + | ||
| 14 | +Une ligne vide à l'invite est ignorée. | ||
| 15 | + | ||
| 16 | +## `@chemin` dans une question | ||
| 17 | + | ||
| 18 | +| Saisie | Effet | | ||
| 19 | +|--------|-------| | ||
| 20 | +| `@<chemin>` en début de ligne ou après un espace, une parenthèse ou un guillemet | Si le chemin existe (relatif au répertoire de lancement, absolu, ou `~/…`), une ligne atténuée `📎 <chemin absolu>` est affichée et `[attached file: <chemin absolu>]` (ou `[attached directory: …]`) est ajouté au message que reçoit le modèle. La ponctuation finale est ignorée ; chaque chemin est attaché une fois. | | ||
| 21 | +| Un `@<chemin>` qui ne désigne rien sur le disque, ou `@` collé à un mot | Laissé tel quel. | | ||
| 22 | + | ||
| 23 | +Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-to/attach-a-file.md). | ||
| 24 | + | ||
| 25 | +## Tout autre texte pendant une génération | ||
| 26 | + | ||
| 27 | +| Saisie | Comportement | | ||
| 28 | +|--------|--------------| | ||
| 29 | +| Une ligne qui n'est pas une commande | Affiche `🔄 New command received. Cancelling current task...`, annule la génération en cours, conserve son historique partiel, puis soumet la nouvelle ligne comme question suivante. | | ||
| 30 | + | ||
| 31 | +## Raccourcis clavier | ||
| 32 | + | ||
| 33 | +| Touche | À l'invite | Pendant une génération | | ||
| 34 | +|--------|------------|------------------------| | ||
| 35 | +| `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. | | ||
| 36 | +| `Ctrl+D` (fin d'entrée) | Quitte. | — | | ||
| 37 | + | ||
| 38 | +## Messages affichés en fin de tour | ||
| 39 | + | ||
| 40 | +| Message | Signification | | ||
| 41 | +|---------|---------------| | ||
| 42 | +| `🛑 Generation aborted.` | Le tour a été annulé (`/abort`, `Ctrl+C`). La question et les tours d'outils terminés restent dans l'historique. | | ||
| 43 | +| `[error: …]` | Le fournisseur a expliqué un échec en une ligne ; une question restée sans réponse est retirée de l'historique. | | ||
| 44 | +| `⚙ N command(s)` | Nombre de commandes `bash` réellement exécutées dans le tour. | | ||
| 45 | +| `· 📝 N file op(s)` | Ajouté quand des outils de fichiers ont tourné. | | ||
| 46 | +| `· 📖 N skill(s)` | Ajouté quand des skills ont été chargés. | | ||
| 47 | +| Liste numérotée en vert | Les commandes et opérations sur fichiers du tour, une par ligne, quand `displayCommands: true`. | | ||
| 48 | + | ||
| 49 | +## Mode ACP | ||
| 50 | + | ||
| 51 | +Deux commandes existent en mode ACP : `/new` et `/compact`. Elles sont annoncées à l'éditeur par une notification `available_commands_update` et interceptées par `session/prompt` quand le texte du prompt, débarrassé de ses espaces, est exactement la commande. Chacune répond par un `agent_message_chunk` portant la ligne que le terminal afficherait, puis `stopReason: end_turn` ; le modèle n'est pas consulté. | ||
| 52 | + | ||
| 53 | +| Commande | En ACP | | ||
| 54 | +|----------|--------| | ||
| 55 | +| `/new` | Comme dans le terminal : historique ramené au prompt système, compte de tokens oublié ; l'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. | | ||
| 56 | +| `/compact` | Comme dans le terminal, toujours forcée : `🗜️ compressed …`, `🗜️ nothing to compact: …` ou `[compact: failed, history kept: …]`. Attend d'abord la fin d'un tour en cours. | | ||
| 57 | + | ||
| 58 | +`/quit` et `/abort` n'existent pas en mode ACP : l'éditeur possède le processus et le termine en fermant stdin, et il annule un tour avec sa propre touche d'arrêt via `session/cancel`. Voir la [référence ACP](acp.md). | ||
added
docs/fr/reference/tools.md +103 -0 | new file mode 100644 | ||
| @@ -0,0 +1,103 @@ | ||
| 1 | +# Référence : outils intégrés | |
| 2 | + | |
| 3 | +> Description neutre des outils déclarés au modèle. Un outil ne renvoie jamais d'erreur Go pour un échec ordinaire : le texte de l'échec est renvoyé au modèle comme sortie de l'outil. Chaque outil enregistre son appel dans le détecteur de boucle ; le même outil, la même entrée et la même sortie trois fois de suite ajoutent `[LOOP_DETECTED: …]` à la sortie. | |
| 4 | + | |
| 5 | +## `bash` | |
| 6 | + | |
| 7 | +Toujours déclaré. | |
| 8 | + | |
| 9 | +| Champ | Type | Description | | |
| 10 | +|-------|------|-------------| | |
| 11 | +| `command` | chaîne | La commande shell à exécuter. | | |
| 12 | + | |
| 13 | +| Aspect | Comportement | | |
| 14 | +|--------|--------------| | |
| 15 | +| Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. | | |
| 16 | +| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. | | |
| 17 | +| Délai | 30 s ; la sortie se termine alors par `[error: 30s timeout exceeded]`. | | |
| 18 | +| Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. | | |
| 19 | +| Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. | | |
| 20 | +| Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. | | |
| 21 | +| ACP | Appel d'outil de genre `execute` ; permission demandée avant exécution ; un refus renvoie `[command rejected by the user]` avec le statut `failed`. | | |
| 22 | + | |
| 23 | +## `read_skill` | |
| 24 | + | |
| 25 | +Déclaré seulement quand `skillsDir` contient au moins un skill (`<nom>.md` ou `<nom>/SKILL.md`). | |
| 26 | + | |
| 27 | +| Champ | Type | Description | | |
| 28 | +|-------|------|-------------| | |
| 29 | +| `name` | chaîne | Nom du skill, exactement tel que listé dans la description de l'outil. | | |
| 30 | + | |
| 31 | +| Aspect | Comportement | | |
| 32 | +|--------|--------------| | |
| 33 | +| Description | Phrase fixe suivie du catalogue : une ligne ` <name> — <description>` par skill, triées par nom. | | |
| 34 | +| Sortie | Le markdown complet de `<skillsDir>/<name>.md`, ou de `<skillsDir>/<name>/SKILL.md` s'il n'y a pas de fichier à plat, tronqué à `maxOutput`. | | |
| 35 | +| Nom inconnu | `No skill named "x". Available skills: a, b, c` | | |
| 36 | +| Sécurité des chemins | Seul le nom de base de `name` est utilisé ; pas de traversée de répertoire. | | |
| 37 | +| Affichage terminal | `📖 read_skill: <name>` ; le contenu n'est pas affiché. | | |
| 38 | +| ACP | Appel d'outil de genre `read` ; pas de dialogue de permission. | | |
| 39 | + | |
| 40 | +## `read_file` | |
| 41 | + | |
| 42 | +Déclaré quand `editTools: true`. | |
| 43 | + | |
| 44 | +| Champ | Type | Défaut | Description | | |
| 45 | +|-------|------|--------|-------------| | |
| 46 | +| `path` | chaîne | — | Chemin du fichier, relatif au répertoire de travail. | | |
| 47 | +| `start` | entier | `0` | Première ligne (base 1) ; `0` = depuis le début. | | |
| 48 | +| `end` | entier | `0` | Dernière ligne (base 1, incluse) ; `0` = jusqu'à la fin. | | |
| 49 | +| `numbered` | booléen | `false` | Préfixe chaque ligne de son numéro. | | |
| 50 | + | |
| 51 | +| Aspect | Comportement | | |
| 52 | +|--------|--------------| | |
| 53 | +| Fichier absent | `<path> does not exist. Create it with write_file` | | |
| 54 | +| Sortie | Les lignes, tronquées à `maxOutput` ; le saut de ligne final n'est conservé que si la fin du fichier est affichée. | | |
| 55 | +| ACP | Genre `read`, avec le chemin absolu comme emplacement. | | |
| 56 | + | |
| 57 | +## `write_file` | |
| 58 | + | |
| 59 | +Déclaré quand `editTools: true`. | |
| 60 | + | |
| 61 | +| Champ | Type | Description | | |
| 62 | +|-------|------|-------------| | |
| 63 | +| `path` | chaîne | Chemin du fichier, relatif au répertoire de travail ; les répertoires parents sont créés. | | |
| 64 | +| `content` | chaîne | Le contenu entier du fichier. | | |
| 65 | + | |
| 66 | +| Aspect | Comportement | | |
| 67 | +|--------|--------------| | |
| 68 | +| Fichier existant | Entièrement remplacé. | | |
| 69 | +| Contenu identique | Rien n'est écrit ; le résultat le dit. | | |
| 70 | +| Sortie | Un en-tête d'une ligne et le diff par rapport au contenu précédent. | | |
| 71 | +| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff (diff de nouveau fichier à la création). | | |
| 72 | + | |
| 73 | +## `edit_file` | |
| 74 | + | |
| 75 | +Déclaré quand `editTools: true`. | |
| 76 | + | |
| 77 | +| Champ | Type | Défaut | Description | | |
| 78 | +|-------|------|--------|-------------| | |
| 79 | +| `path` | chaîne | — | Chemin d'un fichier existant. | | |
| 80 | +| `edits` | tableau de `{old, new}` | — | Remplacements, tous résolus par rapport au fichier original. | | |
| 81 | +| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. | | |
| 82 | +| `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. | | |
| 83 | +| `dry_run` | booléen | `false` | Rapporte ce qui changerait, n'écrit rien. | | |
| 84 | + | |
| 85 | +| Règle | Conséquence si violée | | |
| 86 | +|-------|-----------------------| | |
| 87 | +| `old` apparaît exactement une fois | Refus : introuvable, ou ambigu. | | |
| 88 | +| Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. | | |
| 89 | +| Les éditions sont résolues sur le texte original, jamais en cascade | — | | |
| 90 | +| Toute édition refusée | Rien n'est écrit. | | |
| 91 | + | |
| 92 | +| Aspect | Comportement | | |
| 93 | +|--------|--------------| | |
| 94 | +| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. | | |
| 95 | +| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. | | |
| 96 | + | |
| 97 | +## Compteurs affichés après un tour | |
| 98 | + | |
| 99 | +| Compteur | Compte | | |
| 100 | +|----------|--------| | |
| 101 | +| `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. | | |
| 102 | +| `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. | | |
| 103 | +| `📖 N skill(s)` | Réponses de `read_skill`. | | |
| new file mode 100644 | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | +# Référence : outils intégrés | ||
| 2 | + | ||
| 3 | +> Description neutre des outils déclarés au modèle. Un outil ne renvoie jamais d'erreur Go pour un échec ordinaire : le texte de l'échec est renvoyé au modèle comme sortie de l'outil. Chaque outil enregistre son appel dans le détecteur de boucle ; le même outil, la même entrée et la même sortie trois fois de suite ajoutent `[LOOP_DETECTED: …]` à la sortie. | ||
| 4 | + | ||
| 5 | +## `bash` | ||
| 6 | + | ||
| 7 | +Toujours déclaré. | ||
| 8 | + | ||
| 9 | +| Champ | Type | Description | | ||
| 10 | +|-------|------|-------------| | ||
| 11 | +| `command` | chaîne | La commande shell à exécuter. | | ||
| 12 | + | ||
| 13 | +| Aspect | Comportement | | ||
| 14 | +|--------|--------------| | ||
| 15 | +| Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. | | ||
| 16 | +| Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. | | ||
| 17 | +| Délai | 30 s ; la sortie se termine alors par `[error: 30s timeout exceeded]`. | | ||
| 18 | +| Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. | | ||
| 19 | +| Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. | | ||
| 20 | +| Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. | | ||
| 21 | +| ACP | Appel d'outil de genre `execute` ; permission demandée avant exécution ; un refus renvoie `[command rejected by the user]` avec le statut `failed`. | | ||
| 22 | + | ||
| 23 | +## `read_skill` | ||
| 24 | + | ||
| 25 | +Déclaré seulement quand `skillsDir` contient au moins un skill (`<nom>.md` ou `<nom>/SKILL.md`). | ||
| 26 | + | ||
| 27 | +| Champ | Type | Description | | ||
| 28 | +|-------|------|-------------| | ||
| 29 | +| `name` | chaîne | Nom du skill, exactement tel que listé dans la description de l'outil. | | ||
| 30 | + | ||
| 31 | +| Aspect | Comportement | | ||
| 32 | +|--------|--------------| | ||
| 33 | +| Description | Phrase fixe suivie du catalogue : une ligne ` <name> — <description>` par skill, triées par nom. | | ||
| 34 | +| Sortie | Le markdown complet de `<skillsDir>/<name>.md`, ou de `<skillsDir>/<name>/SKILL.md` s'il n'y a pas de fichier à plat, tronqué à `maxOutput`. | | ||
| 35 | +| Nom inconnu | `No skill named "x". Available skills: a, b, c` | | ||
| 36 | +| Sécurité des chemins | Seul le nom de base de `name` est utilisé ; pas de traversée de répertoire. | | ||
| 37 | +| Affichage terminal | `📖 read_skill: <name>` ; le contenu n'est pas affiché. | | ||
| 38 | +| ACP | Appel d'outil de genre `read` ; pas de dialogue de permission. | | ||
| 39 | + | ||
| 40 | +## `read_file` | ||
| 41 | + | ||
| 42 | +Déclaré quand `editTools: true`. | ||
| 43 | + | ||
| 44 | +| Champ | Type | Défaut | Description | | ||
| 45 | +|-------|------|--------|-------------| | ||
| 46 | +| `path` | chaîne | — | Chemin du fichier, relatif au répertoire de travail. | | ||
| 47 | +| `start` | entier | `0` | Première ligne (base 1) ; `0` = depuis le début. | | ||
| 48 | +| `end` | entier | `0` | Dernière ligne (base 1, incluse) ; `0` = jusqu'à la fin. | | ||
| 49 | +| `numbered` | booléen | `false` | Préfixe chaque ligne de son numéro. | | ||
| 50 | + | ||
| 51 | +| Aspect | Comportement | | ||
| 52 | +|--------|--------------| | ||
| 53 | +| Fichier absent | `<path> does not exist. Create it with write_file` | | ||
| 54 | +| Sortie | Les lignes, tronquées à `maxOutput` ; le saut de ligne final n'est conservé que si la fin du fichier est affichée. | | ||
| 55 | +| ACP | Genre `read`, avec le chemin absolu comme emplacement. | | ||
| 56 | + | ||
| 57 | +## `write_file` | ||
| 58 | + | ||
| 59 | +Déclaré quand `editTools: true`. | ||
| 60 | + | ||
| 61 | +| Champ | Type | Description | | ||
| 62 | +|-------|------|-------------| | ||
| 63 | +| `path` | chaîne | Chemin du fichier, relatif au répertoire de travail ; les répertoires parents sont créés. | | ||
| 64 | +| `content` | chaîne | Le contenu entier du fichier. | | ||
| 65 | + | ||
| 66 | +| Aspect | Comportement | | ||
| 67 | +|--------|--------------| | ||
| 68 | +| Fichier existant | Entièrement remplacé. | | ||
| 69 | +| Contenu identique | Rien n'est écrit ; le résultat le dit. | | ||
| 70 | +| Sortie | Un en-tête d'une ligne et le diff par rapport au contenu précédent. | | ||
| 71 | +| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff (diff de nouveau fichier à la création). | | ||
| 72 | + | ||
| 73 | +## `edit_file` | ||
| 74 | + | ||
| 75 | +Déclaré quand `editTools: true`. | ||
| 76 | + | ||
| 77 | +| Champ | Type | Défaut | Description | | ||
| 78 | +|-------|------|--------|-------------| | ||
| 79 | +| `path` | chaîne | — | Chemin d'un fichier existant. | | ||
| 80 | +| `edits` | tableau de `{old, new}` | — | Remplacements, tous résolus par rapport au fichier original. | | ||
| 81 | +| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. | | ||
| 82 | +| `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. | | ||
| 83 | +| `dry_run` | booléen | `false` | Rapporte ce qui changerait, n'écrit rien. | | ||
| 84 | + | ||
| 85 | +| Règle | Conséquence si violée | | ||
| 86 | +|-------|-----------------------| | ||
| 87 | +| `old` apparaît exactement une fois | Refus : introuvable, ou ambigu. | | ||
| 88 | +| Deux éditions ne doivent ni se chevaucher, ni s'imbriquer, ni se répéter | Refus. | | ||
| 89 | +| Les éditions sont résolues sur le texte original, jamais en cascade | — | | ||
| 90 | +| Toute édition refusée | Rien n'est écrit. | | ||
| 91 | + | ||
| 92 | +| Aspect | Comportement | | ||
| 93 | +|--------|--------------| | ||
| 94 | +| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. | | ||
| 95 | +| ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. | | ||
| 96 | + | ||
| 97 | +## Compteurs affichés après un tour | ||
| 98 | + | ||
| 99 | +| Compteur | Compte | | ||
| 100 | +|----------|--------| | ||
| 101 | +| `⚙ N command(s)` | Réponses de l'outil `bash` dans le tour. | | ||
| 102 | +| `📝 N file op(s)` | Réponses de `read_file`, `write_file`, `edit_file`. | | ||
| 103 | +| `📖 N skill(s)` | Réponses de `read_skill`. | | ||
added
docs/fr/tutorials/getting-started.md +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +# Tutoriel : votre première session avec mini-me | |
| 2 | + | |
| 3 | +À la fin de ce tutoriel, vous aurez compilé le binaire `mm`, lancé l'agent face à Docker Model Runner, posé une question qui lui fait exécuter une commande shell, puis quitté proprement. Aucune connaissance préalable du projet n'est requise. | |
| 4 | + | |
| 5 | +## Prérequis | |
| 6 | + | |
| 7 | +- Go 1.25 ou plus récent dans votre `PATH` (`go version` répond). | |
| 8 | +- Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`. | |
| 9 | +- Le modèle nommé dans `agent.yaml` téléchargé localement. Le fichier livré avec le projet nomme `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` ; récupérez-le avec `docker model pull <ce nom>`. | |
| 10 | +- Un terminal ouvert à la racine du dépôt (le répertoire qui contient `main.go` et `agent.yaml`). | |
| 11 | + | |
| 12 | +## Étape 1 — Compiler le binaire | |
| 13 | + | |
| 14 | +Tapez : | |
| 15 | + | |
| 16 | +```bash | |
| 17 | +go build -o mm . | |
| 18 | +``` | |
| 19 | + | |
| 20 | +Vous ne devez voir aucune sortie. Un fichier `mm` se trouve maintenant dans le répertoire courant. | |
| 21 | + | |
| 22 | +Nous venons de compiler tout l'agent en un seul exécutable. | |
| 23 | + | |
| 24 | +## Étape 2 — Lancer l'agent | |
| 25 | + | |
| 26 | +Tapez : | |
| 27 | + | |
| 28 | +```bash | |
| 29 | +./mm | |
| 30 | +``` | |
| 31 | + | |
| 32 | +Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`agent.yaml`) : | |
| 33 | + | |
| 34 | +``` | |
| 35 | +config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | |
| 36 | +Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session. | |
| 37 | +Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. | |
| 38 | + | |
| 39 | +> | |
| 40 | +``` | |
| 41 | + | |
| 42 | +Si une ligne grisée `[warning: dmr: nothing answers at …]` apparaît au-dessus de la bannière, Docker Model Runner ne tourne pas : démarrez-le, puis relancez `./mm`. L'agent démarre quand même, mais l'étape suivante échouerait. | |
| 43 | + | |
| 44 | +Nous venons de charger la configuration, de nous connecter au serveur de modèle et de déclarer les outils que le modèle peut appeler. | |
| 45 | + | |
| 46 | +## Étape 3 — Poser une question qui demande une commande | |
| 47 | + | |
| 48 | +À l'invite `>`, tapez : | |
| 49 | + | |
| 50 | +``` | |
| 51 | +list the files in this directory | |
| 52 | +``` | |
| 53 | + | |
| 54 | +Vous devez voir, dans l'ordre : un spinner pendant que le modèle réfléchit, une ligne commençant par `🛠️ bash:` qui montre la commande choisie par le modèle (typiquement `ls` ou `ls -la`), les premières lignes de la sortie de cette commande, indentées et grisées, puis la réponse du modèle en texte. Le tour se termine par un récapitulatif grisé tel que : | |
| 55 | + | |
| 56 | +``` | |
| 57 | +⚙ 1 command(s) | |
| 58 | +``` | |
| 59 | + | |
| 60 | +suivi de la liste numérotée, en vert, des commandes exécutées. | |
| 61 | + | |
| 62 | +Nous venons d'observer un tour de la boucle de l'agent : le modèle a demandé un outil, l'outil a tourné, sa sortie est revenue au modèle, et le modèle a répondu. | |
| 63 | + | |
| 64 | +## Étape 4 — Quitter | |
| 65 | + | |
| 66 | +À l'invite `>`, tapez : | |
| 67 | + | |
| 68 | +``` | |
| 69 | +/quit | |
| 70 | +``` | |
| 71 | + | |
| 72 | +Le programme se termine. `Ctrl+C` à l'invite fait la même chose et affiche d'abord `Goodbye.`. | |
| 73 | + | |
| 74 | +## Et ensuite ? | |
| 75 | + | |
| 76 | +Vous avez compilé `mm`, exécuté un tour avec appel d'outil, et quitté. Pour aller plus loin : | |
| 77 | + | |
| 78 | +- Pour le faire tourner sur llama.cpp, l'héberger dans un éditeur ou ajouter des skills → voir les [guides pratiques](../how-to/) | |
| 79 | +- Pour retrouver une option, une clé YAML ou une commande slash → voir la [référence](../reference/) | |
| 80 | +- Pour comprendre comment la boucle et les deux façades s'articulent → voir les [explications](../explanation/) | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +# Tutoriel : votre première session avec mini-me | ||
| 2 | + | ||
| 3 | +À la fin de ce tutoriel, vous aurez compilé le binaire `mm`, lancé l'agent face à Docker Model Runner, posé une question qui lui fait exécuter une commande shell, puis quitté proprement. Aucune connaissance préalable du projet n'est requise. | ||
| 4 | + | ||
| 5 | +## Prérequis | ||
| 6 | + | ||
| 7 | +- Go 1.25 ou plus récent dans votre `PATH` (`go version` répond). | ||
| 8 | +- Docker Desktop avec Docker Model Runner activé et joignable sur `http://localhost:12434`. | ||
| 9 | +- Le modèle nommé dans `agent.yaml` téléchargé localement. Le fichier livré avec le projet nomme `huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M` ; récupérez-le avec `docker model pull <ce nom>`. | ||
| 10 | +- Un terminal ouvert à la racine du dépôt (le répertoire qui contient `main.go` et `agent.yaml`). | ||
| 11 | + | ||
| 12 | +## Étape 1 — Compiler le binaire | ||
| 13 | + | ||
| 14 | +Tapez : | ||
| 15 | + | ||
| 16 | +```bash | ||
| 17 | +go build -o mm . | ||
| 18 | +``` | ||
| 19 | + | ||
| 20 | +Vous ne devez voir aucune sortie. Un fichier `mm` se trouve maintenant dans le répertoire courant. | ||
| 21 | + | ||
| 22 | +Nous venons de compiler tout l'agent en un seul exécutable. | ||
| 23 | + | ||
| 24 | +## Étape 2 — Lancer l'agent | ||
| 25 | + | ||
| 26 | +Tapez : | ||
| 27 | + | ||
| 28 | +```bash | ||
| 29 | +./mm | ||
| 30 | +``` | ||
| 31 | + | ||
| 32 | +Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`agent.yaml`) : | ||
| 33 | + | ||
| 34 | +``` | ||
| 35 | +config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 | ||
| 36 | +Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session. | ||
| 37 | +Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. | ||
| 38 | + | ||
| 39 | +> | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +Si une ligne grisée `[warning: dmr: nothing answers at …]` apparaît au-dessus de la bannière, Docker Model Runner ne tourne pas : démarrez-le, puis relancez `./mm`. L'agent démarre quand même, mais l'étape suivante échouerait. | ||
| 43 | + | ||
| 44 | +Nous venons de charger la configuration, de nous connecter au serveur de modèle et de déclarer les outils que le modèle peut appeler. | ||
| 45 | + | ||
| 46 | +## Étape 3 — Poser une question qui demande une commande | ||
| 47 | + | ||
| 48 | +À l'invite `>`, tapez : | ||
| 49 | + | ||
| 50 | +``` | ||
| 51 | +list the files in this directory | ||
| 52 | +``` | ||
| 53 | + | ||
| 54 | +Vous devez voir, dans l'ordre : un spinner pendant que le modèle réfléchit, une ligne commençant par `🛠️ bash:` qui montre la commande choisie par le modèle (typiquement `ls` ou `ls -la`), les premières lignes de la sortie de cette commande, indentées et grisées, puis la réponse du modèle en texte. Le tour se termine par un récapitulatif grisé tel que : | ||
| 55 | + | ||
| 56 | +``` | ||
| 57 | +⚙ 1 command(s) | ||
| 58 | +``` | ||
| 59 | + | ||
| 60 | +suivi de la liste numérotée, en vert, des commandes exécutées. | ||
| 61 | + | ||
| 62 | +Nous venons d'observer un tour de la boucle de l'agent : le modèle a demandé un outil, l'outil a tourné, sa sortie est revenue au modèle, et le modèle a répondu. | ||
| 63 | + | ||
| 64 | +## Étape 4 — Quitter | ||
| 65 | + | ||
| 66 | +À l'invite `>`, tapez : | ||
| 67 | + | ||
| 68 | +``` | ||
| 69 | +/quit | ||
| 70 | +``` | ||
| 71 | + | ||
| 72 | +Le programme se termine. `Ctrl+C` à l'invite fait la même chose et affiche d'abord `Goodbye.`. | ||
| 73 | + | ||
| 74 | +## Et ensuite ? | ||
| 75 | + | ||
| 76 | +Vous avez compilé `mm`, exécuté un tour avec appel d'outil, et quitté. Pour aller plus loin : | ||
| 77 | + | ||
| 78 | +- Pour le faire tourner sur llama.cpp, l'héberger dans un éditeur ou ajouter des skills → voir les [guides pratiques](../how-to/) | ||
| 79 | +- Pour retrouver une option, une clé YAML ou une commande slash → voir la [référence](../reference/) | ||
| 80 | +- Pour comprendre comment la boucle et les deux façades s'articulent → voir les [explications](../explanation/) | ||
added
go.mod +39 -0 | new file mode 100644 | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +module mm | |
| 2 | + | |
| 3 | +go 1.25.0 | |
| 4 | + | |
| 5 | +require ( | |
| 6 | + github.com/firebase/genkit/go v1.10.0 | |
| 7 | + github.com/openai/openai-go v1.8.2 | |
| 8 | + gopkg.in/yaml.v3 v3.0.1 | |
| 9 | +) | |
| 10 | + | |
| 11 | +require ( | |
| 12 | + github.com/bahlo/generic-list-go v0.2.0 // indirect | |
| 13 | + github.com/buger/jsonparser v1.1.1 // indirect | |
| 14 | + github.com/coder/acp-go-sdk v0.13.5 | |
| 15 | + github.com/coder/websocket v1.8.14 // indirect | |
| 16 | + github.com/go-logr/logr v1.4.3 // indirect | |
| 17 | + github.com/go-logr/stdr v1.2.2 // indirect | |
| 18 | + github.com/goccy/go-yaml v1.17.1 // indirect | |
| 19 | + github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect | |
| 20 | + github.com/google/uuid v1.6.0 // indirect | |
| 21 | + github.com/invopop/jsonschema v0.13.0 // indirect | |
| 22 | + github.com/mailru/easyjson v0.9.0 // indirect | |
| 23 | + github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect | |
| 24 | + github.com/tidwall/gjson v1.18.0 // indirect | |
| 25 | + github.com/tidwall/match v1.1.1 // indirect | |
| 26 | + github.com/tidwall/pretty v1.2.1 // indirect | |
| 27 | + github.com/tidwall/sjson v1.2.5 // indirect | |
| 28 | + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect | |
| 29 | + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | |
| 30 | + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | |
| 31 | + github.com/xeipuuv/gojsonschema v1.2.0 // indirect | |
| 32 | + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect | |
| 33 | + go.opentelemetry.io/auto/sdk v1.1.0 // indirect | |
| 34 | + go.opentelemetry.io/otel v1.36.0 // indirect | |
| 35 | + go.opentelemetry.io/otel/metric v1.36.0 // indirect | |
| 36 | + go.opentelemetry.io/otel/sdk v1.36.0 // indirect | |
| 37 | + go.opentelemetry.io/otel/trace v1.36.0 // indirect | |
| 38 | + golang.org/x/sys v0.34.0 // indirect | |
| 39 | +) | |
| new file mode 100644 | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | +module mm | ||
| 2 | + | ||
| 3 | +go 1.25.0 | ||
| 4 | + | ||
| 5 | +require ( | ||
| 6 | + github.com/firebase/genkit/go v1.10.0 | ||
| 7 | + github.com/openai/openai-go v1.8.2 | ||
| 8 | + gopkg.in/yaml.v3 v3.0.1 | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +require ( | ||
| 12 | + github.com/bahlo/generic-list-go v0.2.0 // indirect | ||
| 13 | + github.com/buger/jsonparser v1.1.1 // indirect | ||
| 14 | + github.com/coder/acp-go-sdk v0.13.5 | ||
| 15 | + github.com/coder/websocket v1.8.14 // indirect | ||
| 16 | + github.com/go-logr/logr v1.4.3 // indirect | ||
| 17 | + github.com/go-logr/stdr v1.2.2 // indirect | ||
| 18 | + github.com/goccy/go-yaml v1.17.1 // indirect | ||
| 19 | + github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect | ||
| 20 | + github.com/google/uuid v1.6.0 // indirect | ||
| 21 | + github.com/invopop/jsonschema v0.13.0 // indirect | ||
| 22 | + github.com/mailru/easyjson v0.9.0 // indirect | ||
| 23 | + github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect | ||
| 24 | + github.com/tidwall/gjson v1.18.0 // indirect | ||
| 25 | + github.com/tidwall/match v1.1.1 // indirect | ||
| 26 | + github.com/tidwall/pretty v1.2.1 // indirect | ||
| 27 | + github.com/tidwall/sjson v1.2.5 // indirect | ||
| 28 | + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect | ||
| 29 | + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | ||
| 30 | + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | ||
| 31 | + github.com/xeipuuv/gojsonschema v1.2.0 // indirect | ||
| 32 | + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect | ||
| 33 | + go.opentelemetry.io/auto/sdk v1.1.0 // indirect | ||
| 34 | + go.opentelemetry.io/otel v1.36.0 // indirect | ||
| 35 | + go.opentelemetry.io/otel/metric v1.36.0 // indirect | ||
| 36 | + go.opentelemetry.io/otel/sdk v1.36.0 // indirect | ||
| 37 | + go.opentelemetry.io/otel/trace v1.36.0 // indirect | ||
| 38 | + golang.org/x/sys v0.34.0 // indirect | ||
| 39 | +) | ||
added
go.sum +86 -0 | new file mode 100644 | ||
| @@ -0,0 +1,86 @@ | ||
| 1 | +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= | |
| 2 | +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= | |
| 3 | +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= | |
| 4 | +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= | |
| 5 | +github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= | |
| 6 | +github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= | |
| 7 | +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= | |
| 8 | +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= | |
| 9 | +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 10 | +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 11 | +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 12 | +github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs= | |
| 13 | +github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= | |
| 14 | +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | |
| 15 | +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= | |
| 16 | +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | |
| 17 | +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= | |
| 18 | +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= | |
| 19 | +github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= | |
| 20 | +github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= | |
| 21 | +github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE= | |
| 22 | +github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254/go.mod h1:k8cjJAQWc//ac/bMnzItyOFbfT01tgRTZGgxELCuxEQ= | |
| 23 | +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | |
| 24 | +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | |
| 25 | +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| 26 | +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 27 | +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= | |
| 28 | +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= | |
| 29 | +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | |
| 30 | +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | |
| 31 | +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | |
| 32 | +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | |
| 33 | +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= | |
| 34 | +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= | |
| 35 | +github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= | |
| 36 | +github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU= | |
| 37 | +github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8= | |
| 38 | +github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= | |
| 39 | +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 40 | +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 41 | +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= | |
| 42 | +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= | |
| 43 | +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | |
| 44 | +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | |
| 45 | +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= | |
| 46 | +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | |
| 47 | +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= | |
| 48 | +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= | |
| 49 | +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= | |
| 50 | +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= | |
| 51 | +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= | |
| 52 | +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= | |
| 53 | +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= | |
| 54 | +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= | |
| 55 | +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= | |
| 56 | +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= | |
| 57 | +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= | |
| 58 | +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= | |
| 59 | +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= | |
| 60 | +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= | |
| 61 | +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= | |
| 62 | +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= | |
| 63 | +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= | |
| 64 | +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= | |
| 65 | +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= | |
| 66 | +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= | |
| 67 | +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= | |
| 68 | +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= | |
| 69 | +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= | |
| 70 | +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= | |
| 71 | +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= | |
| 72 | +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= | |
| 73 | +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= | |
| 74 | +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= | |
| 75 | +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= | |
| 76 | +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= | |
| 77 | +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= | |
| 78 | +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | |
| 79 | +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | |
| 80 | +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= | |
| 81 | +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | |
| 82 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 83 | +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | |
| 84 | +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | |
| 85 | +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| 86 | +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
| new file mode 100644 | |||
| @@ -0,0 +1,86 @@ | |||
| 1 | +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= | ||
| 2 | +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= | ||
| 3 | +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= | ||
| 4 | +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= | ||
| 5 | +github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= | ||
| 6 | +github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= | ||
| 7 | +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= | ||
| 8 | +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= | ||
| 9 | +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 10 | +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 11 | +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 12 | +github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs= | ||
| 13 | +github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E= | ||
| 14 | +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | ||
| 15 | +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= | ||
| 16 | +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||
| 17 | +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= | ||
| 18 | +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= | ||
| 19 | +github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= | ||
| 20 | +github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= | ||
| 21 | +github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE= | ||
| 22 | +github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254/go.mod h1:k8cjJAQWc//ac/bMnzItyOFbfT01tgRTZGgxELCuxEQ= | ||
| 23 | +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | ||
| 24 | +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||
| 25 | +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||
| 26 | +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||
| 27 | +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= | ||
| 28 | +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= | ||
| 29 | +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||
| 30 | +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||
| 31 | +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||
| 32 | +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||
| 33 | +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= | ||
| 34 | +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= | ||
| 35 | +github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= | ||
| 36 | +github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU= | ||
| 37 | +github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8= | ||
| 38 | +github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= | ||
| 39 | +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
| 40 | +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
| 41 | +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= | ||
| 42 | +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= | ||
| 43 | +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||
| 44 | +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||
| 45 | +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= | ||
| 46 | +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||
| 47 | +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= | ||
| 48 | +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= | ||
| 49 | +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= | ||
| 50 | +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= | ||
| 51 | +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= | ||
| 52 | +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= | ||
| 53 | +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= | ||
| 54 | +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= | ||
| 55 | +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= | ||
| 56 | +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= | ||
| 57 | +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= | ||
| 58 | +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= | ||
| 59 | +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= | ||
| 60 | +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= | ||
| 61 | +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= | ||
| 62 | +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= | ||
| 63 | +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= | ||
| 64 | +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= | ||
| 65 | +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= | ||
| 66 | +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= | ||
| 67 | +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= | ||
| 68 | +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= | ||
| 69 | +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= | ||
| 70 | +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= | ||
| 71 | +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= | ||
| 72 | +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= | ||
| 73 | +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= | ||
| 74 | +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= | ||
| 75 | +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= | ||
| 76 | +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= | ||
| 77 | +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= | ||
| 78 | +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||
| 79 | +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||
| 80 | +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= | ||
| 81 | +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | ||
| 82 | +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
| 83 | +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||
| 84 | +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||
| 85 | +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 86 | +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
added
install.sh +17 -0 | new file mode 100755 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +set -euo pipefail | |
| 3 | + | |
| 4 | +BINARY_NAME="mm" | |
| 5 | +INSTALL_DIR="/usr/local/bin" | |
| 6 | + | |
| 7 | +echo "Building ${BINARY_NAME}..." | |
| 8 | +go build -o "${BINARY_NAME}" . | |
| 9 | + | |
| 10 | +echo "Installing ${BINARY_NAME} to ${INSTALL_DIR}..." | |
| 11 | +if [[ -w "${INSTALL_DIR}" ]]; then | |
| 12 | + cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" | |
| 13 | +else | |
| 14 | + sudo cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" | |
| 15 | +fi | |
| 16 | + | |
| 17 | +echo "Done. $(which ${BINARY_NAME})" | |
| new file mode 100755 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +set -euo pipefail | ||
| 3 | + | ||
| 4 | +BINARY_NAME="mm" | ||
| 5 | +INSTALL_DIR="/usr/local/bin" | ||
| 6 | + | ||
| 7 | +echo "Building ${BINARY_NAME}..." | ||
| 8 | +go build -o "${BINARY_NAME}" . | ||
| 9 | + | ||
| 10 | +echo "Installing ${BINARY_NAME} to ${INSTALL_DIR}..." | ||
| 11 | +if [[ -w "${INSTALL_DIR}" ]]; then | ||
| 12 | + cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" | ||
| 13 | +else | ||
| 14 | + sudo cp "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" | ||
| 15 | +fi | ||
| 16 | + | ||
| 17 | +echo "Done. $(which ${BINARY_NAME})" | ||
added
internal/acp/acp.go +462 -0 | new file mode 100644 | ||
| @@ -0,0 +1,462 @@ | ||
| 1 | +// Package acp is bob's second front end: the same engine, the same tools and | |
| 2 | +// the same history rules as the terminal REPL (internal/agent), exposed over | |
| 3 | +// the Agent Client Protocol — JSON-RPC 2.0 on stdio, one message per line — | |
| 4 | +// so that an editor (Zed, IntelliJ, Neovim…) can host the agent. | |
| 5 | +// | |
| 6 | +// The division of labour: | |
| 7 | +// | |
| 8 | +// - the SDK (github.com/coder/acp-go-sdk) does the transport: framing, | |
| 9 | +// routing initialize/session/new/session/prompt to the methods below, | |
| 10 | +// correlating the ids of our own requests to the client (permissions); | |
| 11 | +// - this package translates: a prompt turn becomes one engine.Generate call, | |
| 12 | +// and the events the tools emit through ui.Sink become session/update | |
| 13 | +// notifications — text chunks, tool_call lifecycles, diffs; | |
| 14 | +// - nothing else changes: internal/engine and internal/tools are shared with | |
| 15 | +// the terminal front end, which is the point of the exercise. | |
| 16 | +// | |
| 17 | +// stdout carries JSON-RPC only. Everything human — the banner, the [acp] trail, | |
| 18 | +// the SDK's own diagnostics — goes to stderr, which the spec leaves to logging | |
| 19 | +// and which Zed shows in `dev: open acp logs`. | |
| 20 | +package acp | |
| 21 | + | |
| 22 | +import ( | |
| 23 | + "context" | |
| 24 | + "encoding/json" | |
| 25 | + "errors" | |
| 26 | + "fmt" | |
| 27 | + "io" | |
| 28 | + "log/slog" | |
| 29 | + "os" | |
| 30 | + "strings" | |
| 31 | + "sync" | |
| 32 | + | |
| 33 | + "mm/internal/compact" | |
| 34 | + "mm/internal/config" | |
| 35 | + "mm/internal/engine" | |
| 36 | + "mm/internal/mention" | |
| 37 | + history "mm/internal/session" | |
| 38 | + "mm/internal/ui" | |
| 39 | + | |
| 40 | + sdk "github.com/coder/acp-go-sdk" | |
| 41 | + "github.com/firebase/genkit/go/ai" | |
| 42 | +) | |
| 43 | + | |
| 44 | +// version is what initialize reports in agentInfo — the part number, so a | |
| 45 | +// trace says which chapter of the talk produced it. | |
| 46 | +const version = "0.11.0" | |
| 47 | + | |
| 48 | +// session is one conversation with the client. Like the REPL, the history is | |
| 49 | +// plain []*ai.Message with the system prompt first; unlike the REPL there can | |
| 50 | +// be several of them, one per session/new. | |
| 51 | +type session struct { | |
| 52 | + cwd string | |
| 53 | + messages []*ai.Message | |
| 54 | + cancel context.CancelFunc // cancels the turn in flight, if any | |
| 55 | + // allowAlways remembers the tools the user granted for the whole session | |
| 56 | + // ("allow always" in the permission dialog). | |
| 57 | + allowAlways map[string]bool | |
| 58 | +} | |
| 59 | + | |
| 60 | +type front struct { | |
| 61 | + e *engine.Engine | |
| 62 | + system string | |
| 63 | + tools []ai.ToolRef | |
| 64 | + conn *sdk.AgentSideConnection | |
| 65 | + out *responseWriter // what the SDK writes to; see responseWriter | |
| 66 | + | |
| 67 | + mu sync.Mutex // guards sessions and nextID | |
| 68 | + sessions map[sdk.SessionId]*session | |
| 69 | + nextID int | |
| 70 | + | |
| 71 | + // turnMu serialises the turns. Two reasons, both structural: the active | |
| 72 | + // ui.Sink is process-global, and engine.Generate was never designed for two | |
| 73 | + // concurrent turns. The spec leaves this to the agent, and Zed does not | |
| 74 | + // send a second prompt while one is running — a queued turn just waits. | |
| 75 | + turnMu sync.Mutex | |
| 76 | +} | |
| 77 | + | |
| 78 | +var _ sdk.Agent = (*front)(nil) | |
| 79 | + | |
| 80 | +// Run wires the agent side of an ACP connection on stdio and blocks until the | |
| 81 | +// client closes it. Same arguments as agent.Run: main.go's wiring — engine, | |
| 82 | +// system prompt, tool list — is identical for the two front ends. | |
| 83 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) { | |
| 84 | + f := newFront(e, system, tools, os.Stdout, os.Stdin) | |
| 85 | + select { | |
| 86 | + case <-f.conn.Done(): // the editor closed our stdin: we are done | |
| 87 | + case <-ctx.Done(): | |
| 88 | + } | |
| 89 | +} | |
| 90 | + | |
| 91 | +// newFront wires a front end on an arbitrary pair of streams — stdio in Run, | |
| 92 | +// pipes in the tests. w is where WE write (JSON-RPC to the client), r where | |
| 93 | +// we read the client's messages. | |
| 94 | +func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front { | |
| 95 | + f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}} | |
| 96 | + f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}} | |
| 97 | + conn := sdk.NewAgentSideConnection(f, f.out, r) | |
| 98 | + conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))) | |
| 99 | + f.conn = conn | |
| 100 | + return f | |
| 101 | +} | |
| 102 | + | |
| 103 | +// responseWriter is the io.Writer handed to the SDK: the real output plus one | |
| 104 | +// hook per session, run once the session/new response carrying that session | |
| 105 | +// id has been written. It exists for available_commands_update. The spec says | |
| 106 | +// to announce the commands right after the session is created, and the client | |
| 107 | +// (Zed, verified 2026-09-15) drops any session/update for a session whose | |
| 108 | +// session/new response it has not received yet — "Available commands: none". | |
| 109 | +// But the SDK writes the response only when NewSession returns, after anything | |
| 110 | +// the method itself sent, and offers no "after the response" callback. Watching | |
| 111 | +// the bytes is the one place where the order is certain. | |
| 112 | +type responseWriter struct { | |
| 113 | + io.Writer | |
| 114 | + mu sync.Mutex | |
| 115 | + hooks map[sdk.SessionId]func() | |
| 116 | +} | |
| 117 | + | |
| 118 | +// afterResponse registers hook to run — once, in its own goroutine — after the | |
| 119 | +// response that carries sid has been written. | |
| 120 | +func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) { | |
| 121 | + w.mu.Lock() | |
| 122 | + defer w.mu.Unlock() | |
| 123 | + w.hooks[sid] = hook | |
| 124 | +} | |
| 125 | + | |
| 126 | +// Write forwards to the real output, then fires the hook of the session whose | |
| 127 | +// response this line is, if any. The hook runs in a goroutine: the SDK holds | |
| 128 | +// its write lock here, and the hook is about to write a notification. | |
| 129 | +func (w *responseWriter) Write(p []byte) (int, error) { | |
| 130 | + n, err := w.Writer.Write(p) | |
| 131 | + if err != nil { | |
| 132 | + return n, err | |
| 133 | + } | |
| 134 | + if sid, ok := responseSessionID(p); ok { | |
| 135 | + w.mu.Lock() | |
| 136 | + hook := w.hooks[sid] | |
| 137 | + delete(w.hooks, sid) | |
| 138 | + w.mu.Unlock() | |
| 139 | + if hook != nil { | |
| 140 | + go hook() | |
| 141 | + } | |
| 142 | + } | |
| 143 | + return n, nil | |
| 144 | +} | |
| 145 | + | |
| 146 | +// responseSessionID recognises a JSON-RPC response whose result carries a | |
| 147 | +// sessionId — session/new (and session/load, which this agent does not | |
| 148 | +// offer). Notifications have no id and prompt responses no sessionId, so | |
| 149 | +// neither matches. | |
| 150 | +func responseSessionID(line []byte) (sdk.SessionId, bool) { | |
| 151 | + var m struct { | |
| 152 | + ID json.RawMessage `json:"id"` | |
| 153 | + Result struct { | |
| 154 | + SessionId sdk.SessionId `json:"sessionId"` | |
| 155 | + } `json:"result"` | |
| 156 | + } | |
| 157 | + if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" { | |
| 158 | + return "", false | |
| 159 | + } | |
| 160 | + return m.Result.SessionId, true | |
| 161 | +} | |
| 162 | + | |
| 163 | +// --- initialize --------------------------------------------------------------- | |
| 164 | + | |
| 165 | +func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) { | |
| 166 | + fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d\n", | |
| 167 | + clientName(p.ClientInfo), p.ProtocolVersion) | |
| 168 | + return sdk.InitializeResponse{ | |
| 169 | + ProtocolVersion: sdk.ProtocolVersionNumber, // 1 | |
| 170 | + // Everything absent is false: no loadSession, no image/audio prompts, | |
| 171 | + // no MCP over http. Honest capabilities are what lets a client adapt. | |
| 172 | + AgentCapabilities: sdk.AgentCapabilities{}, | |
| 173 | + AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version}, | |
| 174 | + AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into | |
| 175 | + }, nil | |
| 176 | +} | |
| 177 | + | |
| 178 | +func clientName(impl *sdk.Implementation) string { | |
| 179 | + if impl == nil { | |
| 180 | + return "unknown" | |
| 181 | + } | |
| 182 | + return impl.Name | |
| 183 | +} | |
| 184 | + | |
| 185 | +func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) { | |
| 186 | + return sdk.AuthenticateResponse{}, nil | |
| 187 | +} | |
| 188 | + | |
| 189 | +// --- session/new -------------------------------------------------------------- | |
| 190 | + | |
| 191 | +func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | |
| 192 | + f.mu.Lock() | |
| 193 | + f.nextID++ | |
| 194 | + sid := sdk.SessionId(fmt.Sprintf("bob-%d-%d", os.Getpid(), f.nextID)) | |
| 195 | + f.sessions[sid] = &session{ | |
| 196 | + cwd: p.Cwd, // absolute, per the spec; where bash runs for this session | |
| 197 | + messages: history.Fresh(f.system), | |
| 198 | + allowAlways: map[string]bool{}, | |
| 199 | + } | |
| 200 | + f.mu.Unlock() | |
| 201 | + // mcpServers is where the client offers extra tools. bob's whole point is | |
| 202 | + // the opposite — one built-in tool — so they are acknowledged and ignored. | |
| 203 | + fmt.Fprintf(os.Stderr, "[acp] session %s cwd=%s mcpServers=%d (ignored)\n", sid, p.Cwd, len(p.McpServers)) | |
| 204 | + // The slash commands, announced as the spec asks — right after the session | |
| 205 | + // is created — so the editor can list and complete them. "Right after" | |
| 206 | + // means after the response: Zed keys its sessions on the id that response | |
| 207 | + // carries and drops updates for an id it does not know yet. Hence the hook | |
| 208 | + // on the writer rather than a SessionUpdate call here (see responseWriter). | |
| 209 | + // The request context dies with this method; the hook needs one that lives. | |
| 210 | + f.out.afterResponse(sid, func() { | |
| 211 | + _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{ | |
| 212 | + SessionId: sid, | |
| 213 | + Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, | |
| 214 | + }) | |
| 215 | + }) | |
| 216 | + return sdk.NewSessionResponse{SessionId: sid}, nil | |
| 217 | +} | |
| 218 | + | |
| 219 | +// compactCommand is the REPL's /compact, spelled here because ACP has no other | |
| 220 | +// place for it; /new is shared through internal/session. /quit and /abort are | |
| 221 | +// deliberately absent: the editor owns the process and ends it by closing | |
| 222 | +// stdin, and it cancels a turn with its own stop key through session/cancel. | |
| 223 | +const compactCommand = "/compact" | |
| 224 | + | |
| 225 | +// isCommand says whether input, trimmed, is exactly the given command — the | |
| 226 | +// same rule as session.IsNewCommand: "/compact " is the command, "/compact | |
| 227 | +// now" is a question for the model. | |
| 228 | +func isCommand(input, command string) bool { | |
| 229 | + return strings.TrimSpace(input) == command | |
| 230 | +} | |
| 231 | + | |
| 232 | +// availableCommands lists the slash commands this front end handles itself, | |
| 233 | +// in the shape ACP advertises them: bare names, the client adds the slash. | |
| 234 | +// Only what session/prompt actually intercepts belongs here — announcing | |
| 235 | +// /quit or /abort, which the REPL has and this front end does not, would | |
| 236 | +// promise the editor something the next prompt would hand to the model. | |
| 237 | +func availableCommands() []sdk.AvailableCommand { | |
| 238 | + return []sdk.AvailableCommand{ | |
| 239 | + {Name: history.NewCommandName, Description: "Clear the history and start a new session"}, | |
| 240 | + {Name: strings.TrimPrefix(compactCommand, "/"), Description: "Compress the history now, keeping the last turns"}, | |
| 241 | + } | |
| 242 | +} | |
| 243 | + | |
| 244 | +// --- session/prompt ----------------------------------------------------------- | |
| 245 | + | |
| 246 | +func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | |
| 247 | + f.mu.Lock() | |
| 248 | + s, ok := f.sessions[p.SessionId] | |
| 249 | + f.mu.Unlock() | |
| 250 | + if !ok { | |
| 251 | + return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId}) | |
| 252 | + } | |
| 253 | + | |
| 254 | + input := flatten(p.Prompt) | |
| 255 | + | |
| 256 | + f.turnMu.Lock() | |
| 257 | + defer f.turnMu.Unlock() | |
| 258 | + | |
| 259 | + // A slash command is answered here, without a turn: it is the REPL's "only | |
| 260 | + // between two generations" rule, met for free — turnMu guarantees no | |
| 261 | + // generation is running on this session's history while we replace it. | |
| 262 | + switch { | |
| 263 | + case history.IsNewCommand(input): | |
| 264 | + return f.startNewSession(ctx, p.SessionId, s), nil | |
| 265 | + case isCommand(input, compactCommand): | |
| 266 | + return f.compactSession(ctx, p.SessionId, s), nil | |
| 267 | + } | |
| 268 | + | |
| 269 | + // One cancellable context per turn: session/cancel fires s.cancel, the | |
| 270 | + // generation and any pending permission request fall with it, and the spec's | |
| 271 | + // contract — still answer the prompt, with stopReason "cancelled" — is met | |
| 272 | + // at the bottom of this function. | |
| 273 | + turnCtx, cancel := context.WithCancel(ctx) | |
| 274 | + f.mu.Lock() | |
| 275 | + s.cancel = cancel | |
| 276 | + f.mu.Unlock() | |
| 277 | + defer func() { | |
| 278 | + f.mu.Lock() | |
| 279 | + s.cancel = nil | |
| 280 | + f.mu.Unlock() | |
| 281 | + cancel() | |
| 282 | + }() | |
| 283 | + | |
| 284 | + // The sink is what turns the tools' events into session/update messages; | |
| 285 | + // installing it is what switches engine+tools to "ACP mode" for this turn. | |
| 286 | + ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s}) | |
| 287 | + defer ui.SetActive(nil) | |
| 288 | + | |
| 289 | + // A typed "@path" — the editor's picker bypassed, or an editor without | |
| 290 | + // one — is understood like the resource_link the picker would have sent, | |
| 291 | + // against the session's cwd, which is the project the editor opened. | |
| 292 | + input, attached := mention.Expand(input, s.cwd) | |
| 293 | + for _, a := range attached { | |
| 294 | + fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path) | |
| 295 | + } | |
| 296 | + s.messages = append(s.messages, ai.NewUserTextMessage(input)) | |
| 297 | + before := len(s.messages) | |
| 298 | + | |
| 299 | + resp, history, err := f.e.Generate(turnCtx, s.messages, f.tools) | |
| 300 | + | |
| 301 | + // Same rule as the REPL, for the same reason: keep the FULL history even | |
| 302 | + // when the turn failed or was cancelled — the commands that already ran are | |
| 303 | + // part of the conversation, and forgetting them makes the model re-run or | |
| 304 | + // invent them on the next turn. | |
| 305 | + switch { | |
| 306 | + case len(history) > before: | |
| 307 | + s.messages = history | |
| 308 | + case resp != nil && resp.Message != nil: | |
| 309 | + s.messages = append(s.messages, resp.Message) | |
| 310 | + case err != nil: | |
| 311 | + s.messages = s.messages[:before-1] // question without an answer: forget it | |
| 312 | + } | |
| 313 | + | |
| 314 | + // The ⚙ recap of the REPL, one line on stderr: the editor already renders | |
| 315 | + // every tool call, but the trail is what lets `dev: open acp logs` say at a | |
| 316 | + // glance whether the model worked or improvised. | |
| 317 | + turn := s.messages[min(before, len(s.messages)):] | |
| 318 | + fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n", | |
| 319 | + len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn)) | |
| 320 | + | |
| 321 | + switch { | |
| 322 | + case turnCtx.Err() != nil: | |
| 323 | + // The turn was cancelled — and both roads lead here: our Cancel method | |
| 324 | + // fires s.cancel (the child), AND the SDK cancels the request's own | |
| 325 | + // context when session/cancel arrives (the parent — measured: with the | |
| 326 | + // parent-must-be-intact test the REPL uses, a cancelled turn answered | |
| 327 | + // "Internal error: stream error: context canceled" instead of honouring | |
| 328 | + // the spec's contract). Either way the answer is the same: respond to | |
| 329 | + // session/prompt anyway, with stopReason "cancelled". | |
| 330 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil | |
| 331 | + case err != nil: | |
| 332 | + // One line, in the provider's words — the same text the REPL prints | |
| 333 | + // between brackets, delivered as a JSON-RPC error. | |
| 334 | + return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": f.e.Explain(err)}) | |
| 335 | + } | |
| 336 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil | |
| 337 | +} | |
| 338 | + | |
| 339 | +// startNewSession is the ACP side of /new: the session keeps its id, its cwd | |
| 340 | +// and the "allow always" grants — the dialog promised them "for this session", | |
| 341 | +// and the session, as the editor sees it, is the same one — but its history | |
| 342 | +// goes back to the system prompt alone, and the server's token count of the | |
| 343 | +// last context is dropped with it, as after a compression. The one line the | |
| 344 | +// REPL prints travels as an agent message so the editor shows it in the | |
| 345 | +// thread, and the turn ends right there: the model is not consulted. | |
| 346 | +func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse { | |
| 347 | + forgotten := resetSession(s, f.system) | |
| 348 | + f.e.ForgetInputTokens() | |
| 349 | + fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten) | |
| 350 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{ | |
| 351 | + SessionId: sid, | |
| 352 | + Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)), | |
| 353 | + }) | |
| 354 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn} | |
| 355 | +} | |
| 356 | + | |
| 357 | +// compactSession is the ACP side of /compact: the REPL's compactHistory with | |
| 358 | +// the report sent as an agent message instead of printed. Forced, like the | |
| 359 | +// REPL's — the threshold is for the automatic path, which this front end does | |
| 360 | +// not have — so "nothing to compact" is said rather than swallowed. Runs under | |
| 361 | +// turnMu: Genkit is not holding this history. | |
| 362 | +func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse { | |
| 363 | + cfg := config.Cfg.Context | |
| 364 | + line, compressed := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) { | |
| 365 | + return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens) | |
| 366 | + }, f.e.Explain) | |
| 367 | + if compressed { | |
| 368 | + f.e.ForgetInputTokens() // the server's measure described the history just replaced | |
| 369 | + } | |
| 370 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line) | |
| 371 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{ | |
| 372 | + SessionId: sid, | |
| 373 | + Update: sdk.UpdateAgentMessageText(line), | |
| 374 | + }) | |
| 375 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn} | |
| 376 | +} | |
| 377 | + | |
| 378 | +// compactHistory compresses the session's history in place when there is | |
| 379 | +// something to compress, and returns the one line to show — the REPL's words, | |
| 380 | +// so the editor and the terminal agree — and whether the history changed. | |
| 381 | +// Kept apart from compactSession, with the summariser and the error explainer | |
| 382 | +// injected, so the three outcomes are testable without an engine or a | |
| 383 | +// connection. | |
| 384 | +func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) { | |
| 385 | + res, err := compact.Compact(ctx, s.messages, cfg, summarize) | |
| 386 | + switch { | |
| 387 | + case errors.Is(err, compact.ErrNothingToCompact): | |
| 388 | + return fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(s.messages), cfg.KeepLastTurns), false | |
| 389 | + case err != nil: | |
| 390 | + return fmt.Sprintf("[compact: failed, history kept: %s]", explain(err)), false | |
| 391 | + } | |
| 392 | + s.messages = res.Messages | |
| 393 | + return "🗜️ " + res.Report(), true | |
| 394 | +} | |
| 395 | + | |
| 396 | +// resetSession replaces the session's history with a fresh one and returns how | |
| 397 | +// many messages were forgotten. Kept apart from startNewSession so the rule | |
| 398 | +// — history reset, permissions kept — is testable without a connection. | |
| 399 | +func resetSession(s *session, system string) int { | |
| 400 | + forgotten := history.Forgotten(s.messages) | |
| 401 | + s.messages = history.Fresh(system) | |
| 402 | + return forgotten | |
| 403 | +} | |
| 404 | + | |
| 405 | +// flatten turns the prompt's content blocks into the plain text bob reads. | |
| 406 | +// Text is taken as-is; a resource_link (a file the user @-mentioned through | |
| 407 | +// the editor's picker) becomes its path, in the same shape mention.Expand | |
| 408 | +// gives a typed @path — the model has tools to read it, that is the whole | |
| 409 | +// idea; anything else (image, audio, embedded resource) was not announced in | |
| 410 | +// our capabilities, so a compliant client never sends it. | |
| 411 | +func flatten(blocks []sdk.ContentBlock) string { | |
| 412 | + var b strings.Builder | |
| 413 | + for _, c := range blocks { | |
| 414 | + switch { | |
| 415 | + case c.Text != nil: | |
| 416 | + b.WriteString(c.Text.Text) | |
| 417 | + case c.ResourceLink != nil: | |
| 418 | + fmt.Fprintf(&b, "\n[attached file: %s]", strings.TrimPrefix(c.ResourceLink.Uri, "file://")) | |
| 419 | + } | |
| 420 | + } | |
| 421 | + return b.String() | |
| 422 | +} | |
| 423 | + | |
| 424 | +// --- session/cancel ----------------------------------------------------------- | |
| 425 | + | |
| 426 | +func (f *front) Cancel(_ context.Context, p sdk.CancelNotification) error { | |
| 427 | + f.mu.Lock() | |
| 428 | + defer f.mu.Unlock() | |
| 429 | + if s, ok := f.sessions[p.SessionId]; ok && s.cancel != nil { | |
| 430 | + s.cancel() | |
| 431 | + } | |
| 432 | + return nil | |
| 433 | +} | |
| 434 | + | |
| 435 | +// --- the rest of sdk.Agent: not supported, and saying so ----------------------- | |
| 436 | +// | |
| 437 | +// The SDK requires the full interface; answering "method not found" is the | |
| 438 | +// protocol's way of saying a capability we never announced is indeed absent. | |
| 439 | + | |
| 440 | +func (f *front) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) { | |
| 441 | + return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode) | |
| 442 | +} | |
| 443 | + | |
| 444 | +func (f *front) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) { | |
| 445 | + return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption) | |
| 446 | +} | |
| 447 | + | |
| 448 | +func (f *front) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) { | |
| 449 | + return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList) | |
| 450 | +} | |
| 451 | + | |
| 452 | +func (f *front) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | |
| 453 | + return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume) | |
| 454 | +} | |
| 455 | + | |
| 456 | +func (f *front) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) { | |
| 457 | + return sdk.CloseSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionClose) | |
| 458 | +} | |
| 459 | + | |
| 460 | +func (f *front) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) { | |
| 461 | + return sdk.LogoutResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodLogout) | |
| 462 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,462 @@ | |||
| 1 | +// Package acp is bob's second front end: the same engine, the same tools and | ||
| 2 | +// the same history rules as the terminal REPL (internal/agent), exposed over | ||
| 3 | +// the Agent Client Protocol — JSON-RPC 2.0 on stdio, one message per line — | ||
| 4 | +// so that an editor (Zed, IntelliJ, Neovim…) can host the agent. | ||
| 5 | +// | ||
| 6 | +// The division of labour: | ||
| 7 | +// | ||
| 8 | +// - the SDK (github.com/coder/acp-go-sdk) does the transport: framing, | ||
| 9 | +// routing initialize/session/new/session/prompt to the methods below, | ||
| 10 | +// correlating the ids of our own requests to the client (permissions); | ||
| 11 | +// - this package translates: a prompt turn becomes one engine.Generate call, | ||
| 12 | +// and the events the tools emit through ui.Sink become session/update | ||
| 13 | +// notifications — text chunks, tool_call lifecycles, diffs; | ||
| 14 | +// - nothing else changes: internal/engine and internal/tools are shared with | ||
| 15 | +// the terminal front end, which is the point of the exercise. | ||
| 16 | +// | ||
| 17 | +// stdout carries JSON-RPC only. Everything human — the banner, the [acp] trail, | ||
| 18 | +// the SDK's own diagnostics — goes to stderr, which the spec leaves to logging | ||
| 19 | +// and which Zed shows in `dev: open acp logs`. | ||
| 20 | +package acp | ||
| 21 | + | ||
| 22 | +import ( | ||
| 23 | + "context" | ||
| 24 | + "encoding/json" | ||
| 25 | + "errors" | ||
| 26 | + "fmt" | ||
| 27 | + "io" | ||
| 28 | + "log/slog" | ||
| 29 | + "os" | ||
| 30 | + "strings" | ||
| 31 | + "sync" | ||
| 32 | + | ||
| 33 | + "mm/internal/compact" | ||
| 34 | + "mm/internal/config" | ||
| 35 | + "mm/internal/engine" | ||
| 36 | + "mm/internal/mention" | ||
| 37 | + history "mm/internal/session" | ||
| 38 | + "mm/internal/ui" | ||
| 39 | + | ||
| 40 | + sdk "github.com/coder/acp-go-sdk" | ||
| 41 | + "github.com/firebase/genkit/go/ai" | ||
| 42 | +) | ||
| 43 | + | ||
| 44 | +// version is what initialize reports in agentInfo — the part number, so a | ||
| 45 | +// trace says which chapter of the talk produced it. | ||
| 46 | +const version = "0.11.0" | ||
| 47 | + | ||
| 48 | +// session is one conversation with the client. Like the REPL, the history is | ||
| 49 | +// plain []*ai.Message with the system prompt first; unlike the REPL there can | ||
| 50 | +// be several of them, one per session/new. | ||
| 51 | +type session struct { | ||
| 52 | + cwd string | ||
| 53 | + messages []*ai.Message | ||
| 54 | + cancel context.CancelFunc // cancels the turn in flight, if any | ||
| 55 | + // allowAlways remembers the tools the user granted for the whole session | ||
| 56 | + // ("allow always" in the permission dialog). | ||
| 57 | + allowAlways map[string]bool | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +type front struct { | ||
| 61 | + e *engine.Engine | ||
| 62 | + system string | ||
| 63 | + tools []ai.ToolRef | ||
| 64 | + conn *sdk.AgentSideConnection | ||
| 65 | + out *responseWriter // what the SDK writes to; see responseWriter | ||
| 66 | + | ||
| 67 | + mu sync.Mutex // guards sessions and nextID | ||
| 68 | + sessions map[sdk.SessionId]*session | ||
| 69 | + nextID int | ||
| 70 | + | ||
| 71 | + // turnMu serialises the turns. Two reasons, both structural: the active | ||
| 72 | + // ui.Sink is process-global, and engine.Generate was never designed for two | ||
| 73 | + // concurrent turns. The spec leaves this to the agent, and Zed does not | ||
| 74 | + // send a second prompt while one is running — a queued turn just waits. | ||
| 75 | + turnMu sync.Mutex | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +var _ sdk.Agent = (*front)(nil) | ||
| 79 | + | ||
| 80 | +// Run wires the agent side of an ACP connection on stdio and blocks until the | ||
| 81 | +// client closes it. Same arguments as agent.Run: main.go's wiring — engine, | ||
| 82 | +// system prompt, tool list — is identical for the two front ends. | ||
| 83 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef) { | ||
| 84 | + f := newFront(e, system, tools, os.Stdout, os.Stdin) | ||
| 85 | + select { | ||
| 86 | + case <-f.conn.Done(): // the editor closed our stdin: we are done | ||
| 87 | + case <-ctx.Done(): | ||
| 88 | + } | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +// newFront wires a front end on an arbitrary pair of streams — stdio in Run, | ||
| 92 | +// pipes in the tests. w is where WE write (JSON-RPC to the client), r where | ||
| 93 | +// we read the client's messages. | ||
| 94 | +func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front { | ||
| 95 | + f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}} | ||
| 96 | + f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}} | ||
| 97 | + conn := sdk.NewAgentSideConnection(f, f.out, r) | ||
| 98 | + conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))) | ||
| 99 | + f.conn = conn | ||
| 100 | + return f | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +// responseWriter is the io.Writer handed to the SDK: the real output plus one | ||
| 104 | +// hook per session, run once the session/new response carrying that session | ||
| 105 | +// id has been written. It exists for available_commands_update. The spec says | ||
| 106 | +// to announce the commands right after the session is created, and the client | ||
| 107 | +// (Zed, verified 2026-09-15) drops any session/update for a session whose | ||
| 108 | +// session/new response it has not received yet — "Available commands: none". | ||
| 109 | +// But the SDK writes the response only when NewSession returns, after anything | ||
| 110 | +// the method itself sent, and offers no "after the response" callback. Watching | ||
| 111 | +// the bytes is the one place where the order is certain. | ||
| 112 | +type responseWriter struct { | ||
| 113 | + io.Writer | ||
| 114 | + mu sync.Mutex | ||
| 115 | + hooks map[sdk.SessionId]func() | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +// afterResponse registers hook to run — once, in its own goroutine — after the | ||
| 119 | +// response that carries sid has been written. | ||
| 120 | +func (w *responseWriter) afterResponse(sid sdk.SessionId, hook func()) { | ||
| 121 | + w.mu.Lock() | ||
| 122 | + defer w.mu.Unlock() | ||
| 123 | + w.hooks[sid] = hook | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +// Write forwards to the real output, then fires the hook of the session whose | ||
| 127 | +// response this line is, if any. The hook runs in a goroutine: the SDK holds | ||
| 128 | +// its write lock here, and the hook is about to write a notification. | ||
| 129 | +func (w *responseWriter) Write(p []byte) (int, error) { | ||
| 130 | + n, err := w.Writer.Write(p) | ||
| 131 | + if err != nil { | ||
| 132 | + return n, err | ||
| 133 | + } | ||
| 134 | + if sid, ok := responseSessionID(p); ok { | ||
| 135 | + w.mu.Lock() | ||
| 136 | + hook := w.hooks[sid] | ||
| 137 | + delete(w.hooks, sid) | ||
| 138 | + w.mu.Unlock() | ||
| 139 | + if hook != nil { | ||
| 140 | + go hook() | ||
| 141 | + } | ||
| 142 | + } | ||
| 143 | + return n, nil | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// responseSessionID recognises a JSON-RPC response whose result carries a | ||
| 147 | +// sessionId — session/new (and session/load, which this agent does not | ||
| 148 | +// offer). Notifications have no id and prompt responses no sessionId, so | ||
| 149 | +// neither matches. | ||
| 150 | +func responseSessionID(line []byte) (sdk.SessionId, bool) { | ||
| 151 | + var m struct { | ||
| 152 | + ID json.RawMessage `json:"id"` | ||
| 153 | + Result struct { | ||
| 154 | + SessionId sdk.SessionId `json:"sessionId"` | ||
| 155 | + } `json:"result"` | ||
| 156 | + } | ||
| 157 | + if json.Unmarshal(line, &m) != nil || len(m.ID) == 0 || m.Result.SessionId == "" { | ||
| 158 | + return "", false | ||
| 159 | + } | ||
| 160 | + return m.Result.SessionId, true | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +// --- initialize --------------------------------------------------------------- | ||
| 164 | + | ||
| 165 | +func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.InitializeResponse, error) { | ||
| 166 | + fmt.Fprintf(os.Stderr, "[acp] initialize: client %q protocolVersion=%d\n", | ||
| 167 | + clientName(p.ClientInfo), p.ProtocolVersion) | ||
| 168 | + return sdk.InitializeResponse{ | ||
| 169 | + ProtocolVersion: sdk.ProtocolVersionNumber, // 1 | ||
| 170 | + // Everything absent is false: no loadSession, no image/audio prompts, | ||
| 171 | + // no MCP over http. Honest capabilities are what lets a client adapt. | ||
| 172 | + AgentCapabilities: sdk.AgentCapabilities{}, | ||
| 173 | + AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version}, | ||
| 174 | + AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into | ||
| 175 | + }, nil | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +func clientName(impl *sdk.Implementation) string { | ||
| 179 | + if impl == nil { | ||
| 180 | + return "unknown" | ||
| 181 | + } | ||
| 182 | + return impl.Name | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.AuthenticateResponse, error) { | ||
| 186 | + return sdk.AuthenticateResponse{}, nil | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +// --- session/new -------------------------------------------------------------- | ||
| 190 | + | ||
| 191 | +func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) { | ||
| 192 | + f.mu.Lock() | ||
| 193 | + f.nextID++ | ||
| 194 | + sid := sdk.SessionId(fmt.Sprintf("bob-%d-%d", os.Getpid(), f.nextID)) | ||
| 195 | + f.sessions[sid] = &session{ | ||
| 196 | + cwd: p.Cwd, // absolute, per the spec; where bash runs for this session | ||
| 197 | + messages: history.Fresh(f.system), | ||
| 198 | + allowAlways: map[string]bool{}, | ||
| 199 | + } | ||
| 200 | + f.mu.Unlock() | ||
| 201 | + // mcpServers is where the client offers extra tools. bob's whole point is | ||
| 202 | + // the opposite — one built-in tool — so they are acknowledged and ignored. | ||
| 203 | + fmt.Fprintf(os.Stderr, "[acp] session %s cwd=%s mcpServers=%d (ignored)\n", sid, p.Cwd, len(p.McpServers)) | ||
| 204 | + // The slash commands, announced as the spec asks — right after the session | ||
| 205 | + // is created — so the editor can list and complete them. "Right after" | ||
| 206 | + // means after the response: Zed keys its sessions on the id that response | ||
| 207 | + // carries and drops updates for an id it does not know yet. Hence the hook | ||
| 208 | + // on the writer rather than a SessionUpdate call here (see responseWriter). | ||
| 209 | + // The request context dies with this method; the hook needs one that lives. | ||
| 210 | + f.out.afterResponse(sid, func() { | ||
| 211 | + _ = f.conn.SessionUpdate(context.WithoutCancel(ctx), sdk.SessionNotification{ | ||
| 212 | + SessionId: sid, | ||
| 213 | + Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}}, | ||
| 214 | + }) | ||
| 215 | + }) | ||
| 216 | + return sdk.NewSessionResponse{SessionId: sid}, nil | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +// compactCommand is the REPL's /compact, spelled here because ACP has no other | ||
| 220 | +// place for it; /new is shared through internal/session. /quit and /abort are | ||
| 221 | +// deliberately absent: the editor owns the process and ends it by closing | ||
| 222 | +// stdin, and it cancels a turn with its own stop key through session/cancel. | ||
| 223 | +const compactCommand = "/compact" | ||
| 224 | + | ||
| 225 | +// isCommand says whether input, trimmed, is exactly the given command — the | ||
| 226 | +// same rule as session.IsNewCommand: "/compact " is the command, "/compact | ||
| 227 | +// now" is a question for the model. | ||
| 228 | +func isCommand(input, command string) bool { | ||
| 229 | + return strings.TrimSpace(input) == command | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +// availableCommands lists the slash commands this front end handles itself, | ||
| 233 | +// in the shape ACP advertises them: bare names, the client adds the slash. | ||
| 234 | +// Only what session/prompt actually intercepts belongs here — announcing | ||
| 235 | +// /quit or /abort, which the REPL has and this front end does not, would | ||
| 236 | +// promise the editor something the next prompt would hand to the model. | ||
| 237 | +func availableCommands() []sdk.AvailableCommand { | ||
| 238 | + return []sdk.AvailableCommand{ | ||
| 239 | + {Name: history.NewCommandName, Description: "Clear the history and start a new session"}, | ||
| 240 | + {Name: strings.TrimPrefix(compactCommand, "/"), Description: "Compress the history now, keeping the last turns"}, | ||
| 241 | + } | ||
| 242 | +} | ||
| 243 | + | ||
| 244 | +// --- session/prompt ----------------------------------------------------------- | ||
| 245 | + | ||
| 246 | +func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResponse, error) { | ||
| 247 | + f.mu.Lock() | ||
| 248 | + s, ok := f.sessions[p.SessionId] | ||
| 249 | + f.mu.Unlock() | ||
| 250 | + if !ok { | ||
| 251 | + return sdk.PromptResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId}) | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + input := flatten(p.Prompt) | ||
| 255 | + | ||
| 256 | + f.turnMu.Lock() | ||
| 257 | + defer f.turnMu.Unlock() | ||
| 258 | + | ||
| 259 | + // A slash command is answered here, without a turn: it is the REPL's "only | ||
| 260 | + // between two generations" rule, met for free — turnMu guarantees no | ||
| 261 | + // generation is running on this session's history while we replace it. | ||
| 262 | + switch { | ||
| 263 | + case history.IsNewCommand(input): | ||
| 264 | + return f.startNewSession(ctx, p.SessionId, s), nil | ||
| 265 | + case isCommand(input, compactCommand): | ||
| 266 | + return f.compactSession(ctx, p.SessionId, s), nil | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + // One cancellable context per turn: session/cancel fires s.cancel, the | ||
| 270 | + // generation and any pending permission request fall with it, and the spec's | ||
| 271 | + // contract — still answer the prompt, with stopReason "cancelled" — is met | ||
| 272 | + // at the bottom of this function. | ||
| 273 | + turnCtx, cancel := context.WithCancel(ctx) | ||
| 274 | + f.mu.Lock() | ||
| 275 | + s.cancel = cancel | ||
| 276 | + f.mu.Unlock() | ||
| 277 | + defer func() { | ||
| 278 | + f.mu.Lock() | ||
| 279 | + s.cancel = nil | ||
| 280 | + f.mu.Unlock() | ||
| 281 | + cancel() | ||
| 282 | + }() | ||
| 283 | + | ||
| 284 | + // The sink is what turns the tools' events into session/update messages; | ||
| 285 | + // installing it is what switches engine+tools to "ACP mode" for this turn. | ||
| 286 | + ui.SetActive(&sink{f: f, ctx: turnCtx, sid: p.SessionId, s: s}) | ||
| 287 | + defer ui.SetActive(nil) | ||
| 288 | + | ||
| 289 | + // A typed "@path" — the editor's picker bypassed, or an editor without | ||
| 290 | + // one — is understood like the resource_link the picker would have sent, | ||
| 291 | + // against the session's cwd, which is the project the editor opened. | ||
| 292 | + input, attached := mention.Expand(input, s.cwd) | ||
| 293 | + for _, a := range attached { | ||
| 294 | + fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path) | ||
| 295 | + } | ||
| 296 | + s.messages = append(s.messages, ai.NewUserTextMessage(input)) | ||
| 297 | + before := len(s.messages) | ||
| 298 | + | ||
| 299 | + resp, history, err := f.e.Generate(turnCtx, s.messages, f.tools) | ||
| 300 | + | ||
| 301 | + // Same rule as the REPL, for the same reason: keep the FULL history even | ||
| 302 | + // when the turn failed or was cancelled — the commands that already ran are | ||
| 303 | + // part of the conversation, and forgetting them makes the model re-run or | ||
| 304 | + // invent them on the next turn. | ||
| 305 | + switch { | ||
| 306 | + case len(history) > before: | ||
| 307 | + s.messages = history | ||
| 308 | + case resp != nil && resp.Message != nil: | ||
| 309 | + s.messages = append(s.messages, resp.Message) | ||
| 310 | + case err != nil: | ||
| 311 | + s.messages = s.messages[:before-1] // question without an answer: forget it | ||
| 312 | + } | ||
| 313 | + | ||
| 314 | + // The ⚙ recap of the REPL, one line on stderr: the editor already renders | ||
| 315 | + // every tool call, but the trail is what lets `dev: open acp logs` say at a | ||
| 316 | + // glance whether the model worked or improvised. | ||
| 317 | + turn := s.messages[min(before, len(s.messages)):] | ||
| 318 | + fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n", | ||
| 319 | + len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn)) | ||
| 320 | + | ||
| 321 | + switch { | ||
| 322 | + case turnCtx.Err() != nil: | ||
| 323 | + // The turn was cancelled — and both roads lead here: our Cancel method | ||
| 324 | + // fires s.cancel (the child), AND the SDK cancels the request's own | ||
| 325 | + // context when session/cancel arrives (the parent — measured: with the | ||
| 326 | + // parent-must-be-intact test the REPL uses, a cancelled turn answered | ||
| 327 | + // "Internal error: stream error: context canceled" instead of honouring | ||
| 328 | + // the spec's contract). Either way the answer is the same: respond to | ||
| 329 | + // session/prompt anyway, with stopReason "cancelled". | ||
| 330 | + return sdk.PromptResponse{StopReason: sdk.StopReasonCancelled}, nil | ||
| 331 | + case err != nil: | ||
| 332 | + // One line, in the provider's words — the same text the REPL prints | ||
| 333 | + // between brackets, delivered as a JSON-RPC error. | ||
| 334 | + return sdk.PromptResponse{}, sdk.NewInternalError(map[string]any{"error": f.e.Explain(err)}) | ||
| 335 | + } | ||
| 336 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil | ||
| 337 | +} | ||
| 338 | + | ||
| 339 | +// startNewSession is the ACP side of /new: the session keeps its id, its cwd | ||
| 340 | +// and the "allow always" grants — the dialog promised them "for this session", | ||
| 341 | +// and the session, as the editor sees it, is the same one — but its history | ||
| 342 | +// goes back to the system prompt alone, and the server's token count of the | ||
| 343 | +// last context is dropped with it, as after a compression. The one line the | ||
| 344 | +// REPL prints travels as an agent message so the editor shows it in the | ||
| 345 | +// thread, and the turn ends right there: the model is not consulted. | ||
| 346 | +func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse { | ||
| 347 | + forgotten := resetSession(s, f.system) | ||
| 348 | + f.e.ForgetInputTokens() | ||
| 349 | + fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten) | ||
| 350 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{ | ||
| 351 | + SessionId: sid, | ||
| 352 | + Update: sdk.UpdateAgentMessageText(fmt.Sprintf("🆕 New session: %d message(s) forgotten.", forgotten)), | ||
| 353 | + }) | ||
| 354 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn} | ||
| 355 | +} | ||
| 356 | + | ||
| 357 | +// compactSession is the ACP side of /compact: the REPL's compactHistory with | ||
| 358 | +// the report sent as an agent message instead of printed. Forced, like the | ||
| 359 | +// REPL's — the threshold is for the automatic path, which this front end does | ||
| 360 | +// not have — so "nothing to compact" is said rather than swallowed. Runs under | ||
| 361 | +// turnMu: Genkit is not holding this history. | ||
| 362 | +func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse { | ||
| 363 | + cfg := config.Cfg.Context | ||
| 364 | + line, compressed := compactHistory(ctx, s, cfg, func(ctx context.Context, request []*ai.Message) (string, error) { | ||
| 365 | + return f.e.Summarize(ctx, request, cfg.SummaryMaxTokens) | ||
| 366 | + }, f.e.Explain) | ||
| 367 | + if compressed { | ||
| 368 | + f.e.ForgetInputTokens() // the server's measure described the history just replaced | ||
| 369 | + } | ||
| 370 | + fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line) | ||
| 371 | + _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{ | ||
| 372 | + SessionId: sid, | ||
| 373 | + Update: sdk.UpdateAgentMessageText(line), | ||
| 374 | + }) | ||
| 375 | + return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn} | ||
| 376 | +} | ||
| 377 | + | ||
| 378 | +// compactHistory compresses the session's history in place when there is | ||
| 379 | +// something to compress, and returns the one line to show — the REPL's words, | ||
| 380 | +// so the editor and the terminal agree — and whether the history changed. | ||
| 381 | +// Kept apart from compactSession, with the summariser and the error explainer | ||
| 382 | +// injected, so the three outcomes are testable without an engine or a | ||
| 383 | +// connection. | ||
| 384 | +func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) { | ||
| 385 | + res, err := compact.Compact(ctx, s.messages, cfg, summarize) | ||
| 386 | + switch { | ||
| 387 | + case errors.Is(err, compact.ErrNothingToCompact): | ||
| 388 | + return fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(s.messages), cfg.KeepLastTurns), false | ||
| 389 | + case err != nil: | ||
| 390 | + return fmt.Sprintf("[compact: failed, history kept: %s]", explain(err)), false | ||
| 391 | + } | ||
| 392 | + s.messages = res.Messages | ||
| 393 | + return "🗜️ " + res.Report(), true | ||
| 394 | +} | ||
| 395 | + | ||
| 396 | +// resetSession replaces the session's history with a fresh one and returns how | ||
| 397 | +// many messages were forgotten. Kept apart from startNewSession so the rule | ||
| 398 | +// — history reset, permissions kept — is testable without a connection. | ||
| 399 | +func resetSession(s *session, system string) int { | ||
| 400 | + forgotten := history.Forgotten(s.messages) | ||
| 401 | + s.messages = history.Fresh(system) | ||
| 402 | + return forgotten | ||
| 403 | +} | ||
| 404 | + | ||
| 405 | +// flatten turns the prompt's content blocks into the plain text bob reads. | ||
| 406 | +// Text is taken as-is; a resource_link (a file the user @-mentioned through | ||
| 407 | +// the editor's picker) becomes its path, in the same shape mention.Expand | ||
| 408 | +// gives a typed @path — the model has tools to read it, that is the whole | ||
| 409 | +// idea; anything else (image, audio, embedded resource) was not announced in | ||
| 410 | +// our capabilities, so a compliant client never sends it. | ||
| 411 | +func flatten(blocks []sdk.ContentBlock) string { | ||
| 412 | + var b strings.Builder | ||
| 413 | + for _, c := range blocks { | ||
| 414 | + switch { | ||
| 415 | + case c.Text != nil: | ||
| 416 | + b.WriteString(c.Text.Text) | ||
| 417 | + case c.ResourceLink != nil: | ||
| 418 | + fmt.Fprintf(&b, "\n[attached file: %s]", strings.TrimPrefix(c.ResourceLink.Uri, "file://")) | ||
| 419 | + } | ||
| 420 | + } | ||
| 421 | + return b.String() | ||
| 422 | +} | ||
| 423 | + | ||
| 424 | +// --- session/cancel ----------------------------------------------------------- | ||
| 425 | + | ||
| 426 | +func (f *front) Cancel(_ context.Context, p sdk.CancelNotification) error { | ||
| 427 | + f.mu.Lock() | ||
| 428 | + defer f.mu.Unlock() | ||
| 429 | + if s, ok := f.sessions[p.SessionId]; ok && s.cancel != nil { | ||
| 430 | + s.cancel() | ||
| 431 | + } | ||
| 432 | + return nil | ||
| 433 | +} | ||
| 434 | + | ||
| 435 | +// --- the rest of sdk.Agent: not supported, and saying so ----------------------- | ||
| 436 | +// | ||
| 437 | +// The SDK requires the full interface; answering "method not found" is the | ||
| 438 | +// protocol's way of saying a capability we never announced is indeed absent. | ||
| 439 | + | ||
| 440 | +func (f *front) SetSessionMode(context.Context, sdk.SetSessionModeRequest) (sdk.SetSessionModeResponse, error) { | ||
| 441 | + return sdk.SetSessionModeResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetMode) | ||
| 442 | +} | ||
| 443 | + | ||
| 444 | +func (f *front) SetSessionConfigOption(context.Context, sdk.SetSessionConfigOptionRequest) (sdk.SetSessionConfigOptionResponse, error) { | ||
| 445 | + return sdk.SetSessionConfigOptionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionSetConfigOption) | ||
| 446 | +} | ||
| 447 | + | ||
| 448 | +func (f *front) ListSessions(context.Context, sdk.ListSessionsRequest) (sdk.ListSessionsResponse, error) { | ||
| 449 | + return sdk.ListSessionsResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionList) | ||
| 450 | +} | ||
| 451 | + | ||
| 452 | +func (f *front) ResumeSession(context.Context, sdk.ResumeSessionRequest) (sdk.ResumeSessionResponse, error) { | ||
| 453 | + return sdk.ResumeSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionResume) | ||
| 454 | +} | ||
| 455 | + | ||
| 456 | +func (f *front) CloseSession(context.Context, sdk.CloseSessionRequest) (sdk.CloseSessionResponse, error) { | ||
| 457 | + return sdk.CloseSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionClose) | ||
| 458 | +} | ||
| 459 | + | ||
| 460 | +func (f *front) Logout(context.Context, sdk.LogoutRequest) (sdk.LogoutResponse, error) { | ||
| 461 | + return sdk.LogoutResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodLogout) | ||
| 462 | +} | ||
added
internal/acp/acp_test.go +308 -0 | new file mode 100644 | ||
| @@ -0,0 +1,308 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bufio" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "errors" | |
| 8 | + "io" | |
| 9 | + "strings" | |
| 10 | + "testing" | |
| 11 | + "time" | |
| 12 | + | |
| 13 | + "mm/internal/config" | |
| 14 | + history "mm/internal/session" | |
| 15 | + | |
| 16 | + sdk "github.com/coder/acp-go-sdk" | |
| 17 | + "github.com/firebase/genkit/go/ai" | |
| 18 | +) | |
| 19 | + | |
| 20 | +// A prompt mixes text and attached files (resource_link): the text is kept | |
| 21 | +// as-is and the attachment becomes a path the model can read with its tools — | |
| 22 | +// bob has no embeddedContext capability, so the content itself never travels. | |
| 23 | +func TestFlattenTextAndResourceLink(t *testing.T) { | |
| 24 | + got := flatten([]sdk.ContentBlock{ | |
| 25 | + sdk.TextBlock("fix the greeting"), | |
| 26 | + sdk.ResourceLinkBlock("main.go", "file:///work/project/hello/main.go"), | |
| 27 | + }) | |
| 28 | + want := "fix the greeting\n[attached file: /work/project/hello/main.go]" | |
| 29 | + if got != want { | |
| 30 | + t.Errorf("flatten:\n got %q\nwant %q", got, want) | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +// Unknown kinds must render, not fail: the kind only drives the client's | |
| 35 | +// display, so "execute" is the safe default. | |
| 36 | +func TestToolKindDefaultsToExecute(t *testing.T) { | |
| 37 | + cases := map[string]sdk.ToolKind{ | |
| 38 | + "read": sdk.ToolKindRead, | |
| 39 | + "edit": sdk.ToolKindEdit, | |
| 40 | + "execute": sdk.ToolKindExecute, | |
| 41 | + "": sdk.ToolKindExecute, | |
| 42 | + "exotic": sdk.ToolKindExecute, | |
| 43 | + } | |
| 44 | + for in, want := range cases { | |
| 45 | + if got := toolKind(in); got != want { | |
| 46 | + t.Errorf("toolKind(%q) = %q, want %q", in, got, want) | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +// The list the editor is shown must be exactly what session/prompt | |
| 52 | +// intercepts — no more (a /quit or /abort promised here would reach the model | |
| 53 | +// as a question; the editor's own stop key cancels a turn) and no less (a | |
| 54 | +// command that works but is not listed is one the user never finds). Names | |
| 55 | +// are bare: ACP clients add the slash. | |
| 56 | +func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) { | |
| 57 | + want := []string{history.NewCommandName, "compact"} | |
| 58 | + cmds := availableCommands() | |
| 59 | + if len(cmds) != len(want) { | |
| 60 | + t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds) | |
| 61 | + } | |
| 62 | + for i, c := range cmds { | |
| 63 | + if c.Name != want[i] { | |
| 64 | + t.Errorf("command %d is %q, want %q", i, c.Name, want[i]) | |
| 65 | + } | |
| 66 | + if c.Description == "" { | |
| 67 | + t.Errorf("%q has no description: the editor would show a bare name", c.Name) | |
| 68 | + } | |
| 69 | + if c.Input != nil { | |
| 70 | + t.Errorf("%q takes no input, but an input spec is advertised", c.Name) | |
| 71 | + } | |
| 72 | + // Each advertised name must be recognised when it comes back as "/name". | |
| 73 | + typed := "/" + c.Name | |
| 74 | + if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) { | |
| 75 | + t.Errorf("%q is advertised but session/prompt would hand it to the model", typed) | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +// Same rule as /new: the command is the whole line, spaces aside. | |
| 81 | +func TestIsCommandTrimsAndRequiresExactMatch(t *testing.T) { | |
| 82 | + cases := map[string]bool{ | |
| 83 | + "/compact": true, | |
| 84 | + " /compact\n": true, | |
| 85 | + "/compact now": false, | |
| 86 | + "/compacts": false, | |
| 87 | + "please /compact": false, | |
| 88 | + } | |
| 89 | + for in, want := range cases { | |
| 90 | + if got := isCommand(in, compactCommand); got != want { | |
| 91 | + t.Errorf("isCommand(%q) = %v, want %v", in, got, want) | |
| 92 | + } | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +// resetSession is what /new does to a session: the history goes back to the | |
| 97 | +// system prompt alone, the count says how much was dropped, and everything | |
| 98 | +// that is NOT the history — cwd, the "allow always" grants — survives, because | |
| 99 | +// the editor still sees the same session. | |
| 100 | +func TestResetSessionKeepsIdentityDropsHistory(t *testing.T) { | |
| 101 | + s := &session{ | |
| 102 | + cwd: "/work/project", | |
| 103 | + messages: history.Fresh("you are bob"), | |
| 104 | + allowAlways: map[string]bool{"bash": true}, | |
| 105 | + } | |
| 106 | + s.messages = append(s.messages, ai.NewUserTextMessage("ls"), ai.NewModelTextMessage("main.go")) | |
| 107 | + | |
| 108 | + if got := resetSession(s, "you are bob"); got != 2 { | |
| 109 | + t.Errorf("forgotten = %d, want 2", got) | |
| 110 | + } | |
| 111 | + if len(s.messages) != 1 || s.messages[0].Role != ai.RoleSystem || s.messages[0].Text() != "you are bob" { | |
| 112 | + t.Errorf("history after reset: %+v, want the system prompt alone", s.messages) | |
| 113 | + } | |
| 114 | + if s.cwd != "/work/project" { | |
| 115 | + t.Errorf("cwd changed to %q", s.cwd) | |
| 116 | + } | |
| 117 | + if !s.allowAlways["bash"] { | |
| 118 | + t.Error("the 'allow always' grant for bash was dropped") | |
| 119 | + } | |
| 120 | + | |
| 121 | + // A second /new on a fresh session forgets nothing — and says so. | |
| 122 | + if got := resetSession(s, "you are bob"); got != 0 { | |
| 123 | + t.Errorf("forgotten on a fresh session = %d, want 0", got) | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +// The command is recognised from a flattened prompt — the editor sends it as | |
| 128 | +// a text block, exactly as typed. | |
| 129 | +func TestNewCommandIsRecognisedFromPromptBlocks(t *testing.T) { | |
| 130 | + if !history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new")})) { | |
| 131 | + t.Error("a text block '/new' is not recognised as the command") | |
| 132 | + } | |
| 133 | + if history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new"), sdk.ResourceLinkBlock("main.go", "file:///w/main.go")})) { | |
| 134 | + t.Error("'/new' with an attached file is a question about that file, not the command") | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +// responseSessionID must recognise exactly the line the commands hook waits | |
| 139 | +// for: a response whose result carries a sessionId. A notification (no id) | |
| 140 | +// or a prompt response (no sessionId) must not fire the hook — the first | |
| 141 | +// would announce the commands before the editor knows the session, the | |
| 142 | +// second would never come from session/new at all. | |
| 143 | +func TestResponseSessionIDMatchesOnlySessionResponses(t *testing.T) { | |
| 144 | + cases := map[string]struct { | |
| 145 | + line string | |
| 146 | + want sdk.SessionId | |
| 147 | + }{ | |
| 148 | + "session/new response": {`{"jsonrpc":"2.0","id":1,"result":{"sessionId":"bob-1-1"}}`, "bob-1-1"}, | |
| 149 | + "string id": {`{"jsonrpc":"2.0","id":"a","result":{"sessionId":"bob-1-2"}}`, "bob-1-2"}, | |
| 150 | + "prompt response": {`{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}`, ""}, | |
| 151 | + "session/update": {`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"bob-1-1","update":{}}}`, ""}, | |
| 152 | + "request from the agent": {`{"jsonrpc":"2.0","id":3,"method":"session/request_permission","params":{"sessionId":"bob-1-1"}}`, ""}, | |
| 153 | + "garbage": {`not json`, ""}, | |
| 154 | + } | |
| 155 | + for name, c := range cases { | |
| 156 | + got, ok := responseSessionID([]byte(c.line)) | |
| 157 | + if ok != (c.want != "") || got != c.want { | |
| 158 | + t.Errorf("%s: responseSessionID = (%q, %v), want (%q, %v)", name, got, ok, c.want, c.want != "") | |
| 159 | + } | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +// The order on the wire is the whole point: the editor must read the | |
| 164 | +// session/new response — and so learn the session id — before the | |
| 165 | +// available_commands_update that names it. This drives the real SDK over | |
| 166 | +// pipes, playing the client by hand, and reads the raw lines back. | |
| 167 | +func TestAvailableCommandsAreSentAfterTheSessionNewResponse(t *testing.T) { | |
| 168 | + clientToAgentR, clientToAgentW := io.Pipe() | |
| 169 | + agentToClientR, agentToClientW := io.Pipe() | |
| 170 | + t.Cleanup(func() { | |
| 171 | + _ = clientToAgentW.Close() | |
| 172 | + _ = agentToClientR.Close() | |
| 173 | + }) | |
| 174 | + newFront(nil, "system prompt", nil, agentToClientW, clientToAgentR) | |
| 175 | + | |
| 176 | + go func() { | |
| 177 | + _, _ = io.WriteString(clientToAgentW, `{"jsonrpc":"2.0","id":7,"method":"session/new","params":{"cwd":"/work","mcpServers":[]}}`+"\n") | |
| 178 | + }() | |
| 179 | + | |
| 180 | + lines := make(chan string) | |
| 181 | + go func() { | |
| 182 | + sc := bufio.NewScanner(agentToClientR) | |
| 183 | + for sc.Scan() { | |
| 184 | + lines <- sc.Text() | |
| 185 | + } | |
| 186 | + close(lines) | |
| 187 | + }() | |
| 188 | + next := func() string { | |
| 189 | + select { | |
| 190 | + case l, ok := <-lines: | |
| 191 | + if !ok { | |
| 192 | + t.Fatal("agent closed its output before sending both lines") | |
| 193 | + } | |
| 194 | + return l | |
| 195 | + case <-time.After(5 * time.Second): | |
| 196 | + t.Fatal("timed out waiting for the agent's next line") | |
| 197 | + } | |
| 198 | + return "" | |
| 199 | + } | |
| 200 | + | |
| 201 | + first, second := next(), next() | |
| 202 | + | |
| 203 | + sid, ok := responseSessionID([]byte(first)) | |
| 204 | + if !ok { | |
| 205 | + t.Fatalf("first line is not the session/new response:\n%s", first) | |
| 206 | + } | |
| 207 | + var upd struct { | |
| 208 | + Method string `json:"method"` | |
| 209 | + Params struct { | |
| 210 | + SessionId sdk.SessionId `json:"sessionId"` | |
| 211 | + Update struct { | |
| 212 | + SessionUpdate string `json:"sessionUpdate"` | |
| 213 | + AvailableCommands []sdk.AvailableCommand `json:"availableCommands"` | |
| 214 | + } `json:"update"` | |
| 215 | + } `json:"params"` | |
| 216 | + } | |
| 217 | + if err := json.Unmarshal([]byte(second), &upd); err != nil { | |
| 218 | + t.Fatalf("second line is not JSON: %v\n%s", err, second) | |
| 219 | + } | |
| 220 | + if upd.Method != "session/update" || upd.Params.Update.SessionUpdate != "available_commands_update" { | |
| 221 | + t.Fatalf("second line is not an available_commands_update:\n%s", second) | |
| 222 | + } | |
| 223 | + if upd.Params.SessionId != sid { | |
| 224 | + t.Errorf("commands announced for session %q, response created %q", upd.Params.SessionId, sid) | |
| 225 | + } | |
| 226 | + want := availableCommands() | |
| 227 | + if len(upd.Params.Update.AvailableCommands) != len(want) { | |
| 228 | + t.Fatalf("announced %d command(s), want %d: %+v", len(upd.Params.Update.AvailableCommands), len(want), upd.Params.Update.AvailableCommands) | |
| 229 | + } | |
| 230 | + for i, c := range upd.Params.Update.AvailableCommands { | |
| 231 | + if c.Name != want[i].Name { | |
| 232 | + t.Errorf("announced command %d is %q, want %q", i, c.Name, want[i].Name) | |
| 233 | + } | |
| 234 | + } | |
| 235 | +} | |
| 236 | + | |
| 237 | +// --- /compact and /abort -------------------------------------------------------- | |
| 238 | + | |
| 239 | +// oneTurn is a question answered after one bash command: the smallest unit | |
| 240 | +// compaction cuts on. | |
| 241 | +func oneTurn(q string) []*ai.Message { | |
| 242 | + return []*ai.Message{ | |
| 243 | + ai.NewUserTextMessage(q), | |
| 244 | + ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: q, Name: "bash", Input: map[string]any{"command": "echo " + q}})), | |
| 245 | + ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: q, Name: "bash", Output: q})), | |
| 246 | + ai.NewModelMessage(ai.NewTextPart("done " + q)), | |
| 247 | + } | |
| 248 | +} | |
| 249 | + | |
| 250 | +func sessionWithTurns(qs ...string) *session { | |
| 251 | + s := &session{cwd: "/work", messages: history.Fresh("You are Bob."), allowAlways: map[string]bool{"bash": true}} | |
| 252 | + for _, q := range qs { | |
| 253 | + s.messages = append(s.messages, oneTurn(q)...) | |
| 254 | + } | |
| 255 | + return s | |
| 256 | +} | |
| 257 | + | |
| 258 | +// The three outcomes of /compact, in the REPL's words. Only the first one | |
| 259 | +// touches the history; the two others leave it — and the server's token | |
| 260 | +// count, which the caller resets on `compressed` alone — exactly as they were. | |
| 261 | +func TestCompactHistoryOutcomes(t *testing.T) { | |
| 262 | + cfg := config.ContextConfig{Enabled: true, KeepLastTurns: 1, Threshold: 75, SummaryMaxTokens: 100} | |
| 263 | + summary := func(context.Context, []*ai.Message) (string, error) { return "the user ran a and b", nil } | |
| 264 | + explain := func(err error) string { return "explained: " + err.Error() } | |
| 265 | + | |
| 266 | + t.Run("compresses the turns older than the kept ones", func(t *testing.T) { | |
| 267 | + s := sessionWithTurns("a", "b", "c") | |
| 268 | + before := len(s.messages) | |
| 269 | + line, compressed := compactHistory(context.Background(), s, cfg, summary, explain) | |
| 270 | + if !compressed { | |
| 271 | + t.Fatalf("compressed = false, line %q", line) | |
| 272 | + } | |
| 273 | + if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") { | |
| 274 | + t.Errorf("report %q does not open with the REPL's words and the right counts", line) | |
| 275 | + } | |
| 276 | + if len(s.messages) >= before { | |
| 277 | + t.Errorf("history has %d messages after compaction, %d before", len(s.messages), before) | |
| 278 | + } | |
| 279 | + if s.messages[0].Role != ai.RoleSystem || s.allowAlways["bash"] != true || s.cwd != "/work" { | |
| 280 | + t.Error("compaction touched something other than the turns") | |
| 281 | + } | |
| 282 | + }) | |
| 283 | + | |
| 284 | + t.Run("nothing to compact is said, not swallowed", func(t *testing.T) { | |
| 285 | + s := sessionWithTurns("a") | |
| 286 | + before := len(s.messages) | |
| 287 | + line, compressed := compactHistory(context.Background(), s, cfg, summary, explain) | |
| 288 | + if compressed || len(s.messages) != before { | |
| 289 | + t.Errorf("compressed = %v, %d messages (was %d): a single kept turn must not move", compressed, len(s.messages), before) | |
| 290 | + } | |
| 291 | + if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" { | |
| 292 | + t.Errorf("line %q", line) | |
| 293 | + } | |
| 294 | + }) | |
| 295 | + | |
| 296 | + t.Run("a failed summary keeps the history and names the cause", func(t *testing.T) { | |
| 297 | + s := sessionWithTurns("a", "b", "c") | |
| 298 | + before := len(s.messages) | |
| 299 | + failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") } | |
| 300 | + line, compressed := compactHistory(context.Background(), s, cfg, failing, explain) | |
| 301 | + if compressed || len(s.messages) != before { | |
| 302 | + t.Errorf("compressed = %v, %d messages (was %d): a failure must leave the history alone", compressed, len(s.messages), before) | |
| 303 | + } | |
| 304 | + if line != "[compact: failed, history kept: explained: server down]" { | |
| 305 | + t.Errorf("line %q", line) | |
| 306 | + } | |
| 307 | + }) | |
| 308 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,308 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bufio" | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "errors" | ||
| 8 | + "io" | ||
| 9 | + "strings" | ||
| 10 | + "testing" | ||
| 11 | + "time" | ||
| 12 | + | ||
| 13 | + "mm/internal/config" | ||
| 14 | + history "mm/internal/session" | ||
| 15 | + | ||
| 16 | + sdk "github.com/coder/acp-go-sdk" | ||
| 17 | + "github.com/firebase/genkit/go/ai" | ||
| 18 | +) | ||
| 19 | + | ||
| 20 | +// A prompt mixes text and attached files (resource_link): the text is kept | ||
| 21 | +// as-is and the attachment becomes a path the model can read with its tools — | ||
| 22 | +// bob has no embeddedContext capability, so the content itself never travels. | ||
| 23 | +func TestFlattenTextAndResourceLink(t *testing.T) { | ||
| 24 | + got := flatten([]sdk.ContentBlock{ | ||
| 25 | + sdk.TextBlock("fix the greeting"), | ||
| 26 | + sdk.ResourceLinkBlock("main.go", "file:///work/project/hello/main.go"), | ||
| 27 | + }) | ||
| 28 | + want := "fix the greeting\n[attached file: /work/project/hello/main.go]" | ||
| 29 | + if got != want { | ||
| 30 | + t.Errorf("flatten:\n got %q\nwant %q", got, want) | ||
| 31 | + } | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +// Unknown kinds must render, not fail: the kind only drives the client's | ||
| 35 | +// display, so "execute" is the safe default. | ||
| 36 | +func TestToolKindDefaultsToExecute(t *testing.T) { | ||
| 37 | + cases := map[string]sdk.ToolKind{ | ||
| 38 | + "read": sdk.ToolKindRead, | ||
| 39 | + "edit": sdk.ToolKindEdit, | ||
| 40 | + "execute": sdk.ToolKindExecute, | ||
| 41 | + "": sdk.ToolKindExecute, | ||
| 42 | + "exotic": sdk.ToolKindExecute, | ||
| 43 | + } | ||
| 44 | + for in, want := range cases { | ||
| 45 | + if got := toolKind(in); got != want { | ||
| 46 | + t.Errorf("toolKind(%q) = %q, want %q", in, got, want) | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// The list the editor is shown must be exactly what session/prompt | ||
| 52 | +// intercepts — no more (a /quit or /abort promised here would reach the model | ||
| 53 | +// as a question; the editor's own stop key cancels a turn) and no less (a | ||
| 54 | +// command that works but is not listed is one the user never finds). Names | ||
| 55 | +// are bare: ACP clients add the slash. | ||
| 56 | +func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) { | ||
| 57 | + want := []string{history.NewCommandName, "compact"} | ||
| 58 | + cmds := availableCommands() | ||
| 59 | + if len(cmds) != len(want) { | ||
| 60 | + t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds) | ||
| 61 | + } | ||
| 62 | + for i, c := range cmds { | ||
| 63 | + if c.Name != want[i] { | ||
| 64 | + t.Errorf("command %d is %q, want %q", i, c.Name, want[i]) | ||
| 65 | + } | ||
| 66 | + if c.Description == "" { | ||
| 67 | + t.Errorf("%q has no description: the editor would show a bare name", c.Name) | ||
| 68 | + } | ||
| 69 | + if c.Input != nil { | ||
| 70 | + t.Errorf("%q takes no input, but an input spec is advertised", c.Name) | ||
| 71 | + } | ||
| 72 | + // Each advertised name must be recognised when it comes back as "/name". | ||
| 73 | + typed := "/" + c.Name | ||
| 74 | + if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) { | ||
| 75 | + t.Errorf("%q is advertised but session/prompt would hand it to the model", typed) | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// Same rule as /new: the command is the whole line, spaces aside. | ||
| 81 | +func TestIsCommandTrimsAndRequiresExactMatch(t *testing.T) { | ||
| 82 | + cases := map[string]bool{ | ||
| 83 | + "/compact": true, | ||
| 84 | + " /compact\n": true, | ||
| 85 | + "/compact now": false, | ||
| 86 | + "/compacts": false, | ||
| 87 | + "please /compact": false, | ||
| 88 | + } | ||
| 89 | + for in, want := range cases { | ||
| 90 | + if got := isCommand(in, compactCommand); got != want { | ||
| 91 | + t.Errorf("isCommand(%q) = %v, want %v", in, got, want) | ||
| 92 | + } | ||
| 93 | + } | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +// resetSession is what /new does to a session: the history goes back to the | ||
| 97 | +// system prompt alone, the count says how much was dropped, and everything | ||
| 98 | +// that is NOT the history — cwd, the "allow always" grants — survives, because | ||
| 99 | +// the editor still sees the same session. | ||
| 100 | +func TestResetSessionKeepsIdentityDropsHistory(t *testing.T) { | ||
| 101 | + s := &session{ | ||
| 102 | + cwd: "/work/project", | ||
| 103 | + messages: history.Fresh("you are bob"), | ||
| 104 | + allowAlways: map[string]bool{"bash": true}, | ||
| 105 | + } | ||
| 106 | + s.messages = append(s.messages, ai.NewUserTextMessage("ls"), ai.NewModelTextMessage("main.go")) | ||
| 107 | + | ||
| 108 | + if got := resetSession(s, "you are bob"); got != 2 { | ||
| 109 | + t.Errorf("forgotten = %d, want 2", got) | ||
| 110 | + } | ||
| 111 | + if len(s.messages) != 1 || s.messages[0].Role != ai.RoleSystem || s.messages[0].Text() != "you are bob" { | ||
| 112 | + t.Errorf("history after reset: %+v, want the system prompt alone", s.messages) | ||
| 113 | + } | ||
| 114 | + if s.cwd != "/work/project" { | ||
| 115 | + t.Errorf("cwd changed to %q", s.cwd) | ||
| 116 | + } | ||
| 117 | + if !s.allowAlways["bash"] { | ||
| 118 | + t.Error("the 'allow always' grant for bash was dropped") | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + // A second /new on a fresh session forgets nothing — and says so. | ||
| 122 | + if got := resetSession(s, "you are bob"); got != 0 { | ||
| 123 | + t.Errorf("forgotten on a fresh session = %d, want 0", got) | ||
| 124 | + } | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// The command is recognised from a flattened prompt — the editor sends it as | ||
| 128 | +// a text block, exactly as typed. | ||
| 129 | +func TestNewCommandIsRecognisedFromPromptBlocks(t *testing.T) { | ||
| 130 | + if !history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new")})) { | ||
| 131 | + t.Error("a text block '/new' is not recognised as the command") | ||
| 132 | + } | ||
| 133 | + if history.IsNewCommand(flatten([]sdk.ContentBlock{sdk.TextBlock("/new"), sdk.ResourceLinkBlock("main.go", "file:///w/main.go")})) { | ||
| 134 | + t.Error("'/new' with an attached file is a question about that file, not the command") | ||
| 135 | + } | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +// responseSessionID must recognise exactly the line the commands hook waits | ||
| 139 | +// for: a response whose result carries a sessionId. A notification (no id) | ||
| 140 | +// or a prompt response (no sessionId) must not fire the hook — the first | ||
| 141 | +// would announce the commands before the editor knows the session, the | ||
| 142 | +// second would never come from session/new at all. | ||
| 143 | +func TestResponseSessionIDMatchesOnlySessionResponses(t *testing.T) { | ||
| 144 | + cases := map[string]struct { | ||
| 145 | + line string | ||
| 146 | + want sdk.SessionId | ||
| 147 | + }{ | ||
| 148 | + "session/new response": {`{"jsonrpc":"2.0","id":1,"result":{"sessionId":"bob-1-1"}}`, "bob-1-1"}, | ||
| 149 | + "string id": {`{"jsonrpc":"2.0","id":"a","result":{"sessionId":"bob-1-2"}}`, "bob-1-2"}, | ||
| 150 | + "prompt response": {`{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}`, ""}, | ||
| 151 | + "session/update": {`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"bob-1-1","update":{}}}`, ""}, | ||
| 152 | + "request from the agent": {`{"jsonrpc":"2.0","id":3,"method":"session/request_permission","params":{"sessionId":"bob-1-1"}}`, ""}, | ||
| 153 | + "garbage": {`not json`, ""}, | ||
| 154 | + } | ||
| 155 | + for name, c := range cases { | ||
| 156 | + got, ok := responseSessionID([]byte(c.line)) | ||
| 157 | + if ok != (c.want != "") || got != c.want { | ||
| 158 | + t.Errorf("%s: responseSessionID = (%q, %v), want (%q, %v)", name, got, ok, c.want, c.want != "") | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | +} | ||
| 162 | + | ||
| 163 | +// The order on the wire is the whole point: the editor must read the | ||
| 164 | +// session/new response — and so learn the session id — before the | ||
| 165 | +// available_commands_update that names it. This drives the real SDK over | ||
| 166 | +// pipes, playing the client by hand, and reads the raw lines back. | ||
| 167 | +func TestAvailableCommandsAreSentAfterTheSessionNewResponse(t *testing.T) { | ||
| 168 | + clientToAgentR, clientToAgentW := io.Pipe() | ||
| 169 | + agentToClientR, agentToClientW := io.Pipe() | ||
| 170 | + t.Cleanup(func() { | ||
| 171 | + _ = clientToAgentW.Close() | ||
| 172 | + _ = agentToClientR.Close() | ||
| 173 | + }) | ||
| 174 | + newFront(nil, "system prompt", nil, agentToClientW, clientToAgentR) | ||
| 175 | + | ||
| 176 | + go func() { | ||
| 177 | + _, _ = io.WriteString(clientToAgentW, `{"jsonrpc":"2.0","id":7,"method":"session/new","params":{"cwd":"/work","mcpServers":[]}}`+"\n") | ||
| 178 | + }() | ||
| 179 | + | ||
| 180 | + lines := make(chan string) | ||
| 181 | + go func() { | ||
| 182 | + sc := bufio.NewScanner(agentToClientR) | ||
| 183 | + for sc.Scan() { | ||
| 184 | + lines <- sc.Text() | ||
| 185 | + } | ||
| 186 | + close(lines) | ||
| 187 | + }() | ||
| 188 | + next := func() string { | ||
| 189 | + select { | ||
| 190 | + case l, ok := <-lines: | ||
| 191 | + if !ok { | ||
| 192 | + t.Fatal("agent closed its output before sending both lines") | ||
| 193 | + } | ||
| 194 | + return l | ||
| 195 | + case <-time.After(5 * time.Second): | ||
| 196 | + t.Fatal("timed out waiting for the agent's next line") | ||
| 197 | + } | ||
| 198 | + return "" | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + first, second := next(), next() | ||
| 202 | + | ||
| 203 | + sid, ok := responseSessionID([]byte(first)) | ||
| 204 | + if !ok { | ||
| 205 | + t.Fatalf("first line is not the session/new response:\n%s", first) | ||
| 206 | + } | ||
| 207 | + var upd struct { | ||
| 208 | + Method string `json:"method"` | ||
| 209 | + Params struct { | ||
| 210 | + SessionId sdk.SessionId `json:"sessionId"` | ||
| 211 | + Update struct { | ||
| 212 | + SessionUpdate string `json:"sessionUpdate"` | ||
| 213 | + AvailableCommands []sdk.AvailableCommand `json:"availableCommands"` | ||
| 214 | + } `json:"update"` | ||
| 215 | + } `json:"params"` | ||
| 216 | + } | ||
| 217 | + if err := json.Unmarshal([]byte(second), &upd); err != nil { | ||
| 218 | + t.Fatalf("second line is not JSON: %v\n%s", err, second) | ||
| 219 | + } | ||
| 220 | + if upd.Method != "session/update" || upd.Params.Update.SessionUpdate != "available_commands_update" { | ||
| 221 | + t.Fatalf("second line is not an available_commands_update:\n%s", second) | ||
| 222 | + } | ||
| 223 | + if upd.Params.SessionId != sid { | ||
| 224 | + t.Errorf("commands announced for session %q, response created %q", upd.Params.SessionId, sid) | ||
| 225 | + } | ||
| 226 | + want := availableCommands() | ||
| 227 | + if len(upd.Params.Update.AvailableCommands) != len(want) { | ||
| 228 | + t.Fatalf("announced %d command(s), want %d: %+v", len(upd.Params.Update.AvailableCommands), len(want), upd.Params.Update.AvailableCommands) | ||
| 229 | + } | ||
| 230 | + for i, c := range upd.Params.Update.AvailableCommands { | ||
| 231 | + if c.Name != want[i].Name { | ||
| 232 | + t.Errorf("announced command %d is %q, want %q", i, c.Name, want[i].Name) | ||
| 233 | + } | ||
| 234 | + } | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +// --- /compact and /abort -------------------------------------------------------- | ||
| 238 | + | ||
| 239 | +// oneTurn is a question answered after one bash command: the smallest unit | ||
| 240 | +// compaction cuts on. | ||
| 241 | +func oneTurn(q string) []*ai.Message { | ||
| 242 | + return []*ai.Message{ | ||
| 243 | + ai.NewUserTextMessage(q), | ||
| 244 | + ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: q, Name: "bash", Input: map[string]any{"command": "echo " + q}})), | ||
| 245 | + ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: q, Name: "bash", Output: q})), | ||
| 246 | + ai.NewModelMessage(ai.NewTextPart("done " + q)), | ||
| 247 | + } | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +func sessionWithTurns(qs ...string) *session { | ||
| 251 | + s := &session{cwd: "/work", messages: history.Fresh("You are Bob."), allowAlways: map[string]bool{"bash": true}} | ||
| 252 | + for _, q := range qs { | ||
| 253 | + s.messages = append(s.messages, oneTurn(q)...) | ||
| 254 | + } | ||
| 255 | + return s | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +// The three outcomes of /compact, in the REPL's words. Only the first one | ||
| 259 | +// touches the history; the two others leave it — and the server's token | ||
| 260 | +// count, which the caller resets on `compressed` alone — exactly as they were. | ||
| 261 | +func TestCompactHistoryOutcomes(t *testing.T) { | ||
| 262 | + cfg := config.ContextConfig{Enabled: true, KeepLastTurns: 1, Threshold: 75, SummaryMaxTokens: 100} | ||
| 263 | + summary := func(context.Context, []*ai.Message) (string, error) { return "the user ran a and b", nil } | ||
| 264 | + explain := func(err error) string { return "explained: " + err.Error() } | ||
| 265 | + | ||
| 266 | + t.Run("compresses the turns older than the kept ones", func(t *testing.T) { | ||
| 267 | + s := sessionWithTurns("a", "b", "c") | ||
| 268 | + before := len(s.messages) | ||
| 269 | + line, compressed := compactHistory(context.Background(), s, cfg, summary, explain) | ||
| 270 | + if !compressed { | ||
| 271 | + t.Fatalf("compressed = false, line %q", line) | ||
| 272 | + } | ||
| 273 | + if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") { | ||
| 274 | + t.Errorf("report %q does not open with the REPL's words and the right counts", line) | ||
| 275 | + } | ||
| 276 | + if len(s.messages) >= before { | ||
| 277 | + t.Errorf("history has %d messages after compaction, %d before", len(s.messages), before) | ||
| 278 | + } | ||
| 279 | + if s.messages[0].Role != ai.RoleSystem || s.allowAlways["bash"] != true || s.cwd != "/work" { | ||
| 280 | + t.Error("compaction touched something other than the turns") | ||
| 281 | + } | ||
| 282 | + }) | ||
| 283 | + | ||
| 284 | + t.Run("nothing to compact is said, not swallowed", func(t *testing.T) { | ||
| 285 | + s := sessionWithTurns("a") | ||
| 286 | + before := len(s.messages) | ||
| 287 | + line, compressed := compactHistory(context.Background(), s, cfg, summary, explain) | ||
| 288 | + if compressed || len(s.messages) != before { | ||
| 289 | + t.Errorf("compressed = %v, %d messages (was %d): a single kept turn must not move", compressed, len(s.messages), before) | ||
| 290 | + } | ||
| 291 | + if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" { | ||
| 292 | + t.Errorf("line %q", line) | ||
| 293 | + } | ||
| 294 | + }) | ||
| 295 | + | ||
| 296 | + t.Run("a failed summary keeps the history and names the cause", func(t *testing.T) { | ||
| 297 | + s := sessionWithTurns("a", "b", "c") | ||
| 298 | + before := len(s.messages) | ||
| 299 | + failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") } | ||
| 300 | + line, compressed := compactHistory(context.Background(), s, cfg, failing, explain) | ||
| 301 | + if compressed || len(s.messages) != before { | ||
| 302 | + t.Errorf("compressed = %v, %d messages (was %d): a failure must leave the history alone", compressed, len(s.messages), before) | ||
| 303 | + } | ||
| 304 | + if line != "[compact: failed, history kept: explained: server down]" { | ||
| 305 | + t.Errorf("line %q", line) | ||
| 306 | + } | ||
| 307 | + }) | ||
| 308 | +} | ||
added
internal/acp/sink.go +142 -0 | new file mode 100644 | ||
| @@ -0,0 +1,142 @@ | ||
| 1 | +package acp | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + | |
| 6 | + "mm/internal/ui" | |
| 7 | + | |
| 8 | + sdk "github.com/coder/acp-go-sdk" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// sink translates bob's tool events (ui.Sink) into ACP messages for one turn. | |
| 12 | +// It is the ACP counterpart of what the terminal front end does with fmt: Text | |
| 13 | +// is the 🤖 stream, ToolStart the 🛠️ line, ToolEnd the grey echo — except the | |
| 14 | +// client renders them, so they travel as structure, not as emojis. | |
| 15 | +type sink struct { | |
| 16 | + f *front | |
| 17 | + ctx context.Context // the turn's context: cancelled by session/cancel | |
| 18 | + sid sdk.SessionId | |
| 19 | + s *session | |
| 20 | +} | |
| 21 | + | |
| 22 | +var _ ui.Sink = (*sink)(nil) | |
| 23 | + | |
| 24 | +// send pushes one session/update. Errors are dropped on purpose: a | |
| 25 | +// notification that cannot be written means the client is gone, and the turn's | |
| 26 | +// context is about to be cancelled anyway — there is nobody left to tell. | |
| 27 | +func (k *sink) send(u sdk.SessionUpdate) { | |
| 28 | + _ = k.f.conn.SessionUpdate(k.ctx, sdk.SessionNotification{SessionId: k.sid, Update: u}) | |
| 29 | +} | |
| 30 | + | |
| 31 | +// Text streams one chunk of the model's answer. The raw chunk, whitespace | |
| 32 | +// included: the editor renders Markdown and owns the layout, so the terminal's | |
| 33 | +// gap/squeeze machinery (which fights the screen, not the text) stays out. | |
| 34 | +func (k *sink) Text(text string) { | |
| 35 | + k.send(sdk.UpdateAgentMessageText(text)) | |
| 36 | +} | |
| 37 | + | |
| 38 | +// WorkDir is the session's cwd — the project the editor opened. bash runs | |
| 39 | +// there, and relative file paths resolve against it (ui.Resolve). | |
| 40 | +func (k *sink) WorkDir() string { return k.s.cwd } | |
| 41 | + | |
| 42 | +// ToolStart announces the call, status pending, before anything runs — the | |
| 43 | +// same promise as the terminal's 🛠️ line: it prints BEFORE execution, so its | |
| 44 | +// absence proves nothing ran. | |
| 45 | +func (k *sink) ToolStart(ev ui.ToolEvent) { | |
| 46 | + opts := []sdk.ToolCallStartOpt{ | |
| 47 | + sdk.WithStartKind(toolKind(ev.Kind)), | |
| 48 | + sdk.WithStartStatus(sdk.ToolCallStatusPending), | |
| 49 | + sdk.WithStartRawInput(ev.Input), | |
| 50 | + } | |
| 51 | + if ev.Path != "" { | |
| 52 | + // The location is what lets the editor "follow the agent" — jump to | |
| 53 | + // the file the tool is touching. Absolute, per the spec. | |
| 54 | + opts = append(opts, sdk.WithStartLocations([]sdk.ToolCallLocation{{Path: ev.Path}})) | |
| 55 | + } | |
| 56 | + k.send(sdk.StartToolCall(sdk.ToolCallId(ev.ID), ev.Title, opts...)) | |
| 57 | +} | |
| 58 | + | |
| 59 | +// Allow asks the user, through the editor, before the tool runs. This dialog | |
| 60 | +// simply does not exist in the terminal front end — it is the main thing an | |
| 61 | +// editor adds — so the policy lives here, not in the tools: "allow always" is | |
| 62 | +// remembered per tool for the rest of the session. | |
| 63 | +func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool { | |
| 64 | + if k.s.allowAlways[ev.Tool] { | |
| 65 | + return true | |
| 66 | + } | |
| 67 | + resp, err := k.f.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{ | |
| 68 | + SessionId: k.sid, | |
| 69 | + ToolCall: sdk.ToolCallUpdate{ | |
| 70 | + ToolCallId: sdk.ToolCallId(ev.ID), | |
| 71 | + Title: sdk.Ptr(ev.Title), | |
| 72 | + Kind: sdk.Ptr(toolKind(ev.Kind)), | |
| 73 | + Status: sdk.Ptr(sdk.ToolCallStatusPending), | |
| 74 | + RawInput: ev.Input, | |
| 75 | + }, | |
| 76 | + Options: []sdk.PermissionOption{ | |
| 77 | + {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce}, | |
| 78 | + {OptionId: "allow_always", Name: "Allow " + ev.Tool + " for this session", Kind: sdk.PermissionOptionKindAllowAlways}, | |
| 79 | + {OptionId: "reject", Name: "Reject", Kind: sdk.PermissionOptionKindRejectOnce}, | |
| 80 | + }, | |
| 81 | + }) | |
| 82 | + // An error or a cancelled outcome both mean "do not run": the client hung | |
| 83 | + // up, or the user hit stop while the dialog was open — the spec requires | |
| 84 | + // the client to answer "cancelled" in that case. | |
| 85 | + if err != nil || resp.Outcome.Cancelled != nil { | |
| 86 | + return false | |
| 87 | + } | |
| 88 | + if sel := resp.Outcome.Selected; sel != nil { | |
| 89 | + switch string(sel.OptionId) { | |
| 90 | + case "allow": | |
| 91 | + return true | |
| 92 | + case "allow_always": | |
| 93 | + k.s.allowAlways[ev.Tool] = true | |
| 94 | + return true | |
| 95 | + } | |
| 96 | + } | |
| 97 | + return false | |
| 98 | +} | |
| 99 | + | |
| 100 | +// ToolRunning flips the call to in_progress: permission granted, executing. | |
| 101 | +func (k *sink) ToolRunning(id string) { | |
| 102 | + k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress))) | |
| 103 | +} | |
| 104 | + | |
| 105 | +// ToolEnd closes the call. A file change travels as ACP diff content — that is | |
| 106 | +// what the editor renders as a real diff; anything else ships its output as | |
| 107 | +// text content, the equivalent of the terminal's grey echo. rawOutput carries | |
| 108 | +// the output either way, for clients that show the raw exchange. | |
| 109 | +func (k *sink) ToolEnd(id string, res ui.ToolResult) { | |
| 110 | + status := sdk.ToolCallStatusCompleted | |
| 111 | + if res.Failed { | |
| 112 | + status = sdk.ToolCallStatusFailed | |
| 113 | + } | |
| 114 | + var content []sdk.ToolCallContent | |
| 115 | + switch { | |
| 116 | + case res.Diff != nil && res.Diff.Created: | |
| 117 | + content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText)) | |
| 118 | + case res.Diff != nil: | |
| 119 | + content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText, res.Diff.OldText)) | |
| 120 | + case res.Output != "": | |
| 121 | + content = append(content, sdk.ToolContent(sdk.TextBlock(res.Output))) | |
| 122 | + } | |
| 123 | + k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), | |
| 124 | + sdk.WithUpdateStatus(status), | |
| 125 | + sdk.WithUpdateContent(content), | |
| 126 | + sdk.WithUpdateRawOutput(map[string]any{"output": res.Output}), | |
| 127 | + )) | |
| 128 | +} | |
| 129 | + | |
| 130 | +// toolKind maps bob's event kinds onto ACP's tool-call taxonomy. The kind only | |
| 131 | +// drives how a client displays the call (icon, colour), so unknown values | |
| 132 | +// default to execute rather than failing. | |
| 133 | +func toolKind(kind string) sdk.ToolKind { | |
| 134 | + switch kind { | |
| 135 | + case "read": | |
| 136 | + return sdk.ToolKindRead | |
| 137 | + case "edit": | |
| 138 | + return sdk.ToolKindEdit | |
| 139 | + default: | |
| 140 | + return sdk.ToolKindExecute | |
| 141 | + } | |
| 142 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,142 @@ | |||
| 1 | +package acp | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + | ||
| 6 | + "mm/internal/ui" | ||
| 7 | + | ||
| 8 | + sdk "github.com/coder/acp-go-sdk" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// sink translates bob's tool events (ui.Sink) into ACP messages for one turn. | ||
| 12 | +// It is the ACP counterpart of what the terminal front end does with fmt: Text | ||
| 13 | +// is the 🤖 stream, ToolStart the 🛠️ line, ToolEnd the grey echo — except the | ||
| 14 | +// client renders them, so they travel as structure, not as emojis. | ||
| 15 | +type sink struct { | ||
| 16 | + f *front | ||
| 17 | + ctx context.Context // the turn's context: cancelled by session/cancel | ||
| 18 | + sid sdk.SessionId | ||
| 19 | + s *session | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +var _ ui.Sink = (*sink)(nil) | ||
| 23 | + | ||
| 24 | +// send pushes one session/update. Errors are dropped on purpose: a | ||
| 25 | +// notification that cannot be written means the client is gone, and the turn's | ||
| 26 | +// context is about to be cancelled anyway — there is nobody left to tell. | ||
| 27 | +func (k *sink) send(u sdk.SessionUpdate) { | ||
| 28 | + _ = k.f.conn.SessionUpdate(k.ctx, sdk.SessionNotification{SessionId: k.sid, Update: u}) | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// Text streams one chunk of the model's answer. The raw chunk, whitespace | ||
| 32 | +// included: the editor renders Markdown and owns the layout, so the terminal's | ||
| 33 | +// gap/squeeze machinery (which fights the screen, not the text) stays out. | ||
| 34 | +func (k *sink) Text(text string) { | ||
| 35 | + k.send(sdk.UpdateAgentMessageText(text)) | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// WorkDir is the session's cwd — the project the editor opened. bash runs | ||
| 39 | +// there, and relative file paths resolve against it (ui.Resolve). | ||
| 40 | +func (k *sink) WorkDir() string { return k.s.cwd } | ||
| 41 | + | ||
| 42 | +// ToolStart announces the call, status pending, before anything runs — the | ||
| 43 | +// same promise as the terminal's 🛠️ line: it prints BEFORE execution, so its | ||
| 44 | +// absence proves nothing ran. | ||
| 45 | +func (k *sink) ToolStart(ev ui.ToolEvent) { | ||
| 46 | + opts := []sdk.ToolCallStartOpt{ | ||
| 47 | + sdk.WithStartKind(toolKind(ev.Kind)), | ||
| 48 | + sdk.WithStartStatus(sdk.ToolCallStatusPending), | ||
| 49 | + sdk.WithStartRawInput(ev.Input), | ||
| 50 | + } | ||
| 51 | + if ev.Path != "" { | ||
| 52 | + // The location is what lets the editor "follow the agent" — jump to | ||
| 53 | + // the file the tool is touching. Absolute, per the spec. | ||
| 54 | + opts = append(opts, sdk.WithStartLocations([]sdk.ToolCallLocation{{Path: ev.Path}})) | ||
| 55 | + } | ||
| 56 | + k.send(sdk.StartToolCall(sdk.ToolCallId(ev.ID), ev.Title, opts...)) | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// Allow asks the user, through the editor, before the tool runs. This dialog | ||
| 60 | +// simply does not exist in the terminal front end — it is the main thing an | ||
| 61 | +// editor adds — so the policy lives here, not in the tools: "allow always" is | ||
| 62 | +// remembered per tool for the rest of the session. | ||
| 63 | +func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool { | ||
| 64 | + if k.s.allowAlways[ev.Tool] { | ||
| 65 | + return true | ||
| 66 | + } | ||
| 67 | + resp, err := k.f.conn.RequestPermission(ctx, sdk.RequestPermissionRequest{ | ||
| 68 | + SessionId: k.sid, | ||
| 69 | + ToolCall: sdk.ToolCallUpdate{ | ||
| 70 | + ToolCallId: sdk.ToolCallId(ev.ID), | ||
| 71 | + Title: sdk.Ptr(ev.Title), | ||
| 72 | + Kind: sdk.Ptr(toolKind(ev.Kind)), | ||
| 73 | + Status: sdk.Ptr(sdk.ToolCallStatusPending), | ||
| 74 | + RawInput: ev.Input, | ||
| 75 | + }, | ||
| 76 | + Options: []sdk.PermissionOption{ | ||
| 77 | + {OptionId: "allow", Name: "Allow once", Kind: sdk.PermissionOptionKindAllowOnce}, | ||
| 78 | + {OptionId: "allow_always", Name: "Allow " + ev.Tool + " for this session", Kind: sdk.PermissionOptionKindAllowAlways}, | ||
| 79 | + {OptionId: "reject", Name: "Reject", Kind: sdk.PermissionOptionKindRejectOnce}, | ||
| 80 | + }, | ||
| 81 | + }) | ||
| 82 | + // An error or a cancelled outcome both mean "do not run": the client hung | ||
| 83 | + // up, or the user hit stop while the dialog was open — the spec requires | ||
| 84 | + // the client to answer "cancelled" in that case. | ||
| 85 | + if err != nil || resp.Outcome.Cancelled != nil { | ||
| 86 | + return false | ||
| 87 | + } | ||
| 88 | + if sel := resp.Outcome.Selected; sel != nil { | ||
| 89 | + switch string(sel.OptionId) { | ||
| 90 | + case "allow": | ||
| 91 | + return true | ||
| 92 | + case "allow_always": | ||
| 93 | + k.s.allowAlways[ev.Tool] = true | ||
| 94 | + return true | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + return false | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +// ToolRunning flips the call to in_progress: permission granted, executing. | ||
| 101 | +func (k *sink) ToolRunning(id string) { | ||
| 102 | + k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), sdk.WithUpdateStatus(sdk.ToolCallStatusInProgress))) | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +// ToolEnd closes the call. A file change travels as ACP diff content — that is | ||
| 106 | +// what the editor renders as a real diff; anything else ships its output as | ||
| 107 | +// text content, the equivalent of the terminal's grey echo. rawOutput carries | ||
| 108 | +// the output either way, for clients that show the raw exchange. | ||
| 109 | +func (k *sink) ToolEnd(id string, res ui.ToolResult) { | ||
| 110 | + status := sdk.ToolCallStatusCompleted | ||
| 111 | + if res.Failed { | ||
| 112 | + status = sdk.ToolCallStatusFailed | ||
| 113 | + } | ||
| 114 | + var content []sdk.ToolCallContent | ||
| 115 | + switch { | ||
| 116 | + case res.Diff != nil && res.Diff.Created: | ||
| 117 | + content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText)) | ||
| 118 | + case res.Diff != nil: | ||
| 119 | + content = append(content, sdk.ToolDiffContent(res.Diff.Path, res.Diff.NewText, res.Diff.OldText)) | ||
| 120 | + case res.Output != "": | ||
| 121 | + content = append(content, sdk.ToolContent(sdk.TextBlock(res.Output))) | ||
| 122 | + } | ||
| 123 | + k.send(sdk.UpdateToolCall(sdk.ToolCallId(id), | ||
| 124 | + sdk.WithUpdateStatus(status), | ||
| 125 | + sdk.WithUpdateContent(content), | ||
| 126 | + sdk.WithUpdateRawOutput(map[string]any{"output": res.Output}), | ||
| 127 | + )) | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +// toolKind maps bob's event kinds onto ACP's tool-call taxonomy. The kind only | ||
| 131 | +// drives how a client displays the call (icon, colour), so unknown values | ||
| 132 | +// default to execute rather than failing. | ||
| 133 | +func toolKind(kind string) sdk.ToolKind { | ||
| 134 | + switch kind { | ||
| 135 | + case "read": | ||
| 136 | + return sdk.ToolKindRead | ||
| 137 | + case "edit": | ||
| 138 | + return sdk.ToolKindEdit | ||
| 139 | + default: | ||
| 140 | + return sdk.ToolKindExecute | ||
| 141 | + } | ||
| 142 | +} | ||
added
internal/agent/agent.go +425 -0 | new file mode 100644 | ||
| @@ -0,0 +1,425 @@ | ||
| 1 | +// Package agent contains the interactive conversation loop (REPL). | |
| 2 | +package agent | |
| 3 | + | |
| 4 | +import ( | |
| 5 | + "bufio" | |
| 6 | + "context" | |
| 7 | + "errors" | |
| 8 | + "fmt" | |
| 9 | + "io" | |
| 10 | + "os" | |
| 11 | + "os/signal" | |
| 12 | + "strings" | |
| 13 | + | |
| 14 | + "mm/internal/compact" | |
| 15 | + "mm/internal/config" | |
| 16 | + "mm/internal/detector" | |
| 17 | + "mm/internal/engine" | |
| 18 | + "mm/internal/mention" | |
| 19 | + "mm/internal/session" | |
| 20 | + "mm/internal/spinner" | |
| 21 | + | |
| 22 | + "github.com/firebase/genkit/go/ai" | |
| 23 | +) | |
| 24 | + | |
| 25 | +type genResult struct { | |
| 26 | + resp *ai.ModelResponse | |
| 27 | + // history is the FULL conversation returned by engine.Generate — tool turns | |
| 28 | + // included. Genkit only hands back the last message; without this field the | |
| 29 | + // agent forgets the commands it just ran and starts telling stories instead | |
| 30 | + // of re-reading. | |
| 31 | + history []*ai.Message | |
| 32 | + err error | |
| 33 | +} | |
| 34 | + | |
| 35 | +// Run starts the loop: read user input → generate (with automatic tool calls) → | |
| 36 | +// print → start over, keeping the history. | |
| 37 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) { | |
| 38 | + messages := session.Fresh(system) | |
| 39 | + ctx, cancelAll := context.WithCancel(ctx) | |
| 40 | + defer cancelAll() | |
| 41 | + | |
| 42 | + // @path mentions are relative to where mm was started — the same anchor | |
| 43 | + // as a relative skillsDir and as the bash tool's own working directory. | |
| 44 | + cwd, _ := os.Getwd() | |
| 45 | + | |
| 46 | + // sigCh captures Ctrl+C (SIGINT). | |
| 47 | + sigCh := make(chan os.Signal, 1) | |
| 48 | + signal.Notify(sigCh, os.Interrupt) | |
| 49 | + | |
| 50 | + // inputCh receives commands from the reader goroutine. | |
| 51 | + inputCh := make(chan string) | |
| 52 | + | |
| 53 | + // startInputReader launches a goroutine that reads from Stdin. | |
| 54 | + // It is designed to restart if an interrupt (Ctrl+C) occurs. | |
| 55 | + startInputReader := func() { | |
| 56 | + go func() { | |
| 57 | + for { | |
| 58 | + reader := bufio.NewReader(os.Stdin) | |
| 59 | + line, err := reader.ReadString('\n') | |
| 60 | + if err != nil { | |
| 61 | + select { | |
| 62 | + case <-ctx.Done(): | |
| 63 | + return | |
| 64 | + default: | |
| 65 | + if err == io.EOF { | |
| 66 | + close(inputCh) | |
| 67 | + return | |
| 68 | + } | |
| 69 | + // If it's an interrupt, just continue to restart the reader. | |
| 70 | + continue | |
| 71 | + } | |
| 72 | + } | |
| 73 | + select { | |
| 74 | + case <-ctx.Done(): | |
| 75 | + return | |
| 76 | + case inputCh <- strings.TrimSpace(line): | |
| 77 | + } | |
| 78 | + } | |
| 79 | + }() | |
| 80 | + } | |
| 81 | + | |
| 82 | + startInputReader() | |
| 83 | + | |
| 84 | + fmt.Println(`Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.`) | |
| 85 | + fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`) | |
| 86 | + | |
| 87 | + generating := false | |
| 88 | + | |
| 89 | + for { | |
| 90 | + fmt.Print("\n> ") | |
| 91 | + | |
| 92 | + // 1. Wait for the next user input OR a quit signal. | |
| 93 | + var input string | |
| 94 | + var ok bool | |
| 95 | + | |
| 96 | + select { | |
| 97 | + case <-sigCh: | |
| 98 | + if !generating { | |
| 99 | + fmt.Println("\nGoodbye.") | |
| 100 | + return | |
| 101 | + } | |
| 102 | + // If generating, we let the generation loop handle the SIGINT. | |
| 103 | + // We fall through to wait for the next input (which will be the prompt). | |
| 104 | + case input, ok = <-inputCh: | |
| 105 | + if !ok { | |
| 106 | + return | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + // 2. Basic Command Handling. | |
| 111 | + if input == "/quit" { | |
| 112 | + break | |
| 113 | + } | |
| 114 | + if input == "" { | |
| 115 | + continue | |
| 116 | + } | |
| 117 | + if input == "/abort" { | |
| 118 | + spinner.Stop() | |
| 119 | + fmt.Println("⚠️ No generation in progress.") | |
| 120 | + continue | |
| 121 | + } | |
| 122 | + // Manual compression. Only reachable here, between two generations: | |
| 123 | + // Genkit holds its own copy of the conversation while it runs, and a | |
| 124 | + // tool_call/tool_result pair in flight must not move under it. | |
| 125 | + if input == "/compact" { | |
| 126 | + messages = compactHistory(ctx, e, messages, true) | |
| 127 | + continue | |
| 128 | + } | |
| 129 | + // New session. Same place and same reason as /compact: the history | |
| 130 | + // only moves between two generations. Typed DURING a generation, the | |
| 131 | + // command goes through the "new command received" branch below, which | |
| 132 | + // cancels the turn and re-injects it here — so the reset always applies | |
| 133 | + // to the finished history, never to one Genkit still holds. | |
| 134 | + if session.IsNewCommand(input) { | |
| 135 | + messages = startNewSession(e, d, system, messages) | |
| 136 | + continue | |
| 137 | + } | |
| 138 | + | |
| 139 | + // 3. Normal message: Add to history and start generation. | |
| 140 | + // | |
| 141 | + // The automatic compression runs BEFORE the question is appended and | |
| 142 | + // BEFORE `before` is computed: that index is what the rollback | |
| 143 | + // (`messages[:before-1]`) and the ⚙ line (`res.history[before:]`) | |
| 144 | + // rely on, and a history that shrinks after it is taken would break | |
| 145 | + // both. The server's own count of the last context is preferred to | |
| 146 | + // the estimate when it is larger — it sees the tools' JSON and the | |
| 147 | + // chat template, which the estimate cannot. The window is the one the | |
| 148 | + // Engine knows: the yaml's contextWindow, or what the probe learned. | |
| 149 | + // | |
| 150 | + // That probe ran once, at start-up. A llama-server started AFTER bob | |
| 151 | + // left `ctx: unknown` for the whole session — observed — and the | |
| 152 | + // compression could then only trigger on maxMessages. So, while the | |
| 153 | + // window is unknown and compression is on, ask the server again | |
| 154 | + // before deciding; once known, never again. A probe that still | |
| 155 | + // fails prints nothing: the start-up warning already said so. | |
| 156 | + if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 { | |
| 157 | + if w, src := e.EnsureContextWindow(ctx); w > 0 { | |
| 158 | + dimln(fmt.Sprintf("ctx: %d (%s)", w, src)) | |
| 159 | + } | |
| 160 | + } | |
| 161 | + if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact { | |
| 162 | + messages = compactHistory(ctx, e, messages, false) | |
| 163 | + } | |
| 164 | + // "@path" in the question names a file for the model to look at. The | |
| 165 | + // text stays as typed; each existing path is appended as an | |
| 166 | + // [attached file: …] line — what an editor's @-mention becomes over | |
| 167 | + // ACP — and echoed here so the user sees the notation was understood. | |
| 168 | + input, attached := mention.Expand(input, cwd) | |
| 169 | + for _, a := range attached { | |
| 170 | + dimln("📎 " + a.Path) | |
| 171 | + } | |
| 172 | + messages = append(messages, ai.NewUserTextMessage(input)) | |
| 173 | + before := len(messages) | |
| 174 | + fmt.Println() | |
| 175 | + | |
| 176 | + // Create a cancellable context for this specific generation cycle. | |
| 177 | + genCtx, cancelGen := context.WithCancel(ctx) | |
| 178 | + generating = true | |
| 179 | + | |
| 180 | + // Launch generation in a goroutine. | |
| 181 | + resultCh := make(chan genResult, 1) | |
| 182 | + go func() { | |
| 183 | + resp, history, err := e.Generate(genCtx, messages, tools) | |
| 184 | + resultCh <- genResult{resp: resp, history: history, err: err} | |
| 185 | + }() | |
| 186 | + | |
| 187 | + // 4. The "Waiting Room": Listen for completion, interruption, OR new command. | |
| 188 | + generationRunning := true | |
| 189 | + for generationRunning { | |
| 190 | + select { | |
| 191 | + case res := <-resultCh: | |
| 192 | + generationRunning = false | |
| 193 | + generating = false | |
| 194 | + | |
| 195 | + // We take the FULL history returned by engine.Generate, not just | |
| 196 | + // resp.Message — and we take it EVEN when the generation failed | |
| 197 | + // or was interrupted: on an abort (Ctrl+C, /abort), a maxTurns | |
| 198 | + // overrun or a silence caught by the watchdog, all the work of | |
| 199 | + // the turn is in `history`, and Genkit itself returns nothing. | |
| 200 | + // (resp.History() will not do: empty on the streamed path.) | |
| 201 | + switch { | |
| 202 | + case len(res.history) > before: | |
| 203 | + messages = res.history | |
| 204 | + case res.resp != nil && res.resp.Message != nil: | |
| 205 | + messages = append(messages, res.resp.Message) | |
| 206 | + case res.err != nil: | |
| 207 | + messages = messages[:before-1] // question sans réponse : on l'oublie | |
| 208 | + } | |
| 209 | + | |
| 210 | + if res.err != nil { | |
| 211 | + if genCtx.Err() == context.Canceled { | |
| 212 | + fmt.Println("\n🛑 Generation aborted.") | |
| 213 | + } else { | |
| 214 | + // One line, in the provider's words: see engine.Explain. | |
| 215 | + fmt.Println("\n[error: " + e.Explain(res.err) + "]") | |
| 216 | + } | |
| 217 | + } else { | |
| 218 | + // --- Global Loop Detection --- | |
| 219 | + // We check the tool calls in the model response. | |
| 220 | + if res.resp != nil && res.resp.Message != nil { | |
| 221 | + for _, part := range res.resp.Message.Content { | |
| 222 | + if part.IsToolRequest() && part.ToolRequest != nil { | |
| 223 | + d.Record(detector.Action{ | |
| 224 | + ToolName: part.ToolRequest.Name, | |
| 225 | + Input: fmt.Sprintf("%v", part.ToolRequest.Input), | |
| 226 | + Output: "[tool_call]", | |
| 227 | + }) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + } | |
| 231 | + fmt.Println() | |
| 232 | + } | |
| 233 | + | |
| 234 | + // The command count is the only visible proof that the model | |
| 235 | + // worked rather than improvised. Zero is normal on a | |
| 236 | + // conversational question; zero on "read this file" is not. | |
| 237 | + turn := res.history[min(before, len(res.history)):] | |
| 238 | + cmds := engine.CommandList(turn) | |
| 239 | + ops := engine.FileOps(turn) | |
| 240 | + line := fmt.Sprintf("⚙ %d command(s)", len(cmds)) | |
| 241 | + // The file tools have their own column: the A/B of this part | |
| 242 | + // is "edits through bash" versus "edits through tools", and | |
| 243 | + // a single number would add up what it is meant to separate. | |
| 244 | + if len(ops) > 0 { | |
| 245 | + line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops)) | |
| 246 | + } | |
| 247 | + if k := engine.Skills(turn); k > 0 { | |
| 248 | + line += fmt.Sprintf(" · 📖 %d skill(s)", k) | |
| 249 | + } | |
| 250 | + if spinner.Styled() { | |
| 251 | + fmt.Printf("\033[2m%s\033[0m\n", line) | |
| 252 | + } else { | |
| 253 | + fmt.Println(line) | |
| 254 | + } | |
| 255 | + if config.Cfg.DisplayCommands { | |
| 256 | + printCommands(cmds) | |
| 257 | + printCommands(ops) | |
| 258 | + } | |
| 259 | + | |
| 260 | + case <-sigCh: | |
| 261 | + // Handle Ctrl+C during generation. | |
| 262 | + // Stop() first: the spinner is running during the generation, | |
| 263 | + // and without it this message lands on its line — the next | |
| 264 | + // frame would erase it. Same rule as in dmr.emit. | |
| 265 | + spinner.Stop() | |
| 266 | + fmt.Println("\n🛑 Interrupted (Ctrl+C).") | |
| 267 | + cancelGen() | |
| 268 | + // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error. | |
| 269 | + | |
| 270 | + case nextInput := <-inputCh: | |
| 271 | + spinner.Stop() // le spinner tourne : on lui reprend la ligne | |
| 272 | + switch nextInput { | |
| 273 | + case "/abort": | |
| 274 | + cancelGen() | |
| 275 | + fmt.Println("🛑 Aborting...") | |
| 276 | + case "/quit": | |
| 277 | + cancelGen() | |
| 278 | + fmt.Println("Goodbye.") | |
| 279 | + return | |
| 280 | + default: | |
| 281 | + // A new message arrived! Cancel current task and re-inject the message. | |
| 282 | + fmt.Println("🔄 New command received. Cancelling current task...") | |
| 283 | + cancelGen() | |
| 284 | + | |
| 285 | + // Wait for current goroutine to clean up before re-injecting. | |
| 286 | + res := <-resultCh | |
| 287 | + | |
| 288 | + // Same principle as above: the commands already run stay in | |
| 289 | + // the history, otherwise the next question starts again | |
| 290 | + // without knowing what was done. | |
| 291 | + switch { | |
| 292 | + case len(res.history) > before: | |
| 293 | + messages = res.history | |
| 294 | + case res.err != nil: | |
| 295 | + messages = messages[:before-1] | |
| 296 | + if genCtx.Err() != context.Canceled { | |
| 297 | + fmt.Println("[error: " + e.Explain(res.err) + "]") | |
| 298 | + } | |
| 299 | + } | |
| 300 | + generationRunning = false | |
| 301 | + generating = false | |
| 302 | + | |
| 303 | + // Re-inject the command into the main loop. | |
| 304 | + go func(cmd string) { inputCh <- cmd }(nextInput) | |
| 305 | + } | |
| 306 | + } | |
| 307 | + } | |
| 308 | + // Final cleanup for this cycle. | |
| 309 | + cancelGen() | |
| 310 | + } | |
| 311 | +} | |
| 312 | + | |
| 313 | +// printCommands recaps the commands of the turn, in green. | |
| 314 | +// | |
| 315 | +// The count says HOW MANY, the list says WHAT: it reads at a glance once the | |
| 316 | +// answer is written, whereas the 🛠️ lines are scattered through the output of | |
| 317 | +// the commands. Green separates it from both the grey of the tools and the | |
| 318 | +// model's own text — and it only comes out on a terminal, like every other | |
| 319 | +// colour here. | |
| 320 | +func printCommands(cmds []string) { | |
| 321 | + if len(cmds) == 0 { | |
| 322 | + return | |
| 323 | + } | |
| 324 | + green, off := "", "" | |
| 325 | + if spinner.Styled() { | |
| 326 | + green, off = "\033[32m", "\033[0m" | |
| 327 | + } | |
| 328 | + | |
| 329 | + var b strings.Builder | |
| 330 | + for i, c := range cmds { | |
| 331 | + b.WriteString(green + fmt.Sprintf(" %d. %s", i+1, oneLine(c)) + off + "\n") | |
| 332 | + } | |
| 333 | + fmt.Print(b.String()) | |
| 334 | +} | |
| 335 | + | |
| 336 | +// maxCommandWidth is the width beyond which a command is cut. A hundred | |
| 337 | +// columns: that fits a demo terminal without overflowing it. | |
| 338 | +const maxCommandWidth = 100 | |
| 339 | + | |
| 340 | +// oneLine folds a command onto ONE line and truncates it when needed. | |
| 341 | +// | |
| 342 | +// A command can be long and span several lines — a heredoc, a trailing &&. The | |
| 343 | +// recap is there to scan what the agent did, not to re-read its code: spread | |
| 344 | +// over several lines it would grow longer than the output it summarises, and | |
| 345 | +// the numbering would stop being readable. | |
| 346 | +func oneLine(s string) string { | |
| 347 | + // Fields splits on ALL whitespace, line breaks included: that is exactly | |
| 348 | + // the folding we want, heredoc indentation and all. | |
| 349 | + s = strings.Join(strings.Fields(s), " ") | |
| 350 | + r := []rune(s) | |
| 351 | + if len(r) <= maxCommandWidth { | |
| 352 | + return s | |
| 353 | + } | |
| 354 | + return string(r[:maxCommandWidth-1]) + "…" | |
| 355 | +} | |
| 356 | + | |
| 357 | +// compactHistory replaces the old turns of `msgs` by a model-written summary | |
| 358 | +// and says so in one line. On ANY failure — server down, watchdog, empty or | |
| 359 | +// malformed result — it returns `msgs` unchanged: the compression exists to | |
| 360 | +// keep the next question possible, so it must never cost one. | |
| 361 | +// | |
| 362 | +// `forced` is the /compact command: it skips the threshold, not the "is there | |
| 363 | +// anything older than the kept turns" check, and it says so when there is not. | |
| 364 | +// The loop detector is deliberately not touched: it records what the agent | |
| 365 | +// DOES, and forgetting a conversation does not make a repeated command new. | |
| 366 | +func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, forced bool) []*ai.Message { | |
| 367 | + cfg := config.Cfg.Context | |
| 368 | + | |
| 369 | + // The summary is the longest prefill of the session and prints nothing | |
| 370 | + // while it runs: without a label the agent looks hung, exactly the case | |
| 371 | + // the spinner exists for. | |
| 372 | + spinner.Start("Compressing") | |
| 373 | + res, err := compact.Compact(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) { | |
| 374 | + return e.Summarize(ctx, request, cfg.SummaryMaxTokens) | |
| 375 | + }) | |
| 376 | + spinner.Stop() | |
| 377 | + | |
| 378 | + switch { | |
| 379 | + case errors.Is(err, compact.ErrNothingToCompact): | |
| 380 | + if forced { | |
| 381 | + dimln(fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns)) | |
| 382 | + } | |
| 383 | + return msgs | |
| 384 | + case err != nil: | |
| 385 | + // One line, in the provider's words, like every other error here. | |
| 386 | + fmt.Printf("[compact: failed, history kept: %s]\n", e.Explain(err)) | |
| 387 | + return msgs | |
| 388 | + } | |
| 389 | + | |
| 390 | + // The server's last measure described the history we just replaced. | |
| 391 | + e.ForgetInputTokens() | |
| 392 | + | |
| 393 | + if cfg.ShowStats { | |
| 394 | + dimln("🗜️ " + res.Report()) | |
| 395 | + } | |
| 396 | + return res.Messages | |
| 397 | +} | |
| 398 | + | |
| 399 | +// startNewSession forgets the conversation and returns the history of a fresh | |
| 400 | +// one: the system prompt alone. Three things make up "the session" here, and | |
| 401 | +// all three are reset — the messages, the server's token count of the last | |
| 402 | +// context (it described a history that no longer exists, same rule as after a | |
| 403 | +// compression), and the loop detector (a new session is a new task: a command | |
| 404 | +// repeated in the old one must not be flagged as a loop in this one — the | |
| 405 | +// opposite of compactHistory's choice, where the task continues). | |
| 406 | +// | |
| 407 | +// It says in one line how much was forgotten, so a /new on an empty session | |
| 408 | +// visibly did nothing rather than silently nothing. | |
| 409 | +func startNewSession(e *engine.Engine, d *detector.LoopDetector, system string, old []*ai.Message) []*ai.Message { | |
| 410 | + forgotten := session.Forgotten(old) | |
| 411 | + e.ForgetInputTokens() | |
| 412 | + d.Reset() | |
| 413 | + dimln(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten)) | |
| 414 | + return session.Fresh(system) | |
| 415 | +} | |
| 416 | + | |
| 417 | +// dimln prints one line, dimmed on a terminal and plain in a pipe — the same | |
| 418 | +// rule as the ⚙ line: `bob | jq` must not receive escape codes. | |
| 419 | +func dimln(line string) { | |
| 420 | + if spinner.Styled() { | |
| 421 | + fmt.Printf("\033[2m%s\033[0m\n", line) | |
| 422 | + } else { | |
| 423 | + fmt.Println(line) | |
| 424 | + } | |
| 425 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,425 @@ | |||
| 1 | +// Package agent contains the interactive conversation loop (REPL). | ||
| 2 | +package agent | ||
| 3 | + | ||
| 4 | +import ( | ||
| 5 | + "bufio" | ||
| 6 | + "context" | ||
| 7 | + "errors" | ||
| 8 | + "fmt" | ||
| 9 | + "io" | ||
| 10 | + "os" | ||
| 11 | + "os/signal" | ||
| 12 | + "strings" | ||
| 13 | + | ||
| 14 | + "mm/internal/compact" | ||
| 15 | + "mm/internal/config" | ||
| 16 | + "mm/internal/detector" | ||
| 17 | + "mm/internal/engine" | ||
| 18 | + "mm/internal/mention" | ||
| 19 | + "mm/internal/session" | ||
| 20 | + "mm/internal/spinner" | ||
| 21 | + | ||
| 22 | + "github.com/firebase/genkit/go/ai" | ||
| 23 | +) | ||
| 24 | + | ||
| 25 | +type genResult struct { | ||
| 26 | + resp *ai.ModelResponse | ||
| 27 | + // history is the FULL conversation returned by engine.Generate — tool turns | ||
| 28 | + // included. Genkit only hands back the last message; without this field the | ||
| 29 | + // agent forgets the commands it just ran and starts telling stories instead | ||
| 30 | + // of re-reading. | ||
| 31 | + history []*ai.Message | ||
| 32 | + err error | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +// Run starts the loop: read user input → generate (with automatic tool calls) → | ||
| 36 | +// print → start over, keeping the history. | ||
| 37 | +func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRef, d *detector.LoopDetector) { | ||
| 38 | + messages := session.Fresh(system) | ||
| 39 | + ctx, cancelAll := context.WithCancel(ctx) | ||
| 40 | + defer cancelAll() | ||
| 41 | + | ||
| 42 | + // @path mentions are relative to where mm was started — the same anchor | ||
| 43 | + // as a relative skillsDir and as the bash tool's own working directory. | ||
| 44 | + cwd, _ := os.Getwd() | ||
| 45 | + | ||
| 46 | + // sigCh captures Ctrl+C (SIGINT). | ||
| 47 | + sigCh := make(chan os.Signal, 1) | ||
| 48 | + signal.Notify(sigCh, os.Interrupt) | ||
| 49 | + | ||
| 50 | + // inputCh receives commands from the reader goroutine. | ||
| 51 | + inputCh := make(chan string) | ||
| 52 | + | ||
| 53 | + // startInputReader launches a goroutine that reads from Stdin. | ||
| 54 | + // It is designed to restart if an interrupt (Ctrl+C) occurs. | ||
| 55 | + startInputReader := func() { | ||
| 56 | + go func() { | ||
| 57 | + for { | ||
| 58 | + reader := bufio.NewReader(os.Stdin) | ||
| 59 | + line, err := reader.ReadString('\n') | ||
| 60 | + if err != nil { | ||
| 61 | + select { | ||
| 62 | + case <-ctx.Done(): | ||
| 63 | + return | ||
| 64 | + default: | ||
| 65 | + if err == io.EOF { | ||
| 66 | + close(inputCh) | ||
| 67 | + return | ||
| 68 | + } | ||
| 69 | + // If it's an interrupt, just continue to restart the reader. | ||
| 70 | + continue | ||
| 71 | + } | ||
| 72 | + } | ||
| 73 | + select { | ||
| 74 | + case <-ctx.Done(): | ||
| 75 | + return | ||
| 76 | + case inputCh <- strings.TrimSpace(line): | ||
| 77 | + } | ||
| 78 | + } | ||
| 79 | + }() | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + startInputReader() | ||
| 83 | + | ||
| 84 | + fmt.Println(`Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.`) | ||
| 85 | + fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`) | ||
| 86 | + | ||
| 87 | + generating := false | ||
| 88 | + | ||
| 89 | + for { | ||
| 90 | + fmt.Print("\n> ") | ||
| 91 | + | ||
| 92 | + // 1. Wait for the next user input OR a quit signal. | ||
| 93 | + var input string | ||
| 94 | + var ok bool | ||
| 95 | + | ||
| 96 | + select { | ||
| 97 | + case <-sigCh: | ||
| 98 | + if !generating { | ||
| 99 | + fmt.Println("\nGoodbye.") | ||
| 100 | + return | ||
| 101 | + } | ||
| 102 | + // If generating, we let the generation loop handle the SIGINT. | ||
| 103 | + // We fall through to wait for the next input (which will be the prompt). | ||
| 104 | + case input, ok = <-inputCh: | ||
| 105 | + if !ok { | ||
| 106 | + return | ||
| 107 | + } | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + // 2. Basic Command Handling. | ||
| 111 | + if input == "/quit" { | ||
| 112 | + break | ||
| 113 | + } | ||
| 114 | + if input == "" { | ||
| 115 | + continue | ||
| 116 | + } | ||
| 117 | + if input == "/abort" { | ||
| 118 | + spinner.Stop() | ||
| 119 | + fmt.Println("⚠️ No generation in progress.") | ||
| 120 | + continue | ||
| 121 | + } | ||
| 122 | + // Manual compression. Only reachable here, between two generations: | ||
| 123 | + // Genkit holds its own copy of the conversation while it runs, and a | ||
| 124 | + // tool_call/tool_result pair in flight must not move under it. | ||
| 125 | + if input == "/compact" { | ||
| 126 | + messages = compactHistory(ctx, e, messages, true) | ||
| 127 | + continue | ||
| 128 | + } | ||
| 129 | + // New session. Same place and same reason as /compact: the history | ||
| 130 | + // only moves between two generations. Typed DURING a generation, the | ||
| 131 | + // command goes through the "new command received" branch below, which | ||
| 132 | + // cancels the turn and re-injects it here — so the reset always applies | ||
| 133 | + // to the finished history, never to one Genkit still holds. | ||
| 134 | + if session.IsNewCommand(input) { | ||
| 135 | + messages = startNewSession(e, d, system, messages) | ||
| 136 | + continue | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + // 3. Normal message: Add to history and start generation. | ||
| 140 | + // | ||
| 141 | + // The automatic compression runs BEFORE the question is appended and | ||
| 142 | + // BEFORE `before` is computed: that index is what the rollback | ||
| 143 | + // (`messages[:before-1]`) and the ⚙ line (`res.history[before:]`) | ||
| 144 | + // rely on, and a history that shrinks after it is taken would break | ||
| 145 | + // both. The server's own count of the last context is preferred to | ||
| 146 | + // the estimate when it is larger — it sees the tools' JSON and the | ||
| 147 | + // chat template, which the estimate cannot. The window is the one the | ||
| 148 | + // Engine knows: the yaml's contextWindow, or what the probe learned. | ||
| 149 | + // | ||
| 150 | + // That probe ran once, at start-up. A llama-server started AFTER bob | ||
| 151 | + // left `ctx: unknown` for the whole session — observed — and the | ||
| 152 | + // compression could then only trigger on maxMessages. So, while the | ||
| 153 | + // window is unknown and compression is on, ask the server again | ||
| 154 | + // before deciding; once known, never again. A probe that still | ||
| 155 | + // fails prints nothing: the start-up warning already said so. | ||
| 156 | + if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 { | ||
| 157 | + if w, src := e.EnsureContextWindow(ctx); w > 0 { | ||
| 158 | + dimln(fmt.Sprintf("ctx: %d (%s)", w, src)) | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | + if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact { | ||
| 162 | + messages = compactHistory(ctx, e, messages, false) | ||
| 163 | + } | ||
| 164 | + // "@path" in the question names a file for the model to look at. The | ||
| 165 | + // text stays as typed; each existing path is appended as an | ||
| 166 | + // [attached file: …] line — what an editor's @-mention becomes over | ||
| 167 | + // ACP — and echoed here so the user sees the notation was understood. | ||
| 168 | + input, attached := mention.Expand(input, cwd) | ||
| 169 | + for _, a := range attached { | ||
| 170 | + dimln("📎 " + a.Path) | ||
| 171 | + } | ||
| 172 | + messages = append(messages, ai.NewUserTextMessage(input)) | ||
| 173 | + before := len(messages) | ||
| 174 | + fmt.Println() | ||
| 175 | + | ||
| 176 | + // Create a cancellable context for this specific generation cycle. | ||
| 177 | + genCtx, cancelGen := context.WithCancel(ctx) | ||
| 178 | + generating = true | ||
| 179 | + | ||
| 180 | + // Launch generation in a goroutine. | ||
| 181 | + resultCh := make(chan genResult, 1) | ||
| 182 | + go func() { | ||
| 183 | + resp, history, err := e.Generate(genCtx, messages, tools) | ||
| 184 | + resultCh <- genResult{resp: resp, history: history, err: err} | ||
| 185 | + }() | ||
| 186 | + | ||
| 187 | + // 4. The "Waiting Room": Listen for completion, interruption, OR new command. | ||
| 188 | + generationRunning := true | ||
| 189 | + for generationRunning { | ||
| 190 | + select { | ||
| 191 | + case res := <-resultCh: | ||
| 192 | + generationRunning = false | ||
| 193 | + generating = false | ||
| 194 | + | ||
| 195 | + // We take the FULL history returned by engine.Generate, not just | ||
| 196 | + // resp.Message — and we take it EVEN when the generation failed | ||
| 197 | + // or was interrupted: on an abort (Ctrl+C, /abort), a maxTurns | ||
| 198 | + // overrun or a silence caught by the watchdog, all the work of | ||
| 199 | + // the turn is in `history`, and Genkit itself returns nothing. | ||
| 200 | + // (resp.History() will not do: empty on the streamed path.) | ||
| 201 | + switch { | ||
| 202 | + case len(res.history) > before: | ||
| 203 | + messages = res.history | ||
| 204 | + case res.resp != nil && res.resp.Message != nil: | ||
| 205 | + messages = append(messages, res.resp.Message) | ||
| 206 | + case res.err != nil: | ||
| 207 | + messages = messages[:before-1] // question sans réponse : on l'oublie | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + if res.err != nil { | ||
| 211 | + if genCtx.Err() == context.Canceled { | ||
| 212 | + fmt.Println("\n🛑 Generation aborted.") | ||
| 213 | + } else { | ||
| 214 | + // One line, in the provider's words: see engine.Explain. | ||
| 215 | + fmt.Println("\n[error: " + e.Explain(res.err) + "]") | ||
| 216 | + } | ||
| 217 | + } else { | ||
| 218 | + // --- Global Loop Detection --- | ||
| 219 | + // We check the tool calls in the model response. | ||
| 220 | + if res.resp != nil && res.resp.Message != nil { | ||
| 221 | + for _, part := range res.resp.Message.Content { | ||
| 222 | + if part.IsToolRequest() && part.ToolRequest != nil { | ||
| 223 | + d.Record(detector.Action{ | ||
| 224 | + ToolName: part.ToolRequest.Name, | ||
| 225 | + Input: fmt.Sprintf("%v", part.ToolRequest.Input), | ||
| 226 | + Output: "[tool_call]", | ||
| 227 | + }) | ||
| 228 | + } | ||
| 229 | + } | ||
| 230 | + } | ||
| 231 | + fmt.Println() | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + // The command count is the only visible proof that the model | ||
| 235 | + // worked rather than improvised. Zero is normal on a | ||
| 236 | + // conversational question; zero on "read this file" is not. | ||
| 237 | + turn := res.history[min(before, len(res.history)):] | ||
| 238 | + cmds := engine.CommandList(turn) | ||
| 239 | + ops := engine.FileOps(turn) | ||
| 240 | + line := fmt.Sprintf("⚙ %d command(s)", len(cmds)) | ||
| 241 | + // The file tools have their own column: the A/B of this part | ||
| 242 | + // is "edits through bash" versus "edits through tools", and | ||
| 243 | + // a single number would add up what it is meant to separate. | ||
| 244 | + if len(ops) > 0 { | ||
| 245 | + line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops)) | ||
| 246 | + } | ||
| 247 | + if k := engine.Skills(turn); k > 0 { | ||
| 248 | + line += fmt.Sprintf(" · 📖 %d skill(s)", k) | ||
| 249 | + } | ||
| 250 | + if spinner.Styled() { | ||
| 251 | + fmt.Printf("\033[2m%s\033[0m\n", line) | ||
| 252 | + } else { | ||
| 253 | + fmt.Println(line) | ||
| 254 | + } | ||
| 255 | + if config.Cfg.DisplayCommands { | ||
| 256 | + printCommands(cmds) | ||
| 257 | + printCommands(ops) | ||
| 258 | + } | ||
| 259 | + | ||
| 260 | + case <-sigCh: | ||
| 261 | + // Handle Ctrl+C during generation. | ||
| 262 | + // Stop() first: the spinner is running during the generation, | ||
| 263 | + // and without it this message lands on its line — the next | ||
| 264 | + // frame would erase it. Same rule as in dmr.emit. | ||
| 265 | + spinner.Stop() | ||
| 266 | + fmt.Println("\n🛑 Interrupted (Ctrl+C).") | ||
| 267 | + cancelGen() | ||
| 268 | + // We don't set generationRunning=false; we wait for resultCh to catch the Canceled error. | ||
| 269 | + | ||
| 270 | + case nextInput := <-inputCh: | ||
| 271 | + spinner.Stop() // le spinner tourne : on lui reprend la ligne | ||
| 272 | + switch nextInput { | ||
| 273 | + case "/abort": | ||
| 274 | + cancelGen() | ||
| 275 | + fmt.Println("🛑 Aborting...") | ||
| 276 | + case "/quit": | ||
| 277 | + cancelGen() | ||
| 278 | + fmt.Println("Goodbye.") | ||
| 279 | + return | ||
| 280 | + default: | ||
| 281 | + // A new message arrived! Cancel current task and re-inject the message. | ||
| 282 | + fmt.Println("🔄 New command received. Cancelling current task...") | ||
| 283 | + cancelGen() | ||
| 284 | + | ||
| 285 | + // Wait for current goroutine to clean up before re-injecting. | ||
| 286 | + res := <-resultCh | ||
| 287 | + | ||
| 288 | + // Same principle as above: the commands already run stay in | ||
| 289 | + // the history, otherwise the next question starts again | ||
| 290 | + // without knowing what was done. | ||
| 291 | + switch { | ||
| 292 | + case len(res.history) > before: | ||
| 293 | + messages = res.history | ||
| 294 | + case res.err != nil: | ||
| 295 | + messages = messages[:before-1] | ||
| 296 | + if genCtx.Err() != context.Canceled { | ||
| 297 | + fmt.Println("[error: " + e.Explain(res.err) + "]") | ||
| 298 | + } | ||
| 299 | + } | ||
| 300 | + generationRunning = false | ||
| 301 | + generating = false | ||
| 302 | + | ||
| 303 | + // Re-inject the command into the main loop. | ||
| 304 | + go func(cmd string) { inputCh <- cmd }(nextInput) | ||
| 305 | + } | ||
| 306 | + } | ||
| 307 | + } | ||
| 308 | + // Final cleanup for this cycle. | ||
| 309 | + cancelGen() | ||
| 310 | + } | ||
| 311 | +} | ||
| 312 | + | ||
| 313 | +// printCommands recaps the commands of the turn, in green. | ||
| 314 | +// | ||
| 315 | +// The count says HOW MANY, the list says WHAT: it reads at a glance once the | ||
| 316 | +// answer is written, whereas the 🛠️ lines are scattered through the output of | ||
| 317 | +// the commands. Green separates it from both the grey of the tools and the | ||
| 318 | +// model's own text — and it only comes out on a terminal, like every other | ||
| 319 | +// colour here. | ||
| 320 | +func printCommands(cmds []string) { | ||
| 321 | + if len(cmds) == 0 { | ||
| 322 | + return | ||
| 323 | + } | ||
| 324 | + green, off := "", "" | ||
| 325 | + if spinner.Styled() { | ||
| 326 | + green, off = "\033[32m", "\033[0m" | ||
| 327 | + } | ||
| 328 | + | ||
| 329 | + var b strings.Builder | ||
| 330 | + for i, c := range cmds { | ||
| 331 | + b.WriteString(green + fmt.Sprintf(" %d. %s", i+1, oneLine(c)) + off + "\n") | ||
| 332 | + } | ||
| 333 | + fmt.Print(b.String()) | ||
| 334 | +} | ||
| 335 | + | ||
| 336 | +// maxCommandWidth is the width beyond which a command is cut. A hundred | ||
| 337 | +// columns: that fits a demo terminal without overflowing it. | ||
| 338 | +const maxCommandWidth = 100 | ||
| 339 | + | ||
| 340 | +// oneLine folds a command onto ONE line and truncates it when needed. | ||
| 341 | +// | ||
| 342 | +// A command can be long and span several lines — a heredoc, a trailing &&. The | ||
| 343 | +// recap is there to scan what the agent did, not to re-read its code: spread | ||
| 344 | +// over several lines it would grow longer than the output it summarises, and | ||
| 345 | +// the numbering would stop being readable. | ||
| 346 | +func oneLine(s string) string { | ||
| 347 | + // Fields splits on ALL whitespace, line breaks included: that is exactly | ||
| 348 | + // the folding we want, heredoc indentation and all. | ||
| 349 | + s = strings.Join(strings.Fields(s), " ") | ||
| 350 | + r := []rune(s) | ||
| 351 | + if len(r) <= maxCommandWidth { | ||
| 352 | + return s | ||
| 353 | + } | ||
| 354 | + return string(r[:maxCommandWidth-1]) + "…" | ||
| 355 | +} | ||
| 356 | + | ||
| 357 | +// compactHistory replaces the old turns of `msgs` by a model-written summary | ||
| 358 | +// and says so in one line. On ANY failure — server down, watchdog, empty or | ||
| 359 | +// malformed result — it returns `msgs` unchanged: the compression exists to | ||
| 360 | +// keep the next question possible, so it must never cost one. | ||
| 361 | +// | ||
| 362 | +// `forced` is the /compact command: it skips the threshold, not the "is there | ||
| 363 | +// anything older than the kept turns" check, and it says so when there is not. | ||
| 364 | +// The loop detector is deliberately not touched: it records what the agent | ||
| 365 | +// DOES, and forgetting a conversation does not make a repeated command new. | ||
| 366 | +func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, forced bool) []*ai.Message { | ||
| 367 | + cfg := config.Cfg.Context | ||
| 368 | + | ||
| 369 | + // The summary is the longest prefill of the session and prints nothing | ||
| 370 | + // while it runs: without a label the agent looks hung, exactly the case | ||
| 371 | + // the spinner exists for. | ||
| 372 | + spinner.Start("Compressing") | ||
| 373 | + res, err := compact.Compact(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) { | ||
| 374 | + return e.Summarize(ctx, request, cfg.SummaryMaxTokens) | ||
| 375 | + }) | ||
| 376 | + spinner.Stop() | ||
| 377 | + | ||
| 378 | + switch { | ||
| 379 | + case errors.Is(err, compact.ErrNothingToCompact): | ||
| 380 | + if forced { | ||
| 381 | + dimln(fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns)) | ||
| 382 | + } | ||
| 383 | + return msgs | ||
| 384 | + case err != nil: | ||
| 385 | + // One line, in the provider's words, like every other error here. | ||
| 386 | + fmt.Printf("[compact: failed, history kept: %s]\n", e.Explain(err)) | ||
| 387 | + return msgs | ||
| 388 | + } | ||
| 389 | + | ||
| 390 | + // The server's last measure described the history we just replaced. | ||
| 391 | + e.ForgetInputTokens() | ||
| 392 | + | ||
| 393 | + if cfg.ShowStats { | ||
| 394 | + dimln("🗜️ " + res.Report()) | ||
| 395 | + } | ||
| 396 | + return res.Messages | ||
| 397 | +} | ||
| 398 | + | ||
| 399 | +// startNewSession forgets the conversation and returns the history of a fresh | ||
| 400 | +// one: the system prompt alone. Three things make up "the session" here, and | ||
| 401 | +// all three are reset — the messages, the server's token count of the last | ||
| 402 | +// context (it described a history that no longer exists, same rule as after a | ||
| 403 | +// compression), and the loop detector (a new session is a new task: a command | ||
| 404 | +// repeated in the old one must not be flagged as a loop in this one — the | ||
| 405 | +// opposite of compactHistory's choice, where the task continues). | ||
| 406 | +// | ||
| 407 | +// It says in one line how much was forgotten, so a /new on an empty session | ||
| 408 | +// visibly did nothing rather than silently nothing. | ||
| 409 | +func startNewSession(e *engine.Engine, d *detector.LoopDetector, system string, old []*ai.Message) []*ai.Message { | ||
| 410 | + forgotten := session.Forgotten(old) | ||
| 411 | + e.ForgetInputTokens() | ||
| 412 | + d.Reset() | ||
| 413 | + dimln(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten)) | ||
| 414 | + return session.Fresh(system) | ||
| 415 | +} | ||
| 416 | + | ||
| 417 | +// dimln prints one line, dimmed on a terminal and plain in a pipe — the same | ||
| 418 | +// rule as the ⚙ line: `bob | jq` must not receive escape codes. | ||
| 419 | +func dimln(line string) { | ||
| 420 | + if spinner.Styled() { | ||
| 421 | + fmt.Printf("\033[2m%s\033[0m\n", line) | ||
| 422 | + } else { | ||
| 423 | + fmt.Println(line) | ||
| 424 | + } | ||
| 425 | +} | ||
added
internal/compact/compact.go +307 -0 | new file mode 100644 | ||
| @@ -0,0 +1,307 @@ | ||
| 1 | +// Package compact keeps the conversation history inside the model's context | |
| 2 | +// window. When the history grows past a threshold, the OLD question turns are | |
| 3 | +// replaced by ONE summary written by the model itself, and the last N turns | |
| 4 | +// stay raw — the hybrid of CONTEXT_WINDOW.md § 2.e. | |
| 5 | +// | |
| 6 | +// Why it exists: nothing in the agent ever shortens `messages`. Measured with | |
| 7 | +// the fake engine of part 03, the history sent to the model grew 2 → 5 → 7 → 9 | |
| 8 | +// messages over four requests, and every `bash` output (up to maxOutput | |
| 9 | +// characters) stays until /quit. On a local model the window is fixed at load | |
| 10 | +// time, so a long session first shows up as a false watchdog stall, then as a | |
| 11 | +// 400 from the engine. | |
| 12 | +// | |
| 13 | +// The package never calls the model directly: it builds the summary request | |
| 14 | +// and hands it to a Summarizer (engine.Summarize in production, a function | |
| 15 | +// returning a fixed text in tests). It knows nothing about the screen either — | |
| 16 | +// the report line is the agent's job. | |
| 17 | +package compact | |
| 18 | + | |
| 19 | +import ( | |
| 20 | + "context" | |
| 21 | + "encoding/json" | |
| 22 | + "errors" | |
| 23 | + "fmt" | |
| 24 | + "slices" | |
| 25 | + "strings" | |
| 26 | + "time" | |
| 27 | + | |
| 28 | + "mm/internal/config" | |
| 29 | + | |
| 30 | + "github.com/firebase/genkit/go/ai" | |
| 31 | +) | |
| 32 | + | |
| 33 | +// Summarizer answers a request built by this package — a system message, the | |
| 34 | +// old turns, then the prompt as a user message — with plain text. | |
| 35 | +type Summarizer func(ctx context.Context, request []*ai.Message) (string, error) | |
| 36 | + | |
| 37 | +// Result describes one compression, for the one-line report. | |
| 38 | +type Result struct { | |
| 39 | + Messages []*ai.Message | |
| 40 | + Compressed int // messages replaced by the summary pair | |
| 41 | + Kept int // raw messages kept after the summary (system excluded) | |
| 42 | + Before int // estimated tokens before | |
| 43 | + After int // estimated tokens after | |
| 44 | + Elapsed time.Duration | |
| 45 | +} | |
| 46 | + | |
| 47 | +// Report is the one-line account of a compression, shared by the two front | |
| 48 | +// ends so the terminal and the editor say the same thing: | |
| 49 | +// | |
| 50 | +// compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s) | |
| 51 | +func (r Result) Report() string { | |
| 52 | + return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)", | |
| 53 | + r.Compressed, r.Kept, kilo(r.Before), kilo(r.After), seconds(r.Elapsed)) | |
| 54 | +} | |
| 55 | + | |
| 56 | +// kilo prints a token count the way the banner does: 18.2k, not 18234. | |
| 57 | +func kilo(n int) string { | |
| 58 | + if n < 1000 { | |
| 59 | + return fmt.Sprintf("%d", n) | |
| 60 | + } | |
| 61 | + return fmt.Sprintf("%.1fk", float64(n)/1000) | |
| 62 | +} | |
| 63 | + | |
| 64 | +// seconds prints a duration with one decimal under ten seconds, none above. | |
| 65 | +func seconds(d time.Duration) string { | |
| 66 | + if d < 10*time.Second { | |
| 67 | + return fmt.Sprintf("%.1fs", d.Seconds()) | |
| 68 | + } | |
| 69 | + return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 70 | +} | |
| 71 | + | |
| 72 | +// ErrNothingToCompact is returned when the history holds no turn older than | |
| 73 | +// the ones to keep. It is not a failure: the threshold can be reached inside a | |
| 74 | +// single long turn, and there is then nothing to summarise yet. | |
| 75 | +var ErrNothingToCompact = errors.New("nothing to compact") | |
| 76 | + | |
| 77 | +// metaKey marks the two messages a compression inserts, so that a later | |
| 78 | +// compression recognises an earlier summary (and merges it instead of | |
| 79 | +// summarising it a second time — detail decays exponentially otherwise). | |
| 80 | +const metaKey = "compact" | |
| 81 | + | |
| 82 | +// Decision says whether to compress before the next question, and on what | |
| 83 | +// grounds. Tokens is the figure that was compared to the threshold; it is | |
| 84 | +// reported even when nothing triggers, for the /context-style displays. | |
| 85 | +type Decision struct { | |
| 86 | + Compact bool | |
| 87 | + Tokens int | |
| 88 | + Reason string // "tokens", "messages", or "" when nothing triggered | |
| 89 | +} | |
| 90 | + | |
| 91 | +// Decide applies the automatic trigger. | |
| 92 | +// | |
| 93 | +// `measured` is the input-token count the engine reported for the last call | |
| 94 | +// to the model (0 when it reported nothing, as the fake engine does). The | |
| 95 | +// larger of it and the local estimate is used: the measure sees what the | |
| 96 | +// estimate cannot (the tools' JSON, the chat template), but it lags one call | |
| 97 | +// behind and dies with the compression that made it stale — the caller | |
| 98 | +// forgets it after a successful compression. | |
| 99 | +// | |
| 100 | +// `window` is the served context size as far as the agent knows it: the | |
| 101 | +// yaml's `contextWindow`, or what the provider's probe learned from the server | |
| 102 | +// (llama-server's /props) — see engine.Engine.ContextWindow. 0 = unknown, and | |
| 103 | +// then only MaxMessages can trigger. | |
| 104 | +func Decide(msgs []*ai.Message, measured, window int, cfg config.ContextConfig) Decision { | |
| 105 | + d := Decision{Tokens: max(Estimate(msgs), measured)} | |
| 106 | + if !cfg.Enabled { | |
| 107 | + return d | |
| 108 | + } | |
| 109 | + switch { | |
| 110 | + // Cross-multiplied to stay in integers: tokens ≥ window × threshold / 100. | |
| 111 | + case window > 0 && d.Tokens*100 >= window*cfg.Threshold: | |
| 112 | + d.Compact, d.Reason = true, "tokens" | |
| 113 | + case cfg.MaxMessages > 0 && len(msgs) >= cfg.MaxMessages: | |
| 114 | + d.Compact, d.Reason = true, "messages" | |
| 115 | + } | |
| 116 | + return d | |
| 117 | +} | |
| 118 | + | |
| 119 | +// Compact replaces every turn but the last cfg.KeepLastTurns by a summary pair | |
| 120 | +// (a user message carrying the notes, a short model acknowledgement), inserted | |
| 121 | +// right after the system prompt. The kept turns are reused as they are, so the | |
| 122 | +// tool_call/tool_result pairs inside them cannot be split; the cut itself falls | |
| 123 | +// on a turn boundary, never on a message count. | |
| 124 | +// | |
| 125 | +// On any error the caller's history is left untouched: the returned Result is | |
| 126 | +// empty and `msgs` was never modified. | |
| 127 | +func Compact(ctx context.Context, msgs []*ai.Message, cfg config.ContextConfig, summarize Summarizer) (Result, error) { | |
| 128 | + start := time.Now() | |
| 129 | + | |
| 130 | + head, turns := Split(msgs) | |
| 131 | + keep := max(cfg.KeepLastTurns, 1) | |
| 132 | + if len(turns) <= keep { | |
| 133 | + return Result{}, ErrNothingToCompact | |
| 134 | + } | |
| 135 | + old, recent := turns[:len(turns)-keep], turns[len(turns)-keep:] | |
| 136 | + | |
| 137 | + // An earlier summary that is the only old turn: re-summarising a summary | |
| 138 | + // loses detail and gains nothing. | |
| 139 | + merge := IsSummary(old[0][0]) | |
| 140 | + if merge && len(old) == 1 { | |
| 141 | + return Result{}, ErrNothingToCompact | |
| 142 | + } | |
| 143 | + | |
| 144 | + oldMsgs := flatten(old) | |
| 145 | + text, err := summarize(ctx, Request(oldMsgs, cfg.Prompt, merge)) | |
| 146 | + if err != nil { | |
| 147 | + return Result{}, err | |
| 148 | + } | |
| 149 | + if strings.TrimSpace(text) == "" { | |
| 150 | + return Result{}, errors.New("empty summary") | |
| 151 | + } | |
| 152 | + | |
| 153 | + questions := len(old) | |
| 154 | + if merge { | |
| 155 | + questions-- // the summary pair is not a question the user asked | |
| 156 | + } | |
| 157 | + pair := SummaryPair(text, len(oldMsgs), questions, commands(oldMsgs)) | |
| 158 | + | |
| 159 | + out := make([]*ai.Message, 0, len(head)+len(pair)+len(msgs)) | |
| 160 | + out = append(out, head...) | |
| 161 | + out = append(out, pair...) | |
| 162 | + kept := flatten(recent) | |
| 163 | + out = append(out, kept...) | |
| 164 | + | |
| 165 | + // The safety net: the cut above cannot split a pair, but the check is | |
| 166 | + // cheap and the failure it guards against is a 400 on the next question. | |
| 167 | + if err := Valid(out); err != nil { | |
| 168 | + return Result{}, fmt.Errorf("compressed history is invalid: %w", err) | |
| 169 | + } | |
| 170 | + | |
| 171 | + return Result{ | |
| 172 | + Messages: out, | |
| 173 | + Compressed: len(oldMsgs), | |
| 174 | + Kept: len(kept), | |
| 175 | + Before: Estimate(msgs), | |
| 176 | + After: Estimate(out), | |
| 177 | + Elapsed: time.Since(start), | |
| 178 | + }, nil | |
| 179 | +} | |
| 180 | + | |
| 181 | +// Split separates the leading system message(s) from the question turns. A | |
| 182 | +// turn starts at a user message and runs until the next one, so it holds the | |
| 183 | +// whole model ↔ tools exchange the question caused; an earlier summary pair is | |
| 184 | +// a turn of its own, since it starts with a user message too. | |
| 185 | +// | |
| 186 | +// Every strategy of CONTEXT_WINDOW.md cuts on these boundaries: cutting on a | |
| 187 | +// message count can leave a `tool` message without the `assistant` that asked | |
| 188 | +// for it, which the OpenAI API refuses outright. | |
| 189 | +func Split(msgs []*ai.Message) (head []*ai.Message, turns [][]*ai.Message) { | |
| 190 | + i := 0 | |
| 191 | + for i < len(msgs) && msgs[i].Role == ai.RoleSystem { | |
| 192 | + i++ | |
| 193 | + } | |
| 194 | + head = msgs[:i] | |
| 195 | + for _, m := range msgs[i:] { | |
| 196 | + if m.Role == ai.RoleUser || len(turns) == 0 { | |
| 197 | + turns = append(turns, nil) | |
| 198 | + } | |
| 199 | + turns[len(turns)-1] = append(turns[len(turns)-1], m) | |
| 200 | + } | |
| 201 | + return head, turns | |
| 202 | +} | |
| 203 | + | |
| 204 | +// Valid checks what an OpenAI-compatible server checks before answering: the | |
| 205 | +// history starts with the system prompt, every tool request sits in a model | |
| 206 | +// message, and every tool response answers a request seen earlier — paired by | |
| 207 | +// Ref when the server set one, by tool name otherwise (same rule as | |
| 208 | +// engine.executed). | |
| 209 | +func Valid(msgs []*ai.Message) error { | |
| 210 | + if len(msgs) == 0 || msgs[0].Role != ai.RoleSystem { | |
| 211 | + return errors.New("history does not start with the system prompt") | |
| 212 | + } | |
| 213 | + type call struct{ ref, name string } | |
| 214 | + var pending []call | |
| 215 | + for i, m := range msgs { | |
| 216 | + for _, p := range m.Content { | |
| 217 | + switch { | |
| 218 | + case p.IsToolRequest() && p.ToolRequest != nil: | |
| 219 | + if m.Role != ai.RoleModel { | |
| 220 | + return fmt.Errorf("message %d: tool request in a %s message", i, m.Role) | |
| 221 | + } | |
| 222 | + pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name}) | |
| 223 | + case p.IsToolResponse() && p.ToolResponse != nil: | |
| 224 | + r := p.ToolResponse | |
| 225 | + j := slices.IndexFunc(pending, func(c call) bool { | |
| 226 | + if r.Ref != "" { | |
| 227 | + return c.ref == r.Ref | |
| 228 | + } | |
| 229 | + return c.name == r.Name | |
| 230 | + }) | |
| 231 | + if j < 0 { | |
| 232 | + return fmt.Errorf("message %d: tool response %q answers no pending request", i, r.Name) | |
| 233 | + } | |
| 234 | + pending = slices.Delete(pending, j, j+1) | |
| 235 | + } | |
| 236 | + } | |
| 237 | + } | |
| 238 | + return nil | |
| 239 | +} | |
| 240 | + | |
| 241 | +// Estimate approximates the token count of a history without asking the | |
| 242 | +// engine: characters divided by 3.5, plus a few tokens of framing per message. | |
| 243 | +// | |
| 244 | +// 4 characters per token holds for English prose; code, JSON, paths and French | |
| 245 | +// tokenise closer to 3. Taking 3.5 over-estimates prose a little, which is the | |
| 246 | +// safe side for a threshold. The engine's own count, when it reports one, is | |
| 247 | +// preferred by Decide — this is the fallback, and the number the report shows | |
| 248 | +// for a history that was never sent. | |
| 249 | +func Estimate(msgs []*ai.Message) int { | |
| 250 | + chars := 0 | |
| 251 | + for _, m := range msgs { | |
| 252 | + for _, p := range m.Content { | |
| 253 | + switch { | |
| 254 | + case p.IsText(): | |
| 255 | + chars += len(p.Text) | |
| 256 | + case p.IsToolRequest() && p.ToolRequest != nil: | |
| 257 | + chars += len(p.ToolRequest.Name) + len(jsonOf(p.ToolRequest.Input)) | |
| 258 | + case p.IsToolResponse() && p.ToolResponse != nil: | |
| 259 | + chars += len(p.ToolResponse.Name) + len(jsonOf(p.ToolResponse.Output)) | |
| 260 | + } | |
| 261 | + } | |
| 262 | + } | |
| 263 | + return (chars*2+6)/7 + 4*len(msgs) | |
| 264 | +} | |
| 265 | + | |
| 266 | +// jsonOf renders a tool payload the way the request will carry it. A string | |
| 267 | +// output is taken as-is: marshalling it would count the escaping twice. | |
| 268 | +func jsonOf(v any) string { | |
| 269 | + if s, ok := v.(string); ok { | |
| 270 | + return s | |
| 271 | + } | |
| 272 | + b, err := json.Marshal(v) | |
| 273 | + if err != nil { | |
| 274 | + return fmt.Sprint(v) | |
| 275 | + } | |
| 276 | + return string(b) | |
| 277 | +} | |
| 278 | + | |
| 279 | +// IsSummary reports whether a message is the notes half of a summary pair. | |
| 280 | +func IsSummary(m *ai.Message) bool { | |
| 281 | + return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary" | |
| 282 | +} | |
| 283 | + | |
| 284 | +// flatten joins turns back into one message list. | |
| 285 | +func flatten(turns [][]*ai.Message) []*ai.Message { | |
| 286 | + var out []*ai.Message | |
| 287 | + for _, t := range turns { | |
| 288 | + out = append(out, t...) | |
| 289 | + } | |
| 290 | + return out | |
| 291 | +} | |
| 292 | + | |
| 293 | +// commands counts the bash commands that actually ran in a history — one per | |
| 294 | +// response of the `bash` tool, the same measure as engine.Commands. It is not | |
| 295 | +// imported from engine so that this package stays free of Genkit's wiring and | |
| 296 | +// testable with a plain function. | |
| 297 | +func commands(msgs []*ai.Message) int { | |
| 298 | + n := 0 | |
| 299 | + for _, m := range msgs { | |
| 300 | + for _, p := range m.Content { | |
| 301 | + if p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == "bash" { | |
| 302 | + n++ | |
| 303 | + } | |
| 304 | + } | |
| 305 | + } | |
| 306 | + return n | |
| 307 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,307 @@ | |||
| 1 | +// Package compact keeps the conversation history inside the model's context | ||
| 2 | +// window. When the history grows past a threshold, the OLD question turns are | ||
| 3 | +// replaced by ONE summary written by the model itself, and the last N turns | ||
| 4 | +// stay raw — the hybrid of CONTEXT_WINDOW.md § 2.e. | ||
| 5 | +// | ||
| 6 | +// Why it exists: nothing in the agent ever shortens `messages`. Measured with | ||
| 7 | +// the fake engine of part 03, the history sent to the model grew 2 → 5 → 7 → 9 | ||
| 8 | +// messages over four requests, and every `bash` output (up to maxOutput | ||
| 9 | +// characters) stays until /quit. On a local model the window is fixed at load | ||
| 10 | +// time, so a long session first shows up as a false watchdog stall, then as a | ||
| 11 | +// 400 from the engine. | ||
| 12 | +// | ||
| 13 | +// The package never calls the model directly: it builds the summary request | ||
| 14 | +// and hands it to a Summarizer (engine.Summarize in production, a function | ||
| 15 | +// returning a fixed text in tests). It knows nothing about the screen either — | ||
| 16 | +// the report line is the agent's job. | ||
| 17 | +package compact | ||
| 18 | + | ||
| 19 | +import ( | ||
| 20 | + "context" | ||
| 21 | + "encoding/json" | ||
| 22 | + "errors" | ||
| 23 | + "fmt" | ||
| 24 | + "slices" | ||
| 25 | + "strings" | ||
| 26 | + "time" | ||
| 27 | + | ||
| 28 | + "mm/internal/config" | ||
| 29 | + | ||
| 30 | + "github.com/firebase/genkit/go/ai" | ||
| 31 | +) | ||
| 32 | + | ||
| 33 | +// Summarizer answers a request built by this package — a system message, the | ||
| 34 | +// old turns, then the prompt as a user message — with plain text. | ||
| 35 | +type Summarizer func(ctx context.Context, request []*ai.Message) (string, error) | ||
| 36 | + | ||
| 37 | +// Result describes one compression, for the one-line report. | ||
| 38 | +type Result struct { | ||
| 39 | + Messages []*ai.Message | ||
| 40 | + Compressed int // messages replaced by the summary pair | ||
| 41 | + Kept int // raw messages kept after the summary (system excluded) | ||
| 42 | + Before int // estimated tokens before | ||
| 43 | + After int // estimated tokens after | ||
| 44 | + Elapsed time.Duration | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +// Report is the one-line account of a compression, shared by the two front | ||
| 48 | +// ends so the terminal and the editor say the same thing: | ||
| 49 | +// | ||
| 50 | +// compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s) | ||
| 51 | +func (r Result) Report() string { | ||
| 52 | + return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)", | ||
| 53 | + r.Compressed, r.Kept, kilo(r.Before), kilo(r.After), seconds(r.Elapsed)) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +// kilo prints a token count the way the banner does: 18.2k, not 18234. | ||
| 57 | +func kilo(n int) string { | ||
| 58 | + if n < 1000 { | ||
| 59 | + return fmt.Sprintf("%d", n) | ||
| 60 | + } | ||
| 61 | + return fmt.Sprintf("%.1fk", float64(n)/1000) | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +// seconds prints a duration with one decimal under ten seconds, none above. | ||
| 65 | +func seconds(d time.Duration) string { | ||
| 66 | + if d < 10*time.Second { | ||
| 67 | + return fmt.Sprintf("%.1fs", d.Seconds()) | ||
| 68 | + } | ||
| 69 | + return fmt.Sprintf("%ds", int(d.Seconds())) | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +// ErrNothingToCompact is returned when the history holds no turn older than | ||
| 73 | +// the ones to keep. It is not a failure: the threshold can be reached inside a | ||
| 74 | +// single long turn, and there is then nothing to summarise yet. | ||
| 75 | +var ErrNothingToCompact = errors.New("nothing to compact") | ||
| 76 | + | ||
| 77 | +// metaKey marks the two messages a compression inserts, so that a later | ||
| 78 | +// compression recognises an earlier summary (and merges it instead of | ||
| 79 | +// summarising it a second time — detail decays exponentially otherwise). | ||
| 80 | +const metaKey = "compact" | ||
| 81 | + | ||
| 82 | +// Decision says whether to compress before the next question, and on what | ||
| 83 | +// grounds. Tokens is the figure that was compared to the threshold; it is | ||
| 84 | +// reported even when nothing triggers, for the /context-style displays. | ||
| 85 | +type Decision struct { | ||
| 86 | + Compact bool | ||
| 87 | + Tokens int | ||
| 88 | + Reason string // "tokens", "messages", or "" when nothing triggered | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +// Decide applies the automatic trigger. | ||
| 92 | +// | ||
| 93 | +// `measured` is the input-token count the engine reported for the last call | ||
| 94 | +// to the model (0 when it reported nothing, as the fake engine does). The | ||
| 95 | +// larger of it and the local estimate is used: the measure sees what the | ||
| 96 | +// estimate cannot (the tools' JSON, the chat template), but it lags one call | ||
| 97 | +// behind and dies with the compression that made it stale — the caller | ||
| 98 | +// forgets it after a successful compression. | ||
| 99 | +// | ||
| 100 | +// `window` is the served context size as far as the agent knows it: the | ||
| 101 | +// yaml's `contextWindow`, or what the provider's probe learned from the server | ||
| 102 | +// (llama-server's /props) — see engine.Engine.ContextWindow. 0 = unknown, and | ||
| 103 | +// then only MaxMessages can trigger. | ||
| 104 | +func Decide(msgs []*ai.Message, measured, window int, cfg config.ContextConfig) Decision { | ||
| 105 | + d := Decision{Tokens: max(Estimate(msgs), measured)} | ||
| 106 | + if !cfg.Enabled { | ||
| 107 | + return d | ||
| 108 | + } | ||
| 109 | + switch { | ||
| 110 | + // Cross-multiplied to stay in integers: tokens ≥ window × threshold / 100. | ||
| 111 | + case window > 0 && d.Tokens*100 >= window*cfg.Threshold: | ||
| 112 | + d.Compact, d.Reason = true, "tokens" | ||
| 113 | + case cfg.MaxMessages > 0 && len(msgs) >= cfg.MaxMessages: | ||
| 114 | + d.Compact, d.Reason = true, "messages" | ||
| 115 | + } | ||
| 116 | + return d | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +// Compact replaces every turn but the last cfg.KeepLastTurns by a summary pair | ||
| 120 | +// (a user message carrying the notes, a short model acknowledgement), inserted | ||
| 121 | +// right after the system prompt. The kept turns are reused as they are, so the | ||
| 122 | +// tool_call/tool_result pairs inside them cannot be split; the cut itself falls | ||
| 123 | +// on a turn boundary, never on a message count. | ||
| 124 | +// | ||
| 125 | +// On any error the caller's history is left untouched: the returned Result is | ||
| 126 | +// empty and `msgs` was never modified. | ||
| 127 | +func Compact(ctx context.Context, msgs []*ai.Message, cfg config.ContextConfig, summarize Summarizer) (Result, error) { | ||
| 128 | + start := time.Now() | ||
| 129 | + | ||
| 130 | + head, turns := Split(msgs) | ||
| 131 | + keep := max(cfg.KeepLastTurns, 1) | ||
| 132 | + if len(turns) <= keep { | ||
| 133 | + return Result{}, ErrNothingToCompact | ||
| 134 | + } | ||
| 135 | + old, recent := turns[:len(turns)-keep], turns[len(turns)-keep:] | ||
| 136 | + | ||
| 137 | + // An earlier summary that is the only old turn: re-summarising a summary | ||
| 138 | + // loses detail and gains nothing. | ||
| 139 | + merge := IsSummary(old[0][0]) | ||
| 140 | + if merge && len(old) == 1 { | ||
| 141 | + return Result{}, ErrNothingToCompact | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + oldMsgs := flatten(old) | ||
| 145 | + text, err := summarize(ctx, Request(oldMsgs, cfg.Prompt, merge)) | ||
| 146 | + if err != nil { | ||
| 147 | + return Result{}, err | ||
| 148 | + } | ||
| 149 | + if strings.TrimSpace(text) == "" { | ||
| 150 | + return Result{}, errors.New("empty summary") | ||
| 151 | + } | ||
| 152 | + | ||
| 153 | + questions := len(old) | ||
| 154 | + if merge { | ||
| 155 | + questions-- // the summary pair is not a question the user asked | ||
| 156 | + } | ||
| 157 | + pair := SummaryPair(text, len(oldMsgs), questions, commands(oldMsgs)) | ||
| 158 | + | ||
| 159 | + out := make([]*ai.Message, 0, len(head)+len(pair)+len(msgs)) | ||
| 160 | + out = append(out, head...) | ||
| 161 | + out = append(out, pair...) | ||
| 162 | + kept := flatten(recent) | ||
| 163 | + out = append(out, kept...) | ||
| 164 | + | ||
| 165 | + // The safety net: the cut above cannot split a pair, but the check is | ||
| 166 | + // cheap and the failure it guards against is a 400 on the next question. | ||
| 167 | + if err := Valid(out); err != nil { | ||
| 168 | + return Result{}, fmt.Errorf("compressed history is invalid: %w", err) | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + return Result{ | ||
| 172 | + Messages: out, | ||
| 173 | + Compressed: len(oldMsgs), | ||
| 174 | + Kept: len(kept), | ||
| 175 | + Before: Estimate(msgs), | ||
| 176 | + After: Estimate(out), | ||
| 177 | + Elapsed: time.Since(start), | ||
| 178 | + }, nil | ||
| 179 | +} | ||
| 180 | + | ||
| 181 | +// Split separates the leading system message(s) from the question turns. A | ||
| 182 | +// turn starts at a user message and runs until the next one, so it holds the | ||
| 183 | +// whole model ↔ tools exchange the question caused; an earlier summary pair is | ||
| 184 | +// a turn of its own, since it starts with a user message too. | ||
| 185 | +// | ||
| 186 | +// Every strategy of CONTEXT_WINDOW.md cuts on these boundaries: cutting on a | ||
| 187 | +// message count can leave a `tool` message without the `assistant` that asked | ||
| 188 | +// for it, which the OpenAI API refuses outright. | ||
| 189 | +func Split(msgs []*ai.Message) (head []*ai.Message, turns [][]*ai.Message) { | ||
| 190 | + i := 0 | ||
| 191 | + for i < len(msgs) && msgs[i].Role == ai.RoleSystem { | ||
| 192 | + i++ | ||
| 193 | + } | ||
| 194 | + head = msgs[:i] | ||
| 195 | + for _, m := range msgs[i:] { | ||
| 196 | + if m.Role == ai.RoleUser || len(turns) == 0 { | ||
| 197 | + turns = append(turns, nil) | ||
| 198 | + } | ||
| 199 | + turns[len(turns)-1] = append(turns[len(turns)-1], m) | ||
| 200 | + } | ||
| 201 | + return head, turns | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +// Valid checks what an OpenAI-compatible server checks before answering: the | ||
| 205 | +// history starts with the system prompt, every tool request sits in a model | ||
| 206 | +// message, and every tool response answers a request seen earlier — paired by | ||
| 207 | +// Ref when the server set one, by tool name otherwise (same rule as | ||
| 208 | +// engine.executed). | ||
| 209 | +func Valid(msgs []*ai.Message) error { | ||
| 210 | + if len(msgs) == 0 || msgs[0].Role != ai.RoleSystem { | ||
| 211 | + return errors.New("history does not start with the system prompt") | ||
| 212 | + } | ||
| 213 | + type call struct{ ref, name string } | ||
| 214 | + var pending []call | ||
| 215 | + for i, m := range msgs { | ||
| 216 | + for _, p := range m.Content { | ||
| 217 | + switch { | ||
| 218 | + case p.IsToolRequest() && p.ToolRequest != nil: | ||
| 219 | + if m.Role != ai.RoleModel { | ||
| 220 | + return fmt.Errorf("message %d: tool request in a %s message", i, m.Role) | ||
| 221 | + } | ||
| 222 | + pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name}) | ||
| 223 | + case p.IsToolResponse() && p.ToolResponse != nil: | ||
| 224 | + r := p.ToolResponse | ||
| 225 | + j := slices.IndexFunc(pending, func(c call) bool { | ||
| 226 | + if r.Ref != "" { | ||
| 227 | + return c.ref == r.Ref | ||
| 228 | + } | ||
| 229 | + return c.name == r.Name | ||
| 230 | + }) | ||
| 231 | + if j < 0 { | ||
| 232 | + return fmt.Errorf("message %d: tool response %q answers no pending request", i, r.Name) | ||
| 233 | + } | ||
| 234 | + pending = slices.Delete(pending, j, j+1) | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | + } | ||
| 238 | + return nil | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +// Estimate approximates the token count of a history without asking the | ||
| 242 | +// engine: characters divided by 3.5, plus a few tokens of framing per message. | ||
| 243 | +// | ||
| 244 | +// 4 characters per token holds for English prose; code, JSON, paths and French | ||
| 245 | +// tokenise closer to 3. Taking 3.5 over-estimates prose a little, which is the | ||
| 246 | +// safe side for a threshold. The engine's own count, when it reports one, is | ||
| 247 | +// preferred by Decide — this is the fallback, and the number the report shows | ||
| 248 | +// for a history that was never sent. | ||
| 249 | +func Estimate(msgs []*ai.Message) int { | ||
| 250 | + chars := 0 | ||
| 251 | + for _, m := range msgs { | ||
| 252 | + for _, p := range m.Content { | ||
| 253 | + switch { | ||
| 254 | + case p.IsText(): | ||
| 255 | + chars += len(p.Text) | ||
| 256 | + case p.IsToolRequest() && p.ToolRequest != nil: | ||
| 257 | + chars += len(p.ToolRequest.Name) + len(jsonOf(p.ToolRequest.Input)) | ||
| 258 | + case p.IsToolResponse() && p.ToolResponse != nil: | ||
| 259 | + chars += len(p.ToolResponse.Name) + len(jsonOf(p.ToolResponse.Output)) | ||
| 260 | + } | ||
| 261 | + } | ||
| 262 | + } | ||
| 263 | + return (chars*2+6)/7 + 4*len(msgs) | ||
| 264 | +} | ||
| 265 | + | ||
| 266 | +// jsonOf renders a tool payload the way the request will carry it. A string | ||
| 267 | +// output is taken as-is: marshalling it would count the escaping twice. | ||
| 268 | +func jsonOf(v any) string { | ||
| 269 | + if s, ok := v.(string); ok { | ||
| 270 | + return s | ||
| 271 | + } | ||
| 272 | + b, err := json.Marshal(v) | ||
| 273 | + if err != nil { | ||
| 274 | + return fmt.Sprint(v) | ||
| 275 | + } | ||
| 276 | + return string(b) | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +// IsSummary reports whether a message is the notes half of a summary pair. | ||
| 280 | +func IsSummary(m *ai.Message) bool { | ||
| 281 | + return m != nil && m.Role == ai.RoleUser && m.Metadata[metaKey] == "summary" | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +// flatten joins turns back into one message list. | ||
| 285 | +func flatten(turns [][]*ai.Message) []*ai.Message { | ||
| 286 | + var out []*ai.Message | ||
| 287 | + for _, t := range turns { | ||
| 288 | + out = append(out, t...) | ||
| 289 | + } | ||
| 290 | + return out | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +// commands counts the bash commands that actually ran in a history — one per | ||
| 294 | +// response of the `bash` tool, the same measure as engine.Commands. It is not | ||
| 295 | +// imported from engine so that this package stays free of Genkit's wiring and | ||
| 296 | +// testable with a plain function. | ||
| 297 | +func commands(msgs []*ai.Message) int { | ||
| 298 | + n := 0 | ||
| 299 | + for _, m := range msgs { | ||
| 300 | + for _, p := range m.Content { | ||
| 301 | + if p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == "bash" { | ||
| 302 | + n++ | ||
| 303 | + } | ||
| 304 | + } | ||
| 305 | + } | ||
| 306 | + return n | ||
| 307 | +} | ||
added
internal/compact/compact_test.go +366 -0 | new file mode 100644 | ||
| @@ -0,0 +1,366 @@ | ||
| 1 | +// The tests build histories by hand, the way TestCommandList does in | |
| 2 | +// internal/engine, and check the one property a compression must never break: | |
| 3 | +// the next request is still a valid conversation — system prompt first, every | |
| 4 | +// tool response under the request that asked for it, the recent turns intact. | |
| 5 | +// | |
| 6 | +// No network and no model: the Summarizer is a function here. The last test | |
| 7 | +// goes through engine.Summarize with a fake Genkit model, so the wiring the | |
| 8 | +// agent uses is exercised too. | |
| 9 | +package compact | |
| 10 | + | |
| 11 | +import ( | |
| 12 | + "context" | |
| 13 | + "errors" | |
| 14 | + "strings" | |
| 15 | + "testing" | |
| 16 | + | |
| 17 | + "mm/internal/config" | |
| 18 | + "mm/internal/engine" | |
| 19 | + | |
| 20 | + "github.com/firebase/genkit/go/ai" | |
| 21 | + "github.com/firebase/genkit/go/genkit" | |
| 22 | +) | |
| 23 | + | |
| 24 | +// --- fixtures --------------------------------------------------------------- | |
| 25 | + | |
| 26 | +func req(ref, cmd string) *ai.Part { | |
| 27 | + return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: "bash", Input: map[string]any{"command": cmd}}) | |
| 28 | +} | |
| 29 | + | |
| 30 | +func resp(ref, out string) *ai.Part { | |
| 31 | + return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: "bash", Output: out}) | |
| 32 | +} | |
| 33 | + | |
| 34 | +// turn builds one question turn with n commands: user, then n × (model call, | |
| 35 | +// tool response), then the model's final text. | |
| 36 | +func turn(question string, n int, output string) []*ai.Message { | |
| 37 | + msgs := []*ai.Message{ai.NewUserTextMessage(question)} | |
| 38 | + for i := 0; i < n; i++ { | |
| 39 | + ref := question + "-" + string(rune('a'+i)) | |
| 40 | + msgs = append(msgs, | |
| 41 | + ai.NewModelMessage(req(ref, "echo "+ref)), | |
| 42 | + ai.NewMessage(ai.RoleTool, nil, resp(ref, output)), | |
| 43 | + ) | |
| 44 | + } | |
| 45 | + return append(msgs, ai.NewModelMessage(ai.NewTextPart("done "+question))) | |
| 46 | +} | |
| 47 | + | |
| 48 | +// history is a system prompt followed by the given turns. | |
| 49 | +func history(turns ...[]*ai.Message) []*ai.Message { | |
| 50 | + msgs := []*ai.Message{ai.NewSystemTextMessage("You are Bob.")} | |
| 51 | + for _, t := range turns { | |
| 52 | + msgs = append(msgs, t...) | |
| 53 | + } | |
| 54 | + return msgs | |
| 55 | +} | |
| 56 | + | |
| 57 | +func cfg(keep int) config.ContextConfig { | |
| 58 | + return config.ContextConfig{Enabled: true, KeepLastTurns: keep, Threshold: 75, MaxMessages: 0, SummaryMaxTokens: 100} | |
| 59 | +} | |
| 60 | + | |
| 61 | +// fixed is a Summarizer that returns a constant and records the request. | |
| 62 | +func fixed(text string) (Summarizer, *[]*ai.Message) { | |
| 63 | + var seen []*ai.Message | |
| 64 | + return func(_ context.Context, request []*ai.Message) (string, error) { | |
| 65 | + seen = request | |
| 66 | + return text, nil | |
| 67 | + }, &seen | |
| 68 | +} | |
| 69 | + | |
| 70 | +// --- Split / Valid / Estimate ----------------------------------------------- | |
| 71 | + | |
| 72 | +func TestSplitCutsOnUserMessages(t *testing.T) { | |
| 73 | + msgs := history(turn("q1", 2, "x"), turn("q2", 0, "x"), turn("q3", 1, "x")) | |
| 74 | + head, turns := Split(msgs) | |
| 75 | + if len(head) != 1 || head[0].Role != ai.RoleSystem { | |
| 76 | + t.Fatalf("head = %d message(s), want the system prompt alone", len(head)) | |
| 77 | + } | |
| 78 | + if len(turns) != 3 { | |
| 79 | + t.Fatalf("turns = %d, want 3", len(turns)) | |
| 80 | + } | |
| 81 | + // q1: user + 2×(call, response) + text = 6 ; q2: 2 ; q3: 4. | |
| 82 | + for i, want := range []int{6, 2, 4} { | |
| 83 | + if len(turns[i]) != want { | |
| 84 | + t.Errorf("turn %d has %d messages, want %d", i, len(turns[i]), want) | |
| 85 | + } | |
| 86 | + if turns[i][0].Role != ai.RoleUser { | |
| 87 | + t.Errorf("turn %d does not start with a user message", i) | |
| 88 | + } | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +func TestSplitTreatsSummaryPairAsATurn(t *testing.T) { | |
| 93 | + pair := SummaryPair("notes", 10, 2, 3) | |
| 94 | + msgs := history(pair, turn("q4", 1, "x")) | |
| 95 | + _, turns := Split(msgs) | |
| 96 | + if len(turns) != 2 || !IsSummary(turns[0][0]) || len(turns[0]) != 2 { | |
| 97 | + t.Fatalf("the summary pair should be its own two-message turn, got %d turn(s)", len(turns)) | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +func TestValid(t *testing.T) { | |
| 102 | + ok := history(turn("q1", 2, "x")) | |
| 103 | + if err := Valid(ok); err != nil { | |
| 104 | + t.Errorf("a well-formed history is refused: %v", err) | |
| 105 | + } | |
| 106 | + | |
| 107 | + // A `tool` message whose request was cut away: the classic broken window. | |
| 108 | + orphan := []*ai.Message{ | |
| 109 | + ai.NewSystemTextMessage("s"), | |
| 110 | + ai.NewMessage(ai.RoleTool, nil, resp("z", "out")), | |
| 111 | + } | |
| 112 | + if err := Valid(orphan); err == nil { | |
| 113 | + t.Error("an orphan tool response is accepted") | |
| 114 | + } | |
| 115 | + | |
| 116 | + noSystem := turn("q1", 0, "x") | |
| 117 | + if err := Valid(noSystem); err == nil { | |
| 118 | + t.Error("a history without the system prompt is accepted") | |
| 119 | + } | |
| 120 | + | |
| 121 | + // No Ref on either side: pairing by name must still work. | |
| 122 | + byName := []*ai.Message{ | |
| 123 | + ai.NewSystemTextMessage("s"), | |
| 124 | + ai.NewUserTextMessage("q"), | |
| 125 | + ai.NewModelMessage(req("", "ls")), | |
| 126 | + ai.NewMessage(ai.RoleTool, nil, resp("", "out")), | |
| 127 | + } | |
| 128 | + if err := Valid(byName); err != nil { | |
| 129 | + t.Errorf("pairing by name fails: %v", err) | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +func TestEstimateGrowsWithToolOutput(t *testing.T) { | |
| 134 | + small := history(turn("q1", 1, "x")) | |
| 135 | + big := history(turn("q1", 1, strings.Repeat("0123456789\n", 350))) // 3 850 chars ≈ 1 100 tokens | |
| 136 | + s, b := Estimate(small), Estimate(big) | |
| 137 | + if b <= s { | |
| 138 | + t.Fatalf("Estimate(big) = %d is not above Estimate(small) = %d", b, s) | |
| 139 | + } | |
| 140 | + // 3.5 characters per token: 3 850 chars of output add about 1 100 tokens. | |
| 141 | + if delta := b - s; delta < 1000 || delta > 1200 { | |
| 142 | + t.Errorf("3 850 characters of output add %d tokens, want ~1 100", delta) | |
| 143 | + } | |
| 144 | +} | |
| 145 | + | |
| 146 | +// --- Decide ----------------------------------------------------------------- | |
| 147 | + | |
| 148 | +func TestDecide(t *testing.T) { | |
| 149 | + msgs := history(turn("q1", 1, strings.Repeat("x", 3500))) // ≈ 1 000 tokens estimated | |
| 150 | + | |
| 151 | + off := cfg(1) | |
| 152 | + off.Enabled = false | |
| 153 | + if d := Decide(msgs, 0, 1000, off); d.Compact { | |
| 154 | + t.Error("disabled config still triggers") | |
| 155 | + } | |
| 156 | + | |
| 157 | + // 1 000 tokens ≥ 75 % of a 1 000-token window: triggers on tokens. | |
| 158 | + if d := Decide(msgs, 0, 1000, cfg(1)); !d.Compact || d.Reason != "tokens" { | |
| 159 | + t.Errorf("tokens trigger: got %+v", d) | |
| 160 | + } | |
| 161 | + | |
| 162 | + // A window ten times wider: the estimate is far below the threshold… | |
| 163 | + if d := Decide(msgs, 0, 10000, cfg(1)); d.Compact { | |
| 164 | + t.Errorf("estimate below threshold still triggers: %+v", d) | |
| 165 | + } | |
| 166 | + // …but the engine's own measure wins when it is larger. | |
| 167 | + if d := Decide(msgs, 9000, 10000, cfg(1)); !d.Compact || d.Tokens != 9000 { | |
| 168 | + t.Errorf("measured tokens should win: %+v", d) | |
| 169 | + } | |
| 170 | + | |
| 171 | + // Unknown window (neither the yaml nor the probe gave one): only the | |
| 172 | + // message count can trigger. | |
| 173 | + byCount := cfg(1) | |
| 174 | + byCount.MaxMessages = 4 | |
| 175 | + if d := Decide(msgs, 0, 0, byCount); !d.Compact || d.Reason != "messages" { | |
| 176 | + t.Errorf("messages fallback: got %+v", d) | |
| 177 | + } | |
| 178 | + byCount.MaxMessages = 100 | |
| 179 | + if d := Decide(msgs, 0, 0, byCount); d.Compact { | |
| 180 | + t.Errorf("unknown window and few messages still triggers: %+v", d) | |
| 181 | + } | |
| 182 | +} | |
| 183 | + | |
| 184 | +// --- Compact ---------------------------------------------------------------- | |
| 185 | + | |
| 186 | +func TestCompactKeepsLastTurnsAndPairs(t *testing.T) { | |
| 187 | + // Realistic outputs: a `cat` or a `go test -v` runs to hundreds of lines, | |
| 188 | + // and that is the weight the summary has to beat — with three-character | |
| 189 | + // outputs the wrapper alone would be heavier than what it replaces. | |
| 190 | + big := strings.Repeat("line of tool output\n", 60) | |
| 191 | + q1, q2, q3, q4 := turn("q1", 2, big), turn("q2", 1, big), turn("q3", 1, big), turn("q4", 0, "") | |
| 192 | + msgs := history(q1, q2, q3, q4) | |
| 193 | + summarize, seen := fixed("## Goal\nsummary of q1 and q2") | |
| 194 | + | |
| 195 | + res, err := Compact(context.Background(), msgs, cfg(2), summarize) | |
| 196 | + if err != nil { | |
| 197 | + t.Fatalf("Compact: %v", err) | |
| 198 | + } | |
| 199 | + out := res.Messages | |
| 200 | + | |
| 201 | + // The request the model saw: note-taker system, the two old turns, prompt. | |
| 202 | + if len(*seen) != 1+len(q1)+len(q2)+1 { | |
| 203 | + t.Errorf("summary request has %d messages, want %d", len(*seen), 1+len(q1)+len(q2)+1) | |
| 204 | + } | |
| 205 | + if (*seen)[0].Role != ai.RoleSystem || (*seen)[0].Text() == msgs[0].Text() { | |
| 206 | + t.Error("the summary request must use the note-taker's system prompt, not the agent's") | |
| 207 | + } | |
| 208 | + if last := (*seen)[len(*seen)-1]; last.Role != ai.RoleUser || !strings.Contains(last.Text(), "## Files touched") { | |
| 209 | + t.Error("the summary request must end with the prompt") | |
| 210 | + } | |
| 211 | + if strings.Contains((*seen)[len(*seen)-1].Text(), "earlier summary") { | |
| 212 | + t.Error("no earlier summary here: the merge line must not be added") | |
| 213 | + } | |
| 214 | + | |
| 215 | + // The shape: system (same pointer), summary pair, then q3 and q4 untouched. | |
| 216 | + if out[0] != msgs[0] { | |
| 217 | + t.Error("the system prompt must be kept as-is, in first position") | |
| 218 | + } | |
| 219 | + if !IsSummary(out[1]) || !strings.Contains(out[1].Text(), "[Context summary — the 10 earlier messages") { | |
| 220 | + t.Errorf("message 1 should be the summary with its wrapper, got %q", out[1].Text()) | |
| 221 | + } | |
| 222 | + if !strings.Contains(out[1].Text(), "(2 question(s), 3 command(s))") { | |
| 223 | + t.Errorf("wrapper counts are off: %q", strings.SplitN(out[1].Text(), "\n", 2)[0]) | |
| 224 | + } | |
| 225 | + if out[2].Role != ai.RoleModel { | |
| 226 | + t.Error("message 2 should be the model's acknowledgement") | |
| 227 | + } | |
| 228 | + rest := out[3:] | |
| 229 | + want := append(append([]*ai.Message{}, q3...), q4...) | |
| 230 | + if len(rest) != len(want) { | |
| 231 | + t.Fatalf("kept %d messages, want %d", len(rest), len(want)) | |
| 232 | + } | |
| 233 | + for i := range want { | |
| 234 | + if rest[i] != want[i] { | |
| 235 | + t.Errorf("kept message %d is not the original pointer", i) | |
| 236 | + } | |
| 237 | + } | |
| 238 | + | |
| 239 | + if err := Valid(out); err != nil { | |
| 240 | + t.Errorf("compressed history invalid: %v", err) | |
| 241 | + } | |
| 242 | + if res.Compressed != len(q1)+len(q2) || res.Kept != len(q3)+len(q4) { | |
| 243 | + t.Errorf("counts: compressed %d kept %d, want %d and %d", res.Compressed, res.Kept, len(q1)+len(q2), len(q3)+len(q4)) | |
| 244 | + } | |
| 245 | + if res.After >= res.Before { | |
| 246 | + t.Errorf("tokens did not go down: %d → %d", res.Before, res.After) | |
| 247 | + } | |
| 248 | + // The input must not have been modified: same length, same pointers. | |
| 249 | + if len(msgs) != 1+len(q1)+len(q2)+len(q3)+len(q4) || msgs[1] != q1[0] { | |
| 250 | + t.Error("Compact modified its input") | |
| 251 | + } | |
| 252 | +} | |
| 253 | + | |
| 254 | +func TestCompactNothingToDo(t *testing.T) { | |
| 255 | + msgs := history(turn("q1", 3, "x")) | |
| 256 | + called := false | |
| 257 | + _, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) { | |
| 258 | + called = true | |
| 259 | + return "should not be asked", nil | |
| 260 | + }) | |
| 261 | + if !errors.Is(err, ErrNothingToCompact) { | |
| 262 | + t.Fatalf("err = %v, want ErrNothingToCompact", err) | |
| 263 | + } | |
| 264 | + if called { | |
| 265 | + t.Error("the model was asked for a summary with nothing to summarise") | |
| 266 | + } | |
| 267 | +} | |
| 268 | + | |
| 269 | +func TestCompactMergesAnEarlierSummary(t *testing.T) { | |
| 270 | + pair := SummaryPair("old notes", 12, 3, 5) | |
| 271 | + msgs := history(pair, turn("q4", 1, "x"), turn("q5", 0, "")) | |
| 272 | + summarize, seen := fixed("merged notes") | |
| 273 | + | |
| 274 | + res, err := Compact(context.Background(), msgs, cfg(1), summarize) | |
| 275 | + if err != nil { | |
| 276 | + t.Fatalf("Compact: %v", err) | |
| 277 | + } | |
| 278 | + prompt := (*seen)[len(*seen)-1].Text() | |
| 279 | + if !strings.Contains(prompt, "earlier summary") { | |
| 280 | + t.Error("the merge line is missing from the prompt") | |
| 281 | + } | |
| 282 | + // The old summary is IN the request (to be merged), not summarised away. | |
| 283 | + if !IsSummary((*seen)[1]) { | |
| 284 | + t.Error("the earlier summary should be the first old message of the request") | |
| 285 | + } | |
| 286 | + // Exactly one summary pair remains, and the wrapper counts q4 alone. | |
| 287 | + if n := countSummaries(res.Messages); n != 1 { | |
| 288 | + t.Errorf("%d summary messages after merge, want 1", n) | |
| 289 | + } | |
| 290 | + if !strings.Contains(res.Messages[1].Text(), "(1 question(s), 1 command(s))") { | |
| 291 | + t.Errorf("wrapper should count the real question only: %q", strings.SplitN(res.Messages[1].Text(), "\n", 2)[0]) | |
| 292 | + } | |
| 293 | + | |
| 294 | + // A summary that is the only old turn is not worth a model call. | |
| 295 | + onlySummary := history(pair, turn("q4", 1, "x")) | |
| 296 | + if _, err := Compact(context.Background(), onlySummary, cfg(1), summarize); !errors.Is(err, ErrNothingToCompact) { | |
| 297 | + t.Errorf("re-summarising a lone summary: err = %v, want ErrNothingToCompact", err) | |
| 298 | + } | |
| 299 | +} | |
| 300 | + | |
| 301 | +func TestCompactFailureLeavesNoResult(t *testing.T) { | |
| 302 | + msgs := history(turn("q1", 1, "x"), turn("q2", 0, "")) | |
| 303 | + boom := errors.New("engine down") | |
| 304 | + res, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) { | |
| 305 | + return "", boom | |
| 306 | + }) | |
| 307 | + if !errors.Is(err, boom) || res.Messages != nil { | |
| 308 | + t.Errorf("a failing summary must surface the error and no messages, got err=%v res=%+v", err, res) | |
| 309 | + } | |
| 310 | + _, err = Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) { | |
| 311 | + return " \n", nil | |
| 312 | + }) | |
| 313 | + if err == nil { | |
| 314 | + t.Error("an empty summary must be an error — an empty history is worse than a long one") | |
| 315 | + } | |
| 316 | +} | |
| 317 | + | |
| 318 | +func countSummaries(msgs []*ai.Message) int { | |
| 319 | + n := 0 | |
| 320 | + for _, m := range msgs { | |
| 321 | + if IsSummary(m) { | |
| 322 | + n++ | |
| 323 | + } | |
| 324 | + } | |
| 325 | + return n | |
| 326 | +} | |
| 327 | + | |
| 328 | +// --- through engine.Summarize ----------------------------------------------- | |
| 329 | + | |
| 330 | +// TestCompactThroughEngineSummarize wires the real Summarizer the agent uses | |
| 331 | +// onto a fake Genkit model, the same approach as internal/engine's tests: the | |
| 332 | +// request the model receives must carry no tools, and the text it returns must | |
| 333 | +// land in the summary message. The Engine is built the way testEngine does it | |
| 334 | +// there — the model reference is all Summarize reads from it. | |
| 335 | +func TestCompactThroughEngineSummarize(t *testing.T) { | |
| 336 | + g := genkit.Init(context.Background()) | |
| 337 | + var sawTools int | |
| 338 | + genkit.DefineModel(g, "dmr/"+config.Cfg.Model, | |
| 339 | + &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}}, | |
| 340 | + func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { | |
| 341 | + sawTools = len(r.Tools) | |
| 342 | + return &ai.ModelResponse{ | |
| 343 | + Message: ai.NewModelMessage(ai.NewTextPart("## Goal\nfrom the fake model")), | |
| 344 | + FinishReason: ai.FinishReasonStop, | |
| 345 | + }, nil | |
| 346 | + }) | |
| 347 | + e := &engine.Engine{G: g, Model: "dmr/" + config.Cfg.Model} | |
| 348 | + | |
| 349 | + msgs := history(turn("q1", 2, "x"), turn("q2", 1, "y")) | |
| 350 | + c := cfg(1) | |
| 351 | + res, err := Compact(context.Background(), msgs, c, func(ctx context.Context, request []*ai.Message) (string, error) { | |
| 352 | + return e.Summarize(ctx, request, c.SummaryMaxTokens) | |
| 353 | + }) | |
| 354 | + if err != nil { | |
| 355 | + t.Fatalf("Compact via engine.Summarize: %v", err) | |
| 356 | + } | |
| 357 | + if sawTools != 0 { | |
| 358 | + t.Errorf("the summary request declared %d tool(s), want none", sawTools) | |
| 359 | + } | |
| 360 | + if !strings.Contains(res.Messages[1].Text(), "from the fake model") { | |
| 361 | + t.Errorf("the model's text is not in the summary: %q", res.Messages[1].Text()) | |
| 362 | + } | |
| 363 | + if err := Valid(res.Messages); err != nil { | |
| 364 | + t.Errorf("invalid history: %v", err) | |
| 365 | + } | |
| 366 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,366 @@ | |||
| 1 | +// The tests build histories by hand, the way TestCommandList does in | ||
| 2 | +// internal/engine, and check the one property a compression must never break: | ||
| 3 | +// the next request is still a valid conversation — system prompt first, every | ||
| 4 | +// tool response under the request that asked for it, the recent turns intact. | ||
| 5 | +// | ||
| 6 | +// No network and no model: the Summarizer is a function here. The last test | ||
| 7 | +// goes through engine.Summarize with a fake Genkit model, so the wiring the | ||
| 8 | +// agent uses is exercised too. | ||
| 9 | +package compact | ||
| 10 | + | ||
| 11 | +import ( | ||
| 12 | + "context" | ||
| 13 | + "errors" | ||
| 14 | + "strings" | ||
| 15 | + "testing" | ||
| 16 | + | ||
| 17 | + "mm/internal/config" | ||
| 18 | + "mm/internal/engine" | ||
| 19 | + | ||
| 20 | + "github.com/firebase/genkit/go/ai" | ||
| 21 | + "github.com/firebase/genkit/go/genkit" | ||
| 22 | +) | ||
| 23 | + | ||
| 24 | +// --- fixtures --------------------------------------------------------------- | ||
| 25 | + | ||
| 26 | +func req(ref, cmd string) *ai.Part { | ||
| 27 | + return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: "bash", Input: map[string]any{"command": cmd}}) | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +func resp(ref, out string) *ai.Part { | ||
| 31 | + return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: "bash", Output: out}) | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +// turn builds one question turn with n commands: user, then n × (model call, | ||
| 35 | +// tool response), then the model's final text. | ||
| 36 | +func turn(question string, n int, output string) []*ai.Message { | ||
| 37 | + msgs := []*ai.Message{ai.NewUserTextMessage(question)} | ||
| 38 | + for i := 0; i < n; i++ { | ||
| 39 | + ref := question + "-" + string(rune('a'+i)) | ||
| 40 | + msgs = append(msgs, | ||
| 41 | + ai.NewModelMessage(req(ref, "echo "+ref)), | ||
| 42 | + ai.NewMessage(ai.RoleTool, nil, resp(ref, output)), | ||
| 43 | + ) | ||
| 44 | + } | ||
| 45 | + return append(msgs, ai.NewModelMessage(ai.NewTextPart("done "+question))) | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +// history is a system prompt followed by the given turns. | ||
| 49 | +func history(turns ...[]*ai.Message) []*ai.Message { | ||
| 50 | + msgs := []*ai.Message{ai.NewSystemTextMessage("You are Bob.")} | ||
| 51 | + for _, t := range turns { | ||
| 52 | + msgs = append(msgs, t...) | ||
| 53 | + } | ||
| 54 | + return msgs | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +func cfg(keep int) config.ContextConfig { | ||
| 58 | + return config.ContextConfig{Enabled: true, KeepLastTurns: keep, Threshold: 75, MaxMessages: 0, SummaryMaxTokens: 100} | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +// fixed is a Summarizer that returns a constant and records the request. | ||
| 62 | +func fixed(text string) (Summarizer, *[]*ai.Message) { | ||
| 63 | + var seen []*ai.Message | ||
| 64 | + return func(_ context.Context, request []*ai.Message) (string, error) { | ||
| 65 | + seen = request | ||
| 66 | + return text, nil | ||
| 67 | + }, &seen | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// --- Split / Valid / Estimate ----------------------------------------------- | ||
| 71 | + | ||
| 72 | +func TestSplitCutsOnUserMessages(t *testing.T) { | ||
| 73 | + msgs := history(turn("q1", 2, "x"), turn("q2", 0, "x"), turn("q3", 1, "x")) | ||
| 74 | + head, turns := Split(msgs) | ||
| 75 | + if len(head) != 1 || head[0].Role != ai.RoleSystem { | ||
| 76 | + t.Fatalf("head = %d message(s), want the system prompt alone", len(head)) | ||
| 77 | + } | ||
| 78 | + if len(turns) != 3 { | ||
| 79 | + t.Fatalf("turns = %d, want 3", len(turns)) | ||
| 80 | + } | ||
| 81 | + // q1: user + 2×(call, response) + text = 6 ; q2: 2 ; q3: 4. | ||
| 82 | + for i, want := range []int{6, 2, 4} { | ||
| 83 | + if len(turns[i]) != want { | ||
| 84 | + t.Errorf("turn %d has %d messages, want %d", i, len(turns[i]), want) | ||
| 85 | + } | ||
| 86 | + if turns[i][0].Role != ai.RoleUser { | ||
| 87 | + t.Errorf("turn %d does not start with a user message", i) | ||
| 88 | + } | ||
| 89 | + } | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +func TestSplitTreatsSummaryPairAsATurn(t *testing.T) { | ||
| 93 | + pair := SummaryPair("notes", 10, 2, 3) | ||
| 94 | + msgs := history(pair, turn("q4", 1, "x")) | ||
| 95 | + _, turns := Split(msgs) | ||
| 96 | + if len(turns) != 2 || !IsSummary(turns[0][0]) || len(turns[0]) != 2 { | ||
| 97 | + t.Fatalf("the summary pair should be its own two-message turn, got %d turn(s)", len(turns)) | ||
| 98 | + } | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +func TestValid(t *testing.T) { | ||
| 102 | + ok := history(turn("q1", 2, "x")) | ||
| 103 | + if err := Valid(ok); err != nil { | ||
| 104 | + t.Errorf("a well-formed history is refused: %v", err) | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + // A `tool` message whose request was cut away: the classic broken window. | ||
| 108 | + orphan := []*ai.Message{ | ||
| 109 | + ai.NewSystemTextMessage("s"), | ||
| 110 | + ai.NewMessage(ai.RoleTool, nil, resp("z", "out")), | ||
| 111 | + } | ||
| 112 | + if err := Valid(orphan); err == nil { | ||
| 113 | + t.Error("an orphan tool response is accepted") | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + noSystem := turn("q1", 0, "x") | ||
| 117 | + if err := Valid(noSystem); err == nil { | ||
| 118 | + t.Error("a history without the system prompt is accepted") | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + // No Ref on either side: pairing by name must still work. | ||
| 122 | + byName := []*ai.Message{ | ||
| 123 | + ai.NewSystemTextMessage("s"), | ||
| 124 | + ai.NewUserTextMessage("q"), | ||
| 125 | + ai.NewModelMessage(req("", "ls")), | ||
| 126 | + ai.NewMessage(ai.RoleTool, nil, resp("", "out")), | ||
| 127 | + } | ||
| 128 | + if err := Valid(byName); err != nil { | ||
| 129 | + t.Errorf("pairing by name fails: %v", err) | ||
| 130 | + } | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +func TestEstimateGrowsWithToolOutput(t *testing.T) { | ||
| 134 | + small := history(turn("q1", 1, "x")) | ||
| 135 | + big := history(turn("q1", 1, strings.Repeat("0123456789\n", 350))) // 3 850 chars ≈ 1 100 tokens | ||
| 136 | + s, b := Estimate(small), Estimate(big) | ||
| 137 | + if b <= s { | ||
| 138 | + t.Fatalf("Estimate(big) = %d is not above Estimate(small) = %d", b, s) | ||
| 139 | + } | ||
| 140 | + // 3.5 characters per token: 3 850 chars of output add about 1 100 tokens. | ||
| 141 | + if delta := b - s; delta < 1000 || delta > 1200 { | ||
| 142 | + t.Errorf("3 850 characters of output add %d tokens, want ~1 100", delta) | ||
| 143 | + } | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// --- Decide ----------------------------------------------------------------- | ||
| 147 | + | ||
| 148 | +func TestDecide(t *testing.T) { | ||
| 149 | + msgs := history(turn("q1", 1, strings.Repeat("x", 3500))) // ≈ 1 000 tokens estimated | ||
| 150 | + | ||
| 151 | + off := cfg(1) | ||
| 152 | + off.Enabled = false | ||
| 153 | + if d := Decide(msgs, 0, 1000, off); d.Compact { | ||
| 154 | + t.Error("disabled config still triggers") | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + // 1 000 tokens ≥ 75 % of a 1 000-token window: triggers on tokens. | ||
| 158 | + if d := Decide(msgs, 0, 1000, cfg(1)); !d.Compact || d.Reason != "tokens" { | ||
| 159 | + t.Errorf("tokens trigger: got %+v", d) | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + // A window ten times wider: the estimate is far below the threshold… | ||
| 163 | + if d := Decide(msgs, 0, 10000, cfg(1)); d.Compact { | ||
| 164 | + t.Errorf("estimate below threshold still triggers: %+v", d) | ||
| 165 | + } | ||
| 166 | + // …but the engine's own measure wins when it is larger. | ||
| 167 | + if d := Decide(msgs, 9000, 10000, cfg(1)); !d.Compact || d.Tokens != 9000 { | ||
| 168 | + t.Errorf("measured tokens should win: %+v", d) | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + // Unknown window (neither the yaml nor the probe gave one): only the | ||
| 172 | + // message count can trigger. | ||
| 173 | + byCount := cfg(1) | ||
| 174 | + byCount.MaxMessages = 4 | ||
| 175 | + if d := Decide(msgs, 0, 0, byCount); !d.Compact || d.Reason != "messages" { | ||
| 176 | + t.Errorf("messages fallback: got %+v", d) | ||
| 177 | + } | ||
| 178 | + byCount.MaxMessages = 100 | ||
| 179 | + if d := Decide(msgs, 0, 0, byCount); d.Compact { | ||
| 180 | + t.Errorf("unknown window and few messages still triggers: %+v", d) | ||
| 181 | + } | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +// --- Compact ---------------------------------------------------------------- | ||
| 185 | + | ||
| 186 | +func TestCompactKeepsLastTurnsAndPairs(t *testing.T) { | ||
| 187 | + // Realistic outputs: a `cat` or a `go test -v` runs to hundreds of lines, | ||
| 188 | + // and that is the weight the summary has to beat — with three-character | ||
| 189 | + // outputs the wrapper alone would be heavier than what it replaces. | ||
| 190 | + big := strings.Repeat("line of tool output\n", 60) | ||
| 191 | + q1, q2, q3, q4 := turn("q1", 2, big), turn("q2", 1, big), turn("q3", 1, big), turn("q4", 0, "") | ||
| 192 | + msgs := history(q1, q2, q3, q4) | ||
| 193 | + summarize, seen := fixed("## Goal\nsummary of q1 and q2") | ||
| 194 | + | ||
| 195 | + res, err := Compact(context.Background(), msgs, cfg(2), summarize) | ||
| 196 | + if err != nil { | ||
| 197 | + t.Fatalf("Compact: %v", err) | ||
| 198 | + } | ||
| 199 | + out := res.Messages | ||
| 200 | + | ||
| 201 | + // The request the model saw: note-taker system, the two old turns, prompt. | ||
| 202 | + if len(*seen) != 1+len(q1)+len(q2)+1 { | ||
| 203 | + t.Errorf("summary request has %d messages, want %d", len(*seen), 1+len(q1)+len(q2)+1) | ||
| 204 | + } | ||
| 205 | + if (*seen)[0].Role != ai.RoleSystem || (*seen)[0].Text() == msgs[0].Text() { | ||
| 206 | + t.Error("the summary request must use the note-taker's system prompt, not the agent's") | ||
| 207 | + } | ||
| 208 | + if last := (*seen)[len(*seen)-1]; last.Role != ai.RoleUser || !strings.Contains(last.Text(), "## Files touched") { | ||
| 209 | + t.Error("the summary request must end with the prompt") | ||
| 210 | + } | ||
| 211 | + if strings.Contains((*seen)[len(*seen)-1].Text(), "earlier summary") { | ||
| 212 | + t.Error("no earlier summary here: the merge line must not be added") | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + // The shape: system (same pointer), summary pair, then q3 and q4 untouched. | ||
| 216 | + if out[0] != msgs[0] { | ||
| 217 | + t.Error("the system prompt must be kept as-is, in first position") | ||
| 218 | + } | ||
| 219 | + if !IsSummary(out[1]) || !strings.Contains(out[1].Text(), "[Context summary — the 10 earlier messages") { | ||
| 220 | + t.Errorf("message 1 should be the summary with its wrapper, got %q", out[1].Text()) | ||
| 221 | + } | ||
| 222 | + if !strings.Contains(out[1].Text(), "(2 question(s), 3 command(s))") { | ||
| 223 | + t.Errorf("wrapper counts are off: %q", strings.SplitN(out[1].Text(), "\n", 2)[0]) | ||
| 224 | + } | ||
| 225 | + if out[2].Role != ai.RoleModel { | ||
| 226 | + t.Error("message 2 should be the model's acknowledgement") | ||
| 227 | + } | ||
| 228 | + rest := out[3:] | ||
| 229 | + want := append(append([]*ai.Message{}, q3...), q4...) | ||
| 230 | + if len(rest) != len(want) { | ||
| 231 | + t.Fatalf("kept %d messages, want %d", len(rest), len(want)) | ||
| 232 | + } | ||
| 233 | + for i := range want { | ||
| 234 | + if rest[i] != want[i] { | ||
| 235 | + t.Errorf("kept message %d is not the original pointer", i) | ||
| 236 | + } | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + if err := Valid(out); err != nil { | ||
| 240 | + t.Errorf("compressed history invalid: %v", err) | ||
| 241 | + } | ||
| 242 | + if res.Compressed != len(q1)+len(q2) || res.Kept != len(q3)+len(q4) { | ||
| 243 | + t.Errorf("counts: compressed %d kept %d, want %d and %d", res.Compressed, res.Kept, len(q1)+len(q2), len(q3)+len(q4)) | ||
| 244 | + } | ||
| 245 | + if res.After >= res.Before { | ||
| 246 | + t.Errorf("tokens did not go down: %d → %d", res.Before, res.After) | ||
| 247 | + } | ||
| 248 | + // The input must not have been modified: same length, same pointers. | ||
| 249 | + if len(msgs) != 1+len(q1)+len(q2)+len(q3)+len(q4) || msgs[1] != q1[0] { | ||
| 250 | + t.Error("Compact modified its input") | ||
| 251 | + } | ||
| 252 | +} | ||
| 253 | + | ||
| 254 | +func TestCompactNothingToDo(t *testing.T) { | ||
| 255 | + msgs := history(turn("q1", 3, "x")) | ||
| 256 | + called := false | ||
| 257 | + _, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) { | ||
| 258 | + called = true | ||
| 259 | + return "should not be asked", nil | ||
| 260 | + }) | ||
| 261 | + if !errors.Is(err, ErrNothingToCompact) { | ||
| 262 | + t.Fatalf("err = %v, want ErrNothingToCompact", err) | ||
| 263 | + } | ||
| 264 | + if called { | ||
| 265 | + t.Error("the model was asked for a summary with nothing to summarise") | ||
| 266 | + } | ||
| 267 | +} | ||
| 268 | + | ||
| 269 | +func TestCompactMergesAnEarlierSummary(t *testing.T) { | ||
| 270 | + pair := SummaryPair("old notes", 12, 3, 5) | ||
| 271 | + msgs := history(pair, turn("q4", 1, "x"), turn("q5", 0, "")) | ||
| 272 | + summarize, seen := fixed("merged notes") | ||
| 273 | + | ||
| 274 | + res, err := Compact(context.Background(), msgs, cfg(1), summarize) | ||
| 275 | + if err != nil { | ||
| 276 | + t.Fatalf("Compact: %v", err) | ||
| 277 | + } | ||
| 278 | + prompt := (*seen)[len(*seen)-1].Text() | ||
| 279 | + if !strings.Contains(prompt, "earlier summary") { | ||
| 280 | + t.Error("the merge line is missing from the prompt") | ||
| 281 | + } | ||
| 282 | + // The old summary is IN the request (to be merged), not summarised away. | ||
| 283 | + if !IsSummary((*seen)[1]) { | ||
| 284 | + t.Error("the earlier summary should be the first old message of the request") | ||
| 285 | + } | ||
| 286 | + // Exactly one summary pair remains, and the wrapper counts q4 alone. | ||
| 287 | + if n := countSummaries(res.Messages); n != 1 { | ||
| 288 | + t.Errorf("%d summary messages after merge, want 1", n) | ||
| 289 | + } | ||
| 290 | + if !strings.Contains(res.Messages[1].Text(), "(1 question(s), 1 command(s))") { | ||
| 291 | + t.Errorf("wrapper should count the real question only: %q", strings.SplitN(res.Messages[1].Text(), "\n", 2)[0]) | ||
| 292 | + } | ||
| 293 | + | ||
| 294 | + // A summary that is the only old turn is not worth a model call. | ||
| 295 | + onlySummary := history(pair, turn("q4", 1, "x")) | ||
| 296 | + if _, err := Compact(context.Background(), onlySummary, cfg(1), summarize); !errors.Is(err, ErrNothingToCompact) { | ||
| 297 | + t.Errorf("re-summarising a lone summary: err = %v, want ErrNothingToCompact", err) | ||
| 298 | + } | ||
| 299 | +} | ||
| 300 | + | ||
| 301 | +func TestCompactFailureLeavesNoResult(t *testing.T) { | ||
| 302 | + msgs := history(turn("q1", 1, "x"), turn("q2", 0, "")) | ||
| 303 | + boom := errors.New("engine down") | ||
| 304 | + res, err := Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) { | ||
| 305 | + return "", boom | ||
| 306 | + }) | ||
| 307 | + if !errors.Is(err, boom) || res.Messages != nil { | ||
| 308 | + t.Errorf("a failing summary must surface the error and no messages, got err=%v res=%+v", err, res) | ||
| 309 | + } | ||
| 310 | + _, err = Compact(context.Background(), msgs, cfg(1), func(context.Context, []*ai.Message) (string, error) { | ||
| 311 | + return " \n", nil | ||
| 312 | + }) | ||
| 313 | + if err == nil { | ||
| 314 | + t.Error("an empty summary must be an error — an empty history is worse than a long one") | ||
| 315 | + } | ||
| 316 | +} | ||
| 317 | + | ||
| 318 | +func countSummaries(msgs []*ai.Message) int { | ||
| 319 | + n := 0 | ||
| 320 | + for _, m := range msgs { | ||
| 321 | + if IsSummary(m) { | ||
| 322 | + n++ | ||
| 323 | + } | ||
| 324 | + } | ||
| 325 | + return n | ||
| 326 | +} | ||
| 327 | + | ||
| 328 | +// --- through engine.Summarize ----------------------------------------------- | ||
| 329 | + | ||
| 330 | +// TestCompactThroughEngineSummarize wires the real Summarizer the agent uses | ||
| 331 | +// onto a fake Genkit model, the same approach as internal/engine's tests: the | ||
| 332 | +// request the model receives must carry no tools, and the text it returns must | ||
| 333 | +// land in the summary message. The Engine is built the way testEngine does it | ||
| 334 | +// there — the model reference is all Summarize reads from it. | ||
| 335 | +func TestCompactThroughEngineSummarize(t *testing.T) { | ||
| 336 | + g := genkit.Init(context.Background()) | ||
| 337 | + var sawTools int | ||
| 338 | + genkit.DefineModel(g, "dmr/"+config.Cfg.Model, | ||
| 339 | + &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}}, | ||
| 340 | + func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { | ||
| 341 | + sawTools = len(r.Tools) | ||
| 342 | + return &ai.ModelResponse{ | ||
| 343 | + Message: ai.NewModelMessage(ai.NewTextPart("## Goal\nfrom the fake model")), | ||
| 344 | + FinishReason: ai.FinishReasonStop, | ||
| 345 | + }, nil | ||
| 346 | + }) | ||
| 347 | + e := &engine.Engine{G: g, Model: "dmr/" + config.Cfg.Model} | ||
| 348 | + | ||
| 349 | + msgs := history(turn("q1", 2, "x"), turn("q2", 1, "y")) | ||
| 350 | + c := cfg(1) | ||
| 351 | + res, err := Compact(context.Background(), msgs, c, func(ctx context.Context, request []*ai.Message) (string, error) { | ||
| 352 | + return e.Summarize(ctx, request, c.SummaryMaxTokens) | ||
| 353 | + }) | ||
| 354 | + if err != nil { | ||
| 355 | + t.Fatalf("Compact via engine.Summarize: %v", err) | ||
| 356 | + } | ||
| 357 | + if sawTools != 0 { | ||
| 358 | + t.Errorf("the summary request declared %d tool(s), want none", sawTools) | ||
| 359 | + } | ||
| 360 | + if !strings.Contains(res.Messages[1].Text(), "from the fake model") { | ||
| 361 | + t.Errorf("the model's text is not in the summary: %q", res.Messages[1].Text()) | ||
| 362 | + } | ||
| 363 | + if err := Valid(res.Messages); err != nil { | ||
| 364 | + t.Errorf("invalid history: %v", err) | ||
| 365 | + } | ||
| 366 | +} | ||
added
internal/compact/prompt.go +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +package compact | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + | |
| 6 | + "github.com/firebase/genkit/go/ai" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// summarySystem replaces the agent's own system prompt in the summary request. | |
| 10 | +// The agent's prompt says "you are a coding agent, run commands": sent along | |
| 11 | +// with a history full of tool calls and no tools declared, it invites the model | |
| 12 | +// to keep acting. The note-taker's prompt asks for the one thing wanted here. | |
| 13 | +const summarySystem = `You are the note-taker of a coding agent. You write precise, structured notes about a coding session, in plain markdown, and you never invent details that are not in the conversation.` | |
| 14 | + | |
| 15 | +// DefaultPrompt is the summary request (CONTEXT_WINDOW.md § 4.1). The seven | |
| 16 | +// fixed sections are the contract: what the model must preserve, and a shape | |
| 17 | +// the agent can rely on. The ban on quoting file contents is deliberate — a | |
| 18 | +// file may have changed since it was read, and the agent's system prompt | |
| 19 | +// already forbids stating what a command in THIS answer did not return. | |
| 20 | +const DefaultPrompt = `You are compressing the history of a coding session so that the same agent | |
| 21 | +can continue the work with less context. Everything above this message is | |
| 22 | +that history. Write a summary, in English, using EXACTLY the sections below, | |
| 23 | +markdown headings included. Be concrete: exact file paths, exact command | |
| 24 | +lines, exact error messages. Do not invent anything that is not in the | |
| 25 | +history. Do not quote file contents — files may have changed since; say what | |
| 26 | +was done to them instead. | |
| 27 | + | |
| 28 | +## Goal | |
| 29 | +What the user is trying to achieve, in one or two sentences, in their words. | |
| 30 | + | |
| 31 | +## Files touched | |
| 32 | +One line per file: path — created | modified | deleted | read-only — what | |
| 33 | +changed. Nothing else. | |
| 34 | + | |
| 35 | +## Decisions | |
| 36 | +Choices made and their reason (libraries, structure, names, things the user | |
| 37 | +refused). One line each. | |
| 38 | + | |
| 39 | +## Errors and fixes | |
| 40 | +Errors met, and what fixed them. One line each. Skip errors that are not | |
| 41 | +relevant any more. | |
| 42 | + | |
| 43 | +## Done | |
| 44 | +The tasks completed, one line each. | |
| 45 | + | |
| 46 | +## In progress / next steps | |
| 47 | +What was being done when the history ends, and what remains. Be explicit | |
| 48 | +about anything half-finished. | |
| 49 | + | |
| 50 | +## Useful commands | |
| 51 | +Commands to rebuild, test or run the project, and any background job left | |
| 52 | +running (with its pid file). One per line. | |
| 53 | + | |
| 54 | +Keep the whole summary under 40 lines. If a section is empty, write "none".` | |
| 55 | + | |
| 56 | +// mergeLine is appended to the prompt when the history to summarise already | |
| 57 | +// starts with an earlier summary. | |
| 58 | +const mergeLine = "\n\nAn earlier summary is at the top of the history; merge it into the new one, do not repeat it as-is." | |
| 59 | + | |
| 60 | +// acknowledgement is the model half of the summary pair. It keeps the | |
| 61 | +// user/assistant alternation that chat templates expect after a user message, | |
| 62 | +// and it restates the one rule the summary must not erode. | |
| 63 | +const acknowledgement = "Understood — I will re-check files before editing them." | |
| 64 | + | |
| 65 | +// Request builds the summary request: the note-taker's system message, the | |
| 66 | +// old turns as they were sent to the model, then the prompt. `prompt` empty | |
| 67 | +// means DefaultPrompt. | |
| 68 | +func Request(old []*ai.Message, prompt string, merge bool) []*ai.Message { | |
| 69 | + if prompt == "" { | |
| 70 | + prompt = DefaultPrompt | |
| 71 | + } | |
| 72 | + if merge { | |
| 73 | + prompt += mergeLine | |
| 74 | + } | |
| 75 | + req := make([]*ai.Message, 0, len(old)+2) | |
| 76 | + req = append(req, ai.NewSystemTextMessage(summarySystem)) | |
| 77 | + req = append(req, old...) | |
| 78 | + req = append(req, ai.NewUserTextMessage(prompt)) | |
| 79 | + return req | |
| 80 | +} | |
| 81 | + | |
| 82 | +// SummaryPair wraps the model's notes into the two messages that replace the | |
| 83 | +// old turns. The wrapper says what the notes are and what they are not: the | |
| 84 | +// model must treat them as notes to verify, not as observations — otherwise a | |
| 85 | +// wrong line in the summary becomes a fact for the rest of the session. | |
| 86 | +func SummaryPair(summary string, messages, questions, commands int) []*ai.Message { | |
| 87 | + meta := map[string]any{metaKey: "summary"} | |
| 88 | + notes := fmt.Sprintf(`[Context summary — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.] | |
| 89 | + | |
| 90 | +%s`, messages, questions, commands, summary) | |
| 91 | + return []*ai.Message{ | |
| 92 | + ai.NewMessage(ai.RoleUser, meta, ai.NewTextPart(notes)), | |
| 93 | + ai.NewMessage(ai.RoleModel, meta, ai.NewTextPart(acknowledgement)), | |
| 94 | + } | |
| 95 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +package compact | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + | ||
| 6 | + "github.com/firebase/genkit/go/ai" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// summarySystem replaces the agent's own system prompt in the summary request. | ||
| 10 | +// The agent's prompt says "you are a coding agent, run commands": sent along | ||
| 11 | +// with a history full of tool calls and no tools declared, it invites the model | ||
| 12 | +// to keep acting. The note-taker's prompt asks for the one thing wanted here. | ||
| 13 | +const summarySystem = `You are the note-taker of a coding agent. You write precise, structured notes about a coding session, in plain markdown, and you never invent details that are not in the conversation.` | ||
| 14 | + | ||
| 15 | +// DefaultPrompt is the summary request (CONTEXT_WINDOW.md § 4.1). The seven | ||
| 16 | +// fixed sections are the contract: what the model must preserve, and a shape | ||
| 17 | +// the agent can rely on. The ban on quoting file contents is deliberate — a | ||
| 18 | +// file may have changed since it was read, and the agent's system prompt | ||
| 19 | +// already forbids stating what a command in THIS answer did not return. | ||
| 20 | +const DefaultPrompt = `You are compressing the history of a coding session so that the same agent | ||
| 21 | +can continue the work with less context. Everything above this message is | ||
| 22 | +that history. Write a summary, in English, using EXACTLY the sections below, | ||
| 23 | +markdown headings included. Be concrete: exact file paths, exact command | ||
| 24 | +lines, exact error messages. Do not invent anything that is not in the | ||
| 25 | +history. Do not quote file contents — files may have changed since; say what | ||
| 26 | +was done to them instead. | ||
| 27 | + | ||
| 28 | +## Goal | ||
| 29 | +What the user is trying to achieve, in one or two sentences, in their words. | ||
| 30 | + | ||
| 31 | +## Files touched | ||
| 32 | +One line per file: path — created | modified | deleted | read-only — what | ||
| 33 | +changed. Nothing else. | ||
| 34 | + | ||
| 35 | +## Decisions | ||
| 36 | +Choices made and their reason (libraries, structure, names, things the user | ||
| 37 | +refused). One line each. | ||
| 38 | + | ||
| 39 | +## Errors and fixes | ||
| 40 | +Errors met, and what fixed them. One line each. Skip errors that are not | ||
| 41 | +relevant any more. | ||
| 42 | + | ||
| 43 | +## Done | ||
| 44 | +The tasks completed, one line each. | ||
| 45 | + | ||
| 46 | +## In progress / next steps | ||
| 47 | +What was being done when the history ends, and what remains. Be explicit | ||
| 48 | +about anything half-finished. | ||
| 49 | + | ||
| 50 | +## Useful commands | ||
| 51 | +Commands to rebuild, test or run the project, and any background job left | ||
| 52 | +running (with its pid file). One per line. | ||
| 53 | + | ||
| 54 | +Keep the whole summary under 40 lines. If a section is empty, write "none".` | ||
| 55 | + | ||
| 56 | +// mergeLine is appended to the prompt when the history to summarise already | ||
| 57 | +// starts with an earlier summary. | ||
| 58 | +const mergeLine = "\n\nAn earlier summary is at the top of the history; merge it into the new one, do not repeat it as-is." | ||
| 59 | + | ||
| 60 | +// acknowledgement is the model half of the summary pair. It keeps the | ||
| 61 | +// user/assistant alternation that chat templates expect after a user message, | ||
| 62 | +// and it restates the one rule the summary must not erode. | ||
| 63 | +const acknowledgement = "Understood — I will re-check files before editing them." | ||
| 64 | + | ||
| 65 | +// Request builds the summary request: the note-taker's system message, the | ||
| 66 | +// old turns as they were sent to the model, then the prompt. `prompt` empty | ||
| 67 | +// means DefaultPrompt. | ||
| 68 | +func Request(old []*ai.Message, prompt string, merge bool) []*ai.Message { | ||
| 69 | + if prompt == "" { | ||
| 70 | + prompt = DefaultPrompt | ||
| 71 | + } | ||
| 72 | + if merge { | ||
| 73 | + prompt += mergeLine | ||
| 74 | + } | ||
| 75 | + req := make([]*ai.Message, 0, len(old)+2) | ||
| 76 | + req = append(req, ai.NewSystemTextMessage(summarySystem)) | ||
| 77 | + req = append(req, old...) | ||
| 78 | + req = append(req, ai.NewUserTextMessage(prompt)) | ||
| 79 | + return req | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +// SummaryPair wraps the model's notes into the two messages that replace the | ||
| 83 | +// old turns. The wrapper says what the notes are and what they are not: the | ||
| 84 | +// model must treat them as notes to verify, not as observations — otherwise a | ||
| 85 | +// wrong line in the summary becomes a fact for the rest of the session. | ||
| 86 | +func SummaryPair(summary string, messages, questions, commands int) []*ai.Message { | ||
| 87 | + meta := map[string]any{metaKey: "summary"} | ||
| 88 | + notes := fmt.Sprintf(`[Context summary — the %d earlier messages of this session (%d question(s), %d command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.] | ||
| 89 | + | ||
| 90 | +%s`, messages, questions, commands, summary) | ||
| 91 | + return []*ai.Message{ | ||
| 92 | + ai.NewMessage(ai.RoleUser, meta, ai.NewTextPart(notes)), | ||
| 93 | + ai.NewMessage(ai.RoleModel, meta, ai.NewTextPart(acknowledgement)), | ||
| 94 | + } | ||
| 95 | +} | ||
added
internal/config/config.go +281 -0 | new file mode 100644 | ||
| @@ -0,0 +1,281 @@ | ||
| 1 | +// Package config holds every setting of the agent in ONE place. | |
| 2 | +// | |
| 3 | +// The values below are the built-in defaults; a YAML file overrides the keys it | |
| 4 | +// mentions and leaves the others alone. That way the agent still runs with no | |
| 5 | +// config file at all, and a single `agent.yaml` is enough to change the model, | |
| 6 | +// the system prompt or the sampling — no recompilation. | |
| 7 | +package config | |
| 8 | + | |
| 9 | +import ( | |
| 10 | + "errors" | |
| 11 | + "fmt" | |
| 12 | + "io/fs" | |
| 13 | + "os" | |
| 14 | + "time" | |
| 15 | + | |
| 16 | + "gopkg.in/yaml.v3" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// Config: everything that can be tuned without touching the code. | |
| 20 | +type Config struct { | |
| 21 | + // Provider: which LLM server is behind BaseURL — a key of the engine | |
| 22 | + // registry ("dmr", "llamacpp"). Defaults to "dmr" so that every agent.yaml | |
| 23 | + // written before this key existed keeps its meaning. | |
| 24 | + Provider string `yaml:"provider"` | |
| 25 | + | |
| 26 | + // Model: identifier of the chat model, in the provider's own naming | |
| 27 | + // (DMR: "ai/qwen2.5-coder", "hf.co/…"; llama-server: the alias it serves). | |
| 28 | + Model string `yaml:"model"` | |
| 29 | + | |
| 30 | + // BaseURL: the OpenAI-compatible endpoint, tried first. Empty = the | |
| 31 | + // provider's default, so a llama.cpp config does not inherit DMR's port. | |
| 32 | + BaseURL string `yaml:"baseUrl"` | |
| 33 | + | |
| 34 | + // Fallback: used when BaseURL does not answer — on the host DMR sits on | |
| 35 | + // localhost, but from inside a container or a sandbox it is reachable as | |
| 36 | + // host.docker.internal. A pointer, because "absent" and "" differ: absent | |
| 37 | + // means the provider's default, "" means no fallback at all (the fake | |
| 38 | + // engine's probe.yaml relies on that to stay pinned to one URL). | |
| 39 | + Fallback *string `yaml:"fallback"` | |
| 40 | + | |
| 41 | + // APIKeyEnv: the NAME of the environment variable holding the key — never | |
| 42 | + // the key itself, so agent.yaml can be committed. Empty = the provider's | |
| 43 | + // default variable; servers that ignore the key need none. | |
| 44 | + APIKeyEnv string `yaml:"apiKeyEnv"` | |
| 45 | + | |
| 46 | + // ContextWindow: how many tokens the server actually serves, when the | |
| 47 | + // operator knows it (llama-server -c, `docker model configure`). 0 = ask the | |
| 48 | + // server, then unknown. Displayed at start-up; the context-compression work | |
| 49 | + // reads it. | |
| 50 | + ContextWindow int `yaml:"contextWindow"` | |
| 51 | + | |
| 52 | + // MaxOutput: max number of characters a tool returns to the model | |
| 53 | + // (context safeguard: large outputs are truncated). | |
| 54 | + MaxOutput int `yaml:"maxOutput"` | |
| 55 | + | |
| 56 | + // MaxTurns: max number of model ↔ tools round trips for one question. | |
| 57 | + MaxTurns int `yaml:"maxTurns"` | |
| 58 | + | |
| 59 | + // PreviewLines: how many lines of a command's output are echoed to the | |
| 60 | + // terminal. The user asked to SEE something; running the command is not | |
| 61 | + // showing it. 0 disables the echo. | |
| 62 | + PreviewLines int `yaml:"previewLines"` | |
| 63 | + | |
| 64 | + // DisplayCommands: recap, after the answer, the list of commands the agent | |
| 65 | + // actually ran. The count alone says HOW MUCH it worked; the list says | |
| 66 | + // WHAT it did — worth showing on a screen, noise in a log. | |
| 67 | + DisplayCommands bool `yaml:"displayCommands"` | |
| 68 | + | |
| 69 | + // System: the system prompt — what the agent is, and what it may do. | |
| 70 | + System string `yaml:"system"` | |
| 71 | + | |
| 72 | + // SkillsDir: directory of markdown procedures exposed by the `read_skill` | |
| 73 | + // tool. When it holds no *.md file, the tool is not declared at all. | |
| 74 | + SkillsDir string `yaml:"skillsDir"` | |
| 75 | + | |
| 76 | + // EditTools: declare the built-in file tools (read_file, write_file, | |
| 77 | + // edit_file). Off, the agent has bash and read_skill only, exactly like | |
| 78 | + // part 09 — and edits files through the `edit` CLI if it is on the PATH. | |
| 79 | + // One binary, two set-ups: that is what lets the two be compared. | |
| 80 | + EditTools bool `yaml:"editTools"` | |
| 81 | + | |
| 82 | + // Sampling: generation settings (OpenAI API keys; the plugin converts them.) | |
| 83 | + Sampling map[string]any `yaml:"sampling"` | |
| 84 | + | |
| 85 | + // WatchdogTimeout: how long to wait for new tokens before assuming the connection has hung. | |
| 86 | + WatchdogTimeout time.Duration `yaml:"watchdogTimeout"` | |
| 87 | + | |
| 88 | + // Context: when and how the conversation history is compressed | |
| 89 | + // (internal/compact). Off unless `context.enabled: true`. The window it | |
| 90 | + // measures against is the top-level ContextWindow above — or, when that is | |
| 91 | + // 0, what the provider's probe learned from the server. | |
| 92 | + Context ContextConfig `yaml:"context"` | |
| 93 | +} | |
| 94 | + | |
| 95 | +// ContextConfig drives the compression of the history (see | |
| 96 | +// 08-context-compression/CONTEXT_WINDOW.md; the code is the same as 08's). | |
| 97 | +// | |
| 98 | +// Off by default: with `enabled: false` this part behaves exactly as before. | |
| 99 | +// Nothing in the agent shortens the history otherwise — measured with the fake | |
| 100 | +// engine of part 03, it grew 2 → 5 → 7 → 9 messages over four requests, and on | |
| 101 | +// a local model the window is fixed at load time. | |
| 102 | +// | |
| 103 | +// Unlike 08 there is no window here: `contextWindow` is ONE key, at the top | |
| 104 | +// level, shared by the banner and the trigger — two keys for the same number | |
| 105 | +// would drift apart. | |
| 106 | +type ContextConfig struct { | |
| 107 | + // Enabled turns the automatic compression on. `/compact` works regardless. | |
| 108 | + Enabled bool `yaml:"enabled"` | |
| 109 | + | |
| 110 | + // Threshold: share of the context window, in percent, beyond which the | |
| 111 | + // history is compressed before the next question is sent. | |
| 112 | + Threshold int `yaml:"threshold"` | |
| 113 | + | |
| 114 | + // MaxMessages: fallback trigger on the message count, for when the window | |
| 115 | + // is unknown (no contextWindow, no /props) or the estimate is off. 0 | |
| 116 | + // disables it. | |
| 117 | + MaxMessages int `yaml:"maxMessages"` | |
| 118 | + | |
| 119 | + // KeepLastTurns: question turns kept raw at the end of the history. | |
| 120 | + KeepLastTurns int `yaml:"keepLastTurns"` | |
| 121 | + | |
| 122 | + // SummaryMaxTokens: max_tokens of the summary request. | |
| 123 | + SummaryMaxTokens int `yaml:"summaryMaxTokens"` | |
| 124 | + | |
| 125 | + // Prompt replaces the built-in summary prompt when not empty. | |
| 126 | + Prompt string `yaml:"prompt"` | |
| 127 | + | |
| 128 | + // ShowStats: print the one-line 🗜️ report after each compression. | |
| 129 | + ShowStats bool `yaml:"showStats"` | |
| 130 | +} | |
| 131 | + | |
| 132 | +// DefaultPath: the config file looked up when neither the command line nor | |
| 133 | +// AGENT_CONFIG says otherwise. | |
| 134 | +const DefaultPath = "agent.yaml" | |
| 135 | + | |
| 136 | +// Cfg: the live settings, pre-filled with the built-in defaults. | |
| 137 | +// | |
| 138 | +// Defaults suited to a CODING AGENT — low temperature for precise and | |
| 139 | +// reproducible answers. | |
| 140 | +var Cfg = Config{ | |
| 141 | + Provider: "dmr", | |
| 142 | + Model: "huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M", | |
| 143 | + // BaseURL and Fallback are left empty on purpose: the provider fills them | |
| 144 | + // in (for "dmr": localhost:12434 and host.docker.internal:12434, exactly the | |
| 145 | + // values that used to be here). | |
| 146 | + MaxOutput: 16000, | |
| 147 | + MaxTurns: 10, | |
| 148 | + PreviewLines: 20, | |
| 149 | + // Off by default: the recap is a demo/debug aid, not something every run | |
| 150 | + // needs. `displayCommands: true` in the YAML turns it on. | |
| 151 | + DisplayCommands: false, | |
| 152 | + SkillsDir: "skills", | |
| 153 | + EditTools: true, | |
| 154 | + System: `You are a coding agent working in a terminal. | |
| 155 | +You have a "bash" tool to run shell commands. | |
| 156 | +Use it to explore files, run tests, inspect the repository, etc. | |
| 157 | +Chain several commands if needed, then answer clearly in English. | |
| 158 | + | |
| 159 | +A request often mixes things you answer from yourself ("say hello") with things | |
| 160 | +only a command can answer ("list the files"). Handle every part, in the order | |
| 161 | +asked, and run a command for each part that needs one. | |
| 162 | +Never state the contents of a file, the output of a command, or the state of the | |
| 163 | +repository unless a command in THIS answer returned it. What you did not read, | |
| 164 | +you do not know: run the command instead of recalling it. | |
| 165 | + | |
| 166 | +BACKGROUND JOBS | |
| 167 | +Never let a command block the answer. Anything that serves, watches or runs | |
| 168 | +long goes to the background, with BOTH streams redirected and its pid kept: | |
| 169 | + | |
| 170 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | |
| 171 | + | |
| 172 | +Redirecting only stdout still blocks until the process exits. Read the | |
| 173 | +"bg-jobs" skill before you wait on, inspect or stop such a job — each has a | |
| 174 | +rule you cannot guess. Stop every job you started before you finish, and say | |
| 175 | +which ones you left running.`, | |
| 176 | + Sampling: map[string]any{ | |
| 177 | + "temperature": 0.0, | |
| 178 | + "top_p": 0.9, | |
| 179 | + "max_tokens": 4096, | |
| 180 | + }, | |
| 181 | + WatchdogTimeout: 20 * time.Second, | |
| 182 | + Context: ContextConfig{ | |
| 183 | + Enabled: false, | |
| 184 | + // 75 % leaves a quarter of the window for the next question, the tool | |
| 185 | + // outputs of its turns and the answer: one `bash` output alone can be | |
| 186 | + // maxOutput characters, about 4-5k tokens. | |
| 187 | + Threshold: 75, | |
| 188 | + // One command costs 2 messages (call + response): 80 is roughly 30 | |
| 189 | + // commands of history, the point where a 12B local model slows down. | |
| 190 | + MaxMessages: 80, | |
| 191 | + // The recent turns are where the model works; summarising them makes | |
| 192 | + // it re-run what it just did. | |
| 193 | + KeepLastTurns: 3, | |
| 194 | + // Seven sections of one line per item fit in far less; the cap stops | |
| 195 | + // a runaway model from filling the window it was asked to empty. | |
| 196 | + SummaryMaxTokens: 1200, | |
| 197 | + ShowStats: true, | |
| 198 | + }, | |
| 199 | +} | |
| 200 | + | |
| 201 | +// Load reads the YAML file on top of the defaults and returns the path actually | |
| 202 | +// used ("" when no file was found). | |
| 203 | +// | |
| 204 | +// Three places are tried, first one wins: the command line (cliPath, what main | |
| 205 | +// read from -config or from a lone argument), then AGENT_CONFIG, then | |
| 206 | +// ./agent.yaml. The command line wins over the environment because it is the | |
| 207 | +// more local of the two — closer to the run you are making right now. | |
| 208 | +// | |
| 209 | +// A path asked for EXPLICITLY (command line or AGENT_CONFIG) that does not | |
| 210 | +// exist is an error: you named a file, it should be there. A missing | |
| 211 | +// ./agent.yaml is not an error — the built-in defaults are enough to run. | |
| 212 | +func Load(cliPath string) (string, error) { | |
| 213 | + path, explicit := cliPath, true | |
| 214 | + if path == "" { | |
| 215 | + path = os.Getenv("AGENT_CONFIG") | |
| 216 | + } | |
| 217 | + if path == "" { | |
| 218 | + path, explicit = DefaultPath, false | |
| 219 | + } | |
| 220 | + | |
| 221 | + data, err := os.ReadFile(path) | |
| 222 | + if errors.Is(err, fs.ErrNotExist) && !explicit { | |
| 223 | + return "", nil // no file: the defaults stand | |
| 224 | + } | |
| 225 | + if err != nil { | |
| 226 | + return "", err | |
| 227 | + } | |
| 228 | + | |
| 229 | + // Unmarshalling INTO Cfg: keys absent from the file keep their default. | |
| 230 | + if err := yaml.Unmarshal(data, &Cfg); err != nil { | |
| 231 | + return "", fmt.Errorf("%s: %w", path, err) | |
| 232 | + } | |
| 233 | + applyEnv() | |
| 234 | + if err := Cfg.validate(); err != nil { | |
| 235 | + return "", fmt.Errorf("%s: %w", path, err) | |
| 236 | + } | |
| 237 | + return path, nil | |
| 238 | +} | |
| 239 | + | |
| 240 | +// applyEnv lets three variables override the file: AGENT_PROVIDER, AGENT_MODEL, | |
| 241 | +// and — resolved by the provider, see engine.Resolve — AGENT_BASE_URL. Same | |
| 242 | +// reasoning as for AGENT_CONFIG: switching engine for one run should not mean | |
| 243 | +// editing a file that is shown on screen. | |
| 244 | +func applyEnv() { | |
| 245 | + if v := os.Getenv("AGENT_PROVIDER"); v != "" { | |
| 246 | + Cfg.Provider = v | |
| 247 | + } | |
| 248 | + if v := os.Getenv("AGENT_MODEL"); v != "" { | |
| 249 | + Cfg.Model = v | |
| 250 | + } | |
| 251 | +} | |
| 252 | + | |
| 253 | +// validate catches the settings that would break the agent at runtime. | |
| 254 | +func (c Config) validate() error { | |
| 255 | + switch { | |
| 256 | + case c.Provider == "": | |
| 257 | + return errors.New("provider must not be empty") | |
| 258 | + case c.Model == "": | |
| 259 | + return errors.New("model must not be empty") | |
| 260 | + case c.ContextWindow < 0: | |
| 261 | + return errors.New("contextWindow must be >= 0") | |
| 262 | + case c.MaxOutput <= 0: | |
| 263 | + return errors.New("maxOutput must be > 0") | |
| 264 | + case c.MaxTurns <= 0: | |
| 265 | + return errors.New("maxTurns must be > 0") | |
| 266 | + case c.PreviewLines < 0: | |
| 267 | + return errors.New("previewLines must be >= 0") | |
| 268 | + case c.Context.Threshold < 1 || c.Context.Threshold > 100: | |
| 269 | + return errors.New("context.threshold must be between 1 and 100") | |
| 270 | + case c.Context.KeepLastTurns < 1: | |
| 271 | + return errors.New("context.keepLastTurns must be >= 1") | |
| 272 | + case c.Context.SummaryMaxTokens <= 0: | |
| 273 | + return errors.New("context.summaryMaxTokens must be > 0") | |
| 274 | + case c.Context.MaxMessages < 0: | |
| 275 | + return errors.New("context.maxMessages must be >= 0") | |
| 276 | + } | |
| 277 | + // `enabled: true` with no window and no maxMessages would never trigger — | |
| 278 | + // but the window may still come from the server's probe at start-up, so | |
| 279 | + // that case is a warning in main, not an error here. | |
| 280 | + return nil | |
| 281 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,281 @@ | |||
| 1 | +// Package config holds every setting of the agent in ONE place. | ||
| 2 | +// | ||
| 3 | +// The values below are the built-in defaults; a YAML file overrides the keys it | ||
| 4 | +// mentions and leaves the others alone. That way the agent still runs with no | ||
| 5 | +// config file at all, and a single `agent.yaml` is enough to change the model, | ||
| 6 | +// the system prompt or the sampling — no recompilation. | ||
| 7 | +package config | ||
| 8 | + | ||
| 9 | +import ( | ||
| 10 | + "errors" | ||
| 11 | + "fmt" | ||
| 12 | + "io/fs" | ||
| 13 | + "os" | ||
| 14 | + "time" | ||
| 15 | + | ||
| 16 | + "gopkg.in/yaml.v3" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// Config: everything that can be tuned without touching the code. | ||
| 20 | +type Config struct { | ||
| 21 | + // Provider: which LLM server is behind BaseURL — a key of the engine | ||
| 22 | + // registry ("dmr", "llamacpp"). Defaults to "dmr" so that every agent.yaml | ||
| 23 | + // written before this key existed keeps its meaning. | ||
| 24 | + Provider string `yaml:"provider"` | ||
| 25 | + | ||
| 26 | + // Model: identifier of the chat model, in the provider's own naming | ||
| 27 | + // (DMR: "ai/qwen2.5-coder", "hf.co/…"; llama-server: the alias it serves). | ||
| 28 | + Model string `yaml:"model"` | ||
| 29 | + | ||
| 30 | + // BaseURL: the OpenAI-compatible endpoint, tried first. Empty = the | ||
| 31 | + // provider's default, so a llama.cpp config does not inherit DMR's port. | ||
| 32 | + BaseURL string `yaml:"baseUrl"` | ||
| 33 | + | ||
| 34 | + // Fallback: used when BaseURL does not answer — on the host DMR sits on | ||
| 35 | + // localhost, but from inside a container or a sandbox it is reachable as | ||
| 36 | + // host.docker.internal. A pointer, because "absent" and "" differ: absent | ||
| 37 | + // means the provider's default, "" means no fallback at all (the fake | ||
| 38 | + // engine's probe.yaml relies on that to stay pinned to one URL). | ||
| 39 | + Fallback *string `yaml:"fallback"` | ||
| 40 | + | ||
| 41 | + // APIKeyEnv: the NAME of the environment variable holding the key — never | ||
| 42 | + // the key itself, so agent.yaml can be committed. Empty = the provider's | ||
| 43 | + // default variable; servers that ignore the key need none. | ||
| 44 | + APIKeyEnv string `yaml:"apiKeyEnv"` | ||
| 45 | + | ||
| 46 | + // ContextWindow: how many tokens the server actually serves, when the | ||
| 47 | + // operator knows it (llama-server -c, `docker model configure`). 0 = ask the | ||
| 48 | + // server, then unknown. Displayed at start-up; the context-compression work | ||
| 49 | + // reads it. | ||
| 50 | + ContextWindow int `yaml:"contextWindow"` | ||
| 51 | + | ||
| 52 | + // MaxOutput: max number of characters a tool returns to the model | ||
| 53 | + // (context safeguard: large outputs are truncated). | ||
| 54 | + MaxOutput int `yaml:"maxOutput"` | ||
| 55 | + | ||
| 56 | + // MaxTurns: max number of model ↔ tools round trips for one question. | ||
| 57 | + MaxTurns int `yaml:"maxTurns"` | ||
| 58 | + | ||
| 59 | + // PreviewLines: how many lines of a command's output are echoed to the | ||
| 60 | + // terminal. The user asked to SEE something; running the command is not | ||
| 61 | + // showing it. 0 disables the echo. | ||
| 62 | + PreviewLines int `yaml:"previewLines"` | ||
| 63 | + | ||
| 64 | + // DisplayCommands: recap, after the answer, the list of commands the agent | ||
| 65 | + // actually ran. The count alone says HOW MUCH it worked; the list says | ||
| 66 | + // WHAT it did — worth showing on a screen, noise in a log. | ||
| 67 | + DisplayCommands bool `yaml:"displayCommands"` | ||
| 68 | + | ||
| 69 | + // System: the system prompt — what the agent is, and what it may do. | ||
| 70 | + System string `yaml:"system"` | ||
| 71 | + | ||
| 72 | + // SkillsDir: directory of markdown procedures exposed by the `read_skill` | ||
| 73 | + // tool. When it holds no *.md file, the tool is not declared at all. | ||
| 74 | + SkillsDir string `yaml:"skillsDir"` | ||
| 75 | + | ||
| 76 | + // EditTools: declare the built-in file tools (read_file, write_file, | ||
| 77 | + // edit_file). Off, the agent has bash and read_skill only, exactly like | ||
| 78 | + // part 09 — and edits files through the `edit` CLI if it is on the PATH. | ||
| 79 | + // One binary, two set-ups: that is what lets the two be compared. | ||
| 80 | + EditTools bool `yaml:"editTools"` | ||
| 81 | + | ||
| 82 | + // Sampling: generation settings (OpenAI API keys; the plugin converts them.) | ||
| 83 | + Sampling map[string]any `yaml:"sampling"` | ||
| 84 | + | ||
| 85 | + // WatchdogTimeout: how long to wait for new tokens before assuming the connection has hung. | ||
| 86 | + WatchdogTimeout time.Duration `yaml:"watchdogTimeout"` | ||
| 87 | + | ||
| 88 | + // Context: when and how the conversation history is compressed | ||
| 89 | + // (internal/compact). Off unless `context.enabled: true`. The window it | ||
| 90 | + // measures against is the top-level ContextWindow above — or, when that is | ||
| 91 | + // 0, what the provider's probe learned from the server. | ||
| 92 | + Context ContextConfig `yaml:"context"` | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +// ContextConfig drives the compression of the history (see | ||
| 96 | +// 08-context-compression/CONTEXT_WINDOW.md; the code is the same as 08's). | ||
| 97 | +// | ||
| 98 | +// Off by default: with `enabled: false` this part behaves exactly as before. | ||
| 99 | +// Nothing in the agent shortens the history otherwise — measured with the fake | ||
| 100 | +// engine of part 03, it grew 2 → 5 → 7 → 9 messages over four requests, and on | ||
| 101 | +// a local model the window is fixed at load time. | ||
| 102 | +// | ||
| 103 | +// Unlike 08 there is no window here: `contextWindow` is ONE key, at the top | ||
| 104 | +// level, shared by the banner and the trigger — two keys for the same number | ||
| 105 | +// would drift apart. | ||
| 106 | +type ContextConfig struct { | ||
| 107 | + // Enabled turns the automatic compression on. `/compact` works regardless. | ||
| 108 | + Enabled bool `yaml:"enabled"` | ||
| 109 | + | ||
| 110 | + // Threshold: share of the context window, in percent, beyond which the | ||
| 111 | + // history is compressed before the next question is sent. | ||
| 112 | + Threshold int `yaml:"threshold"` | ||
| 113 | + | ||
| 114 | + // MaxMessages: fallback trigger on the message count, for when the window | ||
| 115 | + // is unknown (no contextWindow, no /props) or the estimate is off. 0 | ||
| 116 | + // disables it. | ||
| 117 | + MaxMessages int `yaml:"maxMessages"` | ||
| 118 | + | ||
| 119 | + // KeepLastTurns: question turns kept raw at the end of the history. | ||
| 120 | + KeepLastTurns int `yaml:"keepLastTurns"` | ||
| 121 | + | ||
| 122 | + // SummaryMaxTokens: max_tokens of the summary request. | ||
| 123 | + SummaryMaxTokens int `yaml:"summaryMaxTokens"` | ||
| 124 | + | ||
| 125 | + // Prompt replaces the built-in summary prompt when not empty. | ||
| 126 | + Prompt string `yaml:"prompt"` | ||
| 127 | + | ||
| 128 | + // ShowStats: print the one-line 🗜️ report after each compression. | ||
| 129 | + ShowStats bool `yaml:"showStats"` | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +// DefaultPath: the config file looked up when neither the command line nor | ||
| 133 | +// AGENT_CONFIG says otherwise. | ||
| 134 | +const DefaultPath = "agent.yaml" | ||
| 135 | + | ||
| 136 | +// Cfg: the live settings, pre-filled with the built-in defaults. | ||
| 137 | +// | ||
| 138 | +// Defaults suited to a CODING AGENT — low temperature for precise and | ||
| 139 | +// reproducible answers. | ||
| 140 | +var Cfg = Config{ | ||
| 141 | + Provider: "dmr", | ||
| 142 | + Model: "huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M", | ||
| 143 | + // BaseURL and Fallback are left empty on purpose: the provider fills them | ||
| 144 | + // in (for "dmr": localhost:12434 and host.docker.internal:12434, exactly the | ||
| 145 | + // values that used to be here). | ||
| 146 | + MaxOutput: 16000, | ||
| 147 | + MaxTurns: 10, | ||
| 148 | + PreviewLines: 20, | ||
| 149 | + // Off by default: the recap is a demo/debug aid, not something every run | ||
| 150 | + // needs. `displayCommands: true` in the YAML turns it on. | ||
| 151 | + DisplayCommands: false, | ||
| 152 | + SkillsDir: "skills", | ||
| 153 | + EditTools: true, | ||
| 154 | + System: `You are a coding agent working in a terminal. | ||
| 155 | +You have a "bash" tool to run shell commands. | ||
| 156 | +Use it to explore files, run tests, inspect the repository, etc. | ||
| 157 | +Chain several commands if needed, then answer clearly in English. | ||
| 158 | + | ||
| 159 | +A request often mixes things you answer from yourself ("say hello") with things | ||
| 160 | +only a command can answer ("list the files"). Handle every part, in the order | ||
| 161 | +asked, and run a command for each part that needs one. | ||
| 162 | +Never state the contents of a file, the output of a command, or the state of the | ||
| 163 | +repository unless a command in THIS answer returned it. What you did not read, | ||
| 164 | +you do not know: run the command instead of recalling it. | ||
| 165 | + | ||
| 166 | +BACKGROUND JOBS | ||
| 167 | +Never let a command block the answer. Anything that serves, watches or runs | ||
| 168 | +long goes to the background, with BOTH streams redirected and its pid kept: | ||
| 169 | + | ||
| 170 | + nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid | ||
| 171 | + | ||
| 172 | +Redirecting only stdout still blocks until the process exits. Read the | ||
| 173 | +"bg-jobs" skill before you wait on, inspect or stop such a job — each has a | ||
| 174 | +rule you cannot guess. Stop every job you started before you finish, and say | ||
| 175 | +which ones you left running.`, | ||
| 176 | + Sampling: map[string]any{ | ||
| 177 | + "temperature": 0.0, | ||
| 178 | + "top_p": 0.9, | ||
| 179 | + "max_tokens": 4096, | ||
| 180 | + }, | ||
| 181 | + WatchdogTimeout: 20 * time.Second, | ||
| 182 | + Context: ContextConfig{ | ||
| 183 | + Enabled: false, | ||
| 184 | + // 75 % leaves a quarter of the window for the next question, the tool | ||
| 185 | + // outputs of its turns and the answer: one `bash` output alone can be | ||
| 186 | + // maxOutput characters, about 4-5k tokens. | ||
| 187 | + Threshold: 75, | ||
| 188 | + // One command costs 2 messages (call + response): 80 is roughly 30 | ||
| 189 | + // commands of history, the point where a 12B local model slows down. | ||
| 190 | + MaxMessages: 80, | ||
| 191 | + // The recent turns are where the model works; summarising them makes | ||
| 192 | + // it re-run what it just did. | ||
| 193 | + KeepLastTurns: 3, | ||
| 194 | + // Seven sections of one line per item fit in far less; the cap stops | ||
| 195 | + // a runaway model from filling the window it was asked to empty. | ||
| 196 | + SummaryMaxTokens: 1200, | ||
| 197 | + ShowStats: true, | ||
| 198 | + }, | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +// Load reads the YAML file on top of the defaults and returns the path actually | ||
| 202 | +// used ("" when no file was found). | ||
| 203 | +// | ||
| 204 | +// Three places are tried, first one wins: the command line (cliPath, what main | ||
| 205 | +// read from -config or from a lone argument), then AGENT_CONFIG, then | ||
| 206 | +// ./agent.yaml. The command line wins over the environment because it is the | ||
| 207 | +// more local of the two — closer to the run you are making right now. | ||
| 208 | +// | ||
| 209 | +// A path asked for EXPLICITLY (command line or AGENT_CONFIG) that does not | ||
| 210 | +// exist is an error: you named a file, it should be there. A missing | ||
| 211 | +// ./agent.yaml is not an error — the built-in defaults are enough to run. | ||
| 212 | +func Load(cliPath string) (string, error) { | ||
| 213 | + path, explicit := cliPath, true | ||
| 214 | + if path == "" { | ||
| 215 | + path = os.Getenv("AGENT_CONFIG") | ||
| 216 | + } | ||
| 217 | + if path == "" { | ||
| 218 | + path, explicit = DefaultPath, false | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + data, err := os.ReadFile(path) | ||
| 222 | + if errors.Is(err, fs.ErrNotExist) && !explicit { | ||
| 223 | + return "", nil // no file: the defaults stand | ||
| 224 | + } | ||
| 225 | + if err != nil { | ||
| 226 | + return "", err | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + // Unmarshalling INTO Cfg: keys absent from the file keep their default. | ||
| 230 | + if err := yaml.Unmarshal(data, &Cfg); err != nil { | ||
| 231 | + return "", fmt.Errorf("%s: %w", path, err) | ||
| 232 | + } | ||
| 233 | + applyEnv() | ||
| 234 | + if err := Cfg.validate(); err != nil { | ||
| 235 | + return "", fmt.Errorf("%s: %w", path, err) | ||
| 236 | + } | ||
| 237 | + return path, nil | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +// applyEnv lets three variables override the file: AGENT_PROVIDER, AGENT_MODEL, | ||
| 241 | +// and — resolved by the provider, see engine.Resolve — AGENT_BASE_URL. Same | ||
| 242 | +// reasoning as for AGENT_CONFIG: switching engine for one run should not mean | ||
| 243 | +// editing a file that is shown on screen. | ||
| 244 | +func applyEnv() { | ||
| 245 | + if v := os.Getenv("AGENT_PROVIDER"); v != "" { | ||
| 246 | + Cfg.Provider = v | ||
| 247 | + } | ||
| 248 | + if v := os.Getenv("AGENT_MODEL"); v != "" { | ||
| 249 | + Cfg.Model = v | ||
| 250 | + } | ||
| 251 | +} | ||
| 252 | + | ||
| 253 | +// validate catches the settings that would break the agent at runtime. | ||
| 254 | +func (c Config) validate() error { | ||
| 255 | + switch { | ||
| 256 | + case c.Provider == "": | ||
| 257 | + return errors.New("provider must not be empty") | ||
| 258 | + case c.Model == "": | ||
| 259 | + return errors.New("model must not be empty") | ||
| 260 | + case c.ContextWindow < 0: | ||
| 261 | + return errors.New("contextWindow must be >= 0") | ||
| 262 | + case c.MaxOutput <= 0: | ||
| 263 | + return errors.New("maxOutput must be > 0") | ||
| 264 | + case c.MaxTurns <= 0: | ||
| 265 | + return errors.New("maxTurns must be > 0") | ||
| 266 | + case c.PreviewLines < 0: | ||
| 267 | + return errors.New("previewLines must be >= 0") | ||
| 268 | + case c.Context.Threshold < 1 || c.Context.Threshold > 100: | ||
| 269 | + return errors.New("context.threshold must be between 1 and 100") | ||
| 270 | + case c.Context.KeepLastTurns < 1: | ||
| 271 | + return errors.New("context.keepLastTurns must be >= 1") | ||
| 272 | + case c.Context.SummaryMaxTokens <= 0: | ||
| 273 | + return errors.New("context.summaryMaxTokens must be > 0") | ||
| 274 | + case c.Context.MaxMessages < 0: | ||
| 275 | + return errors.New("context.maxMessages must be >= 0") | ||
| 276 | + } | ||
| 277 | + // `enabled: true` with no window and no maxMessages would never trigger — | ||
| 278 | + // but the window may still come from the server's probe at start-up, so | ||
| 279 | + // that case is a warning in main, not an error here. | ||
| 280 | + return nil | ||
| 281 | +} | ||
added
internal/detector/detector.go +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +// Package detector provides a mechanism to detect repetitive patterns in agent actions. | |
| 2 | +package detector | |
| 3 | + | |
| 4 | +import ( | |
| 5 | + "sync" | |
| 6 | +) | |
| 7 | + | |
| 8 | +// Action represents a single step taken by the agent. | |
| 9 | +type Action struct { | |
| 10 | + ToolName string | |
| 11 | + Input string | |
| 12 | + Output string | |
| 13 | +} | |
| 14 | + | |
| 15 | +// LoopDetector tracks the history of actions to detect repetitive cycles. | |
| 16 | +type LoopDetector struct { | |
| 17 | + history []Action | |
| 18 | + historyMu sync.Mutex | |
| 19 | + maxHistory int | |
| 20 | + threshold int | |
| 21 | +} | |
| 22 | + | |
| 23 | +// NewLoopDetector creates a new detector with a given history limit and threshold. | |
| 24 | +func NewLoopDetector(maxHistory, threshold int) *LoopDetector { | |
| 25 | + return &LoopDetector{ | |
| 26 | + history: make([]Action, 0), | |
| 27 | + maxHistory: maxHistory, | |
| 28 | + threshold: threshold, | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +// Record adds an action to the history and returns true if a loop is detected. | |
| 33 | +func (d *LoopDetector) Record(action Action) bool { | |
| 34 | + d.historyMu.Lock() | |
| 35 | + defer d.historyMu.Unlock() | |
| 36 | + | |
| 37 | + // Check if the current action (tool + input + output) has occurred consecutively. | |
| 38 | + count := 0 | |
| 39 | + for i := len(d.history) - 1; i >= 0; i-- { | |
| 40 | + if d.history[i].ToolName == action.ToolName && | |
| 41 | + d.history[i].Input == action.Input && | |
| 42 | + d.history[i].Output == action.Output { | |
| 43 | + count++ | |
| 44 | + if count >= d.threshold { | |
| 45 | + return true | |
| 46 | + } | |
| 47 | + } else { | |
| 48 | + break | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + // Append the new action. | |
| 53 | + d.history = append(d.history, action) | |
| 54 | + if len(d.history) > d.maxHistory { | |
| 55 | + d.history = d.history[1:] | |
| 56 | + } | |
| 57 | + | |
| 58 | + return false | |
| 59 | +} | |
| 60 | + | |
| 61 | +// Reset forgets every recorded action and keeps the settings. It is what the | |
| 62 | +// /new command calls: a new session is a new task, and a command the model | |
| 63 | +// repeated in the previous one must not be flagged as a loop in this one. | |
| 64 | +// | |
| 65 | +// d := detector.NewLoopDetector(10, 3) | |
| 66 | +// // ... a session's worth of Record calls ... | |
| 67 | +// d.Reset() // start the count again | |
| 68 | +func (d *LoopDetector) Reset() { | |
| 69 | + d.historyMu.Lock() | |
| 70 | + defer d.historyMu.Unlock() | |
| 71 | + d.history = d.history[:0] | |
| 72 | +} | |
| 73 | + | |
| 74 | +// LoopError returns a standardized error message when a loop is detected. | |
| 75 | +func (d *LoopDetector) LoopError() string { | |
| 76 | + return "[LOOP_DETECTED: You have attempted the same action multiple times with the same result. Please try a different approach or provide more information.]" | |
| 77 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +// Package detector provides a mechanism to detect repetitive patterns in agent actions. | ||
| 2 | +package detector | ||
| 3 | + | ||
| 4 | +import ( | ||
| 5 | + "sync" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +// Action represents a single step taken by the agent. | ||
| 9 | +type Action struct { | ||
| 10 | + ToolName string | ||
| 11 | + Input string | ||
| 12 | + Output string | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +// LoopDetector tracks the history of actions to detect repetitive cycles. | ||
| 16 | +type LoopDetector struct { | ||
| 17 | + history []Action | ||
| 18 | + historyMu sync.Mutex | ||
| 19 | + maxHistory int | ||
| 20 | + threshold int | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +// NewLoopDetector creates a new detector with a given history limit and threshold. | ||
| 24 | +func NewLoopDetector(maxHistory, threshold int) *LoopDetector { | ||
| 25 | + return &LoopDetector{ | ||
| 26 | + history: make([]Action, 0), | ||
| 27 | + maxHistory: maxHistory, | ||
| 28 | + threshold: threshold, | ||
| 29 | + } | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +// Record adds an action to the history and returns true if a loop is detected. | ||
| 33 | +func (d *LoopDetector) Record(action Action) bool { | ||
| 34 | + d.historyMu.Lock() | ||
| 35 | + defer d.historyMu.Unlock() | ||
| 36 | + | ||
| 37 | + // Check if the current action (tool + input + output) has occurred consecutively. | ||
| 38 | + count := 0 | ||
| 39 | + for i := len(d.history) - 1; i >= 0; i-- { | ||
| 40 | + if d.history[i].ToolName == action.ToolName && | ||
| 41 | + d.history[i].Input == action.Input && | ||
| 42 | + d.history[i].Output == action.Output { | ||
| 43 | + count++ | ||
| 44 | + if count >= d.threshold { | ||
| 45 | + return true | ||
| 46 | + } | ||
| 47 | + } else { | ||
| 48 | + break | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + // Append the new action. | ||
| 53 | + d.history = append(d.history, action) | ||
| 54 | + if len(d.history) > d.maxHistory { | ||
| 55 | + d.history = d.history[1:] | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + return false | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +// Reset forgets every recorded action and keeps the settings. It is what the | ||
| 62 | +// /new command calls: a new session is a new task, and a command the model | ||
| 63 | +// repeated in the previous one must not be flagged as a loop in this one. | ||
| 64 | +// | ||
| 65 | +// d := detector.NewLoopDetector(10, 3) | ||
| 66 | +// // ... a session's worth of Record calls ... | ||
| 67 | +// d.Reset() // start the count again | ||
| 68 | +func (d *LoopDetector) Reset() { | ||
| 69 | + d.historyMu.Lock() | ||
| 70 | + defer d.historyMu.Unlock() | ||
| 71 | + d.history = d.history[:0] | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +// LoopError returns a standardized error message when a loop is detected. | ||
| 75 | +func (d *LoopDetector) LoopError() string { | ||
| 76 | + return "[LOOP_DETECTED: You have attempted the same action multiple times with the same result. Please try a different approach or provide more information.]" | ||
| 77 | +} | ||
added
internal/detector/detector_test.go +57 -0 | new file mode 100644 | ||
| @@ -0,0 +1,57 @@ | ||
| 1 | +package detector | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "testing" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func same() Action { | |
| 9 | + return Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"} | |
| 10 | +} | |
| 11 | + | |
| 12 | +// Reset forgets what the detector saw: a command repeated up to the threshold | |
| 13 | +// is flagged, and after /new the same command counts from zero again — the | |
| 14 | +// new session is a new task, and the old repetitions belong to the old one. | |
| 15 | +func TestResetForgetsTheRecordedActions(t *testing.T) { | |
| 16 | + d := NewLoopDetector(10, 3) | |
| 17 | + for i := 0; i < 3; i++ { | |
| 18 | + if d.Record(same()) { | |
| 19 | + t.Fatalf("loop flagged after %d identical action(s), threshold is 3", i+1) | |
| 20 | + } | |
| 21 | + } | |
| 22 | + if !d.Record(same()) { | |
| 23 | + t.Fatal("4th identical action not flagged as a loop") | |
| 24 | + } | |
| 25 | + | |
| 26 | + d.Reset() | |
| 27 | + | |
| 28 | + for i := 0; i < 3; i++ { | |
| 29 | + if d.Record(same()) { | |
| 30 | + t.Fatalf("after Reset, loop flagged after %d identical action(s)", i+1) | |
| 31 | + } | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +// Reset keeps the detector's settings: the threshold still applies afterwards. | |
| 36 | +func TestResetKeepsThreshold(t *testing.T) { | |
| 37 | + d := NewLoopDetector(10, 2) | |
| 38 | + d.Reset() | |
| 39 | + d.Record(same()) | |
| 40 | + d.Record(same()) | |
| 41 | + if !d.Record(same()) { | |
| 42 | + t.Fatal("after Reset, the threshold of 2 no longer applies") | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +func ExampleLoopDetector_Reset() { | |
| 47 | + d := NewLoopDetector(10, 2) | |
| 48 | + a := Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"} | |
| 49 | + d.Record(a) | |
| 50 | + d.Record(a) | |
| 51 | + fmt.Println(d.Record(a)) // third time in a row: a loop | |
| 52 | + d.Reset() | |
| 53 | + fmt.Println(d.Record(a)) // a new session: counted from zero | |
| 54 | + // Output: | |
| 55 | + // true | |
| 56 | + // false | |
| 57 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,57 @@ | |||
| 1 | +package detector | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "testing" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +func same() Action { | ||
| 9 | + return Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"} | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +// Reset forgets what the detector saw: a command repeated up to the threshold | ||
| 13 | +// is flagged, and after /new the same command counts from zero again — the | ||
| 14 | +// new session is a new task, and the old repetitions belong to the old one. | ||
| 15 | +func TestResetForgetsTheRecordedActions(t *testing.T) { | ||
| 16 | + d := NewLoopDetector(10, 3) | ||
| 17 | + for i := 0; i < 3; i++ { | ||
| 18 | + if d.Record(same()) { | ||
| 19 | + t.Fatalf("loop flagged after %d identical action(s), threshold is 3", i+1) | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + if !d.Record(same()) { | ||
| 23 | + t.Fatal("4th identical action not flagged as a loop") | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + d.Reset() | ||
| 27 | + | ||
| 28 | + for i := 0; i < 3; i++ { | ||
| 29 | + if d.Record(same()) { | ||
| 30 | + t.Fatalf("after Reset, loop flagged after %d identical action(s)", i+1) | ||
| 31 | + } | ||
| 32 | + } | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +// Reset keeps the detector's settings: the threshold still applies afterwards. | ||
| 36 | +func TestResetKeepsThreshold(t *testing.T) { | ||
| 37 | + d := NewLoopDetector(10, 2) | ||
| 38 | + d.Reset() | ||
| 39 | + d.Record(same()) | ||
| 40 | + d.Record(same()) | ||
| 41 | + if !d.Record(same()) { | ||
| 42 | + t.Fatal("after Reset, the threshold of 2 no longer applies") | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +func ExampleLoopDetector_Reset() { | ||
| 47 | + d := NewLoopDetector(10, 2) | ||
| 48 | + a := Action{ToolName: "bash", Input: "ls", Output: "[tool_call]"} | ||
| 49 | + d.Record(a) | ||
| 50 | + d.Record(a) | ||
| 51 | + fmt.Println(d.Record(a)) // third time in a row: a loop | ||
| 52 | + d.Reset() | ||
| 53 | + fmt.Println(d.Record(a)) // a new session: counted from zero | ||
| 54 | + // Output: | ||
| 55 | + // true | ||
| 56 | + // false | ||
| 57 | +} | ||
added
internal/engine/engine.go +622 -0 | new file mode 100644 | ||
| @@ -0,0 +1,622 @@ | ||
| 1 | +// Package engine wraps the connection to the LLM engine — Docker Model Runner, | |
| 2 | +// llama-server, or any other Provider of the registry — and the generation of | |
| 3 | +// answers. Everything here is engine-agnostic: the only lines that know WHICH | |
| 4 | +// server is behind the URL live in the Provider implementations (see | |
| 5 | +// provider.go and openai_compat.go). This package used to be called `dmr`; | |
| 6 | +// measured before the split, exactly one function (Init) and two string | |
| 7 | +// constants were specific to Docker Model Runner. The rest — the whitespace | |
| 8 | +// gap, the watchdog, the retry, the command counter — already behaved the same | |
| 9 | +// against the fake engine, which is not DMR either. | |
| 10 | +package engine | |
| 11 | + | |
| 12 | +import ( | |
| 13 | + "context" | |
| 14 | + "encoding/json" | |
| 15 | + "fmt" | |
| 16 | + "maps" | |
| 17 | + "slices" | |
| 18 | + "strings" | |
| 19 | + "sync" | |
| 20 | + "sync/atomic" | |
| 21 | + "time" | |
| 22 | + | |
| 23 | + "mm/internal/config" | |
| 24 | + "mm/internal/spinner" | |
| 25 | + "mm/internal/ui" | |
| 26 | + | |
| 27 | + "github.com/firebase/genkit/go/ai" | |
| 28 | + "github.com/firebase/genkit/go/genkit" | |
| 29 | +) | |
| 30 | + | |
| 31 | +// Engine is what main.go hands to the agent and to the tools: an initialised | |
| 32 | +// Genkit, the FULL model reference Genkit expects ("dmr/<id>", | |
| 33 | +// "llamacpp/<id>"), and the provider that built it — kept around for the one | |
| 34 | +// thing that stays provider-specific after start-up: explaining an error in one | |
| 35 | +// line (see Explain). | |
| 36 | +type Engine struct { | |
| 37 | + G *genkit.Genkit | |
| 38 | + Model string | |
| 39 | + Provider Provider | |
| 40 | + Backend Backend | |
| 41 | + | |
| 42 | + // ContextWindow is the served context size as far as the agent knows it: | |
| 43 | + // the config's hint, or what Probe learned from the server (llama-server's | |
| 44 | + // /props). 0 = unknown. The context compression (internal/compact) reads | |
| 45 | + // it; the banner shows it with its origin, because a number without an | |
| 46 | + // origin never gets corrected. | |
| 47 | + ContextWindow int | |
| 48 | + | |
| 49 | + // lastInput is the input-token count the server reported on the most | |
| 50 | + // recent call to the model, captured in Generate's WrapModel hook. It is | |
| 51 | + // the exact figure the compression trigger prefers over its estimate. | |
| 52 | + lastInput atomic.Int64 | |
| 53 | +} | |
| 54 | + | |
| 55 | +// New reads the provider named in the config, resolves its backend (URL, key, | |
| 56 | +// model) and opens it. Unknown providers are refused HERE, with the list of | |
| 57 | +// known names: a typo in `provider:` must not turn into a "model not found" | |
| 58 | +// twenty seconds later, once the model has tried to load. | |
| 59 | +func New(ctx context.Context) (*Engine, error) { | |
| 60 | + p, err := Lookup(config.Cfg.Provider) | |
| 61 | + if err != nil { | |
| 62 | + return nil, err | |
| 63 | + } | |
| 64 | + b, err := p.Resolve(config.Cfg) | |
| 65 | + if err != nil { | |
| 66 | + return nil, err | |
| 67 | + } | |
| 68 | + g, model, err := p.Open(ctx, b) | |
| 69 | + if err != nil { | |
| 70 | + return nil, err | |
| 71 | + } | |
| 72 | + return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil | |
| 73 | +} | |
| 74 | + | |
| 75 | +// Probe asks the provider what it can learn about the server BEFORE the first | |
| 76 | +// question: reachability, the context window it serves, a model that is not | |
| 77 | +// pulled. Never fatal — the user may well start the server after the agent. | |
| 78 | +// | |
| 79 | +// The context window it learns is remembered on the Engine. The provider has | |
| 80 | +// already applied the precedence — the config's value wins over the server's, | |
| 81 | +// because the operator knows what they started the server with — so what is | |
| 82 | +// stored here is the one number the whole agent reasons with. | |
| 83 | +func (e *Engine) Probe(ctx context.Context) Info { | |
| 84 | + info := e.Provider.Probe(ctx, e.Backend) | |
| 85 | + if info.ContextWindow > 0 { | |
| 86 | + e.ContextWindow = info.ContextWindow | |
| 87 | + } | |
| 88 | + return info | |
| 89 | +} | |
| 90 | + | |
| 91 | +// EnsureContextWindow re-runs the provider's probe when the window is still | |
| 92 | +// unknown, and reports what it learned: the window and its origin ("/props"), | |
| 93 | +// or 0 and "" when the server still tells nothing. | |
| 94 | +// | |
| 95 | +// Why: the start-up probe runs once (main.go), and on a demo machine the | |
| 96 | +// server is often started AFTER the agent — observed: bob started before | |
| 97 | +// llama-server left `ctx: unknown` for the whole session, so the compression | |
| 98 | +// could only ever trigger on maxMessages. Asking again, lazily, fixes that | |
| 99 | +// without a background poller. Cheap by construction: the caller only asks | |
| 100 | +// while the window is unknown, and a known window is returned as-is, without | |
| 101 | +// a request — a `/props` hit per question would be noise the server does not | |
| 102 | +// deserve. Warnings are deliberately dropped here: the start-up probe already | |
| 103 | +// printed them, and repeating "nothing answers" at every question is nagging. | |
| 104 | +func (e *Engine) EnsureContextWindow(ctx context.Context) (window int, source string) { | |
| 105 | + if e.ContextWindow > 0 { | |
| 106 | + return e.ContextWindow, "" | |
| 107 | + } | |
| 108 | + info := e.Provider.Probe(ctx, e.Backend) | |
| 109 | + if info.ContextWindow > 0 { | |
| 110 | + e.ContextWindow = info.ContextWindow | |
| 111 | + return info.ContextWindow, info.ContextSource | |
| 112 | + } | |
| 113 | + return 0, "" | |
| 114 | +} | |
| 115 | + | |
| 116 | +// LastInputTokens returns the server's own count of the context it last read, | |
| 117 | +// or 0 when it reports no usage (the fake engine of part 03, some proxies). | |
| 118 | +func (e *Engine) LastInputTokens() int { return int(e.lastInput.Load()) } | |
| 119 | + | |
| 120 | +// ForgetInputTokens clears that count. Called after a compression: the measure | |
| 121 | +// described the history that was just replaced, and keeping it would trigger a | |
| 122 | +// second compression on the very next question. | |
| 123 | +func (e *Engine) ForgetInputTokens() { e.lastInput.Store(0) } | |
| 124 | + | |
| 125 | +// Explain turns a transport error into the one-line, actionable message the | |
| 126 | +// REPL prints between brackets. See Provider.Explain for the rationale. | |
| 127 | +func (e *Engine) Explain(err error) string { | |
| 128 | + return e.Provider.Explain(err, e.Backend) | |
| 129 | +} | |
| 130 | + | |
| 131 | +// blanks is the set of characters the model adds around its text without any of | |
| 132 | +// it showing on screen. | |
| 133 | +const blanks = " \t\r\n" | |
| 134 | + | |
| 135 | +// gap holds back the TRAILING whitespace of the streamed text: it is only | |
| 136 | +// printed if visible text follows. Without that, the line breaks the model puts | |
| 137 | +// after its sentence are printed as-is, and the screen hollows out. | |
| 138 | +// | |
| 139 | +// The model produces MORE AND MORE of it as the turns go by — it imitates its | |
| 140 | +// own messages, which it re-reads in the history every turn. Measured: 2 blank | |
| 141 | +// lines before the first 🛠️, 4 before the second, 7, then 11. Holding the | |
| 142 | +// whitespace back is enough to remove all of it, since that whitespace is never | |
| 143 | +// followed by text: it is a tool that writes next. | |
| 144 | +type gap struct { | |
| 145 | + // The `bash` tool may call drop() from SEVERAL goroutines — Genkit runs the | |
| 146 | + // calls of a single turn in parallel (see printMu in tools). | |
| 147 | + mu sync.Mutex | |
| 148 | + held string // whitespace waiting to be followed by text | |
| 149 | + started bool // visible text has already been printed | |
| 150 | +} | |
| 151 | + | |
| 152 | +// next returns what should be printed for this chunk, "" when there is nothing | |
| 153 | +// to print right now. | |
| 154 | +func (g *gap) next(text string) string { | |
| 155 | + g.mu.Lock() | |
| 156 | + defer g.mu.Unlock() | |
| 157 | + | |
| 158 | + body := strings.TrimRight(text, blanks) | |
| 159 | + if body == "" { // an all-whitespace chunk: set aside, not printed | |
| 160 | + if g.started { | |
| 161 | + g.held += text | |
| 162 | + } | |
| 163 | + return "" | |
| 164 | + } | |
| 165 | + | |
| 166 | + // The chunk splits in three: leading whitespace, text, trailing whitespace. | |
| 167 | + tail := text[len(body):] | |
| 168 | + visible := strings.TrimLeft(body, blanks) | |
| 169 | + lead := body[:len(body)-len(visible)] | |
| 170 | + | |
| 171 | + out := "" | |
| 172 | + if g.started { // before the first word, all whitespace is dropped | |
| 173 | + out = squeeze(g.held + lead) | |
| 174 | + } | |
| 175 | + g.held, g.started = tail, true | |
| 176 | + return out + visible | |
| 177 | +} | |
| 178 | + | |
| 179 | +// drop forgets the pending whitespace. Called when someone ELSE is about to | |
| 180 | +// write — a tool announcing its command: that whitespace belonged to the text | |
| 181 | +// before it, and printing it now would reopen the hole we just closed. | |
| 182 | +func (g *gap) drop() { | |
| 183 | + g.mu.Lock() | |
| 184 | + g.held = "" | |
| 185 | + g.mu.Unlock() | |
| 186 | +} | |
| 187 | + | |
| 188 | +// squeeze brings any run of line breaks down to two, that is ONE blank line at | |
| 189 | +// most. Spaces are left alone: they carry the indentation of a code block, and | |
| 190 | +// cutting it would shift the displayed code. | |
| 191 | +func squeeze(s string) string { | |
| 192 | + var b strings.Builder | |
| 193 | + run := 0 | |
| 194 | + for _, r := range s { | |
| 195 | + if r == '\n' { | |
| 196 | + run++ | |
| 197 | + if run > 2 { | |
| 198 | + continue | |
| 199 | + } | |
| 200 | + } else { | |
| 201 | + run = 0 | |
| 202 | + } | |
| 203 | + b.WriteRune(r) | |
| 204 | + } | |
| 205 | + return b.String() | |
| 206 | +} | |
| 207 | + | |
| 208 | +// Generate queries the model, printing the answer as it comes (streaming). Some | |
| 209 | +// OpenAI-compatible servers sometimes cut the SSE stream abruptly ("unexpected | |
| 210 | +// end of JSON input"): if nothing has been printed AND no command has run yet, | |
| 211 | +// we retry once WITHOUT streaming — which is more robust. The "no command" | |
| 212 | +// condition matters: see the comment on the retry below. | |
| 213 | +// | |
| 214 | +// It returns the FULL conversation alongside the response — every intermediate | |
| 215 | +// turn included — because Genkit only hands back the last message. The caller | |
| 216 | +// needs the rest, or the agent forgets the commands it just ran. | |
| 217 | +// | |
| 218 | +// A spinner covers every moment where the program is waiting rather than | |
| 219 | +// printing. On a local engine the first of those is long — the model may still | |
| 220 | +// be loading, and the whole prompt has to be processed before a single token | |
| 221 | +// comes back — and without it the agent looks hung. | |
| 222 | +// | |
| 223 | +// Watchdog: if no tokens arrive for a certain duration, we assume the connection | |
| 224 | +// is hung and we cancel the context. This is the "silent loop" of | |
| 225 | +// STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one. | |
| 226 | +func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { | |
| 227 | + // Genkit only hands us back the LAST message. The intermediate turns — "I | |
| 228 | + // am calling bash", "here is the output" — exist only in the request it | |
| 229 | + // builds for the next turn. This middleware captures that request on its way | |
| 230 | + // through: the last turn seen therefore holds the whole conversation. | |
| 231 | + // | |
| 232 | + // The `WrapModel` hook wraps every call to the model — one per turn — so the | |
| 233 | + // last request seen is the most complete one. `MiddlewareFunc` adapts a | |
| 234 | + // closure to the `ai.Middleware` contract without going through a plugin: | |
| 235 | + // `New` is called once per `Generate`, which keeps `full` captured at the | |
| 236 | + // right level. | |
| 237 | + var full []*ai.Message | |
| 238 | + var gaps gap | |
| 239 | + capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { | |
| 240 | + return &ai.Hooks{ | |
| 241 | + WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { | |
| 242 | + full = slices.Clone(params.Request.Messages) | |
| 243 | + resp, err := next(ctx, params) | |
| 244 | + // The server's own count of what it just read: the one exact | |
| 245 | + // measure of the context size, kept for the compression | |
| 246 | + // trigger. Zero means the server reported nothing. | |
| 247 | + if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 { | |
| 248 | + e.lastInput.Store(int64(resp.Usage.InputTokens)) | |
| 249 | + } | |
| 250 | + return resp, err | |
| 251 | + }, | |
| 252 | + // The tool is about to write to the screen: we forget the | |
| 253 | + // whitespace the previous text left pending. The hook lands at | |
| 254 | + // exactly the right moment — just before the tool prints its 🛠️ | |
| 255 | + // line. | |
| 256 | + WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) { | |
| 257 | + gaps.drop() | |
| 258 | + return next(ctx, params) | |
| 259 | + }, | |
| 260 | + }, nil | |
| 261 | + }) | |
| 262 | + | |
| 263 | + opts := []ai.GenerateOption{ | |
| 264 | + ai.WithModelName(e.Model), | |
| 265 | + ai.WithMessages(messages...), | |
| 266 | + ai.WithTools(tools...), | |
| 267 | + ai.WithMaxTurns(config.Cfg.MaxTurns), // max model ↔ tools round trips | |
| 268 | + ai.WithConfig(config.Cfg.Sampling), | |
| 269 | + ai.WithUse(capture), | |
| 270 | + } | |
| 271 | + | |
| 272 | + // The prefix is printed by the first token rather than up front, so the | |
| 273 | + // spinner has a line to itself and vanishes without taking anything with it. | |
| 274 | + printed := false | |
| 275 | + emit := func(text string) { | |
| 276 | + // An event-consuming front end (ACP) takes the chunk raw: the editor | |
| 277 | + // renders Markdown and owns the layout, so the gap/squeeze machinery — | |
| 278 | + // which fights the terminal screen, not the text — stays out of the | |
| 279 | + // way. `printed` is still maintained: the no-streaming retry below must | |
| 280 | + // know whether the client already saw part of an answer. | |
| 281 | + if s := ui.ActiveSink(); s != nil { | |
| 282 | + if text == "" { | |
| 283 | + return | |
| 284 | + } | |
| 285 | + printed = true | |
| 286 | + s.Text(text) | |
| 287 | + return | |
| 288 | + } | |
| 289 | + // Trailing whitespace is held back, not printed: see the gap type. | |
| 290 | + out := gaps.next(text) | |
| 291 | + if out == "" { | |
| 292 | + return | |
| 293 | + } | |
| 294 | + // Stop BEFORE every print, not only the first one. | |
| 295 | + // | |
| 296 | + // A question can take several turns, and the tool restarts the spinner | |
| 297 | + // on its way back to the model. The text of the next turn then landed on | |
| 298 | + // the spinner's line, which erased it on the following frame | |
| 299 | + // (`\r\033[2K`): "Hello 👋 to Sam" became "⠦ Thinking… 0s 👋 to Sam". | |
| 300 | + // Worse, the last sentence of an answer vanished entirely, erased by the | |
| 301 | + // final Stop(). | |
| 302 | + // | |
| 303 | + // Stop() is idempotent and returns immediately when nothing is running, | |
| 304 | + // so calling it on every chunk costs nothing. | |
| 305 | + spinner.Stop() | |
| 306 | + if !printed { | |
| 307 | + fmt.Print("🤖 ") | |
| 308 | + printed = true | |
| 309 | + } | |
| 310 | + fmt.Print(out) | |
| 311 | + } | |
| 312 | + | |
| 313 | + // --- Watchdog --- | |
| 314 | + // A sub-context the watchdog cancels when no token has arrived for | |
| 315 | + // WatchdogTimeout; the streaming callback beats it on every chunk. | |
| 316 | + genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout) | |
| 317 | + defer cancelGen() | |
| 318 | + | |
| 319 | + spinner.Start("Thinking") | |
| 320 | + defer spinner.Stop() | |
| 321 | + | |
| 322 | + resp, err := genkit.Generate(genCtx, e.G, append(opts, | |
| 323 | + ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error { | |
| 324 | + beat() | |
| 325 | + emit(chunk.Text()) | |
| 326 | + return nil | |
| 327 | + }), | |
| 328 | + )...) | |
| 329 | + | |
| 330 | + // `full` grows as soon as a tool turn has happened: it is the witness that | |
| 331 | + // says whether commands ran, and it is used twice below. | |
| 332 | + toolsRan := len(full) > len(messages) | |
| 333 | + | |
| 334 | + // The watchdog cut `genCtx` while the PARENT context is intact: this is a | |
| 335 | + // silence of the connection, not a cancellation by the user. | |
| 336 | + // | |
| 337 | + // We question the contexts rather than the error. Testing | |
| 338 | + // `err == context.Canceled` does not work: Genkit wraps the error, the | |
| 339 | + // comparison fails, and the turn then went off into the non-streamed retry — | |
| 340 | + // that is, it called the MUTE engine again. Measured: 5 s of watchdog then | |
| 341 | + // 20 s of "Retrying without streaming…" against a silent server, and the | |
| 342 | + // "stalled" message never shown. | |
| 343 | + stalled := genCtx.Err() != nil && ctx.Err() == nil | |
| 344 | + if err != nil && stalled { | |
| 345 | + // We return `full` despite the failure: if commands ran before the | |
| 346 | + // silence, their work must not be lost. | |
| 347 | + return nil, full, fmt.Errorf("generation timed out (stalled)") | |
| 348 | + } | |
| 349 | + | |
| 350 | + // The retry is only worth it for an SSE stream cut before the first byte. | |
| 351 | + // Any other error (maxTurns exceeded, a failing tool, invalid arguments) | |
| 352 | + // happens AFTER commands have run, and replaying the turn would replay them: | |
| 353 | + // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool | |
| 354 | + // turn has happened, which is enough to tell the two apart. | |
| 355 | + if err != nil && !printed && !toolsRan { | |
| 356 | + spinner.Start("Retrying without streaming") | |
| 357 | + if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil { | |
| 358 | + emit(resp.Text()) | |
| 359 | + } | |
| 360 | + } | |
| 361 | + | |
| 362 | + // `full` stops at the last call to the model; its answer is appended after. | |
| 363 | + if resp != nil && resp.Message != nil { | |
| 364 | + full = append(full, resp.Message) | |
| 365 | + } | |
| 366 | + return resp, full, err | |
| 367 | +} | |
| 368 | + | |
| 369 | +// watchdog returns a child of ctx that is cancelled when beat() has not been | |
| 370 | +// called for `timeout`. This is the "silent loop" of STALL_DETECTION_PROBLEM.md | |
| 371 | +// — a liveness failure, not a behavioural one — and it is shared by Generate | |
| 372 | +// and Summarize so that both fail the same way. | |
| 373 | +// | |
| 374 | +// The timestamp is atomic because beat() runs on the streaming goroutine while | |
| 375 | +// the ticker reads it from another. | |
| 376 | +func watchdog(ctx context.Context, timeout time.Duration) (genCtx context.Context, beat func(), cancel context.CancelFunc) { | |
| 377 | + var last atomic.Int64 // UnixNano | |
| 378 | + last.Store(time.Now().UnixNano()) | |
| 379 | + genCtx, cancel = context.WithCancel(ctx) | |
| 380 | + | |
| 381 | + go func() { | |
| 382 | + ticker := time.NewTicker(1 * time.Second) | |
| 383 | + defer ticker.Stop() | |
| 384 | + for { | |
| 385 | + select { | |
| 386 | + case <-genCtx.Done(): | |
| 387 | + return | |
| 388 | + case <-ticker.C: | |
| 389 | + if time.Since(time.Unix(0, last.Load())) > timeout { | |
| 390 | + // Stop() before printing, like everything else: without it | |
| 391 | + // the watchdog message lands on the spinner's line and the | |
| 392 | + // next frame erases it — the diagnosis would vanish at the | |
| 393 | + // very moment it is needed. | |
| 394 | + spinner.Stop() | |
| 395 | + // ui.Out, not stdout: in ACP mode this diagnosis belongs to | |
| 396 | + // the logs (stderr), never to the JSON-RPC stream. | |
| 397 | + fmt.Fprintf(ui.Out, "\n[watchdog: connection hang detected after %v]\n", timeout) | |
| 398 | + cancel() | |
| 399 | + return | |
| 400 | + } | |
| 401 | + } | |
| 402 | + } | |
| 403 | + }() | |
| 404 | + | |
| 405 | + return genCtx, func() { last.Store(time.Now().UnixNano()) }, cancel | |
| 406 | +} | |
| 407 | + | |
| 408 | +// Summarize asks the model for a plain-text answer to `messages` — no tools, | |
| 409 | +// no screen output. It exists for the context compression (internal/compact): | |
| 410 | +// the summary must not stream onto the screen as if it were an answer, and a | |
| 411 | +// request that offers tools invites the model to call them instead of writing. | |
| 412 | +// It goes through the same Genkit and the same model reference as Generate, | |
| 413 | +// so it lands on the same provider and the same server. | |
| 414 | +// | |
| 415 | +// The watchdog is kept, with twice the usual patience: this is the longest | |
| 416 | +// prefill of the session (the whole old history at once) and nothing streams | |
| 417 | +// until it is done. Streaming stays on so that the summary's own tokens beat | |
| 418 | +// the watchdog while it is being written. | |
| 419 | +func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) { | |
| 420 | + // The agent's sampling, minus what does not apply here: `parallel_tool_calls` | |
| 421 | + // without tools is a 400 on the OpenAI API, and `max_tokens` is the summary's | |
| 422 | + // budget, not the answer's. | |
| 423 | + sampling := maps.Clone(config.Cfg.Sampling) | |
| 424 | + if sampling == nil { | |
| 425 | + sampling = map[string]any{} | |
| 426 | + } | |
| 427 | + delete(sampling, "parallel_tool_calls") | |
| 428 | + sampling["max_tokens"] = maxTokens | |
| 429 | + | |
| 430 | + genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout) | |
| 431 | + defer cancel() | |
| 432 | + | |
| 433 | + resp, err := genkit.Generate(genCtx, e.G, | |
| 434 | + ai.WithModelName(e.Model), | |
| 435 | + ai.WithMessages(messages...), | |
| 436 | + ai.WithConfig(sampling), | |
| 437 | + ai.WithStreaming(func(_ context.Context, _ *ai.ModelResponseChunk) error { | |
| 438 | + beat() | |
| 439 | + return nil | |
| 440 | + }), | |
| 441 | + ) | |
| 442 | + if err != nil { | |
| 443 | + // Same test as in Generate: the contexts, not the wrapped error. | |
| 444 | + if genCtx.Err() != nil && ctx.Err() == nil { | |
| 445 | + return "", fmt.Errorf("summary timed out (stalled)") | |
| 446 | + } | |
| 447 | + return "", err | |
| 448 | + } | |
| 449 | + text := strings.TrimSpace(resp.Text()) | |
| 450 | + if text == "" { | |
| 451 | + return "", fmt.Errorf("the model returned an empty summary") | |
| 452 | + } | |
| 453 | + return text, nil | |
| 454 | +} | |
| 455 | + | |
| 456 | +// Names of the tools whose responses we count. They live here rather than | |
| 457 | +// scattered through the display code: the counter is a measurement, and a | |
| 458 | +// measurement needs a single definition. | |
| 459 | +const ( | |
| 460 | + toolBash = "bash" | |
| 461 | + toolSkill = "read_skill" | |
| 462 | + toolRead = "read_file" | |
| 463 | + toolWrite = "write_file" | |
| 464 | + toolEdit = "edit_file" | |
| 465 | +) | |
| 466 | + | |
| 467 | +// executed returns, in order, the text of the calls to a given tool that | |
| 468 | +// ACTUALLY ran. | |
| 469 | +// | |
| 470 | +// We start from the RESPONSES: a call the model asked for but which never ran | |
| 471 | +// (interrupted turn, maxTurns exceeded) produces none. The text of the call, on | |
| 472 | +// the other hand, is ONLY in the request — hence the pairing by Ref, the | |
| 473 | +// identifier the server gives to each call. | |
| 474 | +func executed(history []*ai.Message, name string) []string { | |
| 475 | + // Calls with no Ref (some servers do not set one) are paired in order, BUT | |
| 476 | + // only against the same tool name: a turn mixes `bash` and `read_skill`, and | |
| 477 | + // the first pending call is not necessarily for the right tool. | |
| 478 | + type call struct{ ref, name, text string } | |
| 479 | + var pending []call | |
| 480 | + var out []string | |
| 481 | + | |
| 482 | + for _, m := range history { | |
| 483 | + for _, p := range m.Content { | |
| 484 | + switch { | |
| 485 | + case p.IsToolRequest(): | |
| 486 | + pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, commandText(p.ToolRequest)}) | |
| 487 | + | |
| 488 | + case p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == name: | |
| 489 | + ref := p.ToolResponse.Ref | |
| 490 | + i := slices.IndexFunc(pending, func(c call) bool { | |
| 491 | + if ref != "" { | |
| 492 | + return c.ref == ref | |
| 493 | + } | |
| 494 | + return c.name == name | |
| 495 | + }) | |
| 496 | + if i < 0 { // lost call: the name beats nothing | |
| 497 | + out = append(out, name) | |
| 498 | + continue | |
| 499 | + } | |
| 500 | + out = append(out, pending[i].text) | |
| 501 | + pending = slices.Delete(pending, i, i+1) | |
| 502 | + } | |
| 503 | + } | |
| 504 | + } | |
| 505 | + return out | |
| 506 | +} | |
| 507 | + | |
| 508 | +// commandText describes a tool call in one line. For `bash` it is the command | |
| 509 | +// itself — the rest is only wrapping; for any other tool, its name followed by | |
| 510 | +// its arguments. | |
| 511 | +func commandText(r *ai.ToolRequest) string { | |
| 512 | + if in, ok := r.Input.(map[string]any); ok { | |
| 513 | + if c, ok := in["command"].(string); ok && strings.TrimSpace(c) != "" { | |
| 514 | + return strings.TrimSpace(c) | |
| 515 | + } | |
| 516 | + } | |
| 517 | + args, err := json.Marshal(r.Input) | |
| 518 | + if err != nil || string(args) == "null" { | |
| 519 | + return r.Name | |
| 520 | + } | |
| 521 | + return r.Name + " " + string(args) | |
| 522 | +} | |
| 523 | + | |
| 524 | +// responses counts the responses of a given tool in a history. | |
| 525 | +func responses(history []*ai.Message, name string) int { | |
| 526 | + return len(executed(history, name)) | |
| 527 | +} | |
| 528 | + | |
| 529 | +// Commands counts the commands actually run in a history — one per response of | |
| 530 | +// the `bash` tool. It is the only reliable measure of the work done: a model | |
| 531 | +// that tells stories without acting produces a fine answer and a count of zero. | |
| 532 | +// | |
| 533 | +// The other tools do NOT count. Loading a skill runs nothing, and counting those | |
| 534 | +// reads would make the number say the opposite of what it is for: measured | |
| 535 | +// before the fix, a turn with two reads and a single command displayed | |
| 536 | +// "⚙ 3 command(s)". | |
| 537 | +func Commands(history []*ai.Message) int { | |
| 538 | + return responses(history, toolBash) | |
| 539 | +} | |
| 540 | + | |
| 541 | +// CommandList returns, in order, the text of the commands Commands counts — the | |
| 542 | +// one shown in the recap under the count when `displayCommands` is on. The count | |
| 543 | +// says HOW MANY, the list says WHAT. | |
| 544 | +func CommandList(history []*ai.Message) []string { | |
| 545 | + return executed(history, toolBash) | |
| 546 | +} | |
| 547 | + | |
| 548 | +// FileOps returns, in order, the file-tool calls that ran — read_file, | |
| 549 | +// write_file, edit_file — as one line each ("edit_file hello/main.go (2 edits)"). | |
| 550 | +// | |
| 551 | +// They are counted APART from the commands, not folded into them: this part | |
| 552 | +// compares editing through the `edit` CLI (one bash command per edit) with | |
| 553 | +// editing through built-in tools, and a recap that counted both as "commands" | |
| 554 | +// would hide the very difference being measured. `⚙ 3 command(s) · 📝 2 file | |
| 555 | +// op(s)` keeps the two columns readable side by side. | |
| 556 | +func FileOps(history []*ai.Message) []string { | |
| 557 | + return executedAny(history, toolRead, toolWrite, toolEdit) | |
| 558 | +} | |
| 559 | + | |
| 560 | +// executedAny is executed() across several tool names, keeping the order in | |
| 561 | +// which the responses appear — a per-name pass would lose the interleaving of | |
| 562 | +// a read followed by an edit. | |
| 563 | +func executedAny(history []*ai.Message, names ...string) []string { | |
| 564 | + type call struct{ ref, name, text string } | |
| 565 | + var pending []call | |
| 566 | + var out []string | |
| 567 | + isOurs := func(n string) bool { return slices.Contains(names, n) } | |
| 568 | + | |
| 569 | + for _, m := range history { | |
| 570 | + for _, p := range m.Content { | |
| 571 | + switch { | |
| 572 | + case p.IsToolRequest(): | |
| 573 | + pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, fileOpText(p.ToolRequest)}) | |
| 574 | + case p.IsToolResponse() && p.ToolResponse != nil && isOurs(p.ToolResponse.Name): | |
| 575 | + ref, name := p.ToolResponse.Ref, p.ToolResponse.Name | |
| 576 | + i := slices.IndexFunc(pending, func(c call) bool { | |
| 577 | + if ref != "" { | |
| 578 | + return c.ref == ref | |
| 579 | + } | |
| 580 | + return c.name == name | |
| 581 | + }) | |
| 582 | + if i < 0 { | |
| 583 | + out = append(out, name) | |
| 584 | + continue | |
| 585 | + } | |
| 586 | + out = append(out, pending[i].text) | |
| 587 | + pending = slices.Delete(pending, i, i+1) | |
| 588 | + } | |
| 589 | + } | |
| 590 | + } | |
| 591 | + return out | |
| 592 | +} | |
| 593 | + | |
| 594 | +// fileOpText describes a file-tool call in one line: the tool, the path, and | |
| 595 | +// for edit_file the number of edits — never the texts themselves, which can | |
| 596 | +// run to dozens of lines and belong in the diff, not in the recap. | |
| 597 | +func fileOpText(r *ai.ToolRequest) string { | |
| 598 | + in, _ := r.Input.(map[string]any) | |
| 599 | + path, _ := in["path"].(string) | |
| 600 | + switch r.Name { | |
| 601 | + case toolEdit: | |
| 602 | + if edits, ok := in["edits"].([]any); ok { | |
| 603 | + return fmt.Sprintf("%s %s (%d edit(s))", r.Name, path, len(edits)) | |
| 604 | + } | |
| 605 | + case toolRead: | |
| 606 | + if s, ok := in["start"].(float64); ok && s > 0 { | |
| 607 | + e, _ := in["end"].(float64) | |
| 608 | + return fmt.Sprintf("%s %s %d-%d", r.Name, path, int(s), int(e)) | |
| 609 | + } | |
| 610 | + } | |
| 611 | + if path == "" { | |
| 612 | + return commandText(r) | |
| 613 | + } | |
| 614 | + return r.Name + " " + path | |
| 615 | +} | |
| 616 | + | |
| 617 | +// Skills counts the skills loaded — one per response of `read_skill`. Shown | |
| 618 | +// next to the command count: seeing which procedure the agent chose to follow is | |
| 619 | +// half the point of this part. | |
| 620 | +func Skills(history []*ai.Message) int { | |
| 621 | + return responses(history, toolSkill) | |
| 622 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,622 @@ | |||
| 1 | +// Package engine wraps the connection to the LLM engine — Docker Model Runner, | ||
| 2 | +// llama-server, or any other Provider of the registry — and the generation of | ||
| 3 | +// answers. Everything here is engine-agnostic: the only lines that know WHICH | ||
| 4 | +// server is behind the URL live in the Provider implementations (see | ||
| 5 | +// provider.go and openai_compat.go). This package used to be called `dmr`; | ||
| 6 | +// measured before the split, exactly one function (Init) and two string | ||
| 7 | +// constants were specific to Docker Model Runner. The rest — the whitespace | ||
| 8 | +// gap, the watchdog, the retry, the command counter — already behaved the same | ||
| 9 | +// against the fake engine, which is not DMR either. | ||
| 10 | +package engine | ||
| 11 | + | ||
| 12 | +import ( | ||
| 13 | + "context" | ||
| 14 | + "encoding/json" | ||
| 15 | + "fmt" | ||
| 16 | + "maps" | ||
| 17 | + "slices" | ||
| 18 | + "strings" | ||
| 19 | + "sync" | ||
| 20 | + "sync/atomic" | ||
| 21 | + "time" | ||
| 22 | + | ||
| 23 | + "mm/internal/config" | ||
| 24 | + "mm/internal/spinner" | ||
| 25 | + "mm/internal/ui" | ||
| 26 | + | ||
| 27 | + "github.com/firebase/genkit/go/ai" | ||
| 28 | + "github.com/firebase/genkit/go/genkit" | ||
| 29 | +) | ||
| 30 | + | ||
| 31 | +// Engine is what main.go hands to the agent and to the tools: an initialised | ||
| 32 | +// Genkit, the FULL model reference Genkit expects ("dmr/<id>", | ||
| 33 | +// "llamacpp/<id>"), and the provider that built it — kept around for the one | ||
| 34 | +// thing that stays provider-specific after start-up: explaining an error in one | ||
| 35 | +// line (see Explain). | ||
| 36 | +type Engine struct { | ||
| 37 | + G *genkit.Genkit | ||
| 38 | + Model string | ||
| 39 | + Provider Provider | ||
| 40 | + Backend Backend | ||
| 41 | + | ||
| 42 | + // ContextWindow is the served context size as far as the agent knows it: | ||
| 43 | + // the config's hint, or what Probe learned from the server (llama-server's | ||
| 44 | + // /props). 0 = unknown. The context compression (internal/compact) reads | ||
| 45 | + // it; the banner shows it with its origin, because a number without an | ||
| 46 | + // origin never gets corrected. | ||
| 47 | + ContextWindow int | ||
| 48 | + | ||
| 49 | + // lastInput is the input-token count the server reported on the most | ||
| 50 | + // recent call to the model, captured in Generate's WrapModel hook. It is | ||
| 51 | + // the exact figure the compression trigger prefers over its estimate. | ||
| 52 | + lastInput atomic.Int64 | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +// New reads the provider named in the config, resolves its backend (URL, key, | ||
| 56 | +// model) and opens it. Unknown providers are refused HERE, with the list of | ||
| 57 | +// known names: a typo in `provider:` must not turn into a "model not found" | ||
| 58 | +// twenty seconds later, once the model has tried to load. | ||
| 59 | +func New(ctx context.Context) (*Engine, error) { | ||
| 60 | + p, err := Lookup(config.Cfg.Provider) | ||
| 61 | + if err != nil { | ||
| 62 | + return nil, err | ||
| 63 | + } | ||
| 64 | + b, err := p.Resolve(config.Cfg) | ||
| 65 | + if err != nil { | ||
| 66 | + return nil, err | ||
| 67 | + } | ||
| 68 | + g, model, err := p.Open(ctx, b) | ||
| 69 | + if err != nil { | ||
| 70 | + return nil, err | ||
| 71 | + } | ||
| 72 | + return &Engine{G: g, Model: model, Provider: p, Backend: b, ContextWindow: b.ContextWindow}, nil | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +// Probe asks the provider what it can learn about the server BEFORE the first | ||
| 76 | +// question: reachability, the context window it serves, a model that is not | ||
| 77 | +// pulled. Never fatal — the user may well start the server after the agent. | ||
| 78 | +// | ||
| 79 | +// The context window it learns is remembered on the Engine. The provider has | ||
| 80 | +// already applied the precedence — the config's value wins over the server's, | ||
| 81 | +// because the operator knows what they started the server with — so what is | ||
| 82 | +// stored here is the one number the whole agent reasons with. | ||
| 83 | +func (e *Engine) Probe(ctx context.Context) Info { | ||
| 84 | + info := e.Provider.Probe(ctx, e.Backend) | ||
| 85 | + if info.ContextWindow > 0 { | ||
| 86 | + e.ContextWindow = info.ContextWindow | ||
| 87 | + } | ||
| 88 | + return info | ||
| 89 | +} | ||
| 90 | + | ||
| 91 | +// EnsureContextWindow re-runs the provider's probe when the window is still | ||
| 92 | +// unknown, and reports what it learned: the window and its origin ("/props"), | ||
| 93 | +// or 0 and "" when the server still tells nothing. | ||
| 94 | +// | ||
| 95 | +// Why: the start-up probe runs once (main.go), and on a demo machine the | ||
| 96 | +// server is often started AFTER the agent — observed: bob started before | ||
| 97 | +// llama-server left `ctx: unknown` for the whole session, so the compression | ||
| 98 | +// could only ever trigger on maxMessages. Asking again, lazily, fixes that | ||
| 99 | +// without a background poller. Cheap by construction: the caller only asks | ||
| 100 | +// while the window is unknown, and a known window is returned as-is, without | ||
| 101 | +// a request — a `/props` hit per question would be noise the server does not | ||
| 102 | +// deserve. Warnings are deliberately dropped here: the start-up probe already | ||
| 103 | +// printed them, and repeating "nothing answers" at every question is nagging. | ||
| 104 | +func (e *Engine) EnsureContextWindow(ctx context.Context) (window int, source string) { | ||
| 105 | + if e.ContextWindow > 0 { | ||
| 106 | + return e.ContextWindow, "" | ||
| 107 | + } | ||
| 108 | + info := e.Provider.Probe(ctx, e.Backend) | ||
| 109 | + if info.ContextWindow > 0 { | ||
| 110 | + e.ContextWindow = info.ContextWindow | ||
| 111 | + return info.ContextWindow, info.ContextSource | ||
| 112 | + } | ||
| 113 | + return 0, "" | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +// LastInputTokens returns the server's own count of the context it last read, | ||
| 117 | +// or 0 when it reports no usage (the fake engine of part 03, some proxies). | ||
| 118 | +func (e *Engine) LastInputTokens() int { return int(e.lastInput.Load()) } | ||
| 119 | + | ||
| 120 | +// ForgetInputTokens clears that count. Called after a compression: the measure | ||
| 121 | +// described the history that was just replaced, and keeping it would trigger a | ||
| 122 | +// second compression on the very next question. | ||
| 123 | +func (e *Engine) ForgetInputTokens() { e.lastInput.Store(0) } | ||
| 124 | + | ||
| 125 | +// Explain turns a transport error into the one-line, actionable message the | ||
| 126 | +// REPL prints between brackets. See Provider.Explain for the rationale. | ||
| 127 | +func (e *Engine) Explain(err error) string { | ||
| 128 | + return e.Provider.Explain(err, e.Backend) | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +// blanks is the set of characters the model adds around its text without any of | ||
| 132 | +// it showing on screen. | ||
| 133 | +const blanks = " \t\r\n" | ||
| 134 | + | ||
| 135 | +// gap holds back the TRAILING whitespace of the streamed text: it is only | ||
| 136 | +// printed if visible text follows. Without that, the line breaks the model puts | ||
| 137 | +// after its sentence are printed as-is, and the screen hollows out. | ||
| 138 | +// | ||
| 139 | +// The model produces MORE AND MORE of it as the turns go by — it imitates its | ||
| 140 | +// own messages, which it re-reads in the history every turn. Measured: 2 blank | ||
| 141 | +// lines before the first 🛠️, 4 before the second, 7, then 11. Holding the | ||
| 142 | +// whitespace back is enough to remove all of it, since that whitespace is never | ||
| 143 | +// followed by text: it is a tool that writes next. | ||
| 144 | +type gap struct { | ||
| 145 | + // The `bash` tool may call drop() from SEVERAL goroutines — Genkit runs the | ||
| 146 | + // calls of a single turn in parallel (see printMu in tools). | ||
| 147 | + mu sync.Mutex | ||
| 148 | + held string // whitespace waiting to be followed by text | ||
| 149 | + started bool // visible text has already been printed | ||
| 150 | +} | ||
| 151 | + | ||
| 152 | +// next returns what should be printed for this chunk, "" when there is nothing | ||
| 153 | +// to print right now. | ||
| 154 | +func (g *gap) next(text string) string { | ||
| 155 | + g.mu.Lock() | ||
| 156 | + defer g.mu.Unlock() | ||
| 157 | + | ||
| 158 | + body := strings.TrimRight(text, blanks) | ||
| 159 | + if body == "" { // an all-whitespace chunk: set aside, not printed | ||
| 160 | + if g.started { | ||
| 161 | + g.held += text | ||
| 162 | + } | ||
| 163 | + return "" | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + // The chunk splits in three: leading whitespace, text, trailing whitespace. | ||
| 167 | + tail := text[len(body):] | ||
| 168 | + visible := strings.TrimLeft(body, blanks) | ||
| 169 | + lead := body[:len(body)-len(visible)] | ||
| 170 | + | ||
| 171 | + out := "" | ||
| 172 | + if g.started { // before the first word, all whitespace is dropped | ||
| 173 | + out = squeeze(g.held + lead) | ||
| 174 | + } | ||
| 175 | + g.held, g.started = tail, true | ||
| 176 | + return out + visible | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +// drop forgets the pending whitespace. Called when someone ELSE is about to | ||
| 180 | +// write — a tool announcing its command: that whitespace belonged to the text | ||
| 181 | +// before it, and printing it now would reopen the hole we just closed. | ||
| 182 | +func (g *gap) drop() { | ||
| 183 | + g.mu.Lock() | ||
| 184 | + g.held = "" | ||
| 185 | + g.mu.Unlock() | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +// squeeze brings any run of line breaks down to two, that is ONE blank line at | ||
| 189 | +// most. Spaces are left alone: they carry the indentation of a code block, and | ||
| 190 | +// cutting it would shift the displayed code. | ||
| 191 | +func squeeze(s string) string { | ||
| 192 | + var b strings.Builder | ||
| 193 | + run := 0 | ||
| 194 | + for _, r := range s { | ||
| 195 | + if r == '\n' { | ||
| 196 | + run++ | ||
| 197 | + if run > 2 { | ||
| 198 | + continue | ||
| 199 | + } | ||
| 200 | + } else { | ||
| 201 | + run = 0 | ||
| 202 | + } | ||
| 203 | + b.WriteRune(r) | ||
| 204 | + } | ||
| 205 | + return b.String() | ||
| 206 | +} | ||
| 207 | + | ||
| 208 | +// Generate queries the model, printing the answer as it comes (streaming). Some | ||
| 209 | +// OpenAI-compatible servers sometimes cut the SSE stream abruptly ("unexpected | ||
| 210 | +// end of JSON input"): if nothing has been printed AND no command has run yet, | ||
| 211 | +// we retry once WITHOUT streaming — which is more robust. The "no command" | ||
| 212 | +// condition matters: see the comment on the retry below. | ||
| 213 | +// | ||
| 214 | +// It returns the FULL conversation alongside the response — every intermediate | ||
| 215 | +// turn included — because Genkit only hands back the last message. The caller | ||
| 216 | +// needs the rest, or the agent forgets the commands it just ran. | ||
| 217 | +// | ||
| 218 | +// A spinner covers every moment where the program is waiting rather than | ||
| 219 | +// printing. On a local engine the first of those is long — the model may still | ||
| 220 | +// be loading, and the whole prompt has to be processed before a single token | ||
| 221 | +// comes back — and without it the agent looks hung. | ||
| 222 | +// | ||
| 223 | +// Watchdog: if no tokens arrive for a certain duration, we assume the connection | ||
| 224 | +// is hung and we cancel the context. This is the "silent loop" of | ||
| 225 | +// STALL_DETECTION_PROBLEM.md — a liveness failure, not a behavioural one. | ||
| 226 | +func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) { | ||
| 227 | + // Genkit only hands us back the LAST message. The intermediate turns — "I | ||
| 228 | + // am calling bash", "here is the output" — exist only in the request it | ||
| 229 | + // builds for the next turn. This middleware captures that request on its way | ||
| 230 | + // through: the last turn seen therefore holds the whole conversation. | ||
| 231 | + // | ||
| 232 | + // The `WrapModel` hook wraps every call to the model — one per turn — so the | ||
| 233 | + // last request seen is the most complete one. `MiddlewareFunc` adapts a | ||
| 234 | + // closure to the `ai.Middleware` contract without going through a plugin: | ||
| 235 | + // `New` is called once per `Generate`, which keeps `full` captured at the | ||
| 236 | + // right level. | ||
| 237 | + var full []*ai.Message | ||
| 238 | + var gaps gap | ||
| 239 | + capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { | ||
| 240 | + return &ai.Hooks{ | ||
| 241 | + WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { | ||
| 242 | + full = slices.Clone(params.Request.Messages) | ||
| 243 | + resp, err := next(ctx, params) | ||
| 244 | + // The server's own count of what it just read: the one exact | ||
| 245 | + // measure of the context size, kept for the compression | ||
| 246 | + // trigger. Zero means the server reported nothing. | ||
| 247 | + if resp != nil && resp.Usage != nil && resp.Usage.InputTokens > 0 { | ||
| 248 | + e.lastInput.Store(int64(resp.Usage.InputTokens)) | ||
| 249 | + } | ||
| 250 | + return resp, err | ||
| 251 | + }, | ||
| 252 | + // The tool is about to write to the screen: we forget the | ||
| 253 | + // whitespace the previous text left pending. The hook lands at | ||
| 254 | + // exactly the right moment — just before the tool prints its 🛠️ | ||
| 255 | + // line. | ||
| 256 | + WrapTool: func(ctx context.Context, params *ai.ToolParams, next ai.ToolNext) (*ai.MultipartToolResponse, error) { | ||
| 257 | + gaps.drop() | ||
| 258 | + return next(ctx, params) | ||
| 259 | + }, | ||
| 260 | + }, nil | ||
| 261 | + }) | ||
| 262 | + | ||
| 263 | + opts := []ai.GenerateOption{ | ||
| 264 | + ai.WithModelName(e.Model), | ||
| 265 | + ai.WithMessages(messages...), | ||
| 266 | + ai.WithTools(tools...), | ||
| 267 | + ai.WithMaxTurns(config.Cfg.MaxTurns), // max model ↔ tools round trips | ||
| 268 | + ai.WithConfig(config.Cfg.Sampling), | ||
| 269 | + ai.WithUse(capture), | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + // The prefix is printed by the first token rather than up front, so the | ||
| 273 | + // spinner has a line to itself and vanishes without taking anything with it. | ||
| 274 | + printed := false | ||
| 275 | + emit := func(text string) { | ||
| 276 | + // An event-consuming front end (ACP) takes the chunk raw: the editor | ||
| 277 | + // renders Markdown and owns the layout, so the gap/squeeze machinery — | ||
| 278 | + // which fights the terminal screen, not the text — stays out of the | ||
| 279 | + // way. `printed` is still maintained: the no-streaming retry below must | ||
| 280 | + // know whether the client already saw part of an answer. | ||
| 281 | + if s := ui.ActiveSink(); s != nil { | ||
| 282 | + if text == "" { | ||
| 283 | + return | ||
| 284 | + } | ||
| 285 | + printed = true | ||
| 286 | + s.Text(text) | ||
| 287 | + return | ||
| 288 | + } | ||
| 289 | + // Trailing whitespace is held back, not printed: see the gap type. | ||
| 290 | + out := gaps.next(text) | ||
| 291 | + if out == "" { | ||
| 292 | + return | ||
| 293 | + } | ||
| 294 | + // Stop BEFORE every print, not only the first one. | ||
| 295 | + // | ||
| 296 | + // A question can take several turns, and the tool restarts the spinner | ||
| 297 | + // on its way back to the model. The text of the next turn then landed on | ||
| 298 | + // the spinner's line, which erased it on the following frame | ||
| 299 | + // (`\r\033[2K`): "Hello 👋 to Sam" became "⠦ Thinking… 0s 👋 to Sam". | ||
| 300 | + // Worse, the last sentence of an answer vanished entirely, erased by the | ||
| 301 | + // final Stop(). | ||
| 302 | + // | ||
| 303 | + // Stop() is idempotent and returns immediately when nothing is running, | ||
| 304 | + // so calling it on every chunk costs nothing. | ||
| 305 | + spinner.Stop() | ||
| 306 | + if !printed { | ||
| 307 | + fmt.Print("🤖 ") | ||
| 308 | + printed = true | ||
| 309 | + } | ||
| 310 | + fmt.Print(out) | ||
| 311 | + } | ||
| 312 | + | ||
| 313 | + // --- Watchdog --- | ||
| 314 | + // A sub-context the watchdog cancels when no token has arrived for | ||
| 315 | + // WatchdogTimeout; the streaming callback beats it on every chunk. | ||
| 316 | + genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout) | ||
| 317 | + defer cancelGen() | ||
| 318 | + | ||
| 319 | + spinner.Start("Thinking") | ||
| 320 | + defer spinner.Stop() | ||
| 321 | + | ||
| 322 | + resp, err := genkit.Generate(genCtx, e.G, append(opts, | ||
| 323 | + ai.WithStreaming(func(_ context.Context, chunk *ai.ModelResponseChunk) error { | ||
| 324 | + beat() | ||
| 325 | + emit(chunk.Text()) | ||
| 326 | + return nil | ||
| 327 | + }), | ||
| 328 | + )...) | ||
| 329 | + | ||
| 330 | + // `full` grows as soon as a tool turn has happened: it is the witness that | ||
| 331 | + // says whether commands ran, and it is used twice below. | ||
| 332 | + toolsRan := len(full) > len(messages) | ||
| 333 | + | ||
| 334 | + // The watchdog cut `genCtx` while the PARENT context is intact: this is a | ||
| 335 | + // silence of the connection, not a cancellation by the user. | ||
| 336 | + // | ||
| 337 | + // We question the contexts rather than the error. Testing | ||
| 338 | + // `err == context.Canceled` does not work: Genkit wraps the error, the | ||
| 339 | + // comparison fails, and the turn then went off into the non-streamed retry — | ||
| 340 | + // that is, it called the MUTE engine again. Measured: 5 s of watchdog then | ||
| 341 | + // 20 s of "Retrying without streaming…" against a silent server, and the | ||
| 342 | + // "stalled" message never shown. | ||
| 343 | + stalled := genCtx.Err() != nil && ctx.Err() == nil | ||
| 344 | + if err != nil && stalled { | ||
| 345 | + // We return `full` despite the failure: if commands ran before the | ||
| 346 | + // silence, their work must not be lost. | ||
| 347 | + return nil, full, fmt.Errorf("generation timed out (stalled)") | ||
| 348 | + } | ||
| 349 | + | ||
| 350 | + // The retry is only worth it for an SSE stream cut before the first byte. | ||
| 351 | + // Any other error (maxTurns exceeded, a failing tool, invalid arguments) | ||
| 352 | + // happens AFTER commands have run, and replaying the turn would replay them: | ||
| 353 | + // measured, an `echo >>` wrote itself twice. `full` grows as soon as a tool | ||
| 354 | + // turn has happened, which is enough to tell the two apart. | ||
| 355 | + if err != nil && !printed && !toolsRan { | ||
| 356 | + spinner.Start("Retrying without streaming") | ||
| 357 | + if resp, err = genkit.Generate(ctx, e.G, opts...); err == nil { | ||
| 358 | + emit(resp.Text()) | ||
| 359 | + } | ||
| 360 | + } | ||
| 361 | + | ||
| 362 | + // `full` stops at the last call to the model; its answer is appended after. | ||
| 363 | + if resp != nil && resp.Message != nil { | ||
| 364 | + full = append(full, resp.Message) | ||
| 365 | + } | ||
| 366 | + return resp, full, err | ||
| 367 | +} | ||
| 368 | + | ||
| 369 | +// watchdog returns a child of ctx that is cancelled when beat() has not been | ||
| 370 | +// called for `timeout`. This is the "silent loop" of STALL_DETECTION_PROBLEM.md | ||
| 371 | +// — a liveness failure, not a behavioural one — and it is shared by Generate | ||
| 372 | +// and Summarize so that both fail the same way. | ||
| 373 | +// | ||
| 374 | +// The timestamp is atomic because beat() runs on the streaming goroutine while | ||
| 375 | +// the ticker reads it from another. | ||
| 376 | +func watchdog(ctx context.Context, timeout time.Duration) (genCtx context.Context, beat func(), cancel context.CancelFunc) { | ||
| 377 | + var last atomic.Int64 // UnixNano | ||
| 378 | + last.Store(time.Now().UnixNano()) | ||
| 379 | + genCtx, cancel = context.WithCancel(ctx) | ||
| 380 | + | ||
| 381 | + go func() { | ||
| 382 | + ticker := time.NewTicker(1 * time.Second) | ||
| 383 | + defer ticker.Stop() | ||
| 384 | + for { | ||
| 385 | + select { | ||
| 386 | + case <-genCtx.Done(): | ||
| 387 | + return | ||
| 388 | + case <-ticker.C: | ||
| 389 | + if time.Since(time.Unix(0, last.Load())) > timeout { | ||
| 390 | + // Stop() before printing, like everything else: without it | ||
| 391 | + // the watchdog message lands on the spinner's line and the | ||
| 392 | + // next frame erases it — the diagnosis would vanish at the | ||
| 393 | + // very moment it is needed. | ||
| 394 | + spinner.Stop() | ||
| 395 | + // ui.Out, not stdout: in ACP mode this diagnosis belongs to | ||
| 396 | + // the logs (stderr), never to the JSON-RPC stream. | ||
| 397 | + fmt.Fprintf(ui.Out, "\n[watchdog: connection hang detected after %v]\n", timeout) | ||
| 398 | + cancel() | ||
| 399 | + return | ||
| 400 | + } | ||
| 401 | + } | ||
| 402 | + } | ||
| 403 | + }() | ||
| 404 | + | ||
| 405 | + return genCtx, func() { last.Store(time.Now().UnixNano()) }, cancel | ||
| 406 | +} | ||
| 407 | + | ||
| 408 | +// Summarize asks the model for a plain-text answer to `messages` — no tools, | ||
| 409 | +// no screen output. It exists for the context compression (internal/compact): | ||
| 410 | +// the summary must not stream onto the screen as if it were an answer, and a | ||
| 411 | +// request that offers tools invites the model to call them instead of writing. | ||
| 412 | +// It goes through the same Genkit and the same model reference as Generate, | ||
| 413 | +// so it lands on the same provider and the same server. | ||
| 414 | +// | ||
| 415 | +// The watchdog is kept, with twice the usual patience: this is the longest | ||
| 416 | +// prefill of the session (the whole old history at once) and nothing streams | ||
| 417 | +// until it is done. Streaming stays on so that the summary's own tokens beat | ||
| 418 | +// the watchdog while it is being written. | ||
| 419 | +func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error) { | ||
| 420 | + // The agent's sampling, minus what does not apply here: `parallel_tool_calls` | ||
| 421 | + // without tools is a 400 on the OpenAI API, and `max_tokens` is the summary's | ||
| 422 | + // budget, not the answer's. | ||
| 423 | + sampling := maps.Clone(config.Cfg.Sampling) | ||
| 424 | + if sampling == nil { | ||
| 425 | + sampling = map[string]any{} | ||
| 426 | + } | ||
| 427 | + delete(sampling, "parallel_tool_calls") | ||
| 428 | + sampling["max_tokens"] = maxTokens | ||
| 429 | + | ||
| 430 | + genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout) | ||
| 431 | + defer cancel() | ||
| 432 | + | ||
| 433 | + resp, err := genkit.Generate(genCtx, e.G, | ||
| 434 | + ai.WithModelName(e.Model), | ||
| 435 | + ai.WithMessages(messages...), | ||
| 436 | + ai.WithConfig(sampling), | ||
| 437 | + ai.WithStreaming(func(_ context.Context, _ *ai.ModelResponseChunk) error { | ||
| 438 | + beat() | ||
| 439 | + return nil | ||
| 440 | + }), | ||
| 441 | + ) | ||
| 442 | + if err != nil { | ||
| 443 | + // Same test as in Generate: the contexts, not the wrapped error. | ||
| 444 | + if genCtx.Err() != nil && ctx.Err() == nil { | ||
| 445 | + return "", fmt.Errorf("summary timed out (stalled)") | ||
| 446 | + } | ||
| 447 | + return "", err | ||
| 448 | + } | ||
| 449 | + text := strings.TrimSpace(resp.Text()) | ||
| 450 | + if text == "" { | ||
| 451 | + return "", fmt.Errorf("the model returned an empty summary") | ||
| 452 | + } | ||
| 453 | + return text, nil | ||
| 454 | +} | ||
| 455 | + | ||
| 456 | +// Names of the tools whose responses we count. They live here rather than | ||
| 457 | +// scattered through the display code: the counter is a measurement, and a | ||
| 458 | +// measurement needs a single definition. | ||
| 459 | +const ( | ||
| 460 | + toolBash = "bash" | ||
| 461 | + toolSkill = "read_skill" | ||
| 462 | + toolRead = "read_file" | ||
| 463 | + toolWrite = "write_file" | ||
| 464 | + toolEdit = "edit_file" | ||
| 465 | +) | ||
| 466 | + | ||
| 467 | +// executed returns, in order, the text of the calls to a given tool that | ||
| 468 | +// ACTUALLY ran. | ||
| 469 | +// | ||
| 470 | +// We start from the RESPONSES: a call the model asked for but which never ran | ||
| 471 | +// (interrupted turn, maxTurns exceeded) produces none. The text of the call, on | ||
| 472 | +// the other hand, is ONLY in the request — hence the pairing by Ref, the | ||
| 473 | +// identifier the server gives to each call. | ||
| 474 | +func executed(history []*ai.Message, name string) []string { | ||
| 475 | + // Calls with no Ref (some servers do not set one) are paired in order, BUT | ||
| 476 | + // only against the same tool name: a turn mixes `bash` and `read_skill`, and | ||
| 477 | + // the first pending call is not necessarily for the right tool. | ||
| 478 | + type call struct{ ref, name, text string } | ||
| 479 | + var pending []call | ||
| 480 | + var out []string | ||
| 481 | + | ||
| 482 | + for _, m := range history { | ||
| 483 | + for _, p := range m.Content { | ||
| 484 | + switch { | ||
| 485 | + case p.IsToolRequest(): | ||
| 486 | + pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, commandText(p.ToolRequest)}) | ||
| 487 | + | ||
| 488 | + case p.IsToolResponse() && p.ToolResponse != nil && p.ToolResponse.Name == name: | ||
| 489 | + ref := p.ToolResponse.Ref | ||
| 490 | + i := slices.IndexFunc(pending, func(c call) bool { | ||
| 491 | + if ref != "" { | ||
| 492 | + return c.ref == ref | ||
| 493 | + } | ||
| 494 | + return c.name == name | ||
| 495 | + }) | ||
| 496 | + if i < 0 { // lost call: the name beats nothing | ||
| 497 | + out = append(out, name) | ||
| 498 | + continue | ||
| 499 | + } | ||
| 500 | + out = append(out, pending[i].text) | ||
| 501 | + pending = slices.Delete(pending, i, i+1) | ||
| 502 | + } | ||
| 503 | + } | ||
| 504 | + } | ||
| 505 | + return out | ||
| 506 | +} | ||
| 507 | + | ||
| 508 | +// commandText describes a tool call in one line. For `bash` it is the command | ||
| 509 | +// itself — the rest is only wrapping; for any other tool, its name followed by | ||
| 510 | +// its arguments. | ||
| 511 | +func commandText(r *ai.ToolRequest) string { | ||
| 512 | + if in, ok := r.Input.(map[string]any); ok { | ||
| 513 | + if c, ok := in["command"].(string); ok && strings.TrimSpace(c) != "" { | ||
| 514 | + return strings.TrimSpace(c) | ||
| 515 | + } | ||
| 516 | + } | ||
| 517 | + args, err := json.Marshal(r.Input) | ||
| 518 | + if err != nil || string(args) == "null" { | ||
| 519 | + return r.Name | ||
| 520 | + } | ||
| 521 | + return r.Name + " " + string(args) | ||
| 522 | +} | ||
| 523 | + | ||
| 524 | +// responses counts the responses of a given tool in a history. | ||
| 525 | +func responses(history []*ai.Message, name string) int { | ||
| 526 | + return len(executed(history, name)) | ||
| 527 | +} | ||
| 528 | + | ||
| 529 | +// Commands counts the commands actually run in a history — one per response of | ||
| 530 | +// the `bash` tool. It is the only reliable measure of the work done: a model | ||
| 531 | +// that tells stories without acting produces a fine answer and a count of zero. | ||
| 532 | +// | ||
| 533 | +// The other tools do NOT count. Loading a skill runs nothing, and counting those | ||
| 534 | +// reads would make the number say the opposite of what it is for: measured | ||
| 535 | +// before the fix, a turn with two reads and a single command displayed | ||
| 536 | +// "⚙ 3 command(s)". | ||
| 537 | +func Commands(history []*ai.Message) int { | ||
| 538 | + return responses(history, toolBash) | ||
| 539 | +} | ||
| 540 | + | ||
| 541 | +// CommandList returns, in order, the text of the commands Commands counts — the | ||
| 542 | +// one shown in the recap under the count when `displayCommands` is on. The count | ||
| 543 | +// says HOW MANY, the list says WHAT. | ||
| 544 | +func CommandList(history []*ai.Message) []string { | ||
| 545 | + return executed(history, toolBash) | ||
| 546 | +} | ||
| 547 | + | ||
| 548 | +// FileOps returns, in order, the file-tool calls that ran — read_file, | ||
| 549 | +// write_file, edit_file — as one line each ("edit_file hello/main.go (2 edits)"). | ||
| 550 | +// | ||
| 551 | +// They are counted APART from the commands, not folded into them: this part | ||
| 552 | +// compares editing through the `edit` CLI (one bash command per edit) with | ||
| 553 | +// editing through built-in tools, and a recap that counted both as "commands" | ||
| 554 | +// would hide the very difference being measured. `⚙ 3 command(s) · 📝 2 file | ||
| 555 | +// op(s)` keeps the two columns readable side by side. | ||
| 556 | +func FileOps(history []*ai.Message) []string { | ||
| 557 | + return executedAny(history, toolRead, toolWrite, toolEdit) | ||
| 558 | +} | ||
| 559 | + | ||
| 560 | +// executedAny is executed() across several tool names, keeping the order in | ||
| 561 | +// which the responses appear — a per-name pass would lose the interleaving of | ||
| 562 | +// a read followed by an edit. | ||
| 563 | +func executedAny(history []*ai.Message, names ...string) []string { | ||
| 564 | + type call struct{ ref, name, text string } | ||
| 565 | + var pending []call | ||
| 566 | + var out []string | ||
| 567 | + isOurs := func(n string) bool { return slices.Contains(names, n) } | ||
| 568 | + | ||
| 569 | + for _, m := range history { | ||
| 570 | + for _, p := range m.Content { | ||
| 571 | + switch { | ||
| 572 | + case p.IsToolRequest(): | ||
| 573 | + pending = append(pending, call{p.ToolRequest.Ref, p.ToolRequest.Name, fileOpText(p.ToolRequest)}) | ||
| 574 | + case p.IsToolResponse() && p.ToolResponse != nil && isOurs(p.ToolResponse.Name): | ||
| 575 | + ref, name := p.ToolResponse.Ref, p.ToolResponse.Name | ||
| 576 | + i := slices.IndexFunc(pending, func(c call) bool { | ||
| 577 | + if ref != "" { | ||
| 578 | + return c.ref == ref | ||
| 579 | + } | ||
| 580 | + return c.name == name | ||
| 581 | + }) | ||
| 582 | + if i < 0 { | ||
| 583 | + out = append(out, name) | ||
| 584 | + continue | ||
| 585 | + } | ||
| 586 | + out = append(out, pending[i].text) | ||
| 587 | + pending = slices.Delete(pending, i, i+1) | ||
| 588 | + } | ||
| 589 | + } | ||
| 590 | + } | ||
| 591 | + return out | ||
| 592 | +} | ||
| 593 | + | ||
| 594 | +// fileOpText describes a file-tool call in one line: the tool, the path, and | ||
| 595 | +// for edit_file the number of edits — never the texts themselves, which can | ||
| 596 | +// run to dozens of lines and belong in the diff, not in the recap. | ||
| 597 | +func fileOpText(r *ai.ToolRequest) string { | ||
| 598 | + in, _ := r.Input.(map[string]any) | ||
| 599 | + path, _ := in["path"].(string) | ||
| 600 | + switch r.Name { | ||
| 601 | + case toolEdit: | ||
| 602 | + if edits, ok := in["edits"].([]any); ok { | ||
| 603 | + return fmt.Sprintf("%s %s (%d edit(s))", r.Name, path, len(edits)) | ||
| 604 | + } | ||
| 605 | + case toolRead: | ||
| 606 | + if s, ok := in["start"].(float64); ok && s > 0 { | ||
| 607 | + e, _ := in["end"].(float64) | ||
| 608 | + return fmt.Sprintf("%s %s %d-%d", r.Name, path, int(s), int(e)) | ||
| 609 | + } | ||
| 610 | + } | ||
| 611 | + if path == "" { | ||
| 612 | + return commandText(r) | ||
| 613 | + } | ||
| 614 | + return r.Name + " " + path | ||
| 615 | +} | ||
| 616 | + | ||
| 617 | +// Skills counts the skills loaded — one per response of `read_skill`. Shown | ||
| 618 | +// next to the command count: seeing which procedure the agent chose to follow is | ||
| 619 | +// half the point of this part. | ||
| 620 | +func Skills(history []*ai.Message) int { | ||
| 621 | + return responses(history, toolSkill) | ||
| 622 | +} | ||
added
internal/engine/engine_test.go +362 -0 | new file mode 100644 | ||
| @@ -0,0 +1,362 @@ | ||
| 1 | +// The test that matters here is the one about CAPTURE: Genkit only hands back | |
| 2 | +// the last message, and the whole point of `Generate` is to hand back the | |
| 3 | +// intermediate turns too. That is the one thing the `WithMiddleware` → `WithUse` | |
| 4 | +// migration could have broken silently — the code compiles just as well with a | |
| 5 | +// hook that never sees anything. | |
| 6 | +// | |
| 7 | +// No real model is needed: a fake model registered in Genkit plays one tool turn | |
| 8 | +// then answers, which is exactly the shape the history must keep a trace of. | |
| 9 | +// | |
| 10 | +// The watchdog of this version runs during these tests: since the fake model | |
| 11 | +// answers instantly it never fires — and if it did, the tests would fail, which | |
| 12 | +// is the intended behaviour. | |
| 13 | +package engine | |
| 14 | + | |
| 15 | +import ( | |
| 16 | + "context" | |
| 17 | + "testing" | |
| 18 | + | |
| 19 | + "mm/internal/config" | |
| 20 | + | |
| 21 | + "github.com/firebase/genkit/go/ai" | |
| 22 | + "github.com/firebase/genkit/go/genkit" | |
| 23 | +) | |
| 24 | + | |
| 25 | +// fakeGenkit registers a "dmr/<Cfg.Model>" model whose every call is served by | |
| 26 | +// the next answer in `turns`, and a `bash` tool returning a fixed output. | |
| 27 | +// `calls` counts the calls to the model. | |
| 28 | +func fakeGenkit(t *testing.T, turns ...*ai.ModelResponse) (*genkit.Genkit, *int) { | |
| 29 | + t.Helper() | |
| 30 | + g := genkit.Init(context.Background()) | |
| 31 | + | |
| 32 | + calls := 0 | |
| 33 | + genkit.DefineModel(g, "dmr/"+config.Cfg.Model, | |
| 34 | + &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}}, | |
| 35 | + func(_ context.Context, _ *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { | |
| 36 | + if calls >= len(turns) { | |
| 37 | + t.Errorf("the model was called %d times for %d planned turns", calls+1, len(turns)) | |
| 38 | + return turns[len(turns)-1], nil | |
| 39 | + } | |
| 40 | + resp := turns[calls] | |
| 41 | + calls++ | |
| 42 | + return resp, nil | |
| 43 | + }) | |
| 44 | + | |
| 45 | + genkit.DefineTool(g, "bash", "run a shell command", | |
| 46 | + func(_ *ai.ToolContext, in struct { | |
| 47 | + Command string `json:"command"` | |
| 48 | + }) (string, error) { | |
| 49 | + return "hello from " + in.Command, nil | |
| 50 | + }) | |
| 51 | + | |
| 52 | + genkit.DefineTool(g, "read_skill", "load a markdown procedure", | |
| 53 | + func(_ *ai.ToolContext, in struct { | |
| 54 | + Name string `json:"name"` | |
| 55 | + }) (string, error) { | |
| 56 | + return "# procedure " + in.Name, nil | |
| 57 | + }) | |
| 58 | + | |
| 59 | + return g, &calls | |
| 60 | +} | |
| 61 | + | |
| 62 | +// testEngine wraps a fake Genkit the way New would: the model reference is the | |
| 63 | +// only thing Generate reads from the Engine, so no provider is needed here. | |
| 64 | +func testEngine(g *genkit.Genkit) *Engine { | |
| 65 | + return &Engine{G: g, Model: "dmr/" + config.Cfg.Model} | |
| 66 | +} | |
| 67 | + | |
| 68 | +func modelTurn(parts ...*ai.Part) *ai.ModelResponse { | |
| 69 | + return &ai.ModelResponse{ | |
| 70 | + Message: ai.NewModelMessage(parts...), | |
| 71 | + FinishReason: ai.FinishReasonStop, | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +// TestGenerateReturnsFullConversation is the regression test for the capture: on | |
| 76 | +// a tool turn, the history handed back must contain the question, the tool | |
| 77 | +// request, its response AND the final text — not just that last one. | |
| 78 | +func TestGenerateReturnsFullConversation(t *testing.T) { | |
| 79 | + toolReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{ | |
| 80 | + Name: "bash", | |
| 81 | + Input: map[string]any{"command": "echo hi"}, | |
| 82 | + })) | |
| 83 | + final := modelTurn(ai.NewTextPart("done")) | |
| 84 | + | |
| 85 | + g, calls := fakeGenkit(t, toolReq, final) | |
| 86 | + | |
| 87 | + resp, full, err := testEngine(g).Generate(context.Background(), | |
| 88 | + []*ai.Message{ai.NewUserTextMessage("run echo hi")}, | |
| 89 | + []ai.ToolRef{ai.ToolName("bash")}) | |
| 90 | + if err != nil { | |
| 91 | + t.Fatalf("Generate: %v", err) | |
| 92 | + } | |
| 93 | + if *calls != 2 { | |
| 94 | + t.Errorf("calls to the model = %d, want 2 (tool, then answer)", *calls) | |
| 95 | + } | |
| 96 | + if got := resp.Text(); got != "done" { | |
| 97 | + t.Errorf("resp.Text() = %q, want %q", got, "done") | |
| 98 | + } | |
| 99 | + | |
| 100 | + // The full history: without the capture, Genkit only hands back the last | |
| 101 | + // message and `full` would have length 1. | |
| 102 | + if len(full) < 4 { | |
| 103 | + t.Fatalf("history of %d messages, want at least 4 (question, call, tool response, text): %+v", len(full), full) | |
| 104 | + } | |
| 105 | + if full[len(full)-1] != final.Message { | |
| 106 | + t.Errorf("the last message of the history is not the final answer") | |
| 107 | + } | |
| 108 | + | |
| 109 | + // The two markers that exist ONLY in the intermediate turns. | |
| 110 | + var sawRequest, sawResponse bool | |
| 111 | + for _, m := range full { | |
| 112 | + for _, p := range m.Content { | |
| 113 | + if p.IsToolRequest() { | |
| 114 | + sawRequest = true | |
| 115 | + } | |
| 116 | + if p.IsToolResponse() { | |
| 117 | + sawResponse = true | |
| 118 | + } | |
| 119 | + } | |
| 120 | + } | |
| 121 | + if !sawRequest { | |
| 122 | + t.Error("the history contains no tool request") | |
| 123 | + } | |
| 124 | + if !sawResponse { | |
| 125 | + t.Error("the history contains no tool response") | |
| 126 | + } | |
| 127 | + | |
| 128 | + // Commands reads the same trace: one command run, exactly one. | |
| 129 | + if n := Commands(full); n != 1 { | |
| 130 | + t.Errorf("Commands(full) = %d, want 1", n) | |
| 131 | + } | |
| 132 | + // And CommandList hands back its TEXT, which exists only in the call — that | |
| 133 | + // is what the green recap displays. | |
| 134 | + if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" { | |
| 135 | + t.Errorf("CommandList(full) = %q, want [\"echo hi\"]", got) | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +// TestCommandsCountsBashOnly guards the bug introduced by adding `read_skill`: | |
| 140 | +// `Commands` counted EVERY tool response, so loading two skills and running a | |
| 141 | +// single command displayed "⚙ 3 command(s)". The number then said the opposite | |
| 142 | +// of what it is for — that the model acted. | |
| 143 | +func TestCommandsCountsBashOnly(t *testing.T) { | |
| 144 | + skillReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{ | |
| 145 | + Name: "read_skill", | |
| 146 | + Input: map[string]any{"name": "go-rename"}, | |
| 147 | + })) | |
| 148 | + bashReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{ | |
| 149 | + Name: "bash", | |
| 150 | + Input: map[string]any{"command": "echo hi"}, | |
| 151 | + })) | |
| 152 | + final := modelTurn(ai.NewTextPart("done")) | |
| 153 | + | |
| 154 | + g, calls := fakeGenkit(t, skillReq, bashReq, final) | |
| 155 | + | |
| 156 | + _, full, err := testEngine(g).Generate(context.Background(), | |
| 157 | + []*ai.Message{ai.NewUserTextMessage("rename Greet")}, | |
| 158 | + []ai.ToolRef{ai.ToolName("bash"), ai.ToolName("read_skill")}) | |
| 159 | + if err != nil { | |
| 160 | + t.Fatalf("Generate: %v", err) | |
| 161 | + } | |
| 162 | + if *calls != 3 { | |
| 163 | + t.Errorf("calls to the model = %d, want 3", *calls) | |
| 164 | + } | |
| 165 | + if n := Commands(full); n != 1 { | |
| 166 | + t.Errorf("Commands = %d, want 1 — only the bash command counts", n) | |
| 167 | + } | |
| 168 | + if k := Skills(full); k != 1 { | |
| 169 | + t.Errorf("Skills = %d, want 1", k) | |
| 170 | + } | |
| 171 | + // The list follows the same rule as the count: the skill read is not in it. | |
| 172 | + if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" { | |
| 173 | + t.Errorf("CommandList = %q, want [\"echo hi\"] — a skill is not a command", got) | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +// TestCommandList covers the shapes the call↔response pairing has to take: | |
| 178 | +// several commands in order, a Ref that does not follow the order they were | |
| 179 | +// issued in, a call left without a response (nothing ran: nothing to show) | |
| 180 | +// and a turn mixing `bash` and `read_skill` with no Ref. | |
| 181 | +func TestCommandList(t *testing.T) { | |
| 182 | + req := func(ref, name string, in any) *ai.Part { | |
| 183 | + return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in}) | |
| 184 | + } | |
| 185 | + resp := func(ref, name string) *ai.Part { | |
| 186 | + return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"}) | |
| 187 | + } | |
| 188 | + bash := func(cmd string) map[string]any { return map[string]any{"command": cmd} } | |
| 189 | + | |
| 190 | + cases := []struct { | |
| 191 | + name string | |
| 192 | + history []*ai.Message | |
| 193 | + want []string | |
| 194 | + }{ | |
| 195 | + { | |
| 196 | + name: "two commands, in the order they ran", | |
| 197 | + history: []*ai.Message{ | |
| 198 | + ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))), | |
| 199 | + ai.NewMessage(ai.RoleTool, nil, resp("a", "bash"), resp("b", "bash")), | |
| 200 | + }, | |
| 201 | + want: []string{"ls", "pwd"}, | |
| 202 | + }, | |
| 203 | + { | |
| 204 | + name: "responses out of order: the Ref decides", | |
| 205 | + history: []*ai.Message{ | |
| 206 | + ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))), | |
| 207 | + ai.NewMessage(ai.RoleTool, nil, resp("b", "bash"), resp("a", "bash")), | |
| 208 | + }, | |
| 209 | + want: []string{"pwd", "ls"}, | |
| 210 | + }, | |
| 211 | + { | |
| 212 | + name: "no Ref, mixed tools: paired against the same name", | |
| 213 | + history: []*ai.Message{ | |
| 214 | + ai.NewModelMessage( | |
| 215 | + req("", "read_skill", map[string]any{"name": "go-rename"}), | |
| 216 | + req("", "bash", bash("ls")), | |
| 217 | + ), | |
| 218 | + ai.NewMessage(ai.RoleTool, nil, resp("", "read_skill"), resp("", "bash")), | |
| 219 | + }, | |
| 220 | + want: []string{"ls"}, | |
| 221 | + }, | |
| 222 | + { | |
| 223 | + name: "call with no response: the command never ran", | |
| 224 | + history: []*ai.Message{ | |
| 225 | + ai.NewModelMessage(req("a", "bash", bash("ls"))), | |
| 226 | + }, | |
| 227 | + want: nil, | |
| 228 | + }, | |
| 229 | + } | |
| 230 | + | |
| 231 | + for _, tc := range cases { | |
| 232 | + t.Run(tc.name, func(t *testing.T) { | |
| 233 | + got := CommandList(tc.history) | |
| 234 | + if len(got) != len(tc.want) { | |
| 235 | + t.Fatalf("CommandList = %q, want %q", got, tc.want) | |
| 236 | + } | |
| 237 | + for i := range got { | |
| 238 | + if got[i] != tc.want[i] { | |
| 239 | + t.Errorf("CommandList[%d] = %q, want %q", i, got[i], tc.want[i]) | |
| 240 | + } | |
| 241 | + } | |
| 242 | + }) | |
| 243 | + } | |
| 244 | +} | |
| 245 | + | |
| 246 | +// TestGenerateNoToolCall checks the bare case: with no tool called, the history | |
| 247 | +// stays the question plus the answer, and Commands counts zero — a model that | |
| 248 | +// tells stories without acting must not be credited with a command. | |
| 249 | +func TestGenerateNoToolCall(t *testing.T) { | |
| 250 | + final := modelTurn(ai.NewTextPart("hello")) | |
| 251 | + g, calls := fakeGenkit(t, final) | |
| 252 | + | |
| 253 | + _, full, err := testEngine(g).Generate(context.Background(), | |
| 254 | + []*ai.Message{ai.NewUserTextMessage("say hello")}, | |
| 255 | + nil) | |
| 256 | + if err != nil { | |
| 257 | + t.Fatalf("Generate: %v", err) | |
| 258 | + } | |
| 259 | + if *calls != 1 { | |
| 260 | + t.Errorf("calls to the model = %d, want 1", *calls) | |
| 261 | + } | |
| 262 | + if len(full) != 2 { | |
| 263 | + t.Fatalf("history of %d messages, want 2 (question, answer): %+v", len(full), full) | |
| 264 | + } | |
| 265 | + if n := Commands(full); n != 0 { | |
| 266 | + t.Errorf("Commands(full) = %d, want 0", n) | |
| 267 | + } | |
| 268 | +} | |
| 269 | + | |
| 270 | +// TestGenerateCapturesInputTokens: the compression trigger prefers the server's | |
| 271 | +// own count of the context to its estimate, and that count only exists inside | |
| 272 | +// the WrapModel hook. A fake response carrying Usage must surface through | |
| 273 | +// LastInputTokens; ForgetInputTokens must clear it. | |
| 274 | +func TestGenerateCapturesInputTokens(t *testing.T) { | |
| 275 | + withUsage := modelTurn(ai.NewTextPart("ok")) | |
| 276 | + withUsage.Usage = &ai.GenerationUsage{InputTokens: 1234, OutputTokens: 5} | |
| 277 | + | |
| 278 | + g, _ := fakeGenkit(t, withUsage) | |
| 279 | + e := testEngine(g) | |
| 280 | + if _, _, err := e.Generate(context.Background(), | |
| 281 | + []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil { | |
| 282 | + t.Fatalf("Generate: %v", err) | |
| 283 | + } | |
| 284 | + if got := e.LastInputTokens(); got != 1234 { | |
| 285 | + t.Errorf("LastInputTokens() = %d, want 1234", got) | |
| 286 | + } | |
| 287 | + e.ForgetInputTokens() | |
| 288 | + if got := e.LastInputTokens(); got != 0 { | |
| 289 | + t.Errorf("after ForgetInputTokens, LastInputTokens() = %d, want 0", got) | |
| 290 | + } | |
| 291 | +} | |
| 292 | + | |
| 293 | +// TestSummarize: the summary request declares no tools (the model must write, | |
| 294 | +// not act), goes to the Engine's own model reference, and the model's text | |
| 295 | +// comes back trimmed. An empty answer is an error — replacing the history with | |
| 296 | +// nothing is worse than keeping it. | |
| 297 | +func TestSummarize(t *testing.T) { | |
| 298 | + g := genkit.Init(context.Background()) | |
| 299 | + var sawTools int | |
| 300 | + answers := []string{" ## Goal\nnotes\n", ""} | |
| 301 | + call := 0 | |
| 302 | + genkit.DefineModel(g, "dmr/"+config.Cfg.Model, | |
| 303 | + &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}}, | |
| 304 | + func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { | |
| 305 | + sawTools = len(r.Tools) | |
| 306 | + a := answers[call] | |
| 307 | + call++ | |
| 308 | + return modelTurn(ai.NewTextPart(a)), nil | |
| 309 | + }) | |
| 310 | + e := testEngine(g) | |
| 311 | + | |
| 312 | + req := []*ai.Message{ai.NewSystemTextMessage("note-taker"), ai.NewUserTextMessage("summarise")} | |
| 313 | + text, err := e.Summarize(context.Background(), req, 100) | |
| 314 | + if err != nil { | |
| 315 | + t.Fatalf("Summarize: %v", err) | |
| 316 | + } | |
| 317 | + if text != "## Goal\nnotes" { | |
| 318 | + t.Errorf("text = %q, want the trimmed answer", text) | |
| 319 | + } | |
| 320 | + if sawTools != 0 { | |
| 321 | + t.Errorf("the summary request declared %d tool(s), want none", sawTools) | |
| 322 | + } | |
| 323 | + if _, err := e.Summarize(context.Background(), req, 100); err == nil { | |
| 324 | + t.Error("an empty answer must be an error") | |
| 325 | + } | |
| 326 | +} | |
| 327 | + | |
| 328 | +// TestFileOpsKeepsTheOrder: the recap lists file operations in the order they | |
| 329 | +// ran, across the three tools — a read then an edit must not come out as "all | |
| 330 | +// reads, then all edits", which a per-tool pass would produce. Commands stay | |
| 331 | +// bash-only: the file tools have their own column. | |
| 332 | +func TestFileOpsKeepsTheOrder(t *testing.T) { | |
| 333 | + req := func(ref, name string, in map[string]any) *ai.Part { | |
| 334 | + return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in}) | |
| 335 | + } | |
| 336 | + resp := func(ref, name string) *ai.Part { | |
| 337 | + return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"}) | |
| 338 | + } | |
| 339 | + history := []*ai.Message{ | |
| 340 | + ai.NewModelMessage(req("a", "read_file", map[string]any{"path": "f.go", "start": 1.0, "end": 9.0})), | |
| 341 | + ai.NewMessage(ai.RoleTool, nil, resp("a", "read_file")), | |
| 342 | + ai.NewModelMessage(req("b", "bash", map[string]any{"command": "go vet ./..."})), | |
| 343 | + ai.NewMessage(ai.RoleTool, nil, resp("b", "bash")), | |
| 344 | + ai.NewModelMessage(req("c", "edit_file", map[string]any{"path": "f.go", "edits": []any{map[string]any{"old": "a", "new": "b"}, map[string]any{"old": "c", "new": "d"}}})), | |
| 345 | + ai.NewMessage(ai.RoleTool, nil, resp("c", "edit_file")), | |
| 346 | + ai.NewModelMessage(req("d", "write_file", map[string]any{"path": "notes.md", "content": "x"})), | |
| 347 | + ai.NewMessage(ai.RoleTool, nil, resp("d", "write_file")), | |
| 348 | + } | |
| 349 | + want := []string{"read_file f.go 1-9", "edit_file f.go (2 edit(s))", "write_file notes.md"} | |
| 350 | + got := FileOps(history) | |
| 351 | + if len(got) != len(want) { | |
| 352 | + t.Fatalf("FileOps = %q, want %q", got, want) | |
| 353 | + } | |
| 354 | + for i := range want { | |
| 355 | + if got[i] != want[i] { | |
| 356 | + t.Errorf("FileOps[%d] = %q, want %q", i, got[i], want[i]) | |
| 357 | + } | |
| 358 | + } | |
| 359 | + if n := Commands(history); n != 1 { | |
| 360 | + t.Errorf("Commands = %d, want 1 — file ops are not commands", n) | |
| 361 | + } | |
| 362 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,362 @@ | |||
| 1 | +// The test that matters here is the one about CAPTURE: Genkit only hands back | ||
| 2 | +// the last message, and the whole point of `Generate` is to hand back the | ||
| 3 | +// intermediate turns too. That is the one thing the `WithMiddleware` → `WithUse` | ||
| 4 | +// migration could have broken silently — the code compiles just as well with a | ||
| 5 | +// hook that never sees anything. | ||
| 6 | +// | ||
| 7 | +// No real model is needed: a fake model registered in Genkit plays one tool turn | ||
| 8 | +// then answers, which is exactly the shape the history must keep a trace of. | ||
| 9 | +// | ||
| 10 | +// The watchdog of this version runs during these tests: since the fake model | ||
| 11 | +// answers instantly it never fires — and if it did, the tests would fail, which | ||
| 12 | +// is the intended behaviour. | ||
| 13 | +package engine | ||
| 14 | + | ||
| 15 | +import ( | ||
| 16 | + "context" | ||
| 17 | + "testing" | ||
| 18 | + | ||
| 19 | + "mm/internal/config" | ||
| 20 | + | ||
| 21 | + "github.com/firebase/genkit/go/ai" | ||
| 22 | + "github.com/firebase/genkit/go/genkit" | ||
| 23 | +) | ||
| 24 | + | ||
| 25 | +// fakeGenkit registers a "dmr/<Cfg.Model>" model whose every call is served by | ||
| 26 | +// the next answer in `turns`, and a `bash` tool returning a fixed output. | ||
| 27 | +// `calls` counts the calls to the model. | ||
| 28 | +func fakeGenkit(t *testing.T, turns ...*ai.ModelResponse) (*genkit.Genkit, *int) { | ||
| 29 | + t.Helper() | ||
| 30 | + g := genkit.Init(context.Background()) | ||
| 31 | + | ||
| 32 | + calls := 0 | ||
| 33 | + genkit.DefineModel(g, "dmr/"+config.Cfg.Model, | ||
| 34 | + &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}}, | ||
| 35 | + func(_ context.Context, _ *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { | ||
| 36 | + if calls >= len(turns) { | ||
| 37 | + t.Errorf("the model was called %d times for %d planned turns", calls+1, len(turns)) | ||
| 38 | + return turns[len(turns)-1], nil | ||
| 39 | + } | ||
| 40 | + resp := turns[calls] | ||
| 41 | + calls++ | ||
| 42 | + return resp, nil | ||
| 43 | + }) | ||
| 44 | + | ||
| 45 | + genkit.DefineTool(g, "bash", "run a shell command", | ||
| 46 | + func(_ *ai.ToolContext, in struct { | ||
| 47 | + Command string `json:"command"` | ||
| 48 | + }) (string, error) { | ||
| 49 | + return "hello from " + in.Command, nil | ||
| 50 | + }) | ||
| 51 | + | ||
| 52 | + genkit.DefineTool(g, "read_skill", "load a markdown procedure", | ||
| 53 | + func(_ *ai.ToolContext, in struct { | ||
| 54 | + Name string `json:"name"` | ||
| 55 | + }) (string, error) { | ||
| 56 | + return "# procedure " + in.Name, nil | ||
| 57 | + }) | ||
| 58 | + | ||
| 59 | + return g, &calls | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// testEngine wraps a fake Genkit the way New would: the model reference is the | ||
| 63 | +// only thing Generate reads from the Engine, so no provider is needed here. | ||
| 64 | +func testEngine(g *genkit.Genkit) *Engine { | ||
| 65 | + return &Engine{G: g, Model: "dmr/" + config.Cfg.Model} | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +func modelTurn(parts ...*ai.Part) *ai.ModelResponse { | ||
| 69 | + return &ai.ModelResponse{ | ||
| 70 | + Message: ai.NewModelMessage(parts...), | ||
| 71 | + FinishReason: ai.FinishReasonStop, | ||
| 72 | + } | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +// TestGenerateReturnsFullConversation is the regression test for the capture: on | ||
| 76 | +// a tool turn, the history handed back must contain the question, the tool | ||
| 77 | +// request, its response AND the final text — not just that last one. | ||
| 78 | +func TestGenerateReturnsFullConversation(t *testing.T) { | ||
| 79 | + toolReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{ | ||
| 80 | + Name: "bash", | ||
| 81 | + Input: map[string]any{"command": "echo hi"}, | ||
| 82 | + })) | ||
| 83 | + final := modelTurn(ai.NewTextPart("done")) | ||
| 84 | + | ||
| 85 | + g, calls := fakeGenkit(t, toolReq, final) | ||
| 86 | + | ||
| 87 | + resp, full, err := testEngine(g).Generate(context.Background(), | ||
| 88 | + []*ai.Message{ai.NewUserTextMessage("run echo hi")}, | ||
| 89 | + []ai.ToolRef{ai.ToolName("bash")}) | ||
| 90 | + if err != nil { | ||
| 91 | + t.Fatalf("Generate: %v", err) | ||
| 92 | + } | ||
| 93 | + if *calls != 2 { | ||
| 94 | + t.Errorf("calls to the model = %d, want 2 (tool, then answer)", *calls) | ||
| 95 | + } | ||
| 96 | + if got := resp.Text(); got != "done" { | ||
| 97 | + t.Errorf("resp.Text() = %q, want %q", got, "done") | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + // The full history: without the capture, Genkit only hands back the last | ||
| 101 | + // message and `full` would have length 1. | ||
| 102 | + if len(full) < 4 { | ||
| 103 | + t.Fatalf("history of %d messages, want at least 4 (question, call, tool response, text): %+v", len(full), full) | ||
| 104 | + } | ||
| 105 | + if full[len(full)-1] != final.Message { | ||
| 106 | + t.Errorf("the last message of the history is not the final answer") | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + // The two markers that exist ONLY in the intermediate turns. | ||
| 110 | + var sawRequest, sawResponse bool | ||
| 111 | + for _, m := range full { | ||
| 112 | + for _, p := range m.Content { | ||
| 113 | + if p.IsToolRequest() { | ||
| 114 | + sawRequest = true | ||
| 115 | + } | ||
| 116 | + if p.IsToolResponse() { | ||
| 117 | + sawResponse = true | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + } | ||
| 121 | + if !sawRequest { | ||
| 122 | + t.Error("the history contains no tool request") | ||
| 123 | + } | ||
| 124 | + if !sawResponse { | ||
| 125 | + t.Error("the history contains no tool response") | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + // Commands reads the same trace: one command run, exactly one. | ||
| 129 | + if n := Commands(full); n != 1 { | ||
| 130 | + t.Errorf("Commands(full) = %d, want 1", n) | ||
| 131 | + } | ||
| 132 | + // And CommandList hands back its TEXT, which exists only in the call — that | ||
| 133 | + // is what the green recap displays. | ||
| 134 | + if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" { | ||
| 135 | + t.Errorf("CommandList(full) = %q, want [\"echo hi\"]", got) | ||
| 136 | + } | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +// TestCommandsCountsBashOnly guards the bug introduced by adding `read_skill`: | ||
| 140 | +// `Commands` counted EVERY tool response, so loading two skills and running a | ||
| 141 | +// single command displayed "⚙ 3 command(s)". The number then said the opposite | ||
| 142 | +// of what it is for — that the model acted. | ||
| 143 | +func TestCommandsCountsBashOnly(t *testing.T) { | ||
| 144 | + skillReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{ | ||
| 145 | + Name: "read_skill", | ||
| 146 | + Input: map[string]any{"name": "go-rename"}, | ||
| 147 | + })) | ||
| 148 | + bashReq := modelTurn(ai.NewToolRequestPart(&ai.ToolRequest{ | ||
| 149 | + Name: "bash", | ||
| 150 | + Input: map[string]any{"command": "echo hi"}, | ||
| 151 | + })) | ||
| 152 | + final := modelTurn(ai.NewTextPart("done")) | ||
| 153 | + | ||
| 154 | + g, calls := fakeGenkit(t, skillReq, bashReq, final) | ||
| 155 | + | ||
| 156 | + _, full, err := testEngine(g).Generate(context.Background(), | ||
| 157 | + []*ai.Message{ai.NewUserTextMessage("rename Greet")}, | ||
| 158 | + []ai.ToolRef{ai.ToolName("bash"), ai.ToolName("read_skill")}) | ||
| 159 | + if err != nil { | ||
| 160 | + t.Fatalf("Generate: %v", err) | ||
| 161 | + } | ||
| 162 | + if *calls != 3 { | ||
| 163 | + t.Errorf("calls to the model = %d, want 3", *calls) | ||
| 164 | + } | ||
| 165 | + if n := Commands(full); n != 1 { | ||
| 166 | + t.Errorf("Commands = %d, want 1 — only the bash command counts", n) | ||
| 167 | + } | ||
| 168 | + if k := Skills(full); k != 1 { | ||
| 169 | + t.Errorf("Skills = %d, want 1", k) | ||
| 170 | + } | ||
| 171 | + // The list follows the same rule as the count: the skill read is not in it. | ||
| 172 | + if got := CommandList(full); len(got) != 1 || got[0] != "echo hi" { | ||
| 173 | + t.Errorf("CommandList = %q, want [\"echo hi\"] — a skill is not a command", got) | ||
| 174 | + } | ||
| 175 | +} | ||
| 176 | + | ||
| 177 | +// TestCommandList covers the shapes the call↔response pairing has to take: | ||
| 178 | +// several commands in order, a Ref that does not follow the order they were | ||
| 179 | +// issued in, a call left without a response (nothing ran: nothing to show) | ||
| 180 | +// and a turn mixing `bash` and `read_skill` with no Ref. | ||
| 181 | +func TestCommandList(t *testing.T) { | ||
| 182 | + req := func(ref, name string, in any) *ai.Part { | ||
| 183 | + return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in}) | ||
| 184 | + } | ||
| 185 | + resp := func(ref, name string) *ai.Part { | ||
| 186 | + return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"}) | ||
| 187 | + } | ||
| 188 | + bash := func(cmd string) map[string]any { return map[string]any{"command": cmd} } | ||
| 189 | + | ||
| 190 | + cases := []struct { | ||
| 191 | + name string | ||
| 192 | + history []*ai.Message | ||
| 193 | + want []string | ||
| 194 | + }{ | ||
| 195 | + { | ||
| 196 | + name: "two commands, in the order they ran", | ||
| 197 | + history: []*ai.Message{ | ||
| 198 | + ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))), | ||
| 199 | + ai.NewMessage(ai.RoleTool, nil, resp("a", "bash"), resp("b", "bash")), | ||
| 200 | + }, | ||
| 201 | + want: []string{"ls", "pwd"}, | ||
| 202 | + }, | ||
| 203 | + { | ||
| 204 | + name: "responses out of order: the Ref decides", | ||
| 205 | + history: []*ai.Message{ | ||
| 206 | + ai.NewModelMessage(req("a", "bash", bash("ls")), req("b", "bash", bash("pwd"))), | ||
| 207 | + ai.NewMessage(ai.RoleTool, nil, resp("b", "bash"), resp("a", "bash")), | ||
| 208 | + }, | ||
| 209 | + want: []string{"pwd", "ls"}, | ||
| 210 | + }, | ||
| 211 | + { | ||
| 212 | + name: "no Ref, mixed tools: paired against the same name", | ||
| 213 | + history: []*ai.Message{ | ||
| 214 | + ai.NewModelMessage( | ||
| 215 | + req("", "read_skill", map[string]any{"name": "go-rename"}), | ||
| 216 | + req("", "bash", bash("ls")), | ||
| 217 | + ), | ||
| 218 | + ai.NewMessage(ai.RoleTool, nil, resp("", "read_skill"), resp("", "bash")), | ||
| 219 | + }, | ||
| 220 | + want: []string{"ls"}, | ||
| 221 | + }, | ||
| 222 | + { | ||
| 223 | + name: "call with no response: the command never ran", | ||
| 224 | + history: []*ai.Message{ | ||
| 225 | + ai.NewModelMessage(req("a", "bash", bash("ls"))), | ||
| 226 | + }, | ||
| 227 | + want: nil, | ||
| 228 | + }, | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + for _, tc := range cases { | ||
| 232 | + t.Run(tc.name, func(t *testing.T) { | ||
| 233 | + got := CommandList(tc.history) | ||
| 234 | + if len(got) != len(tc.want) { | ||
| 235 | + t.Fatalf("CommandList = %q, want %q", got, tc.want) | ||
| 236 | + } | ||
| 237 | + for i := range got { | ||
| 238 | + if got[i] != tc.want[i] { | ||
| 239 | + t.Errorf("CommandList[%d] = %q, want %q", i, got[i], tc.want[i]) | ||
| 240 | + } | ||
| 241 | + } | ||
| 242 | + }) | ||
| 243 | + } | ||
| 244 | +} | ||
| 245 | + | ||
| 246 | +// TestGenerateNoToolCall checks the bare case: with no tool called, the history | ||
| 247 | +// stays the question plus the answer, and Commands counts zero — a model that | ||
| 248 | +// tells stories without acting must not be credited with a command. | ||
| 249 | +func TestGenerateNoToolCall(t *testing.T) { | ||
| 250 | + final := modelTurn(ai.NewTextPart("hello")) | ||
| 251 | + g, calls := fakeGenkit(t, final) | ||
| 252 | + | ||
| 253 | + _, full, err := testEngine(g).Generate(context.Background(), | ||
| 254 | + []*ai.Message{ai.NewUserTextMessage("say hello")}, | ||
| 255 | + nil) | ||
| 256 | + if err != nil { | ||
| 257 | + t.Fatalf("Generate: %v", err) | ||
| 258 | + } | ||
| 259 | + if *calls != 1 { | ||
| 260 | + t.Errorf("calls to the model = %d, want 1", *calls) | ||
| 261 | + } | ||
| 262 | + if len(full) != 2 { | ||
| 263 | + t.Fatalf("history of %d messages, want 2 (question, answer): %+v", len(full), full) | ||
| 264 | + } | ||
| 265 | + if n := Commands(full); n != 0 { | ||
| 266 | + t.Errorf("Commands(full) = %d, want 0", n) | ||
| 267 | + } | ||
| 268 | +} | ||
| 269 | + | ||
| 270 | +// TestGenerateCapturesInputTokens: the compression trigger prefers the server's | ||
| 271 | +// own count of the context to its estimate, and that count only exists inside | ||
| 272 | +// the WrapModel hook. A fake response carrying Usage must surface through | ||
| 273 | +// LastInputTokens; ForgetInputTokens must clear it. | ||
| 274 | +func TestGenerateCapturesInputTokens(t *testing.T) { | ||
| 275 | + withUsage := modelTurn(ai.NewTextPart("ok")) | ||
| 276 | + withUsage.Usage = &ai.GenerationUsage{InputTokens: 1234, OutputTokens: 5} | ||
| 277 | + | ||
| 278 | + g, _ := fakeGenkit(t, withUsage) | ||
| 279 | + e := testEngine(g) | ||
| 280 | + if _, _, err := e.Generate(context.Background(), | ||
| 281 | + []*ai.Message{ai.NewUserTextMessage("hi")}, nil); err != nil { | ||
| 282 | + t.Fatalf("Generate: %v", err) | ||
| 283 | + } | ||
| 284 | + if got := e.LastInputTokens(); got != 1234 { | ||
| 285 | + t.Errorf("LastInputTokens() = %d, want 1234", got) | ||
| 286 | + } | ||
| 287 | + e.ForgetInputTokens() | ||
| 288 | + if got := e.LastInputTokens(); got != 0 { | ||
| 289 | + t.Errorf("after ForgetInputTokens, LastInputTokens() = %d, want 0", got) | ||
| 290 | + } | ||
| 291 | +} | ||
| 292 | + | ||
| 293 | +// TestSummarize: the summary request declares no tools (the model must write, | ||
| 294 | +// not act), goes to the Engine's own model reference, and the model's text | ||
| 295 | +// comes back trimmed. An empty answer is an error — replacing the history with | ||
| 296 | +// nothing is worse than keeping it. | ||
| 297 | +func TestSummarize(t *testing.T) { | ||
| 298 | + g := genkit.Init(context.Background()) | ||
| 299 | + var sawTools int | ||
| 300 | + answers := []string{" ## Goal\nnotes\n", ""} | ||
| 301 | + call := 0 | ||
| 302 | + genkit.DefineModel(g, "dmr/"+config.Cfg.Model, | ||
| 303 | + &ai.ModelOptions{Supports: &ai.ModelSupports{Tools: true, Multiturn: true, SystemRole: true}}, | ||
| 304 | + func(_ context.Context, r *ai.ModelRequest, _ ai.ModelStreamCallback) (*ai.ModelResponse, error) { | ||
| 305 | + sawTools = len(r.Tools) | ||
| 306 | + a := answers[call] | ||
| 307 | + call++ | ||
| 308 | + return modelTurn(ai.NewTextPart(a)), nil | ||
| 309 | + }) | ||
| 310 | + e := testEngine(g) | ||
| 311 | + | ||
| 312 | + req := []*ai.Message{ai.NewSystemTextMessage("note-taker"), ai.NewUserTextMessage("summarise")} | ||
| 313 | + text, err := e.Summarize(context.Background(), req, 100) | ||
| 314 | + if err != nil { | ||
| 315 | + t.Fatalf("Summarize: %v", err) | ||
| 316 | + } | ||
| 317 | + if text != "## Goal\nnotes" { | ||
| 318 | + t.Errorf("text = %q, want the trimmed answer", text) | ||
| 319 | + } | ||
| 320 | + if sawTools != 0 { | ||
| 321 | + t.Errorf("the summary request declared %d tool(s), want none", sawTools) | ||
| 322 | + } | ||
| 323 | + if _, err := e.Summarize(context.Background(), req, 100); err == nil { | ||
| 324 | + t.Error("an empty answer must be an error") | ||
| 325 | + } | ||
| 326 | +} | ||
| 327 | + | ||
| 328 | +// TestFileOpsKeepsTheOrder: the recap lists file operations in the order they | ||
| 329 | +// ran, across the three tools — a read then an edit must not come out as "all | ||
| 330 | +// reads, then all edits", which a per-tool pass would produce. Commands stay | ||
| 331 | +// bash-only: the file tools have their own column. | ||
| 332 | +func TestFileOpsKeepsTheOrder(t *testing.T) { | ||
| 333 | + req := func(ref, name string, in map[string]any) *ai.Part { | ||
| 334 | + return ai.NewToolRequestPart(&ai.ToolRequest{Ref: ref, Name: name, Input: in}) | ||
| 335 | + } | ||
| 336 | + resp := func(ref, name string) *ai.Part { | ||
| 337 | + return ai.NewToolResponsePart(&ai.ToolResponse{Ref: ref, Name: name, Output: "ok"}) | ||
| 338 | + } | ||
| 339 | + history := []*ai.Message{ | ||
| 340 | + ai.NewModelMessage(req("a", "read_file", map[string]any{"path": "f.go", "start": 1.0, "end": 9.0})), | ||
| 341 | + ai.NewMessage(ai.RoleTool, nil, resp("a", "read_file")), | ||
| 342 | + ai.NewModelMessage(req("b", "bash", map[string]any{"command": "go vet ./..."})), | ||
| 343 | + ai.NewMessage(ai.RoleTool, nil, resp("b", "bash")), | ||
| 344 | + ai.NewModelMessage(req("c", "edit_file", map[string]any{"path": "f.go", "edits": []any{map[string]any{"old": "a", "new": "b"}, map[string]any{"old": "c", "new": "d"}}})), | ||
| 345 | + ai.NewMessage(ai.RoleTool, nil, resp("c", "edit_file")), | ||
| 346 | + ai.NewModelMessage(req("d", "write_file", map[string]any{"path": "notes.md", "content": "x"})), | ||
| 347 | + ai.NewMessage(ai.RoleTool, nil, resp("d", "write_file")), | ||
| 348 | + } | ||
| 349 | + want := []string{"read_file f.go 1-9", "edit_file f.go (2 edit(s))", "write_file notes.md"} | ||
| 350 | + got := FileOps(history) | ||
| 351 | + if len(got) != len(want) { | ||
| 352 | + t.Fatalf("FileOps = %q, want %q", got, want) | ||
| 353 | + } | ||
| 354 | + for i := range want { | ||
| 355 | + if got[i] != want[i] { | ||
| 356 | + t.Errorf("FileOps[%d] = %q, want %q", i, got[i], want[i]) | ||
| 357 | + } | ||
| 358 | + } | ||
| 359 | + if n := Commands(history); n != 1 { | ||
| 360 | + t.Errorf("Commands = %d, want 1 — file ops are not commands", n) | ||
| 361 | + } | ||
| 362 | +} | ||
added
internal/engine/explain.go +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "fmt" | |
| 6 | + "net" | |
| 7 | + "regexp" | |
| 8 | + "strconv" | |
| 9 | + "strings" | |
| 10 | + | |
| 11 | + "github.com/openai/openai-go" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// statusInText recovers the HTTP status from the OpenAI client's error text | |
| 15 | +// (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors | |
| 16 | +// with %v instead of %w, and the typed error is then out of reach of errors.As; | |
| 17 | +// the text still carries the number. | |
| 18 | +var statusInText = regexp.MustCompile(`": (\d{3}) `) | |
| 19 | + | |
| 20 | +// Explain maps a failed generation to one line the user can act on. The REPL | |
| 21 | +// prints `[error: …]` and moves on; a stack of wrapped messages there is noise | |
| 22 | +// on stage, while "start llama-server with --jinja" is the whole diagnosis. | |
| 23 | +// Unknown errors come back unchanged: better a raw message than a wrong hint. | |
| 24 | +func (p *openaiCompat) Explain(err error, b Backend) string { | |
| 25 | + if err == nil { | |
| 26 | + return "" | |
| 27 | + } | |
| 28 | + msg := err.Error() | |
| 29 | + | |
| 30 | + status := 0 | |
| 31 | + var apiErr *openai.Error | |
| 32 | + if errors.As(err, &apiErr) && apiErr.Response != nil { | |
| 33 | + status = apiErr.Response.StatusCode | |
| 34 | + } else if m := statusInText.FindStringSubmatch(msg); m != nil { | |
| 35 | + status, _ = strconv.Atoi(m[1]) | |
| 36 | + } | |
| 37 | + | |
| 38 | + switch { | |
| 39 | + case connectionRefused(err, msg): | |
| 40 | + return fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint) | |
| 41 | + | |
| 42 | + // llama-server without --jinja refuses `tools` with a message naming the | |
| 43 | + // flag. It is the one llama.cpp error a first-time user hits, so it gets | |
| 44 | + // its own line rather than the generic "server error". | |
| 45 | + case strings.Contains(msg, "jinja"): | |
| 46 | + return fmt.Sprintf("%s: tool calls need llama-server started with --jinja", p.name) | |
| 47 | + | |
| 48 | + // The variable named here is the one Resolve actually read (yaml `apiKeyEnv`, | |
| 49 | + // else the provider's default). When there is none — DMR ignores keys — a | |
| 50 | + // 401 means the URL is not the server we think it is, so that is the hint. | |
| 51 | + case status == 401 || status == 403: | |
| 52 | + if b.APIKeyEnv == "" { | |
| 53 | + return fmt.Sprintf("%s: authentication failed at %s — this server should not need a key; check baseUrl", p.name, b.BaseURL) | |
| 54 | + } | |
| 55 | + return fmt.Sprintf("%s: authentication failed at %s — is $%s set and valid?", p.name, b.BaseURL, b.APIKeyEnv) | |
| 56 | + | |
| 57 | + case status == 404: | |
| 58 | + return fmt.Sprintf("%s: %q not found at %s — %s", p.name, b.Model, b.BaseURL, fmt.Sprintf(p.notFoundHint, b.Model)) | |
| 59 | + | |
| 60 | + case status == 429: | |
| 61 | + return fmt.Sprintf("%s: rate limited — wait, or lower the pace", p.name) | |
| 62 | + | |
| 63 | + case status >= 500: | |
| 64 | + return fmt.Sprintf("%s: server error %d — %s", p.name, status, firstLine(msg)) | |
| 65 | + } | |
| 66 | + return msg | |
| 67 | +} | |
| 68 | + | |
| 69 | +// connectionRefused: a dial that failed, whatever the wrapping. The typed check | |
| 70 | +// covers the errors.As path; the text check covers Genkit's %v rebuilds. | |
| 71 | +func connectionRefused(err error, msg string) bool { | |
| 72 | + var opErr *net.OpError | |
| 73 | + if errors.As(err, &opErr) && opErr.Op == "dial" { | |
| 74 | + return true | |
| 75 | + } | |
| 76 | + return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host") | |
| 77 | +} | |
| 78 | + | |
| 79 | +// firstLine keeps a server's message to its first line, cut at 120 columns — | |
| 80 | +// the same one-line-per-item rule as the command recap. | |
| 81 | +func firstLine(s string) string { | |
| 82 | + if i := strings.IndexByte(s, '\n'); i >= 0 { | |
| 83 | + s = s[:i] | |
| 84 | + } | |
| 85 | + if r := []rune(s); len(r) > 120 { | |
| 86 | + return string(r[:119]) + "…" | |
| 87 | + } | |
| 88 | + return s | |
| 89 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "fmt" | ||
| 6 | + "net" | ||
| 7 | + "regexp" | ||
| 8 | + "strconv" | ||
| 9 | + "strings" | ||
| 10 | + | ||
| 11 | + "github.com/openai/openai-go" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// statusInText recovers the HTTP status from the OpenAI client's error text | ||
| 15 | +// (`POST "http://…": 401 Unauthorized {…}`). Genkit sometimes rebuilds errors | ||
| 16 | +// with %v instead of %w, and the typed error is then out of reach of errors.As; | ||
| 17 | +// the text still carries the number. | ||
| 18 | +var statusInText = regexp.MustCompile(`": (\d{3}) `) | ||
| 19 | + | ||
| 20 | +// Explain maps a failed generation to one line the user can act on. The REPL | ||
| 21 | +// prints `[error: …]` and moves on; a stack of wrapped messages there is noise | ||
| 22 | +// on stage, while "start llama-server with --jinja" is the whole diagnosis. | ||
| 23 | +// Unknown errors come back unchanged: better a raw message than a wrong hint. | ||
| 24 | +func (p *openaiCompat) Explain(err error, b Backend) string { | ||
| 25 | + if err == nil { | ||
| 26 | + return "" | ||
| 27 | + } | ||
| 28 | + msg := err.Error() | ||
| 29 | + | ||
| 30 | + status := 0 | ||
| 31 | + var apiErr *openai.Error | ||
| 32 | + if errors.As(err, &apiErr) && apiErr.Response != nil { | ||
| 33 | + status = apiErr.Response.StatusCode | ||
| 34 | + } else if m := statusInText.FindStringSubmatch(msg); m != nil { | ||
| 35 | + status, _ = strconv.Atoi(m[1]) | ||
| 36 | + } | ||
| 37 | + | ||
| 38 | + switch { | ||
| 39 | + case connectionRefused(err, msg): | ||
| 40 | + return fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint) | ||
| 41 | + | ||
| 42 | + // llama-server without --jinja refuses `tools` with a message naming the | ||
| 43 | + // flag. It is the one llama.cpp error a first-time user hits, so it gets | ||
| 44 | + // its own line rather than the generic "server error". | ||
| 45 | + case strings.Contains(msg, "jinja"): | ||
| 46 | + return fmt.Sprintf("%s: tool calls need llama-server started with --jinja", p.name) | ||
| 47 | + | ||
| 48 | + // The variable named here is the one Resolve actually read (yaml `apiKeyEnv`, | ||
| 49 | + // else the provider's default). When there is none — DMR ignores keys — a | ||
| 50 | + // 401 means the URL is not the server we think it is, so that is the hint. | ||
| 51 | + case status == 401 || status == 403: | ||
| 52 | + if b.APIKeyEnv == "" { | ||
| 53 | + return fmt.Sprintf("%s: authentication failed at %s — this server should not need a key; check baseUrl", p.name, b.BaseURL) | ||
| 54 | + } | ||
| 55 | + return fmt.Sprintf("%s: authentication failed at %s — is $%s set and valid?", p.name, b.BaseURL, b.APIKeyEnv) | ||
| 56 | + | ||
| 57 | + case status == 404: | ||
| 58 | + return fmt.Sprintf("%s: %q not found at %s — %s", p.name, b.Model, b.BaseURL, fmt.Sprintf(p.notFoundHint, b.Model)) | ||
| 59 | + | ||
| 60 | + case status == 429: | ||
| 61 | + return fmt.Sprintf("%s: rate limited — wait, or lower the pace", p.name) | ||
| 62 | + | ||
| 63 | + case status >= 500: | ||
| 64 | + return fmt.Sprintf("%s: server error %d — %s", p.name, status, firstLine(msg)) | ||
| 65 | + } | ||
| 66 | + return msg | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +// connectionRefused: a dial that failed, whatever the wrapping. The typed check | ||
| 70 | +// covers the errors.As path; the text check covers Genkit's %v rebuilds. | ||
| 71 | +func connectionRefused(err error, msg string) bool { | ||
| 72 | + var opErr *net.OpError | ||
| 73 | + if errors.As(err, &opErr) && opErr.Op == "dial" { | ||
| 74 | + return true | ||
| 75 | + } | ||
| 76 | + return strings.Contains(msg, "connection refused") || strings.Contains(msg, "no such host") | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +// firstLine keeps a server's message to its first line, cut at 120 columns — | ||
| 80 | +// the same one-line-per-item rule as the command recap. | ||
| 81 | +func firstLine(s string) string { | ||
| 82 | + if i := strings.IndexByte(s, '\n'); i >= 0 { | ||
| 83 | + s = s[:i] | ||
| 84 | + } | ||
| 85 | + if r := []rune(s); len(r) > 120 { | ||
| 86 | + return string(r[:119]) + "…" | ||
| 87 | + } | ||
| 88 | + return s | ||
| 89 | +} | ||
added
internal/engine/gap_test.go +130 -0 | new file mode 100644 | ||
| @@ -0,0 +1,130 @@ | ||
| 1 | +// Tests for the spacing of the streamed text. The bug they guard: the model | |
| 2 | +// ends its sentence with line breaks — more and more of them as the turns go by | |
| 3 | +// — and the screen hollowed out by as much. Reproducible end to end with | |
| 4 | +// `SCENARIO=padding python3 ../03-one-agent-one-tool/docs/fake-engine.py`. | |
| 5 | +package engine | |
| 6 | + | |
| 7 | +import "testing" | |
| 8 | + | |
| 9 | +// next is called once per streamed chunk; we check what gets printed for each | |
| 10 | +// of them, in order. | |
| 11 | +func TestGapHoldsTrailingBlanks(t *testing.T) { | |
| 12 | + cases := []struct { | |
| 13 | + name string | |
| 14 | + fragments []string | |
| 15 | + want []string | |
| 16 | + }{{ | |
| 17 | + name: "plain text", | |
| 18 | + fragments: []string{"Hello"}, | |
| 19 | + want: []string{"Hello"}, | |
| 20 | + }, { | |
| 21 | + name: "trailing whitespace is not printed", | |
| 22 | + fragments: []string{"Let me look:\n\n"}, | |
| 23 | + want: []string{"Let me look:"}, | |
| 24 | + }, { | |
| 25 | + name: "it is printed when text follows, down to one blank line", | |
| 26 | + fragments: []string{"para one\n\n\n\n\n", "para two"}, | |
| 27 | + want: []string{"para one", "\n\npara two"}, | |
| 28 | + }, { | |
| 29 | + name: "leading whitespace is dropped before the first word", | |
| 30 | + fragments: []string{"\n\n", "Hello"}, | |
| 31 | + want: []string{"", "Hello"}, | |
| 32 | + }, { | |
| 33 | + name: "all-whitespace chunks: accumulated, then capped", | |
| 34 | + fragments: []string{"a", "\n", "\n", "\n", "\n", "b"}, | |
| 35 | + want: []string{"a", "", "", "", "", "\n\nb"}, | |
| 36 | + }, { | |
| 37 | + name: "the indentation of a code block survives", | |
| 38 | + fragments: []string{"code:", "\n fmt.Println()"}, | |
| 39 | + want: []string{"code:", "\n fmt.Println()"}, | |
| 40 | + }, { | |
| 41 | + name: "a single break between two lines stays single", | |
| 42 | + fragments: []string{"line one\n", "line two"}, | |
| 43 | + want: []string{"line one", "\nline two"}, | |
| 44 | + }, { | |
| 45 | + name: "empty chunk", | |
| 46 | + fragments: []string{"a", "", "b"}, | |
| 47 | + want: []string{"a", "", "b"}, | |
| 48 | + }} | |
| 49 | + | |
| 50 | + for _, c := range cases { | |
| 51 | + t.Run(c.name, func(t *testing.T) { | |
| 52 | + var g gap | |
| 53 | + for i, frag := range c.fragments { | |
| 54 | + if got := g.next(frag); got != c.want[i] { | |
| 55 | + t.Errorf("chunk %d (%q): printed %q, want %q", | |
| 56 | + i, frag, got, c.want[i]) | |
| 57 | + } | |
| 58 | + } | |
| 59 | + }) | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +// drop is called when a tool is about to write: the whitespace held back | |
| 64 | +// belonged to the text before it, and printing it after the 🛠️ line would | |
| 65 | +// reopen the hole. | |
| 66 | +func TestGapDropForgetsHeldBlanks(t *testing.T) { | |
| 67 | + var g gap | |
| 68 | + if got := g.next("Let me look:\n\n\n"); got != "Let me look:" { | |
| 69 | + t.Fatalf("printed %q", got) | |
| 70 | + } | |
| 71 | + g.drop() // 🛠️ bash: … gets printed here | |
| 72 | + if got := g.next("Now let me check:"); got != "Now let me check:" { | |
| 73 | + t.Errorf("after drop: printed %q, want %q with no whitespace in front", | |
| 74 | + got, "Now let me check:") | |
| 75 | + } | |
| 76 | +} | |
| 77 | + | |
| 78 | +// The full scenario of the bug: four turns whose trailing whitespace grows, a | |
| 79 | +// tool between each. None of that whitespace may be printed. | |
| 80 | +func TestGapPaddingScenario(t *testing.T) { | |
| 81 | + var g gap | |
| 82 | + turns := []string{ | |
| 83 | + "Let me look at the file:" + nl(2), | |
| 84 | + "Now let me check something else:" + nl(4), | |
| 85 | + "Let me try another approach:" + nl(7), | |
| 86 | + "Let me rebuild the program:" + nl(11), | |
| 87 | + } | |
| 88 | + for i, turn := range turns { | |
| 89 | + out := g.next(turn) | |
| 90 | + if hasBlank(out) { | |
| 91 | + t.Errorf("turn %d: %q contains trailing whitespace", i, out) | |
| 92 | + } | |
| 93 | + g.drop() // the tool writes its 🛠️ line | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +func TestSqueeze(t *testing.T) { | |
| 98 | + cases := map[string]string{ | |
| 99 | + "": "", | |
| 100 | + "\n": "\n", | |
| 101 | + "\n\n": "\n\n", | |
| 102 | + "\n\n\n": "\n\n", | |
| 103 | + "\n\n\n\n\n\n\n\n": "\n\n", | |
| 104 | + "\n ": "\n ", // indentation preserved | |
| 105 | + "\n\n\n ": "\n\n ", // capped, indentation kept | |
| 106 | + "a\n\n\n\nb": "a\n\nb", // in the middle of the text too | |
| 107 | + " ": " ", // spaces are left alone | |
| 108 | + } | |
| 109 | + for in, want := range cases { | |
| 110 | + if got := squeeze(in); got != want { | |
| 111 | + t.Errorf("squeeze(%q) = %q, want %q", in, got, want) | |
| 112 | + } | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +func nl(n int) string { | |
| 117 | + s := "" | |
| 118 | + for range n { | |
| 119 | + s += "\n" | |
| 120 | + } | |
| 121 | + return s | |
| 122 | +} | |
| 123 | + | |
| 124 | +func hasBlank(s string) bool { | |
| 125 | + if s == "" { | |
| 126 | + return false | |
| 127 | + } | |
| 128 | + last := s[len(s)-1] | |
| 129 | + return last == '\n' || last == ' ' || last == '\t' || last == '\r' | |
| 130 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,130 @@ | |||
| 1 | +// Tests for the spacing of the streamed text. The bug they guard: the model | ||
| 2 | +// ends its sentence with line breaks — more and more of them as the turns go by | ||
| 3 | +// — and the screen hollowed out by as much. Reproducible end to end with | ||
| 4 | +// `SCENARIO=padding python3 ../03-one-agent-one-tool/docs/fake-engine.py`. | ||
| 5 | +package engine | ||
| 6 | + | ||
| 7 | +import "testing" | ||
| 8 | + | ||
| 9 | +// next is called once per streamed chunk; we check what gets printed for each | ||
| 10 | +// of them, in order. | ||
| 11 | +func TestGapHoldsTrailingBlanks(t *testing.T) { | ||
| 12 | + cases := []struct { | ||
| 13 | + name string | ||
| 14 | + fragments []string | ||
| 15 | + want []string | ||
| 16 | + }{{ | ||
| 17 | + name: "plain text", | ||
| 18 | + fragments: []string{"Hello"}, | ||
| 19 | + want: []string{"Hello"}, | ||
| 20 | + }, { | ||
| 21 | + name: "trailing whitespace is not printed", | ||
| 22 | + fragments: []string{"Let me look:\n\n"}, | ||
| 23 | + want: []string{"Let me look:"}, | ||
| 24 | + }, { | ||
| 25 | + name: "it is printed when text follows, down to one blank line", | ||
| 26 | + fragments: []string{"para one\n\n\n\n\n", "para two"}, | ||
| 27 | + want: []string{"para one", "\n\npara two"}, | ||
| 28 | + }, { | ||
| 29 | + name: "leading whitespace is dropped before the first word", | ||
| 30 | + fragments: []string{"\n\n", "Hello"}, | ||
| 31 | + want: []string{"", "Hello"}, | ||
| 32 | + }, { | ||
| 33 | + name: "all-whitespace chunks: accumulated, then capped", | ||
| 34 | + fragments: []string{"a", "\n", "\n", "\n", "\n", "b"}, | ||
| 35 | + want: []string{"a", "", "", "", "", "\n\nb"}, | ||
| 36 | + }, { | ||
| 37 | + name: "the indentation of a code block survives", | ||
| 38 | + fragments: []string{"code:", "\n fmt.Println()"}, | ||
| 39 | + want: []string{"code:", "\n fmt.Println()"}, | ||
| 40 | + }, { | ||
| 41 | + name: "a single break between two lines stays single", | ||
| 42 | + fragments: []string{"line one\n", "line two"}, | ||
| 43 | + want: []string{"line one", "\nline two"}, | ||
| 44 | + }, { | ||
| 45 | + name: "empty chunk", | ||
| 46 | + fragments: []string{"a", "", "b"}, | ||
| 47 | + want: []string{"a", "", "b"}, | ||
| 48 | + }} | ||
| 49 | + | ||
| 50 | + for _, c := range cases { | ||
| 51 | + t.Run(c.name, func(t *testing.T) { | ||
| 52 | + var g gap | ||
| 53 | + for i, frag := range c.fragments { | ||
| 54 | + if got := g.next(frag); got != c.want[i] { | ||
| 55 | + t.Errorf("chunk %d (%q): printed %q, want %q", | ||
| 56 | + i, frag, got, c.want[i]) | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | + }) | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// drop is called when a tool is about to write: the whitespace held back | ||
| 64 | +// belonged to the text before it, and printing it after the 🛠️ line would | ||
| 65 | +// reopen the hole. | ||
| 66 | +func TestGapDropForgetsHeldBlanks(t *testing.T) { | ||
| 67 | + var g gap | ||
| 68 | + if got := g.next("Let me look:\n\n\n"); got != "Let me look:" { | ||
| 69 | + t.Fatalf("printed %q", got) | ||
| 70 | + } | ||
| 71 | + g.drop() // 🛠️ bash: … gets printed here | ||
| 72 | + if got := g.next("Now let me check:"); got != "Now let me check:" { | ||
| 73 | + t.Errorf("after drop: printed %q, want %q with no whitespace in front", | ||
| 74 | + got, "Now let me check:") | ||
| 75 | + } | ||
| 76 | +} | ||
| 77 | + | ||
| 78 | +// The full scenario of the bug: four turns whose trailing whitespace grows, a | ||
| 79 | +// tool between each. None of that whitespace may be printed. | ||
| 80 | +func TestGapPaddingScenario(t *testing.T) { | ||
| 81 | + var g gap | ||
| 82 | + turns := []string{ | ||
| 83 | + "Let me look at the file:" + nl(2), | ||
| 84 | + "Now let me check something else:" + nl(4), | ||
| 85 | + "Let me try another approach:" + nl(7), | ||
| 86 | + "Let me rebuild the program:" + nl(11), | ||
| 87 | + } | ||
| 88 | + for i, turn := range turns { | ||
| 89 | + out := g.next(turn) | ||
| 90 | + if hasBlank(out) { | ||
| 91 | + t.Errorf("turn %d: %q contains trailing whitespace", i, out) | ||
| 92 | + } | ||
| 93 | + g.drop() // the tool writes its 🛠️ line | ||
| 94 | + } | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +func TestSqueeze(t *testing.T) { | ||
| 98 | + cases := map[string]string{ | ||
| 99 | + "": "", | ||
| 100 | + "\n": "\n", | ||
| 101 | + "\n\n": "\n\n", | ||
| 102 | + "\n\n\n": "\n\n", | ||
| 103 | + "\n\n\n\n\n\n\n\n": "\n\n", | ||
| 104 | + "\n ": "\n ", // indentation preserved | ||
| 105 | + "\n\n\n ": "\n\n ", // capped, indentation kept | ||
| 106 | + "a\n\n\n\nb": "a\n\nb", // in the middle of the text too | ||
| 107 | + " ": " ", // spaces are left alone | ||
| 108 | + } | ||
| 109 | + for in, want := range cases { | ||
| 110 | + if got := squeeze(in); got != want { | ||
| 111 | + t.Errorf("squeeze(%q) = %q, want %q", in, got, want) | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +func nl(n int) string { | ||
| 117 | + s := "" | ||
| 118 | + for range n { | ||
| 119 | + s += "\n" | ||
| 120 | + } | ||
| 121 | + return s | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +func hasBlank(s string) bool { | ||
| 125 | + if s == "" { | ||
| 126 | + return false | ||
| 127 | + } | ||
| 128 | + last := s[len(s)-1] | ||
| 129 | + return last == '\n' || last == ' ' || last == '\t' || last == '\r' | ||
| 130 | +} | ||
added
internal/engine/openai_compat.go +240 -0 | new file mode 100644 | ||
| @@ -0,0 +1,240 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "net/http" | |
| 8 | + "os" | |
| 9 | + "strings" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "mm/internal/config" | |
| 13 | + | |
| 14 | + "github.com/firebase/genkit/go/ai" | |
| 15 | + "github.com/firebase/genkit/go/genkit" | |
| 16 | + "github.com/firebase/genkit/go/plugins/compat_oai" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// openaiCompat is the Provider for every server that speaks the OpenAI | |
| 20 | +// chat-completions protocol. Docker Model Runner and llama-server are two | |
| 21 | +// parametrisations of it; Ollama's /v1, vLLM and the remote APIs would be more | |
| 22 | +// entries in init() below — the point of keeping the struct data-only. | |
| 23 | +type openaiCompat struct { | |
| 24 | + name string | |
| 25 | + | |
| 26 | + baseURL string // used when the config gives none | |
| 27 | + fallback string // tried when baseURL does not answer; "" = no fallback | |
| 28 | + | |
| 29 | + // legacyBaseURLEnv keeps DMR_BASE_URL working for the `dmr` provider: the | |
| 30 | + // variable is in every README and in the sandbox start scripts. The | |
| 31 | + // provider-neutral AGENT_BASE_URL wins over it. | |
| 32 | + legacyBaseURLEnv string | |
| 33 | + | |
| 34 | + // apiKeyEnv names the variable holding the key; keyRequired says whether an | |
| 35 | + // empty one is an error (a paid API) or just means "this server does not | |
| 36 | + // check" (llama-server without --api-key). dummyKey is sent in that case | |
| 37 | + // because the OpenAI client wants SOMETHING in the header. | |
| 38 | + apiKeyEnv string | |
| 39 | + keyRequired bool | |
| 40 | + dummyKey string | |
| 41 | + | |
| 42 | + // Words for Explain: how to start the server, how to get the model. | |
| 43 | + startHint string | |
| 44 | + notFoundHint string // %s = model | |
| 45 | + | |
| 46 | + // probe is the server-specific part of Probe; nil = reachability only. | |
| 47 | + probe func(ctx context.Context, b Backend) Info | |
| 48 | +} | |
| 49 | + | |
| 50 | +func init() { | |
| 51 | + register(&openaiCompat{ | |
| 52 | + name: "dmr", | |
| 53 | + baseURL: "http://localhost:12434/engines/v1", | |
| 54 | + fallback: "http://host.docker.internal:12434/engines/v1", | |
| 55 | + legacyBaseURLEnv: "DMR_BASE_URL", | |
| 56 | + dummyKey: "not-needed", // DMR ignores the Authorization header | |
| 57 | + startHint: "is Docker Model Runner running? (docker model status)", | |
| 58 | + notFoundHint: "docker model pull %s", | |
| 59 | + probe: probeModels, | |
| 60 | + }) | |
| 61 | + register(&openaiCompat{ | |
| 62 | + name: "llamacpp", | |
| 63 | + baseURL: "http://127.0.0.1:8080/v1", // llama-server's default host:port, plus the OpenAI prefix | |
| 64 | + // llama-server only checks a key when started with --api-key; most | |
| 65 | + // local set-ups are not, so an unset LLAMA_API_KEY is normal. | |
| 66 | + apiKeyEnv: "LLAMA_API_KEY", | |
| 67 | + dummyKey: "not-needed", | |
| 68 | + startHint: "start it: llama-server -m <model.gguf> --jinja --port 8080", | |
| 69 | + notFoundHint: "llama-server serves one model; check baseUrl ends with /v1 (model: %s)", | |
| 70 | + probe: probeLlamaCpp, | |
| 71 | + }) | |
| 72 | +} | |
| 73 | + | |
| 74 | +func (p *openaiCompat) Name() string { return p.name } | |
| 75 | + | |
| 76 | +// Resolve applies the same precedence the `dmr` package used for its URL — | |
| 77 | +// environment, then config, then fallback if the first does not answer — and | |
| 78 | +// extends it to the key and the model. | |
| 79 | +func (p *openaiCompat) Resolve(cfg config.Config) (Backend, error) { | |
| 80 | + b := Backend{Provider: p.name, Model: cfg.Model, ContextWindow: cfg.ContextWindow} | |
| 81 | + | |
| 82 | + // An explicit environment override is taken as-is, without the fallback | |
| 83 | + // probe: whoever set it knows where the server is. | |
| 84 | + env := os.Getenv("AGENT_BASE_URL") | |
| 85 | + if env == "" && p.legacyBaseURLEnv != "" { | |
| 86 | + env = os.Getenv(p.legacyBaseURLEnv) | |
| 87 | + } | |
| 88 | + switch { | |
| 89 | + case env != "": | |
| 90 | + b.BaseURL = env | |
| 91 | + default: | |
| 92 | + base := cfg.BaseURL | |
| 93 | + if base == "" { | |
| 94 | + base = p.baseURL | |
| 95 | + } | |
| 96 | + // nil = the key is absent from the file, use the provider's fallback; | |
| 97 | + // an explicit "" disables it (probe.yaml does that to pin the fake | |
| 98 | + // engine). The distinction needs the pointer. | |
| 99 | + fallback := p.fallback | |
| 100 | + if cfg.Fallback != nil { | |
| 101 | + fallback = *cfg.Fallback | |
| 102 | + } | |
| 103 | + b.BaseURL = base | |
| 104 | + if fallback != "" && !reachable(base) { | |
| 105 | + b.BaseURL = fallback | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + keyEnv := cfg.APIKeyEnv | |
| 110 | + if keyEnv == "" { | |
| 111 | + keyEnv = p.apiKeyEnv | |
| 112 | + } | |
| 113 | + b.APIKeyEnv = keyEnv | |
| 114 | + if keyEnv != "" { | |
| 115 | + b.APIKey = os.Getenv(keyEnv) | |
| 116 | + } | |
| 117 | + if b.APIKey == "" { | |
| 118 | + if p.keyRequired { | |
| 119 | + return b, fmt.Errorf("%s: %s is not set (the key never goes in the YAML: export it)", p.name, keyEnv) | |
| 120 | + } | |
| 121 | + b.APIKey = p.dummyKey | |
| 122 | + } | |
| 123 | + return b, nil | |
| 124 | +} | |
| 125 | + | |
| 126 | +// Open is the former dmr.Init with the constants replaced by the Backend. | |
| 127 | +func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error) { | |
| 128 | + client := &compat_oai.OpenAICompatible{ | |
| 129 | + Provider: p.name, // model prefix: "<provider>/<id>" | |
| 130 | + BaseURL: b.BaseURL, | |
| 131 | + APIKey: b.APIKey, | |
| 132 | + } | |
| 133 | + g := genkit.Init(ctx, genkit.WithPlugins(client)) | |
| 134 | + client.DefineModel(p.name, b.Model, ai.ModelOptions{ | |
| 135 | + Label: b.Model, | |
| 136 | + Supports: &compat_oai.Multimodal, // enables Tools, Multiturn, SystemRole… | |
| 137 | + }) | |
| 138 | + return g, p.name + "/" + b.Model, nil | |
| 139 | +} | |
| 140 | + | |
| 141 | +// Probe: reachability on /models — the one endpoint every OpenAI-compatible | |
| 142 | +// server has, and the one the fallback logic already relied on — then whatever | |
| 143 | +// the server-specific probe adds. The config's context window always wins over | |
| 144 | +// the server's: the operator knows what they started it with. | |
| 145 | +func (p *openaiCompat) Probe(ctx context.Context, b Backend) Info { | |
| 146 | + var info Info | |
| 147 | + if p.probe != nil { | |
| 148 | + info = p.probe(ctx, b) | |
| 149 | + } else { | |
| 150 | + info.Reachable = reachable(b.BaseURL) | |
| 151 | + } | |
| 152 | + if !info.Reachable { | |
| 153 | + info.Warnings = append(info.Warnings, | |
| 154 | + fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint)) | |
| 155 | + } | |
| 156 | + if b.ContextWindow > 0 { | |
| 157 | + info.ContextWindow, info.ContextSource = b.ContextWindow, "config" | |
| 158 | + } | |
| 159 | + return info | |
| 160 | +} | |
| 161 | + | |
| 162 | +// probeClient: two seconds, like the old reachable(). A local server answers in | |
| 163 | +// milliseconds; a longer wait only delays the prompt when it is down. | |
| 164 | +var probeClient = &http.Client{Timeout: 2 * time.Second} | |
| 165 | + | |
| 166 | +// reachable reports whether an OpenAI-compatible endpoint answers on /models. | |
| 167 | +func reachable(baseURL string) bool { | |
| 168 | + resp, err := probeClient.Get(baseURL + "/models") | |
| 169 | + if err != nil { | |
| 170 | + return false | |
| 171 | + } | |
| 172 | + defer resp.Body.Close() | |
| 173 | + return resp.StatusCode == http.StatusOK | |
| 174 | +} | |
| 175 | + | |
| 176 | +// getJSON fetches and decodes one endpoint; false when anything failed. Probes | |
| 177 | +// do not distinguish "down" from "not that kind of server": both mean "no info". | |
| 178 | +func getJSON(url string, out any) bool { | |
| 179 | + resp, err := probeClient.Get(url) | |
| 180 | + if err != nil { | |
| 181 | + return false | |
| 182 | + } | |
| 183 | + defer resp.Body.Close() | |
| 184 | + if resp.StatusCode != http.StatusOK { | |
| 185 | + return false | |
| 186 | + } | |
| 187 | + return json.NewDecoder(resp.Body).Decode(out) == nil | |
| 188 | +} | |
| 189 | + | |
| 190 | +// probeModels (DMR): lists /models and warns when the configured model is not | |
| 191 | +// among them. Measured on DMR: asking for a model that is not pulled returns an | |
| 192 | +// error only once the request is sent — after the spinner has run a while. | |
| 193 | +func probeModels(_ context.Context, b Backend) Info { | |
| 194 | + var list struct { | |
| 195 | + Data []struct { | |
| 196 | + ID string `json:"id"` | |
| 197 | + } `json:"data"` | |
| 198 | + } | |
| 199 | + info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | |
| 200 | + if !info.Reachable || len(list.Data) == 0 { | |
| 201 | + return info | |
| 202 | + } | |
| 203 | + for _, m := range list.Data { | |
| 204 | + if m.ID == b.Model { | |
| 205 | + return info | |
| 206 | + } | |
| 207 | + } | |
| 208 | + info.Warnings = append(info.Warnings, | |
| 209 | + fmt.Sprintf("%s: model %q is not in the server's list — docker model pull %s", b.Provider, b.Model, b.Model)) | |
| 210 | + return info | |
| 211 | +} | |
| 212 | + | |
| 213 | +// probeLlamaCpp: llama-server exposes GET /props, one level above /v1, with the | |
| 214 | +// context size it actually serves (`default_generation_settings.n_ctx`) — not | |
| 215 | +// the one the model was trained with, which is what /v1/models reports. The | |
| 216 | +// served size is the number the context-compression work needs. | |
| 217 | +func probeLlamaCpp(_ context.Context, b Backend) Info { | |
| 218 | + var props struct { | |
| 219 | + Settings struct { | |
| 220 | + NCtx int `json:"n_ctx"` | |
| 221 | + } `json:"default_generation_settings"` | |
| 222 | + ChatTemplate string `json:"chat_template"` | |
| 223 | + } | |
| 224 | + root := strings.TrimSuffix(strings.TrimSuffix(b.BaseURL, "/"), "/v1") | |
| 225 | + info := Info{Reachable: getJSON(root+"/props", &props)} | |
| 226 | + if !info.Reachable { | |
| 227 | + // /props missing but /models answering: some other OpenAI-compatible | |
| 228 | + // server is on that port. Reachable, but nothing llama.cpp to report. | |
| 229 | + info.Reachable = reachable(b.BaseURL) | |
| 230 | + return info | |
| 231 | + } | |
| 232 | + if props.Settings.NCtx > 0 { | |
| 233 | + info.ContextWindow, info.ContextSource = props.Settings.NCtx, "/props" | |
| 234 | + } | |
| 235 | + if props.ChatTemplate == "" { | |
| 236 | + info.Warnings = append(info.Warnings, | |
| 237 | + "llamacpp: the server reports no chat template; tool calls need one (and --jinja)") | |
| 238 | + } | |
| 239 | + return info | |
| 240 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,240 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "net/http" | ||
| 8 | + "os" | ||
| 9 | + "strings" | ||
| 10 | + "time" | ||
| 11 | + | ||
| 12 | + "mm/internal/config" | ||
| 13 | + | ||
| 14 | + "github.com/firebase/genkit/go/ai" | ||
| 15 | + "github.com/firebase/genkit/go/genkit" | ||
| 16 | + "github.com/firebase/genkit/go/plugins/compat_oai" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// openaiCompat is the Provider for every server that speaks the OpenAI | ||
| 20 | +// chat-completions protocol. Docker Model Runner and llama-server are two | ||
| 21 | +// parametrisations of it; Ollama's /v1, vLLM and the remote APIs would be more | ||
| 22 | +// entries in init() below — the point of keeping the struct data-only. | ||
| 23 | +type openaiCompat struct { | ||
| 24 | + name string | ||
| 25 | + | ||
| 26 | + baseURL string // used when the config gives none | ||
| 27 | + fallback string // tried when baseURL does not answer; "" = no fallback | ||
| 28 | + | ||
| 29 | + // legacyBaseURLEnv keeps DMR_BASE_URL working for the `dmr` provider: the | ||
| 30 | + // variable is in every README and in the sandbox start scripts. The | ||
| 31 | + // provider-neutral AGENT_BASE_URL wins over it. | ||
| 32 | + legacyBaseURLEnv string | ||
| 33 | + | ||
| 34 | + // apiKeyEnv names the variable holding the key; keyRequired says whether an | ||
| 35 | + // empty one is an error (a paid API) or just means "this server does not | ||
| 36 | + // check" (llama-server without --api-key). dummyKey is sent in that case | ||
| 37 | + // because the OpenAI client wants SOMETHING in the header. | ||
| 38 | + apiKeyEnv string | ||
| 39 | + keyRequired bool | ||
| 40 | + dummyKey string | ||
| 41 | + | ||
| 42 | + // Words for Explain: how to start the server, how to get the model. | ||
| 43 | + startHint string | ||
| 44 | + notFoundHint string // %s = model | ||
| 45 | + | ||
| 46 | + // probe is the server-specific part of Probe; nil = reachability only. | ||
| 47 | + probe func(ctx context.Context, b Backend) Info | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +func init() { | ||
| 51 | + register(&openaiCompat{ | ||
| 52 | + name: "dmr", | ||
| 53 | + baseURL: "http://localhost:12434/engines/v1", | ||
| 54 | + fallback: "http://host.docker.internal:12434/engines/v1", | ||
| 55 | + legacyBaseURLEnv: "DMR_BASE_URL", | ||
| 56 | + dummyKey: "not-needed", // DMR ignores the Authorization header | ||
| 57 | + startHint: "is Docker Model Runner running? (docker model status)", | ||
| 58 | + notFoundHint: "docker model pull %s", | ||
| 59 | + probe: probeModels, | ||
| 60 | + }) | ||
| 61 | + register(&openaiCompat{ | ||
| 62 | + name: "llamacpp", | ||
| 63 | + baseURL: "http://127.0.0.1:8080/v1", // llama-server's default host:port, plus the OpenAI prefix | ||
| 64 | + // llama-server only checks a key when started with --api-key; most | ||
| 65 | + // local set-ups are not, so an unset LLAMA_API_KEY is normal. | ||
| 66 | + apiKeyEnv: "LLAMA_API_KEY", | ||
| 67 | + dummyKey: "not-needed", | ||
| 68 | + startHint: "start it: llama-server -m <model.gguf> --jinja --port 8080", | ||
| 69 | + notFoundHint: "llama-server serves one model; check baseUrl ends with /v1 (model: %s)", | ||
| 70 | + probe: probeLlamaCpp, | ||
| 71 | + }) | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +func (p *openaiCompat) Name() string { return p.name } | ||
| 75 | + | ||
| 76 | +// Resolve applies the same precedence the `dmr` package used for its URL — | ||
| 77 | +// environment, then config, then fallback if the first does not answer — and | ||
| 78 | +// extends it to the key and the model. | ||
| 79 | +func (p *openaiCompat) Resolve(cfg config.Config) (Backend, error) { | ||
| 80 | + b := Backend{Provider: p.name, Model: cfg.Model, ContextWindow: cfg.ContextWindow} | ||
| 81 | + | ||
| 82 | + // An explicit environment override is taken as-is, without the fallback | ||
| 83 | + // probe: whoever set it knows where the server is. | ||
| 84 | + env := os.Getenv("AGENT_BASE_URL") | ||
| 85 | + if env == "" && p.legacyBaseURLEnv != "" { | ||
| 86 | + env = os.Getenv(p.legacyBaseURLEnv) | ||
| 87 | + } | ||
| 88 | + switch { | ||
| 89 | + case env != "": | ||
| 90 | + b.BaseURL = env | ||
| 91 | + default: | ||
| 92 | + base := cfg.BaseURL | ||
| 93 | + if base == "" { | ||
| 94 | + base = p.baseURL | ||
| 95 | + } | ||
| 96 | + // nil = the key is absent from the file, use the provider's fallback; | ||
| 97 | + // an explicit "" disables it (probe.yaml does that to pin the fake | ||
| 98 | + // engine). The distinction needs the pointer. | ||
| 99 | + fallback := p.fallback | ||
| 100 | + if cfg.Fallback != nil { | ||
| 101 | + fallback = *cfg.Fallback | ||
| 102 | + } | ||
| 103 | + b.BaseURL = base | ||
| 104 | + if fallback != "" && !reachable(base) { | ||
| 105 | + b.BaseURL = fallback | ||
| 106 | + } | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + keyEnv := cfg.APIKeyEnv | ||
| 110 | + if keyEnv == "" { | ||
| 111 | + keyEnv = p.apiKeyEnv | ||
| 112 | + } | ||
| 113 | + b.APIKeyEnv = keyEnv | ||
| 114 | + if keyEnv != "" { | ||
| 115 | + b.APIKey = os.Getenv(keyEnv) | ||
| 116 | + } | ||
| 117 | + if b.APIKey == "" { | ||
| 118 | + if p.keyRequired { | ||
| 119 | + return b, fmt.Errorf("%s: %s is not set (the key never goes in the YAML: export it)", p.name, keyEnv) | ||
| 120 | + } | ||
| 121 | + b.APIKey = p.dummyKey | ||
| 122 | + } | ||
| 123 | + return b, nil | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +// Open is the former dmr.Init with the constants replaced by the Backend. | ||
| 127 | +func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error) { | ||
| 128 | + client := &compat_oai.OpenAICompatible{ | ||
| 129 | + Provider: p.name, // model prefix: "<provider>/<id>" | ||
| 130 | + BaseURL: b.BaseURL, | ||
| 131 | + APIKey: b.APIKey, | ||
| 132 | + } | ||
| 133 | + g := genkit.Init(ctx, genkit.WithPlugins(client)) | ||
| 134 | + client.DefineModel(p.name, b.Model, ai.ModelOptions{ | ||
| 135 | + Label: b.Model, | ||
| 136 | + Supports: &compat_oai.Multimodal, // enables Tools, Multiturn, SystemRole… | ||
| 137 | + }) | ||
| 138 | + return g, p.name + "/" + b.Model, nil | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +// Probe: reachability on /models — the one endpoint every OpenAI-compatible | ||
| 142 | +// server has, and the one the fallback logic already relied on — then whatever | ||
| 143 | +// the server-specific probe adds. The config's context window always wins over | ||
| 144 | +// the server's: the operator knows what they started it with. | ||
| 145 | +func (p *openaiCompat) Probe(ctx context.Context, b Backend) Info { | ||
| 146 | + var info Info | ||
| 147 | + if p.probe != nil { | ||
| 148 | + info = p.probe(ctx, b) | ||
| 149 | + } else { | ||
| 150 | + info.Reachable = reachable(b.BaseURL) | ||
| 151 | + } | ||
| 152 | + if !info.Reachable { | ||
| 153 | + info.Warnings = append(info.Warnings, | ||
| 154 | + fmt.Sprintf("%s: nothing answers at %s — %s", p.name, b.BaseURL, p.startHint)) | ||
| 155 | + } | ||
| 156 | + if b.ContextWindow > 0 { | ||
| 157 | + info.ContextWindow, info.ContextSource = b.ContextWindow, "config" | ||
| 158 | + } | ||
| 159 | + return info | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +// probeClient: two seconds, like the old reachable(). A local server answers in | ||
| 163 | +// milliseconds; a longer wait only delays the prompt when it is down. | ||
| 164 | +var probeClient = &http.Client{Timeout: 2 * time.Second} | ||
| 165 | + | ||
| 166 | +// reachable reports whether an OpenAI-compatible endpoint answers on /models. | ||
| 167 | +func reachable(baseURL string) bool { | ||
| 168 | + resp, err := probeClient.Get(baseURL + "/models") | ||
| 169 | + if err != nil { | ||
| 170 | + return false | ||
| 171 | + } | ||
| 172 | + defer resp.Body.Close() | ||
| 173 | + return resp.StatusCode == http.StatusOK | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +// getJSON fetches and decodes one endpoint; false when anything failed. Probes | ||
| 177 | +// do not distinguish "down" from "not that kind of server": both mean "no info". | ||
| 178 | +func getJSON(url string, out any) bool { | ||
| 179 | + resp, err := probeClient.Get(url) | ||
| 180 | + if err != nil { | ||
| 181 | + return false | ||
| 182 | + } | ||
| 183 | + defer resp.Body.Close() | ||
| 184 | + if resp.StatusCode != http.StatusOK { | ||
| 185 | + return false | ||
| 186 | + } | ||
| 187 | + return json.NewDecoder(resp.Body).Decode(out) == nil | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +// probeModels (DMR): lists /models and warns when the configured model is not | ||
| 191 | +// among them. Measured on DMR: asking for a model that is not pulled returns an | ||
| 192 | +// error only once the request is sent — after the spinner has run a while. | ||
| 193 | +func probeModels(_ context.Context, b Backend) Info { | ||
| 194 | + var list struct { | ||
| 195 | + Data []struct { | ||
| 196 | + ID string `json:"id"` | ||
| 197 | + } `json:"data"` | ||
| 198 | + } | ||
| 199 | + info := Info{Reachable: getJSON(b.BaseURL+"/models", &list)} | ||
| 200 | + if !info.Reachable || len(list.Data) == 0 { | ||
| 201 | + return info | ||
| 202 | + } | ||
| 203 | + for _, m := range list.Data { | ||
| 204 | + if m.ID == b.Model { | ||
| 205 | + return info | ||
| 206 | + } | ||
| 207 | + } | ||
| 208 | + info.Warnings = append(info.Warnings, | ||
| 209 | + fmt.Sprintf("%s: model %q is not in the server's list — docker model pull %s", b.Provider, b.Model, b.Model)) | ||
| 210 | + return info | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +// probeLlamaCpp: llama-server exposes GET /props, one level above /v1, with the | ||
| 214 | +// context size it actually serves (`default_generation_settings.n_ctx`) — not | ||
| 215 | +// the one the model was trained with, which is what /v1/models reports. The | ||
| 216 | +// served size is the number the context-compression work needs. | ||
| 217 | +func probeLlamaCpp(_ context.Context, b Backend) Info { | ||
| 218 | + var props struct { | ||
| 219 | + Settings struct { | ||
| 220 | + NCtx int `json:"n_ctx"` | ||
| 221 | + } `json:"default_generation_settings"` | ||
| 222 | + ChatTemplate string `json:"chat_template"` | ||
| 223 | + } | ||
| 224 | + root := strings.TrimSuffix(strings.TrimSuffix(b.BaseURL, "/"), "/v1") | ||
| 225 | + info := Info{Reachable: getJSON(root+"/props", &props)} | ||
| 226 | + if !info.Reachable { | ||
| 227 | + // /props missing but /models answering: some other OpenAI-compatible | ||
| 228 | + // server is on that port. Reachable, but nothing llama.cpp to report. | ||
| 229 | + info.Reachable = reachable(b.BaseURL) | ||
| 230 | + return info | ||
| 231 | + } | ||
| 232 | + if props.Settings.NCtx > 0 { | ||
| 233 | + info.ContextWindow, info.ContextSource = props.Settings.NCtx, "/props" | ||
| 234 | + } | ||
| 235 | + if props.ChatTemplate == "" { | ||
| 236 | + info.Warnings = append(info.Warnings, | ||
| 237 | + "llamacpp: the server reports no chat template; tool calls need one (and --jinja)") | ||
| 238 | + } | ||
| 239 | + return info | ||
| 240 | +} | ||
added
internal/engine/provider.go +97 -0 | new file mode 100644 | ||
| @@ -0,0 +1,97 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "sort" | |
| 7 | + "strings" | |
| 8 | + | |
| 9 | + "mm/internal/config" | |
| 10 | + | |
| 11 | + "github.com/firebase/genkit/go/genkit" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// Provider is what changes from one LLM server to the next. There is ONE | |
| 15 | +// implementation per wire protocol, not one per vendor: Docker Model Runner and | |
| 16 | +// llama-server both speak the OpenAI chat-completions format, and differ only | |
| 17 | +// in a base URL, a key policy and the words of their error messages. Adding a | |
| 18 | +// server that speaks the same protocol is a registry entry (see openai_compat.go); | |
| 19 | +// adding a protocol (Anthropic's Messages API, Ollama's /api/chat) is a new type. | |
| 20 | +type Provider interface { | |
| 21 | + // Name is the registry key AND the Genkit plugin name, hence the prefix of | |
| 22 | + // the model reference Generate uses ("dmr/<id>"). | |
| 23 | + Name() string | |
| 24 | + | |
| 25 | + // Resolve turns the loaded config into a concrete Backend: the URL after | |
| 26 | + // environment overrides and the fallback probe, the key read from the | |
| 27 | + // environment, the model in the provider's own naming. | |
| 28 | + Resolve(cfg config.Config) (Backend, error) | |
| 29 | + | |
| 30 | + // Open initialises Genkit with this provider's plugin, declares the model | |
| 31 | + // (stating that it knows how to call tools) and returns the full model | |
| 32 | + // reference. One Genkit per Engine for now; several backends in one Genkit | |
| 33 | + // is the seam for a `/model` command later. | |
| 34 | + Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error) | |
| 35 | + | |
| 36 | + // Probe learns what it can about the server before the first question. | |
| 37 | + // Best effort, never fatal: a server started AFTER the agent is a normal | |
| 38 | + // demo situation, not an error. | |
| 39 | + Probe(ctx context.Context, b Backend) Info | |
| 40 | + | |
| 41 | + // Explain rewrites a transport error as ONE actionable line, in the words of | |
| 42 | + // this server: "start llama-server with --jinja" means nothing to a DMR | |
| 43 | + // user, and "docker model pull" nothing to a llama.cpp user. | |
| 44 | + Explain(err error, b Backend) string | |
| 45 | +} | |
| 46 | + | |
| 47 | +// Backend is a resolved target: everything Open and Explain need, and nothing | |
| 48 | +// that still depends on the environment. | |
| 49 | +type Backend struct { | |
| 50 | + Provider string | |
| 51 | + BaseURL string | |
| 52 | + Model string // in the provider's naming, no Genkit prefix | |
| 53 | + APIKey string // the resolved value; a placeholder when the server ignores it | |
| 54 | + // APIKeyEnv is the variable the key was read from — the yaml's `apiKeyEnv`, | |
| 55 | + // else the provider's default — or "" when neither names one. Explain cites | |
| 56 | + // it on a 401: the first version named a variable nothing read, and a | |
| 57 | + // message that sends the user to `export` the wrong name is worse than none. | |
| 58 | + APIKeyEnv string | |
| 59 | + // ContextWindow is the hint from the config, 0 when absent. The probe may | |
| 60 | + // fill it from the server (llama-server tells its n_ctx); the config wins, | |
| 61 | + // because the operator knows what they started the server with. | |
| 62 | + ContextWindow int | |
| 63 | +} | |
| 64 | + | |
| 65 | +// Info is what Probe found out. Warnings are printed one per line at start-up, | |
| 66 | +// dimmed like the config line; they are advice, not failures. | |
| 67 | +type Info struct { | |
| 68 | + Reachable bool | |
| 69 | + ContextWindow int // 0 = unknown | |
| 70 | + ContextSource string // "config", "/props", … — a number without an origin never gets corrected | |
| 71 | + Warnings []string | |
| 72 | +} | |
| 73 | + | |
| 74 | +// registry: the providers this build knows. Keys are the values `provider:` | |
| 75 | +// accepts in agent.yaml. | |
| 76 | +var registry = map[string]Provider{} | |
| 77 | + | |
| 78 | +func register(p Provider) { registry[p.Name()] = p } | |
| 79 | + | |
| 80 | +// Lookup finds a provider by its config name. The error lists the known names: | |
| 81 | +// the user typed one, the fix is to pick another. | |
| 82 | +func Lookup(name string) (Provider, error) { | |
| 83 | + if p, ok := registry[name]; ok { | |
| 84 | + return p, nil | |
| 85 | + } | |
| 86 | + return nil, fmt.Errorf("unknown provider %q (known: %s)", name, strings.Join(Names(), ", ")) | |
| 87 | +} | |
| 88 | + | |
| 89 | +// Names lists the registered providers, sorted for stable messages. | |
| 90 | +func Names() []string { | |
| 91 | + names := make([]string, 0, len(registry)) | |
| 92 | + for n := range registry { | |
| 93 | + names = append(names, n) | |
| 94 | + } | |
| 95 | + sort.Strings(names) | |
| 96 | + return names | |
| 97 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,97 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "sort" | ||
| 7 | + "strings" | ||
| 8 | + | ||
| 9 | + "mm/internal/config" | ||
| 10 | + | ||
| 11 | + "github.com/firebase/genkit/go/genkit" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// Provider is what changes from one LLM server to the next. There is ONE | ||
| 15 | +// implementation per wire protocol, not one per vendor: Docker Model Runner and | ||
| 16 | +// llama-server both speak the OpenAI chat-completions format, and differ only | ||
| 17 | +// in a base URL, a key policy and the words of their error messages. Adding a | ||
| 18 | +// server that speaks the same protocol is a registry entry (see openai_compat.go); | ||
| 19 | +// adding a protocol (Anthropic's Messages API, Ollama's /api/chat) is a new type. | ||
| 20 | +type Provider interface { | ||
| 21 | + // Name is the registry key AND the Genkit plugin name, hence the prefix of | ||
| 22 | + // the model reference Generate uses ("dmr/<id>"). | ||
| 23 | + Name() string | ||
| 24 | + | ||
| 25 | + // Resolve turns the loaded config into a concrete Backend: the URL after | ||
| 26 | + // environment overrides and the fallback probe, the key read from the | ||
| 27 | + // environment, the model in the provider's own naming. | ||
| 28 | + Resolve(cfg config.Config) (Backend, error) | ||
| 29 | + | ||
| 30 | + // Open initialises Genkit with this provider's plugin, declares the model | ||
| 31 | + // (stating that it knows how to call tools) and returns the full model | ||
| 32 | + // reference. One Genkit per Engine for now; several backends in one Genkit | ||
| 33 | + // is the seam for a `/model` command later. | ||
| 34 | + Open(ctx context.Context, b Backend) (*genkit.Genkit, string, error) | ||
| 35 | + | ||
| 36 | + // Probe learns what it can about the server before the first question. | ||
| 37 | + // Best effort, never fatal: a server started AFTER the agent is a normal | ||
| 38 | + // demo situation, not an error. | ||
| 39 | + Probe(ctx context.Context, b Backend) Info | ||
| 40 | + | ||
| 41 | + // Explain rewrites a transport error as ONE actionable line, in the words of | ||
| 42 | + // this server: "start llama-server with --jinja" means nothing to a DMR | ||
| 43 | + // user, and "docker model pull" nothing to a llama.cpp user. | ||
| 44 | + Explain(err error, b Backend) string | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +// Backend is a resolved target: everything Open and Explain need, and nothing | ||
| 48 | +// that still depends on the environment. | ||
| 49 | +type Backend struct { | ||
| 50 | + Provider string | ||
| 51 | + BaseURL string | ||
| 52 | + Model string // in the provider's naming, no Genkit prefix | ||
| 53 | + APIKey string // the resolved value; a placeholder when the server ignores it | ||
| 54 | + // APIKeyEnv is the variable the key was read from — the yaml's `apiKeyEnv`, | ||
| 55 | + // else the provider's default — or "" when neither names one. Explain cites | ||
| 56 | + // it on a 401: the first version named a variable nothing read, and a | ||
| 57 | + // message that sends the user to `export` the wrong name is worse than none. | ||
| 58 | + APIKeyEnv string | ||
| 59 | + // ContextWindow is the hint from the config, 0 when absent. The probe may | ||
| 60 | + // fill it from the server (llama-server tells its n_ctx); the config wins, | ||
| 61 | + // because the operator knows what they started the server with. | ||
| 62 | + ContextWindow int | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +// Info is what Probe found out. Warnings are printed one per line at start-up, | ||
| 66 | +// dimmed like the config line; they are advice, not failures. | ||
| 67 | +type Info struct { | ||
| 68 | + Reachable bool | ||
| 69 | + ContextWindow int // 0 = unknown | ||
| 70 | + ContextSource string // "config", "/props", … — a number without an origin never gets corrected | ||
| 71 | + Warnings []string | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +// registry: the providers this build knows. Keys are the values `provider:` | ||
| 75 | +// accepts in agent.yaml. | ||
| 76 | +var registry = map[string]Provider{} | ||
| 77 | + | ||
| 78 | +func register(p Provider) { registry[p.Name()] = p } | ||
| 79 | + | ||
| 80 | +// Lookup finds a provider by its config name. The error lists the known names: | ||
| 81 | +// the user typed one, the fix is to pick another. | ||
| 82 | +func Lookup(name string) (Provider, error) { | ||
| 83 | + if p, ok := registry[name]; ok { | ||
| 84 | + return p, nil | ||
| 85 | + } | ||
| 86 | + return nil, fmt.Errorf("unknown provider %q (known: %s)", name, strings.Join(Names(), ", ")) | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +// Names lists the registered providers, sorted for stable messages. | ||
| 90 | +func Names() []string { | ||
| 91 | + names := make([]string, 0, len(registry)) | ||
| 92 | + for n := range registry { | ||
| 93 | + names = append(names, n) | ||
| 94 | + } | ||
| 95 | + sort.Strings(names) | ||
| 96 | + return names | ||
| 97 | +} | ||
added
internal/engine/provider_test.go +450 -0 | new file mode 100644 | ||
| @@ -0,0 +1,450 @@ | ||
| 1 | +// Tests for the provider layer, against httptest servers: the fake engine of | |
| 2 | +// the backup docs speaks plain OpenAI and cannot say "I am llama-server", nor | |
| 3 | +// fail on demand. What these tests guard: the URL precedence the `dmr` package | |
| 4 | +// had (env, then file, then fallback when the first does not answer), the | |
| 5 | +// llama.cpp probe, and the one-line error mapping — each line of the table in | |
| 6 | +// PROVIDERS.md §5.7 that this step implements. | |
| 7 | +package engine | |
| 8 | + | |
| 9 | +import ( | |
| 10 | + "context" | |
| 11 | + "encoding/json" | |
| 12 | + "net/http" | |
| 13 | + "net/http/httptest" | |
| 14 | + "os" | |
| 15 | + "path/filepath" | |
| 16 | + "strings" | |
| 17 | + "testing" | |
| 18 | + | |
| 19 | + "mm/internal/config" | |
| 20 | + | |
| 21 | + "github.com/firebase/genkit/go/ai" | |
| 22 | +) | |
| 23 | + | |
| 24 | +// openaiServer is a minimal OpenAI-compatible server: /models lists `models`, | |
| 25 | +// every POST answers `status` with `body`. Enough to drive Resolve, Probe and | |
| 26 | +// Explain; Generate's happy path is covered by engine_test.go's fake model. | |
| 27 | +func openaiServer(t *testing.T, status int, body string, models ...string) *httptest.Server { | |
| 28 | + t.Helper() | |
| 29 | + mux := http.NewServeMux() | |
| 30 | + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | |
| 31 | + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") { | |
| 32 | + var data []map[string]string | |
| 33 | + for _, m := range models { | |
| 34 | + data = append(data, map[string]string{"id": m, "object": "model"}) | |
| 35 | + } | |
| 36 | + _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data}) | |
| 37 | + return | |
| 38 | + } | |
| 39 | + if r.Method == http.MethodPost { | |
| 40 | + w.Header().Set("Content-Type", "application/json") | |
| 41 | + w.WriteHeader(status) | |
| 42 | + _, _ = w.Write([]byte(body)) | |
| 43 | + return | |
| 44 | + } | |
| 45 | + http.NotFound(w, r) | |
| 46 | + }) | |
| 47 | + srv := httptest.NewServer(mux) | |
| 48 | + t.Cleanup(srv.Close) | |
| 49 | + return srv | |
| 50 | +} | |
| 51 | + | |
| 52 | +// closedURL returns a URL nothing listens on: a server started and stopped, so | |
| 53 | +// the port was free a moment ago. | |
| 54 | +func closedURL(t *testing.T) string { | |
| 55 | + t.Helper() | |
| 56 | + srv := httptest.NewServer(http.NotFoundHandler()) | |
| 57 | + url := srv.URL | |
| 58 | + srv.Close() | |
| 59 | + return url | |
| 60 | +} | |
| 61 | + | |
| 62 | +func ptr(s string) *string { return &s } | |
| 63 | + | |
| 64 | +// clearEnv: the precedence tests must not depend on the developer's shell, | |
| 65 | +// where DMR_BASE_URL is often exported. | |
| 66 | +func clearEnv(t *testing.T) { | |
| 67 | + t.Helper() | |
| 68 | + for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} { | |
| 69 | + t.Setenv(k, "") | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +func TestLookupListsKnownProviders(t *testing.T) { | |
| 74 | + _, err := Lookup("ollama") | |
| 75 | + if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp") { | |
| 76 | + t.Fatalf("Lookup(ollama) = %v, want an error naming the known providers", err) | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +// TestResolveURLPrecedence replays the rules of the former config.DMRBaseURL — | |
| 81 | +// env wins without probing, the fallback is taken only when baseUrl does not | |
| 82 | +// answer — and checks that llamacpp neither inherits DMR's URL nor its env var. | |
| 83 | +func TestResolveURLPrecedence(t *testing.T) { | |
| 84 | + clearEnv(t) | |
| 85 | + up := openaiServer(t, 200, "{}", "fake-model") | |
| 86 | + down := closedURL(t) | |
| 87 | + | |
| 88 | + cases := []struct { | |
| 89 | + name string | |
| 90 | + provider string | |
| 91 | + cfg config.Config | |
| 92 | + env map[string]string | |
| 93 | + wantURL string | |
| 94 | + wantKey string | |
| 95 | + }{ | |
| 96 | + {"dmr keeps a reachable baseUrl", "dmr", config.Config{BaseURL: up.URL}, nil, up.URL, "not-needed"}, | |
| 97 | + {"dmr falls back when baseUrl is down", "dmr", config.Config{BaseURL: down, Fallback: ptr(up.URL)}, nil, up.URL, "not-needed"}, | |
| 98 | + {"an explicit empty fallback disables it", "dmr", config.Config{BaseURL: down, Fallback: ptr("")}, nil, down, "not-needed"}, | |
| 99 | + {"AGENT_BASE_URL wins without probing", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"AGENT_BASE_URL": down}, down, "not-needed"}, | |
| 100 | + {"DMR_BASE_URL still works for dmr", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"DMR_BASE_URL": down}, down, "not-needed"}, | |
| 101 | + {"llamacpp default is llama-server's port", "llamacpp", config.Config{}, nil, "http://127.0.0.1:8080/v1", "not-needed"}, | |
| 102 | + {"llamacpp ignores DMR_BASE_URL", "llamacpp", config.Config{}, map[string]string{"DMR_BASE_URL": down}, "http://127.0.0.1:8080/v1", "not-needed"}, | |
| 103 | + {"llamacpp reads LLAMA_API_KEY when set", "llamacpp", config.Config{}, map[string]string{"LLAMA_API_KEY": "secret"}, "http://127.0.0.1:8080/v1", "secret"}, | |
| 104 | + {"apiKeyEnv names another variable", "llamacpp", config.Config{APIKeyEnv: "MY_KEY"}, map[string]string{"MY_KEY": "k2"}, "http://127.0.0.1:8080/v1", "k2"}, | |
| 105 | + } | |
| 106 | + for _, tc := range cases { | |
| 107 | + t.Run(tc.name, func(t *testing.T) { | |
| 108 | + clearEnv(t) | |
| 109 | + for k, v := range tc.env { | |
| 110 | + t.Setenv(k, v) | |
| 111 | + } | |
| 112 | + p, _ := Lookup(tc.provider) | |
| 113 | + tc.cfg.Model = "fake-model" | |
| 114 | + b, err := p.Resolve(tc.cfg) | |
| 115 | + if err != nil { | |
| 116 | + t.Fatalf("Resolve: %v", err) | |
| 117 | + } | |
| 118 | + if b.BaseURL != tc.wantURL { | |
| 119 | + t.Errorf("BaseURL = %q, want %q", b.BaseURL, tc.wantURL) | |
| 120 | + } | |
| 121 | + if b.APIKey != tc.wantKey { | |
| 122 | + t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey) | |
| 123 | + } | |
| 124 | + }) | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +// TestProbeLlamaCpp: /props sits one level above /v1 and carries the SERVED | |
| 129 | +// context size; the config's value, when given, wins over it. | |
| 130 | +func TestProbeLlamaCpp(t *testing.T) { | |
| 131 | + mux := http.NewServeMux() | |
| 132 | + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { | |
| 133 | + _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`)) | |
| 134 | + }) | |
| 135 | + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) { | |
| 136 | + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"qwen2.5-coder","object":"model"}]}`)) | |
| 137 | + }) | |
| 138 | + srv := httptest.NewServer(mux) | |
| 139 | + defer srv.Close() | |
| 140 | + | |
| 141 | + p, _ := Lookup("llamacpp") | |
| 142 | + info := p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder"}) | |
| 143 | + if !info.Reachable || info.ContextWindow != 32768 || info.ContextSource != "/props" { | |
| 144 | + t.Errorf("Probe = %+v, want reachable, 32768 from /props", info) | |
| 145 | + } | |
| 146 | + if len(info.Warnings) != 0 { | |
| 147 | + t.Errorf("unexpected warnings: %q", info.Warnings) | |
| 148 | + } | |
| 149 | + | |
| 150 | + info = p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder", ContextWindow: 4096}) | |
| 151 | + if info.ContextWindow != 4096 || info.ContextSource != "config" { | |
| 152 | + t.Errorf("with a config hint: %+v, want 4096 from config", info) | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +// TestEnsureContextWindowReprobes: the start-up probe runs once, and a | |
| 157 | +// llama-server started AFTER the agent left `ctx: unknown` for the whole | |
| 158 | +// session. The lazy re-probe must turn 0 into n_ctx once /props answers — and | |
| 159 | +// must stop asking as soon as the window is known, or when the config gave | |
| 160 | +// it: the hit counter on /props is the measure. | |
| 161 | +func TestEnsureContextWindowReprobes(t *testing.T) { | |
| 162 | + var propsHits int | |
| 163 | + up := false // false = llama-server not ready yet: /props answers 503 | |
| 164 | + mux := http.NewServeMux() | |
| 165 | + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { | |
| 166 | + propsHits++ | |
| 167 | + if !up { | |
| 168 | + w.WriteHeader(http.StatusServiceUnavailable) | |
| 169 | + return | |
| 170 | + } | |
| 171 | + _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`)) | |
| 172 | + }) | |
| 173 | + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) { | |
| 174 | + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m","object":"model"}]}`)) | |
| 175 | + }) | |
| 176 | + srv := httptest.NewServer(mux) | |
| 177 | + defer srv.Close() | |
| 178 | + | |
| 179 | + p, _ := Lookup("llamacpp") | |
| 180 | + e := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m"}} | |
| 181 | + | |
| 182 | + // Server not ready: still unknown, nothing learned, but the probe was made. | |
| 183 | + if w, src := e.EnsureContextWindow(context.Background()); w != 0 || src != "" || e.ContextWindow != 0 { | |
| 184 | + t.Errorf("before the server is up: got (%d, %q), ContextWindow=%d, want (0, \"\"), 0", w, src, e.ContextWindow) | |
| 185 | + } | |
| 186 | + if propsHits != 1 { | |
| 187 | + t.Fatalf("/props hits = %d after the first re-probe, want 1", propsHits) | |
| 188 | + } | |
| 189 | + | |
| 190 | + // Server up: the window is learned, remembered, and its origin is named. | |
| 191 | + up = true | |
| 192 | + if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "/props" || e.ContextWindow != 32768 { | |
| 193 | + t.Errorf("after the server is up: got (%d, %q), ContextWindow=%d, want (32768, \"/props\"), 32768", w, src, e.ContextWindow) | |
| 194 | + } | |
| 195 | + if propsHits != 2 { | |
| 196 | + t.Fatalf("/props hits = %d after the second re-probe, want 2", propsHits) | |
| 197 | + } | |
| 198 | + | |
| 199 | + // Known window: answered from memory, no request, no origin to announce. | |
| 200 | + if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "" { | |
| 201 | + t.Errorf("known window: got (%d, %q), want (32768, \"\")", w, src) | |
| 202 | + } | |
| 203 | + if propsHits != 2 { | |
| 204 | + t.Errorf("/props hits = %d after asking with a known window, want still 2", propsHits) | |
| 205 | + } | |
| 206 | + | |
| 207 | + // A window from the config is known from the start: never probed at all. | |
| 208 | + propsHits = 0 | |
| 209 | + known := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m", ContextWindow: 4096}, ContextWindow: 4096} | |
| 210 | + if w, src := known.EnsureContextWindow(context.Background()); w != 4096 || src != "" || propsHits != 0 { | |
| 211 | + t.Errorf("config window: got (%d, %q) with %d /props hit(s), want (4096, \"\") and 0", w, src, propsHits) | |
| 212 | + } | |
| 213 | +} | |
| 214 | + | |
| 215 | +// TestProbeUnreachableWarns: a server that is down is a warning with the start | |
| 216 | +// command, not a failure — the demo often starts the server second. | |
| 217 | +func TestProbeUnreachableWarns(t *testing.T) { | |
| 218 | + for _, name := range []string{"dmr", "llamacpp"} { | |
| 219 | + p, _ := Lookup(name) | |
| 220 | + info := p.Probe(context.Background(), Backend{Provider: name, BaseURL: closedURL(t) + "/v1", Model: "m"}) | |
| 221 | + if info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "nothing answers") { | |
| 222 | + t.Errorf("%s: Probe on a closed port = %+v", name, info) | |
| 223 | + } | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +// TestProbeModelsWarnsOnMissingModel: DMR lists what is pulled; asking for | |
| 228 | +// something else fails only once the request is sent, after a long spinner. | |
| 229 | +func TestProbeModelsWarnsOnMissingModel(t *testing.T) { | |
| 230 | + srv := openaiServer(t, 200, "{}", "ai/other") | |
| 231 | + p, _ := Lookup("dmr") | |
| 232 | + info := p.Probe(context.Background(), Backend{Provider: "dmr", BaseURL: srv.URL, Model: "ai/qwen2.5-coder"}) | |
| 233 | + if !info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "docker model pull ai/qwen2.5-coder") { | |
| 234 | + t.Errorf("Probe = %+v, want the pull hint", info) | |
| 235 | + } | |
| 236 | +} | |
| 237 | + | |
| 238 | +// TestExplainThroughTheStack drives a real Open + Generate against servers that | |
| 239 | +// fail on purpose, and checks the ONE line the REPL would print. Going through | |
| 240 | +// Genkit matters: it is what wraps the OpenAI client's error, and the mapping | |
| 241 | +// has to survive that wrapping. | |
| 242 | +func TestExplainThroughTheStack(t *testing.T) { | |
| 243 | + cases := []struct { | |
| 244 | + name string | |
| 245 | + provider string | |
| 246 | + status int | |
| 247 | + body string | |
| 248 | + want string | |
| 249 | + }{ | |
| 250 | + {"llama-server without --jinja", "llamacpp", 500, | |
| 251 | + `{"error":{"code":500,"message":"tools param requires --jinja flag","type":"server_error"}}`, | |
| 252 | + "tool calls need llama-server started with --jinja"}, | |
| 253 | + {"401", "llamacpp", 401, `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`, | |
| 254 | + "authentication failed"}, | |
| 255 | + {"404 on dmr names the pull command", "dmr", 404, `{"error":{"message":"model not found","type":"not_found"}}`, | |
| 256 | + "docker model pull fake-model"}, | |
| 257 | + } | |
| 258 | + for _, tc := range cases { | |
| 259 | + t.Run(tc.name, func(t *testing.T) { | |
| 260 | + srv := openaiServer(t, tc.status, tc.body, "fake-model") | |
| 261 | + got := explainAgainst(t, tc.provider, srv.URL) | |
| 262 | + if !strings.Contains(got, tc.want) { | |
| 263 | + t.Errorf("Explain = %q, want it to contain %q", got, tc.want) | |
| 264 | + } | |
| 265 | + }) | |
| 266 | + } | |
| 267 | + | |
| 268 | + t.Run("connection refused", func(t *testing.T) { | |
| 269 | + got := explainAgainst(t, "llamacpp", closedURL(t)) | |
| 270 | + if !strings.Contains(got, "nothing answers") || !strings.Contains(got, "--jinja --port 8080") { | |
| 271 | + t.Errorf("Explain = %q, want the start hint", got) | |
| 272 | + } | |
| 273 | + }) | |
| 274 | +} | |
| 275 | + | |
| 276 | +// TestExplain401CitesTheResolvedVariable guards the phantom variable: the first | |
| 277 | +// Explain named $AGENT_API_KEY on a 401, a variable nothing read. The message | |
| 278 | +// must name the variable Resolve used — the yaml's `apiKeyEnv` when set — and | |
| 279 | +// name none when the provider has none, rather than invent one. | |
| 280 | +func TestExplain401CitesTheResolvedVariable(t *testing.T) { | |
| 281 | + body := `{"error":{"message":"Invalid API Key","type":"authentication_error"}}` | |
| 282 | + | |
| 283 | + t.Run("yaml apiKeyEnv is the name cited", func(t *testing.T) { | |
| 284 | + clearEnv(t) | |
| 285 | + t.Setenv("MY_KEY", "wrong") | |
| 286 | + srv := openaiServer(t, 401, body, "fake-model") | |
| 287 | + p, _ := Lookup("llamacpp") | |
| 288 | + b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr(""), APIKeyEnv: "MY_KEY"}) | |
| 289 | + if err != nil { | |
| 290 | + t.Fatal(err) | |
| 291 | + } | |
| 292 | + got := explainWith(t, p, b) | |
| 293 | + if !strings.Contains(got, "$MY_KEY") || strings.Contains(got, "LLAMA_API_KEY") { | |
| 294 | + t.Errorf("Explain = %q, want it to cite $MY_KEY and nothing else", got) | |
| 295 | + } | |
| 296 | + }) | |
| 297 | + | |
| 298 | + t.Run("dmr has no variable: none is invented", func(t *testing.T) { | |
| 299 | + clearEnv(t) | |
| 300 | + srv := openaiServer(t, 401, body, "fake-model") | |
| 301 | + p, _ := Lookup("dmr") | |
| 302 | + b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr("")}) | |
| 303 | + if err != nil { | |
| 304 | + t.Fatal(err) | |
| 305 | + } | |
| 306 | + got := explainWith(t, p, b) | |
| 307 | + if strings.Contains(got, "$") || !strings.Contains(got, "should not need a key") { | |
| 308 | + t.Errorf("Explain = %q, want no $VARIABLE and the check-baseUrl hint", got) | |
| 309 | + } | |
| 310 | + }) | |
| 311 | +} | |
| 312 | + | |
| 313 | +// TestResolveKeyPolicy exercises the keyRequired branch with a throwaway | |
| 314 | +// provider built directly — not registered, so the global registry stays as the | |
| 315 | +// other tests expect it. Neither shipped provider requires a key, so without | |
| 316 | +// this the branch a paid API will rely on has never run. | |
| 317 | +func TestResolveKeyPolicy(t *testing.T) { | |
| 318 | + clearEnv(t) | |
| 319 | + paid := &openaiCompat{name: "paid", baseURL: "https://paid.example/v1", apiKeyEnv: "PAID_API_KEY", keyRequired: true, dummyKey: "must-not-appear"} | |
| 320 | + free := &openaiCompat{name: "free", baseURL: "http://127.0.0.1:1/v1", apiKeyEnv: "FREE_API_KEY", dummyKey: "not-needed"} | |
| 321 | + | |
| 322 | + cases := []struct { | |
| 323 | + name string | |
| 324 | + p *openaiCompat | |
| 325 | + cfg config.Config | |
| 326 | + env map[string]string | |
| 327 | + wantErr string // "" = success | |
| 328 | + wantKey string | |
| 329 | + wantEnv string | |
| 330 | + }{ | |
| 331 | + {"required and unset: the error names the variable", paid, config.Config{}, nil, | |
| 332 | + "paid: PAID_API_KEY is not set", "", "PAID_API_KEY"}, | |
| 333 | + {"required and set: the value, not the dummy", paid, config.Config{}, map[string]string{"PAID_API_KEY": "sk-live"}, | |
| 334 | + "", "sk-live", "PAID_API_KEY"}, | |
| 335 | + {"required, yaml apiKeyEnv redirects to another variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, map[string]string{"OTHER_KEY": "sk-other"}, | |
| 336 | + "", "sk-other", "OTHER_KEY"}, | |
| 337 | + {"required, yaml apiKeyEnv unset: names THAT variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, nil, | |
| 338 | + "paid: OTHER_KEY is not set", "", "OTHER_KEY"}, | |
| 339 | + {"not required and unset: the dummy key", free, config.Config{}, nil, | |
| 340 | + "", "not-needed", "FREE_API_KEY"}, | |
| 341 | + {"not required and set: the value", free, config.Config{}, map[string]string{"FREE_API_KEY": "abc"}, | |
| 342 | + "", "abc", "FREE_API_KEY"}, | |
| 343 | + } | |
| 344 | + for _, tc := range cases { | |
| 345 | + t.Run(tc.name, func(t *testing.T) { | |
| 346 | + for _, k := range []string{"PAID_API_KEY", "FREE_API_KEY", "OTHER_KEY"} { | |
| 347 | + t.Setenv(k, "") | |
| 348 | + } | |
| 349 | + for k, v := range tc.env { | |
| 350 | + t.Setenv(k, v) | |
| 351 | + } | |
| 352 | + tc.cfg.Model = "m" | |
| 353 | + tc.cfg.Fallback = ptr("") // no probe: the URLs here do not exist | |
| 354 | + b, err := tc.p.Resolve(tc.cfg) | |
| 355 | + switch { | |
| 356 | + case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)): | |
| 357 | + t.Fatalf("Resolve error = %v, want it to contain %q", err, tc.wantErr) | |
| 358 | + case tc.wantErr == "" && err != nil: | |
| 359 | + t.Fatalf("Resolve: %v", err) | |
| 360 | + } | |
| 361 | + if b.APIKey != tc.wantKey { | |
| 362 | + t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey) | |
| 363 | + } | |
| 364 | + if b.APIKeyEnv != tc.wantEnv { | |
| 365 | + t.Errorf("APIKeyEnv = %q, want %q", b.APIKeyEnv, tc.wantEnv) | |
| 366 | + } | |
| 367 | + }) | |
| 368 | + } | |
| 369 | +} | |
| 370 | + | |
| 371 | +// explainAgainst opens the provider on baseURL, asks one question and returns | |
| 372 | +// the explained error. The spinner and the retry print to stdout: harmless. | |
| 373 | +func explainAgainst(t *testing.T, provider, baseURL string) string { | |
| 374 | + t.Helper() | |
| 375 | + p, _ := Lookup(provider) | |
| 376 | + return explainWith(t, p, Backend{Provider: provider, BaseURL: baseURL, Model: "fake-model", APIKey: "not-needed"}) | |
| 377 | +} | |
| 378 | + | |
| 379 | +// explainWith is explainAgainst for a Backend built by Resolve — needed when | |
| 380 | +// the message under test depends on what Resolve recorded (APIKeyEnv). | |
| 381 | +func explainWith(t *testing.T, p Provider, b Backend) string { | |
| 382 | + t.Helper() | |
| 383 | + g, model, err := p.Open(context.Background(), b) | |
| 384 | + if err != nil { | |
| 385 | + t.Fatalf("Open: %v", err) | |
| 386 | + } | |
| 387 | + e := &Engine{G: g, Model: model, Provider: p, Backend: b} | |
| 388 | + _, _, err = e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil) | |
| 389 | + if err == nil { | |
| 390 | + t.Fatal("Generate succeeded against a failing server") | |
| 391 | + } | |
| 392 | + return e.Explain(err) | |
| 393 | +} | |
| 394 | + | |
| 395 | +// TestConfigCompat: an agent.yaml written for part 07 — no `provider`, an | |
| 396 | +// explicit `fallback: ""` — must load with the same meaning; and a llamacpp | |
| 397 | +// file with no baseUrl must land on llama-server's port, not DMR's. | |
| 398 | +func TestConfigCompat(t *testing.T) { | |
| 399 | + clearEnv(t) | |
| 400 | + saved := config.Cfg | |
| 401 | + defer func() { config.Cfg = saved }() | |
| 402 | + | |
| 403 | + dir := t.TempDir() | |
| 404 | + write := func(name, body string) string { | |
| 405 | + path := filepath.Join(dir, name) | |
| 406 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 407 | + t.Fatal(err) | |
| 408 | + } | |
| 409 | + return path | |
| 410 | + } | |
| 411 | + | |
| 412 | + old := write("probe.yaml", "model: fake-model\nbaseUrl: http://127.0.0.1:18234/engines/v1\nfallback: \"\"\nmaxOutput: 16000\nmaxTurns: 25\nsystem: |\n Probe.\nsampling:\n temperature: 0.0\n") | |
| 413 | + if _, err := config.Load(old); err != nil { | |
| 414 | + t.Fatalf("Load(07 config): %v", err) | |
| 415 | + } | |
| 416 | + if config.Cfg.Provider != "dmr" { | |
| 417 | + t.Errorf("Provider = %q, want dmr by default", config.Cfg.Provider) | |
| 418 | + } | |
| 419 | + p, _ := Lookup(config.Cfg.Provider) | |
| 420 | + b, err := p.Resolve(config.Cfg) | |
| 421 | + if err != nil { | |
| 422 | + t.Fatal(err) | |
| 423 | + } | |
| 424 | + if b.BaseURL != "http://127.0.0.1:18234/engines/v1" { | |
| 425 | + t.Errorf("BaseURL = %q: an explicit empty fallback must keep the file's URL even when it is down", b.BaseURL) | |
| 426 | + } | |
| 427 | + | |
| 428 | + config.Cfg = saved | |
| 429 | + llama := write("llama.yaml", "provider: llamacpp\nmodel: qwen2.5-coder\ncontextWindow: 16384\n") | |
| 430 | + if _, err := config.Load(llama); err != nil { | |
| 431 | + t.Fatalf("Load(llamacpp config): %v", err) | |
| 432 | + } | |
| 433 | + p, _ = Lookup(config.Cfg.Provider) | |
| 434 | + b, err = p.Resolve(config.Cfg) | |
| 435 | + if err != nil { | |
| 436 | + t.Fatal(err) | |
| 437 | + } | |
| 438 | + if b.BaseURL != "http://127.0.0.1:8080/v1" || b.ContextWindow != 16384 { | |
| 439 | + t.Errorf("Backend = %+v, want llama-server's default URL and the 16384 hint", b) | |
| 440 | + } | |
| 441 | + | |
| 442 | + config.Cfg = saved | |
| 443 | + bad := write("bad.yaml", "provider: ollama\nmodel: m\n") | |
| 444 | + if _, err := config.Load(bad); err != nil { | |
| 445 | + t.Fatalf("Load must accept an unknown provider (engine.New refuses it): %v", err) | |
| 446 | + } | |
| 447 | + if _, err := Lookup(config.Cfg.Provider); err == nil { | |
| 448 | + t.Error("Lookup(ollama) must fail") | |
| 449 | + } | |
| 450 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,450 @@ | |||
| 1 | +// Tests for the provider layer, against httptest servers: the fake engine of | ||
| 2 | +// the backup docs speaks plain OpenAI and cannot say "I am llama-server", nor | ||
| 3 | +// fail on demand. What these tests guard: the URL precedence the `dmr` package | ||
| 4 | +// had (env, then file, then fallback when the first does not answer), the | ||
| 5 | +// llama.cpp probe, and the one-line error mapping — each line of the table in | ||
| 6 | +// PROVIDERS.md §5.7 that this step implements. | ||
| 7 | +package engine | ||
| 8 | + | ||
| 9 | +import ( | ||
| 10 | + "context" | ||
| 11 | + "encoding/json" | ||
| 12 | + "net/http" | ||
| 13 | + "net/http/httptest" | ||
| 14 | + "os" | ||
| 15 | + "path/filepath" | ||
| 16 | + "strings" | ||
| 17 | + "testing" | ||
| 18 | + | ||
| 19 | + "mm/internal/config" | ||
| 20 | + | ||
| 21 | + "github.com/firebase/genkit/go/ai" | ||
| 22 | +) | ||
| 23 | + | ||
| 24 | +// openaiServer is a minimal OpenAI-compatible server: /models lists `models`, | ||
| 25 | +// every POST answers `status` with `body`. Enough to drive Resolve, Probe and | ||
| 26 | +// Explain; Generate's happy path is covered by engine_test.go's fake model. | ||
| 27 | +func openaiServer(t *testing.T, status int, body string, models ...string) *httptest.Server { | ||
| 28 | + t.Helper() | ||
| 29 | + mux := http.NewServeMux() | ||
| 30 | + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | ||
| 31 | + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") { | ||
| 32 | + var data []map[string]string | ||
| 33 | + for _, m := range models { | ||
| 34 | + data = append(data, map[string]string{"id": m, "object": "model"}) | ||
| 35 | + } | ||
| 36 | + _ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data}) | ||
| 37 | + return | ||
| 38 | + } | ||
| 39 | + if r.Method == http.MethodPost { | ||
| 40 | + w.Header().Set("Content-Type", "application/json") | ||
| 41 | + w.WriteHeader(status) | ||
| 42 | + _, _ = w.Write([]byte(body)) | ||
| 43 | + return | ||
| 44 | + } | ||
| 45 | + http.NotFound(w, r) | ||
| 46 | + }) | ||
| 47 | + srv := httptest.NewServer(mux) | ||
| 48 | + t.Cleanup(srv.Close) | ||
| 49 | + return srv | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +// closedURL returns a URL nothing listens on: a server started and stopped, so | ||
| 53 | +// the port was free a moment ago. | ||
| 54 | +func closedURL(t *testing.T) string { | ||
| 55 | + t.Helper() | ||
| 56 | + srv := httptest.NewServer(http.NotFoundHandler()) | ||
| 57 | + url := srv.URL | ||
| 58 | + srv.Close() | ||
| 59 | + return url | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +func ptr(s string) *string { return &s } | ||
| 63 | + | ||
| 64 | +// clearEnv: the precedence tests must not depend on the developer's shell, | ||
| 65 | +// where DMR_BASE_URL is often exported. | ||
| 66 | +func clearEnv(t *testing.T) { | ||
| 67 | + t.Helper() | ||
| 68 | + for _, k := range []string{"AGENT_BASE_URL", "DMR_BASE_URL", "LLAMA_API_KEY", "AGENT_PROVIDER", "AGENT_MODEL"} { | ||
| 69 | + t.Setenv(k, "") | ||
| 70 | + } | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +func TestLookupListsKnownProviders(t *testing.T) { | ||
| 74 | + _, err := Lookup("ollama") | ||
| 75 | + if err == nil || !strings.Contains(err.Error(), "dmr, llamacpp") { | ||
| 76 | + t.Fatalf("Lookup(ollama) = %v, want an error naming the known providers", err) | ||
| 77 | + } | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// TestResolveURLPrecedence replays the rules of the former config.DMRBaseURL — | ||
| 81 | +// env wins without probing, the fallback is taken only when baseUrl does not | ||
| 82 | +// answer — and checks that llamacpp neither inherits DMR's URL nor its env var. | ||
| 83 | +func TestResolveURLPrecedence(t *testing.T) { | ||
| 84 | + clearEnv(t) | ||
| 85 | + up := openaiServer(t, 200, "{}", "fake-model") | ||
| 86 | + down := closedURL(t) | ||
| 87 | + | ||
| 88 | + cases := []struct { | ||
| 89 | + name string | ||
| 90 | + provider string | ||
| 91 | + cfg config.Config | ||
| 92 | + env map[string]string | ||
| 93 | + wantURL string | ||
| 94 | + wantKey string | ||
| 95 | + }{ | ||
| 96 | + {"dmr keeps a reachable baseUrl", "dmr", config.Config{BaseURL: up.URL}, nil, up.URL, "not-needed"}, | ||
| 97 | + {"dmr falls back when baseUrl is down", "dmr", config.Config{BaseURL: down, Fallback: ptr(up.URL)}, nil, up.URL, "not-needed"}, | ||
| 98 | + {"an explicit empty fallback disables it", "dmr", config.Config{BaseURL: down, Fallback: ptr("")}, nil, down, "not-needed"}, | ||
| 99 | + {"AGENT_BASE_URL wins without probing", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"AGENT_BASE_URL": down}, down, "not-needed"}, | ||
| 100 | + {"DMR_BASE_URL still works for dmr", "dmr", config.Config{BaseURL: up.URL, Fallback: ptr("")}, map[string]string{"DMR_BASE_URL": down}, down, "not-needed"}, | ||
| 101 | + {"llamacpp default is llama-server's port", "llamacpp", config.Config{}, nil, "http://127.0.0.1:8080/v1", "not-needed"}, | ||
| 102 | + {"llamacpp ignores DMR_BASE_URL", "llamacpp", config.Config{}, map[string]string{"DMR_BASE_URL": down}, "http://127.0.0.1:8080/v1", "not-needed"}, | ||
| 103 | + {"llamacpp reads LLAMA_API_KEY when set", "llamacpp", config.Config{}, map[string]string{"LLAMA_API_KEY": "secret"}, "http://127.0.0.1:8080/v1", "secret"}, | ||
| 104 | + {"apiKeyEnv names another variable", "llamacpp", config.Config{APIKeyEnv: "MY_KEY"}, map[string]string{"MY_KEY": "k2"}, "http://127.0.0.1:8080/v1", "k2"}, | ||
| 105 | + } | ||
| 106 | + for _, tc := range cases { | ||
| 107 | + t.Run(tc.name, func(t *testing.T) { | ||
| 108 | + clearEnv(t) | ||
| 109 | + for k, v := range tc.env { | ||
| 110 | + t.Setenv(k, v) | ||
| 111 | + } | ||
| 112 | + p, _ := Lookup(tc.provider) | ||
| 113 | + tc.cfg.Model = "fake-model" | ||
| 114 | + b, err := p.Resolve(tc.cfg) | ||
| 115 | + if err != nil { | ||
| 116 | + t.Fatalf("Resolve: %v", err) | ||
| 117 | + } | ||
| 118 | + if b.BaseURL != tc.wantURL { | ||
| 119 | + t.Errorf("BaseURL = %q, want %q", b.BaseURL, tc.wantURL) | ||
| 120 | + } | ||
| 121 | + if b.APIKey != tc.wantKey { | ||
| 122 | + t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey) | ||
| 123 | + } | ||
| 124 | + }) | ||
| 125 | + } | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +// TestProbeLlamaCpp: /props sits one level above /v1 and carries the SERVED | ||
| 129 | +// context size; the config's value, when given, wins over it. | ||
| 130 | +func TestProbeLlamaCpp(t *testing.T) { | ||
| 131 | + mux := http.NewServeMux() | ||
| 132 | + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { | ||
| 133 | + _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`)) | ||
| 134 | + }) | ||
| 135 | + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) { | ||
| 136 | + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"qwen2.5-coder","object":"model"}]}`)) | ||
| 137 | + }) | ||
| 138 | + srv := httptest.NewServer(mux) | ||
| 139 | + defer srv.Close() | ||
| 140 | + | ||
| 141 | + p, _ := Lookup("llamacpp") | ||
| 142 | + info := p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder"}) | ||
| 143 | + if !info.Reachable || info.ContextWindow != 32768 || info.ContextSource != "/props" { | ||
| 144 | + t.Errorf("Probe = %+v, want reachable, 32768 from /props", info) | ||
| 145 | + } | ||
| 146 | + if len(info.Warnings) != 0 { | ||
| 147 | + t.Errorf("unexpected warnings: %q", info.Warnings) | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + info = p.Probe(context.Background(), Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "qwen2.5-coder", ContextWindow: 4096}) | ||
| 151 | + if info.ContextWindow != 4096 || info.ContextSource != "config" { | ||
| 152 | + t.Errorf("with a config hint: %+v, want 4096 from config", info) | ||
| 153 | + } | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +// TestEnsureContextWindowReprobes: the start-up probe runs once, and a | ||
| 157 | +// llama-server started AFTER the agent left `ctx: unknown` for the whole | ||
| 158 | +// session. The lazy re-probe must turn 0 into n_ctx once /props answers — and | ||
| 159 | +// must stop asking as soon as the window is known, or when the config gave | ||
| 160 | +// it: the hit counter on /props is the measure. | ||
| 161 | +func TestEnsureContextWindowReprobes(t *testing.T) { | ||
| 162 | + var propsHits int | ||
| 163 | + up := false // false = llama-server not ready yet: /props answers 503 | ||
| 164 | + mux := http.NewServeMux() | ||
| 165 | + mux.HandleFunc("/props", func(w http.ResponseWriter, _ *http.Request) { | ||
| 166 | + propsHits++ | ||
| 167 | + if !up { | ||
| 168 | + w.WriteHeader(http.StatusServiceUnavailable) | ||
| 169 | + return | ||
| 170 | + } | ||
| 171 | + _, _ = w.Write([]byte(`{"default_generation_settings":{"n_ctx":32768},"total_slots":1,"chat_template":"{{ messages }}"}`)) | ||
| 172 | + }) | ||
| 173 | + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) { | ||
| 174 | + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m","object":"model"}]}`)) | ||
| 175 | + }) | ||
| 176 | + srv := httptest.NewServer(mux) | ||
| 177 | + defer srv.Close() | ||
| 178 | + | ||
| 179 | + p, _ := Lookup("llamacpp") | ||
| 180 | + e := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m"}} | ||
| 181 | + | ||
| 182 | + // Server not ready: still unknown, nothing learned, but the probe was made. | ||
| 183 | + if w, src := e.EnsureContextWindow(context.Background()); w != 0 || src != "" || e.ContextWindow != 0 { | ||
| 184 | + t.Errorf("before the server is up: got (%d, %q), ContextWindow=%d, want (0, \"\"), 0", w, src, e.ContextWindow) | ||
| 185 | + } | ||
| 186 | + if propsHits != 1 { | ||
| 187 | + t.Fatalf("/props hits = %d after the first re-probe, want 1", propsHits) | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + // Server up: the window is learned, remembered, and its origin is named. | ||
| 191 | + up = true | ||
| 192 | + if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "/props" || e.ContextWindow != 32768 { | ||
| 193 | + t.Errorf("after the server is up: got (%d, %q), ContextWindow=%d, want (32768, \"/props\"), 32768", w, src, e.ContextWindow) | ||
| 194 | + } | ||
| 195 | + if propsHits != 2 { | ||
| 196 | + t.Fatalf("/props hits = %d after the second re-probe, want 2", propsHits) | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + // Known window: answered from memory, no request, no origin to announce. | ||
| 200 | + if w, src := e.EnsureContextWindow(context.Background()); w != 32768 || src != "" { | ||
| 201 | + t.Errorf("known window: got (%d, %q), want (32768, \"\")", w, src) | ||
| 202 | + } | ||
| 203 | + if propsHits != 2 { | ||
| 204 | + t.Errorf("/props hits = %d after asking with a known window, want still 2", propsHits) | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + // A window from the config is known from the start: never probed at all. | ||
| 208 | + propsHits = 0 | ||
| 209 | + known := &Engine{Provider: p, Backend: Backend{Provider: "llamacpp", BaseURL: srv.URL + "/v1", Model: "m", ContextWindow: 4096}, ContextWindow: 4096} | ||
| 210 | + if w, src := known.EnsureContextWindow(context.Background()); w != 4096 || src != "" || propsHits != 0 { | ||
| 211 | + t.Errorf("config window: got (%d, %q) with %d /props hit(s), want (4096, \"\") and 0", w, src, propsHits) | ||
| 212 | + } | ||
| 213 | +} | ||
| 214 | + | ||
| 215 | +// TestProbeUnreachableWarns: a server that is down is a warning with the start | ||
| 216 | +// command, not a failure — the demo often starts the server second. | ||
| 217 | +func TestProbeUnreachableWarns(t *testing.T) { | ||
| 218 | + for _, name := range []string{"dmr", "llamacpp"} { | ||
| 219 | + p, _ := Lookup(name) | ||
| 220 | + info := p.Probe(context.Background(), Backend{Provider: name, BaseURL: closedURL(t) + "/v1", Model: "m"}) | ||
| 221 | + if info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "nothing answers") { | ||
| 222 | + t.Errorf("%s: Probe on a closed port = %+v", name, info) | ||
| 223 | + } | ||
| 224 | + } | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +// TestProbeModelsWarnsOnMissingModel: DMR lists what is pulled; asking for | ||
| 228 | +// something else fails only once the request is sent, after a long spinner. | ||
| 229 | +func TestProbeModelsWarnsOnMissingModel(t *testing.T) { | ||
| 230 | + srv := openaiServer(t, 200, "{}", "ai/other") | ||
| 231 | + p, _ := Lookup("dmr") | ||
| 232 | + info := p.Probe(context.Background(), Backend{Provider: "dmr", BaseURL: srv.URL, Model: "ai/qwen2.5-coder"}) | ||
| 233 | + if !info.Reachable || len(info.Warnings) != 1 || !strings.Contains(info.Warnings[0], "docker model pull ai/qwen2.5-coder") { | ||
| 234 | + t.Errorf("Probe = %+v, want the pull hint", info) | ||
| 235 | + } | ||
| 236 | +} | ||
| 237 | + | ||
| 238 | +// TestExplainThroughTheStack drives a real Open + Generate against servers that | ||
| 239 | +// fail on purpose, and checks the ONE line the REPL would print. Going through | ||
| 240 | +// Genkit matters: it is what wraps the OpenAI client's error, and the mapping | ||
| 241 | +// has to survive that wrapping. | ||
| 242 | +func TestExplainThroughTheStack(t *testing.T) { | ||
| 243 | + cases := []struct { | ||
| 244 | + name string | ||
| 245 | + provider string | ||
| 246 | + status int | ||
| 247 | + body string | ||
| 248 | + want string | ||
| 249 | + }{ | ||
| 250 | + {"llama-server without --jinja", "llamacpp", 500, | ||
| 251 | + `{"error":{"code":500,"message":"tools param requires --jinja flag","type":"server_error"}}`, | ||
| 252 | + "tool calls need llama-server started with --jinja"}, | ||
| 253 | + {"401", "llamacpp", 401, `{"error":{"message":"Invalid API Key","type":"authentication_error"}}`, | ||
| 254 | + "authentication failed"}, | ||
| 255 | + {"404 on dmr names the pull command", "dmr", 404, `{"error":{"message":"model not found","type":"not_found"}}`, | ||
| 256 | + "docker model pull fake-model"}, | ||
| 257 | + } | ||
| 258 | + for _, tc := range cases { | ||
| 259 | + t.Run(tc.name, func(t *testing.T) { | ||
| 260 | + srv := openaiServer(t, tc.status, tc.body, "fake-model") | ||
| 261 | + got := explainAgainst(t, tc.provider, srv.URL) | ||
| 262 | + if !strings.Contains(got, tc.want) { | ||
| 263 | + t.Errorf("Explain = %q, want it to contain %q", got, tc.want) | ||
| 264 | + } | ||
| 265 | + }) | ||
| 266 | + } | ||
| 267 | + | ||
| 268 | + t.Run("connection refused", func(t *testing.T) { | ||
| 269 | + got := explainAgainst(t, "llamacpp", closedURL(t)) | ||
| 270 | + if !strings.Contains(got, "nothing answers") || !strings.Contains(got, "--jinja --port 8080") { | ||
| 271 | + t.Errorf("Explain = %q, want the start hint", got) | ||
| 272 | + } | ||
| 273 | + }) | ||
| 274 | +} | ||
| 275 | + | ||
| 276 | +// TestExplain401CitesTheResolvedVariable guards the phantom variable: the first | ||
| 277 | +// Explain named $AGENT_API_KEY on a 401, a variable nothing read. The message | ||
| 278 | +// must name the variable Resolve used — the yaml's `apiKeyEnv` when set — and | ||
| 279 | +// name none when the provider has none, rather than invent one. | ||
| 280 | +func TestExplain401CitesTheResolvedVariable(t *testing.T) { | ||
| 281 | + body := `{"error":{"message":"Invalid API Key","type":"authentication_error"}}` | ||
| 282 | + | ||
| 283 | + t.Run("yaml apiKeyEnv is the name cited", func(t *testing.T) { | ||
| 284 | + clearEnv(t) | ||
| 285 | + t.Setenv("MY_KEY", "wrong") | ||
| 286 | + srv := openaiServer(t, 401, body, "fake-model") | ||
| 287 | + p, _ := Lookup("llamacpp") | ||
| 288 | + b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr(""), APIKeyEnv: "MY_KEY"}) | ||
| 289 | + if err != nil { | ||
| 290 | + t.Fatal(err) | ||
| 291 | + } | ||
| 292 | + got := explainWith(t, p, b) | ||
| 293 | + if !strings.Contains(got, "$MY_KEY") || strings.Contains(got, "LLAMA_API_KEY") { | ||
| 294 | + t.Errorf("Explain = %q, want it to cite $MY_KEY and nothing else", got) | ||
| 295 | + } | ||
| 296 | + }) | ||
| 297 | + | ||
| 298 | + t.Run("dmr has no variable: none is invented", func(t *testing.T) { | ||
| 299 | + clearEnv(t) | ||
| 300 | + srv := openaiServer(t, 401, body, "fake-model") | ||
| 301 | + p, _ := Lookup("dmr") | ||
| 302 | + b, err := p.Resolve(config.Config{Model: "fake-model", BaseURL: srv.URL, Fallback: ptr("")}) | ||
| 303 | + if err != nil { | ||
| 304 | + t.Fatal(err) | ||
| 305 | + } | ||
| 306 | + got := explainWith(t, p, b) | ||
| 307 | + if strings.Contains(got, "$") || !strings.Contains(got, "should not need a key") { | ||
| 308 | + t.Errorf("Explain = %q, want no $VARIABLE and the check-baseUrl hint", got) | ||
| 309 | + } | ||
| 310 | + }) | ||
| 311 | +} | ||
| 312 | + | ||
| 313 | +// TestResolveKeyPolicy exercises the keyRequired branch with a throwaway | ||
| 314 | +// provider built directly — not registered, so the global registry stays as the | ||
| 315 | +// other tests expect it. Neither shipped provider requires a key, so without | ||
| 316 | +// this the branch a paid API will rely on has never run. | ||
| 317 | +func TestResolveKeyPolicy(t *testing.T) { | ||
| 318 | + clearEnv(t) | ||
| 319 | + paid := &openaiCompat{name: "paid", baseURL: "https://paid.example/v1", apiKeyEnv: "PAID_API_KEY", keyRequired: true, dummyKey: "must-not-appear"} | ||
| 320 | + free := &openaiCompat{name: "free", baseURL: "http://127.0.0.1:1/v1", apiKeyEnv: "FREE_API_KEY", dummyKey: "not-needed"} | ||
| 321 | + | ||
| 322 | + cases := []struct { | ||
| 323 | + name string | ||
| 324 | + p *openaiCompat | ||
| 325 | + cfg config.Config | ||
| 326 | + env map[string]string | ||
| 327 | + wantErr string // "" = success | ||
| 328 | + wantKey string | ||
| 329 | + wantEnv string | ||
| 330 | + }{ | ||
| 331 | + {"required and unset: the error names the variable", paid, config.Config{}, nil, | ||
| 332 | + "paid: PAID_API_KEY is not set", "", "PAID_API_KEY"}, | ||
| 333 | + {"required and set: the value, not the dummy", paid, config.Config{}, map[string]string{"PAID_API_KEY": "sk-live"}, | ||
| 334 | + "", "sk-live", "PAID_API_KEY"}, | ||
| 335 | + {"required, yaml apiKeyEnv redirects to another variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, map[string]string{"OTHER_KEY": "sk-other"}, | ||
| 336 | + "", "sk-other", "OTHER_KEY"}, | ||
| 337 | + {"required, yaml apiKeyEnv unset: names THAT variable", paid, config.Config{APIKeyEnv: "OTHER_KEY"}, nil, | ||
| 338 | + "paid: OTHER_KEY is not set", "", "OTHER_KEY"}, | ||
| 339 | + {"not required and unset: the dummy key", free, config.Config{}, nil, | ||
| 340 | + "", "not-needed", "FREE_API_KEY"}, | ||
| 341 | + {"not required and set: the value", free, config.Config{}, map[string]string{"FREE_API_KEY": "abc"}, | ||
| 342 | + "", "abc", "FREE_API_KEY"}, | ||
| 343 | + } | ||
| 344 | + for _, tc := range cases { | ||
| 345 | + t.Run(tc.name, func(t *testing.T) { | ||
| 346 | + for _, k := range []string{"PAID_API_KEY", "FREE_API_KEY", "OTHER_KEY"} { | ||
| 347 | + t.Setenv(k, "") | ||
| 348 | + } | ||
| 349 | + for k, v := range tc.env { | ||
| 350 | + t.Setenv(k, v) | ||
| 351 | + } | ||
| 352 | + tc.cfg.Model = "m" | ||
| 353 | + tc.cfg.Fallback = ptr("") // no probe: the URLs here do not exist | ||
| 354 | + b, err := tc.p.Resolve(tc.cfg) | ||
| 355 | + switch { | ||
| 356 | + case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)): | ||
| 357 | + t.Fatalf("Resolve error = %v, want it to contain %q", err, tc.wantErr) | ||
| 358 | + case tc.wantErr == "" && err != nil: | ||
| 359 | + t.Fatalf("Resolve: %v", err) | ||
| 360 | + } | ||
| 361 | + if b.APIKey != tc.wantKey { | ||
| 362 | + t.Errorf("APIKey = %q, want %q", b.APIKey, tc.wantKey) | ||
| 363 | + } | ||
| 364 | + if b.APIKeyEnv != tc.wantEnv { | ||
| 365 | + t.Errorf("APIKeyEnv = %q, want %q", b.APIKeyEnv, tc.wantEnv) | ||
| 366 | + } | ||
| 367 | + }) | ||
| 368 | + } | ||
| 369 | +} | ||
| 370 | + | ||
| 371 | +// explainAgainst opens the provider on baseURL, asks one question and returns | ||
| 372 | +// the explained error. The spinner and the retry print to stdout: harmless. | ||
| 373 | +func explainAgainst(t *testing.T, provider, baseURL string) string { | ||
| 374 | + t.Helper() | ||
| 375 | + p, _ := Lookup(provider) | ||
| 376 | + return explainWith(t, p, Backend{Provider: provider, BaseURL: baseURL, Model: "fake-model", APIKey: "not-needed"}) | ||
| 377 | +} | ||
| 378 | + | ||
| 379 | +// explainWith is explainAgainst for a Backend built by Resolve — needed when | ||
| 380 | +// the message under test depends on what Resolve recorded (APIKeyEnv). | ||
| 381 | +func explainWith(t *testing.T, p Provider, b Backend) string { | ||
| 382 | + t.Helper() | ||
| 383 | + g, model, err := p.Open(context.Background(), b) | ||
| 384 | + if err != nil { | ||
| 385 | + t.Fatalf("Open: %v", err) | ||
| 386 | + } | ||
| 387 | + e := &Engine{G: g, Model: model, Provider: p, Backend: b} | ||
| 388 | + _, _, err = e.Generate(context.Background(), []*ai.Message{ai.NewUserTextMessage("hi")}, nil) | ||
| 389 | + if err == nil { | ||
| 390 | + t.Fatal("Generate succeeded against a failing server") | ||
| 391 | + } | ||
| 392 | + return e.Explain(err) | ||
| 393 | +} | ||
| 394 | + | ||
| 395 | +// TestConfigCompat: an agent.yaml written for part 07 — no `provider`, an | ||
| 396 | +// explicit `fallback: ""` — must load with the same meaning; and a llamacpp | ||
| 397 | +// file with no baseUrl must land on llama-server's port, not DMR's. | ||
| 398 | +func TestConfigCompat(t *testing.T) { | ||
| 399 | + clearEnv(t) | ||
| 400 | + saved := config.Cfg | ||
| 401 | + defer func() { config.Cfg = saved }() | ||
| 402 | + | ||
| 403 | + dir := t.TempDir() | ||
| 404 | + write := func(name, body string) string { | ||
| 405 | + path := filepath.Join(dir, name) | ||
| 406 | + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | ||
| 407 | + t.Fatal(err) | ||
| 408 | + } | ||
| 409 | + return path | ||
| 410 | + } | ||
| 411 | + | ||
| 412 | + old := write("probe.yaml", "model: fake-model\nbaseUrl: http://127.0.0.1:18234/engines/v1\nfallback: \"\"\nmaxOutput: 16000\nmaxTurns: 25\nsystem: |\n Probe.\nsampling:\n temperature: 0.0\n") | ||
| 413 | + if _, err := config.Load(old); err != nil { | ||
| 414 | + t.Fatalf("Load(07 config): %v", err) | ||
| 415 | + } | ||
| 416 | + if config.Cfg.Provider != "dmr" { | ||
| 417 | + t.Errorf("Provider = %q, want dmr by default", config.Cfg.Provider) | ||
| 418 | + } | ||
| 419 | + p, _ := Lookup(config.Cfg.Provider) | ||
| 420 | + b, err := p.Resolve(config.Cfg) | ||
| 421 | + if err != nil { | ||
| 422 | + t.Fatal(err) | ||
| 423 | + } | ||
| 424 | + if b.BaseURL != "http://127.0.0.1:18234/engines/v1" { | ||
| 425 | + t.Errorf("BaseURL = %q: an explicit empty fallback must keep the file's URL even when it is down", b.BaseURL) | ||
| 426 | + } | ||
| 427 | + | ||
| 428 | + config.Cfg = saved | ||
| 429 | + llama := write("llama.yaml", "provider: llamacpp\nmodel: qwen2.5-coder\ncontextWindow: 16384\n") | ||
| 430 | + if _, err := config.Load(llama); err != nil { | ||
| 431 | + t.Fatalf("Load(llamacpp config): %v", err) | ||
| 432 | + } | ||
| 433 | + p, _ = Lookup(config.Cfg.Provider) | ||
| 434 | + b, err = p.Resolve(config.Cfg) | ||
| 435 | + if err != nil { | ||
| 436 | + t.Fatal(err) | ||
| 437 | + } | ||
| 438 | + if b.BaseURL != "http://127.0.0.1:8080/v1" || b.ContextWindow != 16384 { | ||
| 439 | + t.Errorf("Backend = %+v, want llama-server's default URL and the 16384 hint", b) | ||
| 440 | + } | ||
| 441 | + | ||
| 442 | + config.Cfg = saved | ||
| 443 | + bad := write("bad.yaml", "provider: ollama\nmodel: m\n") | ||
| 444 | + if _, err := config.Load(bad); err != nil { | ||
| 445 | + t.Fatalf("Load must accept an unknown provider (engine.New refuses it): %v", err) | ||
| 446 | + } | ||
| 447 | + if _, err := Lookup(config.Cfg.Provider); err == nil { | ||
| 448 | + t.Error("Lookup(ollama) must fail") | ||
| 449 | + } | ||
| 450 | +} | ||
added
internal/fileedit/diff.go +367 -0 | new file mode 100644 | ||
| @@ -0,0 +1,367 @@ | ||
| 1 | +package fileedit | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | +) | |
| 7 | + | |
| 8 | +// op is what happened to a line. The values are the characters of the unified | |
| 9 | +// format, which saves a conversion table. | |
| 10 | +type op byte | |
| 11 | + | |
| 12 | +const ( | |
| 13 | + keep op = ' ' | |
| 14 | + del op = '-' | |
| 15 | + add op = '+' | |
| 16 | +) | |
| 17 | + | |
| 18 | +// line is one line of the diff. oldNo and newNo are 1-based, 0 when the line | |
| 19 | +// does not exist on that side. | |
| 20 | +type line struct { | |
| 21 | + op op | |
| 22 | + text string | |
| 23 | + oldNo int | |
| 24 | + newNo int | |
| 25 | +} | |
| 26 | + | |
| 27 | +// comparison is a diff ready to render: the lines, and what splitting into | |
| 28 | +// lines cannot say — whether each side ended with a newline. Two files that | |
| 29 | +// differ only there are two different files, and `git apply` refuses a patch | |
| 30 | +// that ignores it. | |
| 31 | +type comparison struct { | |
| 32 | + lines []line | |
| 33 | + oldNL, newNL bool | |
| 34 | +} | |
| 35 | + | |
| 36 | +// compare splits, compares, and returns what is needed to display and to patch. | |
| 37 | +func compare(old, new string) comparison { | |
| 38 | + oldLines, oldNL := splitLines(old) | |
| 39 | + newLines, newNL := splitLines(new) | |
| 40 | + r := comparison{lines: diffLines(oldLines, newLines), oldNL: oldNL, newNL: newNL} | |
| 41 | + | |
| 42 | + // Edge case: identical line by line, only the final newline moved. Nothing | |
| 43 | + // is marked changed and the patch would be empty while the file changes. | |
| 44 | + // The last line is replayed — deleted then added — which is exactly what | |
| 45 | + // `git diff` prints. | |
| 46 | + if oldNL != newNL && !changed(r.lines) && len(r.lines) > 0 { | |
| 47 | + last := r.lines[len(r.lines)-1] | |
| 48 | + r.lines = append(r.lines[:len(r.lines)-1], | |
| 49 | + line{del, last.text, last.oldNo, 0}, | |
| 50 | + line{add, last.text, 0, last.newNo}, | |
| 51 | + ) | |
| 52 | + } | |
| 53 | + return r | |
| 54 | +} | |
| 55 | + | |
| 56 | +func (r comparison) changed() bool { return changed(r.lines) } | |
| 57 | + | |
| 58 | +// firstChanged is the number, in the NEW version, of the first touched line — | |
| 59 | +// 0 when nothing changed. Enough to open the file at the right place, or to | |
| 60 | +// say "it starts at line 42" rather than "done". | |
| 61 | +func (r comparison) firstChanged() int { | |
| 62 | + for _, l := range r.lines { | |
| 63 | + switch l.op { | |
| 64 | + case add: | |
| 65 | + return l.newNo | |
| 66 | + case del: | |
| 67 | + // A deletion has no line in the new version: point at where it | |
| 68 | + // happened. | |
| 69 | + return max(1, newNoBefore(r.lines, l)) | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return 0 | |
| 73 | +} | |
| 74 | + | |
| 75 | +func (r comparison) stat() (added, deleted int) { | |
| 76 | + for _, l := range r.lines { | |
| 77 | + switch l.op { | |
| 78 | + case add: | |
| 79 | + added++ | |
| 80 | + case del: | |
| 81 | + deleted++ | |
| 82 | + } | |
| 83 | + } | |
| 84 | + return added, deleted | |
| 85 | +} | |
| 86 | + | |
| 87 | +// splitLines splits a body into lines and says whether it ended with a | |
| 88 | +// newline. Both matter: a file without a final newline is a different file, | |
| 89 | +// and the unified patch has to say so. | |
| 90 | +func splitLines(body string) (lines []string, finalNewline bool) { | |
| 91 | + if body == "" { | |
| 92 | + return nil, true | |
| 93 | + } | |
| 94 | + finalNewline = strings.HasSuffix(body, "\n") | |
| 95 | + if finalNewline { | |
| 96 | + body = body[:len(body)-1] | |
| 97 | + } | |
| 98 | + return strings.Split(body, "\n"), finalNewline | |
| 99 | +} | |
| 100 | + | |
| 101 | +// maxCells caps the LCS table. Past it, a line-by-line comparison would cost | |
| 102 | +// hundreds of megabytes for a diff nobody will read: the middle block is then | |
| 103 | +// rendered as a block (all deleted, all added), still exact — only coarser. | |
| 104 | +const maxCells = 4 << 20 | |
| 105 | + | |
| 106 | +// diffLines compares two sequences of lines. | |
| 107 | +// | |
| 108 | +// Common prefix and suffix are removed BEFORE the LCS: a targeted edit touches | |
| 109 | +// a few lines in the middle of a file, and without that cut the table would be | |
| 110 | +// the size of the file squared. | |
| 111 | +func diffLines(old, new []string) []line { | |
| 112 | + var out []line | |
| 113 | + | |
| 114 | + head := 0 | |
| 115 | + for head < len(old) && head < len(new) && old[head] == new[head] { | |
| 116 | + out = append(out, line{keep, old[head], head + 1, head + 1}) | |
| 117 | + head++ | |
| 118 | + } | |
| 119 | + tail := 0 | |
| 120 | + for tail < len(old)-head && tail < len(new)-head && | |
| 121 | + old[len(old)-1-tail] == new[len(new)-1-tail] { | |
| 122 | + tail++ | |
| 123 | + } | |
| 124 | + | |
| 125 | + midOld, midNew := old[head:len(old)-tail], new[head:len(new)-tail] | |
| 126 | + out = append(out, middle(midOld, midNew, head)...) | |
| 127 | + | |
| 128 | + for i := len(old) - tail; i < len(old); i++ { | |
| 129 | + out = append(out, line{keep, old[i], i + 1, i + 1 - len(old) + len(new)}) | |
| 130 | + } | |
| 131 | + return out | |
| 132 | +} | |
| 133 | + | |
| 134 | +// middle compares what remains after trimming the edges. `off` is the number | |
| 135 | +// of lines already consumed, to number correctly. | |
| 136 | +func middle(old, new []string, off int) []line { | |
| 137 | + if len(old) == 0 && len(new) == 0 { | |
| 138 | + return nil | |
| 139 | + } | |
| 140 | + if len(old)*len(new) > maxCells { | |
| 141 | + out := make([]line, 0, len(old)+len(new)) | |
| 142 | + for i, l := range old { | |
| 143 | + out = append(out, line{del, l, off + i + 1, 0}) | |
| 144 | + } | |
| 145 | + for j, l := range new { | |
| 146 | + out = append(out, line{add, l, 0, off + j + 1}) | |
| 147 | + } | |
| 148 | + return out | |
| 149 | + } | |
| 150 | + | |
| 151 | + // Classic LCS: length table, then walk. Files edited by an agent fit in it | |
| 152 | + // comfortably. | |
| 153 | + lcs := make([][]int, len(old)+1) | |
| 154 | + for i := range lcs { | |
| 155 | + lcs[i] = make([]int, len(new)+1) | |
| 156 | + } | |
| 157 | + for i := len(old) - 1; i >= 0; i-- { | |
| 158 | + for j := len(new) - 1; j >= 0; j-- { | |
| 159 | + if old[i] == new[j] { | |
| 160 | + lcs[i][j] = lcs[i+1][j+1] + 1 | |
| 161 | + } else { | |
| 162 | + lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1]) | |
| 163 | + } | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + var out []line | |
| 168 | + i, j := 0, 0 | |
| 169 | + for i < len(old) && j < len(new) { | |
| 170 | + switch { | |
| 171 | + case old[i] == new[j]: | |
| 172 | + out = append(out, line{keep, old[i], off + i + 1, off + j + 1}) | |
| 173 | + i, j = i+1, j+1 | |
| 174 | + case lcs[i+1][j] >= lcs[i][j+1]: | |
| 175 | + // Deletion first on a tie: a replacement then reads "- old" then | |
| 176 | + // "+ new", the order one expects. | |
| 177 | + out = append(out, line{del, old[i], off + i + 1, 0}) | |
| 178 | + i++ | |
| 179 | + default: | |
| 180 | + out = append(out, line{add, new[j], 0, off + j + 1}) | |
| 181 | + j++ | |
| 182 | + } | |
| 183 | + } | |
| 184 | + for ; i < len(old); i++ { | |
| 185 | + out = append(out, line{del, old[i], off + i + 1, 0}) | |
| 186 | + } | |
| 187 | + for ; j < len(new); j++ { | |
| 188 | + out = append(out, line{add, new[j], 0, off + j + 1}) | |
| 189 | + } | |
| 190 | + return out | |
| 191 | +} | |
| 192 | + | |
| 193 | +// newNoBefore finds the number of the kept line that precedes `target`. | |
| 194 | +func newNoBefore(lines []line, target line) int { | |
| 195 | + last := 0 | |
| 196 | + for _, l := range lines { | |
| 197 | + if l == target { | |
| 198 | + break | |
| 199 | + } | |
| 200 | + if l.newNo > 0 { | |
| 201 | + last = l.newNo | |
| 202 | + } | |
| 203 | + } | |
| 204 | + return last + 1 | |
| 205 | +} | |
| 206 | + | |
| 207 | +func changed(lines []line) bool { | |
| 208 | + for _, l := range lines { | |
| 209 | + if l.op != keep { | |
| 210 | + return true | |
| 211 | + } | |
| 212 | + } | |
| 213 | + return false | |
| 214 | +} | |
| 215 | + | |
| 216 | +// noNewline is the line the unified format requires when a file does not end | |
| 217 | +// with a newline. `git apply` reads it; without it, it adds a newline nobody | |
| 218 | +// asked for. | |
| 219 | +const noNewline = `\ No newline at end of file` | |
| 220 | + | |
| 221 | +// contextLines is how many unchanged lines are kept around each change. Three | |
| 222 | +// is the value of `diff -u` and of git. | |
| 223 | +const contextLines = 3 | |
| 224 | + | |
| 225 | +// unified renders a unified patch, applicable as-is by `patch -p1` or | |
| 226 | +// `git apply`. Paths are prefixed a/ and b/ like git does. | |
| 227 | +func (r comparison) unified(path string) string { | |
| 228 | + if !changed(r.lines) { | |
| 229 | + return "" | |
| 230 | + } | |
| 231 | + var b strings.Builder | |
| 232 | + fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path) | |
| 233 | + | |
| 234 | + lastOld, lastNew := 0, 0 | |
| 235 | + for _, l := range r.lines { | |
| 236 | + lastOld, lastNew = max(lastOld, l.oldNo), max(lastNew, l.newNo) | |
| 237 | + } | |
| 238 | + for _, h := range hunks(r.lines) { | |
| 239 | + oldStart, oldCount, newStart, newCount := h.ranges() | |
| 240 | + fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount) | |
| 241 | + for _, l := range h { | |
| 242 | + b.WriteByte(byte(l.op)) | |
| 243 | + b.WriteString(l.text) | |
| 244 | + b.WriteByte('\n') | |
| 245 | + // The marker follows the LAST line of each version, and a kept | |
| 246 | + // line belongs to both: it may carry it for both at once. | |
| 247 | + if (!r.oldNL && l.oldNo == lastOld && l.op != add) || | |
| 248 | + (!r.newNL && l.newNo == lastNew && l.op != del) { | |
| 249 | + b.WriteString(noNewline + "\n") | |
| 250 | + } | |
| 251 | + } | |
| 252 | + } | |
| 253 | + return b.String() | |
| 254 | +} | |
| 255 | + | |
| 256 | +// hunk is a group of contiguous lines shown together. | |
| 257 | +type hunk []line | |
| 258 | + | |
| 259 | +// hunks cuts the diff into pieces: each change plus `contextLines` on both | |
| 260 | +// sides. Touching pieces are merged — otherwise two neighbouring changes would | |
| 261 | +// produce overlapping @@ headers, and the patch would be refused. | |
| 262 | +func hunks(lines []line) []hunk { | |
| 263 | + var out []hunk | |
| 264 | + i := 0 | |
| 265 | + for i < len(lines) { | |
| 266 | + if lines[i].op == keep { | |
| 267 | + i++ | |
| 268 | + continue | |
| 269 | + } | |
| 270 | + start := max(0, i-contextLines) | |
| 271 | + if n := len(out); n > 0 { | |
| 272 | + if prevEnd := indexAfter(lines, out[n-1]); prevEnd >= start { | |
| 273 | + out[n-1] = append(out[n-1], lines[prevEnd:hunkEnd(lines, i)]...) | |
| 274 | + i = hunkEnd(lines, i) | |
| 275 | + continue | |
| 276 | + } | |
| 277 | + } | |
| 278 | + out = append(out, append(hunk(nil), lines[start:hunkEnd(lines, i)]...)) | |
| 279 | + i = hunkEnd(lines, i) | |
| 280 | + } | |
| 281 | + return out | |
| 282 | +} | |
| 283 | + | |
| 284 | +// hunkEnd finds the end of the piece opened at `i`: advance while a change | |
| 285 | +// occurs within `contextLines` lines, then keep the trailing context. | |
| 286 | +func hunkEnd(lines []line, i int) int { | |
| 287 | + last := i | |
| 288 | + for j := i; j < len(lines); j++ { | |
| 289 | + if lines[j].op != keep { | |
| 290 | + last = j | |
| 291 | + continue | |
| 292 | + } | |
| 293 | + if j-last > contextLines { | |
| 294 | + break | |
| 295 | + } | |
| 296 | + } | |
| 297 | + return min(len(lines), last+contextLines+1) | |
| 298 | +} | |
| 299 | + | |
| 300 | +// indexAfter returns the position, in `lines`, of the line following the last | |
| 301 | +// element of `h`. | |
| 302 | +func indexAfter(lines []line, h hunk) int { | |
| 303 | + if len(h) == 0 { | |
| 304 | + return 0 | |
| 305 | + } | |
| 306 | + target := h[len(h)-1] | |
| 307 | + for i, l := range lines { | |
| 308 | + if l == target { | |
| 309 | + return i + 1 | |
| 310 | + } | |
| 311 | + } | |
| 312 | + return 0 | |
| 313 | +} | |
| 314 | + | |
| 315 | +// ranges computes the @@ header of the piece. | |
| 316 | +func (h hunk) ranges() (oldStart, oldCount, newStart, newCount int) { | |
| 317 | + for _, l := range h { | |
| 318 | + if l.op != add { | |
| 319 | + if oldStart == 0 { | |
| 320 | + oldStart = l.oldNo | |
| 321 | + } | |
| 322 | + oldCount++ | |
| 323 | + } | |
| 324 | + if l.op != del { | |
| 325 | + if newStart == 0 { | |
| 326 | + newStart = l.newNo | |
| 327 | + } | |
| 328 | + newCount++ | |
| 329 | + } | |
| 330 | + } | |
| 331 | + if oldCount == 0 { | |
| 332 | + oldStart = 0 | |
| 333 | + } | |
| 334 | + if newCount == 0 { | |
| 335 | + newStart = 0 | |
| 336 | + } | |
| 337 | + return oldStart, oldCount, newStart, newCount | |
| 338 | +} | |
| 339 | + | |
| 340 | +// render formats the diff for the screen and for the model: a line number, a | |
| 341 | +// sign, the text. No colours here at all — the same text goes back to the | |
| 342 | +// model, and ANSI codes in a tool result only get in its way; the screen gets | |
| 343 | +// its grey from the caller's preview mechanism. | |
| 344 | +// | |
| 345 | +// The number shown is ALWAYS the new version's: the one to look at after the | |
| 346 | +// edit. A deleted line no longer exists there, so it gets the number of the | |
| 347 | +// place where it vanished — a replacement shows "-" and "+" on the SAME number | |
| 348 | +// and the column never goes backwards. | |
| 349 | +func (r comparison) render() string { | |
| 350 | + if !changed(r.lines) { | |
| 351 | + return "" | |
| 352 | + } | |
| 353 | + var b strings.Builder | |
| 354 | + for _, h := range hunks(r.lines) { | |
| 355 | + current := 0 | |
| 356 | + for _, l := range h { | |
| 357 | + no := l.newNo | |
| 358 | + if no > 0 { | |
| 359 | + current = no | |
| 360 | + } else { | |
| 361 | + no = current + 1 | |
| 362 | + } | |
| 363 | + fmt.Fprintf(&b, "%4d %c %s\n", no, l.op, l.text) | |
| 364 | + } | |
| 365 | + } | |
| 366 | + return b.String() | |
| 367 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,367 @@ | |||
| 1 | +package fileedit | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strings" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +// op is what happened to a line. The values are the characters of the unified | ||
| 9 | +// format, which saves a conversion table. | ||
| 10 | +type op byte | ||
| 11 | + | ||
| 12 | +const ( | ||
| 13 | + keep op = ' ' | ||
| 14 | + del op = '-' | ||
| 15 | + add op = '+' | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +// line is one line of the diff. oldNo and newNo are 1-based, 0 when the line | ||
| 19 | +// does not exist on that side. | ||
| 20 | +type line struct { | ||
| 21 | + op op | ||
| 22 | + text string | ||
| 23 | + oldNo int | ||
| 24 | + newNo int | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +// comparison is a diff ready to render: the lines, and what splitting into | ||
| 28 | +// lines cannot say — whether each side ended with a newline. Two files that | ||
| 29 | +// differ only there are two different files, and `git apply` refuses a patch | ||
| 30 | +// that ignores it. | ||
| 31 | +type comparison struct { | ||
| 32 | + lines []line | ||
| 33 | + oldNL, newNL bool | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// compare splits, compares, and returns what is needed to display and to patch. | ||
| 37 | +func compare(old, new string) comparison { | ||
| 38 | + oldLines, oldNL := splitLines(old) | ||
| 39 | + newLines, newNL := splitLines(new) | ||
| 40 | + r := comparison{lines: diffLines(oldLines, newLines), oldNL: oldNL, newNL: newNL} | ||
| 41 | + | ||
| 42 | + // Edge case: identical line by line, only the final newline moved. Nothing | ||
| 43 | + // is marked changed and the patch would be empty while the file changes. | ||
| 44 | + // The last line is replayed — deleted then added — which is exactly what | ||
| 45 | + // `git diff` prints. | ||
| 46 | + if oldNL != newNL && !changed(r.lines) && len(r.lines) > 0 { | ||
| 47 | + last := r.lines[len(r.lines)-1] | ||
| 48 | + r.lines = append(r.lines[:len(r.lines)-1], | ||
| 49 | + line{del, last.text, last.oldNo, 0}, | ||
| 50 | + line{add, last.text, 0, last.newNo}, | ||
| 51 | + ) | ||
| 52 | + } | ||
| 53 | + return r | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +func (r comparison) changed() bool { return changed(r.lines) } | ||
| 57 | + | ||
| 58 | +// firstChanged is the number, in the NEW version, of the first touched line — | ||
| 59 | +// 0 when nothing changed. Enough to open the file at the right place, or to | ||
| 60 | +// say "it starts at line 42" rather than "done". | ||
| 61 | +func (r comparison) firstChanged() int { | ||
| 62 | + for _, l := range r.lines { | ||
| 63 | + switch l.op { | ||
| 64 | + case add: | ||
| 65 | + return l.newNo | ||
| 66 | + case del: | ||
| 67 | + // A deletion has no line in the new version: point at where it | ||
| 68 | + // happened. | ||
| 69 | + return max(1, newNoBefore(r.lines, l)) | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | + return 0 | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +func (r comparison) stat() (added, deleted int) { | ||
| 76 | + for _, l := range r.lines { | ||
| 77 | + switch l.op { | ||
| 78 | + case add: | ||
| 79 | + added++ | ||
| 80 | + case del: | ||
| 81 | + deleted++ | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | + return added, deleted | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +// splitLines splits a body into lines and says whether it ended with a | ||
| 88 | +// newline. Both matter: a file without a final newline is a different file, | ||
| 89 | +// and the unified patch has to say so. | ||
| 90 | +func splitLines(body string) (lines []string, finalNewline bool) { | ||
| 91 | + if body == "" { | ||
| 92 | + return nil, true | ||
| 93 | + } | ||
| 94 | + finalNewline = strings.HasSuffix(body, "\n") | ||
| 95 | + if finalNewline { | ||
| 96 | + body = body[:len(body)-1] | ||
| 97 | + } | ||
| 98 | + return strings.Split(body, "\n"), finalNewline | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +// maxCells caps the LCS table. Past it, a line-by-line comparison would cost | ||
| 102 | +// hundreds of megabytes for a diff nobody will read: the middle block is then | ||
| 103 | +// rendered as a block (all deleted, all added), still exact — only coarser. | ||
| 104 | +const maxCells = 4 << 20 | ||
| 105 | + | ||
| 106 | +// diffLines compares two sequences of lines. | ||
| 107 | +// | ||
| 108 | +// Common prefix and suffix are removed BEFORE the LCS: a targeted edit touches | ||
| 109 | +// a few lines in the middle of a file, and without that cut the table would be | ||
| 110 | +// the size of the file squared. | ||
| 111 | +func diffLines(old, new []string) []line { | ||
| 112 | + var out []line | ||
| 113 | + | ||
| 114 | + head := 0 | ||
| 115 | + for head < len(old) && head < len(new) && old[head] == new[head] { | ||
| 116 | + out = append(out, line{keep, old[head], head + 1, head + 1}) | ||
| 117 | + head++ | ||
| 118 | + } | ||
| 119 | + tail := 0 | ||
| 120 | + for tail < len(old)-head && tail < len(new)-head && | ||
| 121 | + old[len(old)-1-tail] == new[len(new)-1-tail] { | ||
| 122 | + tail++ | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + midOld, midNew := old[head:len(old)-tail], new[head:len(new)-tail] | ||
| 126 | + out = append(out, middle(midOld, midNew, head)...) | ||
| 127 | + | ||
| 128 | + for i := len(old) - tail; i < len(old); i++ { | ||
| 129 | + out = append(out, line{keep, old[i], i + 1, i + 1 - len(old) + len(new)}) | ||
| 130 | + } | ||
| 131 | + return out | ||
| 132 | +} | ||
| 133 | + | ||
| 134 | +// middle compares what remains after trimming the edges. `off` is the number | ||
| 135 | +// of lines already consumed, to number correctly. | ||
| 136 | +func middle(old, new []string, off int) []line { | ||
| 137 | + if len(old) == 0 && len(new) == 0 { | ||
| 138 | + return nil | ||
| 139 | + } | ||
| 140 | + if len(old)*len(new) > maxCells { | ||
| 141 | + out := make([]line, 0, len(old)+len(new)) | ||
| 142 | + for i, l := range old { | ||
| 143 | + out = append(out, line{del, l, off + i + 1, 0}) | ||
| 144 | + } | ||
| 145 | + for j, l := range new { | ||
| 146 | + out = append(out, line{add, l, 0, off + j + 1}) | ||
| 147 | + } | ||
| 148 | + return out | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + // Classic LCS: length table, then walk. Files edited by an agent fit in it | ||
| 152 | + // comfortably. | ||
| 153 | + lcs := make([][]int, len(old)+1) | ||
| 154 | + for i := range lcs { | ||
| 155 | + lcs[i] = make([]int, len(new)+1) | ||
| 156 | + } | ||
| 157 | + for i := len(old) - 1; i >= 0; i-- { | ||
| 158 | + for j := len(new) - 1; j >= 0; j-- { | ||
| 159 | + if old[i] == new[j] { | ||
| 160 | + lcs[i][j] = lcs[i+1][j+1] + 1 | ||
| 161 | + } else { | ||
| 162 | + lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1]) | ||
| 163 | + } | ||
| 164 | + } | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + var out []line | ||
| 168 | + i, j := 0, 0 | ||
| 169 | + for i < len(old) && j < len(new) { | ||
| 170 | + switch { | ||
| 171 | + case old[i] == new[j]: | ||
| 172 | + out = append(out, line{keep, old[i], off + i + 1, off + j + 1}) | ||
| 173 | + i, j = i+1, j+1 | ||
| 174 | + case lcs[i+1][j] >= lcs[i][j+1]: | ||
| 175 | + // Deletion first on a tie: a replacement then reads "- old" then | ||
| 176 | + // "+ new", the order one expects. | ||
| 177 | + out = append(out, line{del, old[i], off + i + 1, 0}) | ||
| 178 | + i++ | ||
| 179 | + default: | ||
| 180 | + out = append(out, line{add, new[j], 0, off + j + 1}) | ||
| 181 | + j++ | ||
| 182 | + } | ||
| 183 | + } | ||
| 184 | + for ; i < len(old); i++ { | ||
| 185 | + out = append(out, line{del, old[i], off + i + 1, 0}) | ||
| 186 | + } | ||
| 187 | + for ; j < len(new); j++ { | ||
| 188 | + out = append(out, line{add, new[j], 0, off + j + 1}) | ||
| 189 | + } | ||
| 190 | + return out | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +// newNoBefore finds the number of the kept line that precedes `target`. | ||
| 194 | +func newNoBefore(lines []line, target line) int { | ||
| 195 | + last := 0 | ||
| 196 | + for _, l := range lines { | ||
| 197 | + if l == target { | ||
| 198 | + break | ||
| 199 | + } | ||
| 200 | + if l.newNo > 0 { | ||
| 201 | + last = l.newNo | ||
| 202 | + } | ||
| 203 | + } | ||
| 204 | + return last + 1 | ||
| 205 | +} | ||
| 206 | + | ||
| 207 | +func changed(lines []line) bool { | ||
| 208 | + for _, l := range lines { | ||
| 209 | + if l.op != keep { | ||
| 210 | + return true | ||
| 211 | + } | ||
| 212 | + } | ||
| 213 | + return false | ||
| 214 | +} | ||
| 215 | + | ||
| 216 | +// noNewline is the line the unified format requires when a file does not end | ||
| 217 | +// with a newline. `git apply` reads it; without it, it adds a newline nobody | ||
| 218 | +// asked for. | ||
| 219 | +const noNewline = `\ No newline at end of file` | ||
| 220 | + | ||
| 221 | +// contextLines is how many unchanged lines are kept around each change. Three | ||
| 222 | +// is the value of `diff -u` and of git. | ||
| 223 | +const contextLines = 3 | ||
| 224 | + | ||
| 225 | +// unified renders a unified patch, applicable as-is by `patch -p1` or | ||
| 226 | +// `git apply`. Paths are prefixed a/ and b/ like git does. | ||
| 227 | +func (r comparison) unified(path string) string { | ||
| 228 | + if !changed(r.lines) { | ||
| 229 | + return "" | ||
| 230 | + } | ||
| 231 | + var b strings.Builder | ||
| 232 | + fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", path, path) | ||
| 233 | + | ||
| 234 | + lastOld, lastNew := 0, 0 | ||
| 235 | + for _, l := range r.lines { | ||
| 236 | + lastOld, lastNew = max(lastOld, l.oldNo), max(lastNew, l.newNo) | ||
| 237 | + } | ||
| 238 | + for _, h := range hunks(r.lines) { | ||
| 239 | + oldStart, oldCount, newStart, newCount := h.ranges() | ||
| 240 | + fmt.Fprintf(&b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount) | ||
| 241 | + for _, l := range h { | ||
| 242 | + b.WriteByte(byte(l.op)) | ||
| 243 | + b.WriteString(l.text) | ||
| 244 | + b.WriteByte('\n') | ||
| 245 | + // The marker follows the LAST line of each version, and a kept | ||
| 246 | + // line belongs to both: it may carry it for both at once. | ||
| 247 | + if (!r.oldNL && l.oldNo == lastOld && l.op != add) || | ||
| 248 | + (!r.newNL && l.newNo == lastNew && l.op != del) { | ||
| 249 | + b.WriteString(noNewline + "\n") | ||
| 250 | + } | ||
| 251 | + } | ||
| 252 | + } | ||
| 253 | + return b.String() | ||
| 254 | +} | ||
| 255 | + | ||
| 256 | +// hunk is a group of contiguous lines shown together. | ||
| 257 | +type hunk []line | ||
| 258 | + | ||
| 259 | +// hunks cuts the diff into pieces: each change plus `contextLines` on both | ||
| 260 | +// sides. Touching pieces are merged — otherwise two neighbouring changes would | ||
| 261 | +// produce overlapping @@ headers, and the patch would be refused. | ||
| 262 | +func hunks(lines []line) []hunk { | ||
| 263 | + var out []hunk | ||
| 264 | + i := 0 | ||
| 265 | + for i < len(lines) { | ||
| 266 | + if lines[i].op == keep { | ||
| 267 | + i++ | ||
| 268 | + continue | ||
| 269 | + } | ||
| 270 | + start := max(0, i-contextLines) | ||
| 271 | + if n := len(out); n > 0 { | ||
| 272 | + if prevEnd := indexAfter(lines, out[n-1]); prevEnd >= start { | ||
| 273 | + out[n-1] = append(out[n-1], lines[prevEnd:hunkEnd(lines, i)]...) | ||
| 274 | + i = hunkEnd(lines, i) | ||
| 275 | + continue | ||
| 276 | + } | ||
| 277 | + } | ||
| 278 | + out = append(out, append(hunk(nil), lines[start:hunkEnd(lines, i)]...)) | ||
| 279 | + i = hunkEnd(lines, i) | ||
| 280 | + } | ||
| 281 | + return out | ||
| 282 | +} | ||
| 283 | + | ||
| 284 | +// hunkEnd finds the end of the piece opened at `i`: advance while a change | ||
| 285 | +// occurs within `contextLines` lines, then keep the trailing context. | ||
| 286 | +func hunkEnd(lines []line, i int) int { | ||
| 287 | + last := i | ||
| 288 | + for j := i; j < len(lines); j++ { | ||
| 289 | + if lines[j].op != keep { | ||
| 290 | + last = j | ||
| 291 | + continue | ||
| 292 | + } | ||
| 293 | + if j-last > contextLines { | ||
| 294 | + break | ||
| 295 | + } | ||
| 296 | + } | ||
| 297 | + return min(len(lines), last+contextLines+1) | ||
| 298 | +} | ||
| 299 | + | ||
| 300 | +// indexAfter returns the position, in `lines`, of the line following the last | ||
| 301 | +// element of `h`. | ||
| 302 | +func indexAfter(lines []line, h hunk) int { | ||
| 303 | + if len(h) == 0 { | ||
| 304 | + return 0 | ||
| 305 | + } | ||
| 306 | + target := h[len(h)-1] | ||
| 307 | + for i, l := range lines { | ||
| 308 | + if l == target { | ||
| 309 | + return i + 1 | ||
| 310 | + } | ||
| 311 | + } | ||
| 312 | + return 0 | ||
| 313 | +} | ||
| 314 | + | ||
| 315 | +// ranges computes the @@ header of the piece. | ||
| 316 | +func (h hunk) ranges() (oldStart, oldCount, newStart, newCount int) { | ||
| 317 | + for _, l := range h { | ||
| 318 | + if l.op != add { | ||
| 319 | + if oldStart == 0 { | ||
| 320 | + oldStart = l.oldNo | ||
| 321 | + } | ||
| 322 | + oldCount++ | ||
| 323 | + } | ||
| 324 | + if l.op != del { | ||
| 325 | + if newStart == 0 { | ||
| 326 | + newStart = l.newNo | ||
| 327 | + } | ||
| 328 | + newCount++ | ||
| 329 | + } | ||
| 330 | + } | ||
| 331 | + if oldCount == 0 { | ||
| 332 | + oldStart = 0 | ||
| 333 | + } | ||
| 334 | + if newCount == 0 { | ||
| 335 | + newStart = 0 | ||
| 336 | + } | ||
| 337 | + return oldStart, oldCount, newStart, newCount | ||
| 338 | +} | ||
| 339 | + | ||
| 340 | +// render formats the diff for the screen and for the model: a line number, a | ||
| 341 | +// sign, the text. No colours here at all — the same text goes back to the | ||
| 342 | +// model, and ANSI codes in a tool result only get in its way; the screen gets | ||
| 343 | +// its grey from the caller's preview mechanism. | ||
| 344 | +// | ||
| 345 | +// The number shown is ALWAYS the new version's: the one to look at after the | ||
| 346 | +// edit. A deleted line no longer exists there, so it gets the number of the | ||
| 347 | +// place where it vanished — a replacement shows "-" and "+" on the SAME number | ||
| 348 | +// and the column never goes backwards. | ||
| 349 | +func (r comparison) render() string { | ||
| 350 | + if !changed(r.lines) { | ||
| 351 | + return "" | ||
| 352 | + } | ||
| 353 | + var b strings.Builder | ||
| 354 | + for _, h := range hunks(r.lines) { | ||
| 355 | + current := 0 | ||
| 356 | + for _, l := range h { | ||
| 357 | + no := l.newNo | ||
| 358 | + if no > 0 { | ||
| 359 | + current = no | ||
| 360 | + } else { | ||
| 361 | + no = current + 1 | ||
| 362 | + } | ||
| 363 | + fmt.Fprintf(&b, "%4d %c %s\n", no, l.op, l.text) | ||
| 364 | + } | ||
| 365 | + } | ||
| 366 | + return b.String() | ||
| 367 | +} | ||
added
internal/fileedit/edits.go +122 -0 | new file mode 100644 | ||
| @@ -0,0 +1,122 @@ | ||
| 1 | +package fileedit | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "sort" | |
| 6 | + "strings" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// Edit is one replacement: a text to find, a text to put in its place. The | |
| 10 | +// JSON names are the ones the model sees in the edit_file schema. | |
| 11 | +type Edit struct { | |
| 12 | + Old string `json:"old" jsonschema_description:"Exact text to find: copied from the file character for character, and present exactly once"` | |
| 13 | + New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"` | |
| 14 | +} | |
| 15 | + | |
| 16 | +// span is the region an edit occupies in the ORIGINAL body. | |
| 17 | +type span struct { | |
| 18 | + start, end int | |
| 19 | + index int // rank of the edit, for error messages | |
| 20 | +} | |
| 21 | + | |
| 22 | +// apply applies every edit to the body and returns the result. | |
| 23 | +// | |
| 24 | +// The rules of the `edit` CLI, kept to the letter because they are what makes | |
| 25 | +// an edit verifiable: each `old` appears once and only once; edits are resolved | |
| 26 | +// against the ORIGINAL body, never in cascade; two edits must not touch, nest | |
| 27 | +// or repeat; an empty `new` deletes. An ambiguous edit — two occurrences, two | |
| 28 | +// overlapping regions — would give a result that depends on application order, | |
| 29 | +// hence a file nobody has read. Nothing is written when one edit fails: a | |
| 30 | +// half-edited file is the worst outcome, because it looks like a success. | |
| 31 | +func apply(body string, list []Edit) (string, error) { | |
| 32 | + if len(list) == 0 { | |
| 33 | + return "", fmt.Errorf("no edit given: each edit needs an old and a new text") | |
| 34 | + } | |
| 35 | + | |
| 36 | + spans := make([]span, 0, len(list)) | |
| 37 | + for i, e := range list { | |
| 38 | + s, err := locate(body, e, i) | |
| 39 | + if err != nil { | |
| 40 | + return "", err | |
| 41 | + } | |
| 42 | + spans = append(spans, s) | |
| 43 | + } | |
| 44 | + | |
| 45 | + // Sorted by position: the overlap check and the rebuild both walk the file | |
| 46 | + // in its order, not in the order of the request. | |
| 47 | + sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) | |
| 48 | + for i := 1; i < len(spans); i++ { | |
| 49 | + if spans[i].start < spans[i-1].end { | |
| 50 | + a, b := spans[i-1], spans[i] | |
| 51 | + return "", fmt.Errorf( | |
| 52 | + "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair", | |
| 53 | + a.index+1, b.index+1, | |
| 54 | + lineOf(body, a.start), lineOf(body, a.end), | |
| 55 | + lineOf(body, b.start), lineOf(body, b.end)) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + var b strings.Builder | |
| 60 | + at := 0 | |
| 61 | + for _, s := range spans { | |
| 62 | + b.WriteString(body[at:s.start]) | |
| 63 | + b.WriteString(list[s.index].New) | |
| 64 | + at = s.end | |
| 65 | + } | |
| 66 | + b.WriteString(body[at:]) | |
| 67 | + return b.String(), nil | |
| 68 | +} | |
| 69 | + | |
| 70 | +// locate finds the single occurrence of an `old` text. | |
| 71 | +// | |
| 72 | +// The messages are written FOR A MODEL: they say what is wrong and the move | |
| 73 | +// that fixes it, because a model is what will retry the call. | |
| 74 | +func locate(body string, e Edit, i int) (span, error) { | |
| 75 | + if e.Old == "" { | |
| 76 | + return span{}, fmt.Errorf( | |
| 77 | + "edit #%d has an empty old text: it would match everywhere — use write_file to replace the whole file", | |
| 78 | + i+1) | |
| 79 | + } | |
| 80 | + if e.Old == e.New { | |
| 81 | + return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1) | |
| 82 | + } | |
| 83 | + | |
| 84 | + switch n := strings.Count(body, e.Old); { | |
| 85 | + case n == 0: | |
| 86 | + return span{}, fmt.Errorf( | |
| 87 | + "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s", | |
| 88 | + i+1, quote(e.Old)) | |
| 89 | + case n > 1: | |
| 90 | + return span{}, fmt.Errorf( | |
| 91 | + "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s", | |
| 92 | + i+1, n, quote(e.Old)) | |
| 93 | + } | |
| 94 | + | |
| 95 | + start := strings.Index(body, e.Old) | |
| 96 | + return span{start: start, end: start + len(e.Old), index: i}, nil | |
| 97 | +} | |
| 98 | + | |
| 99 | +// lineOf returns the 1-based line number of an offset in the body. | |
| 100 | +func lineOf(body string, offset int) int { | |
| 101 | + return 1 + strings.Count(body[:offset], "\n") | |
| 102 | +} | |
| 103 | + | |
| 104 | +// quote shows the searched text, indented, capped to its first lines: past | |
| 105 | +// that the error would drown the output and teach nothing more. | |
| 106 | +func quote(s string) string { | |
| 107 | + const maxLines = 6 | |
| 108 | + | |
| 109 | + lines := strings.Split(s, "\n") | |
| 110 | + shown := lines | |
| 111 | + if len(lines) > maxLines { | |
| 112 | + shown = lines[:maxLines] | |
| 113 | + } | |
| 114 | + var b strings.Builder | |
| 115 | + for _, l := range shown { | |
| 116 | + b.WriteString(" │ " + l + "\n") | |
| 117 | + } | |
| 118 | + if len(lines) > len(shown) { | |
| 119 | + fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown)) | |
| 120 | + } | |
| 121 | + return strings.TrimRight(b.String(), "\n") | |
| 122 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | +package fileedit | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "sort" | ||
| 6 | + "strings" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// Edit is one replacement: a text to find, a text to put in its place. The | ||
| 10 | +// JSON names are the ones the model sees in the edit_file schema. | ||
| 11 | +type Edit struct { | ||
| 12 | + Old string `json:"old" jsonschema_description:"Exact text to find: copied from the file character for character, and present exactly once"` | ||
| 13 | + New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"` | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +// span is the region an edit occupies in the ORIGINAL body. | ||
| 17 | +type span struct { | ||
| 18 | + start, end int | ||
| 19 | + index int // rank of the edit, for error messages | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +// apply applies every edit to the body and returns the result. | ||
| 23 | +// | ||
| 24 | +// The rules of the `edit` CLI, kept to the letter because they are what makes | ||
| 25 | +// an edit verifiable: each `old` appears once and only once; edits are resolved | ||
| 26 | +// against the ORIGINAL body, never in cascade; two edits must not touch, nest | ||
| 27 | +// or repeat; an empty `new` deletes. An ambiguous edit — two occurrences, two | ||
| 28 | +// overlapping regions — would give a result that depends on application order, | ||
| 29 | +// hence a file nobody has read. Nothing is written when one edit fails: a | ||
| 30 | +// half-edited file is the worst outcome, because it looks like a success. | ||
| 31 | +func apply(body string, list []Edit) (string, error) { | ||
| 32 | + if len(list) == 0 { | ||
| 33 | + return "", fmt.Errorf("no edit given: each edit needs an old and a new text") | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + spans := make([]span, 0, len(list)) | ||
| 37 | + for i, e := range list { | ||
| 38 | + s, err := locate(body, e, i) | ||
| 39 | + if err != nil { | ||
| 40 | + return "", err | ||
| 41 | + } | ||
| 42 | + spans = append(spans, s) | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + // Sorted by position: the overlap check and the rebuild both walk the file | ||
| 46 | + // in its order, not in the order of the request. | ||
| 47 | + sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start }) | ||
| 48 | + for i := 1; i < len(spans); i++ { | ||
| 49 | + if spans[i].start < spans[i-1].end { | ||
| 50 | + a, b := spans[i-1], spans[i] | ||
| 51 | + return "", fmt.Errorf( | ||
| 52 | + "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair", | ||
| 53 | + a.index+1, b.index+1, | ||
| 54 | + lineOf(body, a.start), lineOf(body, a.end), | ||
| 55 | + lineOf(body, b.start), lineOf(body, b.end)) | ||
| 56 | + } | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + var b strings.Builder | ||
| 60 | + at := 0 | ||
| 61 | + for _, s := range spans { | ||
| 62 | + b.WriteString(body[at:s.start]) | ||
| 63 | + b.WriteString(list[s.index].New) | ||
| 64 | + at = s.end | ||
| 65 | + } | ||
| 66 | + b.WriteString(body[at:]) | ||
| 67 | + return b.String(), nil | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// locate finds the single occurrence of an `old` text. | ||
| 71 | +// | ||
| 72 | +// The messages are written FOR A MODEL: they say what is wrong and the move | ||
| 73 | +// that fixes it, because a model is what will retry the call. | ||
| 74 | +func locate(body string, e Edit, i int) (span, error) { | ||
| 75 | + if e.Old == "" { | ||
| 76 | + return span{}, fmt.Errorf( | ||
| 77 | + "edit #%d has an empty old text: it would match everywhere — use write_file to replace the whole file", | ||
| 78 | + i+1) | ||
| 79 | + } | ||
| 80 | + if e.Old == e.New { | ||
| 81 | + return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1) | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + switch n := strings.Count(body, e.Old); { | ||
| 85 | + case n == 0: | ||
| 86 | + return span{}, fmt.Errorf( | ||
| 87 | + "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s", | ||
| 88 | + i+1, quote(e.Old)) | ||
| 89 | + case n > 1: | ||
| 90 | + return span{}, fmt.Errorf( | ||
| 91 | + "edit #%d: old text appears %d times — add the surrounding lines until it is unique:\n%s", | ||
| 92 | + i+1, n, quote(e.Old)) | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + start := strings.Index(body, e.Old) | ||
| 96 | + return span{start: start, end: start + len(e.Old), index: i}, nil | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +// lineOf returns the 1-based line number of an offset in the body. | ||
| 100 | +func lineOf(body string, offset int) int { | ||
| 101 | + return 1 + strings.Count(body[:offset], "\n") | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// quote shows the searched text, indented, capped to its first lines: past | ||
| 105 | +// that the error would drown the output and teach nothing more. | ||
| 106 | +func quote(s string) string { | ||
| 107 | + const maxLines = 6 | ||
| 108 | + | ||
| 109 | + lines := strings.Split(s, "\n") | ||
| 110 | + shown := lines | ||
| 111 | + if len(lines) > maxLines { | ||
| 112 | + shown = lines[:maxLines] | ||
| 113 | + } | ||
| 114 | + var b strings.Builder | ||
| 115 | + for _, l := range shown { | ||
| 116 | + b.WriteString(" │ " + l + "\n") | ||
| 117 | + } | ||
| 118 | + if len(lines) > len(shown) { | ||
| 119 | + fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown)) | ||
| 120 | + } | ||
| 121 | + return strings.TrimRight(b.String(), "\n") | ||
| 122 | +} | ||
added
internal/fileedit/fileedit.go +166 -0 | new file mode 100644 | ||
| @@ -0,0 +1,166 @@ | ||
| 1 | +package fileedit | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "strings" | |
| 6 | +) | |
| 7 | + | |
| 8 | +// Result is what a write or a replace reports — the fields the `edit` CLI | |
| 9 | +// prints and the Pi tool returns: a headline, the readable diff, the unified | |
| 10 | +// patch, the first changed line. Headline + Diff is what goes back to the | |
| 11 | +// model; the caller decides what the screen shows. | |
| 12 | +type Result struct { | |
| 13 | + Path string | |
| 14 | + Created bool | |
| 15 | + Changed bool | |
| 16 | + DryRun bool | |
| 17 | + Edits int | |
| 18 | + Added, Deleted int | |
| 19 | + FirstChangedLine int | |
| 20 | + Headline string | |
| 21 | + Diff string | |
| 22 | + Patch string | |
| 23 | +} | |
| 24 | + | |
| 25 | +// Read returns the file, or a slice of it (1-based, inclusive; 0 = from the | |
| 26 | +// start / to the end). It is the mandatory companion of Replace: an exact | |
| 27 | +// replacement assumes the exact text was READ, not remembered. | |
| 28 | +func Read(path string, start, end int, numbered bool) (string, error) { | |
| 29 | + f, err := load(path) | |
| 30 | + if err != nil { | |
| 31 | + return "", fmt.Errorf("cannot read %s: %v", path, err) | |
| 32 | + } | |
| 33 | + if f.isNew { | |
| 34 | + return "", fmt.Errorf("%s does not exist. Create it with write_file", path) | |
| 35 | + } | |
| 36 | + | |
| 37 | + lines, finalNewline := splitLines(f.body) | |
| 38 | + from, to, err := bounds(start, end, len(lines)) | |
| 39 | + if err != nil { | |
| 40 | + return "", fmt.Errorf("%s: %v", path, err) | |
| 41 | + } | |
| 42 | + slice := lines[from-1 : to] | |
| 43 | + | |
| 44 | + if numbered { | |
| 45 | + var b strings.Builder | |
| 46 | + for i, l := range slice { | |
| 47 | + fmt.Fprintf(&b, "%6d %s\n", from+i, l) | |
| 48 | + } | |
| 49 | + return b.String(), nil | |
| 50 | + } | |
| 51 | + body := strings.Join(slice, "\n") | |
| 52 | + // The final newline is only put back if it existed AND the end of the file | |
| 53 | + // is shown: otherwise a slice would invent a line the file does not have. | |
| 54 | + if finalNewline || to < len(lines) { | |
| 55 | + body += "\n" | |
| 56 | + } | |
| 57 | + return body, nil | |
| 58 | +} | |
| 59 | + | |
| 60 | +// bounds turns start/end into a valid slice, or says why it is not one. | |
| 61 | +func bounds(start, end, total int) (int, int, error) { | |
| 62 | + if total == 0 { | |
| 63 | + return 1, 0, nil // empty file: nothing to show, not an error | |
| 64 | + } | |
| 65 | + if start == 0 { | |
| 66 | + start = 1 | |
| 67 | + } | |
| 68 | + if end == 0 { | |
| 69 | + end = total | |
| 70 | + } | |
| 71 | + if start < 1 || end < 1 { | |
| 72 | + return 0, 0, fmt.Errorf("start and end are 1-based line numbers") | |
| 73 | + } | |
| 74 | + if start > total { | |
| 75 | + return 0, 0, fmt.Errorf("start %d is past the end of the file (%d lines)", start, total) | |
| 76 | + } | |
| 77 | + if end > total { | |
| 78 | + end = total | |
| 79 | + } | |
| 80 | + if start > end { | |
| 81 | + return 0, 0, fmt.Errorf("start %d comes after end %d", start, end) | |
| 82 | + } | |
| 83 | + return start, end, nil | |
| 84 | +} | |
| 85 | + | |
| 86 | +// Write writes the WHOLE file. The tool for a new file or a deliberate | |
| 87 | +// rewrite; to change three lines of an existing file, Replace is safer — it | |
| 88 | +// fails when the targeted text is not what one thought, where Write overwrites | |
| 89 | +// without checking. | |
| 90 | +func Write(path, content string) (Result, error) { | |
| 91 | + f, err := load(path) | |
| 92 | + if err != nil { | |
| 93 | + return Result{}, fmt.Errorf("cannot read %s: %v", path, err) | |
| 94 | + } | |
| 95 | + // A content without a final newline would give a file without one: on a | |
| 96 | + // text file that is almost always an accident, and every diff tool then | |
| 97 | + // flags it. | |
| 98 | + if content != "" && !strings.HasSuffix(content, "\n") { | |
| 99 | + content += "\n" | |
| 100 | + } | |
| 101 | + return finish(Result{Path: path, Created: f.isNew}, f, content) | |
| 102 | +} | |
| 103 | + | |
| 104 | +// Replace applies exact replacements to an existing file — the operation that | |
| 105 | +// matters: it changes a file without rewriting it, and refuses anything | |
| 106 | +// ambiguous. With dryRun the diff is computed and nothing is written. | |
| 107 | +func Replace(path string, edits []Edit, dryRun bool) (Result, error) { | |
| 108 | + f, err := load(path) | |
| 109 | + if err != nil { | |
| 110 | + return Result{}, fmt.Errorf("cannot read %s: %v", path, err) | |
| 111 | + } | |
| 112 | + if f.isNew { | |
| 113 | + return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path) | |
| 114 | + } | |
| 115 | + body, err := apply(f.body, edits) | |
| 116 | + if err != nil { | |
| 117 | + // The error carries the file name: it is read out of context, in a | |
| 118 | + // tool result among others. | |
| 119 | + return Result{}, fmt.Errorf("%s: %v", path, err) | |
| 120 | + } | |
| 121 | + return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body) | |
| 122 | +} | |
| 123 | + | |
| 124 | +// finish compares, writes if needed, and reports. Write and Replace both end | |
| 125 | +// here: that is what gives them exactly the same output, hence one format for | |
| 126 | +// the model to learn. | |
| 127 | +func finish(r Result, f *file, body string) (Result, error) { | |
| 128 | + c := compare(f.body, body) | |
| 129 | + r.Changed = c.changed() | |
| 130 | + r.Added, r.Deleted = c.stat() | |
| 131 | + r.FirstChangedLine = c.firstChanged() | |
| 132 | + r.Diff = c.render() | |
| 133 | + r.Patch = c.unified(r.Path) | |
| 134 | + | |
| 135 | + // Nothing to write: SAY so rather than touch the file. An identical | |
| 136 | + // rewrite would bump the modification time and wake every watcher, for | |
| 137 | + // nothing. | |
| 138 | + if r.Changed && !r.DryRun { | |
| 139 | + if err := f.write(body); err != nil { | |
| 140 | + return Result{}, fmt.Errorf("cannot write %s: %v", r.Path, err) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + r.Headline = headline(r) | |
| 144 | + return r, nil | |
| 145 | +} | |
| 146 | + | |
| 147 | +// headline sums the operation up in one line — the one the model re-reads in | |
| 148 | +// its tool result, and the one the screen shows under the display line. | |
| 149 | +func headline(r Result) string { | |
| 150 | + switch { | |
| 151 | + case !r.Changed: | |
| 152 | + return fmt.Sprintf("%s: unchanged", r.Path) | |
| 153 | + case r.Created && r.DryRun: | |
| 154 | + return fmt.Sprintf("%s: would be created, %d line(s)", r.Path, r.Added) | |
| 155 | + case r.Created: | |
| 156 | + return fmt.Sprintf("%s: created, %d line(s)", r.Path, r.Added) | |
| 157 | + } | |
| 158 | + what := fmt.Sprintf("+%d -%d, first change at line %d", r.Added, r.Deleted, r.FirstChangedLine) | |
| 159 | + if r.Edits > 0 { | |
| 160 | + what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what) | |
| 161 | + } | |
| 162 | + if r.DryRun { | |
| 163 | + return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what) | |
| 164 | + } | |
| 165 | + return fmt.Sprintf("%s: %s", r.Path, what) | |
| 166 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,166 @@ | |||
| 1 | +package fileedit | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "strings" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +// Result is what a write or a replace reports — the fields the `edit` CLI | ||
| 9 | +// prints and the Pi tool returns: a headline, the readable diff, the unified | ||
| 10 | +// patch, the first changed line. Headline + Diff is what goes back to the | ||
| 11 | +// model; the caller decides what the screen shows. | ||
| 12 | +type Result struct { | ||
| 13 | + Path string | ||
| 14 | + Created bool | ||
| 15 | + Changed bool | ||
| 16 | + DryRun bool | ||
| 17 | + Edits int | ||
| 18 | + Added, Deleted int | ||
| 19 | + FirstChangedLine int | ||
| 20 | + Headline string | ||
| 21 | + Diff string | ||
| 22 | + Patch string | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +// Read returns the file, or a slice of it (1-based, inclusive; 0 = from the | ||
| 26 | +// start / to the end). It is the mandatory companion of Replace: an exact | ||
| 27 | +// replacement assumes the exact text was READ, not remembered. | ||
| 28 | +func Read(path string, start, end int, numbered bool) (string, error) { | ||
| 29 | + f, err := load(path) | ||
| 30 | + if err != nil { | ||
| 31 | + return "", fmt.Errorf("cannot read %s: %v", path, err) | ||
| 32 | + } | ||
| 33 | + if f.isNew { | ||
| 34 | + return "", fmt.Errorf("%s does not exist. Create it with write_file", path) | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + lines, finalNewline := splitLines(f.body) | ||
| 38 | + from, to, err := bounds(start, end, len(lines)) | ||
| 39 | + if err != nil { | ||
| 40 | + return "", fmt.Errorf("%s: %v", path, err) | ||
| 41 | + } | ||
| 42 | + slice := lines[from-1 : to] | ||
| 43 | + | ||
| 44 | + if numbered { | ||
| 45 | + var b strings.Builder | ||
| 46 | + for i, l := range slice { | ||
| 47 | + fmt.Fprintf(&b, "%6d %s\n", from+i, l) | ||
| 48 | + } | ||
| 49 | + return b.String(), nil | ||
| 50 | + } | ||
| 51 | + body := strings.Join(slice, "\n") | ||
| 52 | + // The final newline is only put back if it existed AND the end of the file | ||
| 53 | + // is shown: otherwise a slice would invent a line the file does not have. | ||
| 54 | + if finalNewline || to < len(lines) { | ||
| 55 | + body += "\n" | ||
| 56 | + } | ||
| 57 | + return body, nil | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// bounds turns start/end into a valid slice, or says why it is not one. | ||
| 61 | +func bounds(start, end, total int) (int, int, error) { | ||
| 62 | + if total == 0 { | ||
| 63 | + return 1, 0, nil // empty file: nothing to show, not an error | ||
| 64 | + } | ||
| 65 | + if start == 0 { | ||
| 66 | + start = 1 | ||
| 67 | + } | ||
| 68 | + if end == 0 { | ||
| 69 | + end = total | ||
| 70 | + } | ||
| 71 | + if start < 1 || end < 1 { | ||
| 72 | + return 0, 0, fmt.Errorf("start and end are 1-based line numbers") | ||
| 73 | + } | ||
| 74 | + if start > total { | ||
| 75 | + return 0, 0, fmt.Errorf("start %d is past the end of the file (%d lines)", start, total) | ||
| 76 | + } | ||
| 77 | + if end > total { | ||
| 78 | + end = total | ||
| 79 | + } | ||
| 80 | + if start > end { | ||
| 81 | + return 0, 0, fmt.Errorf("start %d comes after end %d", start, end) | ||
| 82 | + } | ||
| 83 | + return start, end, nil | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +// Write writes the WHOLE file. The tool for a new file or a deliberate | ||
| 87 | +// rewrite; to change three lines of an existing file, Replace is safer — it | ||
| 88 | +// fails when the targeted text is not what one thought, where Write overwrites | ||
| 89 | +// without checking. | ||
| 90 | +func Write(path, content string) (Result, error) { | ||
| 91 | + f, err := load(path) | ||
| 92 | + if err != nil { | ||
| 93 | + return Result{}, fmt.Errorf("cannot read %s: %v", path, err) | ||
| 94 | + } | ||
| 95 | + // A content without a final newline would give a file without one: on a | ||
| 96 | + // text file that is almost always an accident, and every diff tool then | ||
| 97 | + // flags it. | ||
| 98 | + if content != "" && !strings.HasSuffix(content, "\n") { | ||
| 99 | + content += "\n" | ||
| 100 | + } | ||
| 101 | + return finish(Result{Path: path, Created: f.isNew}, f, content) | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// Replace applies exact replacements to an existing file — the operation that | ||
| 105 | +// matters: it changes a file without rewriting it, and refuses anything | ||
| 106 | +// ambiguous. With dryRun the diff is computed and nothing is written. | ||
| 107 | +func Replace(path string, edits []Edit, dryRun bool) (Result, error) { | ||
| 108 | + f, err := load(path) | ||
| 109 | + if err != nil { | ||
| 110 | + return Result{}, fmt.Errorf("cannot read %s: %v", path, err) | ||
| 111 | + } | ||
| 112 | + if f.isNew { | ||
| 113 | + return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path) | ||
| 114 | + } | ||
| 115 | + body, err := apply(f.body, edits) | ||
| 116 | + if err != nil { | ||
| 117 | + // The error carries the file name: it is read out of context, in a | ||
| 118 | + // tool result among others. | ||
| 119 | + return Result{}, fmt.Errorf("%s: %v", path, err) | ||
| 120 | + } | ||
| 121 | + return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body) | ||
| 122 | +} | ||
| 123 | + | ||
| 124 | +// finish compares, writes if needed, and reports. Write and Replace both end | ||
| 125 | +// here: that is what gives them exactly the same output, hence one format for | ||
| 126 | +// the model to learn. | ||
| 127 | +func finish(r Result, f *file, body string) (Result, error) { | ||
| 128 | + c := compare(f.body, body) | ||
| 129 | + r.Changed = c.changed() | ||
| 130 | + r.Added, r.Deleted = c.stat() | ||
| 131 | + r.FirstChangedLine = c.firstChanged() | ||
| 132 | + r.Diff = c.render() | ||
| 133 | + r.Patch = c.unified(r.Path) | ||
| 134 | + | ||
| 135 | + // Nothing to write: SAY so rather than touch the file. An identical | ||
| 136 | + // rewrite would bump the modification time and wake every watcher, for | ||
| 137 | + // nothing. | ||
| 138 | + if r.Changed && !r.DryRun { | ||
| 139 | + if err := f.write(body); err != nil { | ||
| 140 | + return Result{}, fmt.Errorf("cannot write %s: %v", r.Path, err) | ||
| 141 | + } | ||
| 142 | + } | ||
| 143 | + r.Headline = headline(r) | ||
| 144 | + return r, nil | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +// headline sums the operation up in one line — the one the model re-reads in | ||
| 148 | +// its tool result, and the one the screen shows under the display line. | ||
| 149 | +func headline(r Result) string { | ||
| 150 | + switch { | ||
| 151 | + case !r.Changed: | ||
| 152 | + return fmt.Sprintf("%s: unchanged", r.Path) | ||
| 153 | + case r.Created && r.DryRun: | ||
| 154 | + return fmt.Sprintf("%s: would be created, %d line(s)", r.Path, r.Added) | ||
| 155 | + case r.Created: | ||
| 156 | + return fmt.Sprintf("%s: created, %d line(s)", r.Path, r.Added) | ||
| 157 | + } | ||
| 158 | + what := fmt.Sprintf("+%d -%d, first change at line %d", r.Added, r.Deleted, r.FirstChangedLine) | ||
| 159 | + if r.Edits > 0 { | ||
| 160 | + what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what) | ||
| 161 | + } | ||
| 162 | + if r.DryRun { | ||
| 163 | + return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what) | ||
| 164 | + } | ||
| 165 | + return fmt.Sprintf("%s: %s", r.Path, what) | ||
| 166 | +} | ||
added
internal/fileedit/fileedit_test.go +307 -0 | new file mode 100644 | ||
| @@ -0,0 +1,307 @@ | ||
| 1 | +// The cases below are the `edit` CLI's own tests (tools/edit/internal/*), | |
| 2 | +// ported: what they guard is that the built-in tools refuse exactly what the | |
| 3 | +// CLI refuses, in words a model can act on — otherwise the A/B comparison of | |
| 4 | +// this part would measure two different tools. | |
| 5 | +package fileedit | |
| 6 | + | |
| 7 | +import ( | |
| 8 | + "os" | |
| 9 | + "os/exec" | |
| 10 | + "path/filepath" | |
| 11 | + "strings" | |
| 12 | + "testing" | |
| 13 | +) | |
| 14 | + | |
| 15 | +func inDir(t *testing.T, name, content string) string { | |
| 16 | + t.Helper() | |
| 17 | + path := filepath.Join(t.TempDir(), name) | |
| 18 | + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { | |
| 19 | + t.Fatal(err) | |
| 20 | + } | |
| 21 | + return path | |
| 22 | +} | |
| 23 | + | |
| 24 | +func fileOf(t *testing.T, path string) string { | |
| 25 | + t.Helper() | |
| 26 | + data, err := os.ReadFile(path) | |
| 27 | + if err != nil { | |
| 28 | + t.Fatal(err) | |
| 29 | + } | |
| 30 | + return string(data) | |
| 31 | +} | |
| 32 | + | |
| 33 | +func TestApply(t *testing.T) { | |
| 34 | + cases := []struct { | |
| 35 | + name string | |
| 36 | + body string | |
| 37 | + edits []Edit | |
| 38 | + want string | |
| 39 | + }{ | |
| 40 | + {"one replacement", "const port = 3000;\n", | |
| 41 | + []Edit{{"const port = 3000;", "const port = envPort(3000);"}}, "const port = envPort(3000);\n"}, | |
| 42 | + {"two independent edits, resolved against the original", "HOST = \"localhost\"\nPORT = 3000\n", | |
| 43 | + []Edit{{`HOST = "localhost"`, `HOST = env("HOST", "localhost")`}, {`PORT = 3000`, `PORT = envInt("PORT", 3000)`}}, | |
| 44 | + "HOST = env(\"HOST\", \"localhost\")\nPORT = envInt(\"PORT\", 3000)\n"}, | |
| 45 | + {"the order of the edits does not change the result", "a\nb\nc\n", | |
| 46 | + []Edit{{"c", "C"}, {"a", "A"}}, "A\nb\nC\n"}, | |
| 47 | + {"an empty new deletes", "keep\nto delete\nkeep too\n", | |
| 48 | + []Edit{{"to delete\n", ""}}, "keep\nkeep too\n"}, | |
| 49 | + {"the old text spans several lines", "func f() {\n\treturn 1\n}\n", | |
| 50 | + []Edit{{"func f() {\n\treturn 1\n}", "func f() int {\n\treturn 2\n}"}}, "func f() int {\n\treturn 2\n}\n"}, | |
| 51 | + {"an edit is NOT re-read by the next one", "x\ny\n", | |
| 52 | + []Edit{{"x", "y"}, {"y", "z"}}, "y\nz\n"}, | |
| 53 | + } | |
| 54 | + for _, tc := range cases { | |
| 55 | + t.Run(tc.name, func(t *testing.T) { | |
| 56 | + got, err := apply(tc.body, tc.edits) | |
| 57 | + if err != nil { | |
| 58 | + t.Fatalf("apply: %v", err) | |
| 59 | + } | |
| 60 | + if got != tc.want { | |
| 61 | + t.Errorf("apply = %q, want %q", got, tc.want) | |
| 62 | + } | |
| 63 | + }) | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +// TestApplyRefuses guards the rules that make an edit verifiable. Each message | |
| 68 | +// is read by a MODEL, which must be able to fix the call without guessing: the | |
| 69 | +// test also checks that the message carries the fix. | |
| 70 | +func TestApplyRefuses(t *testing.T) { | |
| 71 | + cases := []struct { | |
| 72 | + name string | |
| 73 | + body string | |
| 74 | + edits []Edit | |
| 75 | + says string | |
| 76 | + }{ | |
| 77 | + {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"}, | |
| 78 | + {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times — add the surrounding lines until it is unique"}, | |
| 79 | + {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n", | |
| 80 | + []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}}, | |
| 81 | + "overlap"}, | |
| 82 | + {"empty old", "a\n", []Edit{{"", "b"}}, "empty old text"}, | |
| 83 | + {"an edit that changes nothing", "a\n", []Edit{{"a", "a"}}, "changes nothing"}, | |
| 84 | + {"no edit at all", "a\n", nil, "no edit given"}, | |
| 85 | + } | |
| 86 | + for _, tc := range cases { | |
| 87 | + t.Run(tc.name, func(t *testing.T) { | |
| 88 | + got, err := apply(tc.body, tc.edits) | |
| 89 | + if err == nil { | |
| 90 | + t.Fatalf("apply succeeded, want an error — result %q", got) | |
| 91 | + } | |
| 92 | + if !strings.Contains(err.Error(), tc.says) { | |
| 93 | + t.Errorf("error = %q, want it to contain %q", err, tc.says) | |
| 94 | + } | |
| 95 | + if got != "" { | |
| 96 | + t.Errorf("apply returned %q along with the error: nothing must be written", got) | |
| 97 | + } | |
| 98 | + }) | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +func TestRead(t *testing.T) { | |
| 103 | + path := inDir(t, "f.txt", "one\ntwo\nthree\n") | |
| 104 | + | |
| 105 | + got, err := Read(path, 0, 0, false) | |
| 106 | + if err != nil || got != "one\ntwo\nthree\n" { | |
| 107 | + t.Errorf("Read = (%q, %v)", got, err) | |
| 108 | + } | |
| 109 | + got, err = Read(path, 2, 2, true) | |
| 110 | + if err != nil || got != " 2 two\n" { | |
| 111 | + t.Errorf("Read 2-2 numbered = (%q, %v)", got, err) | |
| 112 | + } | |
| 113 | + if _, err := Read(filepath.Join(filepath.Dir(path), "nope.txt"), 0, 0, false); err == nil || !strings.Contains(err.Error(), "does not exist") { | |
| 114 | + t.Errorf("Read of a missing file = %v", err) | |
| 115 | + } | |
| 116 | + if _, err := Read(path, 99, 0, false); err == nil || !strings.Contains(err.Error(), "past the end") { | |
| 117 | + t.Errorf("Read start 99 = %v", err) | |
| 118 | + } | |
| 119 | +} | |
| 120 | + | |
| 121 | +func TestWriteCreatesAndReports(t *testing.T) { | |
| 122 | + path := filepath.Join(t.TempDir(), "notes.md") | |
| 123 | + | |
| 124 | + r, err := Write(path, "# Notes") | |
| 125 | + if err != nil { | |
| 126 | + t.Fatal(err) | |
| 127 | + } | |
| 128 | + if got := fileOf(t, path); got != "# Notes\n" { | |
| 129 | + t.Errorf("file = %q, want %q — the final newline is missing", got, "# Notes\n") | |
| 130 | + } | |
| 131 | + if !r.Created || !strings.Contains(r.Headline, "created") { | |
| 132 | + t.Errorf("Result = %+v, want it to announce the creation", r) | |
| 133 | + } | |
| 134 | + // Rewriting the same content must do nothing, and SAY so. | |
| 135 | + r, err = Write(path, "# Notes") | |
| 136 | + if err != nil || r.Changed || !strings.Contains(r.Headline, "unchanged") { | |
| 137 | + t.Errorf("identical Write = (%+v, %v)", r, err) | |
| 138 | + } | |
| 139 | +} | |
| 140 | + | |
| 141 | +func TestReplace(t *testing.T) { | |
| 142 | + path := inDir(t, "server.go", "port := 3000\nhost := \"local\"\n") | |
| 143 | + | |
| 144 | + r, err := Replace(path, []Edit{{"port := 3000", "port := envPort(3000)"}}, false) | |
| 145 | + if err != nil { | |
| 146 | + t.Fatal(err) | |
| 147 | + } | |
| 148 | + if got := fileOf(t, path); got != "port := envPort(3000)\nhost := \"local\"\n" { | |
| 149 | + t.Errorf("file = %q", got) | |
| 150 | + } | |
| 151 | + if r.FirstChangedLine != 1 || !strings.Contains(r.Headline, "first change at line 1") || !strings.Contains(r.Diff, "+ port := envPort(3000)") { | |
| 152 | + t.Errorf("Result = %+v", r) | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +// TestReplaceIsAllOrNothing: the second edit is impossible, so the first must | |
| 157 | +// not have happened. A half-edited file is the worst result — it looks like a | |
| 158 | +// success. | |
| 159 | +func TestReplaceIsAllOrNothing(t *testing.T) { | |
| 160 | + const before = "a = 1\nb = 2\n" | |
| 161 | + path := inDir(t, "f.txt", before) | |
| 162 | + _, err := Replace(path, []Edit{{"a = 1", "a = 10"}, {"absent", "x"}}, false) | |
| 163 | + if err == nil || !strings.Contains(err.Error(), "not found") { | |
| 164 | + t.Errorf("err = %v", err) | |
| 165 | + } | |
| 166 | + if got := fileOf(t, path); got != before { | |
| 167 | + t.Errorf("file = %q, want unchanged %q", got, before) | |
| 168 | + } | |
| 169 | +} | |
| 170 | + | |
| 171 | +func TestReplaceRefusesAMissingFile(t *testing.T) { | |
| 172 | + _, err := Replace(filepath.Join(t.TempDir(), "nope.txt"), []Edit{{"a", "b"}}, false) | |
| 173 | + if err == nil || !strings.Contains(err.Error(), "does not exist") { | |
| 174 | + t.Errorf("err = %v", err) | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +func TestDryRunWritesNothing(t *testing.T) { | |
| 179 | + const before = "a = 1\n" | |
| 180 | + path := inDir(t, "f.txt", before) | |
| 181 | + r, err := Replace(path, []Edit{{"a = 1", "a = 2"}}, true) | |
| 182 | + if err != nil || !r.DryRun || !strings.Contains(r.Headline, "dry run") || !strings.Contains(r.Diff, "+ a = 2") { | |
| 183 | + t.Errorf("dry run = (%+v, %v)", r, err) | |
| 184 | + } | |
| 185 | + if got := fileOf(t, path); got != before { | |
| 186 | + t.Errorf("file = %q, want unchanged", got) | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +// TestShapeSurvives: BOM and CRLF are removed for the edit and put back on | |
| 191 | +// write, else every line of a Windows file would show up as changed. | |
| 192 | +func TestShapeSurvives(t *testing.T) { | |
| 193 | + cases := []struct{ name, before, old, new, after string }{ | |
| 194 | + {"CRLF kept", "a\r\nb\r\n", "b", "B", "a\r\nB\r\n"}, | |
| 195 | + {"BOM kept", "\uFEFFa\n", "a", "A", "\uFEFFA\n"}, | |
| 196 | + {"plain LF", "a\n", "a", "A", "A\n"}, | |
| 197 | + } | |
| 198 | + for _, tc := range cases { | |
| 199 | + t.Run(tc.name, func(t *testing.T) { | |
| 200 | + path := inDir(t, "f.txt", tc.before) | |
| 201 | + if _, err := Replace(path, []Edit{{tc.old, tc.new}}, false); err != nil { | |
| 202 | + t.Fatal(err) | |
| 203 | + } | |
| 204 | + if got := fileOf(t, path); got != tc.after { | |
| 205 | + t.Errorf("file = %q, want %q", got, tc.after) | |
| 206 | + } | |
| 207 | + }) | |
| 208 | + } | |
| 209 | +} | |
| 210 | + | |
| 211 | +// TestWriteKeepsTheMode: an executable script that gets edited must stay | |
| 212 | +// executable. | |
| 213 | +func TestWriteKeepsTheMode(t *testing.T) { | |
| 214 | + path := inDir(t, "s.sh", "echo a\n") | |
| 215 | + if err := os.Chmod(path, 0o755); err != nil { | |
| 216 | + t.Fatal(err) | |
| 217 | + } | |
| 218 | + if _, err := Write(path, "echo b\n"); err != nil { | |
| 219 | + t.Fatal(err) | |
| 220 | + } | |
| 221 | + info, _ := os.Stat(path) | |
| 222 | + if info.Mode().Perm() != 0o755 { | |
| 223 | + t.Errorf("mode = %v, want 755", info.Mode().Perm()) | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +func TestWriteCreatesParentDirs(t *testing.T) { | |
| 228 | + path := filepath.Join(t.TempDir(), "a", "b", "notes.md") | |
| 229 | + if _, err := Write(path, "# Notes\n"); err != nil { | |
| 230 | + t.Fatalf("Write: %v", err) | |
| 231 | + } | |
| 232 | + if got := fileOf(t, path); got != "# Notes\n" { | |
| 233 | + t.Errorf("file = %q", got) | |
| 234 | + } | |
| 235 | +} | |
| 236 | + | |
| 237 | +func TestFirstChanged(t *testing.T) { | |
| 238 | + cases := []struct { | |
| 239 | + name string | |
| 240 | + old, new string | |
| 241 | + want int | |
| 242 | + }{ | |
| 243 | + {"nothing changed", "a\nb\n", "a\nb\n", 0}, | |
| 244 | + {"the second line", "a\nb\nc\n", "a\nB\nc\n", 2}, | |
| 245 | + {"an addition at the top", "a\n", "zero\na\n", 1}, | |
| 246 | + {"a deletion", "a\nb\nc\n", "a\nc\n", 2}, | |
| 247 | + {"a created file", "", "a\n", 1}, | |
| 248 | + } | |
| 249 | + for _, tc := range cases { | |
| 250 | + t.Run(tc.name, func(t *testing.T) { | |
| 251 | + if got := compare(tc.old, tc.new).firstChanged(); got != tc.want { | |
| 252 | + t.Errorf("firstChanged = %d, want %d", got, tc.want) | |
| 253 | + } | |
| 254 | + }) | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +func TestRenderHasNoANSI(t *testing.T) { | |
| 259 | + out := compare("a\nb\n", "a\nB\n").render() | |
| 260 | + if strings.Contains(out, "\033") || !strings.Contains(out, "- b") || !strings.Contains(out, "+ B") { | |
| 261 | + t.Errorf("render = %q", out) | |
| 262 | + } | |
| 263 | +} | |
| 264 | + | |
| 265 | +// TestUnifiedMatchesGitApply is the test that counts: a patch that "looks | |
| 266 | +// like" a unified patch is useless. git APPLIES it, and refuses anything that | |
| 267 | +// is not exact — headers, line numbers, counts, end-of-file marker. | |
| 268 | +func TestUnifiedMatchesGitApply(t *testing.T) { | |
| 269 | + if _, err := exec.LookPath("git"); err != nil { | |
| 270 | + t.Skip("git absent") | |
| 271 | + } | |
| 272 | + cases := []struct{ name, old, new string }{ | |
| 273 | + {"one line in the middle", "a\nb\nc\nd\ne\n", "a\nb\nC\nd\ne\n"}, | |
| 274 | + {"two distant areas", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", "1\n2\nTHREE\n4\n5\n6\n7\n8\n9\n10\n11\n12\nTHIRTEEN\n14\n15\n"}, | |
| 275 | + {"two neighbouring areas: one hunk", "1\n2\n3\n4\n5\n6\n7\n8\n", "1\n2\nTHREE\n4\nFIVE\n6\n7\n8\n"}, | |
| 276 | + {"addition at the top", "a\nb\n", "zero\na\nb\n"}, | |
| 277 | + {"deletion at the tail", "a\nb\nc\n", "a\nb\n"}, | |
| 278 | + {"everything replaced", "a\nb\n", "x\ny\nz\n"}, | |
| 279 | + {"no final newline", "a\nb\nc", "a\nB\nc"}, | |
| 280 | + {"final newline added", "a\nb", "a\nb\n"}, | |
| 281 | + {"file emptied", "a\nb\n", ""}, | |
| 282 | + } | |
| 283 | + for _, tc := range cases { | |
| 284 | + t.Run(tc.name, func(t *testing.T) { | |
| 285 | + patch := compare(tc.old, tc.new).unified("f.txt") | |
| 286 | + if patch == "" { | |
| 287 | + t.Fatal("empty patch") | |
| 288 | + } | |
| 289 | + dir := t.TempDir() | |
| 290 | + run := func(args ...string) { | |
| 291 | + t.Helper() | |
| 292 | + cmd := exec.Command("git", args...) | |
| 293 | + cmd.Dir = dir | |
| 294 | + if out, err := cmd.CombinedOutput(); err != nil { | |
| 295 | + t.Fatalf("git %s: %v\n%s\npatch:\n%s", strings.Join(args, " "), err, out, patch) | |
| 296 | + } | |
| 297 | + } | |
| 298 | + run("init", "-q") | |
| 299 | + os.WriteFile(filepath.Join(dir, "f.txt"), []byte(tc.old), 0o644) | |
| 300 | + os.WriteFile(filepath.Join(dir, "p.diff"), []byte(patch), 0o644) | |
| 301 | + run("apply", "p.diff") | |
| 302 | + if got := fileOf(t, filepath.Join(dir, "f.txt")); got != tc.new { | |
| 303 | + t.Errorf("after apply: %q, want %q\npatch:\n%s", got, tc.new, patch) | |
| 304 | + } | |
| 305 | + }) | |
| 306 | + } | |
| 307 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,307 @@ | |||
| 1 | +// The cases below are the `edit` CLI's own tests (tools/edit/internal/*), | ||
| 2 | +// ported: what they guard is that the built-in tools refuse exactly what the | ||
| 3 | +// CLI refuses, in words a model can act on — otherwise the A/B comparison of | ||
| 4 | +// this part would measure two different tools. | ||
| 5 | +package fileedit | ||
| 6 | + | ||
| 7 | +import ( | ||
| 8 | + "os" | ||
| 9 | + "os/exec" | ||
| 10 | + "path/filepath" | ||
| 11 | + "strings" | ||
| 12 | + "testing" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +func inDir(t *testing.T, name, content string) string { | ||
| 16 | + t.Helper() | ||
| 17 | + path := filepath.Join(t.TempDir(), name) | ||
| 18 | + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { | ||
| 19 | + t.Fatal(err) | ||
| 20 | + } | ||
| 21 | + return path | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +func fileOf(t *testing.T, path string) string { | ||
| 25 | + t.Helper() | ||
| 26 | + data, err := os.ReadFile(path) | ||
| 27 | + if err != nil { | ||
| 28 | + t.Fatal(err) | ||
| 29 | + } | ||
| 30 | + return string(data) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +func TestApply(t *testing.T) { | ||
| 34 | + cases := []struct { | ||
| 35 | + name string | ||
| 36 | + body string | ||
| 37 | + edits []Edit | ||
| 38 | + want string | ||
| 39 | + }{ | ||
| 40 | + {"one replacement", "const port = 3000;\n", | ||
| 41 | + []Edit{{"const port = 3000;", "const port = envPort(3000);"}}, "const port = envPort(3000);\n"}, | ||
| 42 | + {"two independent edits, resolved against the original", "HOST = \"localhost\"\nPORT = 3000\n", | ||
| 43 | + []Edit{{`HOST = "localhost"`, `HOST = env("HOST", "localhost")`}, {`PORT = 3000`, `PORT = envInt("PORT", 3000)`}}, | ||
| 44 | + "HOST = env(\"HOST\", \"localhost\")\nPORT = envInt(\"PORT\", 3000)\n"}, | ||
| 45 | + {"the order of the edits does not change the result", "a\nb\nc\n", | ||
| 46 | + []Edit{{"c", "C"}, {"a", "A"}}, "A\nb\nC\n"}, | ||
| 47 | + {"an empty new deletes", "keep\nto delete\nkeep too\n", | ||
| 48 | + []Edit{{"to delete\n", ""}}, "keep\nkeep too\n"}, | ||
| 49 | + {"the old text spans several lines", "func f() {\n\treturn 1\n}\n", | ||
| 50 | + []Edit{{"func f() {\n\treturn 1\n}", "func f() int {\n\treturn 2\n}"}}, "func f() int {\n\treturn 2\n}\n"}, | ||
| 51 | + {"an edit is NOT re-read by the next one", "x\ny\n", | ||
| 52 | + []Edit{{"x", "y"}, {"y", "z"}}, "y\nz\n"}, | ||
| 53 | + } | ||
| 54 | + for _, tc := range cases { | ||
| 55 | + t.Run(tc.name, func(t *testing.T) { | ||
| 56 | + got, err := apply(tc.body, tc.edits) | ||
| 57 | + if err != nil { | ||
| 58 | + t.Fatalf("apply: %v", err) | ||
| 59 | + } | ||
| 60 | + if got != tc.want { | ||
| 61 | + t.Errorf("apply = %q, want %q", got, tc.want) | ||
| 62 | + } | ||
| 63 | + }) | ||
| 64 | + } | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +// TestApplyRefuses guards the rules that make an edit verifiable. Each message | ||
| 68 | +// is read by a MODEL, which must be able to fix the call without guessing: the | ||
| 69 | +// test also checks that the message carries the fix. | ||
| 70 | +func TestApplyRefuses(t *testing.T) { | ||
| 71 | + cases := []struct { | ||
| 72 | + name string | ||
| 73 | + body string | ||
| 74 | + edits []Edit | ||
| 75 | + says string | ||
| 76 | + }{ | ||
| 77 | + {"text not found", "a\n", []Edit{{"b", "c"}}, "not found"}, | ||
| 78 | + {"ambiguous text", "x = 1\nx = 1\n", []Edit{{"x = 1", "x = 2"}}, "appears 2 times — add the surrounding lines until it is unique"}, | ||
| 79 | + {"overlapping regions", "func hello() {\n\treturn \"hello\"\n}\n", | ||
| 80 | + []Edit{{"func hello() {\n\treturn \"hello\"\n}", "func hello() {\n\treturn \"bonjour\"\n}"}, {"return \"hello\"", "return \"salut\""}}, | ||
| 81 | + "overlap"}, | ||
| 82 | + {"empty old", "a\n", []Edit{{"", "b"}}, "empty old text"}, | ||
| 83 | + {"an edit that changes nothing", "a\n", []Edit{{"a", "a"}}, "changes nothing"}, | ||
| 84 | + {"no edit at all", "a\n", nil, "no edit given"}, | ||
| 85 | + } | ||
| 86 | + for _, tc := range cases { | ||
| 87 | + t.Run(tc.name, func(t *testing.T) { | ||
| 88 | + got, err := apply(tc.body, tc.edits) | ||
| 89 | + if err == nil { | ||
| 90 | + t.Fatalf("apply succeeded, want an error — result %q", got) | ||
| 91 | + } | ||
| 92 | + if !strings.Contains(err.Error(), tc.says) { | ||
| 93 | + t.Errorf("error = %q, want it to contain %q", err, tc.says) | ||
| 94 | + } | ||
| 95 | + if got != "" { | ||
| 96 | + t.Errorf("apply returned %q along with the error: nothing must be written", got) | ||
| 97 | + } | ||
| 98 | + }) | ||
| 99 | + } | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +func TestRead(t *testing.T) { | ||
| 103 | + path := inDir(t, "f.txt", "one\ntwo\nthree\n") | ||
| 104 | + | ||
| 105 | + got, err := Read(path, 0, 0, false) | ||
| 106 | + if err != nil || got != "one\ntwo\nthree\n" { | ||
| 107 | + t.Errorf("Read = (%q, %v)", got, err) | ||
| 108 | + } | ||
| 109 | + got, err = Read(path, 2, 2, true) | ||
| 110 | + if err != nil || got != " 2 two\n" { | ||
| 111 | + t.Errorf("Read 2-2 numbered = (%q, %v)", got, err) | ||
| 112 | + } | ||
| 113 | + if _, err := Read(filepath.Join(filepath.Dir(path), "nope.txt"), 0, 0, false); err == nil || !strings.Contains(err.Error(), "does not exist") { | ||
| 114 | + t.Errorf("Read of a missing file = %v", err) | ||
| 115 | + } | ||
| 116 | + if _, err := Read(path, 99, 0, false); err == nil || !strings.Contains(err.Error(), "past the end") { | ||
| 117 | + t.Errorf("Read start 99 = %v", err) | ||
| 118 | + } | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +func TestWriteCreatesAndReports(t *testing.T) { | ||
| 122 | + path := filepath.Join(t.TempDir(), "notes.md") | ||
| 123 | + | ||
| 124 | + r, err := Write(path, "# Notes") | ||
| 125 | + if err != nil { | ||
| 126 | + t.Fatal(err) | ||
| 127 | + } | ||
| 128 | + if got := fileOf(t, path); got != "# Notes\n" { | ||
| 129 | + t.Errorf("file = %q, want %q — the final newline is missing", got, "# Notes\n") | ||
| 130 | + } | ||
| 131 | + if !r.Created || !strings.Contains(r.Headline, "created") { | ||
| 132 | + t.Errorf("Result = %+v, want it to announce the creation", r) | ||
| 133 | + } | ||
| 134 | + // Rewriting the same content must do nothing, and SAY so. | ||
| 135 | + r, err = Write(path, "# Notes") | ||
| 136 | + if err != nil || r.Changed || !strings.Contains(r.Headline, "unchanged") { | ||
| 137 | + t.Errorf("identical Write = (%+v, %v)", r, err) | ||
| 138 | + } | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +func TestReplace(t *testing.T) { | ||
| 142 | + path := inDir(t, "server.go", "port := 3000\nhost := \"local\"\n") | ||
| 143 | + | ||
| 144 | + r, err := Replace(path, []Edit{{"port := 3000", "port := envPort(3000)"}}, false) | ||
| 145 | + if err != nil { | ||
| 146 | + t.Fatal(err) | ||
| 147 | + } | ||
| 148 | + if got := fileOf(t, path); got != "port := envPort(3000)\nhost := \"local\"\n" { | ||
| 149 | + t.Errorf("file = %q", got) | ||
| 150 | + } | ||
| 151 | + if r.FirstChangedLine != 1 || !strings.Contains(r.Headline, "first change at line 1") || !strings.Contains(r.Diff, "+ port := envPort(3000)") { | ||
| 152 | + t.Errorf("Result = %+v", r) | ||
| 153 | + } | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +// TestReplaceIsAllOrNothing: the second edit is impossible, so the first must | ||
| 157 | +// not have happened. A half-edited file is the worst result — it looks like a | ||
| 158 | +// success. | ||
| 159 | +func TestReplaceIsAllOrNothing(t *testing.T) { | ||
| 160 | + const before = "a = 1\nb = 2\n" | ||
| 161 | + path := inDir(t, "f.txt", before) | ||
| 162 | + _, err := Replace(path, []Edit{{"a = 1", "a = 10"}, {"absent", "x"}}, false) | ||
| 163 | + if err == nil || !strings.Contains(err.Error(), "not found") { | ||
| 164 | + t.Errorf("err = %v", err) | ||
| 165 | + } | ||
| 166 | + if got := fileOf(t, path); got != before { | ||
| 167 | + t.Errorf("file = %q, want unchanged %q", got, before) | ||
| 168 | + } | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +func TestReplaceRefusesAMissingFile(t *testing.T) { | ||
| 172 | + _, err := Replace(filepath.Join(t.TempDir(), "nope.txt"), []Edit{{"a", "b"}}, false) | ||
| 173 | + if err == nil || !strings.Contains(err.Error(), "does not exist") { | ||
| 174 | + t.Errorf("err = %v", err) | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +func TestDryRunWritesNothing(t *testing.T) { | ||
| 179 | + const before = "a = 1\n" | ||
| 180 | + path := inDir(t, "f.txt", before) | ||
| 181 | + r, err := Replace(path, []Edit{{"a = 1", "a = 2"}}, true) | ||
| 182 | + if err != nil || !r.DryRun || !strings.Contains(r.Headline, "dry run") || !strings.Contains(r.Diff, "+ a = 2") { | ||
| 183 | + t.Errorf("dry run = (%+v, %v)", r, err) | ||
| 184 | + } | ||
| 185 | + if got := fileOf(t, path); got != before { | ||
| 186 | + t.Errorf("file = %q, want unchanged", got) | ||
| 187 | + } | ||
| 188 | +} | ||
| 189 | + | ||
| 190 | +// TestShapeSurvives: BOM and CRLF are removed for the edit and put back on | ||
| 191 | +// write, else every line of a Windows file would show up as changed. | ||
| 192 | +func TestShapeSurvives(t *testing.T) { | ||
| 193 | + cases := []struct{ name, before, old, new, after string }{ | ||
| 194 | + {"CRLF kept", "a\r\nb\r\n", "b", "B", "a\r\nB\r\n"}, | ||
| 195 | + {"BOM kept", "\uFEFFa\n", "a", "A", "\uFEFFA\n"}, | ||
| 196 | + {"plain LF", "a\n", "a", "A", "A\n"}, | ||
| 197 | + } | ||
| 198 | + for _, tc := range cases { | ||
| 199 | + t.Run(tc.name, func(t *testing.T) { | ||
| 200 | + path := inDir(t, "f.txt", tc.before) | ||
| 201 | + if _, err := Replace(path, []Edit{{tc.old, tc.new}}, false); err != nil { | ||
| 202 | + t.Fatal(err) | ||
| 203 | + } | ||
| 204 | + if got := fileOf(t, path); got != tc.after { | ||
| 205 | + t.Errorf("file = %q, want %q", got, tc.after) | ||
| 206 | + } | ||
| 207 | + }) | ||
| 208 | + } | ||
| 209 | +} | ||
| 210 | + | ||
| 211 | +// TestWriteKeepsTheMode: an executable script that gets edited must stay | ||
| 212 | +// executable. | ||
| 213 | +func TestWriteKeepsTheMode(t *testing.T) { | ||
| 214 | + path := inDir(t, "s.sh", "echo a\n") | ||
| 215 | + if err := os.Chmod(path, 0o755); err != nil { | ||
| 216 | + t.Fatal(err) | ||
| 217 | + } | ||
| 218 | + if _, err := Write(path, "echo b\n"); err != nil { | ||
| 219 | + t.Fatal(err) | ||
| 220 | + } | ||
| 221 | + info, _ := os.Stat(path) | ||
| 222 | + if info.Mode().Perm() != 0o755 { | ||
| 223 | + t.Errorf("mode = %v, want 755", info.Mode().Perm()) | ||
| 224 | + } | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +func TestWriteCreatesParentDirs(t *testing.T) { | ||
| 228 | + path := filepath.Join(t.TempDir(), "a", "b", "notes.md") | ||
| 229 | + if _, err := Write(path, "# Notes\n"); err != nil { | ||
| 230 | + t.Fatalf("Write: %v", err) | ||
| 231 | + } | ||
| 232 | + if got := fileOf(t, path); got != "# Notes\n" { | ||
| 233 | + t.Errorf("file = %q", got) | ||
| 234 | + } | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +func TestFirstChanged(t *testing.T) { | ||
| 238 | + cases := []struct { | ||
| 239 | + name string | ||
| 240 | + old, new string | ||
| 241 | + want int | ||
| 242 | + }{ | ||
| 243 | + {"nothing changed", "a\nb\n", "a\nb\n", 0}, | ||
| 244 | + {"the second line", "a\nb\nc\n", "a\nB\nc\n", 2}, | ||
| 245 | + {"an addition at the top", "a\n", "zero\na\n", 1}, | ||
| 246 | + {"a deletion", "a\nb\nc\n", "a\nc\n", 2}, | ||
| 247 | + {"a created file", "", "a\n", 1}, | ||
| 248 | + } | ||
| 249 | + for _, tc := range cases { | ||
| 250 | + t.Run(tc.name, func(t *testing.T) { | ||
| 251 | + if got := compare(tc.old, tc.new).firstChanged(); got != tc.want { | ||
| 252 | + t.Errorf("firstChanged = %d, want %d", got, tc.want) | ||
| 253 | + } | ||
| 254 | + }) | ||
| 255 | + } | ||
| 256 | +} | ||
| 257 | + | ||
| 258 | +func TestRenderHasNoANSI(t *testing.T) { | ||
| 259 | + out := compare("a\nb\n", "a\nB\n").render() | ||
| 260 | + if strings.Contains(out, "\033") || !strings.Contains(out, "- b") || !strings.Contains(out, "+ B") { | ||
| 261 | + t.Errorf("render = %q", out) | ||
| 262 | + } | ||
| 263 | +} | ||
| 264 | + | ||
| 265 | +// TestUnifiedMatchesGitApply is the test that counts: a patch that "looks | ||
| 266 | +// like" a unified patch is useless. git APPLIES it, and refuses anything that | ||
| 267 | +// is not exact — headers, line numbers, counts, end-of-file marker. | ||
| 268 | +func TestUnifiedMatchesGitApply(t *testing.T) { | ||
| 269 | + if _, err := exec.LookPath("git"); err != nil { | ||
| 270 | + t.Skip("git absent") | ||
| 271 | + } | ||
| 272 | + cases := []struct{ name, old, new string }{ | ||
| 273 | + {"one line in the middle", "a\nb\nc\nd\ne\n", "a\nb\nC\nd\ne\n"}, | ||
| 274 | + {"two distant areas", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", "1\n2\nTHREE\n4\n5\n6\n7\n8\n9\n10\n11\n12\nTHIRTEEN\n14\n15\n"}, | ||
| 275 | + {"two neighbouring areas: one hunk", "1\n2\n3\n4\n5\n6\n7\n8\n", "1\n2\nTHREE\n4\nFIVE\n6\n7\n8\n"}, | ||
| 276 | + {"addition at the top", "a\nb\n", "zero\na\nb\n"}, | ||
| 277 | + {"deletion at the tail", "a\nb\nc\n", "a\nb\n"}, | ||
| 278 | + {"everything replaced", "a\nb\n", "x\ny\nz\n"}, | ||
| 279 | + {"no final newline", "a\nb\nc", "a\nB\nc"}, | ||
| 280 | + {"final newline added", "a\nb", "a\nb\n"}, | ||
| 281 | + {"file emptied", "a\nb\n", ""}, | ||
| 282 | + } | ||
| 283 | + for _, tc := range cases { | ||
| 284 | + t.Run(tc.name, func(t *testing.T) { | ||
| 285 | + patch := compare(tc.old, tc.new).unified("f.txt") | ||
| 286 | + if patch == "" { | ||
| 287 | + t.Fatal("empty patch") | ||
| 288 | + } | ||
| 289 | + dir := t.TempDir() | ||
| 290 | + run := func(args ...string) { | ||
| 291 | + t.Helper() | ||
| 292 | + cmd := exec.Command("git", args...) | ||
| 293 | + cmd.Dir = dir | ||
| 294 | + if out, err := cmd.CombinedOutput(); err != nil { | ||
| 295 | + t.Fatalf("git %s: %v\n%s\npatch:\n%s", strings.Join(args, " "), err, out, patch) | ||
| 296 | + } | ||
| 297 | + } | ||
| 298 | + run("init", "-q") | ||
| 299 | + os.WriteFile(filepath.Join(dir, "f.txt"), []byte(tc.old), 0o644) | ||
| 300 | + os.WriteFile(filepath.Join(dir, "p.diff"), []byte(patch), 0o644) | ||
| 301 | + run("apply", "p.diff") | ||
| 302 | + if got := fileOf(t, filepath.Join(dir, "f.txt")); got != tc.new { | ||
| 303 | + t.Errorf("after apply: %q, want %q\npatch:\n%s", got, tc.new, patch) | ||
| 304 | + } | ||
| 305 | + }) | ||
| 306 | + } | ||
| 307 | +} | ||
added
internal/fileedit/textfile.go +120 -0 | new file mode 100644 | ||
| @@ -0,0 +1,120 @@ | ||
| 1 | +// Package fileedit reads, writes and edits ONE text file by exact replacement. | |
| 2 | +// It is a port of the core of the `edit` CLI (tools/edit: internal/edits, | |
| 3 | +// internal/diff, internal/textfile) with the same rules and, as far as the | |
| 4 | +// tool names allow, the same messages — so that the built-in tools of this part | |
| 5 | +// and the CLI of part 05 refuse the same things, in the same words, and the | |
| 6 | +// A/B comparison between them measures the MODEL, not the tool. | |
| 7 | +// | |
| 8 | +// Copied rather than imported: the CLI keeps its core under internal/, which Go | |
| 9 | +// does not let another module import, and moving it out would change a module | |
| 10 | +// this talk shows on stage. Four hundred lines of stdlib are cheaper than that. | |
| 11 | +package fileedit | |
| 12 | + | |
| 13 | +import ( | |
| 14 | + "fmt" | |
| 15 | + "io/fs" | |
| 16 | + "os" | |
| 17 | + "path/filepath" | |
| 18 | + "strings" | |
| 19 | +) | |
| 20 | + | |
| 21 | +// bom is the UTF-8 byte order mark, as it appears at the head of a file. | |
| 22 | +const bom = "\uFEFF" | |
| 23 | + | |
| 24 | +// file is a text file loaded in memory, ready to be edited. | |
| 25 | +// | |
| 26 | +// Two details invisible on screen would not survive a naive read/replace/write | |
| 27 | +// round trip: the UTF-8 BOM some Windows editors put first — left in the body | |
| 28 | +// it sticks to the first word and defeats an `old` that starts at the top of | |
| 29 | +// the file — and CRLF line endings, which would defeat EVERY multi-line `old` | |
| 30 | +// written with \n, that is, every one a model produces. Both are removed on | |
| 31 | +// read and restored on write. | |
| 32 | +type file struct { | |
| 33 | + path string | |
| 34 | + // body is the NORMALISED content: no BOM, \n line endings. Searches and | |
| 35 | + // diffs work on it. | |
| 36 | + body string | |
| 37 | + mode fs.FileMode | |
| 38 | + // isNew says the file did not exist yet. | |
| 39 | + isNew bool | |
| 40 | + hadBOM bool | |
| 41 | + crlf bool | |
| 42 | +} | |
| 43 | + | |
| 44 | +// load reads a file. A missing file is not an error: it comes back empty and | |
| 45 | +// marked isNew, which lets Write create it and Replace refuse with its own | |
| 46 | +// message. | |
| 47 | +func load(path string) (*file, error) { | |
| 48 | + f := &file{path: path, mode: 0o644, isNew: true} | |
| 49 | + | |
| 50 | + data, err := os.ReadFile(path) | |
| 51 | + if err != nil { | |
| 52 | + if os.IsNotExist(err) { | |
| 53 | + return f, nil | |
| 54 | + } | |
| 55 | + return nil, err | |
| 56 | + } | |
| 57 | + if info, err := os.Stat(path); err == nil { | |
| 58 | + f.mode = info.Mode().Perm() | |
| 59 | + } | |
| 60 | + f.isNew = false | |
| 61 | + | |
| 62 | + body := string(data) | |
| 63 | + if strings.HasPrefix(body, bom) { | |
| 64 | + f.hadBOM = true | |
| 65 | + body = strings.TrimPrefix(body, bom) | |
| 66 | + } | |
| 67 | + // The file's line-ending style is that of its FIRST line ending. A mixed | |
| 68 | + // file — it happens, usually by accident — is thus rewritten in that style: | |
| 69 | + // better a consistent file than a diff where every line seems changed. | |
| 70 | + if i := strings.IndexByte(body, '\n'); i > 0 && body[i-1] == '\r' { | |
| 71 | + f.crlf = true | |
| 72 | + } | |
| 73 | + f.body = strings.ReplaceAll(body, "\r\n", "\n") | |
| 74 | + return f, nil | |
| 75 | +} | |
| 76 | + | |
| 77 | +// render puts the original shape (BOM, CRLF) back around a normalised body. | |
| 78 | +func (f *file) render(body string) string { | |
| 79 | + if f.crlf { | |
| 80 | + body = strings.ReplaceAll(body, "\n", "\r\n") | |
| 81 | + } | |
| 82 | + if f.hadBOM { | |
| 83 | + body = bom + body | |
| 84 | + } | |
| 85 | + return body | |
| 86 | +} | |
| 87 | + | |
| 88 | +// write replaces the file with a normalised body. | |
| 89 | +// | |
| 90 | +// It goes through a temporary file in the SAME directory, then a rename: a | |
| 91 | +// full disk or a process killed at the wrong moment leaves the original | |
| 92 | +// intact, where a direct os.WriteFile would have truncated it. The rename is | |
| 93 | +// only atomic on one file system, hence the same directory. | |
| 94 | +func (f *file) write(body string) error { | |
| 95 | + dir := filepath.Dir(f.path) | |
| 96 | + if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 97 | + return err | |
| 98 | + } | |
| 99 | + tmp, err := os.CreateTemp(dir, ".edit-*.tmp") | |
| 100 | + if err != nil { | |
| 101 | + return err | |
| 102 | + } | |
| 103 | + defer os.Remove(tmp.Name()) // no effect once the rename succeeded | |
| 104 | + | |
| 105 | + if _, err := tmp.WriteString(f.render(body)); err != nil { | |
| 106 | + tmp.Close() | |
| 107 | + return err | |
| 108 | + } | |
| 109 | + if err := tmp.Close(); err != nil { | |
| 110 | + return err | |
| 111 | + } | |
| 112 | + if err := os.Chmod(tmp.Name(), f.mode); err != nil { | |
| 113 | + return err | |
| 114 | + } | |
| 115 | + if err := os.Rename(tmp.Name(), f.path); err != nil { | |
| 116 | + return fmt.Errorf("%s: %w", f.path, err) | |
| 117 | + } | |
| 118 | + f.body, f.isNew = body, false | |
| 119 | + return nil | |
| 120 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,120 @@ | |||
| 1 | +// Package fileedit reads, writes and edits ONE text file by exact replacement. | ||
| 2 | +// It is a port of the core of the `edit` CLI (tools/edit: internal/edits, | ||
| 3 | +// internal/diff, internal/textfile) with the same rules and, as far as the | ||
| 4 | +// tool names allow, the same messages — so that the built-in tools of this part | ||
| 5 | +// and the CLI of part 05 refuse the same things, in the same words, and the | ||
| 6 | +// A/B comparison between them measures the MODEL, not the tool. | ||
| 7 | +// | ||
| 8 | +// Copied rather than imported: the CLI keeps its core under internal/, which Go | ||
| 9 | +// does not let another module import, and moving it out would change a module | ||
| 10 | +// this talk shows on stage. Four hundred lines of stdlib are cheaper than that. | ||
| 11 | +package fileedit | ||
| 12 | + | ||
| 13 | +import ( | ||
| 14 | + "fmt" | ||
| 15 | + "io/fs" | ||
| 16 | + "os" | ||
| 17 | + "path/filepath" | ||
| 18 | + "strings" | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +// bom is the UTF-8 byte order mark, as it appears at the head of a file. | ||
| 22 | +const bom = "\uFEFF" | ||
| 23 | + | ||
| 24 | +// file is a text file loaded in memory, ready to be edited. | ||
| 25 | +// | ||
| 26 | +// Two details invisible on screen would not survive a naive read/replace/write | ||
| 27 | +// round trip: the UTF-8 BOM some Windows editors put first — left in the body | ||
| 28 | +// it sticks to the first word and defeats an `old` that starts at the top of | ||
| 29 | +// the file — and CRLF line endings, which would defeat EVERY multi-line `old` | ||
| 30 | +// written with \n, that is, every one a model produces. Both are removed on | ||
| 31 | +// read and restored on write. | ||
| 32 | +type file struct { | ||
| 33 | + path string | ||
| 34 | + // body is the NORMALISED content: no BOM, \n line endings. Searches and | ||
| 35 | + // diffs work on it. | ||
| 36 | + body string | ||
| 37 | + mode fs.FileMode | ||
| 38 | + // isNew says the file did not exist yet. | ||
| 39 | + isNew bool | ||
| 40 | + hadBOM bool | ||
| 41 | + crlf bool | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// load reads a file. A missing file is not an error: it comes back empty and | ||
| 45 | +// marked isNew, which lets Write create it and Replace refuse with its own | ||
| 46 | +// message. | ||
| 47 | +func load(path string) (*file, error) { | ||
| 48 | + f := &file{path: path, mode: 0o644, isNew: true} | ||
| 49 | + | ||
| 50 | + data, err := os.ReadFile(path) | ||
| 51 | + if err != nil { | ||
| 52 | + if os.IsNotExist(err) { | ||
| 53 | + return f, nil | ||
| 54 | + } | ||
| 55 | + return nil, err | ||
| 56 | + } | ||
| 57 | + if info, err := os.Stat(path); err == nil { | ||
| 58 | + f.mode = info.Mode().Perm() | ||
| 59 | + } | ||
| 60 | + f.isNew = false | ||
| 61 | + | ||
| 62 | + body := string(data) | ||
| 63 | + if strings.HasPrefix(body, bom) { | ||
| 64 | + f.hadBOM = true | ||
| 65 | + body = strings.TrimPrefix(body, bom) | ||
| 66 | + } | ||
| 67 | + // The file's line-ending style is that of its FIRST line ending. A mixed | ||
| 68 | + // file — it happens, usually by accident — is thus rewritten in that style: | ||
| 69 | + // better a consistent file than a diff where every line seems changed. | ||
| 70 | + if i := strings.IndexByte(body, '\n'); i > 0 && body[i-1] == '\r' { | ||
| 71 | + f.crlf = true | ||
| 72 | + } | ||
| 73 | + f.body = strings.ReplaceAll(body, "\r\n", "\n") | ||
| 74 | + return f, nil | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +// render puts the original shape (BOM, CRLF) back around a normalised body. | ||
| 78 | +func (f *file) render(body string) string { | ||
| 79 | + if f.crlf { | ||
| 80 | + body = strings.ReplaceAll(body, "\n", "\r\n") | ||
| 81 | + } | ||
| 82 | + if f.hadBOM { | ||
| 83 | + body = bom + body | ||
| 84 | + } | ||
| 85 | + return body | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +// write replaces the file with a normalised body. | ||
| 89 | +// | ||
| 90 | +// It goes through a temporary file in the SAME directory, then a rename: a | ||
| 91 | +// full disk or a process killed at the wrong moment leaves the original | ||
| 92 | +// intact, where a direct os.WriteFile would have truncated it. The rename is | ||
| 93 | +// only atomic on one file system, hence the same directory. | ||
| 94 | +func (f *file) write(body string) error { | ||
| 95 | + dir := filepath.Dir(f.path) | ||
| 96 | + if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| 97 | + return err | ||
| 98 | + } | ||
| 99 | + tmp, err := os.CreateTemp(dir, ".edit-*.tmp") | ||
| 100 | + if err != nil { | ||
| 101 | + return err | ||
| 102 | + } | ||
| 103 | + defer os.Remove(tmp.Name()) // no effect once the rename succeeded | ||
| 104 | + | ||
| 105 | + if _, err := tmp.WriteString(f.render(body)); err != nil { | ||
| 106 | + tmp.Close() | ||
| 107 | + return err | ||
| 108 | + } | ||
| 109 | + if err := tmp.Close(); err != nil { | ||
| 110 | + return err | ||
| 111 | + } | ||
| 112 | + if err := os.Chmod(tmp.Name(), f.mode); err != nil { | ||
| 113 | + return err | ||
| 114 | + } | ||
| 115 | + if err := os.Rename(tmp.Name(), f.path); err != nil { | ||
| 116 | + return fmt.Errorf("%s: %w", f.path, err) | ||
| 117 | + } | ||
| 118 | + f.body, f.isNew = body, false | ||
| 119 | + return nil | ||
| 120 | +} | ||
added
internal/mention/mention.go +114 -0 | new file mode 100644 | ||
| @@ -0,0 +1,114 @@ | ||
| 1 | +// Package mention understands the @path notation of a prompt: "explain | |
| 2 | +// @internal/acp/acp.go" names a file the model should look at. There is no | |
| 3 | +// picker — the user types the path — and the file's content does not travel | |
| 4 | +// in the prompt: as for an editor's @-mention over ACP, the mention becomes an | |
| 5 | +// `[attached file: <absolute path>]` line and the model reads the file with | |
| 6 | +// its tools. Both front ends use it, so a typed @path means the same thing in | |
| 7 | +// the terminal and in an editor whose picker was bypassed. | |
| 8 | +package mention | |
| 9 | + | |
| 10 | +import ( | |
| 11 | + "fmt" | |
| 12 | + "os" | |
| 13 | + "path/filepath" | |
| 14 | + "strings" | |
| 15 | +) | |
| 16 | + | |
| 17 | +// Attachment is one @path mention that named an existing file or directory. | |
| 18 | +type Attachment struct { | |
| 19 | + Mention string // as typed, without the @ and the trailing punctuation | |
| 20 | + Path string // absolute, cleaned | |
| 21 | + Dir bool // a directory rather than a file | |
| 22 | +} | |
| 23 | + | |
| 24 | +// trailing is the sentence punctuation a mention may be glued to — "look at | |
| 25 | +// @main.go, then…" mentions main.go, not "main.go,". | |
| 26 | +const trailing = ",.;:!?)]}'\"`" | |
| 27 | + | |
| 28 | +// Expand finds the @path mentions of input, resolves them against cwd (an | |
| 29 | +// absolute path starts with / or ~/, anything else is relative to cwd) and | |
| 30 | +// returns the input followed by one attachment line per existing path, in | |
| 31 | +// order of first appearance, each path once: | |
| 32 | +// | |
| 33 | +// [attached file: /work/project/main.go] | |
| 34 | +// [attached directory: /work/project/internal] | |
| 35 | +// | |
| 36 | +// A mention that names nothing on disk is left alone: "@bob" in a sentence is | |
| 37 | +// not a file. A @ glued to a word (an e-mail address) is not a mention either. | |
| 38 | +func Expand(input, cwd string) (string, []Attachment) { | |
| 39 | + var out []Attachment | |
| 40 | + seen := map[string]bool{} | |
| 41 | + for _, m := range mentions(input) { | |
| 42 | + path := resolve(m, cwd) | |
| 43 | + if seen[path] { | |
| 44 | + continue | |
| 45 | + } | |
| 46 | + info, err := os.Stat(path) | |
| 47 | + if err != nil { | |
| 48 | + continue | |
| 49 | + } | |
| 50 | + seen[path] = true | |
| 51 | + out = append(out, Attachment{Mention: m, Path: path, Dir: info.IsDir()}) | |
| 52 | + } | |
| 53 | + var b strings.Builder | |
| 54 | + b.WriteString(input) | |
| 55 | + for _, a := range out { | |
| 56 | + fmt.Fprintf(&b, "\n%s", a.Line()) | |
| 57 | + } | |
| 58 | + return b.String(), out | |
| 59 | +} | |
| 60 | + | |
| 61 | +// Line is the attachment as the model sees it — the same shape an ACP | |
| 62 | +// resource_link takes, so the model meets one convention, not two. | |
| 63 | +func (a Attachment) Line() string { | |
| 64 | + if a.Dir { | |
| 65 | + return fmt.Sprintf("[attached directory: %s]", a.Path) | |
| 66 | + } | |
| 67 | + return fmt.Sprintf("[attached file: %s]", a.Path) | |
| 68 | +} | |
| 69 | + | |
| 70 | +// mentions returns the candidate paths: every "@something" that starts the | |
| 71 | +// input or follows a space, an opening bracket or a quote, stripped of the | |
| 72 | +// punctuation it may end with. Existence is the caller's business. | |
| 73 | +func mentions(input string) []string { | |
| 74 | + var out []string | |
| 75 | + for i := 0; i < len(input); i++ { | |
| 76 | + if input[i] != '@' || !startsMention(input, i) { | |
| 77 | + continue | |
| 78 | + } | |
| 79 | + end := i + 1 | |
| 80 | + for end < len(input) && !isSpace(input[end]) { | |
| 81 | + end++ | |
| 82 | + } | |
| 83 | + m := strings.TrimRight(input[i+1:end], trailing) | |
| 84 | + if m != "" { | |
| 85 | + out = append(out, m) | |
| 86 | + } | |
| 87 | + i = end | |
| 88 | + } | |
| 89 | + return out | |
| 90 | +} | |
| 91 | + | |
| 92 | +func startsMention(input string, i int) bool { | |
| 93 | + if i == 0 { | |
| 94 | + return true | |
| 95 | + } | |
| 96 | + return isSpace(input[i-1]) || strings.IndexByte("([{'\"`", input[i-1]) >= 0 | |
| 97 | +} | |
| 98 | + | |
| 99 | +func isSpace(c byte) bool { | |
| 100 | + return c == ' ' || c == '\t' || c == '\n' || c == '\r' | |
| 101 | +} | |
| 102 | + | |
| 103 | +// resolve turns a mention into an absolute, cleaned path. | |
| 104 | +func resolve(m, cwd string) string { | |
| 105 | + switch { | |
| 106 | + case filepath.IsAbs(m): | |
| 107 | + return filepath.Clean(m) | |
| 108 | + case m == "~" || strings.HasPrefix(m, "~/"): | |
| 109 | + if home, err := os.UserHomeDir(); err == nil { | |
| 110 | + return filepath.Clean(filepath.Join(home, m[1:])) | |
| 111 | + } | |
| 112 | + } | |
| 113 | + return filepath.Clean(filepath.Join(cwd, m)) | |
| 114 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,114 @@ | |||
| 1 | +// Package mention understands the @path notation of a prompt: "explain | ||
| 2 | +// @internal/acp/acp.go" names a file the model should look at. There is no | ||
| 3 | +// picker — the user types the path — and the file's content does not travel | ||
| 4 | +// in the prompt: as for an editor's @-mention over ACP, the mention becomes an | ||
| 5 | +// `[attached file: <absolute path>]` line and the model reads the file with | ||
| 6 | +// its tools. Both front ends use it, so a typed @path means the same thing in | ||
| 7 | +// the terminal and in an editor whose picker was bypassed. | ||
| 8 | +package mention | ||
| 9 | + | ||
| 10 | +import ( | ||
| 11 | + "fmt" | ||
| 12 | + "os" | ||
| 13 | + "path/filepath" | ||
| 14 | + "strings" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +// Attachment is one @path mention that named an existing file or directory. | ||
| 18 | +type Attachment struct { | ||
| 19 | + Mention string // as typed, without the @ and the trailing punctuation | ||
| 20 | + Path string // absolute, cleaned | ||
| 21 | + Dir bool // a directory rather than a file | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +// trailing is the sentence punctuation a mention may be glued to — "look at | ||
| 25 | +// @main.go, then…" mentions main.go, not "main.go,". | ||
| 26 | +const trailing = ",.;:!?)]}'\"`" | ||
| 27 | + | ||
| 28 | +// Expand finds the @path mentions of input, resolves them against cwd (an | ||
| 29 | +// absolute path starts with / or ~/, anything else is relative to cwd) and | ||
| 30 | +// returns the input followed by one attachment line per existing path, in | ||
| 31 | +// order of first appearance, each path once: | ||
| 32 | +// | ||
| 33 | +// [attached file: /work/project/main.go] | ||
| 34 | +// [attached directory: /work/project/internal] | ||
| 35 | +// | ||
| 36 | +// A mention that names nothing on disk is left alone: "@bob" in a sentence is | ||
| 37 | +// not a file. A @ glued to a word (an e-mail address) is not a mention either. | ||
| 38 | +func Expand(input, cwd string) (string, []Attachment) { | ||
| 39 | + var out []Attachment | ||
| 40 | + seen := map[string]bool{} | ||
| 41 | + for _, m := range mentions(input) { | ||
| 42 | + path := resolve(m, cwd) | ||
| 43 | + if seen[path] { | ||
| 44 | + continue | ||
| 45 | + } | ||
| 46 | + info, err := os.Stat(path) | ||
| 47 | + if err != nil { | ||
| 48 | + continue | ||
| 49 | + } | ||
| 50 | + seen[path] = true | ||
| 51 | + out = append(out, Attachment{Mention: m, Path: path, Dir: info.IsDir()}) | ||
| 52 | + } | ||
| 53 | + var b strings.Builder | ||
| 54 | + b.WriteString(input) | ||
| 55 | + for _, a := range out { | ||
| 56 | + fmt.Fprintf(&b, "\n%s", a.Line()) | ||
| 57 | + } | ||
| 58 | + return b.String(), out | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +// Line is the attachment as the model sees it — the same shape an ACP | ||
| 62 | +// resource_link takes, so the model meets one convention, not two. | ||
| 63 | +func (a Attachment) Line() string { | ||
| 64 | + if a.Dir { | ||
| 65 | + return fmt.Sprintf("[attached directory: %s]", a.Path) | ||
| 66 | + } | ||
| 67 | + return fmt.Sprintf("[attached file: %s]", a.Path) | ||
| 68 | +} | ||
| 69 | + | ||
| 70 | +// mentions returns the candidate paths: every "@something" that starts the | ||
| 71 | +// input or follows a space, an opening bracket or a quote, stripped of the | ||
| 72 | +// punctuation it may end with. Existence is the caller's business. | ||
| 73 | +func mentions(input string) []string { | ||
| 74 | + var out []string | ||
| 75 | + for i := 0; i < len(input); i++ { | ||
| 76 | + if input[i] != '@' || !startsMention(input, i) { | ||
| 77 | + continue | ||
| 78 | + } | ||
| 79 | + end := i + 1 | ||
| 80 | + for end < len(input) && !isSpace(input[end]) { | ||
| 81 | + end++ | ||
| 82 | + } | ||
| 83 | + m := strings.TrimRight(input[i+1:end], trailing) | ||
| 84 | + if m != "" { | ||
| 85 | + out = append(out, m) | ||
| 86 | + } | ||
| 87 | + i = end | ||
| 88 | + } | ||
| 89 | + return out | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +func startsMention(input string, i int) bool { | ||
| 93 | + if i == 0 { | ||
| 94 | + return true | ||
| 95 | + } | ||
| 96 | + return isSpace(input[i-1]) || strings.IndexByte("([{'\"`", input[i-1]) >= 0 | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +func isSpace(c byte) bool { | ||
| 100 | + return c == ' ' || c == '\t' || c == '\n' || c == '\r' | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +// resolve turns a mention into an absolute, cleaned path. | ||
| 104 | +func resolve(m, cwd string) string { | ||
| 105 | + switch { | ||
| 106 | + case filepath.IsAbs(m): | ||
| 107 | + return filepath.Clean(m) | ||
| 108 | + case m == "~" || strings.HasPrefix(m, "~/"): | ||
| 109 | + if home, err := os.UserHomeDir(); err == nil { | ||
| 110 | + return filepath.Clean(filepath.Join(home, m[1:])) | ||
| 111 | + } | ||
| 112 | + } | ||
| 113 | + return filepath.Clean(filepath.Join(cwd, m)) | ||
| 114 | +} | ||
added
internal/mention/mention_test.go +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +package mention | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// A workspace with a file, a nested file and a directory, to mention. | |
| 11 | +func workspace(t *testing.T) string { | |
| 12 | + t.Helper() | |
| 13 | + dir := t.TempDir() | |
| 14 | + for _, p := range []string{"main.go", "internal/acp/acp.go"} { | |
| 15 | + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, p)), 0o755); err != nil { | |
| 16 | + t.Fatal(err) | |
| 17 | + } | |
| 18 | + if err := os.WriteFile(filepath.Join(dir, p), []byte("package x"), 0o644); err != nil { | |
| 19 | + t.Fatal(err) | |
| 20 | + } | |
| 21 | + } | |
| 22 | + return dir | |
| 23 | +} | |
| 24 | + | |
| 25 | +// The text is kept as typed and each existing path becomes one attachment | |
| 26 | +// line, in order, once — the shape an ACP resource_link takes. | |
| 27 | +func TestExpandAttachesExistingPathsOnce(t *testing.T) { | |
| 28 | + dir := workspace(t) | |
| 29 | + in := "compare @main.go with @internal/acp/acp.go, then @main.go again; and @internal too" | |
| 30 | + got, att := Expand(in, dir) | |
| 31 | + | |
| 32 | + want := in + | |
| 33 | + "\n[attached file: " + filepath.Join(dir, "main.go") + "]" + | |
| 34 | + "\n[attached file: " + filepath.Join(dir, "internal/acp/acp.go") + "]" + | |
| 35 | + "\n[attached directory: " + filepath.Join(dir, "internal") + "]" | |
| 36 | + if got != want { | |
| 37 | + t.Errorf("Expand:\n got %q\nwant %q", got, want) | |
| 38 | + } | |
| 39 | + if len(att) != 3 || att[0].Mention != "main.go" || att[2].Dir != true || att[1].Dir { | |
| 40 | + t.Errorf("attachments %+v", att) | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +// What is NOT a mention: a path that does not exist (a name, a handle), an | |
| 45 | +// e-mail address (@ glued to a word), a lone @, and the input untouched when | |
| 46 | +// nothing matches. | |
| 47 | +func TestExpandLeavesNonPathsAlone(t *testing.T) { | |
| 48 | + dir := workspace(t) | |
| 49 | + for _, in := range []string{ | |
| 50 | + "ask @bob about it", | |
| 51 | + "mail k33g@example.com", | |
| 52 | + "a lone @ sign", | |
| 53 | + "@does/not/exist.go", | |
| 54 | + "", | |
| 55 | + } { | |
| 56 | + got, att := Expand(in, dir) | |
| 57 | + if got != in || len(att) != 0 { | |
| 58 | + t.Errorf("Expand(%q) = %q, %+v; want the input unchanged and no attachment", in, got, att) | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +// Absolute paths, ~ paths, and mentions wrapped in brackets or quotes or | |
| 64 | +// glued to punctuation all resolve to the same clean absolute path. | |
| 65 | +func TestExpandResolvesAbsoluteHomeAndPunctuated(t *testing.T) { | |
| 66 | + dir := workspace(t) | |
| 67 | + t.Setenv("HOME", dir) | |
| 68 | + abs := filepath.Join(dir, "main.go") | |
| 69 | + for _, in := range []string{ | |
| 70 | + "see @" + abs + ".", | |
| 71 | + "see @~/main.go!", | |
| 72 | + "see (@main.go)", | |
| 73 | + "see \"@./main.go\"", | |
| 74 | + "see @" + dir + "/internal/../main.go", | |
| 75 | + "@main.go", | |
| 76 | + } { | |
| 77 | + _, att := Expand(in, dir) | |
| 78 | + if len(att) != 1 || att[0].Path != abs { | |
| 79 | + t.Errorf("Expand(%q) attachments = %+v, want one at %s", in, att, abs) | |
| 80 | + } | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +// A mention is resolved against cwd, not the process directory: the ACP | |
| 85 | +// front end passes the session's cwd, the terminal the directory mm runs in. | |
| 86 | +func TestExpandIsRelativeToCwd(t *testing.T) { | |
| 87 | + dir := workspace(t) | |
| 88 | + _, att := Expand("@acp.go", filepath.Join(dir, "internal", "acp")) | |
| 89 | + if len(att) != 1 || !strings.HasSuffix(att[0].Path, filepath.Join("internal", "acp", "acp.go")) { | |
| 90 | + t.Errorf("attachments %+v", att) | |
| 91 | + } | |
| 92 | + if _, att := Expand("@acp.go", dir); len(att) != 0 { | |
| 93 | + t.Errorf("acp.go does not exist at the root, yet %+v", att) | |
| 94 | + } | |
| 95 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +package mention | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// A workspace with a file, a nested file and a directory, to mention. | ||
| 11 | +func workspace(t *testing.T) string { | ||
| 12 | + t.Helper() | ||
| 13 | + dir := t.TempDir() | ||
| 14 | + for _, p := range []string{"main.go", "internal/acp/acp.go"} { | ||
| 15 | + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, p)), 0o755); err != nil { | ||
| 16 | + t.Fatal(err) | ||
| 17 | + } | ||
| 18 | + if err := os.WriteFile(filepath.Join(dir, p), []byte("package x"), 0o644); err != nil { | ||
| 19 | + t.Fatal(err) | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | + return dir | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +// The text is kept as typed and each existing path becomes one attachment | ||
| 26 | +// line, in order, once — the shape an ACP resource_link takes. | ||
| 27 | +func TestExpandAttachesExistingPathsOnce(t *testing.T) { | ||
| 28 | + dir := workspace(t) | ||
| 29 | + in := "compare @main.go with @internal/acp/acp.go, then @main.go again; and @internal too" | ||
| 30 | + got, att := Expand(in, dir) | ||
| 31 | + | ||
| 32 | + want := in + | ||
| 33 | + "\n[attached file: " + filepath.Join(dir, "main.go") + "]" + | ||
| 34 | + "\n[attached file: " + filepath.Join(dir, "internal/acp/acp.go") + "]" + | ||
| 35 | + "\n[attached directory: " + filepath.Join(dir, "internal") + "]" | ||
| 36 | + if got != want { | ||
| 37 | + t.Errorf("Expand:\n got %q\nwant %q", got, want) | ||
| 38 | + } | ||
| 39 | + if len(att) != 3 || att[0].Mention != "main.go" || att[2].Dir != true || att[1].Dir { | ||
| 40 | + t.Errorf("attachments %+v", att) | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// What is NOT a mention: a path that does not exist (a name, a handle), an | ||
| 45 | +// e-mail address (@ glued to a word), a lone @, and the input untouched when | ||
| 46 | +// nothing matches. | ||
| 47 | +func TestExpandLeavesNonPathsAlone(t *testing.T) { | ||
| 48 | + dir := workspace(t) | ||
| 49 | + for _, in := range []string{ | ||
| 50 | + "ask @bob about it", | ||
| 51 | + "mail k33g@example.com", | ||
| 52 | + "a lone @ sign", | ||
| 53 | + "@does/not/exist.go", | ||
| 54 | + "", | ||
| 55 | + } { | ||
| 56 | + got, att := Expand(in, dir) | ||
| 57 | + if got != in || len(att) != 0 { | ||
| 58 | + t.Errorf("Expand(%q) = %q, %+v; want the input unchanged and no attachment", in, got, att) | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +// Absolute paths, ~ paths, and mentions wrapped in brackets or quotes or | ||
| 64 | +// glued to punctuation all resolve to the same clean absolute path. | ||
| 65 | +func TestExpandResolvesAbsoluteHomeAndPunctuated(t *testing.T) { | ||
| 66 | + dir := workspace(t) | ||
| 67 | + t.Setenv("HOME", dir) | ||
| 68 | + abs := filepath.Join(dir, "main.go") | ||
| 69 | + for _, in := range []string{ | ||
| 70 | + "see @" + abs + ".", | ||
| 71 | + "see @~/main.go!", | ||
| 72 | + "see (@main.go)", | ||
| 73 | + "see \"@./main.go\"", | ||
| 74 | + "see @" + dir + "/internal/../main.go", | ||
| 75 | + "@main.go", | ||
| 76 | + } { | ||
| 77 | + _, att := Expand(in, dir) | ||
| 78 | + if len(att) != 1 || att[0].Path != abs { | ||
| 79 | + t.Errorf("Expand(%q) attachments = %+v, want one at %s", in, att, abs) | ||
| 80 | + } | ||
| 81 | + } | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +// A mention is resolved against cwd, not the process directory: the ACP | ||
| 85 | +// front end passes the session's cwd, the terminal the directory mm runs in. | ||
| 86 | +func TestExpandIsRelativeToCwd(t *testing.T) { | ||
| 87 | + dir := workspace(t) | ||
| 88 | + _, att := Expand("@acp.go", filepath.Join(dir, "internal", "acp")) | ||
| 89 | + if len(att) != 1 || !strings.HasSuffix(att[0].Path, filepath.Join("internal", "acp", "acp.go")) { | ||
| 90 | + t.Errorf("attachments %+v", att) | ||
| 91 | + } | ||
| 92 | + if _, att := Expand("@acp.go", dir); len(att) != 0 { | ||
| 93 | + t.Errorf("acp.go does not exist at the root, yet %+v", att) | ||
| 94 | + } | ||
| 95 | +} | ||
added
internal/session/session.go +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +// Package session holds what the two front ends — the terminal REPL | |
| 2 | +// (internal/agent) and the editor front end (internal/acp) — agree a | |
| 3 | +// conversation starts from, and how it is reset by the /new command. | |
| 4 | +// | |
| 5 | +// A session, for both of them, is a plain []*ai.Message whose first element is | |
| 6 | +// the system prompt. "Starting a new session" means going back to exactly that: | |
| 7 | +// the system prompt alone, every question, answer and tool turn forgotten. | |
| 8 | +// The package knows nothing about the screen or the protocol: printing the | |
| 9 | +// report line, or sending the session/update, stays each front end's job. | |
| 10 | +package session | |
| 11 | + | |
| 12 | +import ( | |
| 13 | + "strings" | |
| 14 | + | |
| 15 | + "github.com/firebase/genkit/go/ai" | |
| 16 | +) | |
| 17 | + | |
| 18 | +// NewCommand is the slash command that clears the history and starts a new | |
| 19 | +// session. The same spelling is used at the terminal prompt and in an ACP | |
| 20 | +// prompt; ACP advertises it without the slash (see NewCommandName). | |
| 21 | +const NewCommand = "/new" | |
| 22 | + | |
| 23 | +// NewCommandName is NewCommand as ACP names it: clients list commands by bare | |
| 24 | +// name and send them back as prompt text with the leading slash. | |
| 25 | +const NewCommandName = "new" | |
| 26 | + | |
| 27 | +// IsNewCommand reports whether the user's input is the /new command. The | |
| 28 | +// input is trimmed first: an editor sends the line as typed, spaces and | |
| 29 | +// trailing newline included, and "/new " must not be mistaken for a question. | |
| 30 | +// | |
| 31 | +// session.IsNewCommand("/new") // true | |
| 32 | +// session.IsNewCommand(" /new\n") // true | |
| 33 | +// session.IsNewCommand("/new one") // false — not the command | |
| 34 | +func IsNewCommand(input string) bool { | |
| 35 | + return strings.TrimSpace(input) == NewCommand | |
| 36 | +} | |
| 37 | + | |
| 38 | +// Fresh returns the history of a brand-new session: the system prompt and | |
| 39 | +// nothing else. Both front ends start from it, and /new returns to it. | |
| 40 | +// | |
| 41 | +// messages := session.Fresh(config.Cfg.System) | |
| 42 | +// // len(messages) == 1, messages[0].Role == ai.RoleSystem | |
| 43 | +func Fresh(system string) []*ai.Message { | |
| 44 | + return []*ai.Message{ai.NewSystemTextMessage(system)} | |
| 45 | +} | |
| 46 | + | |
| 47 | +// Forgotten counts the messages a reset throws away: everything except the | |
| 48 | +// system prompt. It is the figure the report line shows ("12 message(s) | |
| 49 | +// forgotten"), so the user sees that the command did something — or that | |
| 50 | +// there was nothing to forget. | |
| 51 | +// | |
| 52 | +// session.Forgotten(session.Fresh("you are bob")) // 0 | |
| 53 | +func Forgotten(msgs []*ai.Message) int { | |
| 54 | + n := 0 | |
| 55 | + for _, m := range msgs { | |
| 56 | + if m.Role != ai.RoleSystem { | |
| 57 | + n++ | |
| 58 | + } | |
| 59 | + } | |
| 60 | + return n | |
| 61 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +// Package session holds what the two front ends — the terminal REPL | ||
| 2 | +// (internal/agent) and the editor front end (internal/acp) — agree a | ||
| 3 | +// conversation starts from, and how it is reset by the /new command. | ||
| 4 | +// | ||
| 5 | +// A session, for both of them, is a plain []*ai.Message whose first element is | ||
| 6 | +// the system prompt. "Starting a new session" means going back to exactly that: | ||
| 7 | +// the system prompt alone, every question, answer and tool turn forgotten. | ||
| 8 | +// The package knows nothing about the screen or the protocol: printing the | ||
| 9 | +// report line, or sending the session/update, stays each front end's job. | ||
| 10 | +package session | ||
| 11 | + | ||
| 12 | +import ( | ||
| 13 | + "strings" | ||
| 14 | + | ||
| 15 | + "github.com/firebase/genkit/go/ai" | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +// NewCommand is the slash command that clears the history and starts a new | ||
| 19 | +// session. The same spelling is used at the terminal prompt and in an ACP | ||
| 20 | +// prompt; ACP advertises it without the slash (see NewCommandName). | ||
| 21 | +const NewCommand = "/new" | ||
| 22 | + | ||
| 23 | +// NewCommandName is NewCommand as ACP names it: clients list commands by bare | ||
| 24 | +// name and send them back as prompt text with the leading slash. | ||
| 25 | +const NewCommandName = "new" | ||
| 26 | + | ||
| 27 | +// IsNewCommand reports whether the user's input is the /new command. The | ||
| 28 | +// input is trimmed first: an editor sends the line as typed, spaces and | ||
| 29 | +// trailing newline included, and "/new " must not be mistaken for a question. | ||
| 30 | +// | ||
| 31 | +// session.IsNewCommand("/new") // true | ||
| 32 | +// session.IsNewCommand(" /new\n") // true | ||
| 33 | +// session.IsNewCommand("/new one") // false — not the command | ||
| 34 | +func IsNewCommand(input string) bool { | ||
| 35 | + return strings.TrimSpace(input) == NewCommand | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +// Fresh returns the history of a brand-new session: the system prompt and | ||
| 39 | +// nothing else. Both front ends start from it, and /new returns to it. | ||
| 40 | +// | ||
| 41 | +// messages := session.Fresh(config.Cfg.System) | ||
| 42 | +// // len(messages) == 1, messages[0].Role == ai.RoleSystem | ||
| 43 | +func Fresh(system string) []*ai.Message { | ||
| 44 | + return []*ai.Message{ai.NewSystemTextMessage(system)} | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +// Forgotten counts the messages a reset throws away: everything except the | ||
| 48 | +// system prompt. It is the figure the report line shows ("12 message(s) | ||
| 49 | +// forgotten"), so the user sees that the command did something — or that | ||
| 50 | +// there was nothing to forget. | ||
| 51 | +// | ||
| 52 | +// session.Forgotten(session.Fresh("you are bob")) // 0 | ||
| 53 | +func Forgotten(msgs []*ai.Message) int { | ||
| 54 | + n := 0 | ||
| 55 | + for _, m := range msgs { | ||
| 56 | + if m.Role != ai.RoleSystem { | ||
| 57 | + n++ | ||
| 58 | + } | ||
| 59 | + } | ||
| 60 | + return n | ||
| 61 | +} | ||
added
internal/session/session_test.go +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +package session | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "github.com/firebase/genkit/go/ai" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// The command is recognised as typed at a prompt: surrounding whitespace and | |
| 11 | +// the trailing newline an editor keeps must not hide it, and anything after | |
| 12 | +// the word is a question, not the command. | |
| 13 | +func TestIsNewCommandTrimsButDoesNotGuess(t *testing.T) { | |
| 14 | + cases := map[string]bool{ | |
| 15 | + "/new": true, | |
| 16 | + " /new ": true, | |
| 17 | + "/new\n": true, | |
| 18 | + "/new please": false, | |
| 19 | + "/News": false, | |
| 20 | + "new": false, | |
| 21 | + "": false, | |
| 22 | + "/compact": false, | |
| 23 | + "/new\tthings": false, | |
| 24 | + } | |
| 25 | + for in, want := range cases { | |
| 26 | + if got := IsNewCommand(in); got != want { | |
| 27 | + t.Errorf("IsNewCommand(%q) = %v, want %v", in, got, want) | |
| 28 | + } | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +// A fresh session is the system prompt alone, with the text given. | |
| 33 | +func TestFreshIsTheSystemPromptAlone(t *testing.T) { | |
| 34 | + msgs := Fresh("you are bob") | |
| 35 | + if len(msgs) != 1 { | |
| 36 | + t.Fatalf("Fresh: %d message(s), want 1", len(msgs)) | |
| 37 | + } | |
| 38 | + if msgs[0].Role != ai.RoleSystem { | |
| 39 | + t.Errorf("Fresh: role %q, want %q", msgs[0].Role, ai.RoleSystem) | |
| 40 | + } | |
| 41 | + if got := msgs[0].Text(); got != "you are bob" { | |
| 42 | + t.Errorf("Fresh: text %q, want %q", got, "you are bob") | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +// Two fresh sessions must not share their backing array: appending to one | |
| 47 | +// would otherwise leak into the other (one process, several ACP sessions). | |
| 48 | +func TestFreshReturnsIndependentSlices(t *testing.T) { | |
| 49 | + a := Fresh("s") | |
| 50 | + b := Fresh("s") | |
| 51 | + a = append(a, ai.NewUserTextMessage("hello")) | |
| 52 | + if len(a) != 2 { | |
| 53 | + t.Fatalf("appending to a fresh session: %d message(s), want 2", len(a)) | |
| 54 | + } | |
| 55 | + if len(b) != 1 { | |
| 56 | + t.Errorf("appending to one fresh session changed another: %d message(s)", len(b)) | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +// Forgotten counts what a reset drops: every message but the system prompt, | |
| 61 | +// tool turns included. A fresh session forgets nothing. | |
| 62 | +func TestForgottenCountsEverythingButTheSystemPrompt(t *testing.T) { | |
| 63 | + if got := Forgotten(Fresh("s")); got != 0 { | |
| 64 | + t.Errorf("Forgotten(fresh) = %d, want 0", got) | |
| 65 | + } | |
| 66 | + if got := Forgotten(nil); got != 0 { | |
| 67 | + t.Errorf("Forgotten(nil) = %d, want 0", got) | |
| 68 | + } | |
| 69 | + msgs := append(Fresh("s"), | |
| 70 | + ai.NewUserTextMessage("ls"), | |
| 71 | + ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}})), | |
| 72 | + ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "main.go"})), | |
| 73 | + ai.NewModelTextMessage("There is one file."), | |
| 74 | + ) | |
| 75 | + if got := Forgotten(msgs); got != 4 { | |
| 76 | + t.Errorf("Forgotten = %d, want 4", got) | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +func ExampleIsNewCommand() { | |
| 81 | + fmt.Println(IsNewCommand("/new"), IsNewCommand("/new please")) | |
| 82 | + // Output: true false | |
| 83 | +} | |
| 84 | + | |
| 85 | +func ExampleFresh() { | |
| 86 | + msgs := Fresh("You are a coding agent.") | |
| 87 | + fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs)) | |
| 88 | + // Output: 1 system 0 | |
| 89 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +package session | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "github.com/firebase/genkit/go/ai" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// The command is recognised as typed at a prompt: surrounding whitespace and | ||
| 11 | +// the trailing newline an editor keeps must not hide it, and anything after | ||
| 12 | +// the word is a question, not the command. | ||
| 13 | +func TestIsNewCommandTrimsButDoesNotGuess(t *testing.T) { | ||
| 14 | + cases := map[string]bool{ | ||
| 15 | + "/new": true, | ||
| 16 | + " /new ": true, | ||
| 17 | + "/new\n": true, | ||
| 18 | + "/new please": false, | ||
| 19 | + "/News": false, | ||
| 20 | + "new": false, | ||
| 21 | + "": false, | ||
| 22 | + "/compact": false, | ||
| 23 | + "/new\tthings": false, | ||
| 24 | + } | ||
| 25 | + for in, want := range cases { | ||
| 26 | + if got := IsNewCommand(in); got != want { | ||
| 27 | + t.Errorf("IsNewCommand(%q) = %v, want %v", in, got, want) | ||
| 28 | + } | ||
| 29 | + } | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +// A fresh session is the system prompt alone, with the text given. | ||
| 33 | +func TestFreshIsTheSystemPromptAlone(t *testing.T) { | ||
| 34 | + msgs := Fresh("you are bob") | ||
| 35 | + if len(msgs) != 1 { | ||
| 36 | + t.Fatalf("Fresh: %d message(s), want 1", len(msgs)) | ||
| 37 | + } | ||
| 38 | + if msgs[0].Role != ai.RoleSystem { | ||
| 39 | + t.Errorf("Fresh: role %q, want %q", msgs[0].Role, ai.RoleSystem) | ||
| 40 | + } | ||
| 41 | + if got := msgs[0].Text(); got != "you are bob" { | ||
| 42 | + t.Errorf("Fresh: text %q, want %q", got, "you are bob") | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +// Two fresh sessions must not share their backing array: appending to one | ||
| 47 | +// would otherwise leak into the other (one process, several ACP sessions). | ||
| 48 | +func TestFreshReturnsIndependentSlices(t *testing.T) { | ||
| 49 | + a := Fresh("s") | ||
| 50 | + b := Fresh("s") | ||
| 51 | + a = append(a, ai.NewUserTextMessage("hello")) | ||
| 52 | + if len(a) != 2 { | ||
| 53 | + t.Fatalf("appending to a fresh session: %d message(s), want 2", len(a)) | ||
| 54 | + } | ||
| 55 | + if len(b) != 1 { | ||
| 56 | + t.Errorf("appending to one fresh session changed another: %d message(s)", len(b)) | ||
| 57 | + } | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// Forgotten counts what a reset drops: every message but the system prompt, | ||
| 61 | +// tool turns included. A fresh session forgets nothing. | ||
| 62 | +func TestForgottenCountsEverythingButTheSystemPrompt(t *testing.T) { | ||
| 63 | + if got := Forgotten(Fresh("s")); got != 0 { | ||
| 64 | + t.Errorf("Forgotten(fresh) = %d, want 0", got) | ||
| 65 | + } | ||
| 66 | + if got := Forgotten(nil); got != 0 { | ||
| 67 | + t.Errorf("Forgotten(nil) = %d, want 0", got) | ||
| 68 | + } | ||
| 69 | + msgs := append(Fresh("s"), | ||
| 70 | + ai.NewUserTextMessage("ls"), | ||
| 71 | + ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}})), | ||
| 72 | + ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "main.go"})), | ||
| 73 | + ai.NewModelTextMessage("There is one file."), | ||
| 74 | + ) | ||
| 75 | + if got := Forgotten(msgs); got != 4 { | ||
| 76 | + t.Errorf("Forgotten = %d, want 4", got) | ||
| 77 | + } | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +func ExampleIsNewCommand() { | ||
| 81 | + fmt.Println(IsNewCommand("/new"), IsNewCommand("/new please")) | ||
| 82 | + // Output: true false | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +func ExampleFresh() { | ||
| 86 | + msgs := Fresh("You are a coding agent.") | ||
| 87 | + fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs)) | ||
| 88 | + // Output: 1 system 0 | ||
| 89 | +} | ||
added
internal/skills/skills.go +140 -0 | new file mode 100644 | ||
| @@ -0,0 +1,140 @@ | ||
| 1 | +// Package skills discovers the markdown procedures in ./skills. | |
| 2 | +// | |
| 3 | +// A skill is a plain markdown file with a small front matter: | |
| 4 | +// | |
| 5 | +// --- | |
| 6 | +// name: go-rename | |
| 7 | +// description: rename a Go symbol everywhere it is used | |
| 8 | +// --- | |
| 9 | +// # Rename a Go symbol | |
| 10 | +// ... | |
| 11 | +// | |
| 12 | +// The point of the package is to turn that directory into ONE STRING: the | |
| 13 | +// description of the `read_skill` tool. The model never lists the directory | |
| 14 | +// itself — it reads the catalogue in the tool description, the same way it | |
| 15 | +// reads the description of `bash`. | |
| 16 | +package skills | |
| 17 | + | |
| 18 | +import ( | |
| 19 | + "os" | |
| 20 | + "path/filepath" | |
| 21 | + "sort" | |
| 22 | + "strings" | |
| 23 | +) | |
| 24 | + | |
| 25 | +// Skill is one markdown procedure on disk. | |
| 26 | +type Skill struct { | |
| 27 | + Name string // from the front matter, falling back to the file name | |
| 28 | + Description string // the one-liner shown in the catalogue | |
| 29 | + Path string | |
| 30 | +} | |
| 31 | + | |
| 32 | +// skillFile is the file name of a skill stored in its own directory — the | |
| 33 | +// Agent Skills convention (`<skillsDir>/<name>/SKILL.md`), the layout the | |
| 34 | +// shipped examples use. | |
| 35 | +const skillFile = "SKILL.md" | |
| 36 | + | |
| 37 | +// defaultName is the name a skill gets when its front matter has none: the | |
| 38 | +// file name for a flat `<name>.md`, the directory name for `<name>/SKILL.md`. | |
| 39 | +func defaultName(path string) string { | |
| 40 | + if filepath.Base(path) == skillFile { | |
| 41 | + return filepath.Base(filepath.Dir(path)) | |
| 42 | + } | |
| 43 | + return strings.TrimSuffix(filepath.Base(path), ".md") | |
| 44 | +} | |
| 45 | + | |
| 46 | +// parseHeader reads the `name:` and `description:` lines of the front matter. | |
| 47 | +// It stops at the closing `---`: a `description:` in the body is not metadata. | |
| 48 | +func parseHeader(content, path string) Skill { | |
| 49 | + s := Skill{Name: defaultName(path), Path: path} | |
| 50 | + lines := strings.Split(content, "\n") | |
| 51 | + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { | |
| 52 | + return s | |
| 53 | + } | |
| 54 | + for _, line := range lines[1:] { | |
| 55 | + if strings.TrimSpace(line) == "---" { | |
| 56 | + break | |
| 57 | + } | |
| 58 | + key, value, found := strings.Cut(line, ":") | |
| 59 | + if !found { | |
| 60 | + continue | |
| 61 | + } | |
| 62 | + value = strings.TrimSpace(value) | |
| 63 | + switch strings.TrimSpace(key) { | |
| 64 | + case "name": | |
| 65 | + if value != "" { | |
| 66 | + s.Name = value | |
| 67 | + } | |
| 68 | + case "description": | |
| 69 | + s.Description = value | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return s | |
| 73 | +} | |
| 74 | + | |
| 75 | +// List returns the skills of dir, sorted by name. Two layouts are accepted, | |
| 76 | +// and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill, | |
| 77 | +// `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means | |
| 78 | +// this agent has no skills. | |
| 79 | +func List(dir string) []Skill { | |
| 80 | + flat, err := filepath.Glob(filepath.Join(dir, "*.md")) | |
| 81 | + if err != nil { | |
| 82 | + return nil | |
| 83 | + } | |
| 84 | + nested, err := filepath.Glob(filepath.Join(dir, "*", skillFile)) | |
| 85 | + if err != nil { | |
| 86 | + return nil | |
| 87 | + } | |
| 88 | + var out []Skill | |
| 89 | + for _, p := range append(flat, nested...) { | |
| 90 | + content, err := os.ReadFile(p) | |
| 91 | + if err != nil { | |
| 92 | + continue | |
| 93 | + } | |
| 94 | + out = append(out, parseHeader(string(content), p)) | |
| 95 | + } | |
| 96 | + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | |
| 97 | + return out | |
| 98 | +} | |
| 99 | + | |
| 100 | +// Read returns the full markdown of one skill, whichever layout it uses: the | |
| 101 | +// flat `<name>.md` is tried first, then `<name>/SKILL.md`. | |
| 102 | +func Read(dir, name string) (string, error) { | |
| 103 | + // Keep the name a plain file name: no "../" escaping out of the directory. | |
| 104 | + name = filepath.Base(name) | |
| 105 | + content, err := os.ReadFile(filepath.Join(dir, name+".md")) | |
| 106 | + if err != nil { | |
| 107 | + if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil { | |
| 108 | + return string(nested), nil | |
| 109 | + } | |
| 110 | + } | |
| 111 | + return string(content), err | |
| 112 | +} | |
| 113 | + | |
| 114 | +// Catalogue renders the list as the tool description the model will read. | |
| 115 | +// This IS the prompt engineering: what the model knows about the available | |
| 116 | +// skills is exactly these lines. | |
| 117 | +func Catalogue(list []Skill) string { | |
| 118 | + var b strings.Builder | |
| 119 | + b.WriteString("Load a skill: the step-by-step procedure to follow for this kind of task. " + | |
| 120 | + "Call it BEFORE doing the work, with the name of the matching skill, and then follow " + | |
| 121 | + "what it says. Available skills:\n") | |
| 122 | + for _, s := range list { | |
| 123 | + b.WriteString(" " + s.Name) | |
| 124 | + if s.Description != "" { | |
| 125 | + b.WriteString(" — " + s.Description) | |
| 126 | + } | |
| 127 | + b.WriteString("\n") | |
| 128 | + } | |
| 129 | + return b.String() | |
| 130 | +} | |
| 131 | + | |
| 132 | +// Names lists the skill names, for an error message that helps the model | |
| 133 | +// recover from a typo. | |
| 134 | +func Names(list []Skill) []string { | |
| 135 | + out := make([]string, 0, len(list)) | |
| 136 | + for _, s := range list { | |
| 137 | + out = append(out, s.Name) | |
| 138 | + } | |
| 139 | + return out | |
| 140 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,140 @@ | |||
| 1 | +// Package skills discovers the markdown procedures in ./skills. | ||
| 2 | +// | ||
| 3 | +// A skill is a plain markdown file with a small front matter: | ||
| 4 | +// | ||
| 5 | +// --- | ||
| 6 | +// name: go-rename | ||
| 7 | +// description: rename a Go symbol everywhere it is used | ||
| 8 | +// --- | ||
| 9 | +// # Rename a Go symbol | ||
| 10 | +// ... | ||
| 11 | +// | ||
| 12 | +// The point of the package is to turn that directory into ONE STRING: the | ||
| 13 | +// description of the `read_skill` tool. The model never lists the directory | ||
| 14 | +// itself — it reads the catalogue in the tool description, the same way it | ||
| 15 | +// reads the description of `bash`. | ||
| 16 | +package skills | ||
| 17 | + | ||
| 18 | +import ( | ||
| 19 | + "os" | ||
| 20 | + "path/filepath" | ||
| 21 | + "sort" | ||
| 22 | + "strings" | ||
| 23 | +) | ||
| 24 | + | ||
| 25 | +// Skill is one markdown procedure on disk. | ||
| 26 | +type Skill struct { | ||
| 27 | + Name string // from the front matter, falling back to the file name | ||
| 28 | + Description string // the one-liner shown in the catalogue | ||
| 29 | + Path string | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +// skillFile is the file name of a skill stored in its own directory — the | ||
| 33 | +// Agent Skills convention (`<skillsDir>/<name>/SKILL.md`), the layout the | ||
| 34 | +// shipped examples use. | ||
| 35 | +const skillFile = "SKILL.md" | ||
| 36 | + | ||
| 37 | +// defaultName is the name a skill gets when its front matter has none: the | ||
| 38 | +// file name for a flat `<name>.md`, the directory name for `<name>/SKILL.md`. | ||
| 39 | +func defaultName(path string) string { | ||
| 40 | + if filepath.Base(path) == skillFile { | ||
| 41 | + return filepath.Base(filepath.Dir(path)) | ||
| 42 | + } | ||
| 43 | + return strings.TrimSuffix(filepath.Base(path), ".md") | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +// parseHeader reads the `name:` and `description:` lines of the front matter. | ||
| 47 | +// It stops at the closing `---`: a `description:` in the body is not metadata. | ||
| 48 | +func parseHeader(content, path string) Skill { | ||
| 49 | + s := Skill{Name: defaultName(path), Path: path} | ||
| 50 | + lines := strings.Split(content, "\n") | ||
| 51 | + if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { | ||
| 52 | + return s | ||
| 53 | + } | ||
| 54 | + for _, line := range lines[1:] { | ||
| 55 | + if strings.TrimSpace(line) == "---" { | ||
| 56 | + break | ||
| 57 | + } | ||
| 58 | + key, value, found := strings.Cut(line, ":") | ||
| 59 | + if !found { | ||
| 60 | + continue | ||
| 61 | + } | ||
| 62 | + value = strings.TrimSpace(value) | ||
| 63 | + switch strings.TrimSpace(key) { | ||
| 64 | + case "name": | ||
| 65 | + if value != "" { | ||
| 66 | + s.Name = value | ||
| 67 | + } | ||
| 68 | + case "description": | ||
| 69 | + s.Description = value | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | + return s | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +// List returns the skills of dir, sorted by name. Two layouts are accepted, | ||
| 76 | +// and may be mixed: a flat `<dir>/<name>.md`, and one directory per skill, | ||
| 77 | +// `<dir>/<name>/SKILL.md`. A missing directory is not an error: it just means | ||
| 78 | +// this agent has no skills. | ||
| 79 | +func List(dir string) []Skill { | ||
| 80 | + flat, err := filepath.Glob(filepath.Join(dir, "*.md")) | ||
| 81 | + if err != nil { | ||
| 82 | + return nil | ||
| 83 | + } | ||
| 84 | + nested, err := filepath.Glob(filepath.Join(dir, "*", skillFile)) | ||
| 85 | + if err != nil { | ||
| 86 | + return nil | ||
| 87 | + } | ||
| 88 | + var out []Skill | ||
| 89 | + for _, p := range append(flat, nested...) { | ||
| 90 | + content, err := os.ReadFile(p) | ||
| 91 | + if err != nil { | ||
| 92 | + continue | ||
| 93 | + } | ||
| 94 | + out = append(out, parseHeader(string(content), p)) | ||
| 95 | + } | ||
| 96 | + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | ||
| 97 | + return out | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +// Read returns the full markdown of one skill, whichever layout it uses: the | ||
| 101 | +// flat `<name>.md` is tried first, then `<name>/SKILL.md`. | ||
| 102 | +func Read(dir, name string) (string, error) { | ||
| 103 | + // Keep the name a plain file name: no "../" escaping out of the directory. | ||
| 104 | + name = filepath.Base(name) | ||
| 105 | + content, err := os.ReadFile(filepath.Join(dir, name+".md")) | ||
| 106 | + if err != nil { | ||
| 107 | + if nested, nestedErr := os.ReadFile(filepath.Join(dir, name, skillFile)); nestedErr == nil { | ||
| 108 | + return string(nested), nil | ||
| 109 | + } | ||
| 110 | + } | ||
| 111 | + return string(content), err | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +// Catalogue renders the list as the tool description the model will read. | ||
| 115 | +// This IS the prompt engineering: what the model knows about the available | ||
| 116 | +// skills is exactly these lines. | ||
| 117 | +func Catalogue(list []Skill) string { | ||
| 118 | + var b strings.Builder | ||
| 119 | + b.WriteString("Load a skill: the step-by-step procedure to follow for this kind of task. " + | ||
| 120 | + "Call it BEFORE doing the work, with the name of the matching skill, and then follow " + | ||
| 121 | + "what it says. Available skills:\n") | ||
| 122 | + for _, s := range list { | ||
| 123 | + b.WriteString(" " + s.Name) | ||
| 124 | + if s.Description != "" { | ||
| 125 | + b.WriteString(" — " + s.Description) | ||
| 126 | + } | ||
| 127 | + b.WriteString("\n") | ||
| 128 | + } | ||
| 129 | + return b.String() | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +// Names lists the skill names, for an error message that helps the model | ||
| 133 | +// recover from a typo. | ||
| 134 | +func Names(list []Skill) []string { | ||
| 135 | + out := make([]string, 0, len(list)) | ||
| 136 | + for _, s := range list { | ||
| 137 | + out = append(out, s.Name) | ||
| 138 | + } | ||
| 139 | + return out | ||
| 140 | +} | ||
added
internal/skills/skills_test.go +242 -0 | new file mode 100644 | ||
| @@ -0,0 +1,242 @@ | ||
| 1 | +// Tests for the skills package. What matters here: the catalogue is a TOOL | |
| 2 | +// DESCRIPTION, so what the model knows about the available skills is exactly | |
| 3 | +// what these functions produce. One header read wrong, and the model can no | |
| 4 | +// longer name the skill it wants. | |
| 5 | +package skills | |
| 6 | + | |
| 7 | +import ( | |
| 8 | + "os" | |
| 9 | + "path/filepath" | |
| 10 | + "strings" | |
| 11 | + "testing" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// write creates a skill file in a temporary directory. | |
| 15 | +func write(t *testing.T, dir, name, content string) { | |
| 16 | + t.Helper() | |
| 17 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { | |
| 18 | + t.Fatal(err) | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +func TestParseHeader(t *testing.T) { | |
| 23 | + cases := []struct { | |
| 24 | + name string | |
| 25 | + content string | |
| 26 | + wantName string | |
| 27 | + wantDesc string | |
| 28 | + }{{ | |
| 29 | + name: "complete header", | |
| 30 | + content: "---\nname: go-rename\ndescription: rename a symbol\n---\n# Title\n", | |
| 31 | + wantName: "go-rename", | |
| 32 | + wantDesc: "rename a symbol", | |
| 33 | + }, { | |
| 34 | + name: "no header: the name comes from the file", | |
| 35 | + content: "# Just some markdown\n", | |
| 36 | + wantName: "file", | |
| 37 | + wantDesc: "", | |
| 38 | + }, { | |
| 39 | + name: "empty name: the file name is kept", | |
| 40 | + content: "---\nname:\ndescription: something\n---\n", | |
| 41 | + wantName: "file", | |
| 42 | + wantDesc: "something", | |
| 43 | + }, { | |
| 44 | + // The case parseHeader's comment announces: a `description:` in the | |
| 45 | + // BODY is not metadata. | |
| 46 | + name: "description in the body: ignored", | |
| 47 | + content: "---\nname: go-test\n---\ndescription: this is just text\n", | |
| 48 | + wantName: "go-test", | |
| 49 | + wantDesc: "", | |
| 50 | + }, { | |
| 51 | + name: "spaces around the values", | |
| 52 | + content: "---\n name : go-fmt \n description : format code \n---\n", | |
| 53 | + wantName: "go-fmt", | |
| 54 | + wantDesc: "format code", | |
| 55 | + }, { | |
| 56 | + name: "a colon inside the description", | |
| 57 | + content: "---\nname: go-run\ndescription: run: execute a program\n---\n", | |
| 58 | + wantName: "go-run", | |
| 59 | + wantDesc: "run: execute a program", | |
| 60 | + }} | |
| 61 | + | |
| 62 | + for _, c := range cases { | |
| 63 | + t.Run(c.name, func(t *testing.T) { | |
| 64 | + s := parseHeader(c.content, "/tmp/file.md") | |
| 65 | + if s.Name != c.wantName { | |
| 66 | + t.Errorf("Name = %q, want %q", s.Name, c.wantName) | |
| 67 | + } | |
| 68 | + if s.Description != c.wantDesc { | |
| 69 | + t.Errorf("Description = %q, want %q", s.Description, c.wantDesc) | |
| 70 | + } | |
| 71 | + }) | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +func TestListSortsAndReadsHeaders(t *testing.T) { | |
| 76 | + dir := t.TempDir() | |
| 77 | + write(t, dir, "zeta.md", "---\nname: zeta\ndescription: the last one\n---\n") | |
| 78 | + write(t, dir, "alpha.md", "---\nname: alpha\ndescription: the first one\n---\n") | |
| 79 | + write(t, dir, "notes.txt", "not a skill") // ignored: not *.md | |
| 80 | + | |
| 81 | + list := List(dir) | |
| 82 | + if len(list) != 2 { | |
| 83 | + t.Fatalf("%d skills, want 2: %+v", len(list), list) | |
| 84 | + } | |
| 85 | + if list[0].Name != "alpha" || list[1].Name != "zeta" { | |
| 86 | + t.Errorf("expected a sort by name, got %q then %q", list[0].Name, list[1].Name) | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +// A missing directory is not an error: this agent simply has no skills, and | |
| 91 | +// main.go will not declare the tool. | |
| 92 | +func TestListMissingDirIsEmpty(t *testing.T) { | |
| 93 | + if list := List(filepath.Join(t.TempDir(), "doesnotexist")); len(list) != 0 { | |
| 94 | + t.Errorf("%d skills for a missing directory, want 0", len(list)) | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +func TestRead(t *testing.T) { | |
| 99 | + dir := t.TempDir() | |
| 100 | + write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n") | |
| 101 | + | |
| 102 | + content, err := Read(dir, "go-fmt") | |
| 103 | + if err != nil { | |
| 104 | + t.Fatalf("Read: %v", err) | |
| 105 | + } | |
| 106 | + if !strings.Contains(content, "# Format") { | |
| 107 | + t.Errorf("unexpected content: %q", content) | |
| 108 | + } | |
| 109 | + | |
| 110 | + if _, err := Read(dir, "unknown"); err == nil { | |
| 111 | + t.Error("an unknown skill must return an error") | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +// filepath.Base in Read is a barrier: the name comes from the MODEL, so it must | |
| 116 | +// not be able to escape the skills directory. | |
| 117 | +func TestReadCannotEscapeDir(t *testing.T) { | |
| 118 | + dir := t.TempDir() | |
| 119 | + parent := filepath.Dir(dir) | |
| 120 | + if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil { | |
| 121 | + t.Fatal(err) | |
| 122 | + } | |
| 123 | + | |
| 124 | + for _, name := range []string{"../secret", "../../secret", "/etc/passwd"} { | |
| 125 | + if content, err := Read(dir, name); err == nil { | |
| 126 | + t.Errorf("Read(%q) succeeded and returned %q — the barrier is gone", name, content) | |
| 127 | + } | |
| 128 | + } | |
| 129 | +} | |
| 130 | + | |
| 131 | +// The catalogue IS the tool description: every skill must appear in it with its | |
| 132 | +// name, otherwise the model cannot ask for it. | |
| 133 | +func TestCatalogue(t *testing.T) { | |
| 134 | + list := []Skill{ | |
| 135 | + {Name: "go-fmt", Description: "format code"}, | |
| 136 | + {Name: "go-test"}, // no description | |
| 137 | + } | |
| 138 | + cat := Catalogue(list) | |
| 139 | + | |
| 140 | + for _, want := range []string{"go-fmt", "format code", "go-test"} { | |
| 141 | + if !strings.Contains(cat, want) { | |
| 142 | + t.Errorf("the catalogue does not contain %q:\n%s", want, cat) | |
| 143 | + } | |
| 144 | + } | |
| 145 | + if !strings.Contains(cat, "go-fmt — format code") { | |
| 146 | + t.Errorf("name and description must be joined by a dash:\n%s", cat) | |
| 147 | + } | |
| 148 | + // A skill with no description must not leave an orphan dash. | |
| 149 | + if strings.Contains(cat, "go-test —") { | |
| 150 | + t.Errorf("orphan dash for a skill with no description:\n%s", cat) | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +func TestNames(t *testing.T) { | |
| 155 | + got := Names([]Skill{{Name: "a"}, {Name: "b"}}) | |
| 156 | + if strings.Join(got, ",") != "a,b" { | |
| 157 | + t.Errorf("Names = %v", got) | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 161 | +// The skills actually shipped in the repository's skills/ must all have a | |
| 162 | +// name AND a description: without a description, the model picks blindly. | |
| 163 | +// | |
| 164 | +// The path is the real layout — skills/ at the repository root, one directory | |
| 165 | +// per skill. A t.Skip() here would mean "I am testing nothing" without saying | |
| 166 | +// so, so a missing directory is an ERROR, not a skip. | |
| 167 | +func TestShippedSkillsHaveDescriptions(t *testing.T) { | |
| 168 | + dir := filepath.Join("..", "..", "skills") | |
| 169 | + list := List(dir) | |
| 170 | + if len(list) == 0 { | |
| 171 | + t.Fatalf("no skill found in %s — has the layout changed?", dir) | |
| 172 | + } | |
| 173 | + for _, s := range list { | |
| 174 | + if s.Description == "" { | |
| 175 | + t.Errorf("skill %q has no description", s.Name) | |
| 176 | + } | |
| 177 | + if strings.Contains(s.Name, " ") { | |
| 178 | + t.Errorf("name %q contains a space: the model will not be able to quote it", s.Name) | |
| 179 | + } | |
| 180 | + } | |
| 181 | + t.Logf("%d shipped skills, all described", len(list)) | |
| 182 | +} | |
| 183 | + | |
| 184 | +// writeNested stores a skill the Agent Skills way: <dir>/<name>/SKILL.md. | |
| 185 | +func writeNested(t *testing.T, dir, name, content string) { | |
| 186 | + t.Helper() | |
| 187 | + if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil { | |
| 188 | + t.Fatal(err) | |
| 189 | + } | |
| 190 | + if err := os.WriteFile(filepath.Join(dir, name, "SKILL.md"), []byte(content), 0o644); err != nil { | |
| 191 | + t.Fatal(err) | |
| 192 | + } | |
| 193 | +} | |
| 194 | + | |
| 195 | +// The two layouts are both found, may be mixed, and sort together. A nested | |
| 196 | +// skill without a name in its front matter is named after its directory, not | |
| 197 | +// "SKILL"; a stray markdown file inside a skill's directory is not a skill. | |
| 198 | +func TestListAcceptsFlatAndNestedLayouts(t *testing.T) { | |
| 199 | + dir := t.TempDir() | |
| 200 | + write(t, dir, "zeta.md", "---\nname: zeta\ndescription: flat, named\n---\n") | |
| 201 | + writeNested(t, dir, "alpha", "---\ndescription: nested, unnamed\n---\n# Alpha\n") | |
| 202 | + writeNested(t, dir, "mid", "---\nname: renamed\ndescription: nested, named\n---\n") | |
| 203 | + if err := os.WriteFile(filepath.Join(dir, "alpha", "notes.md"), []byte("not a skill"), 0o644); err != nil { | |
| 204 | + t.Fatal(err) | |
| 205 | + } | |
| 206 | + | |
| 207 | + list := List(dir) | |
| 208 | + got := make([]string, 0, len(list)) | |
| 209 | + for _, s := range list { | |
| 210 | + got = append(got, s.Name+"="+s.Description) | |
| 211 | + } | |
| 212 | + want := []string{"alpha=nested, unnamed", "renamed=nested, named", "zeta=flat, named"} | |
| 213 | + if strings.Join(got, "|") != strings.Join(want, "|") { | |
| 214 | + t.Errorf("List:\n got %v\nwant %v", got, want) | |
| 215 | + } | |
| 216 | +} | |
| 217 | + | |
| 218 | +// Read finds a skill whichever layout stores it, prefers the flat file when | |
| 219 | +// both exist (it is the more explicit of the two), and still cannot be walked | |
| 220 | +// out of the directory with a nested name: "../escape" is reduced to "escape", | |
| 221 | +// which does not exist inside dir. | |
| 222 | +func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) { | |
| 223 | + parent := t.TempDir() | |
| 224 | + dir := filepath.Join(parent, "skills") | |
| 225 | + writeNested(t, parent, "escape", "# outside") | |
| 226 | + writeNested(t, dir, "nested-only", "# nested") | |
| 227 | + write(t, dir, "both.md", "# flat wins") | |
| 228 | + writeNested(t, dir, "both", "# nested loses") | |
| 229 | + | |
| 230 | + if got, err := Read(dir, "nested-only"); err != nil || got != "# nested" { | |
| 231 | + t.Errorf("Read(nested-only) = %q, %v", got, err) | |
| 232 | + } | |
| 233 | + if got, err := Read(dir, "both"); err != nil || got != "# flat wins" { | |
| 234 | + t.Errorf("Read(both) = %q, %v; want the flat file", got, err) | |
| 235 | + } | |
| 236 | + if _, err := Read(dir, "missing"); err == nil { | |
| 237 | + t.Error("Read(missing) returned no error") | |
| 238 | + } | |
| 239 | + if _, err := Read(dir, "../escape"); err == nil { | |
| 240 | + t.Error("Read(../escape) escaped the directory") | |
| 241 | + } | |
| 242 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,242 @@ | |||
| 1 | +// Tests for the skills package. What matters here: the catalogue is a TOOL | ||
| 2 | +// DESCRIPTION, so what the model knows about the available skills is exactly | ||
| 3 | +// what these functions produce. One header read wrong, and the model can no | ||
| 4 | +// longer name the skill it wants. | ||
| 5 | +package skills | ||
| 6 | + | ||
| 7 | +import ( | ||
| 8 | + "os" | ||
| 9 | + "path/filepath" | ||
| 10 | + "strings" | ||
| 11 | + "testing" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// write creates a skill file in a temporary directory. | ||
| 15 | +func write(t *testing.T, dir, name, content string) { | ||
| 16 | + t.Helper() | ||
| 17 | + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { | ||
| 18 | + t.Fatal(err) | ||
| 19 | + } | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +func TestParseHeader(t *testing.T) { | ||
| 23 | + cases := []struct { | ||
| 24 | + name string | ||
| 25 | + content string | ||
| 26 | + wantName string | ||
| 27 | + wantDesc string | ||
| 28 | + }{{ | ||
| 29 | + name: "complete header", | ||
| 30 | + content: "---\nname: go-rename\ndescription: rename a symbol\n---\n# Title\n", | ||
| 31 | + wantName: "go-rename", | ||
| 32 | + wantDesc: "rename a symbol", | ||
| 33 | + }, { | ||
| 34 | + name: "no header: the name comes from the file", | ||
| 35 | + content: "# Just some markdown\n", | ||
| 36 | + wantName: "file", | ||
| 37 | + wantDesc: "", | ||
| 38 | + }, { | ||
| 39 | + name: "empty name: the file name is kept", | ||
| 40 | + content: "---\nname:\ndescription: something\n---\n", | ||
| 41 | + wantName: "file", | ||
| 42 | + wantDesc: "something", | ||
| 43 | + }, { | ||
| 44 | + // The case parseHeader's comment announces: a `description:` in the | ||
| 45 | + // BODY is not metadata. | ||
| 46 | + name: "description in the body: ignored", | ||
| 47 | + content: "---\nname: go-test\n---\ndescription: this is just text\n", | ||
| 48 | + wantName: "go-test", | ||
| 49 | + wantDesc: "", | ||
| 50 | + }, { | ||
| 51 | + name: "spaces around the values", | ||
| 52 | + content: "---\n name : go-fmt \n description : format code \n---\n", | ||
| 53 | + wantName: "go-fmt", | ||
| 54 | + wantDesc: "format code", | ||
| 55 | + }, { | ||
| 56 | + name: "a colon inside the description", | ||
| 57 | + content: "---\nname: go-run\ndescription: run: execute a program\n---\n", | ||
| 58 | + wantName: "go-run", | ||
| 59 | + wantDesc: "run: execute a program", | ||
| 60 | + }} | ||
| 61 | + | ||
| 62 | + for _, c := range cases { | ||
| 63 | + t.Run(c.name, func(t *testing.T) { | ||
| 64 | + s := parseHeader(c.content, "/tmp/file.md") | ||
| 65 | + if s.Name != c.wantName { | ||
| 66 | + t.Errorf("Name = %q, want %q", s.Name, c.wantName) | ||
| 67 | + } | ||
| 68 | + if s.Description != c.wantDesc { | ||
| 69 | + t.Errorf("Description = %q, want %q", s.Description, c.wantDesc) | ||
| 70 | + } | ||
| 71 | + }) | ||
| 72 | + } | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +func TestListSortsAndReadsHeaders(t *testing.T) { | ||
| 76 | + dir := t.TempDir() | ||
| 77 | + write(t, dir, "zeta.md", "---\nname: zeta\ndescription: the last one\n---\n") | ||
| 78 | + write(t, dir, "alpha.md", "---\nname: alpha\ndescription: the first one\n---\n") | ||
| 79 | + write(t, dir, "notes.txt", "not a skill") // ignored: not *.md | ||
| 80 | + | ||
| 81 | + list := List(dir) | ||
| 82 | + if len(list) != 2 { | ||
| 83 | + t.Fatalf("%d skills, want 2: %+v", len(list), list) | ||
| 84 | + } | ||
| 85 | + if list[0].Name != "alpha" || list[1].Name != "zeta" { | ||
| 86 | + t.Errorf("expected a sort by name, got %q then %q", list[0].Name, list[1].Name) | ||
| 87 | + } | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// A missing directory is not an error: this agent simply has no skills, and | ||
| 91 | +// main.go will not declare the tool. | ||
| 92 | +func TestListMissingDirIsEmpty(t *testing.T) { | ||
| 93 | + if list := List(filepath.Join(t.TempDir(), "doesnotexist")); len(list) != 0 { | ||
| 94 | + t.Errorf("%d skills for a missing directory, want 0", len(list)) | ||
| 95 | + } | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +func TestRead(t *testing.T) { | ||
| 99 | + dir := t.TempDir() | ||
| 100 | + write(t, dir, "go-fmt.md", "---\nname: go-fmt\n---\n# Format\n") | ||
| 101 | + | ||
| 102 | + content, err := Read(dir, "go-fmt") | ||
| 103 | + if err != nil { | ||
| 104 | + t.Fatalf("Read: %v", err) | ||
| 105 | + } | ||
| 106 | + if !strings.Contains(content, "# Format") { | ||
| 107 | + t.Errorf("unexpected content: %q", content) | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + if _, err := Read(dir, "unknown"); err == nil { | ||
| 111 | + t.Error("an unknown skill must return an error") | ||
| 112 | + } | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +// filepath.Base in Read is a barrier: the name comes from the MODEL, so it must | ||
| 116 | +// not be able to escape the skills directory. | ||
| 117 | +func TestReadCannotEscapeDir(t *testing.T) { | ||
| 118 | + dir := t.TempDir() | ||
| 119 | + parent := filepath.Dir(dir) | ||
| 120 | + if err := os.WriteFile(filepath.Join(parent, "secret.md"), []byte("forbidden"), 0o644); err != nil { | ||
| 121 | + t.Fatal(err) | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + for _, name := range []string{"../secret", "../../secret", "/etc/passwd"} { | ||
| 125 | + if content, err := Read(dir, name); err == nil { | ||
| 126 | + t.Errorf("Read(%q) succeeded and returned %q — the barrier is gone", name, content) | ||
| 127 | + } | ||
| 128 | + } | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +// The catalogue IS the tool description: every skill must appear in it with its | ||
| 132 | +// name, otherwise the model cannot ask for it. | ||
| 133 | +func TestCatalogue(t *testing.T) { | ||
| 134 | + list := []Skill{ | ||
| 135 | + {Name: "go-fmt", Description: "format code"}, | ||
| 136 | + {Name: "go-test"}, // no description | ||
| 137 | + } | ||
| 138 | + cat := Catalogue(list) | ||
| 139 | + | ||
| 140 | + for _, want := range []string{"go-fmt", "format code", "go-test"} { | ||
| 141 | + if !strings.Contains(cat, want) { | ||
| 142 | + t.Errorf("the catalogue does not contain %q:\n%s", want, cat) | ||
| 143 | + } | ||
| 144 | + } | ||
| 145 | + if !strings.Contains(cat, "go-fmt — format code") { | ||
| 146 | + t.Errorf("name and description must be joined by a dash:\n%s", cat) | ||
| 147 | + } | ||
| 148 | + // A skill with no description must not leave an orphan dash. | ||
| 149 | + if strings.Contains(cat, "go-test —") { | ||
| 150 | + t.Errorf("orphan dash for a skill with no description:\n%s", cat) | ||
| 151 | + } | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +func TestNames(t *testing.T) { | ||
| 155 | + got := Names([]Skill{{Name: "a"}, {Name: "b"}}) | ||
| 156 | + if strings.Join(got, ",") != "a,b" { | ||
| 157 | + t.Errorf("Names = %v", got) | ||
| 158 | + } | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +// The skills actually shipped in the repository's skills/ must all have a | ||
| 162 | +// name AND a description: without a description, the model picks blindly. | ||
| 163 | +// | ||
| 164 | +// The path is the real layout — skills/ at the repository root, one directory | ||
| 165 | +// per skill. A t.Skip() here would mean "I am testing nothing" without saying | ||
| 166 | +// so, so a missing directory is an ERROR, not a skip. | ||
| 167 | +func TestShippedSkillsHaveDescriptions(t *testing.T) { | ||
| 168 | + dir := filepath.Join("..", "..", "skills") | ||
| 169 | + list := List(dir) | ||
| 170 | + if len(list) == 0 { | ||
| 171 | + t.Fatalf("no skill found in %s — has the layout changed?", dir) | ||
| 172 | + } | ||
| 173 | + for _, s := range list { | ||
| 174 | + if s.Description == "" { | ||
| 175 | + t.Errorf("skill %q has no description", s.Name) | ||
| 176 | + } | ||
| 177 | + if strings.Contains(s.Name, " ") { | ||
| 178 | + t.Errorf("name %q contains a space: the model will not be able to quote it", s.Name) | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | + t.Logf("%d shipped skills, all described", len(list)) | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +// writeNested stores a skill the Agent Skills way: <dir>/<name>/SKILL.md. | ||
| 185 | +func writeNested(t *testing.T, dir, name, content string) { | ||
| 186 | + t.Helper() | ||
| 187 | + if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil { | ||
| 188 | + t.Fatal(err) | ||
| 189 | + } | ||
| 190 | + if err := os.WriteFile(filepath.Join(dir, name, "SKILL.md"), []byte(content), 0o644); err != nil { | ||
| 191 | + t.Fatal(err) | ||
| 192 | + } | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +// The two layouts are both found, may be mixed, and sort together. A nested | ||
| 196 | +// skill without a name in its front matter is named after its directory, not | ||
| 197 | +// "SKILL"; a stray markdown file inside a skill's directory is not a skill. | ||
| 198 | +func TestListAcceptsFlatAndNestedLayouts(t *testing.T) { | ||
| 199 | + dir := t.TempDir() | ||
| 200 | + write(t, dir, "zeta.md", "---\nname: zeta\ndescription: flat, named\n---\n") | ||
| 201 | + writeNested(t, dir, "alpha", "---\ndescription: nested, unnamed\n---\n# Alpha\n") | ||
| 202 | + writeNested(t, dir, "mid", "---\nname: renamed\ndescription: nested, named\n---\n") | ||
| 203 | + if err := os.WriteFile(filepath.Join(dir, "alpha", "notes.md"), []byte("not a skill"), 0o644); err != nil { | ||
| 204 | + t.Fatal(err) | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + list := List(dir) | ||
| 208 | + got := make([]string, 0, len(list)) | ||
| 209 | + for _, s := range list { | ||
| 210 | + got = append(got, s.Name+"="+s.Description) | ||
| 211 | + } | ||
| 212 | + want := []string{"alpha=nested, unnamed", "renamed=nested, named", "zeta=flat, named"} | ||
| 213 | + if strings.Join(got, "|") != strings.Join(want, "|") { | ||
| 214 | + t.Errorf("List:\n got %v\nwant %v", got, want) | ||
| 215 | + } | ||
| 216 | +} | ||
| 217 | + | ||
| 218 | +// Read finds a skill whichever layout stores it, prefers the flat file when | ||
| 219 | +// both exist (it is the more explicit of the two), and still cannot be walked | ||
| 220 | +// out of the directory with a nested name: "../escape" is reduced to "escape", | ||
| 221 | +// which does not exist inside dir. | ||
| 222 | +func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) { | ||
| 223 | + parent := t.TempDir() | ||
| 224 | + dir := filepath.Join(parent, "skills") | ||
| 225 | + writeNested(t, parent, "escape", "# outside") | ||
| 226 | + writeNested(t, dir, "nested-only", "# nested") | ||
| 227 | + write(t, dir, "both.md", "# flat wins") | ||
| 228 | + writeNested(t, dir, "both", "# nested loses") | ||
| 229 | + | ||
| 230 | + if got, err := Read(dir, "nested-only"); err != nil || got != "# nested" { | ||
| 231 | + t.Errorf("Read(nested-only) = %q, %v", got, err) | ||
| 232 | + } | ||
| 233 | + if got, err := Read(dir, "both"); err != nil || got != "# flat wins" { | ||
| 234 | + t.Errorf("Read(both) = %q, %v; want the flat file", got, err) | ||
| 235 | + } | ||
| 236 | + if _, err := Read(dir, "missing"); err == nil { | ||
| 237 | + t.Error("Read(missing) returned no error") | ||
| 238 | + } | ||
| 239 | + if _, err := Read(dir, "../escape"); err == nil { | ||
| 240 | + t.Error("Read(../escape) escaped the directory") | ||
| 241 | + } | ||
| 242 | +} | ||
added
internal/spinner/spinner.go +165 -0 | new file mode 100644 | ||
| @@ -0,0 +1,165 @@ | ||
| 1 | +// Package spinner shows a "still working" indicator while nothing is being | |
| 2 | +// printed — the gap between sending a question and the first streamed token, | |
| 3 | +// which on a local engine is long enough (model loading, prompt processing) to | |
| 4 | +// look like a freeze. | |
| 5 | +// | |
| 6 | +// There is one spinner for the whole program rather than a value to pass | |
| 7 | +// around: the REPL runs one generation at a time, and the alternative would be | |
| 8 | +// threading a handle through Genkit's tool callbacks, which we do not control. | |
| 9 | +// | |
| 10 | +// The contract is: Start makes the line ours, Stop gives it back. Anything that | |
| 11 | +// prints must Stop first — hence the calls in dmr and tools. | |
| 12 | +package spinner | |
| 13 | + | |
| 14 | +import ( | |
| 15 | + "fmt" | |
| 16 | + "os" | |
| 17 | + "strings" | |
| 18 | + "sync" | |
| 19 | + "time" | |
| 20 | +) | |
| 21 | + | |
| 22 | +// frames is the braille cycle. Each frame is one rune wide, so the line never | |
| 23 | +// reflows as it animates. | |
| 24 | +var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} | |
| 25 | + | |
| 26 | +const interval = 80 * time.Millisecond | |
| 27 | + | |
| 28 | +var ( | |
| 29 | + mu sync.Mutex | |
| 30 | + stopCh chan struct{} // closed to ask the goroutine to stop | |
| 31 | + doneCh chan struct{} // closed by the goroutine once the line is clean | |
| 32 | + label string // read by the goroutine under mu, so Start can retitle | |
| 33 | + enabled = supported() //nolint:gochecknoglobals // resolved once at startup | |
| 34 | +) | |
| 35 | + | |
| 36 | +// supported reports whether animating is a good idea. Piped or redirected | |
| 37 | +// output is not a terminal, and writing escape sequences into it would corrupt | |
| 38 | +// whatever is reading — a real risk here, since the point of the exercise is an | |
| 39 | +// agent whose output another program may consume. | |
| 40 | +func supported() bool { | |
| 41 | + if os.Getenv("TERM") == "dumb" || os.Getenv("NO_COLOR") != "" { | |
| 42 | + return false | |
| 43 | + } | |
| 44 | + info, err := os.Stdout.Stat() | |
| 45 | + return err == nil && info.Mode()&os.ModeCharDevice != 0 | |
| 46 | +} | |
| 47 | + | |
| 48 | +// Disable turns the spinner — and Styled — off for the rest of the process. | |
| 49 | +// The ACP front end calls it once at start-up: stdout then belongs to | |
| 50 | +// JSON-RPC, and even on a real terminal (bob --acp run by hand, for a demo) | |
| 51 | +// not one braille frame may land in the protocol stream. supported() cannot | |
| 52 | +// catch that case — a terminal IS a character device. | |
| 53 | +func Disable() { | |
| 54 | + mu.Lock() | |
| 55 | + enabled = false | |
| 56 | + mu.Unlock() | |
| 57 | +} | |
| 58 | + | |
| 59 | +// Styled reports whether stdout may carry escape sequences — same test that | |
| 60 | +// decides whether to animate. Anything that colours its output should ask, | |
| 61 | +// rather than repeat the predicate: piped output is meant to be consumed by | |
| 62 | +// another program, and `bob | jq` should not receive dim codes any more than it | |
| 63 | +// should receive braille frames. | |
| 64 | +func Styled() bool { | |
| 65 | + return enabled | |
| 66 | +} | |
| 67 | + | |
| 68 | +// Start begins animating with the given label, or just retitles a spinner that | |
| 69 | +// is already running. Safe to call when the previous one was never stopped. | |
| 70 | +func Start(text string) { | |
| 71 | + if !enabled { | |
| 72 | + return | |
| 73 | + } | |
| 74 | + | |
| 75 | + mu.Lock() | |
| 76 | + if stopCh != nil { // already spinning: only the wording changes | |
| 77 | + label = text | |
| 78 | + mu.Unlock() | |
| 79 | + return | |
| 80 | + } | |
| 81 | + | |
| 82 | + label = text | |
| 83 | + stop, done := make(chan struct{}), make(chan struct{}) | |
| 84 | + stopCh, doneCh = stop, done | |
| 85 | + mu.Unlock() | |
| 86 | + | |
| 87 | + go spin(stop, done) | |
| 88 | +} | |
| 89 | + | |
| 90 | +// Stop erases the line and waits for the goroutine to be gone, so that whatever | |
| 91 | +// the caller prints next cannot interleave with a frame. Idempotent. | |
| 92 | +func Stop() { | |
| 93 | + mu.Lock() | |
| 94 | + stop, done := stopCh, doneCh | |
| 95 | + stopCh, doneCh = nil, nil | |
| 96 | + mu.Unlock() | |
| 97 | + | |
| 98 | + if stop == nil { | |
| 99 | + return | |
| 100 | + } | |
| 101 | + close(stop) | |
| 102 | + <-done | |
| 103 | +} | |
| 104 | + | |
| 105 | +// spin owns the line until it is told to stop. It restores the cursor on the | |
| 106 | +// way out whatever happens, because a hidden cursor left behind outlives the | |
| 107 | +// program and makes the user's shell look broken. | |
| 108 | +func spin(stop <-chan struct{}, done chan<- struct{}) { | |
| 109 | + started := time.Now() | |
| 110 | + ticker := time.NewTicker(interval) | |
| 111 | + | |
| 112 | + defer func() { | |
| 113 | + ticker.Stop() | |
| 114 | + fmt.Print("\r\033[2K\033[?25h") // erase line, show cursor | |
| 115 | + close(done) | |
| 116 | + }() | |
| 117 | + | |
| 118 | + fmt.Print("\033[?25l") // hide cursor | |
| 119 | + for i := 0; ; i++ { | |
| 120 | + select { | |
| 121 | + case <-stop: | |
| 122 | + return | |
| 123 | + default: | |
| 124 | + } | |
| 125 | + | |
| 126 | + mu.Lock() | |
| 127 | + text := label | |
| 128 | + mu.Unlock() | |
| 129 | + | |
| 130 | + // \r + erase-to-end rather than padding with spaces: the label can | |
| 131 | + // shrink between two frames, and leftovers would stay on screen. | |
| 132 | + fmt.Printf("\r\033[2K\033[2m%s %s… %s\033[0m", | |
| 133 | + frames[i%len(frames)], text, elapsed(time.Since(started))) | |
| 134 | + | |
| 135 | + select { | |
| 136 | + case <-stop: | |
| 137 | + return | |
| 138 | + case <-ticker.C: | |
| 139 | + } | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +// elapsed formats the wait the way a person reads it: seconds until a minute, | |
| 144 | +// then minutes and seconds. Showing it is the point — it is what distinguishes | |
| 145 | +// "slow" from "hung". | |
| 146 | +func elapsed(d time.Duration) string { | |
| 147 | + if d < time.Minute { | |
| 148 | + return fmt.Sprintf("%ds", int(d.Seconds())) | |
| 149 | + } | |
| 150 | + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) | |
| 151 | +} | |
| 152 | + | |
| 153 | +// Suspend stops the spinner, runs fn, and starts it again under the same label | |
| 154 | +// — for code that has to print in the middle of a wait. | |
| 155 | +func Suspend(fn func()) { | |
| 156 | + mu.Lock() | |
| 157 | + text, running := label, stopCh != nil | |
| 158 | + mu.Unlock() | |
| 159 | + | |
| 160 | + Stop() | |
| 161 | + fn() | |
| 162 | + if running && strings.TrimSpace(text) != "" { | |
| 163 | + Start(text) | |
| 164 | + } | |
| 165 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,165 @@ | |||
| 1 | +// Package spinner shows a "still working" indicator while nothing is being | ||
| 2 | +// printed — the gap between sending a question and the first streamed token, | ||
| 3 | +// which on a local engine is long enough (model loading, prompt processing) to | ||
| 4 | +// look like a freeze. | ||
| 5 | +// | ||
| 6 | +// There is one spinner for the whole program rather than a value to pass | ||
| 7 | +// around: the REPL runs one generation at a time, and the alternative would be | ||
| 8 | +// threading a handle through Genkit's tool callbacks, which we do not control. | ||
| 9 | +// | ||
| 10 | +// The contract is: Start makes the line ours, Stop gives it back. Anything that | ||
| 11 | +// prints must Stop first — hence the calls in dmr and tools. | ||
| 12 | +package spinner | ||
| 13 | + | ||
| 14 | +import ( | ||
| 15 | + "fmt" | ||
| 16 | + "os" | ||
| 17 | + "strings" | ||
| 18 | + "sync" | ||
| 19 | + "time" | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | +// frames is the braille cycle. Each frame is one rune wide, so the line never | ||
| 23 | +// reflows as it animates. | ||
| 24 | +var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} | ||
| 25 | + | ||
| 26 | +const interval = 80 * time.Millisecond | ||
| 27 | + | ||
| 28 | +var ( | ||
| 29 | + mu sync.Mutex | ||
| 30 | + stopCh chan struct{} // closed to ask the goroutine to stop | ||
| 31 | + doneCh chan struct{} // closed by the goroutine once the line is clean | ||
| 32 | + label string // read by the goroutine under mu, so Start can retitle | ||
| 33 | + enabled = supported() //nolint:gochecknoglobals // resolved once at startup | ||
| 34 | +) | ||
| 35 | + | ||
| 36 | +// supported reports whether animating is a good idea. Piped or redirected | ||
| 37 | +// output is not a terminal, and writing escape sequences into it would corrupt | ||
| 38 | +// whatever is reading — a real risk here, since the point of the exercise is an | ||
| 39 | +// agent whose output another program may consume. | ||
| 40 | +func supported() bool { | ||
| 41 | + if os.Getenv("TERM") == "dumb" || os.Getenv("NO_COLOR") != "" { | ||
| 42 | + return false | ||
| 43 | + } | ||
| 44 | + info, err := os.Stdout.Stat() | ||
| 45 | + return err == nil && info.Mode()&os.ModeCharDevice != 0 | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +// Disable turns the spinner — and Styled — off for the rest of the process. | ||
| 49 | +// The ACP front end calls it once at start-up: stdout then belongs to | ||
| 50 | +// JSON-RPC, and even on a real terminal (bob --acp run by hand, for a demo) | ||
| 51 | +// not one braille frame may land in the protocol stream. supported() cannot | ||
| 52 | +// catch that case — a terminal IS a character device. | ||
| 53 | +func Disable() { | ||
| 54 | + mu.Lock() | ||
| 55 | + enabled = false | ||
| 56 | + mu.Unlock() | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +// Styled reports whether stdout may carry escape sequences — same test that | ||
| 60 | +// decides whether to animate. Anything that colours its output should ask, | ||
| 61 | +// rather than repeat the predicate: piped output is meant to be consumed by | ||
| 62 | +// another program, and `bob | jq` should not receive dim codes any more than it | ||
| 63 | +// should receive braille frames. | ||
| 64 | +func Styled() bool { | ||
| 65 | + return enabled | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +// Start begins animating with the given label, or just retitles a spinner that | ||
| 69 | +// is already running. Safe to call when the previous one was never stopped. | ||
| 70 | +func Start(text string) { | ||
| 71 | + if !enabled { | ||
| 72 | + return | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + mu.Lock() | ||
| 76 | + if stopCh != nil { // already spinning: only the wording changes | ||
| 77 | + label = text | ||
| 78 | + mu.Unlock() | ||
| 79 | + return | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + label = text | ||
| 83 | + stop, done := make(chan struct{}), make(chan struct{}) | ||
| 84 | + stopCh, doneCh = stop, done | ||
| 85 | + mu.Unlock() | ||
| 86 | + | ||
| 87 | + go spin(stop, done) | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// Stop erases the line and waits for the goroutine to be gone, so that whatever | ||
| 91 | +// the caller prints next cannot interleave with a frame. Idempotent. | ||
| 92 | +func Stop() { | ||
| 93 | + mu.Lock() | ||
| 94 | + stop, done := stopCh, doneCh | ||
| 95 | + stopCh, doneCh = nil, nil | ||
| 96 | + mu.Unlock() | ||
| 97 | + | ||
| 98 | + if stop == nil { | ||
| 99 | + return | ||
| 100 | + } | ||
| 101 | + close(stop) | ||
| 102 | + <-done | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +// spin owns the line until it is told to stop. It restores the cursor on the | ||
| 106 | +// way out whatever happens, because a hidden cursor left behind outlives the | ||
| 107 | +// program and makes the user's shell look broken. | ||
| 108 | +func spin(stop <-chan struct{}, done chan<- struct{}) { | ||
| 109 | + started := time.Now() | ||
| 110 | + ticker := time.NewTicker(interval) | ||
| 111 | + | ||
| 112 | + defer func() { | ||
| 113 | + ticker.Stop() | ||
| 114 | + fmt.Print("\r\033[2K\033[?25h") // erase line, show cursor | ||
| 115 | + close(done) | ||
| 116 | + }() | ||
| 117 | + | ||
| 118 | + fmt.Print("\033[?25l") // hide cursor | ||
| 119 | + for i := 0; ; i++ { | ||
| 120 | + select { | ||
| 121 | + case <-stop: | ||
| 122 | + return | ||
| 123 | + default: | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + mu.Lock() | ||
| 127 | + text := label | ||
| 128 | + mu.Unlock() | ||
| 129 | + | ||
| 130 | + // \r + erase-to-end rather than padding with spaces: the label can | ||
| 131 | + // shrink between two frames, and leftovers would stay on screen. | ||
| 132 | + fmt.Printf("\r\033[2K\033[2m%s %s… %s\033[0m", | ||
| 133 | + frames[i%len(frames)], text, elapsed(time.Since(started))) | ||
| 134 | + | ||
| 135 | + select { | ||
| 136 | + case <-stop: | ||
| 137 | + return | ||
| 138 | + case <-ticker.C: | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +// elapsed formats the wait the way a person reads it: seconds until a minute, | ||
| 144 | +// then minutes and seconds. Showing it is the point — it is what distinguishes | ||
| 145 | +// "slow" from "hung". | ||
| 146 | +func elapsed(d time.Duration) string { | ||
| 147 | + if d < time.Minute { | ||
| 148 | + return fmt.Sprintf("%ds", int(d.Seconds())) | ||
| 149 | + } | ||
| 150 | + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +// Suspend stops the spinner, runs fn, and starts it again under the same label | ||
| 154 | +// — for code that has to print in the middle of a wait. | ||
| 155 | +func Suspend(fn func()) { | ||
| 156 | + mu.Lock() | ||
| 157 | + text, running := label, stopCh != nil | ||
| 158 | + mu.Unlock() | ||
| 159 | + | ||
| 160 | + Stop() | ||
| 161 | + fn() | ||
| 162 | + if running && strings.TrimSpace(text) != "" { | ||
| 163 | + Start(text) | ||
| 164 | + } | ||
| 165 | +} | ||
added
internal/tools/files.go +324 -0 | new file mode 100644 | ||
| @@ -0,0 +1,324 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "mm/internal/detector" | |
| 11 | + "mm/internal/fileedit" | |
| 12 | + "mm/internal/spinner" | |
| 13 | + "mm/internal/ui" | |
| 14 | + | |
| 15 | + "github.com/firebase/genkit/go/ai" | |
| 16 | + "github.com/firebase/genkit/go/genkit" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// --- read_file / write_file / edit_file --------------------------------------- | |
| 20 | +// | |
| 21 | +// Three built-in tools with the rules of the `edit` CLI (tools/edit). Part 05 | |
| 22 | +// gave the model that CLI through bash; this part gives it the same operations | |
| 23 | +// as tools, so the two can be COMPARED: does a 12B model edit more reliably | |
| 24 | +// when the operation is a tool it can see, or a command it has to spell? | |
| 25 | +// | |
| 26 | +// Same contract as `bash`: a failure is returned AS TEXT, never as a Go error, | |
| 27 | +// so the model reads why and retries; every call prints one scannable line | |
| 28 | +// before running and echoes its result to the user before the model sees it. | |
| 29 | + | |
| 30 | +// maxDisplayWidth is where a display line is cut — the same hundred columns as | |
| 31 | +// the command recap: it fits a demo terminal without wrapping. | |
| 32 | +const maxDisplayWidth = 100 | |
| 33 | + | |
| 34 | +// clip folds a display line onto ONE line and truncates it. A path can be | |
| 35 | +// long, an old text longer: the line is there to be scanned, not read. | |
| 36 | +func clip(s string) string { | |
| 37 | + s = strings.Join(strings.Fields(s), " ") | |
| 38 | + if r := []rune(s); len(r) > maxDisplayWidth { | |
| 39 | + return string(r[:maxDisplayWidth-1]) + "…" | |
| 40 | + } | |
| 41 | + return s | |
| 42 | +} | |
| 43 | + | |
| 44 | +// announce prints the display line of a file tool, the way `bash` prints its | |
| 45 | +// 🛠️ line: under the spinner's Suspend so the label comes back for the wait | |
| 46 | +// that follows, and under printMu so two tools of one turn do not interleave. | |
| 47 | +func announce(line string) { | |
| 48 | + spinner.Suspend(func() { | |
| 49 | + printMu.Lock() | |
| 50 | + fmt.Printf("\n%s\n", clip(line)) | |
| 51 | + printMu.Unlock() | |
| 52 | + }) | |
| 53 | +} | |
| 54 | + | |
| 55 | +// record feeds the loop detector like `bash` does, and appends its instruction | |
| 56 | +// to the output when a loop is detected: the same edit refused three times in | |
| 57 | +// a row is the model going in circles, exactly like a failing command. | |
| 58 | +func record(d *detector.LoopDetector, tool, input, out string) string { | |
| 59 | + if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) { | |
| 60 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()) | |
| 61 | + } | |
| 62 | + return out | |
| 63 | +} | |
| 64 | + | |
| 65 | +type readFileInput struct { | |
| 66 | + Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"` | |
| 67 | + Start int `json:"start,omitempty" jsonschema_description:"First line to return (1-based); 0 or absent = from the start"` | |
| 68 | + End int `json:"end,omitempty" jsonschema_description:"Last line to return (1-based, inclusive); 0 or absent = to the end"` | |
| 69 | + Numbered bool `json:"numbered,omitempty" jsonschema_description:"Prefix each line with its number — useful to pick a range for a second read"` | |
| 70 | +} | |
| 71 | + | |
| 72 | +// ReadFile declares the `read_file` tool. | |
| 73 | +func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | |
| 74 | + description := `Read a text file, or a range of its lines. | |
| 75 | +Read before you edit: edit_file needs the EXACT text of the file, and you cannot match text you have not seen. Do not type it from memory. | |
| 76 | +Use start/end for a slice of a long file, and numbered=true to get line numbers. Long content is truncated, and the text says so.` | |
| 77 | + | |
| 78 | + return genkit.DefineTool(g, "read_file", description, | |
| 79 | + func(_ *ai.ToolContext, in readFileInput) (string, error) { | |
| 80 | + where := in.Path | |
| 81 | + if in.Start > 0 || in.End > 0 { | |
| 82 | + where += fmt.Sprintf(" %d-%d", in.Start, in.End) | |
| 83 | + } | |
| 84 | + if s := ui.ActiveSink(); s != nil { | |
| 85 | + return readFileEvented(s, d, in, where) | |
| 86 | + } | |
| 87 | + announce("📄 read_file: " + where) | |
| 88 | + | |
| 89 | + content, err := fileedit.Read(in.Path, in.Start, in.End, in.Numbered) | |
| 90 | + if err != nil { | |
| 91 | + // Information, not a crash: the message says how to fix the call. | |
| 92 | + out := err.Error() | |
| 93 | + spinner.Suspend(func() { echoOutput(out) }) | |
| 94 | + return record(d, "read_file", where, out), nil | |
| 95 | + } | |
| 96 | + out := truncate(content) | |
| 97 | + // Shown to the user like a command's output: "show me that file" | |
| 98 | + // means show it, whatever tool read it. | |
| 99 | + spinner.Suspend(func() { echoOutput(out) }) | |
| 100 | + return record(d, "read_file", where, out), nil | |
| 101 | + }) | |
| 102 | +} | |
| 103 | + | |
| 104 | +type writeFileInput struct { | |
| 105 | + Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory; parent directories are created"` | |
| 106 | + Content string `json:"content" jsonschema_description:"The WHOLE content of the file"` | |
| 107 | +} | |
| 108 | + | |
| 109 | +// WriteFile declares the `write_file` tool. | |
| 110 | +func WriteFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | |
| 111 | + description := `Write the WHOLE content of a file: create it, or rewrite it entirely and on purpose. | |
| 112 | +On an existing file it overwrites everything, including what you did not intend to touch: to change part of a file, use edit_file instead. | |
| 113 | +Returns a one-line summary and the diff against the previous content. Writing identical content changes nothing and says so.` | |
| 114 | + | |
| 115 | + return genkit.DefineTool(g, "write_file", description, | |
| 116 | + func(tctx *ai.ToolContext, in writeFileInput) (string, error) { | |
| 117 | + if s := ui.ActiveSink(); s != nil { | |
| 118 | + return writeFileEvented(tctx, s, d, in) | |
| 119 | + } | |
| 120 | + announce(fmt.Sprintf("💾 write_file: %s (%d bytes)", in.Path, len(in.Content))) | |
| 121 | + | |
| 122 | + r, err := fileedit.Write(in.Path, in.Content) | |
| 123 | + if err != nil { | |
| 124 | + out := err.Error() | |
| 125 | + spinner.Suspend(func() { echoOutput(out) }) | |
| 126 | + return record(d, "write_file", in.Path, out), nil | |
| 127 | + } | |
| 128 | + return report(d, "write_file", in.Path, r), nil | |
| 129 | + }) | |
| 130 | +} | |
| 131 | + | |
| 132 | +type editFileInput struct { | |
| 133 | + Path string `json:"path" jsonschema_description:"Path of an EXISTING file, relative to the working directory"` | |
| 134 | + Edits []fileedit.Edit `json:"edits" jsonschema_description:"The replacements, all resolved against the ORIGINAL file; two edits must not overlap"` | |
| 135 | + DryRun bool `json:"dry_run,omitempty" jsonschema_description:"Show what would change and write nothing"` | |
| 136 | +} | |
| 137 | + | |
| 138 | +// EditFile declares the `edit_file` tool. | |
| 139 | +func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | |
| 140 | + description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it. | |
| 141 | +Each edit has an old text and a new text. The rules, which are what makes an edit verifiable: | |
| 142 | +1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file. | |
| 143 | +2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique. | |
| 144 | +3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote. | |
| 145 | +4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit. | |
| 146 | +5. An empty new deletes the matched text. | |
| 147 | +6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines. | |
| 148 | +Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.` | |
| 149 | + | |
| 150 | + return genkit.DefineTool(g, "edit_file", description, | |
| 151 | + func(tctx *ai.ToolContext, in editFileInput) (string, error) { | |
| 152 | + what := fmt.Sprintf("%s (%d edit(s))", in.Path, len(in.Edits)) | |
| 153 | + if in.DryRun { | |
| 154 | + what += " dry-run" | |
| 155 | + } | |
| 156 | + if s := ui.ActiveSink(); s != nil { | |
| 157 | + return editFileEvented(tctx, s, d, in, what) | |
| 158 | + } | |
| 159 | + announce("📝 edit_file: " + what) | |
| 160 | + | |
| 161 | + r, err := fileedit.Replace(in.Path, in.Edits, in.DryRun) | |
| 162 | + if err != nil { | |
| 163 | + out := err.Error() | |
| 164 | + spinner.Suspend(func() { echoOutput(out) }) | |
| 165 | + return record(d, "edit_file", editKey(in), out), nil | |
| 166 | + } | |
| 167 | + return report(d, "edit_file", editKey(in), r), nil | |
| 168 | + }) | |
| 169 | +} | |
| 170 | + | |
| 171 | +// editKey is what the loop detector compares for edit_file: the path AND the | |
| 172 | +// edits. Two calls with the same path but different old texts are the model | |
| 173 | +// correcting itself, not looping. | |
| 174 | +func editKey(in editFileInput) string { | |
| 175 | + var b strings.Builder | |
| 176 | + b.WriteString(in.Path) | |
| 177 | + for _, e := range in.Edits { | |
| 178 | + b.WriteString("\x00" + e.Old + "\x00" + e.New) | |
| 179 | + } | |
| 180 | + return b.String() | |
| 181 | +} | |
| 182 | + | |
| 183 | +// --- the same three tools, for an event-consuming front end (ACP) ------------- | |
| 184 | +// | |
| 185 | +// The information is the same as the terminal path prints; only the channel | |
| 186 | +// changes. Two things exist here that the terminal never had: write_file and | |
| 187 | +// edit_file ask the client's user for permission before touching a file, and a | |
| 188 | +// successful change travels as a structured diff the editor can render. Paths | |
| 189 | +// resolve against the SESSION's working directory (the project open in the | |
| 190 | +// editor), not the process's — that is ui.Resolve. | |
| 191 | + | |
| 192 | +// absolute is the location a client can jump to: ACP wants absolute paths. | |
| 193 | +func absolute(path string) string { | |
| 194 | + if abs, err := filepath.Abs(path); err == nil { | |
| 195 | + return abs | |
| 196 | + } | |
| 197 | + return path | |
| 198 | +} | |
| 199 | + | |
| 200 | +func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, where string) (string, error) { | |
| 201 | + path := ui.Resolve(in.Path) | |
| 202 | + ev := ui.ToolEvent{ | |
| 203 | + ID: ui.NextCallID(), | |
| 204 | + Tool: "read_file", | |
| 205 | + Title: "read_file: " + where, | |
| 206 | + Kind: "read", | |
| 207 | + Input: map[string]any{"path": in.Path}, | |
| 208 | + Path: absolute(path), | |
| 209 | + } | |
| 210 | + // Reading is not gated: the permission dialog exists to protect the user's | |
| 211 | + // files from changes and their shell from commands, and asking before every | |
| 212 | + // read teaches them to stop reading the dialogs. | |
| 213 | + s.ToolStart(ev) | |
| 214 | + s.ToolRunning(ev.ID) | |
| 215 | + | |
| 216 | + content, err := fileedit.Read(path, in.Start, in.End, in.Numbered) | |
| 217 | + if err != nil { | |
| 218 | + out := err.Error() | |
| 219 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 220 | + return record(d, "read_file", where, out), nil | |
| 221 | + } | |
| 222 | + out := truncate(content) | |
| 223 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) | |
| 224 | + return record(d, "read_file", where, out), nil | |
| 225 | +} | |
| 226 | + | |
| 227 | +func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) { | |
| 228 | + path := ui.Resolve(in.Path) | |
| 229 | + ev := ui.ToolEvent{ | |
| 230 | + ID: ui.NextCallID(), | |
| 231 | + Tool: "write_file", | |
| 232 | + Title: fmt.Sprintf("write_file: %s (%d bytes)", in.Path, len(in.Content)), | |
| 233 | + Kind: "edit", | |
| 234 | + Input: map[string]any{"path": in.Path}, | |
| 235 | + Path: absolute(path), | |
| 236 | + } | |
| 237 | + s.ToolStart(ev) | |
| 238 | + if !s.Allow(ctx, ev) { | |
| 239 | + out := "[write_file rejected by the user]" | |
| 240 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 241 | + return record(d, "write_file", in.Path, out), nil | |
| 242 | + } | |
| 243 | + s.ToolRunning(ev.ID) | |
| 244 | + | |
| 245 | + // The old content is read BEFORE the write, for the diff the client | |
| 246 | + // renders. Best effort: an unreadable file simply means "created". | |
| 247 | + before, readErr := os.ReadFile(path) | |
| 248 | + | |
| 249 | + r, err := fileedit.Write(path, in.Content) | |
| 250 | + if err != nil { | |
| 251 | + out := err.Error() | |
| 252 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 253 | + return record(d, "write_file", in.Path, out), nil | |
| 254 | + } | |
| 255 | + out := resultText(r) | |
| 256 | + res := ui.ToolResult{Output: out} | |
| 257 | + if r.Changed { | |
| 258 | + res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil} | |
| 259 | + } | |
| 260 | + s.ToolEnd(ev.ID, res) | |
| 261 | + return record(d, "write_file", in.Path, truncate(out)), nil | |
| 262 | +} | |
| 263 | + | |
| 264 | +func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) { | |
| 265 | + path := ui.Resolve(in.Path) | |
| 266 | + ev := ui.ToolEvent{ | |
| 267 | + ID: ui.NextCallID(), | |
| 268 | + Tool: "edit_file", | |
| 269 | + Title: "edit_file: " + what, | |
| 270 | + Kind: "edit", | |
| 271 | + Input: map[string]any{"path": in.Path, "edits": len(in.Edits), "dry_run": in.DryRun}, | |
| 272 | + Path: absolute(path), | |
| 273 | + } | |
| 274 | + s.ToolStart(ev) | |
| 275 | + if !s.Allow(ctx, ev) { | |
| 276 | + out := "[edit_file rejected by the user]" | |
| 277 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 278 | + return record(d, "edit_file", editKey(in), out), nil | |
| 279 | + } | |
| 280 | + s.ToolRunning(ev.ID) | |
| 281 | + | |
| 282 | + before, _ := os.ReadFile(path) | |
| 283 | + | |
| 284 | + r, err := fileedit.Replace(path, in.Edits, in.DryRun) | |
| 285 | + if err != nil { | |
| 286 | + // A refused edit (text not found, ambiguous, overlapping) is the | |
| 287 | + // NORMAL path of this tool: failed status for the client, text for the | |
| 288 | + // model — which reads the rule and fixes its `old`. | |
| 289 | + out := err.Error() | |
| 290 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 291 | + return record(d, "edit_file", editKey(in), out), nil | |
| 292 | + } | |
| 293 | + out := resultText(r) | |
| 294 | + res := ui.ToolResult{Output: out} | |
| 295 | + if r.Changed && !in.DryRun { | |
| 296 | + after, readErr := os.ReadFile(path) | |
| 297 | + if readErr == nil { | |
| 298 | + res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: string(after)} | |
| 299 | + } | |
| 300 | + } | |
| 301 | + s.ToolEnd(ev.ID, res) | |
| 302 | + return record(d, "edit_file", editKey(in), truncate(out)), nil | |
| 303 | +} | |
| 304 | + | |
| 305 | +// resultText is what report() builds for the model, without the echo: the | |
| 306 | +// headline, then the numbered diff. | |
| 307 | +func resultText(r fileedit.Result) string { | |
| 308 | + out := r.Headline | |
| 309 | + if r.Diff != "" { | |
| 310 | + out += "\n" + strings.TrimRight(r.Diff, "\n") | |
| 311 | + } | |
| 312 | + return out | |
| 313 | +} | |
| 314 | + | |
| 315 | +// report echoes a write/edit result to the user — headline then diff, dimmed | |
| 316 | +// and capped by previewLines like a command's output — and returns the same | |
| 317 | +// text to the model, capped by maxOutput. The user sees the change BEFORE the | |
| 318 | +// model comments on it: what the file became no longer depends on what the | |
| 319 | +// model chooses to say about it. | |
| 320 | +func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string { | |
| 321 | + out := resultText(r) | |
| 322 | + spinner.Suspend(func() { echoOutput(out) }) | |
| 323 | + return record(d, tool, key, truncate(out)) | |
| 324 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,324 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "os" | ||
| 7 | + "path/filepath" | ||
| 8 | + "strings" | ||
| 9 | + | ||
| 10 | + "mm/internal/detector" | ||
| 11 | + "mm/internal/fileedit" | ||
| 12 | + "mm/internal/spinner" | ||
| 13 | + "mm/internal/ui" | ||
| 14 | + | ||
| 15 | + "github.com/firebase/genkit/go/ai" | ||
| 16 | + "github.com/firebase/genkit/go/genkit" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// --- read_file / write_file / edit_file --------------------------------------- | ||
| 20 | +// | ||
| 21 | +// Three built-in tools with the rules of the `edit` CLI (tools/edit). Part 05 | ||
| 22 | +// gave the model that CLI through bash; this part gives it the same operations | ||
| 23 | +// as tools, so the two can be COMPARED: does a 12B model edit more reliably | ||
| 24 | +// when the operation is a tool it can see, or a command it has to spell? | ||
| 25 | +// | ||
| 26 | +// Same contract as `bash`: a failure is returned AS TEXT, never as a Go error, | ||
| 27 | +// so the model reads why and retries; every call prints one scannable line | ||
| 28 | +// before running and echoes its result to the user before the model sees it. | ||
| 29 | + | ||
| 30 | +// maxDisplayWidth is where a display line is cut — the same hundred columns as | ||
| 31 | +// the command recap: it fits a demo terminal without wrapping. | ||
| 32 | +const maxDisplayWidth = 100 | ||
| 33 | + | ||
| 34 | +// clip folds a display line onto ONE line and truncates it. A path can be | ||
| 35 | +// long, an old text longer: the line is there to be scanned, not read. | ||
| 36 | +func clip(s string) string { | ||
| 37 | + s = strings.Join(strings.Fields(s), " ") | ||
| 38 | + if r := []rune(s); len(r) > maxDisplayWidth { | ||
| 39 | + return string(r[:maxDisplayWidth-1]) + "…" | ||
| 40 | + } | ||
| 41 | + return s | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// announce prints the display line of a file tool, the way `bash` prints its | ||
| 45 | +// 🛠️ line: under the spinner's Suspend so the label comes back for the wait | ||
| 46 | +// that follows, and under printMu so two tools of one turn do not interleave. | ||
| 47 | +func announce(line string) { | ||
| 48 | + spinner.Suspend(func() { | ||
| 49 | + printMu.Lock() | ||
| 50 | + fmt.Printf("\n%s\n", clip(line)) | ||
| 51 | + printMu.Unlock() | ||
| 52 | + }) | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +// record feeds the loop detector like `bash` does, and appends its instruction | ||
| 56 | +// to the output when a loop is detected: the same edit refused three times in | ||
| 57 | +// a row is the model going in circles, exactly like a failing command. | ||
| 58 | +func record(d *detector.LoopDetector, tool, input, out string) string { | ||
| 59 | + if d.Record(detector.Action{ToolName: tool, Input: input, Output: out}) { | ||
| 60 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()) | ||
| 61 | + } | ||
| 62 | + return out | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +type readFileInput struct { | ||
| 66 | + Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory"` | ||
| 67 | + Start int `json:"start,omitempty" jsonschema_description:"First line to return (1-based); 0 or absent = from the start"` | ||
| 68 | + End int `json:"end,omitempty" jsonschema_description:"Last line to return (1-based, inclusive); 0 or absent = to the end"` | ||
| 69 | + Numbered bool `json:"numbered,omitempty" jsonschema_description:"Prefix each line with its number — useful to pick a range for a second read"` | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +// ReadFile declares the `read_file` tool. | ||
| 73 | +func ReadFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | ||
| 74 | + description := `Read a text file, or a range of its lines. | ||
| 75 | +Read before you edit: edit_file needs the EXACT text of the file, and you cannot match text you have not seen. Do not type it from memory. | ||
| 76 | +Use start/end for a slice of a long file, and numbered=true to get line numbers. Long content is truncated, and the text says so.` | ||
| 77 | + | ||
| 78 | + return genkit.DefineTool(g, "read_file", description, | ||
| 79 | + func(_ *ai.ToolContext, in readFileInput) (string, error) { | ||
| 80 | + where := in.Path | ||
| 81 | + if in.Start > 0 || in.End > 0 { | ||
| 82 | + where += fmt.Sprintf(" %d-%d", in.Start, in.End) | ||
| 83 | + } | ||
| 84 | + if s := ui.ActiveSink(); s != nil { | ||
| 85 | + return readFileEvented(s, d, in, where) | ||
| 86 | + } | ||
| 87 | + announce("📄 read_file: " + where) | ||
| 88 | + | ||
| 89 | + content, err := fileedit.Read(in.Path, in.Start, in.End, in.Numbered) | ||
| 90 | + if err != nil { | ||
| 91 | + // Information, not a crash: the message says how to fix the call. | ||
| 92 | + out := err.Error() | ||
| 93 | + spinner.Suspend(func() { echoOutput(out) }) | ||
| 94 | + return record(d, "read_file", where, out), nil | ||
| 95 | + } | ||
| 96 | + out := truncate(content) | ||
| 97 | + // Shown to the user like a command's output: "show me that file" | ||
| 98 | + // means show it, whatever tool read it. | ||
| 99 | + spinner.Suspend(func() { echoOutput(out) }) | ||
| 100 | + return record(d, "read_file", where, out), nil | ||
| 101 | + }) | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +type writeFileInput struct { | ||
| 105 | + Path string `json:"path" jsonschema_description:"Path of the file, relative to the working directory; parent directories are created"` | ||
| 106 | + Content string `json:"content" jsonschema_description:"The WHOLE content of the file"` | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +// WriteFile declares the `write_file` tool. | ||
| 110 | +func WriteFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | ||
| 111 | + description := `Write the WHOLE content of a file: create it, or rewrite it entirely and on purpose. | ||
| 112 | +On an existing file it overwrites everything, including what you did not intend to touch: to change part of a file, use edit_file instead. | ||
| 113 | +Returns a one-line summary and the diff against the previous content. Writing identical content changes nothing and says so.` | ||
| 114 | + | ||
| 115 | + return genkit.DefineTool(g, "write_file", description, | ||
| 116 | + func(tctx *ai.ToolContext, in writeFileInput) (string, error) { | ||
| 117 | + if s := ui.ActiveSink(); s != nil { | ||
| 118 | + return writeFileEvented(tctx, s, d, in) | ||
| 119 | + } | ||
| 120 | + announce(fmt.Sprintf("💾 write_file: %s (%d bytes)", in.Path, len(in.Content))) | ||
| 121 | + | ||
| 122 | + r, err := fileedit.Write(in.Path, in.Content) | ||
| 123 | + if err != nil { | ||
| 124 | + out := err.Error() | ||
| 125 | + spinner.Suspend(func() { echoOutput(out) }) | ||
| 126 | + return record(d, "write_file", in.Path, out), nil | ||
| 127 | + } | ||
| 128 | + return report(d, "write_file", in.Path, r), nil | ||
| 129 | + }) | ||
| 130 | +} | ||
| 131 | + | ||
| 132 | +type editFileInput struct { | ||
| 133 | + Path string `json:"path" jsonschema_description:"Path of an EXISTING file, relative to the working directory"` | ||
| 134 | + Edits []fileedit.Edit `json:"edits" jsonschema_description:"The replacements, all resolved against the ORIGINAL file; two edits must not overlap"` | ||
| 135 | + DryRun bool `json:"dry_run,omitempty" jsonschema_description:"Show what would change and write nothing"` | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +// EditFile declares the `edit_file` tool. | ||
| 139 | +func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | ||
| 140 | + description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it. | ||
| 141 | +Each edit has an old text and a new text. The rules, which are what makes an edit verifiable: | ||
| 142 | +1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file. | ||
| 143 | +2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique. | ||
| 144 | +3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote. | ||
| 145 | +4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit. | ||
| 146 | +5. An empty new deletes the matched text. | ||
| 147 | +6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines. | ||
| 148 | +Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.` | ||
| 149 | + | ||
| 150 | + return genkit.DefineTool(g, "edit_file", description, | ||
| 151 | + func(tctx *ai.ToolContext, in editFileInput) (string, error) { | ||
| 152 | + what := fmt.Sprintf("%s (%d edit(s))", in.Path, len(in.Edits)) | ||
| 153 | + if in.DryRun { | ||
| 154 | + what += " dry-run" | ||
| 155 | + } | ||
| 156 | + if s := ui.ActiveSink(); s != nil { | ||
| 157 | + return editFileEvented(tctx, s, d, in, what) | ||
| 158 | + } | ||
| 159 | + announce("📝 edit_file: " + what) | ||
| 160 | + | ||
| 161 | + r, err := fileedit.Replace(in.Path, in.Edits, in.DryRun) | ||
| 162 | + if err != nil { | ||
| 163 | + out := err.Error() | ||
| 164 | + spinner.Suspend(func() { echoOutput(out) }) | ||
| 165 | + return record(d, "edit_file", editKey(in), out), nil | ||
| 166 | + } | ||
| 167 | + return report(d, "edit_file", editKey(in), r), nil | ||
| 168 | + }) | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +// editKey is what the loop detector compares for edit_file: the path AND the | ||
| 172 | +// edits. Two calls with the same path but different old texts are the model | ||
| 173 | +// correcting itself, not looping. | ||
| 174 | +func editKey(in editFileInput) string { | ||
| 175 | + var b strings.Builder | ||
| 176 | + b.WriteString(in.Path) | ||
| 177 | + for _, e := range in.Edits { | ||
| 178 | + b.WriteString("\x00" + e.Old + "\x00" + e.New) | ||
| 179 | + } | ||
| 180 | + return b.String() | ||
| 181 | +} | ||
| 182 | + | ||
| 183 | +// --- the same three tools, for an event-consuming front end (ACP) ------------- | ||
| 184 | +// | ||
| 185 | +// The information is the same as the terminal path prints; only the channel | ||
| 186 | +// changes. Two things exist here that the terminal never had: write_file and | ||
| 187 | +// edit_file ask the client's user for permission before touching a file, and a | ||
| 188 | +// successful change travels as a structured diff the editor can render. Paths | ||
| 189 | +// resolve against the SESSION's working directory (the project open in the | ||
| 190 | +// editor), not the process's — that is ui.Resolve. | ||
| 191 | + | ||
| 192 | +// absolute is the location a client can jump to: ACP wants absolute paths. | ||
| 193 | +func absolute(path string) string { | ||
| 194 | + if abs, err := filepath.Abs(path); err == nil { | ||
| 195 | + return abs | ||
| 196 | + } | ||
| 197 | + return path | ||
| 198 | +} | ||
| 199 | + | ||
| 200 | +func readFileEvented(s ui.Sink, d *detector.LoopDetector, in readFileInput, where string) (string, error) { | ||
| 201 | + path := ui.Resolve(in.Path) | ||
| 202 | + ev := ui.ToolEvent{ | ||
| 203 | + ID: ui.NextCallID(), | ||
| 204 | + Tool: "read_file", | ||
| 205 | + Title: "read_file: " + where, | ||
| 206 | + Kind: "read", | ||
| 207 | + Input: map[string]any{"path": in.Path}, | ||
| 208 | + Path: absolute(path), | ||
| 209 | + } | ||
| 210 | + // Reading is not gated: the permission dialog exists to protect the user's | ||
| 211 | + // files from changes and their shell from commands, and asking before every | ||
| 212 | + // read teaches them to stop reading the dialogs. | ||
| 213 | + s.ToolStart(ev) | ||
| 214 | + s.ToolRunning(ev.ID) | ||
| 215 | + | ||
| 216 | + content, err := fileedit.Read(path, in.Start, in.End, in.Numbered) | ||
| 217 | + if err != nil { | ||
| 218 | + out := err.Error() | ||
| 219 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 220 | + return record(d, "read_file", where, out), nil | ||
| 221 | + } | ||
| 222 | + out := truncate(content) | ||
| 223 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) | ||
| 224 | + return record(d, "read_file", where, out), nil | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +func writeFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in writeFileInput) (string, error) { | ||
| 228 | + path := ui.Resolve(in.Path) | ||
| 229 | + ev := ui.ToolEvent{ | ||
| 230 | + ID: ui.NextCallID(), | ||
| 231 | + Tool: "write_file", | ||
| 232 | + Title: fmt.Sprintf("write_file: %s (%d bytes)", in.Path, len(in.Content)), | ||
| 233 | + Kind: "edit", | ||
| 234 | + Input: map[string]any{"path": in.Path}, | ||
| 235 | + Path: absolute(path), | ||
| 236 | + } | ||
| 237 | + s.ToolStart(ev) | ||
| 238 | + if !s.Allow(ctx, ev) { | ||
| 239 | + out := "[write_file rejected by the user]" | ||
| 240 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 241 | + return record(d, "write_file", in.Path, out), nil | ||
| 242 | + } | ||
| 243 | + s.ToolRunning(ev.ID) | ||
| 244 | + | ||
| 245 | + // The old content is read BEFORE the write, for the diff the client | ||
| 246 | + // renders. Best effort: an unreadable file simply means "created". | ||
| 247 | + before, readErr := os.ReadFile(path) | ||
| 248 | + | ||
| 249 | + r, err := fileedit.Write(path, in.Content) | ||
| 250 | + if err != nil { | ||
| 251 | + out := err.Error() | ||
| 252 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 253 | + return record(d, "write_file", in.Path, out), nil | ||
| 254 | + } | ||
| 255 | + out := resultText(r) | ||
| 256 | + res := ui.ToolResult{Output: out} | ||
| 257 | + if r.Changed { | ||
| 258 | + res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: in.Content, Created: readErr != nil} | ||
| 259 | + } | ||
| 260 | + s.ToolEnd(ev.ID, res) | ||
| 261 | + return record(d, "write_file", in.Path, truncate(out)), nil | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +func editFileEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in editFileInput, what string) (string, error) { | ||
| 265 | + path := ui.Resolve(in.Path) | ||
| 266 | + ev := ui.ToolEvent{ | ||
| 267 | + ID: ui.NextCallID(), | ||
| 268 | + Tool: "edit_file", | ||
| 269 | + Title: "edit_file: " + what, | ||
| 270 | + Kind: "edit", | ||
| 271 | + Input: map[string]any{"path": in.Path, "edits": len(in.Edits), "dry_run": in.DryRun}, | ||
| 272 | + Path: absolute(path), | ||
| 273 | + } | ||
| 274 | + s.ToolStart(ev) | ||
| 275 | + if !s.Allow(ctx, ev) { | ||
| 276 | + out := "[edit_file rejected by the user]" | ||
| 277 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 278 | + return record(d, "edit_file", editKey(in), out), nil | ||
| 279 | + } | ||
| 280 | + s.ToolRunning(ev.ID) | ||
| 281 | + | ||
| 282 | + before, _ := os.ReadFile(path) | ||
| 283 | + | ||
| 284 | + r, err := fileedit.Replace(path, in.Edits, in.DryRun) | ||
| 285 | + if err != nil { | ||
| 286 | + // A refused edit (text not found, ambiguous, overlapping) is the | ||
| 287 | + // NORMAL path of this tool: failed status for the client, text for the | ||
| 288 | + // model — which reads the rule and fixes its `old`. | ||
| 289 | + out := err.Error() | ||
| 290 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 291 | + return record(d, "edit_file", editKey(in), out), nil | ||
| 292 | + } | ||
| 293 | + out := resultText(r) | ||
| 294 | + res := ui.ToolResult{Output: out} | ||
| 295 | + if r.Changed && !in.DryRun { | ||
| 296 | + after, readErr := os.ReadFile(path) | ||
| 297 | + if readErr == nil { | ||
| 298 | + res.Diff = &ui.Diff{Path: absolute(path), OldText: string(before), NewText: string(after)} | ||
| 299 | + } | ||
| 300 | + } | ||
| 301 | + s.ToolEnd(ev.ID, res) | ||
| 302 | + return record(d, "edit_file", editKey(in), truncate(out)), nil | ||
| 303 | +} | ||
| 304 | + | ||
| 305 | +// resultText is what report() builds for the model, without the echo: the | ||
| 306 | +// headline, then the numbered diff. | ||
| 307 | +func resultText(r fileedit.Result) string { | ||
| 308 | + out := r.Headline | ||
| 309 | + if r.Diff != "" { | ||
| 310 | + out += "\n" + strings.TrimRight(r.Diff, "\n") | ||
| 311 | + } | ||
| 312 | + return out | ||
| 313 | +} | ||
| 314 | + | ||
| 315 | +// report echoes a write/edit result to the user — headline then diff, dimmed | ||
| 316 | +// and capped by previewLines like a command's output — and returns the same | ||
| 317 | +// text to the model, capped by maxOutput. The user sees the change BEFORE the | ||
| 318 | +// model comments on it: what the file became no longer depends on what the | ||
| 319 | +// model chooses to say about it. | ||
| 320 | +func report(d *detector.LoopDetector, tool, key string, r fileedit.Result) string { | ||
| 321 | + out := resultText(r) | ||
| 322 | + spinner.Suspend(func() { echoOutput(out) }) | ||
| 323 | + return record(d, tool, key, truncate(out)) | ||
| 324 | +} | ||
added
internal/tools/files_test.go +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +package tools | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "mm/internal/fileedit" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// TestClip: a display line is scanned, not read. A hundred columns, one line — | |
| 11 | +// the same rule as the command recap, and the same reason: a multi-line old | |
| 12 | +// text printed whole would be longer than the diff it announces. | |
| 13 | +func TestClip(t *testing.T) { | |
| 14 | + if got := clip("📝 edit_file: a.go\n (2 edits)"); got != "📝 edit_file: a.go (2 edits)" { | |
| 15 | + t.Errorf("clip folded to %q", got) | |
| 16 | + } | |
| 17 | + long := clip("📄 read_file: " + strings.Repeat("x", 200)) | |
| 18 | + if r := []rune(long); len(r) != maxDisplayWidth || r[len(r)-1] != '…' { | |
| 19 | + t.Errorf("clip = %d runes ending %q, want %d ending …", len(r), string(r[len(r)-1]), maxDisplayWidth) | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +// TestEditKeyTellsRetriesApart: the loop detector must see a corrected edit as | |
| 24 | +// a NEW action. Keying on the path alone made "same path, different old text" | |
| 25 | +// look like a repeat, and the detector fired on a model that was fixing itself. | |
| 26 | +func TestEditKeyTellsRetriesApart(t *testing.T) { | |
| 27 | + a := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}}) | |
| 28 | + b := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x2", New: "y"}}}) | |
| 29 | + same := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}}) | |
| 30 | + if a == b || a != same { | |
| 31 | + t.Errorf("editKey: a=%q b=%q same=%q", a, b, same) | |
| 32 | + } | |
| 33 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +package tools | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "mm/internal/fileedit" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// TestClip: a display line is scanned, not read. A hundred columns, one line — | ||
| 11 | +// the same rule as the command recap, and the same reason: a multi-line old | ||
| 12 | +// text printed whole would be longer than the diff it announces. | ||
| 13 | +func TestClip(t *testing.T) { | ||
| 14 | + if got := clip("📝 edit_file: a.go\n (2 edits)"); got != "📝 edit_file: a.go (2 edits)" { | ||
| 15 | + t.Errorf("clip folded to %q", got) | ||
| 16 | + } | ||
| 17 | + long := clip("📄 read_file: " + strings.Repeat("x", 200)) | ||
| 18 | + if r := []rune(long); len(r) != maxDisplayWidth || r[len(r)-1] != '…' { | ||
| 19 | + t.Errorf("clip = %d runes ending %q, want %d ending …", len(r), string(r[len(r)-1]), maxDisplayWidth) | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +// TestEditKeyTellsRetriesApart: the loop detector must see a corrected edit as | ||
| 24 | +// a NEW action. Keying on the path alone made "same path, different old text" | ||
| 25 | +// look like a repeat, and the detector fired on a model that was fixing itself. | ||
| 26 | +func TestEditKeyTellsRetriesApart(t *testing.T) { | ||
| 27 | + a := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}}) | ||
| 28 | + b := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x2", New: "y"}}}) | ||
| 29 | + same := editKey(editFileInput{Path: "f.go", Edits: []fileedit.Edit{{Old: "x", New: "y"}}}) | ||
| 30 | + if a == b || a != same { | ||
| 31 | + t.Errorf("editKey: a=%q b=%q same=%q", a, b, same) | ||
| 32 | + } | ||
| 33 | +} | ||
added
internal/tools/tools.go +316 -0 | new file mode 100644 | ||
| @@ -0,0 +1,316 @@ | ||
| 1 | +// Package tools contains the built-in tools the model can call. | |
| 2 | +// Each `XxxTool(g)` constructor declares the tool in Genkit and returns it. | |
| 3 | +// | |
| 4 | +// Shared principle: we NEVER return a Go `error` for a "normal" failure | |
| 5 | +// (a command that fails, etc.). The error goes back AS TEXT, so that the model | |
| 6 | +// reads it and reacts. | |
| 7 | +package tools | |
| 8 | + | |
| 9 | +import ( | |
| 10 | + "context" | |
| 11 | + "fmt" | |
| 12 | + "os/exec" | |
| 13 | + "strings" | |
| 14 | + "sync" | |
| 15 | + "time" | |
| 16 | + | |
| 17 | + "mm/internal/config" | |
| 18 | + "mm/internal/detector" | |
| 19 | + "mm/internal/skills" | |
| 20 | + "mm/internal/spinner" | |
| 21 | + "mm/internal/ui" | |
| 22 | + | |
| 23 | + "github.com/firebase/genkit/go/ai" | |
| 24 | + "github.com/firebase/genkit/go/genkit" | |
| 25 | +) | |
| 26 | + | |
| 27 | +// --- Tool Implementation ---------------------------------------------------- | |
| 28 | + | |
| 29 | +// truncate caps a large output, keeping the beginning AND the end. | |
| 30 | +func truncate(s string) string { | |
| 31 | + max := config.Cfg.MaxOutput | |
| 32 | + r := []rune(s) | |
| 33 | + if len(r) <= max { | |
| 34 | + return s | |
| 35 | + } | |
| 36 | + // 2000 characters kept at the end — but never more than half the budget, | |
| 37 | + // since maxOutput comes from the config file and may be small. | |
| 38 | + tailLen := min(2000, max/2) | |
| 39 | + headLen := max - tailLen | |
| 40 | + | |
| 41 | + head := string(r[:headLen]) | |
| 42 | + tail := string(r[len(r)-tailLen:]) | |
| 43 | + omitted := len(r) - headLen - tailLen | |
| 44 | + return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", head, omitted, tail) | |
| 45 | +} | |
| 46 | + | |
| 47 | +// printMu serialises what the tools write to the screen. Genkit runs the calls | |
| 48 | +// of a single turn IN PARALLEL (one goroutine each), so two commands may want to | |
| 49 | +// print at the same time; without this lock their outputs interleave line by | |
| 50 | +// line. | |
| 51 | +var printMu sync.Mutex | |
| 52 | + | |
| 53 | +// echoOutput shows on screen what the command answered. | |
| 54 | +// | |
| 55 | +// Without it the agent swallows the output: it goes to the model, which | |
| 56 | +// summarises it — or does not. Seen on "display the content of agent.yaml": the | |
| 57 | +// model runs `cat`, considers the request fulfilled, and the user sees nothing. | |
| 58 | +// But "show me" means show. | |
| 59 | +// | |
| 60 | +// Only the first PreviewLines lines are printed: an output can run to thousands | |
| 61 | +// of lines, and drowning the terminal would be as useless as showing none of | |
| 62 | +// it. | |
| 63 | +func echoOutput(out string) { | |
| 64 | + if config.Cfg.PreviewLines <= 0 { | |
| 65 | + return | |
| 66 | + } | |
| 67 | + out = strings.TrimRight(out, "\n") | |
| 68 | + if out == "" { | |
| 69 | + printMu.Lock() | |
| 70 | + if spinner.Styled() { | |
| 71 | + fmt.Print("\033[2m (no output)\033[0m\n") | |
| 72 | + } else { | |
| 73 | + fmt.Print(" (no output)\n") | |
| 74 | + } | |
| 75 | + printMu.Unlock() | |
| 76 | + return | |
| 77 | + } | |
| 78 | + | |
| 79 | + lines := strings.Split(out, "\n") | |
| 80 | + shown := lines | |
| 81 | + if len(lines) > config.Cfg.PreviewLines { | |
| 82 | + shown = lines[:config.Cfg.PreviewLines] | |
| 83 | + } | |
| 84 | + | |
| 85 | + // Indented and dimmed: one glance must separate what the machine answered | |
| 86 | + // from what the model says about it. The grey only comes out on a terminal. | |
| 87 | + dim, off := "", "" | |
| 88 | + if spinner.Styled() { | |
| 89 | + dim, off = "\033[2m", "\033[0m" | |
| 90 | + } | |
| 91 | + | |
| 92 | + var b strings.Builder | |
| 93 | + for _, l := range shown { | |
| 94 | + b.WriteString(dim + " │ " + l + off + "\n") | |
| 95 | + } | |
| 96 | + if len(lines) > len(shown) { | |
| 97 | + fmt.Fprintf(&b, "%s └ … %d more line(s)%s\n", dim, len(lines)-len(shown), off) | |
| 98 | + } | |
| 99 | + | |
| 100 | + printMu.Lock() | |
| 101 | + fmt.Print(b.String()) | |
| 102 | + printMu.Unlock() | |
| 103 | +} | |
| 104 | + | |
| 105 | +// runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` — | |
| 106 | +// "" meaning the process's own directory, which is the terminal behaviour. | |
| 107 | +// `failed` reports a non-zero exit or a timeout; the text already says so, but | |
| 108 | +// a front end that renders statuses (ACP) needs it as a value, not a substring. | |
| 109 | +func runBash(dir, command string) (output string, failed bool) { | |
| 110 | + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 111 | + defer cancel() | |
| 112 | + | |
| 113 | + cmd := exec.CommandContext(ctx, "bash", "-c", command) | |
| 114 | + cmd.Dir = dir | |
| 115 | + out, err := cmd.CombinedOutput() | |
| 116 | + output = truncate(string(out)) | |
| 117 | + | |
| 118 | + if ctx.Err() == context.DeadlineExceeded { | |
| 119 | + return output + "\n[error: 30s timeout exceeded]", true | |
| 120 | + } | |
| 121 | + if err != nil { | |
| 122 | + return output + fmt.Sprintf("\n[exit code: %v]", err), true | |
| 123 | + } | |
| 124 | + return output, false | |
| 125 | +} | |
| 126 | + | |
| 127 | +// Bash declares the `bash` tool. | |
| 128 | +func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | |
| 129 | + | |
| 130 | + description := `Run a shell command and return its output, plus the exit status when it is not zero. | |
| 131 | +This is a real bash shell: pipes, redirections, && and any installed command are available. | |
| 132 | +Each call starts a NEW shell in the same directory, so a "cd" does not carry over to the next call — chain with && instead. | |
| 133 | +Nothing is interactive: there is no stdin, so never use a command that asks a question. | |
| 134 | +Long output is truncated, and the text says so when it happens. | |
| 135 | +Read before you write: if you do not know a path or the content of a file, run a command to find out rather than guessing.` | |
| 136 | + | |
| 137 | + return genkit.DefineTool(g, "bash", | |
| 138 | + description, | |
| 139 | + func(tctx *ai.ToolContext, in bashInput) (string, error) { | |
| 140 | + // A front end that consumes events (the ACP façade) gets them | |
| 141 | + // instead of the prints below; the terminal path is unchanged. | |
| 142 | + if s := ui.ActiveSink(); s != nil { | |
| 143 | + return bashEvented(tctx, s, d, in) | |
| 144 | + } | |
| 145 | + | |
| 146 | + // Suspend rather than Stop: the model was "Thinking" when it decided | |
| 147 | + // to call us, and it goes back to thinking as soon as we return the | |
| 148 | + // output — so the label is restored for the wait that follows. | |
| 149 | + spinner.Suspend(func() { | |
| 150 | + printMu.Lock() | |
| 151 | + fmt.Printf("\n🛠️ bash: %s\n", in.Command) | |
| 152 | + printMu.Unlock() | |
| 153 | + }) | |
| 154 | + | |
| 155 | + // The command has its own 30s budget, and a slow one looks exactly | |
| 156 | + // like a hung agent. Retitle for the duration, then hand the line | |
| 157 | + // back to the "Thinking" wait Suspend restored. | |
| 158 | + spinner.Start("Running") | |
| 159 | + out, _ := runBash("", in.Command) | |
| 160 | + | |
| 161 | + // The output is shown to the user BEFORE going back to the model: | |
| 162 | + // what the command answered no longer depends on what the model | |
| 163 | + // chooses to say about it. | |
| 164 | + spinner.Suspend(func() { | |
| 165 | + echoOutput(out) | |
| 166 | + }) | |
| 167 | + | |
| 168 | + spinner.Start("Thinking") | |
| 169 | + | |
| 170 | + // The loop detector sees the command AND its output: the same pair | |
| 171 | + // twice in a row, and it hands the model back an instruction to | |
| 172 | + // change its approach (see internal/detector). | |
| 173 | + if d.Record(detector.Action{ | |
| 174 | + ToolName: "bash", | |
| 175 | + Input: in.Command, | |
| 176 | + Output: out, | |
| 177 | + }) { | |
| 178 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil | |
| 179 | + } | |
| 180 | + | |
| 181 | + return out, nil | |
| 182 | + }) | |
| 183 | +} | |
| 184 | + | |
| 185 | +type bashInput struct { | |
| 186 | + Command string `json:"command" jsonschema_description:"The shell command to run"` | |
| 187 | +} | |
| 188 | + | |
| 189 | +// bashEvented is the `bash` tool when a front end consumes events (ACP): the | |
| 190 | +// 🛠️ line becomes ToolStart, the grey echo becomes ToolEnd's output — and one | |
| 191 | +// thing exists here that the terminal never had: the command does not run until | |
| 192 | +// the client's user allowed it. The refusal goes back to the model AS TEXT, | |
| 193 | +// like every normal failure in this package, so it can propose something else | |
| 194 | +// instead of crashing the turn. | |
| 195 | +func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in bashInput) (string, error) { | |
| 196 | + ev := ui.ToolEvent{ | |
| 197 | + ID: ui.NextCallID(), | |
| 198 | + Tool: "bash", | |
| 199 | + Title: "bash: " + in.Command, | |
| 200 | + Kind: "execute", | |
| 201 | + Input: map[string]any{"command": in.Command}, | |
| 202 | + } | |
| 203 | + s.ToolStart(ev) | |
| 204 | + | |
| 205 | + if !s.Allow(ctx, ev) { | |
| 206 | + out := "[command rejected by the user]" | |
| 207 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 208 | + // Recorded like any other outcome: a model that keeps retrying a | |
| 209 | + // command the user keeps rejecting is looping. | |
| 210 | + return record(d, "bash", in.Command, out), nil | |
| 211 | + } | |
| 212 | + | |
| 213 | + s.ToolRunning(ev.ID) | |
| 214 | + // The session's working directory, not the process's: in an editor, the | |
| 215 | + // project the user opened is where the commands belong. | |
| 216 | + out, failed := runBash(s.WorkDir(), in.Command) | |
| 217 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) | |
| 218 | + | |
| 219 | + return record(d, "bash", in.Command, out), nil | |
| 220 | +} | |
| 221 | + | |
| 222 | +// --- read_skill -------------------------------------------------------------- | |
| 223 | + | |
| 224 | +type readSkillInput struct { | |
| 225 | + Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description (for example \"go-rename\")"` | |
| 226 | +} | |
| 227 | + | |
| 228 | +// ReadSkill declares the `read_skill` tool: it loads one markdown procedure | |
| 229 | +// from the skills directory. | |
| 230 | +// | |
| 231 | +// The whole point is in the DESCRIPTION: it is built from the front matter of | |
| 232 | +// the files on disk, so the catalogue the model sees is always the directory's | |
| 233 | +// real content. Nothing to keep in sync by hand — and, unlike a paragraph in | |
| 234 | +// the system prompt, loading a skill becomes a tool the model can SEE. | |
| 235 | +// | |
| 236 | +// Returns nil when the directory holds no skill: an agent with an empty | |
| 237 | +// catalogue should not advertise the tool at all. | |
| 238 | +func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRef { | |
| 239 | + list := skills.List(dir) | |
| 240 | + if len(list) == 0 { | |
| 241 | + return nil | |
| 242 | + } | |
| 243 | + | |
| 244 | + return genkit.DefineTool(g, "read_skill", | |
| 245 | + skills.Catalogue(list), | |
| 246 | + func(_ *ai.ToolContext, in readSkillInput) (string, error) { | |
| 247 | + // Event-consuming front end (ACP): same information, as a tool_call. | |
| 248 | + if s := ui.ActiveSink(); s != nil { | |
| 249 | + return readSkillEvented(s, d, dir, list, in) | |
| 250 | + } | |
| 251 | + | |
| 252 | + // Same reasoning as `bash`: announce the call without losing the | |
| 253 | + // "Thinking" wait that resumes the moment the skill is returned. | |
| 254 | + // Reading a file is fast, so there is no second label here. | |
| 255 | + spinner.Suspend(func() { | |
| 256 | + printMu.Lock() | |
| 257 | + fmt.Printf("\n📖 read_skill: %s\n", in.Name) | |
| 258 | + printMu.Unlock() | |
| 259 | + }) | |
| 260 | + | |
| 261 | + content, err := skills.Read(dir, in.Name) | |
| 262 | + if err != nil { | |
| 263 | + // Unknown skill: information, not a crash. The list of valid | |
| 264 | + // names goes back AS TEXT so the model can correct itself. | |
| 265 | + // | |
| 266 | + // Recorded in the detector like everything else: a model | |
| 267 | + // asking four times for the same missing skill is looping, | |
| 268 | + // exactly as it would on a failing command. | |
| 269 | + out := fmt.Sprintf("No skill named %q. Available skills: %s", | |
| 270 | + in.Name, strings.Join(skills.Names(list), ", ")) | |
| 271 | + if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { | |
| 272 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil | |
| 273 | + } | |
| 274 | + return out, nil | |
| 275 | + } | |
| 276 | + | |
| 277 | + // The content is NOT displayed: a skill runs to dozens of lines, it | |
| 278 | + // is written for the model, and the user already has the useful | |
| 279 | + // information — which one was loaded. What they want to see are the | |
| 280 | + // commands that follow from it, and those will show up. | |
| 281 | + out := truncate(content) | |
| 282 | + if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { | |
| 283 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil | |
| 284 | + } | |
| 285 | + return out, nil | |
| 286 | + }) | |
| 287 | +} | |
| 288 | + | |
| 289 | +// readSkillEvented is `read_skill` for an event-consuming front end. No | |
| 290 | +// permission dialog: it reads a procedure the agent ships, nothing more — and a | |
| 291 | +// dialog for every skill would teach the user to click "allow" without reading, | |
| 292 | +// which is worse than no dialog at all. The client still sees the call, kind | |
| 293 | +// "read", with the loaded content as its output. | |
| 294 | +func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []skills.Skill, in readSkillInput) (string, error) { | |
| 295 | + ev := ui.ToolEvent{ | |
| 296 | + ID: ui.NextCallID(), | |
| 297 | + Tool: "read_skill", | |
| 298 | + Title: "read_skill: " + in.Name, | |
| 299 | + Kind: "read", | |
| 300 | + Input: map[string]any{"name": in.Name}, | |
| 301 | + } | |
| 302 | + s.ToolStart(ev) | |
| 303 | + s.ToolRunning(ev.ID) | |
| 304 | + | |
| 305 | + content, err := skills.Read(dir, in.Name) | |
| 306 | + if err != nil { | |
| 307 | + out := fmt.Sprintf("No skill named %q. Available skills: %s", | |
| 308 | + in.Name, strings.Join(skills.Names(list), ", ")) | |
| 309 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | |
| 310 | + return record(d, "read_skill", in.Name, out), nil | |
| 311 | + } | |
| 312 | + | |
| 313 | + out := truncate(content) | |
| 314 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) | |
| 315 | + return record(d, "read_skill", in.Name, out), nil | |
| 316 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,316 @@ | |||
| 1 | +// Package tools contains the built-in tools the model can call. | ||
| 2 | +// Each `XxxTool(g)` constructor declares the tool in Genkit and returns it. | ||
| 3 | +// | ||
| 4 | +// Shared principle: we NEVER return a Go `error` for a "normal" failure | ||
| 5 | +// (a command that fails, etc.). The error goes back AS TEXT, so that the model | ||
| 6 | +// reads it and reacts. | ||
| 7 | +package tools | ||
| 8 | + | ||
| 9 | +import ( | ||
| 10 | + "context" | ||
| 11 | + "fmt" | ||
| 12 | + "os/exec" | ||
| 13 | + "strings" | ||
| 14 | + "sync" | ||
| 15 | + "time" | ||
| 16 | + | ||
| 17 | + "mm/internal/config" | ||
| 18 | + "mm/internal/detector" | ||
| 19 | + "mm/internal/skills" | ||
| 20 | + "mm/internal/spinner" | ||
| 21 | + "mm/internal/ui" | ||
| 22 | + | ||
| 23 | + "github.com/firebase/genkit/go/ai" | ||
| 24 | + "github.com/firebase/genkit/go/genkit" | ||
| 25 | +) | ||
| 26 | + | ||
| 27 | +// --- Tool Implementation ---------------------------------------------------- | ||
| 28 | + | ||
| 29 | +// truncate caps a large output, keeping the beginning AND the end. | ||
| 30 | +func truncate(s string) string { | ||
| 31 | + max := config.Cfg.MaxOutput | ||
| 32 | + r := []rune(s) | ||
| 33 | + if len(r) <= max { | ||
| 34 | + return s | ||
| 35 | + } | ||
| 36 | + // 2000 characters kept at the end — but never more than half the budget, | ||
| 37 | + // since maxOutput comes from the config file and may be small. | ||
| 38 | + tailLen := min(2000, max/2) | ||
| 39 | + headLen := max - tailLen | ||
| 40 | + | ||
| 41 | + head := string(r[:headLen]) | ||
| 42 | + tail := string(r[len(r)-tailLen:]) | ||
| 43 | + omitted := len(r) - headLen - tailLen | ||
| 44 | + return fmt.Sprintf("%s\n\n[... %d characters truncated ...]\n\n%s", head, omitted, tail) | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +// printMu serialises what the tools write to the screen. Genkit runs the calls | ||
| 48 | +// of a single turn IN PARALLEL (one goroutine each), so two commands may want to | ||
| 49 | +// print at the same time; without this lock their outputs interleave line by | ||
| 50 | +// line. | ||
| 51 | +var printMu sync.Mutex | ||
| 52 | + | ||
| 53 | +// echoOutput shows on screen what the command answered. | ||
| 54 | +// | ||
| 55 | +// Without it the agent swallows the output: it goes to the model, which | ||
| 56 | +// summarises it — or does not. Seen on "display the content of agent.yaml": the | ||
| 57 | +// model runs `cat`, considers the request fulfilled, and the user sees nothing. | ||
| 58 | +// But "show me" means show. | ||
| 59 | +// | ||
| 60 | +// Only the first PreviewLines lines are printed: an output can run to thousands | ||
| 61 | +// of lines, and drowning the terminal would be as useless as showing none of | ||
| 62 | +// it. | ||
| 63 | +func echoOutput(out string) { | ||
| 64 | + if config.Cfg.PreviewLines <= 0 { | ||
| 65 | + return | ||
| 66 | + } | ||
| 67 | + out = strings.TrimRight(out, "\n") | ||
| 68 | + if out == "" { | ||
| 69 | + printMu.Lock() | ||
| 70 | + if spinner.Styled() { | ||
| 71 | + fmt.Print("\033[2m (no output)\033[0m\n") | ||
| 72 | + } else { | ||
| 73 | + fmt.Print(" (no output)\n") | ||
| 74 | + } | ||
| 75 | + printMu.Unlock() | ||
| 76 | + return | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + lines := strings.Split(out, "\n") | ||
| 80 | + shown := lines | ||
| 81 | + if len(lines) > config.Cfg.PreviewLines { | ||
| 82 | + shown = lines[:config.Cfg.PreviewLines] | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + // Indented and dimmed: one glance must separate what the machine answered | ||
| 86 | + // from what the model says about it. The grey only comes out on a terminal. | ||
| 87 | + dim, off := "", "" | ||
| 88 | + if spinner.Styled() { | ||
| 89 | + dim, off = "\033[2m", "\033[0m" | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + var b strings.Builder | ||
| 93 | + for _, l := range shown { | ||
| 94 | + b.WriteString(dim + " │ " + l + off + "\n") | ||
| 95 | + } | ||
| 96 | + if len(lines) > len(shown) { | ||
| 97 | + fmt.Fprintf(&b, "%s └ … %d more line(s)%s\n", dim, len(lines)-len(shown), off) | ||
| 98 | + } | ||
| 99 | + | ||
| 100 | + printMu.Lock() | ||
| 101 | + fmt.Print(b.String()) | ||
| 102 | + printMu.Unlock() | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +// runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` — | ||
| 106 | +// "" meaning the process's own directory, which is the terminal behaviour. | ||
| 107 | +// `failed` reports a non-zero exit or a timeout; the text already says so, but | ||
| 108 | +// a front end that renders statuses (ACP) needs it as a value, not a substring. | ||
| 109 | +func runBash(dir, command string) (output string, failed bool) { | ||
| 110 | + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| 111 | + defer cancel() | ||
| 112 | + | ||
| 113 | + cmd := exec.CommandContext(ctx, "bash", "-c", command) | ||
| 114 | + cmd.Dir = dir | ||
| 115 | + out, err := cmd.CombinedOutput() | ||
| 116 | + output = truncate(string(out)) | ||
| 117 | + | ||
| 118 | + if ctx.Err() == context.DeadlineExceeded { | ||
| 119 | + return output + "\n[error: 30s timeout exceeded]", true | ||
| 120 | + } | ||
| 121 | + if err != nil { | ||
| 122 | + return output + fmt.Sprintf("\n[exit code: %v]", err), true | ||
| 123 | + } | ||
| 124 | + return output, false | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +// Bash declares the `bash` tool. | ||
| 128 | +func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { | ||
| 129 | + | ||
| 130 | + description := `Run a shell command and return its output, plus the exit status when it is not zero. | ||
| 131 | +This is a real bash shell: pipes, redirections, && and any installed command are available. | ||
| 132 | +Each call starts a NEW shell in the same directory, so a "cd" does not carry over to the next call — chain with && instead. | ||
| 133 | +Nothing is interactive: there is no stdin, so never use a command that asks a question. | ||
| 134 | +Long output is truncated, and the text says so when it happens. | ||
| 135 | +Read before you write: if you do not know a path or the content of a file, run a command to find out rather than guessing.` | ||
| 136 | + | ||
| 137 | + return genkit.DefineTool(g, "bash", | ||
| 138 | + description, | ||
| 139 | + func(tctx *ai.ToolContext, in bashInput) (string, error) { | ||
| 140 | + // A front end that consumes events (the ACP façade) gets them | ||
| 141 | + // instead of the prints below; the terminal path is unchanged. | ||
| 142 | + if s := ui.ActiveSink(); s != nil { | ||
| 143 | + return bashEvented(tctx, s, d, in) | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + // Suspend rather than Stop: the model was "Thinking" when it decided | ||
| 147 | + // to call us, and it goes back to thinking as soon as we return the | ||
| 148 | + // output — so the label is restored for the wait that follows. | ||
| 149 | + spinner.Suspend(func() { | ||
| 150 | + printMu.Lock() | ||
| 151 | + fmt.Printf("\n🛠️ bash: %s\n", in.Command) | ||
| 152 | + printMu.Unlock() | ||
| 153 | + }) | ||
| 154 | + | ||
| 155 | + // The command has its own 30s budget, and a slow one looks exactly | ||
| 156 | + // like a hung agent. Retitle for the duration, then hand the line | ||
| 157 | + // back to the "Thinking" wait Suspend restored. | ||
| 158 | + spinner.Start("Running") | ||
| 159 | + out, _ := runBash("", in.Command) | ||
| 160 | + | ||
| 161 | + // The output is shown to the user BEFORE going back to the model: | ||
| 162 | + // what the command answered no longer depends on what the model | ||
| 163 | + // chooses to say about it. | ||
| 164 | + spinner.Suspend(func() { | ||
| 165 | + echoOutput(out) | ||
| 166 | + }) | ||
| 167 | + | ||
| 168 | + spinner.Start("Thinking") | ||
| 169 | + | ||
| 170 | + // The loop detector sees the command AND its output: the same pair | ||
| 171 | + // twice in a row, and it hands the model back an instruction to | ||
| 172 | + // change its approach (see internal/detector). | ||
| 173 | + if d.Record(detector.Action{ | ||
| 174 | + ToolName: "bash", | ||
| 175 | + Input: in.Command, | ||
| 176 | + Output: out, | ||
| 177 | + }) { | ||
| 178 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + return out, nil | ||
| 182 | + }) | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +type bashInput struct { | ||
| 186 | + Command string `json:"command" jsonschema_description:"The shell command to run"` | ||
| 187 | +} | ||
| 188 | + | ||
| 189 | +// bashEvented is the `bash` tool when a front end consumes events (ACP): the | ||
| 190 | +// 🛠️ line becomes ToolStart, the grey echo becomes ToolEnd's output — and one | ||
| 191 | +// thing exists here that the terminal never had: the command does not run until | ||
| 192 | +// the client's user allowed it. The refusal goes back to the model AS TEXT, | ||
| 193 | +// like every normal failure in this package, so it can propose something else | ||
| 194 | +// instead of crashing the turn. | ||
| 195 | +func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in bashInput) (string, error) { | ||
| 196 | + ev := ui.ToolEvent{ | ||
| 197 | + ID: ui.NextCallID(), | ||
| 198 | + Tool: "bash", | ||
| 199 | + Title: "bash: " + in.Command, | ||
| 200 | + Kind: "execute", | ||
| 201 | + Input: map[string]any{"command": in.Command}, | ||
| 202 | + } | ||
| 203 | + s.ToolStart(ev) | ||
| 204 | + | ||
| 205 | + if !s.Allow(ctx, ev) { | ||
| 206 | + out := "[command rejected by the user]" | ||
| 207 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 208 | + // Recorded like any other outcome: a model that keeps retrying a | ||
| 209 | + // command the user keeps rejecting is looping. | ||
| 210 | + return record(d, "bash", in.Command, out), nil | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + s.ToolRunning(ev.ID) | ||
| 214 | + // The session's working directory, not the process's: in an editor, the | ||
| 215 | + // project the user opened is where the commands belong. | ||
| 216 | + out, failed := runBash(s.WorkDir(), in.Command) | ||
| 217 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) | ||
| 218 | + | ||
| 219 | + return record(d, "bash", in.Command, out), nil | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +// --- read_skill -------------------------------------------------------------- | ||
| 223 | + | ||
| 224 | +type readSkillInput struct { | ||
| 225 | + Name string `json:"name" jsonschema_description:"The skill name, exactly as listed in this tool's description (for example \"go-rename\")"` | ||
| 226 | +} | ||
| 227 | + | ||
| 228 | +// ReadSkill declares the `read_skill` tool: it loads one markdown procedure | ||
| 229 | +// from the skills directory. | ||
| 230 | +// | ||
| 231 | +// The whole point is in the DESCRIPTION: it is built from the front matter of | ||
| 232 | +// the files on disk, so the catalogue the model sees is always the directory's | ||
| 233 | +// real content. Nothing to keep in sync by hand — and, unlike a paragraph in | ||
| 234 | +// the system prompt, loading a skill becomes a tool the model can SEE. | ||
| 235 | +// | ||
| 236 | +// Returns nil when the directory holds no skill: an agent with an empty | ||
| 237 | +// catalogue should not advertise the tool at all. | ||
| 238 | +func ReadSkill(g *genkit.Genkit, dir string, d *detector.LoopDetector) ai.ToolRef { | ||
| 239 | + list := skills.List(dir) | ||
| 240 | + if len(list) == 0 { | ||
| 241 | + return nil | ||
| 242 | + } | ||
| 243 | + | ||
| 244 | + return genkit.DefineTool(g, "read_skill", | ||
| 245 | + skills.Catalogue(list), | ||
| 246 | + func(_ *ai.ToolContext, in readSkillInput) (string, error) { | ||
| 247 | + // Event-consuming front end (ACP): same information, as a tool_call. | ||
| 248 | + if s := ui.ActiveSink(); s != nil { | ||
| 249 | + return readSkillEvented(s, d, dir, list, in) | ||
| 250 | + } | ||
| 251 | + | ||
| 252 | + // Same reasoning as `bash`: announce the call without losing the | ||
| 253 | + // "Thinking" wait that resumes the moment the skill is returned. | ||
| 254 | + // Reading a file is fast, so there is no second label here. | ||
| 255 | + spinner.Suspend(func() { | ||
| 256 | + printMu.Lock() | ||
| 257 | + fmt.Printf("\n📖 read_skill: %s\n", in.Name) | ||
| 258 | + printMu.Unlock() | ||
| 259 | + }) | ||
| 260 | + | ||
| 261 | + content, err := skills.Read(dir, in.Name) | ||
| 262 | + if err != nil { | ||
| 263 | + // Unknown skill: information, not a crash. The list of valid | ||
| 264 | + // names goes back AS TEXT so the model can correct itself. | ||
| 265 | + // | ||
| 266 | + // Recorded in the detector like everything else: a model | ||
| 267 | + // asking four times for the same missing skill is looping, | ||
| 268 | + // exactly as it would on a failing command. | ||
| 269 | + out := fmt.Sprintf("No skill named %q. Available skills: %s", | ||
| 270 | + in.Name, strings.Join(skills.Names(list), ", ")) | ||
| 271 | + if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { | ||
| 272 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil | ||
| 273 | + } | ||
| 274 | + return out, nil | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + // The content is NOT displayed: a skill runs to dozens of lines, it | ||
| 278 | + // is written for the model, and the user already has the useful | ||
| 279 | + // information — which one was loaded. What they want to see are the | ||
| 280 | + // commands that follow from it, and those will show up. | ||
| 281 | + out := truncate(content) | ||
| 282 | + if d.Record(detector.Action{ToolName: "read_skill", Input: in.Name, Output: out}) { | ||
| 283 | + return fmt.Sprintf("%s\n\n%s", out, d.LoopError()), nil | ||
| 284 | + } | ||
| 285 | + return out, nil | ||
| 286 | + }) | ||
| 287 | +} | ||
| 288 | + | ||
| 289 | +// readSkillEvented is `read_skill` for an event-consuming front end. No | ||
| 290 | +// permission dialog: it reads a procedure the agent ships, nothing more — and a | ||
| 291 | +// dialog for every skill would teach the user to click "allow" without reading, | ||
| 292 | +// which is worse than no dialog at all. The client still sees the call, kind | ||
| 293 | +// "read", with the loaded content as its output. | ||
| 294 | +func readSkillEvented(s ui.Sink, d *detector.LoopDetector, dir string, list []skills.Skill, in readSkillInput) (string, error) { | ||
| 295 | + ev := ui.ToolEvent{ | ||
| 296 | + ID: ui.NextCallID(), | ||
| 297 | + Tool: "read_skill", | ||
| 298 | + Title: "read_skill: " + in.Name, | ||
| 299 | + Kind: "read", | ||
| 300 | + Input: map[string]any{"name": in.Name}, | ||
| 301 | + } | ||
| 302 | + s.ToolStart(ev) | ||
| 303 | + s.ToolRunning(ev.ID) | ||
| 304 | + | ||
| 305 | + content, err := skills.Read(dir, in.Name) | ||
| 306 | + if err != nil { | ||
| 307 | + out := fmt.Sprintf("No skill named %q. Available skills: %s", | ||
| 308 | + in.Name, strings.Join(skills.Names(list), ", ")) | ||
| 309 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: true}) | ||
| 310 | + return record(d, "read_skill", in.Name, out), nil | ||
| 311 | + } | ||
| 312 | + | ||
| 313 | + out := truncate(content) | ||
| 314 | + s.ToolEnd(ev.ID, ui.ToolResult{Output: out}) | ||
| 315 | + return record(d, "read_skill", in.Name, out), nil | ||
| 316 | +} | ||
added
internal/ui/ui.go +120 -0 | new file mode 100644 | ||
| @@ -0,0 +1,120 @@ | ||
| 1 | +// Package ui decides where the agent's human-facing output goes, and — when a | |
| 2 | +// front end wants events rather than text — carries those events as a Sink. | |
| 3 | +// | |
| 4 | +// Two front ends share the same engine and the same tools: | |
| 5 | +// | |
| 6 | +// - the terminal REPL (internal/agent): Sink stays nil, Out is os.Stdout, | |
| 7 | +// and the tools print their own 🛠️/📄/📝 lines exactly as in part 10; | |
| 8 | +// - the ACP façade (internal/acp): stdout belongs to JSON-RPC — the spec says | |
| 9 | +// the agent "MUST NOT write anything to its stdout that is not a valid ACP | |
| 10 | +// message" — so main.go moves Out to os.Stderr, and the façade installs a | |
| 11 | +// Sink that turns every event into a session/update notification. | |
| 12 | +// | |
| 13 | +// The package holds no protocol knowledge: the event names below are bob's | |
| 14 | +// (a tool starts, a tool ends, may I run this?), and internal/acp is the one | |
| 15 | +// place that translates them into ACP messages. | |
| 16 | +package ui | |
| 17 | + | |
| 18 | +import ( | |
| 19 | + "context" | |
| 20 | + "fmt" | |
| 21 | + "io" | |
| 22 | + "os" | |
| 23 | + "path/filepath" | |
| 24 | + "sync" | |
| 25 | + "sync/atomic" | |
| 26 | +) | |
| 27 | + | |
| 28 | +// Out is where human-facing text goes when no Sink is active: the banner, the | |
| 29 | +// warnings, the watchdog message. os.Stdout for the terminal REPL; main.go | |
| 30 | +// moves it to os.Stderr in ACP mode, so everything already written for the | |
| 31 | +// terminal stays readable — in the editor's agent logs instead of the screen. | |
| 32 | +var Out io.Writer = os.Stdout | |
| 33 | + | |
| 34 | +// ToolEvent describes one tool call, in bob's vocabulary. | |
| 35 | +type ToolEvent struct { | |
| 36 | + ID string // "call_1" — unique within the process, see NextCallID | |
| 37 | + Tool string // the tool's name: "bash", "read_file", … | |
| 38 | + Title string // one line for the client's UI: "bash: ls -1" | |
| 39 | + Kind string // how a client should render it: "execute", "read", "edit" | |
| 40 | + Input map[string]any // the raw input, for the client to display | |
| 41 | + Path string // the file touched (absolute), "" when none | |
| 42 | +} | |
| 43 | + | |
| 44 | +// ToolResult is what a tool call ended with. | |
| 45 | +type ToolResult struct { | |
| 46 | + Output string // what goes back to the model (already truncated) | |
| 47 | + Failed bool // non-zero exit, refused edit, rejected permission… | |
| 48 | + Diff *Diff // the file change, when there is one a client can render | |
| 49 | +} | |
| 50 | + | |
| 51 | +// Diff carries a file change for clients able to show a real diff (ACP has a | |
| 52 | +// dedicated content type for it — that is how Zed renders edits). | |
| 53 | +type Diff struct { | |
| 54 | + Path string // absolute | |
| 55 | + OldText string // ignored when Created | |
| 56 | + NewText string | |
| 57 | + Created bool // the file did not exist before | |
| 58 | +} | |
| 59 | + | |
| 60 | +// Sink receives the events of a turn. nil (the default) means the terminal | |
| 61 | +// front end: the tools print themselves and nobody asks for permission. | |
| 62 | +type Sink interface { | |
| 63 | + // Text is a chunk of the model's streamed answer. | |
| 64 | + Text(text string) | |
| 65 | + // ToolStart announces a call before anything runs (ACP: status "pending"). | |
| 66 | + ToolStart(ev ToolEvent) | |
| 67 | + // Allow asks whether the call may run. The terminal front end has no | |
| 68 | + // gatekeeper and would always say yes; a client shows the user a dialog. | |
| 69 | + // ctx is the turn's context: a cancelled turn abandons the question. | |
| 70 | + Allow(ctx context.Context, ev ToolEvent) bool | |
| 71 | + // ToolRunning reports that the call was allowed and is executing. | |
| 72 | + ToolRunning(id string) | |
| 73 | + // ToolEnd reports the outcome, output included. | |
| 74 | + ToolEnd(id string, res ToolResult) | |
| 75 | + // WorkDir is the session's working directory ("" = the process's own): | |
| 76 | + // where bash runs and what relative file paths resolve against. | |
| 77 | + WorkDir() string | |
| 78 | +} | |
| 79 | + | |
| 80 | +// The active sink is set once per turn by the ACP façade and read by the tools | |
| 81 | +// from Genkit's goroutines — hence the lock, not a plain variable. | |
| 82 | +var ( | |
| 83 | + mu sync.RWMutex | |
| 84 | + active Sink | |
| 85 | +) | |
| 86 | + | |
| 87 | +// SetActive installs (or, with nil, removes) the sink for the current turn. | |
| 88 | +func SetActive(s Sink) { | |
| 89 | + mu.Lock() | |
| 90 | + active = s | |
| 91 | + mu.Unlock() | |
| 92 | +} | |
| 93 | + | |
| 94 | +// ActiveSink returns the sink of the current turn, nil for the terminal. | |
| 95 | +func ActiveSink() Sink { | |
| 96 | + mu.RLock() | |
| 97 | + defer mu.RUnlock() | |
| 98 | + return active | |
| 99 | +} | |
| 100 | + | |
| 101 | +// callID numbers the tool calls of the process. Never reset: a client | |
| 102 | +// correlates tool_call and tool_call_update by this ID, and reusing one across | |
| 103 | +// sessions would merge two calls in its UI. | |
| 104 | +var callID atomic.Int64 | |
| 105 | + | |
| 106 | +// NextCallID returns "call_1", "call_2", … | |
| 107 | +func NextCallID() string { | |
| 108 | + return fmt.Sprintf("call_%d", callID.Add(1)) | |
| 109 | +} | |
| 110 | + | |
| 111 | +// Resolve makes a relative path absolute against the active session's working | |
| 112 | +// directory. With no sink — the terminal REPL — the path is returned as-is: | |
| 113 | +// part 10's behaviour, relative to the process's directory, unchanged. | |
| 114 | +func Resolve(path string) string { | |
| 115 | + s := ActiveSink() | |
| 116 | + if s == nil || filepath.IsAbs(path) || s.WorkDir() == "" { | |
| 117 | + return path | |
| 118 | + } | |
| 119 | + return filepath.Join(s.WorkDir(), path) | |
| 120 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,120 @@ | |||
| 1 | +// Package ui decides where the agent's human-facing output goes, and — when a | ||
| 2 | +// front end wants events rather than text — carries those events as a Sink. | ||
| 3 | +// | ||
| 4 | +// Two front ends share the same engine and the same tools: | ||
| 5 | +// | ||
| 6 | +// - the terminal REPL (internal/agent): Sink stays nil, Out is os.Stdout, | ||
| 7 | +// and the tools print their own 🛠️/📄/📝 lines exactly as in part 10; | ||
| 8 | +// - the ACP façade (internal/acp): stdout belongs to JSON-RPC — the spec says | ||
| 9 | +// the agent "MUST NOT write anything to its stdout that is not a valid ACP | ||
| 10 | +// message" — so main.go moves Out to os.Stderr, and the façade installs a | ||
| 11 | +// Sink that turns every event into a session/update notification. | ||
| 12 | +// | ||
| 13 | +// The package holds no protocol knowledge: the event names below are bob's | ||
| 14 | +// (a tool starts, a tool ends, may I run this?), and internal/acp is the one | ||
| 15 | +// place that translates them into ACP messages. | ||
| 16 | +package ui | ||
| 17 | + | ||
| 18 | +import ( | ||
| 19 | + "context" | ||
| 20 | + "fmt" | ||
| 21 | + "io" | ||
| 22 | + "os" | ||
| 23 | + "path/filepath" | ||
| 24 | + "sync" | ||
| 25 | + "sync/atomic" | ||
| 26 | +) | ||
| 27 | + | ||
| 28 | +// Out is where human-facing text goes when no Sink is active: the banner, the | ||
| 29 | +// warnings, the watchdog message. os.Stdout for the terminal REPL; main.go | ||
| 30 | +// moves it to os.Stderr in ACP mode, so everything already written for the | ||
| 31 | +// terminal stays readable — in the editor's agent logs instead of the screen. | ||
| 32 | +var Out io.Writer = os.Stdout | ||
| 33 | + | ||
| 34 | +// ToolEvent describes one tool call, in bob's vocabulary. | ||
| 35 | +type ToolEvent struct { | ||
| 36 | + ID string // "call_1" — unique within the process, see NextCallID | ||
| 37 | + Tool string // the tool's name: "bash", "read_file", … | ||
| 38 | + Title string // one line for the client's UI: "bash: ls -1" | ||
| 39 | + Kind string // how a client should render it: "execute", "read", "edit" | ||
| 40 | + Input map[string]any // the raw input, for the client to display | ||
| 41 | + Path string // the file touched (absolute), "" when none | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// ToolResult is what a tool call ended with. | ||
| 45 | +type ToolResult struct { | ||
| 46 | + Output string // what goes back to the model (already truncated) | ||
| 47 | + Failed bool // non-zero exit, refused edit, rejected permission… | ||
| 48 | + Diff *Diff // the file change, when there is one a client can render | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// Diff carries a file change for clients able to show a real diff (ACP has a | ||
| 52 | +// dedicated content type for it — that is how Zed renders edits). | ||
| 53 | +type Diff struct { | ||
| 54 | + Path string // absolute | ||
| 55 | + OldText string // ignored when Created | ||
| 56 | + NewText string | ||
| 57 | + Created bool // the file did not exist before | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// Sink receives the events of a turn. nil (the default) means the terminal | ||
| 61 | +// front end: the tools print themselves and nobody asks for permission. | ||
| 62 | +type Sink interface { | ||
| 63 | + // Text is a chunk of the model's streamed answer. | ||
| 64 | + Text(text string) | ||
| 65 | + // ToolStart announces a call before anything runs (ACP: status "pending"). | ||
| 66 | + ToolStart(ev ToolEvent) | ||
| 67 | + // Allow asks whether the call may run. The terminal front end has no | ||
| 68 | + // gatekeeper and would always say yes; a client shows the user a dialog. | ||
| 69 | + // ctx is the turn's context: a cancelled turn abandons the question. | ||
| 70 | + Allow(ctx context.Context, ev ToolEvent) bool | ||
| 71 | + // ToolRunning reports that the call was allowed and is executing. | ||
| 72 | + ToolRunning(id string) | ||
| 73 | + // ToolEnd reports the outcome, output included. | ||
| 74 | + ToolEnd(id string, res ToolResult) | ||
| 75 | + // WorkDir is the session's working directory ("" = the process's own): | ||
| 76 | + // where bash runs and what relative file paths resolve against. | ||
| 77 | + WorkDir() string | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +// The active sink is set once per turn by the ACP façade and read by the tools | ||
| 81 | +// from Genkit's goroutines — hence the lock, not a plain variable. | ||
| 82 | +var ( | ||
| 83 | + mu sync.RWMutex | ||
| 84 | + active Sink | ||
| 85 | +) | ||
| 86 | + | ||
| 87 | +// SetActive installs (or, with nil, removes) the sink for the current turn. | ||
| 88 | +func SetActive(s Sink) { | ||
| 89 | + mu.Lock() | ||
| 90 | + active = s | ||
| 91 | + mu.Unlock() | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// ActiveSink returns the sink of the current turn, nil for the terminal. | ||
| 95 | +func ActiveSink() Sink { | ||
| 96 | + mu.RLock() | ||
| 97 | + defer mu.RUnlock() | ||
| 98 | + return active | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +// callID numbers the tool calls of the process. Never reset: a client | ||
| 102 | +// correlates tool_call and tool_call_update by this ID, and reusing one across | ||
| 103 | +// sessions would merge two calls in its UI. | ||
| 104 | +var callID atomic.Int64 | ||
| 105 | + | ||
| 106 | +// NextCallID returns "call_1", "call_2", … | ||
| 107 | +func NextCallID() string { | ||
| 108 | + return fmt.Sprintf("call_%d", callID.Add(1)) | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +// Resolve makes a relative path absolute against the active session's working | ||
| 112 | +// directory. With no sink — the terminal REPL — the path is returned as-is: | ||
| 113 | +// part 10's behaviour, relative to the process's directory, unchanged. | ||
| 114 | +func Resolve(path string) string { | ||
| 115 | + s := ActiveSink() | ||
| 116 | + if s == nil || filepath.IsAbs(path) || s.WorkDir() == "" { | ||
| 117 | + return path | ||
| 118 | + } | ||
| 119 | + return filepath.Join(s.WorkDir(), path) | ||
| 120 | +} | ||
added
internal/ui/ui_test.go +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +package ui | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "strings" | |
| 6 | + "testing" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// stubSink is the smallest Sink that can carry a working directory. | |
| 10 | +type stubSink struct{ cwd string } | |
| 11 | + | |
| 12 | +func (stubSink) Text(string) {} | |
| 13 | +func (stubSink) ToolStart(ToolEvent) {} | |
| 14 | +func (stubSink) Allow(context.Context, ToolEvent) bool { return true } | |
| 15 | +func (stubSink) ToolRunning(string) {} | |
| 16 | +func (stubSink) ToolEnd(string, ToolResult) {} | |
| 17 | +func (s stubSink) WorkDir() string { return s.cwd } | |
| 18 | + | |
| 19 | +// With no sink — the terminal front end — Resolve must be the identity: part | |
| 20 | +// 10's behaviour, paths relative to the process, unchanged. | |
| 21 | +func TestResolveWithoutSinkIsIdentity(t *testing.T) { | |
| 22 | + SetActive(nil) | |
| 23 | + for _, p := range []string{"hello/main.go", "../x", "/abs/path"} { | |
| 24 | + if got := Resolve(p); got != p { | |
| 25 | + t.Errorf("Resolve(%q) = %q, want unchanged", p, got) | |
| 26 | + } | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +// With a sink, a relative path follows the SESSION's working directory — the | |
| 31 | +// project open in the editor — and an absolute one is left alone. | |
| 32 | +func TestResolveWithSinkFollowsTheSession(t *testing.T) { | |
| 33 | + SetActive(stubSink{cwd: "/work/project"}) | |
| 34 | + defer SetActive(nil) | |
| 35 | + | |
| 36 | + if got := Resolve("hello/main.go"); got != "/work/project/hello/main.go" { | |
| 37 | + t.Errorf("relative path: got %q", got) | |
| 38 | + } | |
| 39 | + if got := Resolve("/etc/hosts"); got != "/etc/hosts" { | |
| 40 | + t.Errorf("absolute path must not move: got %q", got) | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +// Call IDs must never repeat: an ACP client correlates tool_call and | |
| 45 | +// tool_call_update by them, and a duplicate would merge two calls in its UI. | |
| 46 | +func TestNextCallIDNeverRepeats(t *testing.T) { | |
| 47 | + a, b := NextCallID(), NextCallID() | |
| 48 | + if a == b { | |
| 49 | + t.Fatalf("two calls, same id: %q", a) | |
| 50 | + } | |
| 51 | + if !strings.HasPrefix(a, "call_") { | |
| 52 | + t.Fatalf("unexpected shape: %q", a) | |
| 53 | + } | |
| 54 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +package ui | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "strings" | ||
| 6 | + "testing" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// stubSink is the smallest Sink that can carry a working directory. | ||
| 10 | +type stubSink struct{ cwd string } | ||
| 11 | + | ||
| 12 | +func (stubSink) Text(string) {} | ||
| 13 | +func (stubSink) ToolStart(ToolEvent) {} | ||
| 14 | +func (stubSink) Allow(context.Context, ToolEvent) bool { return true } | ||
| 15 | +func (stubSink) ToolRunning(string) {} | ||
| 16 | +func (stubSink) ToolEnd(string, ToolResult) {} | ||
| 17 | +func (s stubSink) WorkDir() string { return s.cwd } | ||
| 18 | + | ||
| 19 | +// With no sink — the terminal front end — Resolve must be the identity: part | ||
| 20 | +// 10's behaviour, paths relative to the process, unchanged. | ||
| 21 | +func TestResolveWithoutSinkIsIdentity(t *testing.T) { | ||
| 22 | + SetActive(nil) | ||
| 23 | + for _, p := range []string{"hello/main.go", "../x", "/abs/path"} { | ||
| 24 | + if got := Resolve(p); got != p { | ||
| 25 | + t.Errorf("Resolve(%q) = %q, want unchanged", p, got) | ||
| 26 | + } | ||
| 27 | + } | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +// With a sink, a relative path follows the SESSION's working directory — the | ||
| 31 | +// project open in the editor — and an absolute one is left alone. | ||
| 32 | +func TestResolveWithSinkFollowsTheSession(t *testing.T) { | ||
| 33 | + SetActive(stubSink{cwd: "/work/project"}) | ||
| 34 | + defer SetActive(nil) | ||
| 35 | + | ||
| 36 | + if got := Resolve("hello/main.go"); got != "/work/project/hello/main.go" { | ||
| 37 | + t.Errorf("relative path: got %q", got) | ||
| 38 | + } | ||
| 39 | + if got := Resolve("/etc/hosts"); got != "/etc/hosts" { | ||
| 40 | + t.Errorf("absolute path must not move: got %q", got) | ||
| 41 | + } | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// Call IDs must never repeat: an ACP client correlates tool_call and | ||
| 45 | +// tool_call_update by them, and a duplicate would merge two calls in its UI. | ||
| 46 | +func TestNextCallIDNeverRepeats(t *testing.T) { | ||
| 47 | + a, b := NextCallID(), NextCallID() | ||
| 48 | + if a == b { | ||
| 49 | + t.Fatalf("two calls, same id: %q", a) | ||
| 50 | + } | ||
| 51 | + if !strings.HasPrefix(a, "call_") { | ||
| 52 | + t.Fatalf("unexpected shape: %q", a) | ||
| 53 | + } | ||
| 54 | +} | ||
added
main.go +195 -0 | new file mode 100644 | ||
| @@ -0,0 +1,195 @@ | ||
| 1 | +// Minimal coding agent: an agent loop with built-in tools — `bash`, | |
| 2 | +// `read_skill`, and (switchable) `read_file`, `write_file`, `edit_file`. | |
| 3 | +// | |
| 4 | +// LLM engine: any provider of internal/engine — Docker Model Runner by default, | |
| 5 | +// llama-server with `provider: llamacpp` — all through the OpenAI-compatible API. | |
| 6 | +// | |
| 7 | +// The code is split into packages (internal/ directory): | |
| 8 | +// - config: every setting, with YAML overrides (see agent.yaml); | |
| 9 | +// - detector: repeated-action detection (the logical loop); | |
| 10 | +// - engine: connection to the LLM engine + generation (streaming + fallback), | |
| 11 | +// with one Provider per kind of server; | |
| 12 | +// - skills: the markdown procedures of ./skills, and their catalogue; | |
| 13 | +// - mention: the @path notation of a question, shared by both front ends; | |
| 14 | +// - fileedit: exact-replacement file editing, the rules of the `edit` CLI; | |
| 15 | +// - tools: the built-in tools (`bash`, `read_skill`, the file tools); | |
| 16 | +// - ui: where the human-facing output goes, and the event sink the | |
| 17 | +// two front ends share; | |
| 18 | +// - session: what a conversation starts from, and the /new command that | |
| 19 | +// takes it back there — shared by the two front ends; | |
| 20 | +// - agent: the terminal front end (the REPL); | |
| 21 | +// - acp: the editor front end — the same loop behind the Agent Client | |
| 22 | +// Protocol (`bob --acp`), for Zed, JetBrains, Neovim… | |
| 23 | +// | |
| 24 | +// main() only does the WIRING: load the settings, engine init, tool list, then | |
| 25 | +// starting the loop. | |
| 26 | +package main | |
| 27 | + | |
| 28 | +import ( | |
| 29 | + "context" | |
| 30 | + "flag" | |
| 31 | + "fmt" | |
| 32 | + "os" | |
| 33 | + "path/filepath" | |
| 34 | + "strings" | |
| 35 | + | |
| 36 | + "mm/internal/acp" | |
| 37 | + "mm/internal/agent" | |
| 38 | + "mm/internal/config" | |
| 39 | + "mm/internal/detector" | |
| 40 | + "mm/internal/engine" | |
| 41 | + "mm/internal/skills" | |
| 42 | + "mm/internal/spinner" | |
| 43 | + "mm/internal/tools" | |
| 44 | + "mm/internal/ui" | |
| 45 | + | |
| 46 | + "github.com/firebase/genkit/go/ai" | |
| 47 | +) | |
| 48 | + | |
| 49 | +func main() { | |
| 50 | + ctx := context.Background() | |
| 51 | + | |
| 52 | + // Where the config file is. Same value, three ways to give it, from the most | |
| 53 | + // local to the most ambient: | |
| 54 | + // | |
| 55 | + // go run . -config ./fast.yaml (the flag) | |
| 56 | + // go run . ./fast.yaml (a lone argument, handy in a demo) | |
| 57 | + // AGENT_CONFIG=./fast.yaml go run . | |
| 58 | + // | |
| 59 | + // The flag package gives us "-h" for free; a second argument is a typo, and | |
| 60 | + // saying so is better than ignoring it. | |
| 61 | + configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)") | |
| 62 | + acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL") | |
| 63 | + flag.Parse() | |
| 64 | + if *configPath == "" && flag.NArg() > 0 { | |
| 65 | + *configPath = flag.Arg(0) | |
| 66 | + } | |
| 67 | + if flag.NArg() > 1 { | |
| 68 | + fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]") | |
| 69 | + os.Exit(1) | |
| 70 | + } | |
| 71 | + | |
| 72 | + // In ACP mode, stdout belongs to JSON-RPC — the spec forbids anything else | |
| 73 | + // on it. Everything below that prints (banner, warnings, errors) goes | |
| 74 | + // through ui.Out, so ONE move sends it all to stderr, where the editor's | |
| 75 | + // agent logs pick it up. The spinner is silenced outright: supported() | |
| 76 | + // cannot tell "a terminal" from "a terminal used as a protocol pipe". | |
| 77 | + if *acpMode { | |
| 78 | + ui.Out = os.Stderr | |
| 79 | + spinner.Disable() | |
| 80 | + } | |
| 81 | + | |
| 82 | + // Settings: built-in defaults, overridden by the YAML file if there is one. | |
| 83 | + path, err := config.Load(*configPath) | |
| 84 | + if err != nil { | |
| 85 | + fmt.Fprintln(ui.Out, "[config error:", err, "]") | |
| 86 | + os.Exit(1) | |
| 87 | + } | |
| 88 | + | |
| 89 | + // Where a relative skillsDir points depends on who started mm. From a | |
| 90 | + // terminal, it is the directory mm was started from — an installed mm | |
| 91 | + // (/usr/local/bin) run inside a project loads THAT project's skills. The | |
| 92 | + // editor launches mm with ITS working directory, so in ACP mode with a | |
| 93 | + // config file the anchor is that file — the one path the user actually | |
| 94 | + // named (env AGENT_CONFIG in the editor's settings). Either way the path | |
| 95 | + // is made absolute here, so the banner can say exactly where it looked. | |
| 96 | + config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode) | |
| 97 | + | |
| 98 | + e, err := engine.New(ctx) | |
| 99 | + if err != nil { | |
| 100 | + fmt.Fprintln(ui.Out, "[engine error:", err, "]") | |
| 101 | + os.Exit(1) | |
| 102 | + } | |
| 103 | + | |
| 104 | + // Initialize the global loop detector. | |
| 105 | + loopDetector := detector.NewLoopDetector(10, 3) | |
| 106 | + | |
| 107 | + agentTools := []ai.ToolRef{ | |
| 108 | + tools.Bash(e.G, loopDetector), | |
| 109 | + } | |
| 110 | + | |
| 111 | + // `read_skill` only exists when there is something to read. Its description | |
| 112 | + // carries the catalogue, so declaring it with an empty directory would | |
| 113 | + // advertise a tool that can do nothing. | |
| 114 | + skillCount := len(skills.List(config.Cfg.SkillsDir)) | |
| 115 | + toolNames := []string{"bash"} | |
| 116 | + if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil { | |
| 117 | + agentTools = append(agentTools, skillTool) | |
| 118 | + toolNames = append(toolNames, "read_skill") | |
| 119 | + } | |
| 120 | + | |
| 121 | + // The file tools are a switch, not a given: the same binary must run as | |
| 122 | + // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so | |
| 123 | + // the two can be compared on the same prompts. | |
| 124 | + if config.Cfg.EditTools { | |
| 125 | + agentTools = append(agentTools, | |
| 126 | + tools.ReadFile(e.G, loopDetector), | |
| 127 | + tools.WriteFile(e.G, loopDetector), | |
| 128 | + tools.EditFile(e.G, loopDetector)) | |
| 129 | + toolNames = append(toolNames, "read_file", "write_file", "edit_file") | |
| 130 | + } | |
| 131 | + | |
| 132 | + if path == "" { | |
| 133 | + path = "built-in defaults" | |
| 134 | + } | |
| 135 | + | |
| 136 | + // The probe runs before the banner so its warnings sit right under the | |
| 137 | + // prompt they explain. It never stops the agent: on a demo machine the | |
| 138 | + // server is often started AFTER the agent. | |
| 139 | + info := e.Probe(ctx) | |
| 140 | + ctxCol := "unknown" | |
| 141 | + if info.ContextWindow > 0 { | |
| 142 | + ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource) | |
| 143 | + } | |
| 144 | + // Dimmed on a terminal, plain in a pipe: the agent's output is meant to be | |
| 145 | + // consumed by another program as much as it is meant to be read. | |
| 146 | + dim, off := "", "" | |
| 147 | + if spinner.Styled() { | |
| 148 | + dim, off = "\033[2m", "\033[0m" | |
| 149 | + } | |
| 150 | + // The compression's own warning belongs with the provider's: it is the | |
| 151 | + // same kind of advice, and it depends on what the probe just learned. | |
| 152 | + if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 && c.MaxMessages == 0 { | |
| 153 | + info.Warnings = append(info.Warnings, | |
| 154 | + "context compression is on, but the context window is unknown and context.maxMessages is 0 — it will never trigger (set contextWindow, or maxMessages)") | |
| 155 | + } | |
| 156 | + // "skills: 0" alone sends people hunting; the resolved path says at once | |
| 157 | + // whether mm looked in the wrong place or found the right one empty. | |
| 158 | + if skillCount == 0 { | |
| 159 | + info.Warnings = append(info.Warnings, | |
| 160 | + fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir)) | |
| 161 | + } | |
| 162 | + for _, w := range info.Warnings { | |
| 163 | + fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off) | |
| 164 | + } | |
| 165 | + fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n", | |
| 166 | + dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off) | |
| 167 | + | |
| 168 | + // Two front ends, one wiring: everything above — config, engine, tools, | |
| 169 | + // detector — is strictly identical whichever loop runs below. | |
| 170 | + if *acpMode { | |
| 171 | + acp.Run(ctx, e, config.Cfg.System, agentTools) | |
| 172 | + return | |
| 173 | + } | |
| 174 | + agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector) | |
| 175 | +} | |
| 176 | + | |
| 177 | +// resolveSkillsDir makes the skills directory absolute. An absolute dir is | |
| 178 | +// kept as-is. A relative one follows the config file in ACP mode when there | |
| 179 | +// is one (the editor's working directory is not the project's), and the | |
| 180 | +// current directory otherwise — for the terminal, that is where the user | |
| 181 | +// started mm. | |
| 182 | +func resolveSkillsDir(dir, configPath string, acpMode bool) string { | |
| 183 | + if filepath.IsAbs(dir) { | |
| 184 | + return dir | |
| 185 | + } | |
| 186 | + anchor := "." | |
| 187 | + if acpMode && configPath != "" { | |
| 188 | + anchor = filepath.Dir(configPath) | |
| 189 | + } | |
| 190 | + abs, err := filepath.Abs(filepath.Join(anchor, dir)) | |
| 191 | + if err != nil { | |
| 192 | + return filepath.Join(anchor, dir) | |
| 193 | + } | |
| 194 | + return abs | |
| 195 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,195 @@ | |||
| 1 | +// Minimal coding agent: an agent loop with built-in tools — `bash`, | ||
| 2 | +// `read_skill`, and (switchable) `read_file`, `write_file`, `edit_file`. | ||
| 3 | +// | ||
| 4 | +// LLM engine: any provider of internal/engine — Docker Model Runner by default, | ||
| 5 | +// llama-server with `provider: llamacpp` — all through the OpenAI-compatible API. | ||
| 6 | +// | ||
| 7 | +// The code is split into packages (internal/ directory): | ||
| 8 | +// - config: every setting, with YAML overrides (see agent.yaml); | ||
| 9 | +// - detector: repeated-action detection (the logical loop); | ||
| 10 | +// - engine: connection to the LLM engine + generation (streaming + fallback), | ||
| 11 | +// with one Provider per kind of server; | ||
| 12 | +// - skills: the markdown procedures of ./skills, and their catalogue; | ||
| 13 | +// - mention: the @path notation of a question, shared by both front ends; | ||
| 14 | +// - fileedit: exact-replacement file editing, the rules of the `edit` CLI; | ||
| 15 | +// - tools: the built-in tools (`bash`, `read_skill`, the file tools); | ||
| 16 | +// - ui: where the human-facing output goes, and the event sink the | ||
| 17 | +// two front ends share; | ||
| 18 | +// - session: what a conversation starts from, and the /new command that | ||
| 19 | +// takes it back there — shared by the two front ends; | ||
| 20 | +// - agent: the terminal front end (the REPL); | ||
| 21 | +// - acp: the editor front end — the same loop behind the Agent Client | ||
| 22 | +// Protocol (`bob --acp`), for Zed, JetBrains, Neovim… | ||
| 23 | +// | ||
| 24 | +// main() only does the WIRING: load the settings, engine init, tool list, then | ||
| 25 | +// starting the loop. | ||
| 26 | +package main | ||
| 27 | + | ||
| 28 | +import ( | ||
| 29 | + "context" | ||
| 30 | + "flag" | ||
| 31 | + "fmt" | ||
| 32 | + "os" | ||
| 33 | + "path/filepath" | ||
| 34 | + "strings" | ||
| 35 | + | ||
| 36 | + "mm/internal/acp" | ||
| 37 | + "mm/internal/agent" | ||
| 38 | + "mm/internal/config" | ||
| 39 | + "mm/internal/detector" | ||
| 40 | + "mm/internal/engine" | ||
| 41 | + "mm/internal/skills" | ||
| 42 | + "mm/internal/spinner" | ||
| 43 | + "mm/internal/tools" | ||
| 44 | + "mm/internal/ui" | ||
| 45 | + | ||
| 46 | + "github.com/firebase/genkit/go/ai" | ||
| 47 | +) | ||
| 48 | + | ||
| 49 | +func main() { | ||
| 50 | + ctx := context.Background() | ||
| 51 | + | ||
| 52 | + // Where the config file is. Same value, three ways to give it, from the most | ||
| 53 | + // local to the most ambient: | ||
| 54 | + // | ||
| 55 | + // go run . -config ./fast.yaml (the flag) | ||
| 56 | + // go run . ./fast.yaml (a lone argument, handy in a demo) | ||
| 57 | + // AGENT_CONFIG=./fast.yaml go run . | ||
| 58 | + // | ||
| 59 | + // The flag package gives us "-h" for free; a second argument is a typo, and | ||
| 60 | + // saying so is better than ignoring it. | ||
| 61 | + configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)") | ||
| 62 | + acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL") | ||
| 63 | + flag.Parse() | ||
| 64 | + if *configPath == "" && flag.NArg() > 0 { | ||
| 65 | + *configPath = flag.Arg(0) | ||
| 66 | + } | ||
| 67 | + if flag.NArg() > 1 { | ||
| 68 | + fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]") | ||
| 69 | + os.Exit(1) | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + // In ACP mode, stdout belongs to JSON-RPC — the spec forbids anything else | ||
| 73 | + // on it. Everything below that prints (banner, warnings, errors) goes | ||
| 74 | + // through ui.Out, so ONE move sends it all to stderr, where the editor's | ||
| 75 | + // agent logs pick it up. The spinner is silenced outright: supported() | ||
| 76 | + // cannot tell "a terminal" from "a terminal used as a protocol pipe". | ||
| 77 | + if *acpMode { | ||
| 78 | + ui.Out = os.Stderr | ||
| 79 | + spinner.Disable() | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + // Settings: built-in defaults, overridden by the YAML file if there is one. | ||
| 83 | + path, err := config.Load(*configPath) | ||
| 84 | + if err != nil { | ||
| 85 | + fmt.Fprintln(ui.Out, "[config error:", err, "]") | ||
| 86 | + os.Exit(1) | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + // Where a relative skillsDir points depends on who started mm. From a | ||
| 90 | + // terminal, it is the directory mm was started from — an installed mm | ||
| 91 | + // (/usr/local/bin) run inside a project loads THAT project's skills. The | ||
| 92 | + // editor launches mm with ITS working directory, so in ACP mode with a | ||
| 93 | + // config file the anchor is that file — the one path the user actually | ||
| 94 | + // named (env AGENT_CONFIG in the editor's settings). Either way the path | ||
| 95 | + // is made absolute here, so the banner can say exactly where it looked. | ||
| 96 | + config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode) | ||
| 97 | + | ||
| 98 | + e, err := engine.New(ctx) | ||
| 99 | + if err != nil { | ||
| 100 | + fmt.Fprintln(ui.Out, "[engine error:", err, "]") | ||
| 101 | + os.Exit(1) | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + // Initialize the global loop detector. | ||
| 105 | + loopDetector := detector.NewLoopDetector(10, 3) | ||
| 106 | + | ||
| 107 | + agentTools := []ai.ToolRef{ | ||
| 108 | + tools.Bash(e.G, loopDetector), | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + // `read_skill` only exists when there is something to read. Its description | ||
| 112 | + // carries the catalogue, so declaring it with an empty directory would | ||
| 113 | + // advertise a tool that can do nothing. | ||
| 114 | + skillCount := len(skills.List(config.Cfg.SkillsDir)) | ||
| 115 | + toolNames := []string{"bash"} | ||
| 116 | + if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil { | ||
| 117 | + agentTools = append(agentTools, skillTool) | ||
| 118 | + toolNames = append(toolNames, "read_skill") | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + // The file tools are a switch, not a given: the same binary must run as | ||
| 122 | + // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so | ||
| 123 | + // the two can be compared on the same prompts. | ||
| 124 | + if config.Cfg.EditTools { | ||
| 125 | + agentTools = append(agentTools, | ||
| 126 | + tools.ReadFile(e.G, loopDetector), | ||
| 127 | + tools.WriteFile(e.G, loopDetector), | ||
| 128 | + tools.EditFile(e.G, loopDetector)) | ||
| 129 | + toolNames = append(toolNames, "read_file", "write_file", "edit_file") | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + if path == "" { | ||
| 133 | + path = "built-in defaults" | ||
| 134 | + } | ||
| 135 | + | ||
| 136 | + // The probe runs before the banner so its warnings sit right under the | ||
| 137 | + // prompt they explain. It never stops the agent: on a demo machine the | ||
| 138 | + // server is often started AFTER the agent. | ||
| 139 | + info := e.Probe(ctx) | ||
| 140 | + ctxCol := "unknown" | ||
| 141 | + if info.ContextWindow > 0 { | ||
| 142 | + ctxCol = fmt.Sprintf("%d (%s)", info.ContextWindow, info.ContextSource) | ||
| 143 | + } | ||
| 144 | + // Dimmed on a terminal, plain in a pipe: the agent's output is meant to be | ||
| 145 | + // consumed by another program as much as it is meant to be read. | ||
| 146 | + dim, off := "", "" | ||
| 147 | + if spinner.Styled() { | ||
| 148 | + dim, off = "\033[2m", "\033[0m" | ||
| 149 | + } | ||
| 150 | + // The compression's own warning belongs with the provider's: it is the | ||
| 151 | + // same kind of advice, and it depends on what the probe just learned. | ||
| 152 | + if c := config.Cfg.Context; c.Enabled && e.ContextWindow == 0 && c.MaxMessages == 0 { | ||
| 153 | + info.Warnings = append(info.Warnings, | ||
| 154 | + "context compression is on, but the context window is unknown and context.maxMessages is 0 — it will never trigger (set contextWindow, or maxMessages)") | ||
| 155 | + } | ||
| 156 | + // "skills: 0" alone sends people hunting; the resolved path says at once | ||
| 157 | + // whether mm looked in the wrong place or found the right one empty. | ||
| 158 | + if skillCount == 0 { | ||
| 159 | + info.Warnings = append(info.Warnings, | ||
| 160 | + fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir)) | ||
| 161 | + } | ||
| 162 | + for _, w := range info.Warnings { | ||
| 163 | + fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off) | ||
| 164 | + } | ||
| 165 | + fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n", | ||
| 166 | + dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off) | ||
| 167 | + | ||
| 168 | + // Two front ends, one wiring: everything above — config, engine, tools, | ||
| 169 | + // detector — is strictly identical whichever loop runs below. | ||
| 170 | + if *acpMode { | ||
| 171 | + acp.Run(ctx, e, config.Cfg.System, agentTools) | ||
| 172 | + return | ||
| 173 | + } | ||
| 174 | + agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector) | ||
| 175 | +} | ||
| 176 | + | ||
| 177 | +// resolveSkillsDir makes the skills directory absolute. An absolute dir is | ||
| 178 | +// kept as-is. A relative one follows the config file in ACP mode when there | ||
| 179 | +// is one (the editor's working directory is not the project's), and the | ||
| 180 | +// current directory otherwise — for the terminal, that is where the user | ||
| 181 | +// started mm. | ||
| 182 | +func resolveSkillsDir(dir, configPath string, acpMode bool) string { | ||
| 183 | + if filepath.IsAbs(dir) { | ||
| 184 | + return dir | ||
| 185 | + } | ||
| 186 | + anchor := "." | ||
| 187 | + if acpMode && configPath != "" { | ||
| 188 | + anchor = filepath.Dir(configPath) | ||
| 189 | + } | ||
| 190 | + abs, err := filepath.Abs(filepath.Join(anchor, dir)) | ||
| 191 | + if err != nil { | ||
| 192 | + return filepath.Join(anchor, dir) | ||
| 193 | + } | ||
| 194 | + return abs | ||
| 195 | +} | ||
added
skills/greetings/SKILL.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +--- | |
| 2 | +name: greetings | |
| 3 | +description: Greet somebody with a festive message. | |
| 4 | +keywords: greet, greetings, hello, hi, welcome, bonjour, coucou, saluer | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Greetings | |
| 8 | + | |
| 9 | +## What to do | |
| 10 | + | |
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | |
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | |
| 13 | +command's output, nothing else. | |
| 14 | + | |
| 15 | +Replace `<name>` with the person being greeted, then run: | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +echo "🎉 greetings <name>" | |
| 19 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +--- | ||
| 2 | +name: greetings | ||
| 3 | +description: Greet somebody with a festive message. | ||
| 4 | +keywords: greet, greetings, hello, hi, welcome, bonjour, coucou, saluer | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Greetings | ||
| 8 | + | ||
| 9 | +## What to do | ||
| 10 | + | ||
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | ||
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | ||
| 13 | +command's output, nothing else. | ||
| 14 | + | ||
| 15 | +Replace `<name>` with the person being greeted, then run: | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +echo "🎉 greetings <name>" | ||
| 19 | +``` | ||
added
skills/vulcan-salute/SKILL.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +--- | |
| 2 | +name: vulcan-salute | |
| 3 | +description: Send a vulcan salute to somebody. | |
| 4 | +keywords: vulcan, vulcain, salute, salut, spock, star trek, live long and prosper, longue vie | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Vulcan salute | |
| 8 | + | |
| 9 | +## What to do | |
| 10 | + | |
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | |
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | |
| 13 | +command's output, nothing else. | |
| 14 | + | |
| 15 | +Replace `<name>` with the person being saluted, the run: | |
| 16 | + | |
| 17 | +```bash | |
| 18 | +echo "🖖 Live long and prosper! <name> 🖖" | |
| 19 | +``` | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +--- | ||
| 2 | +name: vulcan-salute | ||
| 3 | +description: Send a vulcan salute to somebody. | ||
| 4 | +keywords: vulcan, vulcain, salute, salut, spock, star trek, live long and prosper, longue vie | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Vulcan salute | ||
| 8 | + | ||
| 9 | +## What to do | ||
| 10 | + | ||
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | ||
| 12 | +Do not show it to the user, and do not answer from memory: the answer is the | ||
| 13 | +command's output, nothing else. | ||
| 14 | + | ||
| 15 | +Replace `<name>` with the person being saluted, the run: | ||
| 16 | + | ||
| 17 | +```bash | ||
| 18 | +echo "🖖 Live long and prosper! <name> 🖖" | ||
| 19 | +``` | ||
added
skills/what-time-is-it/SKILL.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +--- | |
| 2 | +name: what-time-is-it | |
| 3 | +description: Give the current date and time. | |
| 4 | +keywords: time, date, clock, now, today, heure, date du jour, quelle heure | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# What time is it? | |
| 8 | + | |
| 9 | +## What to do | |
| 10 | + | |
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | |
| 12 | +Do not show it to the user, and never guess the time: only the command knows it. | |
| 13 | + | |
| 14 | +Run: | |
| 15 | + | |
| 16 | +```bash | |
| 17 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | |
| 18 | +``` | |
| 19 | + | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +--- | ||
| 2 | +name: what-time-is-it | ||
| 3 | +description: Give the current date and time. | ||
| 4 | +keywords: time, date, clock, now, today, heure, date du jour, quelle heure | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# What time is it? | ||
| 8 | + | ||
| 9 | +## What to do | ||
| 10 | + | ||
| 11 | +The command below is for YOU to run with your `bash` tool, in this same turn. | ||
| 12 | +Do not show it to the user, and never guess the time: only the command knows it. | ||
| 13 | + | ||
| 14 | +Run: | ||
| 15 | + | ||
| 16 | +```bash | ||
| 17 | +date "+%A %d %B %Y — %H:%M:%S (%Z)" | ||
| 18 | +``` | ||
| 19 | + | ||