diff --git a/AGENTS.md b/AGENTS.md index cbf15c5b..dcbf3718 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1862,8 +1862,8 @@ The probe is **lazy / turn-boundary driven** — `pickProvider()` reads `Date.no A fallover can cross transports — the common `cloud (native_tools) → local (grammar)` default (`appendLocal`) does exactly that. Both the request shape and the response parse are decoupled from the primary: -- **Request.** Each attempt re-resolves `{ transport, adapter }` for the chosen link via `resolveActiveLlmSlice(providerId)`, so the wire shape is correct for whoever serves. `buildLlmStreamParams` keeps `grammar` **populated even on the native path** (it used to blank it) so a grammar-only link handed the request still has its GBNF; native providers ignore `grammar` and read `tools`, so carrying both is safe. -- **Response.** The completion is stamped with `servedTransport` — the transport of the link that actually answered — by the fallback seams in [src/runtime/llm-fallback-seam.ts](src/runtime/llm-fallback-seam.ts) (`createFallbackCompleter` / `createFallbackStreamer`). `step-executor.parseDepsFor(completion, deps)` prefers `servedTransport` over the caller's configured `toolTransport` for every parse decision (`tryParseToolCalls`, the empty-completion recovery gate). Without this, a native primary that fell over to a grammar link parsed the grammar reply as OpenAI `tool_calls` and silently broke tool-calling. The stamp is pinned directly on the real seam factories by [src/runtime/llm-fallback-seam.test.ts](src/runtime/llm-fallback-seam.test.ts) (deleting either stamp turns it red) and end-to-end through the loop by [src/llm/fallback/fallback-e2e.integration.test.ts](src/llm/fallback/fallback-e2e.integration.test.ts). +- **Request.** Each attempt re-resolves `{ transport, adapter }` for the chosen link via `resolveActiveLlmSlice(providerId)`, so the wire shape is correct for whoever serves. `buildLlmStreamParams` keeps `grammar` **populated even on the native path** (it used to blank it) so a grammar-only link handed the request still has its GBNF; native providers ignore `grammar` and read `tools`, so carrying both is safe. On a think-tag profile the prompt shape is per-link too: the main prompt for a native-tools primary is built **prefill-suppressed** (issue #283 — a literal `` shipped to a chat endpoint is at best noise, at worst corrupted server-side), and `LlmStreamParams.grammarPrompt` carries a lazy, memoized prefill-carrying variant that the seams substitute for grammar links (`promptFor`), so a llama-server link still gets the shape its template + GBNF prelude expect. The one-shot repair retry rebuilds both variants repair-shaped. +- **Response.** The completion is stamped with `servedTransport` — the transport of the link that actually answered — by the fallback seams in [src/runtime/llm-fallback-seam.ts](src/runtime/llm-fallback-seam.ts) (`createFallbackCompleter` / `createFallbackStreamer`). `step-executor.parseDepsFor(completion, deps)` prefers `servedTransport` over the caller's configured `toolTransport` for every parse decision (`tryParseToolCalls`, the empty-completion recovery gate). Without this, a native primary that fell over to a grammar link parsed the grammar reply as OpenAI `tool_calls` and silently broke tool-calling. The streamer additionally stamps `servedTransport` on **every chunk** — the return-value stamp only exists after the last delta, which is too late for the live stream parser: `consumeStream` creates its parser lazily off the first chunk's stamp so a grammar-served stream (whose GBNF output starts mid-``) keeps emitting live `reasoning_delta`s under a native primary. `completionAssumesOpenReasoning` keys purely off the served/parse transport: grammar-served output always continues an open think block; a chat completion never does (so even in the unsupported grammar-primary → native-link ordering a clean chat reply is no longer swallowed whole as reasoning). The stamps are pinned directly on the real seam factories by [src/runtime/llm-fallback-seam.test.ts](src/runtime/llm-fallback-seam.test.ts) (deleting either stamp turns it red) and end-to-end through the loop by [src/llm/fallback/fallback-e2e.integration.test.ts](src/llm/fallback/fallback-e2e.integration.test.ts), including think-tag profile cases in both directions of the parse decision. The remaining asymmetry: `tools` is populated only when the **primary's** transport is `native_tools`. Placing a native-tools provider **below** a grammar-only primary would reach it without a `tools` payload — an unusual ordering; order native-tools links at or above the first grammar-only link. Slot affinity is decided pre-request from the primary, so a cloud→local fallover runs the local link without slot-cache reuse for that turn (correctness-neutral). The grammar string itself is always built for the primary model. @@ -1876,7 +1876,7 @@ The remaining asymmetry: `tools` is populated only when the **primary's** transp 5. **Whole-chain exhaustion rethrows the last (already-humanized) error** so `loop_failed` classification is unchanged. Pinned by [src/llm/fallback/run-with-fallback.test.ts](src/llm/fallback/run-with-fallback.test.ts). 6. **Exactly one switch notice per state transition** (away / back), none on sticky turns. Pinned by [src/llm/fallback/provider-fallback-chain.test.ts](src/llm/fallback/provider-fallback-chain.test.ts). 7. **`appendLocal` appends the local provider when configured, nothing when not.** Pinned by [src/llm/fallback/fallback-config.test.ts](src/llm/fallback/fallback-config.test.ts). -8. **A cross-transport fallover parses the response with the served link's transport, not the primary's**, and the turn reaches the fallback's answer instead of `loop_failed`. Pinned by [src/llm/fallback/fallback-e2e.integration.test.ts](src/llm/fallback/fallback-e2e.integration.test.ts) (real `AgentLoop` + `step-executor`, both unary and streaming). +8. **A cross-transport fallover parses the response with the served link's transport, not the primary's**, and the turn reaches the fallback's answer instead of `loop_failed`. On a think-tag profile the grammar link also receives the prefill-carrying `grammarPrompt` variant and its streamed reasoning stays classified live (per-chunk `servedTransport` stamp). Pinned by [src/llm/fallback/fallback-e2e.integration.test.ts](src/llm/fallback/fallback-e2e.integration.test.ts) (real `AgentLoop` + `step-executor` + the real seam factories; both unary and streaming, plain and think-tag profiles). 9. **Breaker state is partitioned by session** — one partition's success does not clear another's armed cooldown, and a keyless call shares one default partition. Pinned by [src/llm/fallback/provider-fallback-chain.test.ts](src/llm/fallback/provider-fallback-chain.test.ts) ("partition isolation"). 10. **The cooldown ladder must be non-decreasing** — a decreasing `cooldownMs` is rejected at parse time so "escalating" stays true. Pinned by [src/config/llm-config.test.ts](src/config/llm-config.test.ts). diff --git a/MEMORY.md b/MEMORY.md index a43184f3..eb0ef6cf 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,6 +2,7 @@ This document is the source-of-truth for how cross-session memory works in `atomic-agent`. It complements: +- `MEMORY_GUIDE.md` — the operator-facing walkthrough with worked examples; start there if you want to *use* memory rather than modify it. - `ARCHITECTURE.md` — overall runtime topology and invariants. - `AGENTS.md` — short engineering summary for automated contributors. - `PROMPT.md` — full anatomy of the stable prefix and variable tail, including where the memory channels render in the prompt. diff --git a/MEMORY_GUIDE.md b/MEMORY_GUIDE.md new file mode 100644 index 00000000..e4934058 --- /dev/null +++ b/MEMORY_GUIDE.md @@ -0,0 +1,419 @@ +# atomic-agent — memory, end to end + +A practical tour for operators: what the agent remembers, when, where it lives +on disk, how it comes back into the conversation, and how to inspect or erase +it. Everything here describes shipped default behaviour — the deep internals +live in [MEMORY.md](MEMORY.md), and the design history in +[MEMORY_FABRIC_V2.md](MEMORY_FABRIC_V2.md) / [MEMORY_FABRIC_V2.5.md](MEMORY_FABRIC_V2.5.md). + +## The short version + +Memory is **not** the chat transcript. It is a separate, bounded, inspectable +store of five kinds of things, all in one local SQLite file: + +| Kind | What it is | Written by | Comes back as | +|---|---|---|---| +| **Profile facts** | short key/value facts about you (`name`, `language`, `deploy_command`) | the agent's `memory.profile.set` tool + reflection | `### profile` block in every prompt (gated) | +| **Notes** | freeform episodic observations, keyword-searchable | `memory.notes.store` tool + reflection | top-3 `### recalled` hits + `### memory-index` pointers | +| **Links** | typed edges between related notes (`RELATES_TO`, `CAUSED_BY`, …) | an automatic post-reflection LLM sub-call | neighbour notes pulled into `### recalled` | +| **Lessons** | reusable principles distilled from clusters of related notes | the background consolidator | `### lessons` pointer rows | +| **Procedures** | advisory how-to templates (never auto-executed) | the background consolidator | `### procedures` pointer rows | + +Two automatic writers do most of the work: **reflection** (a small LLM call +after each turn that extracts durable facts and notes) and the +**consolidator** (a slow background job that distills repeated notes into +lessons and procedures). You never have to hand-craft any of it — though you +can always just say *"remember this"*. + +The store itself never leaves your machine. Memory content does reach the +network as part of the prompts sent to whatever LLM provider you configured — +with the default local `llama-server` it stays fully local. + +## Where it lives on disk + +Everything is under the state directory — `~/.atomic-agent` by default, +overridable with `ATOMIC_AGENT_STATE_DIR`: + +- `/memory.sqlite` — the whole memory fabric: `profile_facts`, + `memories` (+ the `memories_fts` full-text index), `memory_links`, + `lessons`, `procedures`, `vote_events`, and (when embeddings are enabled) + `memory_embeddings`. +- `/sessions.sqlite` — chat transcripts. Separate file; wiping + memory does not touch your session history, and vice versa. +- `/config.json` — all the `memory.*` switches and knobs. + +It is a plain SQLite database — you can look at it directly: + +```bash +sqlite3 ~/.atomic-agent/memory.sqlite '.tables' + +# active profile facts (superseded versions are kept, filtered here) +sqlite3 ~/.atomic-agent/memory.sqlite \ + "SELECT key, value, pinned FROM profile_facts WHERE superseded_by IS NULL;" + +# newest notes (archived = already distilled into a lesson) +sqlite3 ~/.atomic-agent/memory.sqlite \ + "SELECT id, substr(content, 1, 70), tags FROM memories + WHERE consolidated_into IS NULL ORDER BY updated_at DESC LIMIT 10;" + +# distilled lessons and procedures +sqlite3 ~/.atomic-agent/memory.sqlite \ + "SELECT id, status, activation FROM lessons;" +sqlite3 ~/.atomic-agent/memory.sqlite \ + "SELECT id, status, activation FROM procedures;" +``` + +## Who writes memory, and when + +### Explicitly, during a turn + +The agent has nine `memory.*` tools. The write-capable ones fire when you ask +("remember that…", "forget that note") or when the agent decides an +observation is worth keeping: + +| Tool | Args | What it does | +|---|---|---| +| `memory.profile.set` | `{ key, value, pinned?, keywords? }` | upsert a profile fact (old value is kept as history) | +| `memory.profile.remove` | `{ key }` | delete a profile fact | +| `memory.profile.list` | `{}` | list active facts | +| `memory.profile.history` | `{ key }` | full version chain of one key, oldest first | +| `memory.notes.store` | `{ content, tags? }` | save a freeform note | +| `memory.notes.recall` | `{ query \| id, k?, scope?, tags? }` | BM25 search, or fetch one note by id | +| `memory.notes.forget` | `{ id }` | delete a note | +| `memory.lessons.recall` | `{ query \| id, k? }` | read a distilled lesson's full principle | +| `memory.procedures.recall` | `{ query \| id, k? }` | read a procedure's full step list | + +### Automatically, after every turn — reflection + +When a turn ends, the runtime fires one small **fire-and-forget** LLM call +(your reply is never delayed by it) that reads the last user/assistant +exchange and outputs either `NONE` or up to a handful of lines like: + +``` +SET name=Lena +SET deploy_command=make ship [pinned=false; keywords=deploy,ship,release] +NOTE staging flyway migrations need FLYWAY_BASELINE=1 or deploy fails [tags=staging,flyway] +``` + +- `SET` lines land in the profile store (at most 3 per turn, + `memory.reflection.maxFactsPerCall`). +- `NOTE` lines land in the notes store with an implicit `reflection` tag (at + most 2 per turn, `memory.reflection.maxNotesPerCall`). +- The call has a hard timeout (`memory.reflection.timeoutMs`, 10 s); on + timeout or parse failure nothing is written and the next turn just tries + again. At most one reflection is in flight per session. + +Reflection runs on the same model/provider the agent itself uses. With local +`llama-server` it gets a dedicated server slot so your main conversation's +KV-cache is untouched. + +Whether anything gets stored — and how well-worded it is — depends on the +model. Small local models output `NONE` more often and occasionally store +trivia; that is expected, and the voting/eviction layer (below) cleans up +over time. + +### In the background, every few hours — the consolidator + +An in-process job (on while `memory.lessons.enabled` and +`memory.consolidation.enabled` are both true, which is the default) ticks +every 6 hours (`memory.consolidation.intervalMs`): + +1. It looks at notes that have been untouched for at least 24 hours + (`cooldownMs`) and are not yet archived. +2. It clusters related notes — connected via `memory_links` and sharing a + tag. Clusters smaller than 3 notes (`minClusterSize`) are skipped; at most + 5 clusters are processed per tick. +3. One LLM call per cluster distills a **lesson** (a one-line *activation* + pointer + a longer *principle*), and — when `memory.procedures.enabled` — + optionally a **procedure** (activation + ordered steps) in the same call. +4. The source notes are **archived**: they disappear from the + `### memory-index` prompt section but stay readable by id (lessons keep + their parent ids so you can trace where a principle came from). + +Procedures are advisory only: the runtime never executes their steps. The +agent reads them via `memory.procedures.recall` and follows, adapts, or +ignores them. + +### Housekeeping you get for free + +- **Dedup** — a near-duplicate of an existing note is merged instead of + inserted: full-text search fetches the closest existing notes, and the best + candidate absorbs the write when its token-overlap (Jaccard) similarity + clears `memory.dedup.fts5Threshold` (0.85). +- **Voting** — a post-turn sub-call votes surfaced memories up or down by + usefulness; heavily downvoted profile facts stop rendering even if pinned. +- **Eviction** — hard caps (1000 notes, 500 lessons, 500 procedures) with + utility-weighted eviction, so the store cannot grow without bound. +- **Links** — after reflection, another small sub-call connects the new and + recalled notes with typed edges, which later powers both recall expansion + and consolidator clustering. + +## How memory shows up in the prompt + +Every prompt the model sees ends with a variable tail. The memory-fed +sections, in order (each one is skipped when empty): + +``` +### profile +- name: Lena +- language: de + +### lessons +*4 [playwright] When a Playwright click flakes, prefer role-based locators + +### procedures +>2 [playwright] Stabilise a flaky selector before adding retries + +### memory-index +- #17 [reflection, staging, flyway] staging flyway migrations need FLYWAY_… +- #21 [reflection, ci] the release workflow requires a signed tag + +### recalled +- #17 [reflection, staging, flyway] staging flyway migrations need FLYWAY_BASELINE=1 or deploy fails +``` + +The design is **pointers first, bodies on demand**: + +| Section | What it carries | Full body via | +|---|---|---| +| `### profile` | active facts, `- key: value` | already the full value | +| `### lessons` | top-2 BM25 hits for the current turn, `* [tags] activation` one-liners | `memory.lessons.recall { id }` | +| `### procedures` | top-2 BM25 hits for the current turn, `> [tags] activation` one-liners | `memory.procedures.recall { id }` | +| `### memory-index` | up to 20 most recent notes (minus any already in `### recalled`), 60-char previews | `memory.notes.recall { id }` | +| `### recalled` | top-3 BM25 hits for the current message, 160-char previews | `memory.notes.recall { id }` | + +Two gates keep the tail small: + +- Profile facts saved with `pinned=false` render only when one of their + `keywords` appears in your current message — `deploy_command` shows up when + you talk about deploying, not when you ask about the weather. +- Every section has its own token ceiling (`memory.*.maxTokens`), clipped + with a `[truncated]` marker. + +The `### recalled` search runs once per turn against your current message. +Short referential follow-ups ("and what about there?") are first expanded by +a query rewriter (on by default) using the recent turns, and hits are +expanded one hop through the link graph. + +`### lessons` and `### procedures` are gated by the same per-turn recall +query (your message plus recent tool-result summaries), matched against each +row's activation, principle, and tags — only the top +`memory.lessons.recallK` / `memory.procedures.recallK` hits (2 each) render, +so a prompt on an unrelated topic carries no lesson or procedure rows at +all. The complete list is always browsable in the TUI Memory tab. + +Optionally, recall can be made hybrid (BM25 + embedding cosine) by enabling +a local embedding model from the TUI's local-models panel — off by default. + +## Worked example 1 — a profile fact forms and comes back + +*Transcripts are illustrative — exact wording depends on your model.* + +**Session 1, Monday:** + +``` +you › My name is Lena, answer in German from now on. And remember: + we deploy with `make ship`, never `git push`-triggered. +agent › Alles klar, Lena — ab jetzt antworte ich auf Deutsch. Gemerkt: + Deployment läuft über `make ship`. +``` + +The reply may already include an explicit `memory.profile.set` call (you +asked it to remember). Seconds later, reflection independently distills the +exchange: + +``` +SET name=Lena +SET language=de +SET deploy_command=make ship [pinned=false; keywords=deploy,ship,release] +``` + +**Session 2, Thursday (fresh session, same machine):** the first prompt of +the session already contains + +``` +### profile +- name: Lena +- language: de +``` + +so the greeting is in German with no tool call and no searching. +`deploy_command` is contextual — it stays out of the prompt until a message +mentions *deploy*, *ship*, or *release*: + +``` +you › time to deploy the fix +agent › (prompt now also carries `- deploy_command: make ship`) + Ich starte das Deployment mit `make ship` … +``` + +**Changing your mind is versioned, not destructive:** + +``` +you › actually, switch back to English +agent › Done — English it is. +``` + +Reflection emits `SET language=en [valid_from=now; supersedes=language]`. The +old value is not erased; ask *"what language did I use before?"* and the +agent can call `memory.profile.history { key: "language" }` to see the chain +`de → en`. + +## Worked example 2 — a note forms, then saves a future session + +**Session 1 — while debugging together:** + +``` +you › staging deploy failed again with a flyway checksum error +agent › Found it: the staging DB was baselined manually, so migrations must + run with FLYWAY_BASELINE=1. Deploy is green now. Worth remembering? +you › yes please +agent › [memory.notes.store { content: "staging flyway migrations need + FLYWAY_BASELINE=1 — the DB was baselined manually; without the flag + the deploy fails with a checksum error", tags: ["staging","flyway"] }] + → stored #17 +``` + +(Had you not asked, reflection would likely have written a similar `NOTE` on +its own — with the implicit `reflection` tag.) + +**Session 2 — a week later:** + +``` +you › staging is red after the deploy, some flyway checksum thing +``` + +Before the model answers, the runtime searches notes with that message. Note +#17 scores high and is injected: + +``` +### recalled +- #17 [staging, flyway] staging flyway migrations need FLYWAY_BASELINE=1 — the DB was baselined manually; without the flag … +``` + +so the first reply already says *"that's the manually-baselined staging DB — +run migrations with `FLYWAY_BASELINE=1`"*. For a note longer than the 160-char +preview, the agent follows the pointer with +`memory.notes.recall { id: 17 }` to read the full body. + +Even when a note does not match the current message, it stays discoverable: +the 20 most recent notes are always listed as one-line pointers in +`### memory-index`, and the agent can search explicitly with +`memory.notes.recall { query: "flyway staging" }`. + +## Worked example 3 — repeated episodes become a lesson + +Over a week of E2E-test sessions, three separate notes accumulate (some +stored explicitly, some by reflection), all tagged `playwright`, linked to +each other by the automatic link generator: + +``` +#31 [playwright] click on the submit button flaked; switched to getByRole("button", …) and it stabilised +#38 [playwright] css selector .btn-primary broke after a class rename; role-based locator survived +#44 [playwright] added retries around a click; real fix was a getByRole locator, retries then unnecessary +``` + +At some point — the consolidator ticks every 6 hours and only touches notes +older than 24 hours — the cluster is distilled in a single LLM call: + +- a **lesson** row is created: + - activation: `When a Playwright click flakes, prefer role-based locators over CSS selectors` + - principle: the longer distilled reasoning, with `parent_ids: [31, 38, 44]` +- because procedures are enabled, the same call may also emit a **procedure** + (`Stabilise a flaky selector` with 3–4 ordered steps), +- notes #31/#38/#44 are archived: gone from `### memory-index`, still + readable by id. + +From then on, a prompt whose turn touches the topic carries a one-line +pointer — like `### recalled`, lessons are query-matched (the turn's recall +query against activation/principle/tags, top `memory.lessons.recallK` = 2), +so the row appears when you talk Playwright, not in every prompt: + +``` +### lessons +*4 [playwright] When a Playwright click flakes, prefer role-based locators over CSS selectors +``` + +and the agent drills in for the full principle: + +``` +you › the checkout e2e test is flaky again on the pay button +agent › [memory.lessons.recall { id: 4 }] + → activation + full principle + parent note ids + This matches a pattern we've hit three times — switching the pay + button click to getByRole("button", { name: "Pay" }) … +``` + +Lessons are not permanent dogma: each carries success/failure counters and a +vote score, and gets deprecated by age, overflow, or downvotes. + +## Inspecting memory + +- **TUI Memory tab** — type `/memory` in the chat prompt (or Esc → the menu's + Manage section → Memory). A read-only browser over `memory.sqlite` with six + channels: profile, notes, lessons, procedures, votes, links. Keys: `1`–`6` + or `[`/`]` switch channel, `j`/`k` move, Enter opens a row's full detail + (note body, lesson principle, procedure steps, profile history), `f` + cycles the notes archive filter (active/archived/all), `r` refreshes, `a` + toggles 5-second auto-refresh, and `g` on a note's detail expands its link + neighbourhood. +- **`/memory dump`** — prints the active profile into the chat transcript. +- **Just ask** — "what do you remember about me?" typically triggers + `memory.profile.list` and a notes search. +- **sqlite3** — see the queries in "Where it lives on disk" above. + +## Forgetting, disabling, wiping + +**Forget one thing.** Ask in chat — "forget my deploy command", "delete note +17". The agent uses `memory.profile.remove` / `memory.notes.forget`. There is +deliberately no delete tool for lessons and procedures — they age out via +deprecation and votes; to remove one immediately, edit `memory.sqlite` with +`sqlite3` (or wipe, below). + +**Turn a layer off.** Master switches in `/config.json`, all +default-on except embeddings: + +| Key | Turns off | +|---|---| +| `memory.reflection.enabled` | automatic fact/note extraction after turns | +| `memory.reflection.autoStoreNotes` | just the automatic notes (facts still extracted) | +| `memory.profile.enabled` | the `### profile` section + profile tools | +| `memory.notes.enabled` | the notes tools | +| `memory.recallInjection.enabled` | the `### recalled` section | +| `memory.index.enabled` | the `### memory-index` section | +| `memory.links.enabled` | link generation + graph expansion | +| `memory.lessons.enabled` | lessons + the consolidator | +| `memory.consolidation.enabled` | just the consolidator (existing lessons stay) | +| `memory.procedures.enabled` | procedures | +| `memory.voting.enabled` | usefulness voting | +| `memory.retrieve.rewriter.enabled` | the recall query rewriter | +| `memory.embeddings.enabled` | hybrid embedding recall (default **off**) | + +**Wipe everything.** Stop the agent, then: + +```bash +rm ~/.atomic-agent/memory.sqlite* +``` + +(the glob also catches SQLite's `-wal`/`-shm` journal files). The next start +creates a fresh, empty memory database. Chat history (`sessions.sqlite`), +config, skills, and tasks are untouched. + +## Agent memory vs your own notes (Obsidian) + +Memory is the agent's private working store — optimised for prompt injection, +not for reading. For notes **you** own and read, atomic-agent ships an +`obsidian` starter skill (auto-installed with the other starter skills on +first run) that reads, searches, and writes plain Markdown in an Obsidian +vault: + +- The vault path resolves from the `OBSIDIAN_VAULT_PATH` environment variable + (put it in `/.env`), falling back to `~/Documents/Obsidian Vault`. + If neither exists, the skill walks you through it: send the path in chat + and the agent verifies it and appends it to `~/.atomic-agent/.env` itself. +- "Add this to my notes" → the vault, as Markdown you can open in Obsidian. +- "Remember this" → `memory.sqlite`, surfacing automatically in future + prompts. + +The two compose nicely: the vault holds what you want to read and link; +memory holds what the agent should recall on its own. diff --git a/README.md b/README.md index df6c27b1..de602be0 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,8 @@ Atomic Agent's memory is not a giant chat log pasted back into the prompt. It's - **Dedup and eviction** merge near-duplicate memories and evict by usefulness, not age, on by default. - **Reflection** runs after turns, off the main agent slot, and writes memory without blocking the reply. +New to this? [MEMORY_GUIDE.md](MEMORY_GUIDE.md) walks the whole loop end to end — what gets stored when, where the SQLite file lives, how recall shows up in prompts, worked example transcripts, and how to inspect or wipe it all. + ## Ways to Use It
@@ -279,7 +281,7 @@ All six are designed here rather than transcribed from upstream terminal themes, **Mouse.** The TUI is clickable: the breadcrumb (which opens the menu, the same as Esc on an idle prompt), sidebar sessions and tasks, every list row (skills, tasks, memory, MCP, models, providers), the session / theme / slash pickers, approval buttons, tool cards, and the prompt itself — clicking in the input places the caret. A click selects a row, a second click on the selected row opens it, and the wheel scrolls the chat or walks the focused panel. -While mouse reporting is on the terminal hands clicks to the app, so its own plain drag-to-select is unavailable — a terminal-level constraint, not a choice. **To select text, just drag over it**: a drag that starts on plain text (a chat message, panel prose, empty rail space) pauses the mouse for 10 seconds and says so in chat — drag again to select, then copy the way you normally would. The pause ends on its own and clicks come back. This works in every terminal, no modifier to remember. Some terminals also offer an instant path: a Shift-modified drag bypasses reporting natively on kitty, WezTerm, GNOME Terminal and Windows Terminal, and iTerm2 reserves Option for the same thing — there the selection happens immediately, no pause needed. Where the app sees the shifted or alt-modified press instead (Apple Terminal), it opens the same 10-second pause. To hand selection to the terminal permanently instead: `/mouse off` in the app, `atomic-agent tui --no-mouse` for one run, or `"tui": { "mouse": false }` in `/config.json`. With mouse off, wheel scrolling still works through the terminal's alternate-scroll mode, exactly as before. +While mouse reporting is on the terminal hands clicks to the app, so its own plain drag-to-select is unavailable — a terminal-level constraint, not a choice. **To select text, just drag over it**: a drag that starts on plain text (a chat message, panel prose, empty rail space) pauses the mouse for 10 seconds and says so in chat — drag again to select, then copy the way you normally would. The pause ends on its own and clicks come back. This works in every terminal, no modifier to remember. Some terminals also offer an instant path: a Shift-modified drag bypasses reporting natively on kitty, WezTerm, GNOME Terminal and Windows Terminal, and iTerm2 reserves Option for the same thing — there the selection happens immediately, no pause needed. Where the app sees the shifted or alt-modified press instead (Apple Terminal), it opens the same 10-second pause. To hand selection to the terminal permanently instead: `/mouse off` in the app, `atomic-agent tui --no-mouse` for one run, or `"tui": { "mouse": false }` in `/config.json`. With mouse off, wheel scrolling still works through the terminal's alternate-scroll mode, exactly as before. If a terminal (or an ssh hop) answers the tracking request with reports the app cannot decode, the TUI turns mouse support off by itself for that session — with a chat notice — instead of letting coordinates spill into the composer. Cloud provider setup pulls each provider's full live model catalog, hundreds of models, instead of a short hardcoded list; OpenAI-compatible servers are asked for their own `/v1/models`. The picker filters as you type, and `/model` switches models mid-session. @@ -311,6 +313,8 @@ Managed mode downloads the backend, pulls GGUF models, selects the active model, The managed chat daemon stops when the last session exits, freeing the RAM and VRAM the model was holding; set `localModels.managed.stopOnExit: false` in `config.json` to keep the model warm between sessions. Daemons started standalone with `models start` are never touched. +On Windows the backend zip is picked per machine (CUDA when a capable NVIDIA driver is present, Vulkan otherwise). If the GPU build cannot serve a model on your hardware — typical for iGPU-only boxes — the start falls back to the CPU build automatically and records `localModels.managed.backendVariant: "cpu"` in `config.json`; set it to `"auto"`, `"vulkan"`, `"cuda-12.4"` or `"cuda-13.3"` to pick a build yourself (e.g. after a driver update). + Cloud models are searchable from the same command — by id, vendor, or capability, across every configured cloud provider: ```bash @@ -518,7 +522,7 @@ The promise is not magic secrecy. The promise is that the agent control plane do **Linux notes:** - **Desktop tools** (install via your package manager): `ripgrep` (file search; bundled binary used when present), `xclip`/`xsel` (X11) or `wl-clipboard` (Wayland) for clipboard, `libnotify-bin` for notifications, `wmctrl` for window control (X11/XWayland only), `gio` (glib2) or `trash-cli` for `fs.trash`. - **Browser:** Chromium-family sandboxing can fail under some Linux setups (containers, certain kernels). If Chrome refuses to launch, run it with `--no-sandbox`. -- **GPU acceleration (managed mode):** the backend always starts and falls back to CPU when no GPU driver is available. For GPU offload install a Vulkan driver. Intel/AMD: `mesa-vulkan-drivers` (+ `vulkan-loader`/`libvulkan1`); NVIDIA: the stock proprietary driver bundles its Vulkan ICD. Device auto-selected at start; override with `atomic-agent models use-device `, inspect with `atomic-agent models devices`, or press `G` in the TUI Models tab. +- **GPU acceleration (managed mode):** the backend always starts and falls back to CPU when no GPU driver is available. For GPU offload install a Vulkan driver. Intel/AMD: `mesa-vulkan-drivers` (+ `vulkan-loader`/`libvulkan1`); NVIDIA: the stock proprietary driver bundles its Vulkan ICD. Device auto-selected at start; override with `atomic-agent models use-device `, inspect with `atomic-agent models devices`, or press `G` in the TUI Models tab. Multi-GPU: set `localModels.managed.tensorSplit` in `config.json` (e.g. `[3, 1]` for a 75%/25% layer split) to launch llama-server with `--split-mode layer --tensor-split` across every visible GPU; combine with `use-device Vulkan0,Vulkan1` to restrict which devices join the split.
@@ -643,7 +647,8 @@ npm run build Core docs: - [PROMPT.md](PROMPT.md): prompt anatomy -- [MEMORY.md](MEMORY.md): memory and recall +- [MEMORY_GUIDE.md](MEMORY_GUIDE.md): memory end to end, with worked examples +- [MEMORY.md](MEMORY.md): memory and recall internals - [MEMORY_FABRIC_V2.md](MEMORY_FABRIC_V2.md) / [MEMORY_FABRIC_V2.5.md](MEMORY_FABRIC_V2.5.md): memory roadmap - [SKILLS.md](SKILLS.md): skill format - [BUNDLING.md](BUNDLING.md): release packaging diff --git a/src/agent/step-executor.test.ts b/src/agent/step-executor.test.ts index 3bb7fde1..f7cdc0f6 100644 --- a/src/agent/step-executor.test.ts +++ b/src/agent/step-executor.test.ts @@ -273,21 +273,22 @@ describe("executeStep batch handling", () => { ]); }); - it("native_tools: reasoning-only completion (empty content, no tool_calls) is salvaged as a reply", async () => { - // Reasoning models served over OpenAI-compatible APIs (Qwen3.8 with - // `preserve_thinking` on, DeepSeek-R1) routinely end hard turns with - // the entire answer in `reasoning_content`, `content` empty and no - // `tool_calls`. Failing fast here (the pre-Qwen3.8 contract) killed - // whole sessions on healthy completions. The parser now salvages the - // reasoning body — GBNF batch if one is embedded, otherwise a - // length-1 `reply` — without burning a retry: no prompt is replayed, - // so the original fail-fast concern (replaying the same prompt into - // the same wall) does not apply. + it("native_tools: unparseable reasoning-only completion routes through parse_retry, never leaks CoT as a reply", async () => { + // `reasoning_content` is internal scratch space by OpenAI-compatible + // convention. An earlier salvage path wrapped an unparseable + // reasoning body verbatim into `reply { text }` — raw chain-of- + // thought delivered as deliberate agent speech (issue #285). The + // executor must instead treat it like any other unparseable body: + // one `parse_retry` through the repair prompt, and the repaired + // completion's answer — never the reasoning text — reaches the user. const registry = makeRegistry(); const session = createEmptySessionState({ id: "s-native-reasoning-only", workingDir: "/w", }); + const events: Array<{ type: string }> = []; + const cot = + "The user asked whether reasoning tokens leak. I should inspect the binary..."; let llmCalls = 0; const outcome = await executeStep( @@ -305,9 +306,26 @@ describe("executeStep batch handling", () => { slotManager: new SlotManager(2), async llmComplete() { llmCalls += 1; + if (llmCalls === 1) { + return { + content: "", + reasoningContent: cot, + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: -1, + modelId: "openai/gpt-5.5", + }; + } return { content: "", - reasoningContent: "I should call reply.", + reasoningContent: "", stop: true, truncated: false, timing: { @@ -319,6 +337,16 @@ describe("executeStep batch handling", () => { cacheHitTokens: 0, slotId: -1, modelId: "openai/gpt-5.5", + toolCalls: [ + { + id: "call-repair", + type: "function", + function: { + name: "reply", + arguments: JSON.stringify({ text: "Привет!" }), + }, + }, + ], }; }, grammar: "", @@ -326,16 +354,265 @@ describe("executeStep batch handling", () => { toolTransport: "native_tools", toolCallAdapter: null, supportsSlotAffinity: false, + onEvent(event) { + events.push({ type: event.type }); + }, }, ); - expect(llmCalls).toBe(1); + expect(llmCalls).toBe(2); + expect(events.some((event) => event.type === "parse_retry")).toBe(true); expect(outcome.toolResults).toHaveLength(1); expect(outcome.toolResults[0]?.status).toBe("ok"); expect(outcome.nextSession.turns.at(-1)).toMatchObject({ kind: "assistant_reply", - text: "I should call reply.", + text: "Привет!", }); + // The leak itself: no reply anywhere in the transcript may carry the + // raw reasoning body. + for (const turn of outcome.nextSession.turns) { + if (turn.kind === "assistant_reply") { + expect(turn.text).not.toContain(cot); + } + } + expect( + outcome.toolCalls.some( + (call) => + call.tool === "reply" && + typeof call.args?.text === "string" && + call.args.text.includes(cot), + ), + ).toBe(false); + }); + + it("native_tools: recovers a GBNF-shaped batch embedded in `reasoning_content` without a retry", async () => { + // Reasoning models sometimes emit the persona's `[{tool, args}]` + // array inside the think channel and end the turn with `content` + // empty. That is a real tool-call emission, not scratch space — the + // parser must recover it in place (no repair round-trip). + const registry = makeRegistry(); + const session = createEmptySessionState({ + id: "s-native-gbnf-in-reasoning", + workingDir: "/w", + }); + const events: Array<{ type: string }> = []; + let llmCalls = 0; + + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "привет", + }, + { + registry, + slotManager: new SlotManager(2), + async llmComplete() { + llmCalls += 1; + return { + content: "", + reasoningContent: + '[{"tool":"reply","args":{"text":"Привет, инициат!"}}]', + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 12, + }, + cacheHitTokens: 0, + slotId: -1, + modelId: "z-ai/glm-5.3-flash", + }; + }, + grammar: "", + profile: PLAIN_INSTRUCT_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + onEvent(event) { + events.push({ type: event.type }); + }, + }, + ); + + expect(llmCalls).toBe(1); + expect(events.some((event) => event.type === "parse_retry")).toBe(false); + expect(outcome.toolCalls).toHaveLength(1); + expect(outcome.toolCalls[0]).toMatchObject({ + tool: "reply", + args: { text: "Привет, инициат!" }, + }); + expect(outcome.nextSession.turns.at(-1)).toMatchObject({ + kind: "assistant_reply", + text: "Привет, инициат!", + }); + }); + + it("native_tools: reasoning-only on both attempts surfaces a parse error, not the CoT", async () => { + // Twice-unparseable reasoning ends the step as a GrammarError. Before + // issue #285 the first attempt already "succeeded" by leaking the + // reasoning body as the reply, so this path was unreachable. + const registry = makeRegistry(); + const session = createEmptySessionState({ + id: "s-native-reasoning-twice", + workingDir: "/w", + }); + const events: Array<{ type: string }> = []; + let llmCalls = 0; + + await expect( + executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "привет", + }, + { + registry, + slotManager: new SlotManager(2), + async llmComplete() { + llmCalls += 1; + return { + content: "", + reasoningContent: "Hmm, let me think about the binary layout...", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: -1, + modelId: "z-ai/glm-5.3-flash", + }; + }, + grammar: "", + profile: PLAIN_INSTRUCT_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + onEvent(event) { + events.push({ type: event.type }); + }, + }, + ), + ).rejects.toMatchObject({ name: "GrammarError" }); + + expect(llmCalls).toBe(2); + expect(events.some((event) => event.type === "parse_retry")).toBe(true); + }); + + it("native_tools: the repair prompt mandates native function-calling, never a corrected JSON array", async () => { + // The repair replays with the SAME llmParams as the failed attempt — + // under `native_tools` that request carries the OpenAI `tools` + // payload and a stable prefix that forbids text-JSON emission. + // Appending the grammar repair mandate ("Emit a corrected JSON array + // only", "Use a length-1 array") onto that prefix re-creates the + // issue #285 dual mandate at the one retry a failing model gets + // before GrammarError kills the step. + const registry = makeRegistry(); + const session = createEmptySessionState({ + id: "s-native-repair-prompt", + workingDir: "/w", + }); + const prompts: string[] = []; + let llmCalls = 0; + + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "привет", + }, + { + registry, + slotManager: new SlotManager(2), + async llmComplete({ prompt }) { + prompts.push(prompt); + llmCalls += 1; + if (llmCalls === 1) { + // Reasoning-only completion: unparseable, routes through the + // one-shot repair (the exact path issue #285 redirected). + return { + content: "", + reasoningContent: "Let me think about what to do here...", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: -1, + modelId: "openai/gpt-5.5", + }; + } + return { + content: "", + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: -1, + modelId: "openai/gpt-5.5", + toolCalls: [ + { + id: "call-repaired", + type: "function", + function: { + name: "reply", + arguments: JSON.stringify({ text: "готово" }), + }, + }, + ], + }; + }, + grammar: "", + profile: PLAIN_INSTRUCT_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + }, + ); + + expect(outcome.terminal).toBe("turn"); + expect(prompts).toHaveLength(2); + const repairPrompt = prompts[1]!; + expect(repairPrompt).toContain("### tool-call-repair"); + // Native corrective mandate present... + expect(repairPrompt).toContain("native function-calling interface"); + expect(repairPrompt).toContain("do NOT write tool-call JSON as text"); + // ...and no trace of the text-array mandate anywhere in the repair + // prompt (stable prefix included). + expect(repairPrompt).not.toContain("Emit a corrected JSON array"); + expect(repairPrompt).not.toContain("Use a length-1 array"); + expect(repairPrompt).not.toContain("Emit a JSON ARRAY of tool calls now"); + expect(repairPrompt).not.toContain("length-1 array"); + expect(repairPrompt).not.toContain("One tool-call array per step"); }); it("repairs a native-tools reply call with empty args before execution", async () => { @@ -1735,6 +2012,353 @@ describe("parallelToolCalls derivation (issue #104)", () => { }); +describe("native_tools thinking-profile prompt hygiene (issue #283)", () => { + // A think-tag prefill is a llama-server text-completion artifact. On + // the native-tools chat transport the prompt ships as a chat message + // to an OpenAI-compatible endpoint, where the literal `` is at + // best noise and at worst corrupted server-side (Ollama Cloud, + // ollama/ollama#17248) — so it must never be sent there, and parsing + // must never assume a prefill that was not sent. + const grammarsDir = join(process.cwd(), "grammars"); + + function makeReplyRegistry() { + const registry = new ToolRegistry(); + registry.register({ + name: "reply", + description: "reply", + readonly: true, + async run(args: Record) { + return compressToolResult({ + tool: "reply", + status: "ok", + output: String(args.text ?? ""), + }); + }, + }); + return registry; + } + + function mkCompletion(content: string) { + return { + content, + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: -1, + modelId: "mock-cloud", + }; + } + + it("native_tools: the prompt carries no trailing prefill and the reply is not mis-parsed as reasoning", async () => { + const session = createEmptySessionState({ id: "s-283-a", workingDir: "/w" }); + const prompts: string[] = []; + const events: Array<{ type: string; text?: string }> = []; + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry: makeReplyRegistry(), + slotManager: new SlotManager(2), + llmComplete: async (params) => { + prompts.push(params.prompt); + return mkCompletion("All done."); + }, + grammar: "", + profile: QWEN_THINK_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + onEvent: (ev) => { + events.push(ev as { type: string; text?: string }); + }, + }, + ); + + // The literal open tag must not reach the chat endpoint. + expect(prompts).toHaveLength(1); + expect(prompts[0]!.trimEnd().endsWith("")).toBe(false); + // ...and the plain-prose reply must not be re-prefixed with `` + // and swallowed whole as reasoning. + expect(events.some((ev) => ev.type === "reasoning")).toBe(false); + expect(outcome.nextSession.turns.at(-1)).toMatchObject({ + kind: "assistant_reply", + text: "All done.", + }); + }); + + it("native_tools: streamed content deltas are not reclassified as pre-opened reasoning", async () => { + const session = createEmptySessionState({ id: "s-283-b", workingDir: "/w" }); + const events: Array<{ type: string }> = []; + let captured: import("../llm/llama-server-client.js").CompletionResult | null = + null; + const finalCompletion = mkCompletion("Answer text"); + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry: makeReplyRegistry(), + slotManager: new SlotManager(2), + llmComplete: async () => finalCompletion, + llmCompleteStream: async function* () { + yield { delta: "Answer ", reasoningDelta: "", done: false }; + yield { delta: "text", reasoningDelta: "", done: false }; + return finalCompletion; + }, + grammar: "", + profile: QWEN_THINK_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + onCompletion: (c) => { + captured = c; + }, + onEvent: (ev) => { + events.push(ev as { type: string }); + }, + }, + ); + + // Without the fix the stream parser starts in `inside_think` and + // reclassifies the whole reply as reasoning (flushed at stream end + // into `reasoningContent` and surfaced as reasoning events). + expect(events.some((ev) => ev.type === "reasoning_delta")).toBe(false); + expect(events.some((ev) => ev.type === "reasoning")).toBe(false); + expect(captured).not.toBeNull(); + expect(captured!.reasoningContent).toBe(""); + expect(outcome.nextSession.turns.at(-1)).toMatchObject({ + kind: "assistant_reply", + text: "Answer text", + }); + }); + + it("native_tools: the one-shot repair prompt does not re-append the reasoning prefill", async () => { + const session = createEmptySessionState({ id: "s-283-c", workingDir: "/w" }); + const prompts: string[] = []; + // Two terminal `reply` calls in one batch fail validation and route + // through the repair path. + const badBatch = JSON.stringify([ + { tool: "reply", args: { text: "a" } }, + { tool: "reply", args: { text: "b" } }, + ]); + const goodBatch = JSON.stringify([{ tool: "reply", args: { text: "fixed" } }]); + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry: makeReplyRegistry(), + slotManager: new SlotManager(2), + llmComplete: async (params) => { + prompts.push(params.prompt); + return mkCompletion(prompts.length === 1 ? badBatch : goodBatch); + }, + grammar: "", + profile: QWEN_THINK_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + }, + ); + + expect(prompts).toHaveLength(2); + expect(prompts[1]).toContain("### tool-call-repair"); + expect(prompts[1]!.trimEnd().endsWith("")).toBe(false); + expect(outcome.toolResults[0]?.status).toBe("ok"); + }); + + it("grammar transport regression: prefill still sent and reasoning still extracted", async () => { + const registry = makeReplyRegistry(); + const grammar = await buildGrammar(QWEN_THINK_PROFILE, grammarsDir); + const session = createEmptySessionState({ id: "s-283-d", workingDir: "/w" }); + const prompts: string[] = []; + const reasoningEvents: string[] = []; + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry, + slotManager: new SlotManager(2), + llmComplete: async (params) => { + prompts.push(params.prompt); + // Grammar output starts mid-think: body + close tag + array. + return { + ...mkCompletion( + 'thinking it over\n[{"tool":"reply","args":{"text":"hi"}}]', + ), + slotId: 0, + modelId: "mock-local", + }; + }, + grammar, + profile: QWEN_THINK_PROFILE, + toolTransport: "grammar", + toolCallAdapter: null, + supportsSlotAffinity: true, + onEvent: (ev) => { + if (ev.type === "reasoning") reasoningEvents.push(ev.text); + }, + }, + ); + + expect(prompts[0]!.trimEnd().endsWith("")).toBe(true); + expect(reasoningEvents).toEqual(["thinking it over"]); + expect(outcome.nextSession.turns.at(-1)).toMatchObject({ + kind: "assistant_reply", + text: "hi", + }); + }); + + it("cross-transport fallover: a grammar-served stream (servedTransport stamp) keeps LIVE reasoning deltas under a native primary", async () => { + // The documented default hybrid chain: cloud native primary with a + // grammar local last resort (`appendLocal`). During an outage the + // sticky override serves every turn from the grammar link, whose + // GBNF output starts mid-`` — the stream parser must adopt + // the SERVED transport (stamped on each chunk by the fallback seam) + // or live reasoning classification silently dies for the whole + // outage window. + const session = createEmptySessionState({ id: "s-283-e", workingDir: "/w" }); + const raw = + 'pondering deeply about it\n[{"tool":"reply","args":{"text":"hi"}}]'; + const finalCompletion = { + ...{ + content: raw, + reasoningContent: "", + stop: true, + truncated: false, + timing: { + promptMs: 1, + predictedMs: 1, + promptTokens: 20, + predictedTokens: 5, + }, + cacheHitTokens: 0, + slotId: 0, + modelId: "mock-local", + }, + servedTransport: "grammar" as const, + }; + const reasoningDeltas: string[] = []; + const reasoningEvents: string[] = []; + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry: makeReplyRegistry(), + slotManager: new SlotManager(2), + llmComplete: async () => finalCompletion, + llmCompleteStream: async function* () { + const stamp = { reasoningDelta: "", done: false, servedTransport: "grammar" as const }; + yield { ...stamp, delta: "pondering deeply" }; + yield { ...stamp, delta: " about it\n" }; + yield { ...stamp, delta: '[{"tool":"reply","args":{"text":"hi"}}]' }; + return finalCompletion; + }, + grammar: "", + profile: QWEN_THINK_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: null, + supportsSlotAffinity: false, + onEvent: (ev) => { + if (ev.type === "reasoning_delta") reasoningDeltas.push(ev.text); + if (ev.type === "reasoning") reasoningEvents.push(ev.text); + }, + }, + ); + + // Live classification: the reasoning streamed as deltas while the + // model was still generating, not just post-hoc at parse time. + expect(reasoningDeltas.join("")).toBe("pondering deeply about it"); + expect(reasoningEvents).toEqual(["pondering deeply about it"]); + expect(outcome.nextSession.turns.at(-1)).toMatchObject({ + kind: "assistant_reply", + text: "hi", + }); + }); + + it("cross-transport fallover: a native-served completion under a grammar primary is not swallowed as reasoning", async () => { + // The reverse (documented-unsupported) ordering: grammar primary, + // native-tools link below it. A chat completion never continues our + // text-completion prefill — prepending `` here would swallow + // the clean reply whole as reasoning. + const session = createEmptySessionState({ id: "s-283-f", workingDir: "/w" }); + const events: Array<{ type: string; text?: string }> = []; + const outcome = await executeStep( + { + session, + toolDescriptors: DEFAULT_TOOL_DESCRIPTORS, + capabilities: CAPS, + skillCatalog: SKILLS, + stepIndex: 0, + signal: new AbortController().signal, + userMessage: "hi", + }, + { + registry: makeReplyRegistry(), + slotManager: new SlotManager(2), + llmComplete: async () => ({ + ...mkCompletion("Just the answer."), + servedTransport: "native_tools" as const, + }), + grammar: "", + profile: QWEN_THINK_PROFILE, + toolTransport: "grammar", + toolCallAdapter: null, + supportsSlotAffinity: false, + onEvent: (ev) => { + events.push(ev as { type: string; text?: string }); + }, + }, + ); + + expect(events.some((ev) => ev.type === "reasoning")).toBe(false); + expect(outcome.nextSession.turns.at(-1)).toMatchObject({ + kind: "assistant_reply", + text: "Just the answer.", + }); + }); +}); + describe("executeStep raw-network-failure classification", () => { const grammarsDir = join(process.cwd(), "grammars"); diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index b3d307a9..480efcfd 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -18,7 +18,10 @@ import { resourceClassFor, } from "./tool-resource-class.js"; import { createStreamParser } from "../llm/grammar/stream-parser.js"; -import type { StreamParseEvent } from "../llm/grammar/stream-parser.js"; +import type { + StreamParseEvent, + StreamParser, +} from "../llm/grammar/stream-parser.js"; import { checkProfilePromptAligned } from "../llm/profile-invariants.js"; import { CancelledError, @@ -42,6 +45,7 @@ import { getDefaultArgsJsonSchema } from "../prompt/default-tool-args-schemas.js import { validateJsonSchemaValue } from "../llm/provider/openai/coerce-json-schema-value.js"; import { buildPrompt } from "../prompt/build-prompt.js"; import type { BuiltPrompt } from "../prompt/build-prompt.js"; +import type { BuildPromptInput } from "../prompt/build-prompt-types.js"; import { formatCurrentDate } from "../prompt/current-date.js"; import type { CapabilitiesSummary, @@ -90,6 +94,19 @@ export type { PromptCapturedTokens, StepEvent } from "./step-events.js"; export interface LlmStreamParams { prompt: string; + /** + * Lazy grammar-transport variant of `prompt`. Set when `prompt` was + * built prefill-suppressed for a native-tools primary while the + * profile still expects the reasoning prefill / turn framing at a + * text-completion generation point (issue #283). A cross-transport + * fallover hands this request to a grammar (llama-server) link whose + * chat template and GBNF prelude assume the open tag is pre-typed — + * the fallback seam substitutes this variant there, so each link + * receives the prompt shape its transport expects. A thunk so the + * second `buildPrompt` only runs if a grammar link is actually chosen; + * implementations memoize. + */ + grammarPrompt?: () => string; grammar: string; slotId: number; sessionId: string; @@ -331,18 +348,33 @@ async function executeStepInner( ctx: StepContext, deps: StepDependencies, ): Promise { + const promptCarriesPrefill = promptCarriesReasoningPrefill( + deps.profile, + deps.toolTransport, + ); const stepToolDescriptors = ctx.terminalOnly ? ctx.toolDescriptors.filter( ({ name }) => name === "reply" || name === "finish", ) : ctx.toolDescriptors; - const prompt = buildPrompt({ + const promptInput: BuildPromptInput = { session: ctx.session, toolDescriptors: stepToolDescriptors, capabilities: ctx.capabilities, skillCatalog: ctx.skillCatalog, currentDate: formatCurrentDate(new Date()), profile: deps.profile, + // The prefix must match the request shape: a native-tools link gets + // native function-calling guidance instead of the text-JSON array + // mandate (issue #285). Configured transport, not `servedTransport`: + // the prompt is built before any fallback link serves the request. + ...(deps.toolTransport !== undefined + ? { toolTransport: deps.toolTransport } + : {}), + // Chat providers apply their own template server-side; a literal + // reasoning prefill there is at best echoed noise and at worst + // corrupted in transit (Ollama Cloud, ollama/ollama#17248). + suppressReasoningPrefill: deps.toolTransport === "native_tools", ...(deps.contextWindow !== undefined ? { contextWindow: deps.contextWindow } : {}), @@ -355,7 +387,31 @@ async function executeStepInner( ...(ctx.userMessage !== undefined ? { userMessage: ctx.userMessage } : {}), - }); + }; + const prompt = buildPrompt(promptInput); + // A grammar (llama-server) fallback link behind a native-tools primary + // still needs the legacy prefill-carrying prompt shape — its template + // and GBNF prelude expect the reasoning open tag / turn framing at the + // generation point, which the main prompt above deliberately dropped. + // Lazy + memoized: the second build only runs if the fallback seam + // actually routes this request to a grammar link (sticky-fallover + // turns included). See `LlmStreamParams.grammarPrompt`. + const grammarPrompt = + deps.toolTransport === "native_tools" && + deps.profile.requiresPromptThinkPrefix + ? memoizeText( + () => + buildPrompt({ + ...promptInput, + // The grammar link's template/GBNF prelude also expects the + // legacy text-JSON emission mandate, not the native + // function-calling guidance the primary prompt carries + // (issue #285) — rebuild for the grammar transport. + toolTransport: "grammar", + suppressReasoningPrefill: false, + }).text, + ) + : undefined; const slot = deps.supportsSlotAffinity ? deps.slotManager.acquire(ctx.session.id, prompt.stablePrefix) : { @@ -365,7 +421,9 @@ async function executeStepInner( cacheReused: false, }; if (ctx.stepIndex === 0) { - const promptViolations = checkProfilePromptAligned(deps.profile, prompt.text); + const promptViolations = checkProfilePromptAligned(deps.profile, prompt.text, { + promptCarriesPrefill, + }); if (promptViolations.length > 0) { deps.logger?.warn("profile/prompt invariant violated", { profile: deps.profile.id, @@ -395,14 +453,17 @@ async function executeStepInner( promptTokens: prompt.tokens.total, }); - const llmParams: LlmStreamParams = buildLlmStreamParams({ - promptText: prompt.text, - deps, - slotId: slot.slotId, - sessionId: ctx.session.id, - toolDescriptors: stepToolDescriptors, - signal: ctx.signal, - }); + const llmParams: LlmStreamParams = { + ...buildLlmStreamParams({ + promptText: prompt.text, + deps, + slotId: slot.slotId, + sessionId: ctx.session.id, + toolDescriptors: stepToolDescriptors, + signal: ctx.signal, + }), + ...(grammarPrompt ? { grammarPrompt } : {}), + }; const firstAttempt = await runInitialCompletion({ ctx, @@ -413,12 +474,25 @@ async function executeStepInner( }); let completion = firstAttempt.completion; + // Parse-side prefill assumption for a given completion: keyed off the + // transport that actually served it (cross-transport fallover swaps + // it), never off the primary's configuration. + const assumesOpenReasoning = (c: CompletionResult): boolean => + completionAssumesOpenReasoning( + deps.profile, + parseDepsFor(c, deps).toolTransport, + ); + // Prefer the dedicated `reasoning_content` channel when the server // (QwQ, DeepSeek-R1 with `--reasoning-format deepseek`) supplies it — // the content body then no longer embeds `...` blocks. // Fall back to extracting `` from `content` for classic builds // and models that stream CoT inline. - let reasoning = resolveReasoning(completion, deps.profile); + let reasoning = resolveReasoning( + completion, + deps.profile, + assumesOpenReasoning(completion), + ); if (reasoning.length > 0) { deps.onEvent?.({ type: "reasoning", @@ -670,9 +744,34 @@ async function executeStepInner( }); const retryStartedAt = Date.now(); + const repairError = parsed.error; completion = await deps.llmComplete({ ...llmParams, - prompt: buildToolCallRepairPrompt(prompt.text, parsed.error, deps.profile), + prompt: buildToolCallRepairPrompt( + prompt.text, + repairError, + deps.profile, + deps.toolTransport, + promptCarriesPrefill, + ), + // The grammar-link variant must be repair-shaped too — spreading + // `llmParams` alone would hand a grammar fallback link the STALE + // base prompt without the repair notice. It is repair-shaped for + // the GRAMMAR transport: text-JSON corrective mandate (issue #285) + // and the prefill strip/re-append (the grammar prompt carries it). + ...(grammarPrompt + ? { + grammarPrompt: memoizeText(() => + buildToolCallRepairPrompt( + grammarPrompt(), + repairError, + deps.profile, + "grammar", + true, + ), + ), + } + : {}), // Bounded cap on the repair completion. Without it, reasoning // models (qwen-3.5-9b in particular) routinely fall into a // self-deliberation loop after a `BatchValidationError` and burn @@ -709,7 +808,11 @@ async function executeStepInner( cacheReused: slot.cacheReused, }); - const retryReasoning = resolveReasoning(completion, deps.profile); + const retryReasoning = resolveReasoning( + completion, + deps.profile, + assumesOpenReasoning(completion), + ); if (retryReasoning.length > 0) { deps.onEvent?.({ type: "reasoning", @@ -799,7 +902,11 @@ async function executeStepInner( // already applies — instead of failing the whole loop. Only // reasoning came back? Then there is no answer to deliver and the // `GrammarError` still stands. - const fallback = replyFallbackBatch(completion, deps.profile); + const fallback = replyFallbackBatch( + completion, + deps.profile, + assumesOpenReasoning(completion), + ); if (fallback === null) { throw new GrammarError( parsed.error.message, @@ -1020,6 +1127,7 @@ async function runInitialCompletion( deps.llmCompleteStream(llmParams), ctx.stepIndex, deps.profile, + deps.toolTransport, deps.onEvent, ) : await deps.llmComplete(llmParams); @@ -1042,6 +1150,68 @@ async function runInitialCompletion( return { completion }; } +/** + * Whether the prompt built for this runtime carries the trailing + * reasoning-open prefill (`` for qwen-think) / Gemma turn-framing + * tokens. + * + * The prefill is a llama-server *text-completion* artifact: the local + * template expects the open tag pre-typed at the generation point. On + * the native-tools chat transport the prompt ships as a chat message to + * an OpenAI-compatible endpoint, where the literal tag is at best noise + * the model echoes back and at worst corrupted server-side (Ollama + * Cloud mangles literal ``/`` strings — + * ollama/ollama#17248, issue #283) — so `buildPrompt` suppresses it + * there. This predicate keys the PROMPT-side consumers (the alignment + * invariant check, the repair prompt's strip/re-append). Parse-side + * consumers key off `completionAssumesOpenReasoning` with the transport + * that actually served the completion instead — the two differ on a + * cross-transport fallover. + */ +function promptCarriesReasoningPrefill( + profile: ModelProfile, + toolTransport: ToolCallTransport, +): boolean { + return toolTransport !== "native_tools" && profile.requiresPromptThinkPrefix; +} + +/** + * Whether a completion should be parsed as continuing an already-open + * reasoning block (re-prepending the open tag before extraction / + * pre-opening the stream parser's think state). + * + * Keyed purely off the transport that served (or is serving) the + * completion: + * - **Grammar-served output always starts mid-think** — the GBNF + * prelude root emits `body ""` without the open tag — even + * when the prompt did not prefill (a native-tools primary that fell + * over to a grammar local link is handed the prefill-carrying + * `grammarPrompt` variant anyway, see `LlmStreamParams.grammarPrompt`). + * - **A chat (native-tools) completion never continues our + * text-completion prefill**: the reply starts fresh server-side, so + * prepending the open tag would swallow a clean reply whole as + * reasoning. That holds even in the unsupported grammar-primary → + * native-link ordering, where the outbound prompt still (incorrectly) + * carries the literal prefill inside the chat message. + */ +function completionAssumesOpenReasoning( + profile: ModelProfile, + parseTransport: ToolCallTransport, +): boolean { + if (!profile.requiresPromptThinkPrefix) return false; + return parseTransport !== "native_tools"; +} + +/** + * Memoize a lazily built prompt variant so the extra `buildPrompt` / + * repair-prompt render runs at most once per step however many fallback + * attempts consume it. + */ +function memoizeText(build: () => string): () => string { + let cached: string | null = null; + return () => (cached ??= build()); +} + /** * Resolve the reasoning text for a completion, preferring the dedicated * `reasoning_content` channel when present and falling back to inline @@ -1050,13 +1220,18 @@ async function runInitialCompletion( function resolveReasoning( completion: CompletionResult, profile: ModelProfile, + assumeOpenReasoning: boolean, ): string { const fromChannel = typeof completion.reasoningContent === "string" ? completion.reasoningContent : ""; if (fromChannel.length > 0) return fromChannel; - const normalizedContent = normalizeContent(completion, profile); + const normalizedContent = normalizeContent( + completion, + profile, + assumeOpenReasoning, + ); const extracted = extractReasoning( normalizedContent, getReasoningTagOptions(profile), @@ -1067,8 +1242,9 @@ function resolveReasoning( function normalizeContent( completion: CompletionResult, profile: ModelProfile, + assumeOpenReasoning: boolean, ): string { - return profile.requiresPromptThinkPrefix + return assumeOpenReasoning ? `${getReasoningOpenTagPrefix(profile)}${completion.content}` : completion.content; } @@ -1093,9 +1269,11 @@ function isNativeToolsEmptyCompletionHandledByParser( // Reasoning-only completions (Qwen3.8 with preserve_thinking, // DeepSeek-R1 over OpenAI-compatible APIs): the model ends its turn // with all text in `reasoning_content`, `content` empty and no - // tool_calls. The parser salvages these — a GBNF-shaped batch inside - // the reasoning, or the reasoning itself as a `reply`. Only a - // completion with NOTHING in any channel routes through ModelError. + // tool_calls. The parser gets a crack at these: a GBNF-shaped batch + // inside the reasoning is recovered as real tool calls; anything else + // fails the parse and routes through the one-shot repair (never as a + // raw-CoT `reply` — issue #285). Only a completion with NOTHING in + // any channel routes through ModelError. const reasoning = typeof completion.reasoningContent === "string" ? completion.reasoningContent.trim() @@ -1168,11 +1346,19 @@ function tryParseToolCalls( profile: ModelProfile, deps: Pick, ): ToolCallBatchParseResult { + const assumeOpenReasoning = completionAssumesOpenReasoning( + profile, + deps.toolTransport, + ); try { if (deps.toolTransport === "native_tools") { if (completion.toolCalls && completion.toolCalls.length > 0) { const adapter = deps.toolCallAdapter ?? openAiToolCallAdapter; - const reasoning = resolveReasoning(completion, profile); + const reasoning = resolveReasoning( + completion, + profile, + assumeOpenReasoning, + ); const batch = adapter.toolCallsToBatch( completion.toolCalls, reasoning, @@ -1209,7 +1395,7 @@ function tryParseToolCalls( if (typeof replyText === "string" && replyText.trim().length > 0) { try { const grammarBatch = parseToolCalls( - normalizeContent(completion, profile), + normalizeContent(completion, profile, assumeOpenReasoning), getReasoningTagOptions(profile), ); if (grammarBatch.calls.length > 0) { @@ -1228,7 +1414,11 @@ function tryParseToolCalls( } catch { // Not a GBNF-shaped completion — fall through to the reply wrap. } - const reasoning = resolveReasoning(completion, profile); + const reasoning = resolveReasoning( + completion, + profile, + assumeOpenReasoning, + ); return { ok: true, batch: { @@ -1245,11 +1435,15 @@ function tryParseToolCalls( }; } // Reasoning-only completion: no tool_calls, empty content, but the - // think channel carries text. Same two recovery paths as for plain - // content, applied to the reasoning body: models occasionally emit - // the GBNF-style call array inside the think block, and a model - // that reasoned its way to a final answer without ever leaving the - // think channel still has an answer worth delivering as `reply`. + // think channel carries text. Models occasionally emit the + // GBNF-style call array inside the think block — recover those + // calls (they are the model's real intent). Anything else is NOT + // salvaged: `reasoning_content` is internal scratch space by + // OpenAI-compatible convention, and wrapping it as a `reply` leaks + // raw chain-of-thought verbatim as deliberate agent speech (issue + // #285). Returning `ok: false` routes the completion through the + // same one-shot repair as every other unparseable body; a repair + // that fails too ends the step as a parse error, not a CoT leak. const reasoningText = typeof completion.reasoningContent === "string" ? completion.reasoningContent.trim() @@ -1269,21 +1463,13 @@ function tryParseToolCalls( return { ok: true, batch: { ...grammarBatch, calls } }; } } catch { - // Not GBNF-shaped — fall through to the reply wrap. + // Not GBNF-shaped — fall through to the parse failure below. } return { - ok: true, - batch: { - kind: "batch", - calls: [ - { - tool: "reply", - args: { text: reasoningText }, - reasoning: reasoningText, - }, - ], - reasoning: reasoningText, - }, + ok: false, + error: new Error( + "reasoning-only completion: no tool_calls, empty content, and the reasoning body is not a tool-call array", + ), }; } return { @@ -1294,7 +1480,7 @@ function tryParseToolCalls( }; } const batch = parseToolCalls( - normalizeContent(completion, profile), + normalizeContent(completion, profile, assumeOpenReasoning), getReasoningTagOptions(profile), ); return { ok: true, batch }; @@ -1315,9 +1501,10 @@ function tryParseToolCalls( function replyFallbackBatch( completion: CompletionResult, profile: ModelProfile, + assumeOpenReasoning: boolean, ): ToolCallBatch | null { const extracted = extractReasoning( - normalizeContent(completion, profile), + normalizeContent(completion, profile, assumeOpenReasoning), getReasoningTagOptions(profile), ); const text = extracted.body.trim(); @@ -1327,7 +1514,7 @@ function replyFallbackBatch( // validation) — echoing that literal back at the user would be worse // than the `GrammarError`. if (text.startsWith("{") || text.startsWith("[")) return null; - const reasoning = resolveReasoning(completion, profile); + const reasoning = resolveReasoning(completion, profile, assumeOpenReasoning); return { kind: "batch", calls: [ @@ -1638,6 +1825,8 @@ function buildToolCallRepairPrompt( promptText: string, error: Error, profile?: ModelProfile, + toolTransport?: ToolCallTransport, + promptCarriedPrefill = true, ): string { // Strip the trailing reasoning open-tag prefill (e.g. `` for // qwen-think, `<|channel>thought\n` for gemma4-think) before @@ -1657,7 +1846,14 @@ function buildToolCallRepairPrompt( // `GrammarError: tool-call body is empty`. Letting the model think // normally in repair — bounded by `REPAIR_MAX_TOKENS` so it cannot // run away — restores grammar-clean output. - const baseText = stripTrailingReasoningPrefill(promptText, profile); + // + // When the prompt never carried the prefill (native-tools chat + // transport, issue #283) there is nothing to strip — and nothing to + // re-append either: adding `` here would ship the literal tag + // to the cloud endpoint the main prompt deliberately keeps it out of. + const baseText = promptCarriedPrefill + ? stripTrailingReasoningPrefill(promptText, profile) + : promptText; const lines = [ baseText.trimEnd(), "", @@ -1673,15 +1869,35 @@ function buildToolCallRepairPrompt( lines.push("per-call errors:", ...perCall); } } - lines.push( - "Emit a corrected JSON array only. No prose, no commentary after the array.", - "Use a length-1 array for `reply`, `finish`, approval-gated tools, or any call that depends on a previous result.", - "Do not repeat the invalid batch shape.", - "", - "### respond", - "Respond now.", - ); - const openReasoning = renderOpenReasoningBlock(profile); + // The corrective mandate must match the request's transport. This + // repair replays with the SAME params as the failed attempt — under + // `native_tools` that request carries the OpenAI `tools` payload and a + // stable prefix that forbids text-JSON emission, so ordering a + // "corrected JSON array" here would re-create the exact dual mandate + // issue #285 removed, on the one retry a failing model gets before + // GrammarError ends the step. + if (toolTransport === "native_tools") { + lines.push( + "Call the tools again now, through the native function-calling interface (the `tools` payload on this API request) — do NOT write tool-call JSON as text, and do not leave the answer in the reasoning channel.", + "Make it a single tool call for `reply`, `finish`, approval-gated tools, or any call that depends on a previous result.", + "Do not repeat the invalid shape.", + "", + "### respond", + "Respond now.", + ); + } else { + lines.push( + "Emit a corrected JSON array only. No prose, no commentary after the array.", + "Use a length-1 array for `reply`, `finish`, approval-gated tools, or any call that depends on a previous result.", + "Do not repeat the invalid batch shape.", + "", + "### respond", + "Respond now.", + ); + } + const openReasoning = promptCarriedPrefill + ? renderOpenReasoningBlock(profile) + : ""; if (openReasoning.length > 0) { lines.push(openReasoning); } @@ -1814,22 +2030,37 @@ async function consumeStream( stream: AsyncGenerator, stepIndex: number, profile: ModelProfile, + primaryTransport: ToolCallTransport, onEvent?: (event: StepEvent) => void, ): Promise { - const parser = createStreamParser({ - // Pre-opened only when the open tag is prefilled in the prompt. With - // model-emitted reasoning (Gemma 4 turn-framing) the parser must detect - // the open tag live in the stream instead. - preOpenedThink: - profile.requiresPromptThinkPrefix && - !reasoningOpenEmittedByModel(profile), - ...(profile.reasoningStyle !== "none" - ? { - reasoningOpenTag: profile.reasoningOpenTag, - reasoningCloseTag: profile.reasoningCloseTag, - } - : {}), - }); + // The parser's pre-opened state depends on which link SERVES the + // stream, not on the configured primary: a native-tools primary that + // fell over to a grammar local link streams GBNF output that starts + // mid-`` (the fallback seam stamps `servedTransport` on every + // chunk precisely so this is knowable live — the final result's stamp + // arrives only after the last delta, too late to classify reasoning). + // Created lazily on the first chunk; unstamped chunks (direct, + // non-fallback path) key off the primary transport. With model-emitted + // reasoning (Gemma 4 turn-framing) the parser must always detect the + // open tag live in the stream instead. + let servedTransport: ToolCallTransport | undefined; + let parser: StreamParser | null = null; + const getParser = (): StreamParser => { + parser ??= createStreamParser({ + preOpenedThink: + completionAssumesOpenReasoning( + profile, + servedTransport ?? primaryTransport, + ) && !reasoningOpenEmittedByModel(profile), + ...(profile.reasoningStyle !== "none" + ? { + reasoningOpenTag: profile.reasoningOpenTag, + reasoningCloseTag: profile.reasoningCloseTag, + } + : {}), + }); + return parser; + }; let accumulated = ""; // Channel A (server-side `reasoning_content` SSE deltas: QwQ / // DeepSeek-R1 with `--reasoning-format deepseek`) is mutually exclusive @@ -1859,6 +2090,11 @@ async function consumeStream( break; } const chunk = next.value; + // Latch the serving link's transport off the first stamped chunk — + // it is constant for the whole stream (a live stream is never + // restarted on another link) and must be known before the parser is + // first used. + servedTransport ??= chunk.servedTransport; // Channel A: dedicated `reasoning_content` deltas (QwQ, DeepSeek-R1 // with `--reasoning-format deepseek`). Bypass the grammar parser — // these tokens never appear inside `` or JSON, they come on a @@ -1876,7 +2112,7 @@ async function consumeStream( // reasoning / reply-text deltas for us. if (chunk.delta.length > 0) { accumulated += chunk.delta; - emitParseEvents(parser.push(chunk.delta)); + emitParseEvents(getParser().push(chunk.delta)); } if (chunk.done) { // Some servers close the iterator right after the done frame; keep @@ -1884,7 +2120,7 @@ async function consumeStream( // hanging. } } - emitParseEvents(parser.end()); + emitParseEvents(getParser().end()); // Prefer server-emitted channel A reasoning when present; otherwise // fall back to the parser-derived stream (legacy `/completion` // endpoint, which never sets `reasoning_content` server-side). diff --git a/src/cli/index.ts b/src/cli/index.ts index e706f2eb..7b71f344 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { configCommand } from "./config-command.js"; import { serveCommand } from "./serve-command.js"; import { traceCommand } from "./trace-command.js"; import { taskCommand } from "./task-command.js"; +import { memoryCommand } from "./memory-command.js"; import { modelsCommand } from "./models-command.js"; import { importCommand } from "./import-command.js"; import { uninstallCommand } from "./uninstall-command.js"; @@ -28,9 +29,9 @@ interface CommandDescriptor { * 2 usage error — unknown command or subcommand, missing required * argument, argument of the wrong kind. Nothing was attempted. * - * `run`, `skill` and the dispatcher below implement this split. The - * rest of the table does not, and a caller must not read their codes - * through it: + * `run`, `skill`, `memory` and the dispatcher below implement this + * split. The rest of the table does not, and a caller must not read + * their codes through it: * * - `config`, `serve`, `trace`, `task`, `models`, `import` predate * the split and return `1` for usage errors too, so their `1` does @@ -96,6 +97,11 @@ const COMMANDS: CommandDescriptor[] = [ summary: "Manage durable tasks (list|show|create|cancel|run)", run: taskCommand, }, + { + name: "memory", + summary: "Inspect + export the cross-session memory store (export)", + run: memoryCommand, + }, { name: "models", summary: diff --git a/src/cli/memory-command.test.ts b/src/cli/memory-command.test.ts new file mode 100644 index 00000000..235ba880 --- /dev/null +++ b/src/cli/memory-command.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resetConfigCache } from "../config/index.js"; +import { MemoryStore } from "../memory/memory-store.js"; + +import { memoryCommand } from "./memory-command.js"; + +describe("memoryCommand", () => { + let stateDir: string; + let vaultDir: string; + let stdoutChunks: string[]; + let stderrChunks: string[]; + let savedVaultEnv: string | undefined; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-memory-cli-")); + vaultDir = join(stateDir, "vault"); + mkdirSync(vaultDir); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + savedVaultEnv = process.env.OBSIDIAN_VAULT_PATH; + delete process.env.OBSIDIAN_VAULT_PATH; + resetConfigCache(); + stdoutChunks = []; + stderrChunks = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + }); + + afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); + delete process.env.ATOMIC_AGENT_STATE_DIR; + if (savedVaultEnv === undefined) delete process.env.OBSIDIAN_VAULT_PATH; + else process.env.OBSIDIAN_VAULT_PATH = savedVaultEnv; + resetConfigCache(); + vi.restoreAllMocks(); + }); + + function stdout(): string { + return stdoutChunks.join(""); + } + + function stderr(): string { + return stderrChunks.join(""); + } + + function seedMemory(): void { + const store = new MemoryStore({ + dbFile: join(stateDir, "memory.sqlite"), + maxEntries: 100, + }); + try { + store.store({ content: "remember the milk", tags: ["errand"] }); + } finally { + store.close(); + } + } + + it("prints help when no subcommand is passed", async () => { + const code = await memoryCommand([]); + expect(code).toBe(0); + expect(stdout()).toMatch(/atomic-agent memory/); + expect(stdout()).toMatch(/export \[--vault/); + }); + + it("rejects an unknown subcommand with a usage error", async () => { + const code = await memoryCommand(["nope"]); + expect(code).toBe(2); + expect(stderr()).toMatch(/unknown subcommand: nope/); + }); + + it("requires a vault path from --vault or OBSIDIAN_VAULT_PATH", async () => { + const code = await memoryCommand(["export"]); + expect(code).toBe(2); + expect(stderr()).toMatch(/--vault /); + }); + + it("exports the state-dir corpus into the vault passed via --vault", async () => { + seedMemory(); + const code = await memoryCommand(["export", "--vault", vaultDir]); + expect(code).toBe(0); + expect(stdout()).toMatch(/exported 1 notes, 0 lessons, 0 procedures -> /); + expect( + existsSync(join(vaultDir, "atomic-agent", "notes", "note-1.md")), + ).toBe(true); + }); + + it("falls back to OBSIDIAN_VAULT_PATH and honors --folder", async () => { + seedMemory(); + process.env.OBSIDIAN_VAULT_PATH = vaultDir; + const code = await memoryCommand(["export", "--folder", "brain"]); + expect(code).toBe(0); + expect(existsSync(join(vaultDir, "brain", "notes", "note-1.md"))).toBe(true); + }); + + it("maps a folder escaping the vault to a usage error", async () => { + seedMemory(); + const code = await memoryCommand([ + "export", + "--vault", + vaultDir, + "--folder", + "..", + ]); + expect(code).toBe(2); + expect(stderr()).toMatch(/--folder must name a subfolder/); + }); + + it("reports a missing memory database as an operational failure", async () => { + const code = await memoryCommand(["export", "--vault", vaultDir]); + expect(code).toBe(1); + expect(stderr()).toMatch(/memory export failed: no memory database/); + }); +}); diff --git a/src/cli/memory-command.ts b/src/cli/memory-command.ts new file mode 100644 index 00000000..f0f694b2 --- /dev/null +++ b/src/cli/memory-command.ts @@ -0,0 +1,95 @@ +import { getConfig } from "../config/index.js"; +import { + DEFAULT_EXPORT_FOLDER, + ObsidianExportUsageError, + exportMemoryToObsidian, +} from "../memory/index.js"; + +const HELP = + [ + "atomic-agent memory — inspect + export the cross-session memory store", + "", + "The corpus (notes / lessons / procedures) lives in /memory.sqlite.", + "", + "Subcommands:", + " export [--vault ] [--folder ]", + " One-way export of the corpus into an Obsidian", + " vault as markdown files with YAML frontmatter", + " and [[wikilinks]] along the schema's own edges", + " (memory links, lesson/procedure parents).", + " --vault defaults to $OBSIDIAN_VAULT_PATH and", + " must point at an existing vault directory.", + ` --folder is the vault subfolder the export`, + ` owns (default '${DEFAULT_EXPORT_FOLDER}'). Idempotent:`, + " re-exports overwrite in place; stale note-.md /", + " lesson-.md / procedure-.md files whose", + " record is gone are pruned; other files are", + " never touched. The database is opened", + " read-only — nothing syncs back.", + "", + "Examples:", + " atomic-agent memory export --vault ~/Documents/MyVault", + " OBSIDIAN_VAULT_PATH=~/Documents/MyVault atomic-agent memory export", + ].join("\n") + "\n"; + +export async function memoryCommand(args: string[]): Promise { + const sub = args[0]; + if (!sub || sub === "-h" || sub === "--help") { + process.stdout.write(HELP); + return 0; + } + switch (sub) { + case "export": + return handleExport(args.slice(1)); + default: + process.stderr.write(`unknown subcommand: ${sub}\n`); + process.stderr.write(HELP); + return 2; + } +} + +function handleExport(args: string[]): number { + if (args.includes("-h") || args.includes("--help")) { + process.stdout.write(HELP); + return 0; + } + // `getConfig()` first: it merges `/.env` into `process.env`, + // so an OBSIDIAN_VAULT_PATH kept there is visible below. + const config = getConfig(); + const vaultDir = readOption(args, "--vault") ?? process.env.OBSIDIAN_VAULT_PATH; + if (!vaultDir) { + process.stderr.write( + "usage: atomic-agent memory export --vault [--folder ] (or set OBSIDIAN_VAULT_PATH)\n", + ); + return 2; + } + const folder = readOption(args, "--folder"); + try { + const result = exportMemoryToObsidian({ + dbFile: config.paths.memoryDbFile, + vaultDir, + ...(folder !== undefined ? { folder } : {}), + }); + const pruned = result.pruned > 0 ? ` (pruned ${result.pruned} stale)` : ""; + process.stdout.write( + `exported ${result.notes} notes, ${result.lessons} lessons, ${result.procedures} procedures -> ${result.root}${pruned}\n`, + ); + return 0; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof ObsidianExportUsageError) { + process.stderr.write(`${message}\n`); + return 2; + } + process.stderr.write(`memory export failed: ${message}\n`); + return 1; + } +} + +function readOption(args: string[], name: string): string | undefined { + const idx = args.indexOf(name); + if (idx < 0) return undefined; + const value = args[idx + 1]; + if (!value || value.startsWith("--")) return undefined; + return value; +} diff --git a/src/cli/models-command.test.ts b/src/cli/models-command.test.ts index b2253006..f443e166 100644 --- a/src/cli/models-command.test.ts +++ b/src/cli/models-command.test.ts @@ -215,4 +215,20 @@ describe("modelsCommand", () => { resetConfigCache(); expect(getConfig().localModels.managed.device).toBe("cpu"); }); + + it("use-device accepts a comma-separated device list (multi-GPU)", async () => { + const path = getUserConfigPath(stateDir); + writeUserConfigFileSync(path, USER_CONFIG_DEFAULTS); + resetConfigCache(); + const code = await modelsCommand(["use-device", "Vulkan0,Vulkan1"]); + expect(code).toBe(0); + resetConfigCache(); + expect(getConfig().localModels.managed.device).toBe("Vulkan0,Vulkan1"); + }); + + it("use-device rejects a malformed device list", async () => { + const code = await modelsCommand(["use-device", "Vulkan0,,Vulkan1"]); + expect(code).toBe(1); + expect(stderrChunks.join("")).toMatch(/invalid device/); + }); }); diff --git a/src/cli/models-handlers.test.ts b/src/cli/models-handlers.test.ts new file mode 100644 index 00000000..74a14d90 --- /dev/null +++ b/src/cli/models-handlers.test.ts @@ -0,0 +1,167 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../local-llm/index.js", async () => { + const actual = + await vi.importActual( + "../local-llm/index.js", + ); + return { + ...actual, + maybeAutoUpdateBackend: vi.fn(), + resolveManagedDevice: vi.fn(), + startChatAndEmbeddingDaemons: vi.fn(), + fallBackToCpuBackend: vi.fn(), + }; +}); + +import { getUserConfigPath, writeUserConfigFileSync } from "../config/config-file.js"; +import { USER_CONFIG_DEFAULTS } from "../config/config-schema.js"; +import { getConfig, resetConfigCache } from "../config/index.js"; +import * as localLlm from "../local-llm/index.js"; +import { resolveBackendDir } from "../local-llm/index.js"; +import { writeBackendVersion } from "../local-llm/backend-version.js"; +import { DaemonHealthError } from "../local-llm/daemon-lifecycle.js"; +import { + WINDOWS_BACKEND_ASSETS, + setConfiguredBackendVariant, +} from "../local-llm/windows-backend-variant.js"; +import { runLocalModelsStart } from "./models-handlers.js"; + +const healthError = () => + new DaemonHealthError( + "llama-server did not become healthy within 30000ms. Log tail:\n(no log)", + ); + +/** + * Integration tests for the CPU-backend fallback retry block inside + * `runLocalModelsStart` — the CLI twin of the orchestrator wiring + * covered in `local-models-orchestrator-cpu-fallback.test.ts`. The pure + * pieces have their own tests; these prove the handler actually + * consults them, retries on the CPU device, and persists the variant. + */ +describe("runLocalModelsStart CPU-backend fallback", () => { + let stateDir: string; + let stderrChunks: string[]; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "atomic-models-cpufb-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + // The fallback is Windows-only; the real eligibility gate must see + // win32 or these tests would silently assert nothing. + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + resetConfigCache(); + setConfiguredBackendVariant("auto"); + stderrChunks = []; + vi.spyOn(process.stdout, "write").mockReturnValue(true); + vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + vi.mocked(localLlm.maybeAutoUpdateBackend) + .mockReset() + .mockResolvedValue({ action: "current", tag: "turboquant-win" }); + vi.mocked(localLlm.resolveManagedDevice).mockReset().mockResolvedValue("Vulkan0"); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockReset(); + vi.mocked(localLlm.fallBackToCpuBackend) + .mockReset() + .mockResolvedValue({ tag: "turboquant-win" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setConfiguredBackendVariant("auto"); + delete process.env.ATOMIC_AGENT_STATE_DIR; + resetConfigCache(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("retries once on the CPU device and persists the variant", async () => { + const dataDir = prepareManagedWindowsInstall(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons) + .mockRejectedValueOnce(healthError()) + .mockResolvedValueOnce({ chat: { pid: 777 }, embedding: { skipped: true } }); + + await expect(runLocalModelsStart()).resolves.toBe(0); + + // The download must carry a deadline and progress — the exact + // stalled-open-connection hazard the auto-update path guards. + expect(localLlm.fallBackToCpuBackend).toHaveBeenCalledTimes(1); + const [calledDataDir, dlOpts] = vi.mocked(localLlm.fallBackToCpuBackend).mock + .calls[0]! as [string, { signal?: AbortSignal; onProgress?: unknown }]; + expect(calledDataDir).toBe(dataDir); + expect(dlOpts.signal).toBeInstanceOf(AbortSignal); + expect(dlOpts.onProgress).toBeTypeOf("function"); + + // The GPU device picked against the old binary must not leak into + // the retry — the CPU build would reject `--device Vulkan0`. + const starts = vi.mocked(localLlm.startChatAndEmbeddingDaemons).mock.calls; + expect(starts).toHaveLength(2); + expect(starts[0]![0].chat.device).toBe("Vulkan0"); + expect(starts[1]![0].chat.device).toBe("cpu"); + + // Loop-guard against auto-update reinstalling the broken GPU build. + expect(getConfig().localModels.managed.backendVariant).toBe("cpu"); + const stderr = stderrChunks.join(""); + expect(stderr).toContain("falling back to the CPU build"); + expect(stderr).toContain('recorded backendVariant "cpu"'); + }); + + it("does not fall back on a pre-spawn failure", async () => { + prepareManagedWindowsInstall(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockRejectedValue( + new Error("model qwen not downloaded"), + ); + + await expect(runLocalModelsStart()).resolves.toBe(1); + + expect(localLlm.fallBackToCpuBackend).not.toHaveBeenCalled(); + expect(localLlm.startChatAndEmbeddingDaemons).toHaveBeenCalledTimes(1); + expect(getConfig().localModels.managed.backendVariant).toBe("auto"); + expect(stderrChunks.join("")).toContain("model qwen not downloaded"); + }); + + it("surfaces a failed CPU download and exits non-zero", async () => { + prepareManagedWindowsInstall(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockRejectedValue(healthError()); + vi.mocked(localLlm.fallBackToCpuBackend).mockRejectedValue(new Error("HTTP 503")); + + await expect(runLocalModelsStart()).resolves.toBe(1); + + expect(localLlm.startChatAndEmbeddingDaemons).toHaveBeenCalledTimes(1); + expect(stderrChunks.join("")).toContain("HTTP 503"); + }); + + /** + * Managed mode over a stub Windows GPU install, so the real + * `shouldFallBackToCpuBackend` sees an eligible machine: win32, + * variant `auto`, a GPU asset recorded in backend-version.json. + */ + function prepareManagedWindowsInstall(): string { + writeUserConfigFileSync(getUserConfigPath(stateDir), { + ...USER_CONFIG_DEFAULTS, + localModels: { + ...USER_CONFIG_DEFAULTS.localModels, + mode: "managed", + managed: { + ...USER_CONFIG_DEFAULTS.localModels.managed, + modelId: "qwen-3.5-4b", + }, + }, + }); + resetConfigCache(); + const dataDir = getConfig().paths.localModelsDataDir; + mkdirSync(resolveBackendDir(dataDir), { recursive: true }); + writeBackendVersion(dataDir, { + tag: "turboquant-win", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset: WINDOWS_BACKEND_ASSETS.vulkan, + releasedAt: "2026-06-01T00:00:00Z", + }); + return dataDir; + } +}); diff --git a/src/cli/models-handlers.ts b/src/cli/models-handlers.ts index 540d6408..fb4bd79f 100644 --- a/src/cli/models-handlers.ts +++ b/src/cli/models-handlers.ts @@ -9,6 +9,8 @@ import { downloadEmbeddingModel, downloadModel, EMBEDDING_MODELS_CATALOG, + fallBackToCpuBackend, + getConfiguredBackendVariant, getDaemonStatus, getEmbeddingDaemonStatus, getEmbeddingModelDef, @@ -30,6 +32,7 @@ import { resolveMmprojFilePath, resolvePlatformAsset, resolveServerBinPath, + shouldFallBackToCpuBackend, startChatAndEmbeddingDaemons, stopChatAndEmbeddingDaemons, } from "../local-llm/index.js"; @@ -300,16 +303,22 @@ export async function runLocalModelsStart(): Promise { } // Resolve the GPU preference once so both daemons land on the same - // device and we can name the chosen device in the start output. + // device and we can name the chosen device in the start output. A + // configured tensor split keeps `auto` from pinning one device — the + // chat daemon needs every GPU visible to spread layers across them. + const tensorSplit = cfg.localModels.managed.tensorSplit; + const multiGpu = tensorSplit.length > 0; const { binaryName } = resolvePlatformAsset(); const binPath = resolveServerBinPath(dataDir, binaryName); - const device = await resolveManagedDevice(binPath, cfg.localModels.managed.device); + const device = await resolveManagedDevice(binPath, cfg.localModels.managed.device, { + multiGpu, + }); process.stdout.write( - `device: ${describeDeviceChoice(cfg.localModels.managed.device, device)}\n`, + `device: ${describeDeviceChoice(cfg.localModels.managed.device, device, multiGpu)}\n`, ); - try { - const result = await startChatAndEmbeddingDaemons({ + const startWithDevice = (dev: string | undefined) => + startChatAndEmbeddingDaemons({ chat: { dataDir, modelId: mid, @@ -317,7 +326,11 @@ export async function runLocalModelsStart(): Promise { contextSize: cfg.localModels.managed.contextSize, ...(tpl ? { chatTemplateFile: tpl } : {}), ...(mmprojFile ? { mmprojFile } : {}), - ...(device ? { device } : {}), + ...(dev ? { device: dev } : {}), + // A configured tensor split only applies while a GPU build + // serves — the forced-CPU rescue retry (`startWithDevice("cpu")`) + // must not hand multi-GPU split args to the CPU backend. + ...(multiGpu && dev !== "cpu" ? { tensorSplit } : {}), }, ...(embRequested && embReady ? { @@ -325,11 +338,48 @@ export async function runLocalModelsStart(): Promise { dataDir, modelId: embCfg.modelId as never, port: embCfg.port, - ...(device ? { device } : {}), + ...(dev ? { device: dev } : {}), }, } : {}), }); + + try { + let result: Awaited>; + try { + result = await startWithDevice(device); + } catch (e) { + // Windows iGPU-only rescue: the installed GPU build spawned but + // never became healthy (its compute backend cannot load a model + // on this machine — e.g. Vulkan on an AMD APU), so swap in the + // CPU build the nightly also publishes and retry once. + if ( + !shouldFallBackToCpuBackend({ + installedAsset: readBackendVersion(dataDir)?.asset, + configuredVariant: getConfiguredBackendVariant(), + error: e, + }) + ) { + throw e; + } + process.stderr.write( + "the GPU llama.cpp build failed to serve on this machine — falling back to the CPU build…\n", + ); + persistBackendVariantCpu(cfg.paths.userConfigFile); + await fallBackToCpuBackend(dataDir, { + signal: AbortSignal.timeout(BACKEND_DOWNLOAD_TIMEOUT_MS), + onProgress: (p: number, t: number, tot: number) => { + const line = renderPullProgress("cpu backend zip", p, t, tot); + if (process.stderr.isTTY) process.stderr.write(`\r${line.padEnd(79)}`); + else if (p % 5 === 0 || p === 100) process.stderr.write(`${line}\n`); + }, + }); + if (process.stderr.isTTY) process.stderr.write("\n"); + // The GPU device picked against the old binary is meaningless to + // the CPU build (it would reject `--device Vulkan0`). + process.stderr.write("retrying start on the CPU build…\n"); + result = await startWithDevice("cpu"); + } const visionLine = mmprojFile ? `, vision enabled (${m.mmprojFilename})` : m.supportsVision @@ -361,6 +411,36 @@ export async function runLocalModelsStart(): Promise { } } +/** + * Record the CPU fallback as `localModels.managed.backendVariant = "cpu"`. + * Without this the next auto-update's variant-staleness check would + * resolve the GPU zip again and reinstall the build that just failed. + * Best-effort: a config write failure downgrades to a session-only + * fallback (`fallBackToCpuBackend` flips the in-process preference + * regardless) with a note. + */ +function persistBackendVariantCpu(path: string): void { + try { + const user = ensureUserConfigFileSync(path); + const next: UserConfigFile = { + ...user, + localModels: { + ...user.localModels, + managed: { ...user.localModels.managed, backendVariant: "cpu" }, + }, + }; + writeUserConfigFileSync(path, next); + resetConfigCache(); + process.stderr.write( + 'recorded backendVariant "cpu" in config.json — set it to "auto" or "vulkan" to try the GPU build again (e.g. after a driver update)\n', + ); + } catch (e) { + process.stderr.write( + `note: could not persist backendVariant — ${e instanceof Error ? e.message : String(e)}; the CPU build is used for this session only\n`, + ); + } +} + export async function runLocalModelsStop(): Promise { try { await stopChatAndEmbeddingDaemons(getConfig().paths.localModelsDataDir); @@ -377,15 +457,21 @@ export async function runLocalModelsStop(): Promise { * resolved at start. `configured` is the raw config value (`auto` / * `cpu` / a device id); `resolved` is what `resolveManagedDevice` * returned (`undefined` means no GPU was picked → llama.cpp default / - * CPU fallback). + * CPU fallback). `multiGpu` (a configured tensor split) relabels the + * unresolved-`auto` case: there it means "all GPUs, split", not "no + * GPU detected". */ function describeDeviceChoice( configured: string, resolved: string | undefined, + multiGpu = false, ): string { if (configured === "cpu") return "cpu (forced, -ngl 0)"; if (configured === "auto") { - return resolved ? `auto → ${resolved}` : "auto → CPU (no GPU detected)"; + if (resolved) return `auto → ${resolved}`; + return multiGpu + ? "auto → all GPUs (tensor split)" + : "auto → CPU (no GPU detected)"; } return resolved ?? configured; } @@ -397,7 +483,12 @@ function describeDeviceChoice( */ const BACKEND_DOWNLOAD_TIMEOUT_MS = 10 * 60_000; -const DEVICE_ID_RE = /^[A-Za-z]+\d+$/; +/** + * One backend device id, or a comma-separated list of them (llama-server + * accepts `--device Vulkan0,Vulkan1` — the multi-GPU restriction that + * pairs with `localModels.managed.tensorSplit`). + */ +const DEVICE_ID_RE = /^[A-Za-z]+\d+(,[A-Za-z]+\d+)*$/; /** * List compute devices reported by `llama-server --list-devices`. The @@ -415,12 +506,13 @@ export async function runLocalModelsDevices(): Promise { const { binaryName } = resolvePlatformAsset(); const binPath = resolveServerBinPath(dataDir, binaryName); const configured = cfg.localModels.managed.device; + const multiGpu = cfg.localModels.managed.tensorSplit.length > 0; const devices = await listVulkanDevices(binPath); - const resolved = await resolveManagedDevice(binPath, configured); + const resolved = await resolveManagedDevice(binPath, configured, { multiGpu }); process.stdout.write(`configured device: ${configured}\n`); process.stdout.write( - `effective device: ${describeDeviceChoice(configured, resolved)}\n\n`, + `effective device: ${describeDeviceChoice(configured, resolved, multiGpu)}\n\n`, ); if (devices.length === 0) { process.stdout.write( @@ -457,7 +549,8 @@ export async function runLocalModelsUseDevice( if (value !== "auto" && value !== "cpu" && !DEVICE_ID_RE.test(value)) { process.stderr.write( `invalid device ${JSON.stringify(value)}. Expected 'auto', 'cpu', or a device id ` + - "(e.g. Vulkan0 — see 'atomic-agent models devices').\n", + "(e.g. Vulkan0, or a comma-separated list like Vulkan0,Vulkan1 — " + + "see 'atomic-agent models devices').\n", ); return 1; } diff --git a/src/cli/trace-command.test.ts b/src/cli/trace-command.test.ts index 5e313569..03801ce9 100644 --- a/src/cli/trace-command.test.ts +++ b/src/cli/trace-command.test.ts @@ -106,6 +106,7 @@ describe("traceCommand", () => { afterEach(() => { rmSync(stateDir, { recursive: true, force: true }); delete process.env.ATOMIC_AGENT_STATE_DIR; + delete process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET; resetConfigCache(); vi.restoreAllMocks(); }); @@ -181,6 +182,61 @@ describe("traceCommand", () => { expect(parsed[parsed.length - 1].type).toBe("turn_finished"); }); + it("replay rebuilds the skill catalog with the configured token budget", async () => { + // Regression guard for the replay call-site wiring: `handleReplay` + // must pass `config.skills.catalogTokenBudget` to + // `buildSkillCatalog`. The skill catalog feeds the stable prefix, so + // an honored budget changes the recomputed hash; if replay silently + // fell back to the legacy 4096-char cap, both runs below would build + // the same two-entry catalog and print identical currentHash values. + const globalSkillsDir = join(stateDir, "skills"); + for (const name of ["replay-budget-a", "replay-budget-b"]) { + mkdirSync(join(globalSkillsDir, name), { recursive: true }); + writeFileSync( + join(globalSkillsDir, name, "SKILL.md"), + [ + "---", + `name: ${name}`, + `description: "${"d".repeat(100)}"`, + "version: 0.1.0", + "---", + "", + `# ${name}`, + ].join("\n"), + "utf8", + ); + } + + const currentHashFromReplay = async (): Promise => { + (process.stdout.write as ReturnType).mockClear(); + const code = await traceCommand(["replay", "s-fixture"]); + // The fixture's recorded hash can never match a live prefix, so + // replay always reports drift (exit code 2). + expect(code).toBe(2); + const output = (process.stdout.write as ReturnType).mock + .calls.map((c) => c[0]) + .join(""); + const row = output + .split("\n") + .find((line) => line.includes("DRIFT")); + expect(row).toBeDefined(); + const columns = (row as string).trim().split(/\s+/); + const currentHash = columns[columns.length - 1] as string; + expect(currentHash).toMatch(/^[0-9a-f]{16}$/); + return currentHash; + }; + + // Default budget: both catalog entries fit under the 4096-char cap. + const wideHash = await currentHashFromReplay(); + + // 4 tokens x 8 chars/token = 32 chars: the catalog is cut down to + // the single always-kept first entry, which must shift the prefix. + process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET = "4"; + resetConfigCache(); + const narrowHash = await currentHashFromReplay(); + expect(narrowHash).not.toBe(wideHash); + }); + it("fails gracefully for missing session", async () => { const code = await traceCommand(["show", "missing"]); expect(code).toBe(1); diff --git a/src/cli/trace-command.ts b/src/cli/trace-command.ts index 55a1dd46..04e832c3 100644 --- a/src/cli/trace-command.ts +++ b/src/cli/trace-command.ts @@ -137,7 +137,9 @@ async function handleReplay(args: string[]): Promise { projectDir: joinPath(workingDir, config.paths.projectSkillsDirName), }); await skillRegistry.refresh(); - const skillCatalog = buildSkillCatalog(skillRegistry.list()); + const skillCatalog = buildSkillCatalog(skillRegistry.list(), { + tokenBudget: config.skills.catalogTokenBudget, + }); const capabilities = await buildCapabilities({ workingDir, browserChannel: config.browser.channel, diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 460207d5..58d214df 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -757,6 +757,35 @@ describe("parseUserConfigFile", () => { expect(parsed.localModels.managed.device).toBe("auto"); }); + it("defaults localModels.managed.backendVariant to 'auto', v45 files included", () => { + expect( + parseUserConfigFile({ version: USER_CONFIG_VERSION }).localModels.managed + .backendVariant, + ).toBe("auto"); + // A pre-v46 file has no such key — it transparently inherits the + // detection behaviour it already had. + expect( + parseUserConfigFile({ version: 45 }).localModels.managed.backendVariant, + ).toBe("auto"); + }); + + it("preserves an explicit localModels.managed.backendVariant", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { backendVariant: "cpu" } }, + }); + expect(parsed.localModels.managed.backendVariant).toBe("cpu"); + }); + + it("rejects an unknown localModels.managed.backendVariant", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { backendVariant: "opencl" } }, + }), + ).toThrow(/localModels.managed.backendVariant/); + }); + it("defaults localModels.managed.stopOnExit to true", () => { const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); expect(parsed.localModels.managed.stopOnExit).toBe(true); @@ -826,6 +855,71 @@ describe("parseUserConfigFile", () => { expect(parsed.localModels.managed.device).toBe("auto"); }); + it("defaults localModels.managed.tensorSplit to [] (multi-GPU split off)", () => { + const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); + expect(parsed.localModels.managed.tensorSplit).toEqual([]); + }); + + it("migrates a v45 file by filling localModels.managed.tensorSplit=[]", () => { + const parsed = parseUserConfigFile({ version: 45 }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(parsed.localModels.managed.tensorSplit).toEqual([]); + }); + + it("preserves an explicit localModels.managed.tensorSplit ratio list", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { tensorSplit: [3, 1] } }, + }); + expect(parsed.localModels.managed.tensorSplit).toEqual([3, 1]); + }); + + it("accepts fractional ratios and zeros that skip a device", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { tensorSplit: [0.6, 0, 0.4] } }, + }); + expect(parsed.localModels.managed.tensorSplit).toEqual([0.6, 0, 0.4]); + }); + + it("rejects a single-element tensorSplit (not a split)", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { tensorSplit: [1] } }, + }), + ).toThrow(/localModels.managed.tensorSplit/); + }); + + it("rejects negative, non-numeric, and non-finite tensorSplit ratios", () => { + for (const bad of [[1, -1], [1, "1"], [1, Number.NaN], [1, null]]) { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { tensorSplit: bad } }, + }), + ).toThrow(/localModels.managed.tensorSplit\[1\]/); + } + }); + + it("rejects an all-zero tensorSplit (offloads nowhere)", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { tensorSplit: [0, 0] } }, + }), + ).toThrow(/localModels.managed.tensorSplit/); + }); + + it("rejects a non-array tensorSplit", () => { + expect(() => + parseUserConfigFile({ + version: USER_CONFIG_VERSION, + localModels: { managed: { tensorSplit: "3,1" } }, + }), + ).toThrow(/localModels.managed.tensorSplit/); + }); + it("applies skills defaults when the section is absent", () => { const parsed = parseUserConfigFile({ version: USER_CONFIG_VERSION }); expect(parsed.skills).toEqual(USER_CONFIG_DEFAULTS.skills); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index 34c8bda7..b130a80e 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -11,6 +11,11 @@ import { isKnownLocalModelId, type LocalModelDef, } from "../local-llm/models-catalog.js"; +import { + BACKEND_VARIANT_PREFERENCES, + isBackendVariantPreference, + type BackendVariantPreference, +} from "../local-llm/windows-backend-variant.js"; import { parseCustomLocalModels } from "./custom-models-schema.js"; import { MCP_SERVER_NAME_MAX_LENGTH, @@ -343,6 +348,17 @@ export interface AtomicAgentConfig { launchTimeoutMs: number; }; skills: { + /** + * Soft budget for the `### skills` catalog in the stable prefix, + * in tokens. `buildSkillCatalog` converts it to a char cap at + * `SKILL_CATALOG_CHARS_PER_TOKEN` (8) chars/token and drops + * catalog entries past the cap so the prompt stays bounded. Env + * `ATOMIC_AGENT_SKILLS_CATALOG_BUDGET`, default `512` — which + * maps to the historical hardcoded 4096-char cap, so an unset + * key keeps pre-existing behavior byte-for-byte. Env values are + * clamped to `[1, 100_000]` tokens, so a zero or negative value + * cannot drive `maxChars` to 0 and silently collapse the catalog. + */ catalogTokenBudget: number; /** * Names of installed skills that should be hidden from the @@ -955,6 +971,22 @@ export interface UserManagedLocalLlmConfig { * Resolved in `startDaemon` via `resolveManagedDevice`. */ device: string; + /** + * Which llama.cpp build (release zip) the managed backend installs. + * Windows-only — every other platform publishes a single asset. + * - `"auto"` (default) — probe `nvidia-smi` and pick the newest CUDA + * build the driver can run, else Vulkan. + * - `"cpu"` — the CPU-only build. For machines whose Vulkan stack + * cannot load a model at all (iGPU-only boxes); also written back + * automatically when a GPU build fails to serve (see + * `cpu-backend-fallback.ts`). Distinct from `device: "cpu"`, which + * only disables offload — the broken compute backend would still + * be baked into the binary. + * - `"vulkan"` / `"cuda-12.4"` / `"cuda-13.3"` — pin that build + * (and undo an automatic CPU fallback after a driver fix). + * Added in config v47; older files transparently get `"auto"`. + */ + backendVariant: BackendVariantPreference; /** * llama-server context window (`--ctx-size`) for the managed chat * daemon. @@ -964,6 +996,21 @@ export interface UserManagedLocalLlmConfig { * model's trained context ceiling. */ contextSize: number; + /** + * Multi-GPU tensor split for the managed chat daemon. Empty (the + * default) keeps the single-device behavior: `device` auto-picks the + * best GPU and pins offload there. Two or more non-negative ratios + * (at least one positive, e.g. `[3, 1]` for a 75%/25% split) launch + * llama-server with `--split-mode layer --tensor-split ` so + * the model's layers spread across GPUs proportionally. With + * `device: "auto"` no single device is pinned — llama.cpp sees every + * GPU; an explicit comma-separated `device` list (e.g. + * `"Vulkan0,Vulkan1"`) restricts the split to those devices. + * `device: "cpu"` wins over this field and disables splitting. The + * embedding daemon is never split — it keeps pinning one device. + * Added in config v48; older files inherit `[]` transparently. + */ + tensorSplit: number[]; /** * Stop the managed chat daemon when the last CLI session exits. * `true` (default) — closing the terminal frees the RAM/VRAM the @@ -1615,7 +1662,20 @@ export interface UserConfigFile { // with the default `true`, which is the new product behaviour; `false` // keeps both structures in-memory (the pre-v46 behaviour) for workloads // that want a cold cache per run. -export const USER_CONFIG_VERSION = 46; +// v47: localModels.managed gains `backendVariant` — which llama.cpp +// release zip the managed backend installs on Windows (`auto` | `cpu` | +// `vulkan` | `cuda-12.4` | `cuda-13.3`). Exists for iGPU-only boxes whose +// Vulkan build cannot load a model; the start-failure fallback persists +// `"cpu"` here so auto-update stops reinstalling the broken GPU build. +// Additive: older files transparently inherit `"auto"`, the exact +// detection behaviour they already had. (Drafted as a second v46, but v46 +// was already spent on `persistCache` — the additive changes keep distinct +// sequential numbers.) +// v48: localModels.managed gains `tensorSplit` (default `[]` = single-device +// auto-pick, byte-identical launch args). Two or more ratios opt the managed +// chat daemon into multi-GPU layer splitting (`--split-mode layer +// --tensor-split `). Older files transparently inherit `[]`. +export const USER_CONFIG_VERSION = 48; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1750,6 +1810,8 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 43, 44, 45, + 46, + 47, USER_CONFIG_VERSION, ]; @@ -1766,7 +1828,9 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { autoUpdate: true, stopOnExit: true, device: "auto", + backendVariant: "auto", contextSize: 0, + tensorSplit: [], }, embeddings: { enabled: false, @@ -2104,6 +2168,17 @@ export function parseLocalLlmMode(raw: unknown, field: string): LocalLlmMode { ); } +export function parseBackendVariant( + raw: unknown, + field: string, +): BackendVariantPreference { + if (isBackendVariantPreference(raw)) return raw; + throw new ConfigValidationError( + field, + `expected ${BACKEND_VARIANT_PREFERENCES.join("|")}, got ${JSON.stringify(raw)}`, + ); +} + function parseOptionalManagedModelId( raw: unknown, field: string, @@ -2506,6 +2581,50 @@ export function parseStringArrayOrNull( return result; } +/** + * Parse `localModels.managed.tensorSplit` — the multi-GPU ratio list + * forwarded to llama-server as `--tensor-split`. `[]` / absent means + * "feature off" (single-device auto-pick). A non-empty list must name a + * ratio per GPU: at least two finite non-negative numbers with at least + * one positive (a lone ratio is not a split, and an all-zero list would + * make llama-server offload nowhere). Zeros are allowed inside the list + * to skip a device (e.g. `[1, 0, 1]` skips the middle GPU). + */ +export function parseTensorSplit(raw: unknown, field: string): number[] { + if (raw === undefined || raw === null) return []; + if (!Array.isArray(raw)) { + throw new ConfigValidationError( + field, + `expected number[], got ${JSON.stringify(raw)}`, + ); + } + if (raw.length === 0) return []; + if (raw.length === 1) { + throw new ConfigValidationError( + field, + "expected at least two ratios (one per GPU) — a single ratio is not a split; use [] to disable", + ); + } + const result: number[] = []; + for (let i = 0; i < raw.length; i++) { + const entry = raw[i]; + if (typeof entry !== "number" || !Number.isFinite(entry) || entry < 0) { + throw new ConfigValidationError( + `${field}[${i}]`, + `expected finite non-negative number, got ${JSON.stringify(entry)}`, + ); + } + result.push(entry); + } + if (!result.some((r) => r > 0)) { + throw new ConfigValidationError( + field, + "expected at least one positive ratio", + ); + } + return result; +} + const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; /** @@ -3116,11 +3235,20 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { rawManaged.device ?? USER_CONFIG_DEFAULTS.localModels.managed.device, "localModels.managed.device", ), + backendVariant: parseBackendVariant( + rawManaged.backendVariant ?? + USER_CONFIG_DEFAULTS.localModels.managed.backendVariant, + "localModels.managed.backendVariant", + ), contextSize: parseNonNegativeInt( rawManaged.contextSize ?? USER_CONFIG_DEFAULTS.localModels.managed.contextSize, "localModels.managed.contextSize", ), + tensorSplit: parseTensorSplit( + rawManaged.tensorSplit, + "localModels.managed.tensorSplit", + ), }; const rawEmbeddings = diff --git a/src/config/load-config.test.ts b/src/config/load-config.test.ts index 25f0f996..9b9bb25f 100644 --- a/src/config/load-config.test.ts +++ b/src/config/load-config.test.ts @@ -13,7 +13,11 @@ import { join } from "node:path"; import { loadConfig } from "./load-config.js"; import { resetConfigCache } from "./config-cache.js"; import { getUserConfigPath, writeUserConfigFileSync } from "./config-file.js"; -import { USER_CONFIG_DEFAULTS, USER_CONFIG_VERSION } from "./config-schema.js"; +import { + ENV_DEFAULTS, + USER_CONFIG_DEFAULTS, + USER_CONFIG_VERSION, +} from "./config-schema.js"; describe("loadConfig", () => { let stateDir: string; @@ -32,6 +36,7 @@ describe("loadConfig", () => { delete process.env.ATOMIC_AGENT_LLAMA_MAX_TOKENS; delete process.env.ATOMIC_AGENT_BROWSER_CHANNEL; delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + delete process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET; delete process.env.ATOMIC_LOADCONFIG_TEST_KEY; resetConfigCache(); vi.restoreAllMocks(); @@ -61,6 +66,26 @@ describe("loadConfig", () => { expect(loadConfig().localModels.completionMaxTokens).toBe(131_072); }); + it("clamps ATOMIC_AGENT_SKILLS_CATALOG_BUDGET to a positive range", () => { + // The budget multiplies into the skill catalog's char cap; 0 or a + // negative value would collapse the catalog, so the loader clamps. + expect(loadConfig().skills.catalogTokenBudget).toBe( + ENV_DEFAULTS.SKILLS_CATALOG_BUDGET, + ); + process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET = "0"; + resetConfigCache(); + expect(loadConfig().skills.catalogTokenBudget).toBe(1); + process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET = "-64"; + resetConfigCache(); + expect(loadConfig().skills.catalogTokenBudget).toBe(1); + process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET = "999999999"; + resetConfigCache(); + expect(loadConfig().skills.catalogTokenBudget).toBe(100_000); + process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET = "2048"; + resetConfigCache(); + expect(loadConfig().skills.catalogTokenBudget).toBe(2048); + }); + it("reads localModels.completionMaxTokens from the user config file", () => { writeUserConfigFileSync(getUserConfigPath(stateDir), { ...USER_CONFIG_DEFAULTS, diff --git a/src/config/load-config.ts b/src/config/load-config.ts index f8b750c2..43dec648 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -14,6 +14,7 @@ import { getUserConfigPath, } from "./config-file.js"; import { setCustomLocalModels } from "../local-llm/models-catalog.js"; +import { setConfiguredBackendVariant } from "../local-llm/windows-backend-variant.js"; import { loadDotenvFromStateDir } from "./load-dotenv.js"; import { resolveLlmProviderApiKey } from "./resolve-llm-api-key.js"; import type { UserLlmFileConfig } from "./llm-config.js"; @@ -118,6 +119,10 @@ export function loadConfig(): AtomicAgentConfig { // `getLocalModelDef` and `isKnownLocalModelId` resolve them everywhere // a curated id already works. setCustomLocalModels(user.localModels.customModels); + // Same push-in pattern: the backend-zip variant preference has to be + // visible to `resolveDownloadAsset`, which runs deep inside the + // config-free local-llm layer. + setConfiguredBackendVariant(user.localModels.managed.backendVariant); const grammarsDir = resolveAssetDir("ATOMIC_AGENT_GRAMMARS_DIR", "grammars"); const browserChannel: BrowserChannel = readBrowserChannel( @@ -292,9 +297,11 @@ export function loadConfig(): AtomicAgentConfig { ), }, skills: { - catalogTokenBudget: readInt( + catalogTokenBudget: readBoundedPositiveInt( "ATOMIC_AGENT_SKILLS_CATALOG_BUDGET", ENV_DEFAULTS.SKILLS_CATALOG_BUDGET, + 1, + 100_000, ), disabled: user.skills.disabled, taps: user.skills.taps, diff --git a/src/llm/describe-llama-health-failure.test.ts b/src/llm/describe-llama-health-failure.test.ts index 31498a7b..271e77dc 100644 --- a/src/llm/describe-llama-health-failure.test.ts +++ b/src/llm/describe-llama-health-failure.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { describeLlamaHealthFailure } from "./describe-llama-health-failure.js"; +import { + describeLlamaHealthFailure, + looksLikeOllamaUrl, +} from "./describe-llama-health-failure.js"; import type { HealthResult } from "./llama-server-health.js"; function result(partial: Partial): HealthResult { @@ -23,6 +26,34 @@ describe("describeLlamaHealthFailure", () => { expect(line).toContain("openai-compatible, base URL http://127.0.0.1:1234"); }); + it("names Ollama outright when the openai-compat server sits on :11434", () => { + // The most common shape of this verdict by far: the External URL + // pointed at `ollama serve`. "openai-compatible" alone did not tell + // an Ollama user the message was about them. + const line = describeLlamaHealthFailure( + result({ kind: "openai-compat", status: 404, error: "http 404" }), + "http://127.0.0.1:11434", + ); + expect(line).toContain("answers like Ollama"); + expect(line).toContain("Ollama (local), base URL http://127.0.0.1:11434"); + }); + + it("routes a remote Ollama through the manual compat row, keeping its host", () => { + // The "Ollama (local)" preset row never shows a base-URL screen — + // followed by hand it saves the preset's own localhost:11434 — so + // for a remote host the instruction must go through the manual + // openai-compatible row, which asks for the URL. + const line = describeLlamaHealthFailure( + result({ kind: "openai-compat", status: 404, error: "http 404" }), + "http://192.168.1.50:11434", + ); + expect(line).toContain("answers like Ollama"); + expect(line).toContain( + "openai-compatible, base URL http://192.168.1.50:11434", + ); + expect(line).not.toContain("Ollama (local)"); + }); + it("says wait, not reconfigure, while the model is loading", () => { const line = describeLlamaHealthFailure( result({ kind: "llama-loading", status: 503 }), @@ -52,3 +83,16 @@ describe("describeLlamaHealthFailure", () => { expect(line).toBe("local-llm /health failed at http://10.0.0.7:8080: fetch failed"); }); }); + +describe("looksLikeOllamaUrl", () => { + it("recognizes Ollama's default port on any host", () => { + expect(looksLikeOllamaUrl("http://127.0.0.1:11434")).toBe(true); + expect(looksLikeOllamaUrl("http://192.168.1.50:11434")).toBe(true); + }); + + it("rejects other ports and unparseable URLs", () => { + expect(looksLikeOllamaUrl("http://127.0.0.1:1234")).toBe(false); + expect(looksLikeOllamaUrl("http://127.0.0.1:8080")).toBe(false); + expect(looksLikeOllamaUrl("not a url")).toBe(false); + }); +}); diff --git a/src/llm/describe-llama-health-failure.ts b/src/llm/describe-llama-health-failure.ts index 2c0686b6..057ac566 100644 --- a/src/llm/describe-llama-health-failure.ts +++ b/src/llm/describe-llama-health-failure.ts @@ -1,4 +1,23 @@ import type { HealthResult } from "./llama-server-health.js"; +// A leaf predicate with no imports of its own — the one home of the +// loopback host spellings, shared here so the steer text and the +// provider wizard agree on what "local" means. +import { isLocalProviderUrl } from "../tui/providers/is-local-provider-url.js"; + +/** + * True when `url` points at Ollama's default port. Ollama is the server + * operators point the External llama.cpp URL at most often (verified: + * `ollama serve` answers 404 on `/health` and OpenAI-shape on + * `/v1/models`, so the probe reports `openai-compat`), and the port is + * the one signal the probe already has without another round trip. + */ +export function looksLikeOllamaUrl(url: string): boolean { + try { + return new URL(url).port === "11434"; + } catch { + return false; + } +} /** * One operator-actionable line per probe verdict, shared by every @@ -14,7 +33,25 @@ export function describeLlamaHealthFailure( switch (health.kind) { case "openai-compat": // A real server, wrong route: KoboldCpp / LM Studio / Ollama / - // vLLM speak /v1/* but not llama.cpp's native endpoints. + // vLLM speak /v1/* but not llama.cpp's native endpoints. Port + // 11434 is named as Ollama outright — that is the server this + // verdict almost always is, and "openai-compatible" alone did not + // tell an Ollama user the message was about them. + if (looksLikeOllamaUrl(url)) { + // The "Ollama (local)" preset row shows no base-URL screen — it + // saves its own localhost:11434 — so pointing at it is only + // followable when that is the server probed here. A remote + // Ollama goes through the manual compat row instead, which asks + // for the URL and so keeps the host. + return isLocalProviderUrl(url) + ? `${url} answers like Ollama (its default port), not llama.cpp. ` + + `Add it as a cloud provider instead: LLM tab › Cloud › n › ` + + `Ollama (local), base URL ${url}.` + : `${url} answers like Ollama (its default port), not llama.cpp. ` + + `Add it as a cloud provider instead: LLM tab › Cloud › n › ` + + `openai-compatible, base URL ${url} (any API key value ` + + `passes — a stock Ollama has no auth).`; + } return ( `${url} answers like an OpenAI-compatible server, not llama.cpp. ` + `Add it as a cloud provider instead: LLM tab › Cloud › n › ` + diff --git a/src/llm/fallback/fallback-e2e.integration.test.ts b/src/llm/fallback/fallback-e2e.integration.test.ts index ac04ad90..d48e17e7 100644 --- a/src/llm/fallback/fallback-e2e.integration.test.ts +++ b/src/llm/fallback/fallback-e2e.integration.test.ts @@ -25,6 +25,12 @@ import { OpenAiHttpError } from "../provider/openai/openai-http.js"; import { ProviderFallbackChain } from "./provider-fallback-chain.js"; import { DEFAULT_FALLBACK_TIMING } from "./fallback-config.js"; import { runWithFallback } from "./run-with-fallback.js"; +import { QWEN_THINK_PROFILE } from "../model-profile.js"; +import { + createFallbackCompleter, + createFallbackStreamer, + type FallbackSeamDeps, +} from "../../runtime/llm-fallback-seam.js"; const TOOLS: ToolDescriptor[] = [ { @@ -298,4 +304,171 @@ describe("provider fallback chain end-to-end through the agent loop", () => { expect(result.reason).toBe("finish"); expect(events.some((e) => e.type === "loop_failed")).toBe(false); }); + + // --- think-tag profile fallover (issue #283 review) ------------------- + // The documented default hybrid chain (`appendLocal`): a native-tools + // cloud primary with a grammar llama-server last resort, on a + // think-tag profile. These cases run the REAL bootstrap seams + // (`createFallbackCompleter` / `createFallbackStreamer`) so the + // per-chunk `servedTransport` stamp and the per-link prompt + // substitution are pinned end-to-end, not re-implemented inline. + + function thinkSeamDeps(providers: Map): FallbackSeamDeps { + const chain = new ProviderFallbackChain({ + resolve: () => ({ + chain: ["cloud", "local"], + timing: DEFAULT_FALLBACK_TIMING, + }), + }); + return { + fallbackChain: chain, + resolveSlice: (providerId) => { + const provider = providers.get(providerId)!; + return { provider, transport: provider.capabilities.toolTransport }; + }, + recordUnaryUsage: () => {}, + recordStreamUsage: () => {}, + }; + } + + function thinkFinish(): CompletionResult { + // Grammar output starts mid-think: the GBNF prelude root emits + // `body ""` without the open tag. + return { + content: + 'deliberating about the wrap-up\n{"tool":"finish","args":{"summary":"done"}}', + reasoningContent: "", + stop: true, + truncated: false, + timing: { promptMs: 1, predictedMs: 1, promptTokens: 10, predictedTokens: 5 }, + cacheHitTokens: 0, + slotId: 0, + modelId: "local-model", + }; + } + + it("think profile, streaming: a grammar-served fallover stream keeps LIVE reasoning deltas and the prefill-carrying prompt shape", async () => { + const cloudPrompts: string[] = []; + const localPrompts: string[] = []; + const primary = fakeProvider("cloud", "native_tools", async (request) => { + cloudPrompts.push(request.prompt); + throw new OpenAiHttpError("rate limited", 429, "http://cloud", false, null, "cloud"); + }); + const local: LlmProvider = { + ...fakeProvider("local", "grammar", async () => thinkFinish()), + // Stream in several chunks so live classification is observable: + // the reasoning text must surface as deltas BEFORE the stream ends. + async *completeStream(request) { + localPrompts.push(request.prompt); + yield { delta: "deliberating about", reasoningDelta: "", done: false }; + yield { delta: " the wrap-up\n", reasoningDelta: "", done: false }; + yield { + delta: '{"tool":"finish","args":{"summary":"done"}}', + reasoningDelta: "", + done: true, + }; + return thinkFinish(); + }, + }; + const providers = new Map([ + ["cloud", primary], + ["local", local], + ]); + const seamDeps = thinkSeamDeps(providers); + + const events: AgentLoopEvent[] = []; + const reasoningDeltas: string[] = []; + const loop = new AgentLoop({ + registry: buildDefaultToolRegistry(), + slotManager: new SlotManager(2), + grammar: 'root ::= "ok"', + llmComplete: createFallbackCompleter(seamDeps), + llmCompleteStream: createFallbackStreamer(seamDeps), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + profile: QWEN_THINK_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: openAiToolCallAdapter, + supportsSlotAffinity: false, + onEvent: (e) => { + events.push(e); + if (e.type === "llm_event" && e.event.type === "reasoning_delta") { + reasoningDeltas.push(e.event.text); + } + }, + }); + + const session = createEmptySessionState({ id: "s-e2e-think-stream", workingDir }); + const result = await loop.runTurn(session, { + userMessage: "wrap up", + maxSteps: 3, + signal: new AbortController().signal, + }); + + expect(result.reason).toBe("finish"); + expect(events.some((e) => e.type === "loop_failed")).toBe(false); + // Live reasoning classification survives the cross-transport + // fallover: the grammar-served stream starts mid-``, and the + // parser adopted the served transport from the chunk stamp. + expect(reasoningDeltas.join("")).toBe("deliberating about the wrap-up"); + // Per-link prompt shapes: the chat primary got the prefill-suppressed + // prompt (issue #283), the grammar link got the legacy + // prefill-carrying variant its template + GBNF prelude expect. + expect(cloudPrompts).toHaveLength(1); + expect(cloudPrompts[0]!.trimEnd().endsWith("")).toBe(false); + expect(localPrompts).toHaveLength(1); + expect(localPrompts[0]!.trimEnd().endsWith("")).toBe(true); + }); + + it("think profile, unary: a grammar-served fallover completion still surfaces its reasoning and reaches finish", async () => { + const localPrompts: string[] = []; + const primary = fakeProvider("cloud", "native_tools", async () => { + throw new OpenAiHttpError("rate limited", 429, "http://cloud", false, null, "cloud"); + }); + const local = fakeProvider("local", "grammar", async (request) => { + localPrompts.push(request.prompt); + return thinkFinish(); + }); + const providers = new Map([ + ["cloud", primary], + ["local", local], + ]); + const seamDeps = thinkSeamDeps(providers); + + const events: AgentLoopEvent[] = []; + const reasoningEvents: string[] = []; + const loop = new AgentLoop({ + registry: buildDefaultToolRegistry(), + slotManager: new SlotManager(2), + grammar: 'root ::= "ok"', + llmComplete: createFallbackCompleter(seamDeps), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + profile: QWEN_THINK_PROFILE, + toolTransport: "native_tools", + toolCallAdapter: openAiToolCallAdapter, + supportsSlotAffinity: false, + onEvent: (e) => { + events.push(e); + if (e.type === "llm_event" && e.event.type === "reasoning") { + reasoningEvents.push(e.event.text); + } + }, + }); + + const session = createEmptySessionState({ id: "s-e2e-think-unary", workingDir }); + const result = await loop.runTurn(session, { + userMessage: "wrap up", + maxSteps: 3, + signal: new AbortController().signal, + }); + + expect(result.reason).toBe("finish"); + expect(events.some((e) => e.type === "loop_failed")).toBe(false); + expect(reasoningEvents).toEqual(["deliberating about the wrap-up"]); + expect(localPrompts).toHaveLength(1); + expect(localPrompts[0]!.trimEnd().endsWith("")).toBe(true); + }); }); diff --git a/src/llm/grammar/stream-parser.test.ts b/src/llm/grammar/stream-parser.test.ts index 0598b38d..ffb0c700 100644 --- a/src/llm/grammar/stream-parser.test.ts +++ b/src/llm/grammar/stream-parser.test.ts @@ -186,6 +186,71 @@ describe("createStreamParser", () => { expect(concatReply(events)).toBe("Привет!"); }); + it("keeps streaming reasoning past a brace inside the chain-of-thought", () => { + // The brace lands in the buffer while the close tag has not arrived + // yet — the old parser treated it as the tool-call start, closed the + // reasoning stream and froze the live display for the rest of the step. + const events = feedAll([ + 'the config is {"a": 1} so I should', + " change it", + '{"tool":"reply","args":{"text":"ok"}}', + ], { preOpenedThink: true }); + expect(concatReasoning(events)).toBe( + 'the config is {"a": 1} so I should change it', + ); + expect(events.filter((e) => e.kind === "reasoning_close")).toHaveLength(1); + expect(concatReply(events)).toBe("ok"); + }); + + it("keeps streaming reasoning past an array bracket inside the chain-of-thought", () => { + const events = feedAll([ + "steps [1, 2] look", + " fine", + '{"tool":"reply","args":{"text":"ok"}}', + ]); + expect(concatReasoning(events)).toBe("steps [1, 2] look fine"); + expect(concatReply(events)).toBe("ok"); + }); + + it("still hands off to a close-sentinel-less tool call after a decoy brace", () => { + const events = feedAll([ + 'set {"a": 1} hmm ', + '{"tool":"reply","args":{"text":"hi"}}', + ]); + expect(concatReasoning(events)).toBe('set {"a": 1} hmm '); + expect(events.filter((e) => e.kind === "reasoning_close")).toHaveLength(1); + expect(concatReply(events)).toBe("hi"); + }); + + it("holds back a possible tool-call start split by the chunk boundary", () => { + // `{"to` alone could still become `{"tool": …` — it must be neither + // emitted as reasoning nor treated as a payload until resolved. + const events = feedAll([ + "go ", + '{"to', + 'ol":"os.exec","args":{"command":"ls"}}', + ]); + expect(concatReasoning(events)).toBe("go "); + expect(events.filter((e) => e.kind === "reasoning_close")).toHaveLength(1); + expect(events.filter((e) => e.kind === "reply_text_delta")).toHaveLength(0); + }); + + it("gives up on a brace followed by a long whitespace run and streams it as reasoning", () => { + const events = feedAll([ + "a {" + " ".repeat(80), + "b", + '{"tool":"reply","args":{"text":"y"}}', + ]); + expect(concatReasoning(events)).toBe("a {" + " ".repeat(80) + "b"); + expect(concatReply(events)).toBe("y"); + }); + + it("treats an unresolved trailing brace at stream end as reasoning", () => { + const events = feedAll(["ends with {\"a\": 1}"]); + expect(concatReasoning(events)).toBe('ends with {"a": 1}'); + expect(events.at(-1)).toEqual({ kind: "reasoning_close" }); + }); + it("splits reply.args.text across chunks preserving backslash escapes", () => { const raw = '{"tool":"reply","args":{"text":"a\\\\b\\nc"}}'; diff --git a/src/llm/grammar/stream-parser.ts b/src/llm/grammar/stream-parser.ts index b5919eb5..11cbfda3 100644 --- a/src/llm/grammar/stream-parser.ts +++ b/src/llm/grammar/stream-parser.ts @@ -113,19 +113,30 @@ export function createStreamParser(options: StreamParserOptions = {}): StreamPar keepGoing = true; continue; } - const jsonIdx = findJsonToolStart(buffer); - if (jsonIdx !== -1) { - const before = buffer.slice(0, jsonIdx); + // A close-sentinel-less handoff to the tool call is only assumed + // when the buffer really starts a `{"tool": "` payload. A bare + // `{` / `[` inside genuine chain-of-thought (JSON snippets, array + // notation — routine when the model reasons about code) must NOT + // end the reasoning stream: doing so froze the live reasoning + // display for the rest of the step. + const start = findToolCallStart(buffer); + if (start !== null && start.resolved) { + const before = buffer.slice(0, start.index); if (before.length > 0) { out.push({ kind: "reasoning_delta", text: before }); } out.push({ kind: "reasoning_close" }); - buffer = buffer.slice(jsonIdx); + buffer = buffer.slice(start.index); state = "json_tool"; keepGoing = true; continue; } - const cutAt = findSafeReasoningEmitIndex(buffer, reasoningCloseTag, closeHoldbackLen); + // Hold back an unresolved candidate (`{"to…` split by the chunk + // boundary) alongside the possible close-tag prefix; everything + // before either stays live reasoning. + const holdFrom = start === null ? buffer.length : start.index; + const safeIdx = findSafeReasoningEmitIndex(buffer, reasoningCloseTag, closeHoldbackLen); + const cutAt = Math.min(holdFrom, safeIdx); const emit = buffer.slice(0, cutAt); if (emit.length > 0) { out.push({ kind: "reasoning_delta", text: emit }); @@ -189,14 +200,14 @@ export function createStreamParser(options: StreamParserOptions = {}): StreamPar end(): StreamParseEvent[] { const out = advance(); if (state === "inside_think") { - const jsonIdx = findJsonToolStart(buffer); - if (jsonIdx !== -1) { - const before = buffer.slice(0, jsonIdx); + const start = findToolCallStart(buffer); + if (start !== null && start.resolved) { + const before = buffer.slice(0, start.index); if (before.length > 0) { out.push({ kind: "reasoning_delta", text: before }); } out.push({ kind: "reasoning_close" }); - buffer = buffer.slice(jsonIdx); + buffer = buffer.slice(start.index); state = "json_tool"; out.push(...advance()); } else { @@ -330,14 +341,93 @@ function escapeRegex(text: string): string { } /** Index of the first `[` or `{` that may start a grammar tool-call payload. */ -function findJsonToolStart(buffer: string): number { - const bracket = buffer.indexOf("["); - const brace = buffer.indexOf("{"); +function findJsonToolStart(buffer: string, from = 0): number { + const bracket = buffer.indexOf("[", from); + const brace = buffer.indexOf("{", from); if (bracket === -1) return brace; if (brace === -1) return bracket; return Math.min(bracket, brace); } +/** + * Longest prefix a candidate may reach while still undecided: + * `[` + whitespace + `{` + whitespace + `"tool"` + whitespace + `:` + + * whitespace + `"`. Grammar output has next to no whitespace, so a + * candidate still unresolved past this budget is chain-of-thought, not + * a payload — the cap keeps the mid-think holdback (and thus the + * buffered memory) bounded. + */ +const TOOL_START_PROBE_CAP = 64; + +type ToolStartClass = "start" | "pending" | "no"; + +interface ToolCallStartMatch { + index: number; + /** True when the `{"tool": "` shape is fully present at `index`. */ + resolved: boolean; +} + +/** + * Decide whether the text at `buffer[index]` (a `[` or `{`) begins a + * grammar tool-call payload — optional array opener, then `{`, then the + * `"tool"` key up to its opening value quote. "pending" means the + * buffer ended while still matching that shape, so the caller must + * wait for more stream before emitting the candidate as reasoning. + */ +function classifyToolCallStart(buffer: string, index: number): ToolStartClass { + let i = index; + const pending = (): ToolStartClass => + i - index > TOOL_START_PROBE_CAP ? "no" : "pending"; + if (buffer[i] === "[") { + i += 1; + i = skipJsonWhitespace(buffer, i); + if (i >= buffer.length) return pending(); + } + if (buffer[i] !== "{") return "no"; + i += 1; + i = skipJsonWhitespace(buffer, i); + const key = '"tool"'; + for (let k = 0; k < key.length; k += 1, i += 1) { + if (i >= buffer.length) return pending(); + if (buffer[i] !== key[k]) return "no"; + } + i = skipJsonWhitespace(buffer, i); + if (i >= buffer.length) return pending(); + if (buffer[i] !== ":") return "no"; + i += 1; + i = skipJsonWhitespace(buffer, i); + if (i >= buffer.length) return pending(); + return buffer[i] === '"' ? "start" : "no"; +} + +function skipJsonWhitespace(buffer: string, from: number): number { + let i = from; + while (i < buffer.length) { + const c = buffer[i]!; + if (c !== " " && c !== "\n" && c !== "\r" && c !== "\t") break; + i += 1; + } + return i; +} + +/** + * First position in `buffer` that starts (resolved) or may still start + * (pending, cut off by the chunk boundary) a tool-call payload. + * Braces/brackets that provably do not are skipped — they are ordinary + * reasoning text. + */ +function findToolCallStart(buffer: string): ToolCallStartMatch | null { + let from = 0; + while (true) { + const idx = findJsonToolStart(buffer, from); + if (idx === -1) return null; + const cls = classifyToolCallStart(buffer, idx); + if (cls === "start") return { index: idx, resolved: true }; + if (cls === "pending") return { index: idx, resolved: false }; + from = idx + 1; + } +} + function holdPossibleTagStart(buffer: string, tag: string): string { const maxProbe = Math.min(buffer.length, Math.max(1, tag.length - 1)); for (let len = maxProbe; len > 0; len -= 1) { diff --git a/src/llm/profile-invariants.test.ts b/src/llm/profile-invariants.test.ts index 31c37e19..0d0e6844 100644 --- a/src/llm/profile-invariants.test.ts +++ b/src/llm/profile-invariants.test.ts @@ -96,4 +96,37 @@ describe("checkProfilePromptAligned", () => { ), ).toEqual([]); }); + + describe("prefill-suppressed shape (native-tools chat transport, issue #283)", () => { + const options = { promptCarriesPrefill: false }; + + it("flags a suppressed qwen prompt that still leaks the reasoning prelude", () => { + const prompt = "### response\nEmit one JSON tool call now.\n\n"; + expect( + checkProfilePromptAligned(QWEN_THINK_PROFILE, prompt, options), + ).toContain( + "prefill-suppressed prompt must not end with a reasoning prelude", + ); + }); + + it("flags a suppressed gemma prompt that still leaks the turn-framing tail", () => { + const prompt = + "### response\nEmit one JSON tool call now.\n\n<|turn>model\n"; + expect( + checkProfilePromptAligned(GEMMA4_THINK_PROFILE, prompt, options), + ).toContain( + "prefill-suppressed prompt must not end with a model-turn opener", + ); + }); + + it("accepts clean suppressed prompts for both reasoning profiles", () => { + const prompt = "### response\nEmit one JSON tool call now.\n"; + expect( + checkProfilePromptAligned(QWEN_THINK_PROFILE, prompt, options), + ).toEqual([]); + expect( + checkProfilePromptAligned(GEMMA4_THINK_PROFILE, prompt, options), + ).toEqual([]); + }); + }); }); diff --git a/src/llm/profile-invariants.ts b/src/llm/profile-invariants.ts index 9f96df3b..7e1e2c56 100644 --- a/src/llm/profile-invariants.ts +++ b/src/llm/profile-invariants.ts @@ -38,9 +38,23 @@ export function checkProfileGrammarAligned( return violations; } +export interface PromptAlignmentOptions { + /** + * Whether the prompt was built with the reasoning prefill / turn + * framing at the generation point. `false` on the native-tools chat + * transport, where `buildPrompt` suppresses the prefill (a literal + * `` shipped to an OpenAI-compatible endpoint is at best noise + * and at worst corrupted server-side — ollama/ollama#17248, issue + * #283) and the invariant flips: the prompt must NOT end with a + * reasoning prelude. Defaults to `true` (grammar-transport legacy). + */ + promptCarriesPrefill?: boolean; +} + export function checkProfilePromptAligned( profile: ModelProfile, promptText: string, + options: PromptAlignmentOptions = {}, ): string[] { const violations: string[] = []; const trimmed = promptText.trimEnd(); @@ -53,6 +67,27 @@ export function checkProfilePromptAligned( return violations; } + if (options.promptCarriesPrefill === false) { + const leakedPrefix = getKnownReasoningOpenTags().find((tag) => trimmed.endsWith(tag)); + if (leakedPrefix) { + violations.push( + "prefill-suppressed prompt must not end with a reasoning prelude", + ); + } + // Turn-framed profiles (Gemma 4) leak differently: their template + // artifact at the generation point is the model-turn opener, not a + // reasoning open tag. A suppressed prompt must carry neither. + const leakedFraming = getKnownTurnFramingTails().find((tail) => + trimmed.endsWith(tail), + ); + if (leakedFraming) { + violations.push( + "prefill-suppressed prompt must not end with a model-turn opener", + ); + } + return violations; + } + const framing = getReasoningTurnFraming(profile); if (framing) { // Turn-framed profiles (Gemma 4) end at the model-turn opener; the model @@ -81,3 +116,8 @@ function getKnownReasoningOpenTags(): string[] { GEMMA4_THINK_PROFILE.reasoningOpenTag.trimEnd(), ]; } + +function getKnownTurnFramingTails(): string[] { + const framing = getReasoningTurnFraming(GEMMA4_THINK_PROFILE); + return framing ? [framing.assistantOpen.trimEnd()] : []; +} diff --git a/src/llm/provider/completion-types.ts b/src/llm/provider/completion-types.ts index 6acc50d2..8ca3319d 100644 --- a/src/llm/provider/completion-types.ts +++ b/src/llm/provider/completion-types.ts @@ -113,6 +113,17 @@ export interface StreamChunk { delta: string; reasoningDelta: string; done: boolean; + /** + * Tool-call transport of the link that is serving this stream. + * Providers never set it — the fallback streamer seam stamps it on + * every chunk so live consumers (the step executor's stream parser) + * can adapt to a cross-transport fallover BEFORE the final return + * value arrives: `CompletionResult.servedTransport` only exists once + * the stream finishes, which is too late to classify reasoning deltas + * live. Absent on the direct (non-wrapped) path, where the caller's + * configured `toolTransport` is authoritative. + */ + servedTransport?: ToolCallTransport; } export interface StreamFinalResult { diff --git a/src/llm/provider/model-pricing-filter.test.ts b/src/llm/provider/model-pricing-filter.test.ts new file mode 100644 index 00000000..43f87ebe --- /dev/null +++ b/src/llm/provider/model-pricing-filter.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + filterIdsByPricing, + isFreeModelEntry, + matchesModelPricingFilter, + nextModelPricingFilter, +} from "./model-pricing-filter.js"; +import type { ModelCatalogEntry } from "./model-resolver.js"; + +function chatEntry( + id: string, + pricing?: { input: number; output: number }, +): ModelCatalogEntry { + return { + id, + kind: "chat", + contextWindow: 128_000, + supportsVision: false, + supportsTools: "parallel", + supportsPromptCache: false, + reasoningFormat: "none", + ...(pricing ? { pricing } : {}), + }; +} + +describe("nextModelPricingFilter", () => { + it("cycles all → free → paid → all", () => { + expect(nextModelPricingFilter("all")).toBe("free"); + expect(nextModelPricingFilter("free")).toBe("paid"); + expect(nextModelPricingFilter("paid")).toBe("all"); + }); +}); + +describe("isFreeModelEntry", () => { + it("is true only when the catalog proves both prices are zero", () => { + expect(isFreeModelEntry("v/free", chatEntry("v/free", { input: 0, output: 0 }))).toBe(true); + expect(isFreeModelEntry("v/paid", chatEntry("v/paid", { input: 0.3, output: 2.5 }))).toBe(false); + // Output-only pricing is still a price. + expect(isFreeModelEntry("v/out", chatEntry("v/out", { input: 0, output: 1 }))).toBe(false); + }); + + it("treats missing pricing as not-free (aimlapi rows, live /v1/models ids)", () => { + expect(isFreeModelEntry("v/unknown", chatEntry("v/unknown"))).toBe(false); + expect(isFreeModelEntry("v/unknown", undefined)).toBe(false); + }); + + it("keeps openrouter/auto out of free: its tag reads 'routed', and it bills the routed model", () => { + const auto = chatEntry("openrouter/auto", { input: 0, output: 0 }); + expect(isFreeModelEntry("openrouter/auto", auto)).toBe(false); + }); +}); + +describe("matchesModelPricingFilter", () => { + const free = chatEntry("v/free", { input: 0, output: 0 }); + const paid = chatEntry("v/paid", { input: 0.3, output: 2.5 }); + + it("'all' keeps every row", () => { + expect(matchesModelPricingFilter("all", "v/free", free)).toBe(true); + expect(matchesModelPricingFilter("all", "v/paid", paid)).toBe(true); + expect(matchesModelPricingFilter("all", "v/unknown", undefined)).toBe(true); + }); + + it("free and paid partition the catalog, so no row vanishes from both", () => { + expect(matchesModelPricingFilter("free", "v/free", free)).toBe(true); + expect(matchesModelPricingFilter("paid", "v/free", free)).toBe(false); + expect(matchesModelPricingFilter("free", "v/paid", paid)).toBe(false); + expect(matchesModelPricingFilter("paid", "v/paid", paid)).toBe(true); + // A row with no metadata cannot be promised free; it stays in paid. + expect(matchesModelPricingFilter("free", "v/unknown", undefined)).toBe(false); + expect(matchesModelPricingFilter("paid", "v/unknown", undefined)).toBe(true); + }); +}); + +describe("filterIdsByPricing", () => { + const entries = new Map([ + ["v/free", chatEntry("v/free", { input: 0, output: 0 })], + ["v/paid", chatEntry("v/paid", { input: 0.3, output: 2.5 })], + ]); + const lookup = (id: string): ModelCatalogEntry | undefined => entries.get(id); + const ids = ["v/free", "v/paid", "v/unknown"] as const; + + it("returns the input array itself for 'all'", () => { + expect(filterIdsByPricing(ids, "all", lookup)).toBe(ids); + }); + + it("narrows to the facet through the lookup", () => { + expect(filterIdsByPricing(ids, "free", lookup)).toEqual(["v/free"]); + expect(filterIdsByPricing(ids, "paid", lookup)).toEqual(["v/paid", "v/unknown"]); + }); + + it("without a lookup nothing is provably free", () => { + expect(filterIdsByPricing(ids, "free", undefined)).toEqual([]); + expect(filterIdsByPricing(ids, "paid", undefined)).toEqual([...ids]); + }); +}); diff --git a/src/llm/provider/model-pricing-filter.ts b/src/llm/provider/model-pricing-filter.ts new file mode 100644 index 00000000..58903d38 --- /dev/null +++ b/src/llm/provider/model-pricing-filter.ts @@ -0,0 +1,62 @@ +import { formatTokenPrice } from "./format-model-details.js"; +import type { ModelCatalogEntry } from "./model-resolver.js"; +import type { ModelEntryLookup } from "./model-search.js"; + +/** + * The price facet of the cloud model pickers: `all` shows the whole + * catalog, `free` only the rows whose price tag reads "free", `paid` + * the rest. + * + * `free` is deliberately strict — a row stays only when the catalog + * proves both prices are zero, by the same `formatTokenPrice` rule that + * paints the "free" tag on the row, so the facet can never contradict + * what a row says about itself. Everything else is `paid`: rows with + * real prices, `openrouter/auto` (renders "routed" — it bills whatever + * model it picks), and rows with no pricing metadata at all (aimlapi, + * live `/v1/models` ids), which cannot be promised to cost nothing. + * The two facets partition the catalog, so no row ever vanishes from + * both views. + */ +export type ModelPricingFilter = "all" | "free" | "paid"; + +/** Cycle order for the one-key toggle; `all` first because it is the default. */ +const PRICING_CYCLE: readonly ModelPricingFilter[] = ["all", "free", "paid"]; + +export function nextModelPricingFilter( + current: ModelPricingFilter, +): ModelPricingFilter { + const at = PRICING_CYCLE.indexOf(current); + return PRICING_CYCLE[(at + 1) % PRICING_CYCLE.length] ?? "all"; +} + +/** `true` when the row's rendered price tag reads "free" — see the type note. */ +export function isFreeModelEntry( + modelId: string, + entry: ModelCatalogEntry | undefined, +): boolean { + if (!entry?.pricing) return false; + return formatTokenPrice(modelId, entry.pricing) === "free"; +} + +export function matchesModelPricingFilter( + filter: ModelPricingFilter, + modelId: string, + entry: ModelCatalogEntry | undefined, +): boolean { + if (filter === "all") return true; + return isFreeModelEntry(modelId, entry) === (filter === "free"); +} + +/** + * `ids` narrowed to the facet. `all` returns the input array itself so + * the no-facet path costs nothing on a 350-row catalog rebuilt per + * keystroke (same discipline as `filterWizardRows`). + */ +export function filterIdsByPricing( + ids: readonly string[], + filter: ModelPricingFilter, + lookup: ModelEntryLookup | undefined, +): readonly string[] { + if (filter === "all") return ids; + return ids.filter((id) => matchesModelPricingFilter(filter, id, lookup?.(id))); +} diff --git a/src/local-llm/backend-installer.test.ts b/src/local-llm/backend-installer.test.ts index 6a6822c7..3d4e841f 100644 --- a/src/local-llm/backend-installer.test.ts +++ b/src/local-llm/backend-installer.test.ts @@ -20,6 +20,7 @@ import { } from "./backend-installer.js"; import { resolveServerBinPath } from "./backend-paths.js"; import { readBackendVersion, writeBackendVersion } from "./backend-version.js"; +import { setConfiguredBackendVariant } from "./windows-backend-variant.js"; /** Minimal GitHub releases-list payload for the macOS arm64 asset. */ function releasesResponse( @@ -484,6 +485,40 @@ describe("backend-installer", () => { } }); + it("offers the CPU zip as an update when backendVariant 'cpu' is configured over a Vulkan install", async () => { + // The CPU-fallback persistence loop-guard: after the fallback wrote + // backendVariant "cpu", the staleness check must resolve the CPU + // asset — not re-detect Vulkan and reinstall the build that just + // failed on this machine. + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + setConfiguredBackendVariant("cpu"); + writeBackendVersion(dir, { + tag: "turboquant-win", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset: "llama-turboquant-windows-x64-vulkan.zip", + releasedAt: "2026-06-01T00:00:00Z", + }); + globalThis.fetch = vi.fn(async () => + releasesResponse([ + { + tag: "turboquant-win", + publishedAt: "2026-06-01T00:00:00Z", + assetName: "llama-turboquant-windows-x64-cpu.zip", + }, + ]), + ) as typeof fetch; + + try { + const check = await checkForBackendUpdate(dir); + expect(check.updateAvailable).toBe(true); + } finally { + setConfiguredBackendVariant("auto"); + platformSpy.mockRestore(); + archSpy.mockRestore(); + } + }); + it("treats a page-1 miss for this platform as 'no update', not an error", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); const archSpy = vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); diff --git a/src/local-llm/cpu-backend-fallback.test.ts b/src/local-llm/cpu-backend-fallback.test.ts new file mode 100644 index 00000000..c8459957 --- /dev/null +++ b/src/local-llm/cpu-backend-fallback.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const downloadBackendMock = vi.hoisted(() => vi.fn()); +const stopBothMock = vi.hoisted(() => vi.fn()); + +vi.mock("./backend-installer.js", () => ({ + downloadBackend: downloadBackendMock, +})); +vi.mock("./daemon-lifecycle.js", async (importOriginal) => ({ + ...(await importOriginal()), + stopChatAndEmbeddingDaemons: stopBothMock, +})); + +import { + fallBackToCpuBackend, + shouldFallBackToCpuBackend, +} from "./cpu-backend-fallback.js"; +import { DaemonHealthError } from "./daemon-lifecycle.js"; +import { + WINDOWS_BACKEND_ASSETS, + getConfiguredBackendVariant, + setConfiguredBackendVariant, +} from "./windows-backend-variant.js"; + +const healthError = new DaemonHealthError( + "llama-server did not become healthy within 30000ms. Log tail:\n(no log)", +); + +describe("shouldFallBackToCpuBackend", () => { + const eligible = { + installedAsset: WINDOWS_BACKEND_ASSETS.vulkan, + configuredVariant: "auto" as const, + error: healthError, + platform: "win32" as const, + }; + + it("falls back for a Vulkan install that never became healthy", () => { + expect(shouldFallBackToCpuBackend(eligible)).toBe(true); + }); + + it("falls back for either CUDA install too", () => { + expect( + shouldFallBackToCpuBackend({ + ...eligible, + installedAsset: WINDOWS_BACKEND_ASSETS.cuda124, + }), + ).toBe(true); + expect( + shouldFallBackToCpuBackend({ + ...eligible, + installedAsset: WINDOWS_BACKEND_ASSETS.cuda133, + }), + ).toBe(true); + }); + + it("falls back for a pre-`asset`-field install (undefined asset)", () => { + expect( + shouldFallBackToCpuBackend({ ...eligible, installedAsset: undefined }), + ).toBe(true); + }); + + it("never falls back off Windows", () => { + expect(shouldFallBackToCpuBackend({ ...eligible, platform: "darwin" })).toBe( + false, + ); + expect(shouldFallBackToCpuBackend({ ...eligible, platform: "linux" })).toBe( + false, + ); + }); + + it("honours an operator-pinned variant, including 'cpu' itself", () => { + expect( + shouldFallBackToCpuBackend({ ...eligible, configuredVariant: "vulkan" }), + ).toBe(false); + expect( + shouldFallBackToCpuBackend({ ...eligible, configuredVariant: "cpu" }), + ).toBe(false); + }); + + it("does not fall back when the CPU build is already installed", () => { + expect( + shouldFallBackToCpuBackend({ + ...eligible, + installedAsset: WINDOWS_BACKEND_ASSETS.cpu, + }), + ).toBe(false); + }); + + it("only reacts to a health-wait failure, not pre-spawn errors", () => { + // A missing model, a bound port or an "already running" daemon would + // fail the CPU build identically — no swap can fix those. + expect( + shouldFallBackToCpuBackend({ + ...eligible, + error: new Error("model qwen not downloaded"), + }), + ).toBe(false); + expect( + shouldFallBackToCpuBackend({ ...eligible, error: "not even an Error" }), + ).toBe(false); + }); +}); + +describe("fallBackToCpuBackend", () => { + beforeEach(() => { + setConfiguredBackendVariant("auto"); + downloadBackendMock.mockReset().mockResolvedValue({ ok: true, tag: "turboquant-x" }); + stopBothMock.mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + setConfiguredBackendVariant("auto"); + }); + + it("flips the variant to cpu, stops the half-started daemon, re-downloads", async () => { + const result = await fallBackToCpuBackend("/data"); + expect(result).toEqual({ tag: "turboquant-x" }); + expect(getConfiguredBackendVariant()).toBe("cpu"); + expect(stopBothMock).toHaveBeenCalledWith("/data"); + expect(downloadBackendMock).toHaveBeenCalledWith("/data", {}); + // The stop must land before the download, or the Windows file lock + // held by a hung loader would fail the backend swap. + expect(stopBothMock.mock.invocationCallOrder[0]!).toBeLessThan( + downloadBackendMock.mock.invocationCallOrder[0]!, + ); + }); + + it("still downloads when stopping the old daemon fails", async () => { + stopBothMock.mockRejectedValue(new Error("foreign pid")); + await expect(fallBackToCpuBackend("/data")).resolves.toEqual({ + tag: "turboquant-x", + }); + expect(downloadBackendMock).toHaveBeenCalledTimes(1); + }); + + it("propagates a failed download, leaving the cpu preference set", async () => { + downloadBackendMock.mockRejectedValue(new Error("HTTP 503")); + await expect(fallBackToCpuBackend("/data")).rejects.toThrow("HTTP 503"); + expect(getConfiguredBackendVariant()).toBe("cpu"); + }); + + it("forwards progress callback and abort signal to the download", async () => { + const onProgress = vi.fn(); + const signal = AbortSignal.timeout(60_000); + await fallBackToCpuBackend("/data", { onProgress, signal }); + expect(downloadBackendMock).toHaveBeenCalledWith("/data", { + onProgress, + signal, + }); + }); +}); diff --git a/src/local-llm/cpu-backend-fallback.ts b/src/local-llm/cpu-backend-fallback.ts new file mode 100644 index 00000000..2c4fa4e7 --- /dev/null +++ b/src/local-llm/cpu-backend-fallback.ts @@ -0,0 +1,73 @@ +import { downloadBackend } from "./backend-installer.js"; +import type { DownloadProgressFn } from "./download-file.js"; +import { DaemonHealthError, stopChatAndEmbeddingDaemons } from "./daemon-lifecycle.js"; +import { + isWindowsGpuBackendAsset, + setConfiguredBackendVariant, + type BackendVariantPreference, +} from "./windows-backend-variant.js"; + +/** + * Windows-only escape hatch for machines whose GPU stack cannot actually + * serve a model — most commonly an iGPU-only box (e.g. AMD 5600G) where + * the Vulkan build initializes but crashes or hangs on model load, so a + * `-ngl 0` device override cannot save it: the broken compute backend is + * baked into the binary. The only fix is swapping the installed zip for + * the CPU build the turboquant nightly also publishes. + * + * `shouldFallBackToCpuBackend` is the pure eligibility decision; the + * callers (TUI orchestrator, CLI `models start`) own the messaging, the + * `backendVariant: "cpu"` config persistence, and the single retry. + */ +export function shouldFallBackToCpuBackend(opts: { + /** `readBackendVersion(dataDir)?.asset` — undefined on old installs. */ + installedAsset: string | undefined; + /** Configured `localModels.managed.backendVariant`. */ + configuredVariant: BackendVariantPreference; + /** The error `startDaemon` / `startChatAndEmbeddingDaemons` rejected with. */ + error: unknown; + platform?: NodeJS.Platform; +}): boolean { + const platform = opts.platform ?? process.platform; + if (platform !== "win32") return false; + // An operator who pinned a variant made a call — honour it, even when + // that variant fails. `"cpu"` also lands here: falling back to what is + // already installed would loop. + if (opts.configuredVariant !== "auto") return false; + // Only a health-wait failure implicates the compute backend. A missing + // model file, a port already bound, or "already running" would fail the + // CPU build identically. + if (!(opts.error instanceof DaemonHealthError)) return false; + return isWindowsGpuBackendAsset(opts.installedAsset); +} + +/** + * Swap the installed Windows backend for the CPU build: flip the + * in-process variant preference to `"cpu"` (so `resolveDownloadAsset` + * picks the CPU zip), stop whatever half-started daemon is holding the + * binary (a hung loader would otherwise wedge the Windows file-lock on + * the swap), and re-download. Persisting `backendVariant: "cpu"` into + * the user config is the caller's job — without it the next + * auto-update's variant-staleness check would reinstall the GPU build + * and re-break the machine. + */ +export async function fallBackToCpuBackend( + dataDir: string, + opts?: { + onProgress?: DownloadProgressFn; + signal?: AbortSignal; + }, +): Promise<{ tag: string }> { + setConfiguredBackendVariant("cpu"); + try { + await stopChatAndEmbeddingDaemons(dataDir); + } catch { + // Best-effort: a foreign or already-dead pid must not block the + // swap attempt; downloadBackend will surface a real file lock. + } + const { tag } = await downloadBackend(dataDir, { + ...(opts?.onProgress ? { onProgress: opts.onProgress } : {}), + ...(opts?.signal ? { signal: opts.signal } : {}), + }); + return { tag }; +} diff --git a/src/local-llm/daemon-lifecycle.test.ts b/src/local-llm/daemon-lifecycle.test.ts index b918efab..23c09bdf 100644 --- a/src/local-llm/daemon-lifecycle.test.ts +++ b/src/local-llm/daemon-lifecycle.test.ts @@ -1,19 +1,37 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveEmbeddingPidFilePath, resolvePidFilePath } from "./backend-paths.js"; +const spawnMock = vi.hoisted(() => vi.fn()); +const execSyncMock = vi.hoisted(() => vi.fn()); +const execFileMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", () => ({ + spawn: spawnMock, + execSync: execSyncMock, + execFile: execFileMock, +})); + +import { + resolveEmbeddingPidFilePath, + resolveModelFilePath, + resolvePidFilePath, + resolveServerBinPath, +} from "./backend-paths.js"; import { buildEmbeddingServerArgs, buildLlamaServerArgs, + DaemonHealthError, ForeignDaemonError, readRunningPid, + startDaemon, stopDaemon, stopEmbeddingDaemon, type DaemonStartOptions, type EmbeddingDaemonStartOptions, } from "./daemon-lifecycle.js"; +import { getLocalModelDef } from "./models-catalog.js"; const baseOpts: DaemonStartOptions = { dataDir: "/tmp/data", @@ -154,6 +172,52 @@ describe("buildLlamaServerArgs", () => { expect(args[args.indexOf("-ngl") + 1]).toBe("-1"); }); + it("appends --split-mode layer --tensor-split when tensorSplit is set", () => { + const args = buildLlamaServerArgs( + { ...baseOpts, tensorSplit: [3, 1] }, + "/m.gguf", + "alias", + ); + const modeIdx = args.indexOf("--split-mode"); + expect(modeIdx).toBeGreaterThan(-1); + expect(args[modeIdx + 1]).toBe("layer"); + const splitIdx = args.indexOf("--tensor-split"); + expect(splitIdx).toBeGreaterThan(-1); + expect(args[splitIdx + 1]).toBe("3,1"); + // No pinned device: llama.cpp must keep every GPU visible to split. + expect(args).not.toContain("--device"); + expect(args[args.indexOf("-ngl") + 1]).toBe("-1"); + }); + + it("keeps --device alongside the split for an explicit device list", () => { + const args = buildLlamaServerArgs( + { ...baseOpts, device: "Vulkan0,Vulkan1", tensorSplit: [0.6, 0.4] }, + "/m.gguf", + "alias", + ); + expect(args[args.indexOf("--device") + 1]).toBe("Vulkan0,Vulkan1"); + expect(args[args.indexOf("--tensor-split") + 1]).toBe("0.6,0.4"); + }); + + it("does NOT emit split flags when device is 'cpu' (nothing to split)", () => { + const args = buildLlamaServerArgs( + { ...baseOpts, device: "cpu", tensorSplit: [1, 1] }, + "/m.gguf", + "alias", + ); + expect(args).not.toContain("--split-mode"); + expect(args).not.toContain("--tensor-split"); + expect(args[args.indexOf("-ngl") + 1]).toBe("0"); + }); + + it("does NOT emit split flags for an empty or absent tensorSplit", () => { + for (const opts of [baseOpts, { ...baseOpts, tensorSplit: [] }]) { + const args = buildLlamaServerArgs(opts, "/m.gguf", "alias"); + expect(args).not.toContain("--split-mode"); + expect(args).not.toContain("--tensor-split"); + } + }); + it("emits both --chat-template-file and --mmproj together", () => { const args = buildLlamaServerArgs( { @@ -371,3 +435,52 @@ describe("stopDaemon / stopEmbeddingDaemon (cross-user ownership)", () => { }); }); }); + +describe("startDaemon health-wait failure", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + spawnMock.mockReset(); + }); + + it("throws DaemonHealthError when the spawned server never becomes healthy", async () => { + // The typed class is load-bearing: the Windows CPU-backend fallback + // keys on `instanceof DaemonHealthError` to distinguish "the compute + // backend cannot serve on this machine" from pre-spawn failures. A + // revert to a bare `Error` would silently disable the fallback. + const dataDir = mkdtempSync(`${tmpdir()}/atomic-daemon-health-`); + try { + const binPath = resolveServerBinPath(dataDir, "llama-server"); + mkdirSync(dirname(binPath), { recursive: true }); + writeFileSync(binPath, "#!/bin/sh\n", "utf-8"); + const model = getLocalModelDef("qwen-3.5-4b"); + const modelPath = resolveModelFilePath(dataDir, model.id, model.filename); + mkdirSync(dirname(modelPath), { recursive: true }); + writeFileSync(modelPath, "gguf", "utf-8"); + + spawnMock.mockReturnValue({ pid: 4242, unref: () => {} }); + // Every health probe fails — the "server" crashed right after spawn. + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }), + ); + + vi.useFakeTimers(); + const started = startDaemon({ + dataDir, + modelId: "qwen-3.5-4b", + port: 19099, + // Pinned device skips the --list-devices / VRAM probes so the + // whole wait runs on the mocked clock. + device: "cpu", + }); + const rejects = expect(started).rejects.toBeInstanceOf(DaemonHealthError); + await vi.advanceTimersByTimeAsync(31_000); + await rejects; + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/local-llm/daemon-lifecycle.ts b/src/local-llm/daemon-lifecycle.ts index 6f299079..c3281f0a 100644 --- a/src/local-llm/daemon-lifecycle.ts +++ b/src/local-llm/daemon-lifecycle.ts @@ -61,6 +61,17 @@ export interface DaemonStartOptions { * `--ctx-size` exactly (clamped to the model's trained ceiling). */ contextSize?: number; + /** + * Multi-GPU ratios (`localModels.managed.tensorSplit`). A non-empty + * list appends `--split-mode layer --tensor-split ` so the + * model's layers spread across GPUs proportionally, and switches the + * `auto` device resolution from "pin the best single GPU" to "leave + * every GPU visible" (see `resolveManagedDevice`). Ignored when the + * device resolves to `"cpu"` — nothing is offloaded, so there is + * nothing to split. Empty / undefined keeps the single-device launch + * byte-identical. + */ + tensorSplit?: readonly number[]; } /** @@ -105,6 +116,9 @@ export function buildLlamaServerArgs( if (opts.device && opts.device !== "cpu") { args.push("--device", opts.device); } + if (opts.device !== "cpu" && opts.tensorSplit && opts.tensorSplit.length > 0) { + args.push("--split-mode", "layer", "--tensor-split", opts.tensorSplit.join(",")); + } if (opts.chatTemplateFile) { args.push("--chat-template-file", opts.chatTemplateFile); } @@ -260,6 +274,21 @@ export function readRunningPid( return pid; } +/** + * Thrown when a spawned llama-server never reached a healthy `/health` + * within the deadline — the process crashed on startup or could not + * load the model. Typed (rather than a bare `Error`) so the Windows + * CPU-backend fallback can distinguish "the installed compute backend + * cannot serve on this machine" from pre-spawn failures like a missing + * model file, which no backend swap would fix. + */ +export class DaemonHealthError extends Error { + constructor(message: string) { + super(message); + this.name = "DaemonHealthError"; + } +} + async function waitForHealthOkWithLog(dataDir: string, port: number, timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -275,7 +304,7 @@ async function waitForHealthOkWithLog(dataDir: string, port: number, timeoutMs: } catch { tail = "(no log)"; } - throw new Error( + throw new DaemonHealthError( `llama-server did not become healthy within ${timeoutMs}ms. Log tail:\n${tail}`, ); } @@ -301,7 +330,14 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<{ pid: numb ); } - const device = await resolveManagedDevice(binPath, opts.device); + // A configured tensor split flips `auto` device resolution to "leave + // every GPU visible" — pinning one `--device` would defeat the split. + // With no pinned device the context auto-sizer has no single VRAM + // figure to probe and degrades to its conservative no-VRAM default; + // operators splitting across GPUs can pin `contextSize` explicitly. + const device = await resolveManagedDevice(binPath, opts.device, { + multiGpu: (opts.tensorSplit?.length ?? 0) > 0, + }); const contextSize = await resolveEffectiveContextSize(binPath, device, model, { configured: opts.contextSize ?? 0, hasMmproj: Boolean(opts.mmprojFile), diff --git a/src/local-llm/gpu-devices.test.ts b/src/local-llm/gpu-devices.test.ts index 155f09ef..5fb04e27 100644 --- a/src/local-llm/gpu-devices.test.ts +++ b/src/local-llm/gpu-devices.test.ts @@ -1,3 +1,7 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { @@ -249,4 +253,60 @@ describe("resolveManagedDevice", () => { await resolveManagedDevice("/nonexistent/llama-server", undefined), ).toBeUndefined(); }); + + // Multi-GPU (`localModels.managed.tensorSplit` configured): `auto` + // must stop pinning the one best device — a pinned `--device` would + // defeat `--tensor-split` — while `cpu` and explicit ids keep their + // exact single-device semantics. + describe("multiGpu (tensor split configured)", () => { + it("still returns 'cpu' for the cpu sentinel", async () => { + expect( + await resolveManagedDevice("/nonexistent/llama-server", "cpu", { + multiGpu: true, + }), + ).toBe("cpu"); + }); + + it("still passes an explicit device list through", async () => { + expect( + await resolveManagedDevice( + "/nonexistent/llama-server", + "Vulkan0,Vulkan1", + { multiGpu: true }, + ), + ).toBe("Vulkan0,Vulkan1"); + }); + + it.skipIf(process.platform === "win32")( + "does NOT pin a device for 'auto' even when enumeration would find GPUs", + async () => { + // A real fake binary that reports two GPUs: without multiGpu the + // auto pick pins the larger card; with multiGpu it must resolve + // to undefined so llama.cpp keeps both devices visible. + const dir = mkdtempSync(join(tmpdir(), "gpu-devices-test-")); + const bin = join(dir, "llama-server"); + writeFileSync( + bin, + [ + "#!/bin/sh", + 'echo "Available devices:"', + 'echo " Vulkan0: NVIDIA GeForce RTX 4070 (8188 MiB, 8188 MiB free)"', + 'echo " Vulkan1: NVIDIA GeForce RTX 3090 (24576 MiB, 24000 MiB free)"', + ].join("\n"), + { mode: 0o755 }, + ); + try { + expect(await resolveManagedDevice(bin, "auto")).toBe("Vulkan1"); + expect( + await resolveManagedDevice(bin, "auto", { multiGpu: true }), + ).toBeUndefined(); + expect( + await resolveManagedDevice(bin, undefined, { multiGpu: true }), + ).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + }); }); diff --git a/src/local-llm/gpu-devices.ts b/src/local-llm/gpu-devices.ts index 4c6586d5..8ba434fa 100644 --- a/src/local-llm/gpu-devices.ts +++ b/src/local-llm/gpu-devices.ts @@ -183,15 +183,26 @@ export async function listVulkanDevices(binPath: string): Promise { * - `"auto"` / unset → enumerate + `pickBestDevice`; `undefined` * when no usable GPU (llama.cpp defaults / CPU). * + * `opts.multiGpu` (a configured `localModels.managed.tensorSplit`) + * changes only the `"auto"` / unset branch: instead of pinning the one + * best device — which would defeat `--tensor-split` — it resolves to + * `undefined` without enumerating, so llama.cpp keeps every GPU visible + * and splits across them. `"cpu"` still wins outright, and an explicit + * id (including a comma-separated list like `"Vulkan0,Vulkan1"`) still + * passes through so the operator can restrict which devices join the + * split. + * * Best-effort and never throws — enumeration failures fall through to * `undefined`. */ export async function resolveManagedDevice( binPath: string, configured: string | undefined, + opts?: { multiGpu?: boolean }, ): Promise { if (configured === "cpu") return "cpu"; if (configured && configured !== "auto") return configured; + if (opts?.multiGpu) return undefined; const devices = await listVulkanDevices(binPath); return pickBestDevice(devices) ?? undefined; } diff --git a/src/local-llm/index.ts b/src/local-llm/index.ts index 7f3ed3e7..2636695c 100644 --- a/src/local-llm/index.ts +++ b/src/local-llm/index.ts @@ -22,7 +22,19 @@ export { type PlatformAsset, } from "./platform-assets.js"; -export { resolveDownloadAsset } from "./windows-backend-variant.js"; +export { + resolveDownloadAsset, + BACKEND_VARIANT_PREFERENCES, + getConfiguredBackendVariant, + isBackendVariantPreference, + isWindowsGpuBackendAsset, + setConfiguredBackendVariant, + type BackendVariantPreference, +} from "./windows-backend-variant.js"; +export { + fallBackToCpuBackend, + shouldFallBackToCpuBackend, +} from "./cpu-backend-fallback.js"; export { resolveBackendDir, @@ -87,6 +99,7 @@ export { getDaemonStatus, readRunningPid, classifyPidLiveness, + DaemonHealthError, ForeignDaemonError, probeLlamaHealth, buildLlamaServerArgs, diff --git a/src/local-llm/windows-backend-variant.test.ts b/src/local-llm/windows-backend-variant.test.ts index 8617fe6b..ae725f80 100644 --- a/src/local-llm/windows-backend-variant.test.ts +++ b/src/local-llm/windows-backend-variant.test.ts @@ -5,10 +5,12 @@ vi.mock("node:child_process", () => ({ execSync: execSyncMock })); import { WINDOWS_BACKEND_ASSETS, + isWindowsGpuBackendAsset, parseDriverCudaVersion, resetWindowsBackendAssetCache, resolveDownloadAsset, selectWindowsBackendAsset, + setConfiguredBackendVariant, } from "./windows-backend-variant.js"; const NVIDIA_SMI_HEADER = ` @@ -91,14 +93,34 @@ describe("selectWindowsBackendAsset", () => { }); }); +describe("isWindowsGpuBackendAsset", () => { + it("recognises the three GPU builds", () => { + expect(isWindowsGpuBackendAsset(WINDOWS_BACKEND_ASSETS.vulkan)).toBe(true); + expect(isWindowsGpuBackendAsset(WINDOWS_BACKEND_ASSETS.cuda124)).toBe(true); + expect(isWindowsGpuBackendAsset(WINDOWS_BACKEND_ASSETS.cuda133)).toBe(true); + }); + + it("rejects the CPU build", () => { + expect(isWindowsGpuBackendAsset(WINDOWS_BACKEND_ASSETS.cpu)).toBe(false); + }); + + it("treats a pre-`asset`-field install as a GPU build", () => { + // The CPU zip was not downloadable before the field existed, so an + // undefined asset on win32 can only be one of the GPU builds. + expect(isWindowsGpuBackendAsset(undefined)).toBe(true); + }); +}); + describe("resolveDownloadAsset", () => { beforeEach(() => { resetWindowsBackendAssetCache(); + setConfiguredBackendVariant("auto"); execSyncMock.mockReset(); }); afterEach(() => { resetWindowsBackendAssetCache(); + setConfiguredBackendVariant("auto"); }); it("leaves macOS/Linux assets untouched (no nvidia-smi probe)", () => { @@ -134,4 +156,56 @@ describe("resolveDownloadAsset", () => { resolveDownloadAsset("win32", "x64"); expect(execSyncMock).toHaveBeenCalledTimes(1); }); + + it("a configured 'cpu' variant pins the CPU zip without probing nvidia-smi", () => { + setConfiguredBackendVariant("cpu"); + expect(resolveDownloadAsset("win32", "x64").assetName).toBe( + WINDOWS_BACKEND_ASSETS.cpu, + ); + expect(execSyncMock).not.toHaveBeenCalled(); + }); + + it("a configured 'vulkan' variant beats a CUDA-capable driver", () => { + execSyncMock.mockReturnValue(Buffer.from(NVIDIA_SMI_HEADER)); + setConfiguredBackendVariant("vulkan"); + expect(resolveDownloadAsset("win32", "x64").assetName).toBe( + WINDOWS_BACKEND_ASSETS.vulkan, + ); + expect(execSyncMock).not.toHaveBeenCalled(); + }); + + it("a variant configured after detection is not shadowed by the cache", () => { + // The CPU fallback flips the preference mid-process, after the + // auto-update path already detected (and cached) a GPU asset. + execSyncMock.mockReturnValue(Buffer.from(NVIDIA_SMI_HEADER)); + expect(resolveDownloadAsset("win32", "x64").assetName).toBe( + WINDOWS_BACKEND_ASSETS.cuda124, + ); + setConfiguredBackendVariant("cpu"); + expect(resolveDownloadAsset("win32", "x64").assetName).toBe( + WINDOWS_BACKEND_ASSETS.cpu, + ); + }); + + it("returning to 'auto' restores detection", () => { + execSyncMock.mockReturnValue(Buffer.from(NVIDIA_SMI_HEADER)); + setConfiguredBackendVariant("cpu"); + expect(resolveDownloadAsset("win32", "x64").assetName).toBe( + WINDOWS_BACKEND_ASSETS.cpu, + ); + setConfiguredBackendVariant("auto"); + expect(resolveDownloadAsset("win32", "x64").assetName).toBe( + WINDOWS_BACKEND_ASSETS.cuda124, + ); + }); + + it("ignores the variant preference off Windows (single-asset platforms)", () => { + setConfiguredBackendVariant("cpu"); + expect(resolveDownloadAsset("darwin", "arm64").assetName).toBe( + "llama-turboquant-macos-arm64.zip", + ); + expect(resolveDownloadAsset("linux", "x64").assetName).toBe( + "llama-turboquant-linux-x64-vulkan.zip", + ); + }); }); diff --git a/src/local-llm/windows-backend-variant.ts b/src/local-llm/windows-backend-variant.ts index 5c827255..39a76c6c 100644 --- a/src/local-llm/windows-backend-variant.ts +++ b/src/local-llm/windows-backend-variant.ts @@ -7,14 +7,84 @@ import { resolvePlatformAsset, type PlatformAsset } from "./platform-assets.js"; * inside every zip is `llama-server.exe`; only the bundled compute * backend differs. We pick the fastest one the machine can actually run * and fall back to Vulkan (broadest GPU support, no CUDA driver - * requirement) when no compatible NVIDIA driver is detected. + * requirement) when no compatible NVIDIA driver is detected. The CPU + * build is never picked by detection — it exists for boxes whose only + * "GPU" is an iGPU the Vulkan build cannot actually load a model on + * (e.g. AMD 5600G Vega), reached via `localModels.managed.backendVariant` + * or the automatic start-failure fallback in `cpu-backend-fallback.ts`. */ export const WINDOWS_BACKEND_ASSETS = { vulkan: "llama-turboquant-windows-x64-vulkan.zip", cuda124: "llama-turboquant-windows-x64-cuda-12.4.zip", cuda133: "llama-turboquant-windows-x64-cuda-13.3.zip", + cpu: "llama-turboquant-windows-x64-cpu.zip", } as const; +/** + * Operator-facing values for `localModels.managed.backendVariant`. + * `"auto"` keeps the nvidia-smi driven detection; the rest pin one of + * the Windows zips outright (no probe). Meaningful only on win32 — + * every other platform publishes a single asset, so the preference is + * ignored there. + */ +export const BACKEND_VARIANT_PREFERENCES = [ + "auto", + "cpu", + "vulkan", + "cuda-12.4", + "cuda-13.3", +] as const; + +export type BackendVariantPreference = (typeof BACKEND_VARIANT_PREFERENCES)[number]; + +export function isBackendVariantPreference( + raw: unknown, +): raw is BackendVariantPreference { + return BACKEND_VARIANT_PREFERENCES.includes(raw as BackendVariantPreference); +} + +const ASSET_BY_VARIANT_PREFERENCE: Record< + Exclude, + string +> = { + cpu: WINDOWS_BACKEND_ASSETS.cpu, + vulkan: WINDOWS_BACKEND_ASSETS.vulkan, + "cuda-12.4": WINDOWS_BACKEND_ASSETS.cuda124, + "cuda-13.3": WINDOWS_BACKEND_ASSETS.cuda133, +}; + +/** + * Configured `localModels.managed.backendVariant`, pushed in by + * `loadConfig` the same way `setCustomLocalModels` publishes custom + * models — the local-llm layer stays config-free. Also flipped to + * `"cpu"` in-process by the start-failure fallback so the re-download + * that follows resolves the CPU zip without waiting for a config + * round-trip. + */ +let configuredBackendVariant: BackendVariantPreference = "auto"; + +export function setConfiguredBackendVariant(v: BackendVariantPreference): void { + configuredBackendVariant = v; +} + +export function getConfiguredBackendVariant(): BackendVariantPreference { + return configuredBackendVariant; +} + +/** + * True when `assetName` is one of the Windows GPU builds (or an install + * old enough to predate `BackendVersionInfo.asset` — the CPU zip was not + * downloadable back then, so an undefined asset on win32 is a GPU build). + */ +export function isWindowsGpuBackendAsset(assetName: string | undefined): boolean { + if (assetName === undefined) return true; + return ( + assetName === WINDOWS_BACKEND_ASSETS.vulkan || + assetName === WINDOWS_BACKEND_ASSETS.cuda124 || + assetName === WINDOWS_BACKEND_ASSETS.cuda133 + ); +} + export interface CudaVersion { major: number; minor: number; @@ -92,8 +162,14 @@ let cachedWindowsAsset: string | null = null; * result process-wide. Hardware does not change during a run, so we * probe `nvidia-smi` at most once; the hot path (`isBackendDownloaded` * poll) never triggers a probe because it only needs `binaryName`. + * A non-`auto` configured variant bypasses both the probe and the + * cache — the preference can change mid-process (config edit, CPU + * fallback), so it must never be shadowed by a stale detection result. */ export function detectWindowsBackendAsset(): string { + if (configuredBackendVariant !== "auto") { + return ASSET_BY_VARIANT_PREFERENCE[configuredBackendVariant]; + } if (cachedWindowsAsset !== null) return cachedWindowsAsset; cachedWindowsAsset = selectWindowsBackendAsset(detectDriverCudaVersion()); return cachedWindowsAsset; diff --git a/src/memory/index.ts b/src/memory/index.ts index 62955c2c..9823db59 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -63,3 +63,12 @@ export type { HybridRecallOptions, LlamaEmbeddingClientOptions, } from "./embeddings/index.js"; +export { + DEFAULT_EXPORT_FOLDER, + ObsidianExportUsageError, + exportMemoryToObsidian, +} from "./obsidian-export.js"; +export type { + ObsidianExportOptions, + ObsidianExportResult, +} from "./obsidian-export.js"; diff --git a/src/memory/obsidian-export.test.ts b/src/memory/obsidian-export.test.ts new file mode 100644 index 00000000..0ab4da26 --- /dev/null +++ b/src/memory/obsidian-export.test.ts @@ -0,0 +1,325 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Database as DatabaseCtor } from "../native/load-better-sqlite3.js"; +import { MemoryStore } from "./memory-store.js"; +import { LessonStore } from "./lessons/lesson-store.js"; +import { ProcedureStore } from "./procedures/procedure-store.js"; +import { LinkStore } from "./links/link-store.js"; +import { + ObsidianExportUsageError, + exportMemoryToObsidian, +} from "./obsidian-export.js"; + +const T0 = Date.UTC(2026, 0, 2, 3, 4, 5); // 2026-01-02T03:04:05.000Z + +/** + * Build a small corpus exercising every exported edge kind: a + * note→note link, a lesson with parents (one evicted), an archived + * note, and a procedure sourced from both a lesson and a note. + * + * Returns the ids so assertions don't hardcode AUTOINCREMENT values. + */ +function buildCorpus(dbFile: string): { + noteA: number; + noteB: number; + noteC: number; + lesson: number; + procedure: number; +} { + const notes = new MemoryStore({ dbFile, maxEntries: 100, now: () => T0 }); + const lessons = new LessonStore({ dbFile, now: () => T0 }); + const procedures = new ProcedureStore({ dbFile, now: () => T0 }); + try { + const noteA = notes.store({ + content: "Tap the simulator with a duration or taps get dropped.", + tags: ["ios", "simulator"], + }).id; + const noteB = notes.store({ content: "idb is the reliable input path." }).id; + const noteC = notes.store({ content: "A note that will be deleted." }).id; + + const links = new LinkStore({ + db: notes.getDatabaseHandleForEmbeddings(), + now: () => T0, + }); + links.add({ fromId: noteA, toId: noteB, kind: "RELATES_TO" }); + + const lesson = lessons.create({ + activation: "when driving the ios simulator", + principle: "Prefer idb, and always tap with an explicit duration.", + tags: ["ios"], + // 9999 is a soft pointer to an evicted episode — must not render. + parentIds: [noteA, noteB, 9999], + }).id; + notes.archiveInto([noteA], lesson); + + const procedure = procedures.create({ + activation: "installing an app on the simulator", + steps: [ + { description: "Boot the target simulator", toolHint: "os.exec" }, + { description: "Install and launch the app" }, + ], + tags: ["ios"], + parentLessonIds: [lesson], + parentMemoryIds: [noteB, 9999], + }).id; + + return { noteA, noteB, noteC, lesson, procedure }; + } finally { + procedures.close(); + lessons.close(); + notes.close(); + } +} + +describe("exportMemoryToObsidian", () => { + let dir: string; + let dbFile: string; + let vaultDir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "atomic-obsidian-export-")); + dbFile = join(dir, "memory.sqlite"); + vaultDir = join(dir, "vault"); + mkdirSync(vaultDir); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("exports notes, lessons and procedures as markdown with frontmatter", () => { + const ids = buildCorpus(dbFile); + const result = exportMemoryToObsidian({ dbFile, vaultDir }); + + expect(result).toMatchObject({ + notes: 3, + lessons: 1, + procedures: 1, + pruned: 0, + }); + expect(result.root).toBe(join(vaultDir, "atomic-agent")); + + const noteA = readFileSync( + join(result.root, "notes", `note-${ids.noteA}.md`), + "utf8", + ); + expect(noteA).toContain("type: memory-note"); + expect(noteA).toContain(`id: ${ids.noteA}`); + expect(noteA).toContain("created: 2026-01-02T03:04:05.000Z"); + expect(noteA).toContain('source: "agent"'); + expect(noteA).toContain(' - "ios"'); + expect(noteA).toContain(' - "simulator"'); + expect(noteA).toContain( + "Tap the simulator with a duration or taps get dropped.", + ); + + const lesson = readFileSync( + join(result.root, "lessons", `lesson-${ids.lesson}.md`), + "utf8", + ); + expect(lesson).toContain("type: memory-lesson"); + expect(lesson).toContain('status: "active"'); + expect(lesson).toContain("**When:** when driving the ios simulator"); + expect(lesson).toContain( + "Prefer idb, and always tap with an explicit duration.", + ); + + const procedure = readFileSync( + join(result.root, "procedures", `procedure-${ids.procedure}.md`), + "utf8", + ); + expect(procedure).toContain("type: memory-procedure"); + expect(procedure).toContain('source: "consolidator"'); + expect(procedure).toContain("1. Boot the target simulator (`os.exec`)"); + expect(procedure).toContain("2. Install and launch the app"); + }); + + it("renders wikilinks along schema edges and skips dangling soft pointers", () => { + const ids = buildCorpus(dbFile); + const result = exportMemoryToObsidian({ dbFile, vaultDir }); + + const noteA = readFileSync( + join(result.root, "notes", `note-${ids.noteA}.md`), + "utf8", + ); + expect(noteA).toContain(`- relates to [[note-${ids.noteB}]]`); + expect(noteA).toContain(`- consolidated into [[lesson-${ids.lesson}]]`); + + const lesson = readFileSync( + join(result.root, "lessons", `lesson-${ids.lesson}.md`), + "utf8", + ); + expect(lesson).toContain(`- [[note-${ids.noteA}]]`); + expect(lesson).toContain(`- [[note-${ids.noteB}]]`); + expect(lesson).not.toContain("9999"); + + const procedure = readFileSync( + join(result.root, "procedures", `procedure-${ids.procedure}.md`), + "utf8", + ); + expect(procedure).toContain(`- [[lesson-${ids.lesson}]]`); + expect(procedure).toContain(`- [[note-${ids.noteB}]]`); + expect(procedure).not.toContain("9999"); + + // noteB has no outgoing edges and is not archived — no Links section. + const noteB = readFileSync( + join(result.root, "notes", `note-${ids.noteB}.md`), + "utf8", + ); + expect(noteB).not.toContain("## Links"); + }); + + it("is idempotent: a re-export against an unchanged corpus is byte-identical", () => { + buildCorpus(dbFile); + const first = exportMemoryToObsidian({ dbFile, vaultDir }); + const snapshot = new Map(); + for (const sub of ["notes", "lessons", "procedures"]) { + for (const name of readdirSync(join(first.root, sub))) { + snapshot.set( + `${sub}/${name}`, + readFileSync(join(first.root, sub, name), "utf8"), + ); + } + } + + const second = exportMemoryToObsidian({ dbFile, vaultDir }); + expect(second).toEqual(first); + for (const [rel, content] of snapshot) { + expect(readFileSync(join(first.root, rel), "utf8")).toBe(content); + } + }); + + it("prunes machine-owned files of deleted records but never user files", () => { + const ids = buildCorpus(dbFile); + const first = exportMemoryToObsidian({ dbFile, vaultDir }); + const stale = join(first.root, "notes", `note-${ids.noteC}.md`); + expect(existsSync(stale)).toBe(true); + + // A user file inside the export folder and one in the vault root: + // both must survive every re-export. + const userFile = join(first.root, "notes", "my own thoughts.md"); + writeFileSync(userFile, "hands off\n"); + const rootFile = join(vaultDir, "daily.md"); + writeFileSync(rootFile, "unrelated vault note\n"); + + const notes = new MemoryStore({ dbFile, maxEntries: 100, now: () => T0 }); + try { + expect(notes.remove(ids.noteC)).toBe(true); + } finally { + notes.close(); + } + + const second = exportMemoryToObsidian({ dbFile, vaultDir }); + expect(second.notes).toBe(2); + expect(second.pruned).toBe(1); + expect(existsSync(stale)).toBe(false); + expect(readFileSync(userFile, "utf8")).toBe("hands off\n"); + expect(readFileSync(rootFile, "utf8")).toBe("unrelated vault note\n"); + }); + + it("overwrites an exported file after the underlying record changed", () => { + const ids = buildCorpus(dbFile); + const first = exportMemoryToObsidian({ dbFile, vaultDir }); + const path = join(first.root, "notes", `note-${ids.noteB}.md`); + expect(readFileSync(path, "utf8")).toContain("idb is the reliable input path."); + + const db = new DatabaseCtor(dbFile); + try { + db.prepare(`UPDATE memories SET content = ? WHERE id = ?`).run( + "idb is the ONLY reliable input path.", + ids.noteB, + ); + } finally { + db.close(); + } + + exportMemoryToObsidian({ dbFile, vaultDir }); + expect(readFileSync(path, "utf8")).toContain( + "idb is the ONLY reliable input path.", + ); + }); + + it("honors --folder and refuses folders escaping the vault", () => { + buildCorpus(dbFile); + const result = exportMemoryToObsidian({ + dbFile, + vaultDir, + folder: "zettel/agent-memory", + }); + expect(result.root).toBe(join(vaultDir, "zettel", "agent-memory")); + expect(existsSync(join(result.root, "notes"))).toBe(true); + + for (const folder of ["", " ", "..", ".", "../outside"]) { + expect(() => + exportMemoryToObsidian({ dbFile, vaultDir, folder }), + ).toThrow(ObsidianExportUsageError); + } + }); + + it("fails on a missing database or a missing vault directory", () => { + expect(() => exportMemoryToObsidian({ dbFile, vaultDir })).toThrow( + /no memory database/, + ); + buildCorpus(dbFile); + expect(() => + exportMemoryToObsidian({ dbFile, vaultDir: join(dir, "nope") }), + ).toThrow(/vault directory does not exist/); + }); + + it("reads a pre-lessons legacy schema without migrating it", () => { + // Hand-rolled v2-era database: only `memories`, no lessons / + // procedures / links tables, no `consolidated_into` column. + const db = new DatabaseCtor(dbFile); + try { + db.exec(` + CREATE TABLE memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + session_id TEXT, + working_dir TEXT, + tags TEXT + ); + `); + db.prepare( + `INSERT INTO memories (content, created_at, updated_at, source, tags) + VALUES (?, ?, ?, 'agent', ?)`, + ).run("legacy note", T0, T0, JSON.stringify(["old"])); + } finally { + db.close(); + } + + const result = exportMemoryToObsidian({ dbFile, vaultDir }); + expect(result).toMatchObject({ notes: 1, lessons: 0, procedures: 0 }); + const note = readFileSync( + join(result.root, "notes", "note-1.md"), + "utf8", + ); + expect(note).toContain("legacy note"); + expect(note).toContain(' - "old"'); + + // Read-only guarantee: the export created none of the newer tables. + const check = new DatabaseCtor(dbFile, { readonly: true }); + try { + const tables = check + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`) + .all() as { name: string }[]; + expect(tables.map((t) => t.name)).toEqual(["memories", "sqlite_sequence"]); + } finally { + check.close(); + } + }); +}); diff --git a/src/memory/obsidian-export.ts b/src/memory/obsidian-export.ts new file mode 100644 index 00000000..ef13cca8 --- /dev/null +++ b/src/memory/obsidian-export.ts @@ -0,0 +1,435 @@ +/** + * One-way export of the cross-session memory corpus (`memory.sqlite`) + * into an Obsidian vault as plain markdown. + * + * Design decisions (v1, `memory export` CLI): + * + * - **One-way, read-only.** The database is opened with + * `{ readonly: true }` and migrations are *not* applied — an export + * must never mutate agent state, not even a schema bump. Tables + * that predate the current schema (`lessons`, `procedures`, + * `memory_links`) are treated as empty when absent, and rows are + * read via `SELECT *` so missing columns degrade to `undefined` + * instead of a thrown error. + * + * - **Stable, id-based filenames.** `notes/note-.md`, + * `lessons/lesson-.md`, `procedures/procedure-.md` under a + * single export folder inside the vault. Ids are AUTOINCREMENT so a + * record keeps its filename (and therefore its Obsidian identity, + * backlinks included) across re-exports. Obsidian resolves + * `[[note-17]]` by basename regardless of folder. + * + * - **Idempotent + overwrite-safe.** File content is a pure function + * of the database row (no export timestamps), files are rewritten + * only when their bytes actually changed (stable mtimes for vault + * sync tools), and pruning of stale files is restricted to names + * matching the machine-owned `note-.md` / `lesson-.md` / + * `procedure-.md` patterns inside the three export subfolders. + * Anything else the user keeps in those folders is never touched. + * + * - **Wikilinks follow the schema's own edges.** `memory_links` rows + * become a `## Links` section on the source note; `consolidated_into` + * points a note at its lesson; `lessons.parent_ids` and + * `procedures.parent_lesson_ids` / `parent_memory_ids` become + * `## Sources` sections. Soft pointers whose target row no longer + * exists (evicted episodes) are skipped rather than rendered as + * dangling links. + * + * No watch mode, no sync-back, no conflict handling — a re-export + * overwrites the exported files, full stop. + */ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve, sep } from "node:path"; +import type Database from "better-sqlite3"; +import { Database as DatabaseCtor } from "../native/load-better-sqlite3.js"; + +export const DEFAULT_EXPORT_FOLDER = "atomic-agent"; + +/** + * Invalid-argument errors (bad `folder`, …) — the CLI maps these to + * exit code 2 (usage) while every other throw stays an operational + * failure (exit code 1). + */ +export class ObsidianExportUsageError extends Error {} + +export interface ObsidianExportOptions { + /** Path to `memory.sqlite` (usually `getConfig().paths.memoryDbFile`). */ + dbFile: string; + /** Existing Obsidian vault directory. Never created by the export. */ + vaultDir: string; + /** + * Subfolder of the vault that the export owns. Default + * `atomic-agent`. Must stay inside the vault and must not be the + * vault root itself — the pruning pass only runs inside this + * folder's `notes/` / `lessons/` / `procedures/` subfolders. + */ + folder?: string; +} + +export interface ObsidianExportResult { + /** Absolute path of the export folder inside the vault. */ + root: string; + notes: number; + lessons: number; + procedures: number; + /** Stale machine-owned files removed because their record is gone. */ + pruned: number; +} + +interface Row { + [column: string]: unknown; +} + +const NOTE_FILE = /^note-(\d+)\.md$/; +const LESSON_FILE = /^lesson-(\d+)\.md$/; +const PROCEDURE_FILE = /^procedure-(\d+)\.md$/; + +export function exportMemoryToObsidian( + options: ObsidianExportOptions, +): ObsidianExportResult { + const root = resolveExportRoot(options.vaultDir, options.folder); + if (!existsSync(options.dbFile)) { + throw new Error( + `no memory database at ${options.dbFile} — nothing to export`, + ); + } + if (!existsSync(options.vaultDir)) { + throw new Error( + `vault directory does not exist: ${options.vaultDir} (pass an existing Obsidian vault — the export never creates one)`, + ); + } + + const db = new DatabaseCtor(options.dbFile, { + readonly: true, + fileMustExist: true, + }) as Database.Database; + let notes: Row[]; + let lessons: Row[]; + let procedures: Row[]; + let links: Row[]; + try { + notes = readAll(db, "memories", "id"); + lessons = readAll(db, "lessons", "id"); + procedures = readAll(db, "procedures", "id"); + links = readAll(db, "memory_links", "from_id, to_id, kind"); + } finally { + db.close(); + } + + const noteIds = new Set(notes.map((r) => asId(r.id))); + const lessonIds = new Set(lessons.map((r) => asId(r.id))); + const procedureIds = new Set(procedures.map((r) => asId(r.id))); + + const linksByFrom = new Map(); + for (const link of links) { + const from = asId(link.from_id); + const bucket = linksByFrom.get(from); + if (bucket) bucket.push(link); + else linksByFrom.set(from, [link]); + } + + const notesDir = join(root, "notes"); + const lessonsDir = join(root, "lessons"); + const proceduresDir = join(root, "procedures"); + mkdirSync(notesDir, { recursive: true }); + mkdirSync(lessonsDir, { recursive: true }); + mkdirSync(proceduresDir, { recursive: true }); + + for (const row of notes) { + const id = asId(row.id); + writeIfChanged( + join(notesDir, `note-${id}.md`), + renderNote(row, linksByFrom.get(id) ?? [], noteIds, lessonIds), + ); + } + for (const row of lessons) { + const id = asId(row.id); + writeIfChanged(join(lessonsDir, `lesson-${id}.md`), renderLesson(row, noteIds)); + } + for (const row of procedures) { + const id = asId(row.id); + writeIfChanged( + join(proceduresDir, `procedure-${id}.md`), + renderProcedure(row, noteIds, lessonIds), + ); + } + + const pruned = + pruneStale(notesDir, NOTE_FILE, noteIds) + + pruneStale(lessonsDir, LESSON_FILE, lessonIds) + + pruneStale(proceduresDir, PROCEDURE_FILE, procedureIds); + + return { + root, + notes: notes.length, + lessons: lessons.length, + procedures: procedures.length, + pruned, + }; +} + +function resolveExportRoot(vaultDir: string, folder: string | undefined): string { + const name = folder ?? DEFAULT_EXPORT_FOLDER; + if (name.trim().length === 0) { + throw new ObsidianExportUsageError("--folder must not be empty"); + } + const vault = resolve(vaultDir); + const root = resolve(vault, name); + if (root === vault || !root.startsWith(vault + sep)) { + throw new ObsidianExportUsageError( + `--folder must name a subfolder inside the vault, got: ${name}`, + ); + } + return root; +} + +function readAll(db: Database.Database, table: string, orderBy: string): Row[] { + const present = db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`) + .get(table); + if (present === undefined) return []; + return db.prepare(`SELECT * FROM ${table} ORDER BY ${orderBy}`).all() as Row[]; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +function renderNote( + row: Row, + outgoing: Row[], + noteIds: Set, + lessonIds: Set, +): string { + const lines = [ + "---", + "type: memory-note", + `id: ${asId(row.id)}`, + `created: ${isoDate(row.created_at)}`, + `updated: ${isoDate(row.updated_at)}`, + `source: ${yamlScalar(asOptionalString(row.source) ?? "agent")}`, + ...yamlTags(parseStringArray(row.tags)), + "---", + "", + asOptionalString(row.content) ?? "", + ]; + + const linkLines: string[] = []; + for (const link of outgoing) { + const to = asId(link.to_id); + if (!noteIds.has(to)) continue; + linkLines.push(`- ${linkKindLabel(link.kind)} [[note-${to}]]`); + } + const consolidatedInto = asOptionalId(row.consolidated_into); + if (consolidatedInto !== null && lessonIds.has(consolidatedInto)) { + linkLines.push(`- consolidated into [[lesson-${consolidatedInto}]]`); + } + if (linkLines.length > 0) { + lines.push("", "## Links", "", ...linkLines); + } + lines.push(""); + return lines.join("\n"); +} + +function renderLesson(row: Row, noteIds: Set): string { + const lines = [ + "---", + "type: memory-lesson", + `id: ${asId(row.id)}`, + `created: ${isoDate(row.created_at)}`, + `updated: ${isoDate(row.updated_at)}`, + `status: ${yamlScalar(asOptionalString(row.status) ?? "active")}`, + ...yamlTags(parseStringArray(row.tags)), + "---", + "", + `**When:** ${asOptionalString(row.activation) ?? ""}`, + "", + asOptionalString(row.principle) ?? "", + ]; + const sources = parseNumberArray(row.parent_ids).filter((id) => + noteIds.has(id), + ); + if (sources.length > 0) { + lines.push("", "## Sources", "", ...sources.map((id) => `- [[note-${id}]]`)); + } + lines.push(""); + return lines.join("\n"); +} + +function renderProcedure( + row: Row, + noteIds: Set, + lessonIds: Set, +): string { + const lines = [ + "---", + "type: memory-procedure", + `id: ${asId(row.id)}`, + `created: ${isoDate(row.created_at)}`, + `updated: ${isoDate(row.updated_at)}`, + `status: ${yamlScalar(asOptionalString(row.status) ?? "active")}`, + `source: ${yamlScalar(asOptionalString(row.source) ?? "consolidator")}`, + ...yamlTags(parseStringArray(row.tags)), + "---", + "", + `**When:** ${asOptionalString(row.activation) ?? ""}`, + ]; + const steps = parseSteps(row.steps); + if (steps.length > 0) { + lines.push("", "## Steps", ""); + steps.forEach((step, index) => { + const hint = step.toolHint ? ` (\`${step.toolHint}\`)` : ""; + lines.push(`${index + 1}. ${step.description}${hint}`); + }); + } + const sourceLines = [ + ...parseNumberArray(row.parent_lesson_ids) + .filter((id) => lessonIds.has(id)) + .map((id) => `- [[lesson-${id}]]`), + ...parseNumberArray(row.parent_memory_ids) + .filter((id) => noteIds.has(id)) + .map((id) => `- [[note-${id}]]`), + ]; + if (sourceLines.length > 0) { + lines.push("", "## Sources", "", ...sourceLines); + } + lines.push(""); + return lines.join("\n"); +} + +/** `RELATES_TO` → `relates to` — a human-readable edge label. */ +function linkKindLabel(kind: unknown): string { + const raw = asOptionalString(kind) ?? "relates to"; + return raw.toLowerCase().replace(/_/g, " "); +} + +// --------------------------------------------------------------------------- +// YAML helpers — double-quoted JSON scalars are valid YAML, so the +// escaping burden collapses onto JSON.stringify. +// --------------------------------------------------------------------------- + +function yamlScalar(value: string): string { + return JSON.stringify(value); +} + +function yamlTags(tags: string[]): string[] { + if (tags.length === 0) return ["tags: []"]; + return ["tags:", ...tags.map((tag) => ` - ${yamlScalar(tag)}`)]; +} + +// --------------------------------------------------------------------------- +// Defensive row decoding — the export must survive rows written by any +// past schema version, so every accessor tolerates missing / malformed +// values instead of trusting the current column shapes. +// --------------------------------------------------------------------------- + +function asId(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n)) { + throw new Error(`memory export: non-numeric row id: ${String(value)}`); + } + return n; +} + +function asOptionalId(value: unknown): number | null { + if (value === null || value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function asOptionalString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function isoDate(value: unknown): string { + const n = Number(value); + const ms = Number.isFinite(n) ? n : 0; + return new Date(ms).toISOString(); +} + +function parseStringArray(raw: unknown): string[] { + if (typeof raw !== "string" || raw.length === 0) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is string => typeof item === "string"); + } catch { + return []; + } +} + +function parseNumberArray(raw: unknown): number[] { + if (typeof raw !== "string" || raw.length === 0) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)); + } catch { + return []; + } +} + +function parseSteps(raw: unknown): { description: string; toolHint: string | null }[] { + if (typeof raw !== "string" || raw.length === 0) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const steps: { description: string; toolHint: string | null }[] = []; + for (const item of parsed) { + if (typeof item !== "object" || item === null) continue; + const description = (item as Row).description; + if (typeof description !== "string") continue; + const toolHint = (item as Row).toolHint; + steps.push({ + description, + toolHint: typeof toolHint === "string" && toolHint.length > 0 ? toolHint : null, + }); + } + return steps; + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Filesystem +// --------------------------------------------------------------------------- + +/** Skip the write when bytes match so vault-sync tools see stable mtimes. */ +function writeIfChanged(path: string, content: string): void { + if (existsSync(path)) { + try { + if (readFileSync(path, "utf8") === content) return; + } catch { + // Unreadable existing file — fall through to the overwrite. + } + } + writeFileSync(path, content); +} + +/** + * Remove machine-owned files whose record no longer exists. Only exact + * `-.md` names are candidates — the user's own files in the + * export folders are never touched. + */ +function pruneStale(dir: string, pattern: RegExp, liveIds: Set): number { + let pruned = 0; + for (const name of readdirSync(dir)) { + const match = pattern.exec(name); + if (!match) continue; + const id = Number(match[1]); + if (liveIds.has(id)) continue; + rmSync(join(dir, name)); + pruned += 1; + } + return pruned; +} diff --git a/src/prompt/build-prompt-types.ts b/src/prompt/build-prompt-types.ts index 96cc6110..ea503a3e 100644 --- a/src/prompt/build-prompt-types.ts +++ b/src/prompt/build-prompt-types.ts @@ -1,4 +1,5 @@ import type { ModelProfile } from "../llm/model-profile.js"; +import type { ToolCallTransport } from "../llm/provider/completion-types.js"; import type { ProfileFact } from "../memory/profile-store.js"; import type { SessionState } from "../session/session-state.js"; import type { @@ -14,6 +15,14 @@ export interface BuildPromptInput { capabilities: CapabilitiesSummary; skillCatalog: readonly SkillCatalogEntry[]; systemPersona?: string; + /** + * Transport the serving link uses for tool calls. Forwarded into + * `buildStablePrefix`, where `"native_tools"` swaps the text-JSON + * emission mandate for native function-calling guidance (issue #285). + * Omitted or `"grammar"` keeps the stable prefix byte-identical to + * the legacy output. + */ + toolTransport?: ToolCallTransport; /** * Pre-formatted current date (see `formatCurrentDate`) rendered as a * `CURRENT DATE:` line in the variable tail just before `### respond`. @@ -41,6 +50,22 @@ export interface BuildPromptInput { completionMaxTokens?: number; transientNotice?: string; profile?: ModelProfile; + /** + * Suppress the llama-server template artifacts around the generation + * point: the trailing reasoning-open prefill (`` for + * qwen-think) and the Gemma turn-framing tokens (system-turn opener, + * `<|think|>` system token, trailing turn close + model-turn opener). + * + * Set for prompts served over an OpenAI-compatible *chat* API + * (`toolTransport: "native_tools"`): there the prompt ships as a chat + * message, the provider applies its own template server-side, and a + * literal open tag is at best noise the model echoes back — and at + * worst corrupted server-side (Ollama Cloud mangles literal + * ``/`` strings; ollama/ollama#17248, issue #283). + * The prefill only makes sense on the raw text-completion (grammar) + * transport, where the local template expects the tag pre-typed. + */ + suppressReasoningPrefill?: boolean; profileFacts?: readonly ProfileFact[]; profileMaxTokens?: number; userMessage?: string | null; diff --git a/src/prompt/build-prompt.test.ts b/src/prompt/build-prompt.test.ts index 6d7ea8e8..066e2b07 100644 --- a/src/prompt/build-prompt.test.ts +++ b/src/prompt/build-prompt.test.ts @@ -438,6 +438,38 @@ describe("buildPrompt", () => { expect(prompt.tail).not.toContain("<|channel>thought"); }); + it("suppressReasoningPrefill drops the qwen think prefill (chat transports, issue #283)", () => { + const prompt = buildPrompt({ + session: mkSession(), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + profile: QWEN_THINK_PROFILE, + suppressReasoningPrefill: true, + }); + // The prompt must NOT ship a literal `` to a chat endpoint — + // Ollama Cloud corrupts the string server-side (ollama/ollama#17248). + expect(prompt.tail.endsWith("\n")).toBe(false); + expect(prompt.tail).not.toContain(""); + // The emit anchor stays the last directive before generation. + expect(prompt.tail.trimEnd().endsWith("Respond now.")).toBe(true); + }); + + it("suppressReasoningPrefill drops the gemma turn framing and system token (issue #283)", () => { + const prompt = buildPrompt({ + session: mkSession(), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + profile: GEMMA4_THINK_PROFILE, + suppressReasoningPrefill: true, + }); + expect(prompt.tail.endsWith("\n<|turn>model\n")).toBe(false); + expect(prompt.tail).not.toContain("<|turn>"); + expect(prompt.stablePrefix).not.toContain("<|turn>system"); + expect(prompt.stablePrefix).not.toContain("<|think|>"); + }); + it("does not append a think prelude for plain profiles", () => { const prompt = buildPrompt({ session: mkSession(), @@ -1269,3 +1301,82 @@ describe("token-budget helpers", () => { expect(truncateToTokens("abc", 0)).toBe(""); }); }); + +describe("buildPrompt tool transport (issue #285)", () => { + const base = () => ({ + session: mkSession(), + toolDescriptors: TOOLS, + capabilities: CAPS, + skillCatalog: SKILLS, + }); + + it("native_tools prefix drops the text-JSON emission mandate but keeps the ### tools catalog", () => { + const native = buildPrompt({ ...base(), toolTransport: "native_tools" }); + // The dual mandate: with an OpenAI `tools` payload on the request, + // the prompt must not also order text-JSON emission. + expect(native.stablePrefix).not.toContain("Emit a JSON ARRAY of tool calls now"); + expect(native.stablePrefix).not.toContain( + "Each step emits exactly one JSON array matching the tool grammar", + ); + // ...including the `### rules` opener — every text-array mandate + // must go, not just the persona and `### instructions` ones. + expect(native.stablePrefix).not.toContain("One tool-call array per step"); + expect(native.stablePrefix).not.toContain( + "a solo action is a length-1 array", + ); + // ...and the persona's reply-discipline line ("emit that tool JSON"). + expect(native.stablePrefix).not.toContain("emit that tool JSON"); + expect(native.stablePrefix).toContain("call that tool, not `reply`"); + expect(native.stablePrefix).toContain("### rules"); + expect(native.stablePrefix).toContain("One batch of tool calls per step"); + expect(native.stablePrefix).toContain("native function-calling interface"); + // The catalog stays: a fallback chain can hand this session to a + // grammar-only link, and the catalog carries tier/tool.view docs. + expect(native.stablePrefix).toContain("### tools"); + expect(native.stablePrefix).toContain("# common (full)"); + expect(native.stablePrefix).toContain("browser.navigate"); + expect(native.stablePrefix).toContain("### instructions"); + }); + + it("grammar prefix is byte-identical whether the transport is omitted or explicit", () => { + const implicit = buildPrompt(base()); + const explicit = buildPrompt({ ...base(), toolTransport: "grammar" }); + expect(explicit.stablePrefix).toBe(implicit.stablePrefix); + // And it still carries the legacy text-JSON mandate untouched. + expect(explicit.stablePrefix).toContain("Emit a JSON ARRAY of tool calls now"); + expect(explicit.stablePrefix).toContain( + "Each step emits exactly one JSON array matching the tool grammar", + ); + expect(explicit.stablePrefix).toContain( + "One tool-call array per step (including `skill.view`); a solo action is a length-1 array. Destructive or privileged tools may require user approval.", + ); + }); + + it("stable prefix stays byte-stable across turns for a fixed transport", () => { + const turn1 = buildPrompt({ ...base(), toolTransport: "native_tools" }); + const turn2 = buildPrompt({ + ...base(), + session: mkSession({ + turns: [ + { kind: "user", text: "Check inbox", at: 1 }, + { kind: "assistant_reply", text: "Done", at: 2 }, + { kind: "user", text: "Now archive it", at: 3 }, + ], + }), + toolTransport: "native_tools", + }); + expect(turn2.stablePrefix).toBe(turn1.stablePrefix); + }); + + it("an explicit systemPersona override wins on both transports", () => { + const persona = "You are a test persona."; + const native = buildPrompt({ + ...base(), + systemPersona: persona, + toolTransport: "native_tools", + }); + const grammar = buildPrompt({ ...base(), systemPersona: persona }); + expect(native.stablePrefix).toContain(persona); + expect(grammar.stablePrefix).toContain(persona); + }); +}); diff --git a/src/prompt/build-prompt.ts b/src/prompt/build-prompt.ts index ca93eb67..341a1be7 100644 --- a/src/prompt/build-prompt.ts +++ b/src/prompt/build-prompt.ts @@ -113,8 +113,12 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { worldSnapshot: worldSnapshotMaxTokens, }); + // Chat-transport prompts drop every llama-server template artifact: + // no turn framing, no reasoning system token, no trailing prefill + // (see `BuildPromptInput.suppressReasoningPrefill`). + const suppressPrefill = input.suppressReasoningPrefill === true; const turnFraming = - input.profile !== undefined + input.profile !== undefined && !suppressPrefill ? getReasoningTurnFraming(input.profile) : undefined; @@ -122,7 +126,9 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { toolDescriptors: input.toolDescriptors, capabilities: input.capabilities, skillCatalog: input.skillCatalog, - reasoningSystemToken: input.profile?.reasoningSystemToken, + reasoningSystemToken: suppressPrefill + ? undefined + : input.profile?.reasoningSystemToken, maxParallelToolCalls: config.agent.maxParallelToolCalls, ...(turnFraming !== undefined ? { turnSystemOpen: turnFraming.systemOpen } @@ -130,6 +136,9 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { ...(input.systemPersona !== undefined ? { systemPersona: input.systemPersona } : {}), + ...(input.toolTransport !== undefined + ? { toolTransport: input.toolTransport } + : {}), }); const sessionParts = buildSessionSectionParts( @@ -336,6 +345,7 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { ``, ); } else if ( + !suppressPrefill && input.profile?.requiresPromptThinkPrefix && input.profile.reasoningStyle !== "none" ) { diff --git a/src/prompt/index.ts b/src/prompt/index.ts index 32406677..418a4d99 100644 --- a/src/prompt/index.ts +++ b/src/prompt/index.ts @@ -3,6 +3,7 @@ export type { BuildPromptInput, BuiltPrompt } from "./build-prompt.js"; export { buildStablePrefix, DEFAULT_SYSTEM_PERSONA, + NATIVE_TOOLS_SYSTEM_PERSONA, formatToolForLoadedTail, } from "./stable-prefix.js"; export type { diff --git a/src/prompt/stable-prefix.ts b/src/prompt/stable-prefix.ts index 0240c472..3f3bd02f 100644 --- a/src/prompt/stable-prefix.ts +++ b/src/prompt/stable-prefix.ts @@ -1,3 +1,4 @@ +import type { ToolCallTransport } from "../llm/provider/completion-types.js"; import { formatSkillCatalogLine } from "../skills/skill-catalog.js"; /** @@ -75,18 +76,44 @@ export interface StablePrefixInput { */ turnSystemOpen?: string; maxParallelToolCalls?: number; + /** + * Transport the serving link uses for tool calls. Under + * `"native_tools"` the persona's emission mandate and the + * `### instructions` block switch to native function-calling guidance: + * the text-JSON "Emit a JSON ARRAY" mandate contradicts the + * request-level `tools` payload and drives cloud models back to text + * emission (issue #285). The `### tools` catalog is rendered in both + * modes — the provider fallback chain can hand a native-shaped request + * to a grammar-only llama-server link, and the catalog carries the + * tier / `tool.view` semantics. Omitted or `"grammar"` keeps the + * prefix byte-identical to the legacy output (KV-cache safe). + */ + toolTransport?: ToolCallTransport; } /** - * The stable prefix is the part of the prompt that must stay byte-stable - * within a session so llama.cpp can reuse its KV-cache. Order and spacing - * are intentional — changing any byte invalidates the slot. + * Persona lines shared verbatim between the grammar and native-tools + * variants. Only the emission mandate (line 1), the bias-toward-action + * phrasing (line 2), and the reply-discipline line (line 4, see + * `REPLY_DISCIPLINE_LINE_*`) differ per transport; everything else is + * transport-neutral. Extracted so the two personas cannot drift apart. */ -export const DEFAULT_SYSTEM_PERSONA = [ - "You are atomic-agent, a local operator. Each step emits exactly one JSON array matching the tool grammar — no other prose.", - "Bias toward action: keep planning minimal; unless the user explicitly asked for analysis or explanation only, choose the next tool-call array quickly instead of long deliberation. If the template forces a separate reasoning or thinking block before JSON, keep that block to a few words (or effectively empty), then emit the array.", - "Terminals: `reply` returns the final answer to the user and ends the current macro-turn (session stays open). `finish` ends the entire session; only with explicit user intent.", - "`reply` is ONLY for the final user-facing text after all needed tools ran. The user does not see intermediate text — if another tool is next, emit that tool JSON, not `reply`.", +const SYSTEM_PERSONA_TERMINALS_LINE = + "Terminals: `reply` returns the final answer to the user and ends the current macro-turn (session stays open). `finish` ends the entire session; only with explicit user intent."; + +/** Byte-identical to the pre-#285 line (KV-cache safe). */ +const REPLY_DISCIPLINE_LINE_GRAMMAR = + "`reply` is ONLY for the final user-facing text after all needed tools ran. The user does not see intermediate text — if another tool is next, emit that tool JSON, not `reply`."; + +/** + * Native variant of the reply-discipline line: "emit that tool JSON" is + * yet another text-JSON emission mandate, so under `native_tools` it + * becomes "call that tool" (issue #285). + */ +const REPLY_DISCIPLINE_LINE_NATIVE = + "`reply` is ONLY for the final user-facing text after all needed tools ran. The user does not see intermediate text — if another tool is next, call that tool, not `reply`."; + +const SYSTEM_PERSONA_SHARED_LINES = [ "Output discipline: when the request specifies an exact answer format, marker, length, or units, the `reply` text MUST be ONLY that — the bare value or the exact required line and nothing else (correct units, no preamble, no restating the question, no extra commentary or markdown before or after). If a specific final-answer line or marker is required, emit exactly that line as the entire reply. When no format is specified, answer as fully and helpfully as the task warrants.", "Finishing the job: when the user asks you to build, run, compute, or verify something, the deliverable is a real result backed by actual tool output — not a description of one. Do not stop after a stub, a plan, or a single command; keep calling tools until you have actually produced the requested result, then `reply` with what real execution returned. If a tool, install, or network call fails and blocks the real path, say so directly and try an alternative (a different approach, or `reply` to ask the user). NEVER substitute plausible-looking fabricated output (made-up data, invented file contents, synthesised API responses) for results you could not actually produce — reporting a blocker honestly is always better than inventing a result.", "When a line in `### skills` matches the user's request, emit `skill.view` first — the catalog line is a stub, the body has the actual procedure — unless that skill is already under `### loaded-skills`. This applies to every skill, including text-only workflows; do not guess the answer from the catalog summary. Rare tool? `tool.view` first. Loop: tools, read `### world` / `### conversation`, then more tools or `reply`. `browser.navigate` / `browser.search` refresh the world; avoid redundant `read_aria`. Do not invent facts — use `reply` to ask if stuck.", @@ -94,8 +121,54 @@ export const DEFAULT_SYSTEM_PERSONA = [ "Large directories: `os.fs.list` only shows up to maxEntries matches—use extensions, pattern, sort, or `os.fs.glob` to narrow before assuming a file type is absent. For many PDFs or resumes prefer filename `os.fs.glob` patterns plus `os.fs.read_document` on a short candidate list; avoid sweeping `os.fs.grep` with `glob` over huge `*.pdf` trees.", "Deleting files or directories: when the user asks to delete, remove, erase, or trash paths, call `os.fs.trash` with concrete absolute paths in `paths` (use `os.fs.list` / `os.fs.glob` first if you need to discover names). Do not use `os.shell.run` with `rm`, `unlink`, or `rmdir` for that unless the user explicitly demands permanent irreversible shell deletion.", "Memory: persist with `memory.profile.*` and `memory.notes.*` as needed. Use `### lessons` (pointer view of distilled rules from past episodes — call `memory.lessons.recall { id }` to read the full principle), `### procedures` (pointer view of advisory how-to templates — call `memory.procedures.recall { id }` to read the `steps[]`; templates are guidance, not law — follow them or consciously deviate), `### recalled` / `### memory-index` and `memory.notes.recall` for past context. Store distilled facts, not full dumps. `### notice` in the tail is a hard nudge to change strategy.", +]; + +/** + * The stable prefix is the part of the prompt that must stay byte-stable + * within a session so llama.cpp can reuse its KV-cache. Order and spacing + * are intentional — changing any byte invalidates the slot. + */ +export const DEFAULT_SYSTEM_PERSONA = [ + "You are atomic-agent, a local operator. Each step emits exactly one JSON array matching the tool grammar — no other prose.", + "Bias toward action: keep planning minimal; unless the user explicitly asked for analysis or explanation only, choose the next tool-call array quickly instead of long deliberation. If the template forces a separate reasoning or thinking block before JSON, keep that block to a few words (or effectively empty), then emit the array.", + SYSTEM_PERSONA_TERMINALS_LINE, + REPLY_DISCIPLINE_LINE_GRAMMAR, + ...SYSTEM_PERSONA_SHARED_LINES, ].join("\n"); +/** + * Persona for the `native_tools` transport. The request already carries + * an OpenAI `tools` array with `tool_choice: "auto"`, so the persona must + * mandate the function-calling interface — repeating the grammar + * persona's "exactly one JSON array" line alongside a native `tools` + * payload is a dual mandate that measurably drives models back to + * text-JSON emission (issue #285: 0/6 native `tool_calls` under + * `native_tools`). Lines past the first two are shared with + * `DEFAULT_SYSTEM_PERSONA`. + */ +export const NATIVE_TOOLS_SYSTEM_PERSONA = [ + "You are atomic-agent, a local operator. Each step calls tools through the native function-calling interface — never write tool-call JSON into the text of your answer, and never put your answer in the reasoning channel.", + "Bias toward action: keep planning minimal; unless the user explicitly asked for analysis or explanation only, choose the next tool call quickly instead of long deliberation. Keep any reasoning or thinking to a few words (or effectively empty), then make the call.", + SYSTEM_PERSONA_TERMINALS_LINE, + REPLY_DISCIPLINE_LINE_NATIVE, + ...SYSTEM_PERSONA_SHARED_LINES, +].join("\n"); + +/** + * `### rules` body shared verbatim between the transports. Only the + * emission-shape opener differs: grammar mandates the length-1 text + * array, native mandates the function-calling interface — leaving the + * grammar opener in place under `native_tools` re-creates the dual + * mandate the transport split exists to remove (issue #285). + */ +const RULES_SHARED_TAIL = + "Destructive or privileged tools may require user approval. If `### skills` lists a playbook that fits the user goal, call `skill.view` first unless that skill is already under `### loaded-skills`; do not act on a catalog stub — the body has the procedure. This holds for every skill (text replies included), not just browser/shell shortcuts. Summaries in `# extras` list rare tools; call `tool.view` to load the full `args` schema into `### loaded-tools` before use. Large trees: narrow with `os.fs.list` filters or `os.fs.glob` before reading content; do not use `os.fs.grep` with broad binary globs (e.g. every `*.pdf`) across huge folders—use tight globs then `os.fs.read_document` on candidates."; + +/** Byte-identical to the pre-#285 `### rules` line (KV-cache safe). */ +const GRAMMAR_RULES_LINE = `One tool-call array per step (including \`skill.view\`); a solo action is a length-1 array. ${RULES_SHARED_TAIL}`; + +const NATIVE_TOOLS_RULES_LINE = `One batch of tool calls per step (including \`skill.view\`), all through the native function-calling interface — never written out as JSON text; a solo action is a single call. ${RULES_SHARED_TAIL}`; + /** * Windows-only nudge appended after the persona. The default persona and * examples are POSIX-flavoured (`grep`/`cat`/`rm`), so on Windows the model @@ -110,7 +183,10 @@ export const WINDOWS_PLATFORM_HINT = [ ].join("\n"); export function buildStablePrefix(input: StablePrefixInput): string { - const persona = input.systemPersona ?? DEFAULT_SYSTEM_PERSONA; + const nativeTools = input.toolTransport === "native_tools"; + const persona = + input.systemPersona ?? + (nativeTools ? NATIVE_TOOLS_SYSTEM_PERSONA : DEFAULT_SYSTEM_PERSONA); const maxParallelToolCalls = input.maxParallelToolCalls ?? 8; const frequent: ToolDescriptor[] = []; const rare: ToolDescriptor[] = []; @@ -151,7 +227,7 @@ export function buildStablePrefix(input: StablePrefixInput): string { : []), ``, `### rules`, - `One tool-call array per step (including \`skill.view\`); a solo action is a length-1 array. Destructive or privileged tools may require user approval. If \`### skills\` lists a playbook that fits the user goal, call \`skill.view\` first unless that skill is already under \`### loaded-skills\`; do not act on a catalog stub — the body has the procedure. This holds for every skill (text replies included), not just browser/shell shortcuts. Summaries in \`# extras\` list rare tools; call \`tool.view\` to load the full \`args\` schema into \`### loaded-tools\` before use. Large trees: narrow with \`os.fs.list\` filters or \`os.fs.glob\` before reading content; do not use \`os.fs.grep\` with broad binary globs (e.g. every \`*.pdf\`) across huge folders—use tight globs then \`os.fs.read_document\` on candidates.`, + nativeTools ? NATIVE_TOOLS_RULES_LINE : GRAMMAR_RULES_LINE, ``, `### skills`, skills, @@ -167,12 +243,27 @@ export function buildStablePrefix(input: StablePrefixInput): string { caps, ``, `### instructions`, - `Emit a JSON ARRAY of tool calls now. Always start with \`[\` and end with \`]\`, even for a single call. Use \`reply\` for natural-language answers to the user.`, - `PARALLEL: when you need multiple INDEPENDENT actions (e.g. read 3 different files, run 2 globs, look up 4 git logs), put up to ${maxParallelToolCalls} calls in the SAME array — they run in parallel and cut wall time by ~Nx. Examples:`, - ` - one call: [{"tool":"os.fs.read","args":{"path":"a.ts"}}]`, - ` - parallel batch: [{"tool":"os.fs.read","args":{"path":"a.csv"}},{"tool":"os.fs.read","args":{"path":"b.csv"}},{"tool":"os.fs.read","args":{"path":"c.csv"}}]`, - ` - reply: [{"tool":"reply","args":{"text":"..."}}]`, - `Keep a call solo (length-1 array) when: it is \`reply\`/\`finish\`, may need approval (\`os.shell.run\`, \`os.fs.write\`, \`os.fs.edit\`, \`os.fs.trash\`, \`os.fs.patch\`, \`os.fs.archive.extract\`, \`os.proc.kill\`, \`os.http.request\`, \`skill.run_script\`), or its args depend on a previous call's result.`, + // The emission instructions are the one transport-dependent block. + // Grammar links parse text-JSON (GBNF-constrained locally), so they + // mandate the array literal; native links carry an OpenAI `tools` + // payload, and repeating the text-JSON mandate there is a dual + // mandate that drives models back to text emission (issue #285). + // The `### tools` catalog above stays in both modes — see + // `StablePrefixInput.toolTransport`. + ...(nativeTools + ? [ + `Call tools now, through the native function-calling interface (the \`tools\` your API request carries) — do NOT write tool-call JSON as text. The \`### tools\` catalog above is reference documentation for those same tools (tiers, examples, \`tool.view\`). For the final user-facing answer call \`reply\`, or answer in plain text.`, + `PARALLEL: when you need multiple INDEPENDENT actions (e.g. read 3 different files, run 2 globs, look up 4 git logs), emit up to ${maxParallelToolCalls} tool calls in the SAME response — they run in parallel and cut wall time by ~Nx.`, + `Emit a single tool call (no others alongside) when: it is \`reply\`/\`finish\`, may need approval (\`os.shell.run\`, \`os.fs.write\`, \`os.fs.edit\`, \`os.fs.trash\`, \`os.fs.patch\`, \`os.fs.archive.extract\`, \`os.proc.kill\`, \`os.http.request\`, \`skill.run_script\`), or its args depend on a previous call's result.`, + ] + : [ + `Emit a JSON ARRAY of tool calls now. Always start with \`[\` and end with \`]\`, even for a single call. Use \`reply\` for natural-language answers to the user.`, + `PARALLEL: when you need multiple INDEPENDENT actions (e.g. read 3 different files, run 2 globs, look up 4 git logs), put up to ${maxParallelToolCalls} calls in the SAME array — they run in parallel and cut wall time by ~Nx. Examples:`, + ` - one call: [{"tool":"os.fs.read","args":{"path":"a.ts"}}]`, + ` - parallel batch: [{"tool":"os.fs.read","args":{"path":"a.csv"}},{"tool":"os.fs.read","args":{"path":"b.csv"}},{"tool":"os.fs.read","args":{"path":"c.csv"}}]`, + ` - reply: [{"tool":"reply","args":{"text":"..."}}]`, + `Keep a call solo (length-1 array) when: it is \`reply\`/\`finish\`, may need approval (\`os.shell.run\`, \`os.fs.write\`, \`os.fs.edit\`, \`os.fs.trash\`, \`os.fs.patch\`, \`os.fs.archive.extract\`, \`os.proc.kill\`, \`os.http.request\`, \`skill.run_script\`), or its args depend on a previous call's result.`, + ]), ``, ].join("\n"); } diff --git a/src/replay/replay-session.test.ts b/src/replay/replay-session.test.ts index c12c61fd..524cfec6 100644 --- a/src/replay/replay-session.test.ts +++ b/src/replay/replay-session.test.ts @@ -138,6 +138,71 @@ describe("replaySession", () => { report.steps.every((s) => s.recordedHash === "deadbeef".repeat(8)), ).toBe(true); expect(report.steps[0]!.currentHash).not.toBe("deadbeef".repeat(8)); + expect(report.steps.every((s) => s.matchedTransport === null)).toBe(true); + } finally { + rmSync(fx.tmp, { recursive: true, force: true }); + } + }); + + it("reports no drift for a native_tools-recorded trace when no transport is pinned", async () => { + // Since issue #285 the stable prefix differs by construction between + // the transports, and traces do not record which one served the + // session. An unpinned replay must therefore accept a match against + // either variant — otherwise every native-transport session (the + // default on openai / subscription-cli providers) reports 100% false + // drift. + const context = buildCurrentContext(); + const nativeHash = hashPrefix( + buildStablePrefix({ + toolDescriptors: context.toolDescriptors, + capabilities: context.capabilities, + skillCatalog: context.skillCatalog, + reasoningSystemToken: context.profile.reasoningSystemToken, + toolTransport: "native_tools", + }), + ); + const fx = writeTrace({ recordedHash: nativeHash }); + try { + const report = await replaySession({ path: fx.path, context }); + expect(report.driftCount).toBe(0); + expect( + report.steps.every((s) => s.matchedTransport === "native_tools"), + ).toBe(true); + expect(report.steps.every((s) => s.currentHash === nativeHash)).toBe( + true, + ); + } finally { + rmSync(fx.tmp, { recursive: true, force: true }); + } + }); + + it("a pinned transport restricts matching to that variant", async () => { + const context = buildCurrentContext(); + const nativeHash = hashPrefix( + buildStablePrefix({ + toolDescriptors: context.toolDescriptors, + capabilities: context.capabilities, + skillCatalog: context.skillCatalog, + reasoningSystemToken: context.profile.reasoningSystemToken, + toolTransport: "native_tools", + }), + ); + const fx = writeTrace({ recordedHash: nativeHash }); + try { + const pinnedNative = await replaySession({ + path: fx.path, + context: { ...context, toolTransport: "native_tools" }, + }); + expect(pinnedNative.driftCount).toBe(0); + + const pinnedGrammar = await replaySession({ + path: fx.path, + context: { ...context, toolTransport: "grammar" }, + }); + expect(pinnedGrammar.driftCount).toBe(2); + expect( + pinnedGrammar.steps.every((s) => s.matchedTransport === null), + ).toBe(true); } finally { rmSync(fx.tmp, { recursive: true, force: true }); } diff --git a/src/replay/replay-session.ts b/src/replay/replay-session.ts index fe06a410..f7679384 100644 --- a/src/replay/replay-session.ts +++ b/src/replay/replay-session.ts @@ -5,6 +5,7 @@ import type { } from "../prompt/stable-prefix.js"; import { buildStablePrefix } from "../prompt/stable-prefix.js"; import type { ModelProfile } from "../llm/model-profile.js"; +import type { ToolCallTransport } from "../llm/provider/completion-types.js"; import { hashPrefix } from "../llm/slot-manager.js"; import type { TraceEvent } from "../tracing/index.js"; @@ -21,13 +22,32 @@ export interface ReplayContext { * version that used a custom persona. */ systemPersona?: string; + /** + * Transport the traced session used for tool calls. Since issue #285 + * the stable prefix differs by construction between `"grammar"` and + * `"native_tools"`, so drift detection must compare against the right + * variant. Traces do not record the transport, so when this is omitted + * the replay builds BOTH variants and a recorded hash counts as clean + * when it matches EITHER — otherwise every native-transport trace + * (the default on openai / subscription-cli providers) would report + * 100% false drift. Supply the transport to pin the comparison to one + * variant when it is known. + */ + toolTransport?: ToolCallTransport; } export interface ReplayStepReport { turnIndex: number; stepIndex: number; recordedHash: string; + /** + * Hash of the matched prefix variant; when nothing matched (drift), + * the hash of the pinned transport's variant, or of the `"grammar"` + * variant when no transport was pinned. + */ currentHash: string; + /** Transport whose current prefix matched `recordedHash`; `null` on drift. */ + matchedTransport: ToolCallTransport | null; drift: boolean; tokens: { recordedStablePrefix: number; @@ -59,16 +79,27 @@ export async function replaySession(options: { context: ReplayContext; }): Promise { const { path, context } = options; - const currentStablePrefix = buildStablePrefix({ - toolDescriptors: context.toolDescriptors, - capabilities: context.capabilities, - skillCatalog: context.skillCatalog, - reasoningSystemToken: context.profile.reasoningSystemToken, - ...(context.systemPersona !== undefined - ? { systemPersona: context.systemPersona } - : {}), - }); - const currentHash = hashPrefix(currentStablePrefix); + const prefixFor = (transport: ToolCallTransport): string => + buildStablePrefix({ + toolDescriptors: context.toolDescriptors, + capabilities: context.capabilities, + skillCatalog: context.skillCatalog, + reasoningSystemToken: context.profile.reasoningSystemToken, + toolTransport: transport, + ...(context.systemPersona !== undefined + ? { systemPersona: context.systemPersona } + : {}), + }); + // Candidate order matters only for the no-match `currentHash` fallback: + // the first entry (pinned transport, else `"grammar"`) supplies it. + const transports: readonly ToolCallTransport[] = + context.toolTransport !== undefined + ? [context.toolTransport] + : ["grammar", "native_tools"]; + const candidates = transports.map((transport) => ({ + transport, + hash: hashPrefix(prefixFor(transport)), + })); let sessionId = ""; let workingDir: string | null = null; @@ -82,12 +113,15 @@ export async function replaySession(options: { } if (event.type !== "prompt_captured") continue; const recorded = event as Extract; + const matched = + candidates.find((c) => c.hash === recorded.stablePrefixHash) ?? null; steps.push({ turnIndex: recorded.turnIndex, stepIndex: recorded.stepIndex, recordedHash: recorded.stablePrefixHash, - currentHash, - drift: recorded.stablePrefixHash !== currentHash, + currentHash: matched !== null ? matched.hash : candidates[0]!.hash, + matchedTransport: matched !== null ? matched.transport : null, + drift: matched === null, tokens: { recordedStablePrefix: recorded.tokens.stablePrefix, recordedTotal: recorded.tokens.total, diff --git a/src/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts index f873211d..aaa7121a 100644 --- a/src/runtime/bootstrap.test.ts +++ b/src/runtime/bootstrap.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; @@ -37,6 +44,7 @@ import type { TabsInput, TypeInput, } from "../tools/browser/browser-backend.js"; +import { buildSkillCatalog } from "../skills/index.js"; import type { LogRecord } from "../tracing/structured-logger.js"; import type { AgentLoopEvent } from "../agent/agent-loop.js"; import type { CompletionResult } from "../llm/llama-server-client.js"; @@ -100,6 +108,7 @@ describe("createAgentRuntime", () => { rmSync(workingDir, { recursive: true, force: true }); delete process.env.ATOMIC_AGENT_STATE_DIR; delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + delete process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET; resetConfigCache(); }); @@ -131,6 +140,70 @@ describe("createAgentRuntime", () => { } }); + it("wires skills.catalogTokenBudget into the runtime skill catalog, at boot and on refresh", async () => { + // Regression guard for the call-site wiring itself: the original bug + // was `buildSkillCatalog` being called WITHOUT options in bootstrap, + // which silently pinned the catalog to the legacy 4096-char cap. The + // unit tests on `buildSkillCatalog` cannot catch that, so this test + // sets the env knob and asserts the built runtime honors it. + const writeProjectSkill = (name: string): void => { + const dir = join(workingDir, ".atomic-agent", "skills", name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "SKILL.md"), + [ + "---", + `name: ${name}`, + `description: "${"d".repeat(120)}"`, + "version: 0.1.0", + "---", + "", + `# ${name}`, + ].join("\n"), + "utf8", + ); + }; + writeProjectSkill("budget-a"); + writeProjectSkill("budget-b"); + + // 4 tokens x 8 chars/token = 32 chars: far below one rendered entry, + // so an honored budget collapses the catalog to the single + // always-kept first entry, while the legacy 4096-char default would + // keep every skill written above. + process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET = "4"; + resetConfigCache(); + const runtime = await createAgentRuntime({ + workingDir, + approvalLevel: 5, + overrides: { browserBackend: backend, skipLlamaHealthCheck: true }, + }); + try { + expect(runtime.config.skills.catalogTokenBudget).toBe(4); + const records = runtime.skillRegistry.list(); + expect(records.length).toBeGreaterThan(1); + // Sanity: with the default cap this registry yields a bigger catalog, + // so the assertion below genuinely discriminates wired vs unwired. + expect(buildSkillCatalog(records).length).toBeGreaterThan(1); + expect([...runtime.skillCatalog]).toEqual( + buildSkillCatalog(records, { tokenBudget: 4 }), + ); + expect(runtime.skillCatalog).toHaveLength(1); + + // Second call site: the refresh path must rebuild with the same + // configured budget, not fall back to the legacy cap. + writeProjectSkill("budget-c"); + await runtime.refreshSkills(); + expect(runtime.skillRegistry.list().length).toBeGreaterThan( + records.length, + ); + expect(runtime.skillCatalog).toHaveLength(1); + } finally { + await runtime.shutdown(); + delete process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET; + resetConfigCache(); + } + }); + it("keeps the ApprovalGate the single live switch: a level-5 boot flips back to interactive", async () => { // Locked invariant: tools always register `approvalRequired: true`; // the boot level lands in the gate. A tool-level `false` would @@ -1014,6 +1087,7 @@ describe("createAgentRuntime steering", () => { rmSync(workingDir, { recursive: true, force: true }); delete process.env.ATOMIC_AGENT_STATE_DIR; delete process.env.ATOMIC_AGENT_GRAMMARS_DIR; + delete process.env.ATOMIC_AGENT_SKILLS_CATALOG_BUDGET; resetConfigCache(); }); diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 0d964888..075a1dc4 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -1250,6 +1250,7 @@ export async function createAgentRuntime( let skillCatalog: readonly SkillCatalogEntry[] = buildSkillCatalog( skillRegistry.list(), + { tokenBudget: config.skills.catalogTokenBudget }, ); let grammar = await buildGrammar(profile, config.paths.grammarsDir, { @@ -2190,7 +2191,9 @@ export async function createAgentRuntime( error: e.error, }); } - skillCatalog = buildSkillCatalog(skillRegistry.list()); + skillCatalog = buildSkillCatalog(skillRegistry.list(), { + tokenBudget: config.skills.catalogTokenBudget, + }); options.handlers?.onSkillRegistryChange?.([...skillCatalog]); }; diff --git a/src/runtime/llm-fallback-seam.test.ts b/src/runtime/llm-fallback-seam.test.ts index a2a8197a..51c0ee82 100644 --- a/src/runtime/llm-fallback-seam.test.ts +++ b/src/runtime/llm-fallback-seam.test.ts @@ -183,4 +183,119 @@ describe("createFallbackStreamer (real bootstrap seam)", () => { const result = await drain(streamer(baseParams)); expect(result.servedTransport).toBe("native_tools"); }); + + it("stamps the served transport on EVERY chunk (live consumers cannot wait for the final result)", async () => { + const providers = new Map([ + [ + "cloud", + fakeProvider("cloud", "native_tools", async () => { + throw new OpenAiHttpError("rate limited", 429, "http://cloud", false, null, "cloud"); + }), + ], + ["local", fakeProvider("local", "grammar", async () => answer("local"))], + ]); + const streamer = createFallbackStreamer(seamDeps(providers)); + const gen = streamer(baseParams); + const chunks: StreamChunk[] = []; + let next = await gen.next(); + while (!next.done) { + chunks.push(next.value); + next = await gen.next(); + } + expect(chunks.length).toBeGreaterThan(0); + // The load-bearing assertion: the step executor's stream parser keys + // `preOpenedThink` off the serving link's transport, which it must + // learn from the FIRST chunk — the return-value stamp arrives after + // the last delta, too late to classify reasoning live. + for (const chunk of chunks) { + expect(chunk.servedTransport).toBe("grammar"); + } + }); +}); + +describe("per-link prompt substitution (grammarPrompt)", () => { + async function drain( + gen: AsyncGenerator, + ): Promise { + let next = await gen.next(); + while (!next.done) next = await gen.next(); + return next.value; + } + + function promptCapturingProviders(): { + providers: Map; + cloudPrompts: string[]; + localPrompts: string[]; + failCloud: () => void; + } { + const cloudPrompts: string[] = []; + const localPrompts: string[] = []; + let cloudFails = false; + const providers = new Map([ + [ + "cloud", + fakeProvider("cloud", "native_tools", async (request) => { + cloudPrompts.push(request.prompt); + if (cloudFails) { + throw new OpenAiHttpError("rate limited", 429, "http://cloud", false, null, "cloud"); + } + return answer("cloud"); + }), + ], + [ + "local", + fakeProvider("local", "grammar", async (request) => { + localPrompts.push(request.prompt); + return answer("local"); + }), + ], + ]); + return { + providers, + cloudPrompts, + localPrompts, + failCloud: () => { + cloudFails = true; + }, + }; + } + + const paramsWithVariant = { + ...baseParams, + prompt: "suppressed prompt", + grammarPrompt: () => "prefill-carrying prompt", + }; + + it("unary: the native primary gets `prompt`, a grammar fallover link gets the `grammarPrompt` variant", async () => { + const { providers, cloudPrompts, localPrompts, failCloud } = + promptCapturingProviders(); + const complete = createFallbackCompleter(seamDeps(providers)); + + await complete(paramsWithVariant); + expect(cloudPrompts).toEqual(["suppressed prompt"]); + expect(localPrompts).toEqual([]); + + failCloud(); + const result = await complete(paramsWithVariant); + expect(result.modelId).toBe("local-model"); + expect(localPrompts).toEqual(["prefill-carrying prompt"]); + }); + + it("streaming: a grammar fallover link gets the `grammarPrompt` variant", async () => { + const { providers, localPrompts, failCloud } = promptCapturingProviders(); + failCloud(); + const streamer = createFallbackStreamer(seamDeps(providers)); + const result = await drain(streamer(paramsWithVariant)); + expect(result.servedTransport).toBe("grammar"); + expect(localPrompts).toEqual(["prefill-carrying prompt"]); + }); + + it("absent variant: a grammar link falls back to the shared prompt", async () => { + const { providers, localPrompts, failCloud } = promptCapturingProviders(); + failCloud(); + const complete = createFallbackCompleter(seamDeps(providers)); + const result = await complete({ ...baseParams, prompt: "shared prompt" }); + expect(result.modelId).toBe("local-model"); + expect(localPrompts).toEqual(["shared prompt"]); + }); }); diff --git a/src/runtime/llm-fallback-seam.ts b/src/runtime/llm-fallback-seam.ts index d77f7e87..2d47e002 100644 --- a/src/runtime/llm-fallback-seam.ts +++ b/src/runtime/llm-fallback-seam.ts @@ -43,6 +43,26 @@ export interface FallbackSeamDeps { ) => void; } +/** + * Prompt text for the link that is about to serve this attempt. The main + * `prompt` is built for the PRIMARY's transport; when the primary is + * native-tools and the profile needs a reasoning prefill, the prompt was + * built prefill-suppressed (issue #283) — but a grammar (llama-server) + * link still expects the legacy prefill-carrying shape (its template and + * GBNF prelude assume the open tag is pre-typed at the generation + * point). `grammarPrompt` is the lazy variant the step executor provides + * for exactly that fallover; absent (grammar primary, plain profile) the + * shared prompt is already the right shape for the link. + */ +function promptFor( + params: LlmStreamParams, + transport: ToolCallTransport, +): string { + return transport === "native_tools" + ? params.prompt + : params.grammarPrompt?.() ?? params.prompt; +} + /** * Build the unary `llmComplete` seam: route the request through the * cross-provider fallback chain (each attempt resolves the transport for @@ -61,7 +81,7 @@ export function createFallbackCompleter( async (providerId) => { const { provider, transport } = deps.resolveSlice(providerId); const base = { - prompt: params.prompt, + prompt: promptFor(params, transport), sessionId: params.sessionId, ...(typeof params.maxTokens === "number" ? { maxTokens: params.maxTokens } @@ -104,7 +124,11 @@ export function createFallbackCompleter( * inside the fallback attempt so a failure to OPEN the stream (429/5xx * before any output) advances the chain, while a live stream is never * restarted. The served link's transport is stamped on the return value, - * same contract as the unary seam. + * same contract as the unary seam — and on EVERY chunk, because the + * return value only exists once the stream finishes: the step executor's + * live stream parser must know the serving transport up front to + * classify grammar-served reasoning (which starts mid-``) as + * reasoning deltas during a cross-transport fallover. */ export function createFallbackStreamer( deps: FallbackSeamDeps, @@ -120,7 +144,7 @@ export function createFallbackStreamer( }> => { const { provider, transport } = deps.resolveSlice(providerId); const base = { - prompt: params.prompt, + prompt: promptFor(params, transport), sessionId: params.sessionId, ...(params.signal ? { signal: params.signal } : {}), }; @@ -152,13 +176,34 @@ export function createFallbackStreamer( (id) => openStreamPrimed(id, params), params.sessionId, ); - const result = yield* replayPrimedStream(primed); + const result = yield* stampServedTransport( + replayPrimedStream(primed), + transport, + ); return { ...result, servedTransport: transport }; } return meterStream(deps, params.sessionId, run()); }; } +/** + * Stamp the serving link's transport on every chunk (see the + * `StreamChunk.servedTransport` contract — the final result's stamp + * arrives too late for live consumers to reconfigure their parser on a + * cross-transport fallover). + */ +async function* stampServedTransport( + stream: AsyncGenerator, + transport: ToolCallTransport, +): AsyncGenerator { + let next = await stream.next(); + while (!next.done) { + yield { ...next.value, servedTransport: transport }; + next = await stream.next(); + } + return next.value; +} + /** * Pass chunks through untouched and fold the final result's usage. The * stream's *return* value carries `usage` (deltas do not), so totals only diff --git a/src/skills/index.ts b/src/skills/index.ts index c12b5ab1..fbac24c8 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -23,6 +23,8 @@ export type { SkillChangeListener } from "./skill-registry.js"; export { buildSkillCatalog, formatSkillCatalogLine, + SKILL_CATALOG_CHARS_PER_TOKEN, + DEFAULT_CATALOG_MAX_CHARS, } from "./skill-catalog.js"; export type { BuildCatalogOptions } from "./skill-catalog.js"; diff --git a/src/skills/skill-catalog.test.ts b/src/skills/skill-catalog.test.ts index e9229a79..3459ae9b 100644 --- a/src/skills/skill-catalog.test.ts +++ b/src/skills/skill-catalog.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; -import { buildSkillCatalog, formatSkillCatalogLine } from "./skill-catalog.js"; +import { + buildSkillCatalog, + formatSkillCatalogLine, + DEFAULT_CATALOG_MAX_CHARS, + SKILL_CATALOG_CHARS_PER_TOKEN, +} from "./skill-catalog.js"; +import { ENV_DEFAULTS } from "../config/config-schema.js"; import type { SkillRecord } from "./skill-loader.js"; function record( @@ -70,4 +76,55 @@ describe("buildSkillCatalog", () => { expect(tight).toHaveLength(1); expect(tight[0]?.name).toBe("first"); }); + + it("honors tokenBudget: a raised budget keeps entries the default cap drops", () => { + // Ten records of ~600 rendered chars each (~6000 chars total): + // overflowing the default 4096-char cap but fitting in 1024 tokens + // (8192 chars). + const records = Array.from({ length: 10 }, (_, i) => + record(`skill-${i}`, "d".repeat(580)), + ); + const byDefault = buildSkillCatalog(records); + expect(byDefault.length).toBeLessThan(records.length); + + const raised = buildSkillCatalog(records, { tokenBudget: 1024 }); + expect(raised.length).toBeGreaterThan(byDefault.length); + expect(raised.length).toBe(records.length); + }); + + it("shipped default budget maps to the historical 4096-char cap", () => { + // Import the real shipped default so a drive-by change to either the + // default or the chars/token factor trips this guard. + expect( + ENV_DEFAULTS.SKILLS_CATALOG_BUDGET * SKILL_CATALOG_CHARS_PER_TOKEN, + ).toBe(DEFAULT_CATALOG_MAX_CHARS); + + // A record set sized to straddle the 4096-char boundary must be cut + // at the same entry whether the caller passes nothing (legacy + // hardcoded cap) or the shipped config default. + const records = Array.from({ length: 12 }, (_, i) => + record(`skill-${i}`, "d".repeat(390)), + ); + const legacy = buildSkillCatalog(records); + const configured = buildSkillCatalog(records, { + tokenBudget: ENV_DEFAULTS.SKILLS_CATALOG_BUDGET, + }); + expect(configured).toEqual(legacy); + expect(legacy.length).toBeLessThan(records.length); + }); + + it("explicit maxChars wins over tokenBudget", () => { + const records = [record("first", "one"), record("second", "two")]; + const firstLine = formatSkillCatalogLine({ + name: "first", + description: "one", + source: "global", + }); + const catalog = buildSkillCatalog(records, { + maxChars: firstLine.length + 1, + tokenBudget: 1024, + }); + expect(catalog).toHaveLength(1); + expect(catalog[0]?.name).toBe("first"); + }); }); diff --git a/src/skills/skill-catalog.ts b/src/skills/skill-catalog.ts index 021c2eaa..a0979e65 100644 --- a/src/skills/skill-catalog.ts +++ b/src/skills/skill-catalog.ts @@ -10,9 +10,35 @@ export function formatSkillCatalogLine(entry: SkillCatalogEntry): string { return `- ${tag} ${entry.name}: ${entry.description}`; } +/** + * Chars of rendered catalog text budgeted per `skills.catalogTokenBudget` + * token. Deliberately NOT `estimateTokens`'s ~3.6 chars/token: the knob + * shipped with a default of 512 while the catalog was hard-capped at + * 4096 chars, so 8 chars/token is the one factor that makes the default + * config reproduce the historical cap byte-for-byte. Change it and every + * user who never touched the key gets a different `### skills` section + * (and a KV-cache invalidation) on upgrade. + */ +export const SKILL_CATALOG_CHARS_PER_TOKEN = 8; + +/** + * Historical hard cap, kept as the fallback when a caller passes neither + * `maxChars` nor `tokenBudget`. Equals the default `tokenBudget` of 512 + * times {@link SKILL_CATALOG_CHARS_PER_TOKEN}. + */ +export const DEFAULT_CATALOG_MAX_CHARS = 4096; + export interface BuildCatalogOptions { /** Soft cap for total `### skills` chars (join with `\n`). Defaults to 4096. */ maxChars?: number; + /** + * `skills.catalogTokenBudget` from config (env + * `ATOMIC_AGENT_SKILLS_CATALOG_BUDGET`). Converted to a char cap at + * {@link SKILL_CATALOG_CHARS_PER_TOKEN} chars/token; ignored when + * `maxChars` is given explicitly. The shipped default of 512 maps to + * the historical 4096-char cap. + */ + tokenBudget?: number; } /** @@ -25,7 +51,11 @@ export function buildSkillCatalog( records: ReadonlyArray, options: BuildCatalogOptions = {}, ): SkillCatalogEntry[] { - const maxChars = options.maxChars ?? 4096; + const maxChars = + options.maxChars ?? + (options.tokenBudget !== undefined + ? options.tokenBudget * SKILL_CATALOG_CHARS_PER_TOKEN + : DEFAULT_CATALOG_MAX_CHARS); const entries: SkillCatalogEntry[] = []; let used = 0; for (const record of records) { diff --git a/src/tui/app-key-bindings.ts b/src/tui/app-key-bindings.ts index 2745bc33..0fd7648a 100644 --- a/src/tui/app-key-bindings.ts +++ b/src/tui/app-key-bindings.ts @@ -220,6 +220,7 @@ export function isPanelModalOpen(state: TuiState): boolean { state.localModelsPanel.embeddingOnboardingPrompt !== null || state.providersPanel.chatModelPicker !== null || state.llmPanel.externalUrlDraft !== null || + state.llmPanel.externalCompatSteerUrl !== null || state.llmPanel.stopLocalDaemonsPrompt !== null || // Focused inline model filter is a text-entry surface: Tab/Ctrl+B // must not cycle the nav away mid-typing. diff --git a/src/tui/components/llm-mode-rows-cloud.test.tsx b/src/tui/components/llm-mode-rows-cloud.test.tsx index 9a88998e..56c9d0f1 100644 --- a/src/tui/components/llm-mode-rows-cloud.test.tsx +++ b/src/tui/components/llm-mode-rows-cloud.test.tsx @@ -1,6 +1,7 @@ import { render } from "ink-testing-library"; import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; +import { refreshOpenRouterChatCatalogFromApi } from "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js"; import { selectLlmPanelRows } from "../llm-panel/llm-panel-selectors.js"; import type { ProviderRow } from "../providers/providers-panel-state.js"; import { fakeSession } from "../test-fixtures.js"; @@ -161,6 +162,78 @@ describe("CloudRows inline model section", () => { }); }); +describe("CloudRows price facet", () => { + /** + * Seed the module-scoped live OpenRouter cache with two free and two + * paid rows. Only this describe reads the openrouter catalog; every + * other test in the file drives openai-compatible providers off their + * own `/v1/models` cache, so the leftover cache cannot reach them. + */ + async function seedOpenRouterCatalog(): Promise { + const data = [ + { id: "vendor/free-000", price: "0" }, + { id: "vendor/paid-001", price: "0.000001" }, + { id: "vendor/free-002", price: "0" }, + { id: "vendor/paid-003", price: "0.000001" }, + ].map((row, i) => ({ + id: row.id, + name: `Model ${i}`, + context_length: 128_000, + pricing: { prompt: row.price, completion: row.price }, + supported_parameters: ["tools"], + })); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ data }) })), + ); + await refreshOpenRouterChatCatalogFromApi(); + vi.unstubAllGlobals(); + } + + function openRouterProvider(): ProviderRow { + return compatProvider({ + id: "or", + kind: "openrouter", + baseUrl: null, + chatModel: "vendor/free-000", + chatModelOptions: [], + }); + } + + it("renders the price line with its key hint in the default state", async () => { + await seedCompatCache("https://render.nous.example", ["m-000"]); + const frame = renderRows(cloudState([compatProvider()]), 30); + expect(frame).toContain("price: all · p cycles free/paid/all"); + }); + + it("narrows the list to catalog-proven free rows and reports filtered of total", async () => { + await seedOpenRouterCatalog(); + const state = cloudState([openRouterProvider()], { + cloudModelPricing: "free", + }); + const frame = renderRows(state, 30); + expect(frame).toContain("price: free"); + expect(frame).toContain("or/vendor/free-000 [text]"); + expect(frame).toContain("or/vendor/free-002 [text]"); + expect(frame).not.toContain("paid-001"); + expect(frame).not.toContain("paid-003"); + expect(frame).toContain("(1/2 of 4)"); + }); + + it("keeps rows without a free price under the paid facet", async () => { + await seedOpenRouterCatalog(); + const state = cloudState([openRouterProvider()], { + cloudModelPricing: "paid", + }); + const frame = renderRows(state, 30); + expect(frame).toContain("price: paid"); + expect(frame).toContain("or/vendor/paid-001 [text]"); + expect(frame).toContain("or/vendor/paid-003 [text]"); + expect(frame).not.toContain("free-000 [text]"); + expect(frame).not.toContain("free-002 [text]"); + }); +}); + describe("CloudRows empty provider list", () => { // Styling is deliberately not asserted here: ink-testing-library renders at // chalk level 0, so every SGR sequence is dropped before `lastFrame()` sees diff --git a/src/tui/components/llm-mode-rows.tsx b/src/tui/components/llm-mode-rows.tsx index 6043651b..e24573af 100644 --- a/src/tui/components/llm-mode-rows.tsx +++ b/src/tui/components/llm-mode-rows.tsx @@ -176,12 +176,13 @@ function CloudRows({ const section = selectCloudModelSection(state); const filter = state.llmPanel.cloudModelFilter; const filterFocused = state.llmPanel.cloudModelFilterFocused; + const pricing = state.llmPanel.cloudModelPricing; const statusLine = section.status !== "ready"; // Everything around the model window costs lines: section headers (3), - // their bottom margins (3), provider:/filter: (2), the counter (1), - // provider/embedding rows, plus a loading/error line when shown. + // their bottom margins (3), provider:/filter:/price: (3), the counter + // (1), provider/embedding rows, plus a loading/error line when shown. const overhead = - 9 + + 10 + Math.max(1, providerRows.length) + Math.max(1, embeddingRows.length) + (statusLine ? 1 : 0); @@ -239,6 +240,19 @@ function CloudRows({ ) : null} + {/* The price facet stays visible even while narrowed to nothing: + "no match" under `price: free` explains itself, where a bare + empty list would read as a broken catalog. */} + + {"price: "} + + {pricing} + + {" · p cycles free/paid/all"} + {section.status === "loading" ? ( fetching model list… ) : null} diff --git a/src/tui/components/llm-panel-modals.tsx b/src/tui/components/llm-panel-modals.tsx index 5e621e4f..a379e9b9 100644 --- a/src/tui/components/llm-panel-modals.tsx +++ b/src/tui/components/llm-panel-modals.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type { ReactElement, ReactNode } from "react"; +import { looksLikeOllamaUrl } from "../../llm/describe-llama-health-failure.js"; import { PasteFieldTarget } from "../context-menu/paste-field-target.js"; import { pasteIntoLlmModalField } from "../llm-panel/llm-panel-paste.js"; import { theme } from "../theme/theme.js"; @@ -58,6 +59,7 @@ export function hasLlmModal(state: TuiState): boolean { state.localModelsPanel.embeddingRemoveConfirmId !== null || state.providersPanel.chatModelPicker !== null || state.llmPanel.externalUrlDraft !== null || + state.llmPanel.externalCompatSteerUrl !== null || state.llmPanel.stopLocalDaemonsPrompt !== null ); } @@ -225,6 +227,28 @@ export function LlmPanelModals({ ); } + if (state.llmPanel.externalCompatSteerUrl !== null) { + const url = state.llmPanel.externalCompatSteerUrl; + const ollama = looksLikeOllamaUrl(url); + return ( + + + {url} answers like {ollama ? "Ollama" : "an OpenAI-compatible server"}, + which the External llama.cpp route cannot drive. + + + y open the provider wizard with this URL · n/Esc dismiss + + + ); + } if (state.llmPanel.stopLocalDaemonsPrompt) { return ( diff --git a/src/tui/components/providers-wizard.tsx b/src/tui/components/providers-wizard.tsx index 1ddbb112..5bb4c590 100644 --- a/src/tui/components/providers-wizard.tsx +++ b/src/tui/components/providers-wizard.tsx @@ -294,15 +294,23 @@ function CatalogChatModelStep(props: { const service = kind === "openrouter" ? "Chat model (OpenRouter)" : "Chat model (AI/ML API)"; + // The active price facet rides on the title so it stays visible even + // while the search box owns the hint line: a list narrowed to free + // rows must say so wherever the operator happens to be looking. + const facet = w.pricingFilter === "all" ? "" : ` · ${w.pricingFilter} only`; // The refresh notice rides on the title rather than the hint line: it // describes the list, not a key, and the hint already runs to the edge // of a 100-column terminal once the search box has had its say. const title = loading - ? `${service} · updating model list from API…` - : service; + ? `${service}${facet} · updating model list from API…` + : `${service}${facet}`; + // `p` is advertised only while the search box is closed; open, the + // letter types into the query instead of cycling the facet. const hints = pickListHints( w.search, - "PgUp/PgDn jump · Enter select", + w.search === null + ? `PgUp/PgDn jump · Enter select · p price: ${w.pricingFilter}` + : "PgUp/PgDn jump · Enter select", "Esc back", "Esc clears search, again backs out", ); diff --git a/src/tui/components/slash-palette.tsx b/src/tui/components/slash-palette.tsx index ac26f61a..e80f26a6 100644 --- a/src/tui/components/slash-palette.tsx +++ b/src/tui/components/slash-palette.tsx @@ -5,7 +5,7 @@ import type { SlashCommandDef } from "../commands/slash-commands.js"; import { theme } from "../theme/theme.js"; import { MouseListRow } from "../mouse/mouse-list-row.js"; import { MOUSE_LAYER_MODAL } from "../mouse/mouse-registry.js"; -import { handleEditorSubmit } from "../submit-handler.js"; +import { runSlashCommand } from "../submit-handler.js"; interface SlashPaletteProps { query: string; @@ -65,10 +65,14 @@ export function SlashPalette(props: SlashPaletteProps): ReactElement | null { }) } onActivate={(mouse) => { - const state = mouse.getState(); - handleEditorSubmit( - state.inputValue, - state, + // The clicked row IS the choice: run its completion, the same + // way the palette-highlight branch in `handleEditorSubmit` + // runs the highlighted row on Enter. Submitting the raw + // buffer here would run the typed prefix instead, and a + // partial like "/mod" errors as an unknown command. + runSlashCommand( + `/${cmd.name}`, + mouse.getState(), mouse.dispatch, mouse.callbacks, ); diff --git a/src/tui/hooks/use-terminal-size.test.ts b/src/tui/hooks/use-terminal-size.test.ts new file mode 100644 index 00000000..8e0f195f --- /dev/null +++ b/src/tui/hooks/use-terminal-size.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { readTerminalSize } from "./use-terminal-size.js"; + +const tty = (columns: number, rows: number): NodeJS.WriteStream => + ({ columns, rows, isTTY: true }) as unknown as NodeJS.WriteStream; + +describe("readTerminalSize", () => { + it("reports the raw TTY size on a modern host", () => { + expect(readTerminalSize(tty(120, 40), false)).toEqual({ + columns: 120, + rows: 40, + }); + }); + + it("reserves the bottom row on a legacy conhost TTY", () => { + // The frame is pinned to `height={rows}`; on the frozen Win10 + // conhost a frame that touches the bottom terminal row scrolls the + // viewport — the duplicated-last-row / shaking report. One reserved + // row keeps every frame strictly above the scroll trigger. + expect(readTerminalSize(tty(120, 40), true)).toEqual({ + columns: 120, + rows: 39, + }); + }); + + it("leaves non-TTY streams alone even when detection says conhost", () => { + const piped = { + columns: 100, + rows: 30, + isTTY: false, + } as unknown as NodeJS.WriteStream; + expect(readTerminalSize(piped, true)).toEqual({ columns: 100, rows: 30 }); + }); + + it("falls back to 80x24 without a stream, guard or not", () => { + expect(readTerminalSize(undefined, false)).toEqual({ + columns: 80, + rows: 24, + }); + // No stream means no TTY, so the guard cannot apply either. + expect(readTerminalSize(undefined, true)).toEqual({ + columns: 80, + rows: 24, + }); + }); + + it("does not let the guard report a zero-height terminal", () => { + expect(readTerminalSize(tty(80, 1), true)).toEqual({ + columns: 80, + rows: 1, + }); + }); +}); diff --git a/src/tui/hooks/use-terminal-size.ts b/src/tui/hooks/use-terminal-size.ts index 57ef0932..e1574cab 100644 --- a/src/tui/hooks/use-terminal-size.ts +++ b/src/tui/hooks/use-terminal-size.ts @@ -1,6 +1,11 @@ import { useStdout } from "ink"; import { useEffect, useState } from "react"; +import { + clampRowsForLegacyConhost, + isLegacyConhost, +} from "../legacy-conhost.js"; + export interface TerminalSize { columns: number; rows: number; @@ -18,6 +23,10 @@ const DEFAULT_ROWS = 24; * * The hook only listens while mounted — the listener is detached on * unmount to avoid leaking handlers into long-running processes. + * + * On a legacy Win10 conhost the reported height is one row short of the + * real terminal: a full-height frame scrolls that console, which is the + * "shaking" / duplicated-last-row report. See `legacy-conhost.ts`. */ export function useTerminalSize(): TerminalSize { const { stdout } = useStdout(); @@ -35,8 +44,21 @@ export function useTerminalSize(): TerminalSize { return size; } -function readSize(stdout: NodeJS.WriteStream | undefined): TerminalSize { +/** + * Pure size read, exported for tests. The legacy-conhost row guard only + * applies to a real TTY: the fake stdouts used by tests, pipes and CI + * have no scrolling cursor, and their reported size is kept verbatim. + */ +export function readTerminalSize( + stdout: NodeJS.WriteStream | undefined, + legacyConhost: boolean, +): TerminalSize { const columns = stdout?.columns ?? DEFAULT_COLUMNS; const rows = stdout?.rows ?? DEFAULT_ROWS; - return { columns, rows }; + const guard = legacyConhost && stdout?.isTTY === true; + return { columns, rows: clampRowsForLegacyConhost(rows, guard) }; +} + +function readSize(stdout: NodeJS.WriteStream | undefined): TerminalSize { + return readTerminalSize(stdout, isLegacyConhost()); } diff --git a/src/tui/legacy-conhost.test.ts b/src/tui/legacy-conhost.test.ts new file mode 100644 index 00000000..87ba8a46 --- /dev/null +++ b/src/tui/legacy-conhost.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + clampRowsForLegacyConhost, + isLegacyConhost, + legacyConhostStartupHint, +} from "./legacy-conhost.js"; + +describe("isLegacyConhost", () => { + it("detects a bare conhost environment on Windows", () => { + expect(isLegacyConhost({ platform: "win32", env: {} })).toBe(true); + }); + + it("is false under Windows Terminal (WT_SESSION)", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { WT_SESSION: "a-guid" }, + }), + ).toBe(false); + }); + + it("is false under hosts that set TERM_PROGRAM (VS Code, mintty)", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { TERM_PROGRAM: "vscode" }, + }), + ).toBe(false); + }); + + it("is false everywhere that is not Windows", () => { + expect(isLegacyConhost({ platform: "darwin", env: {} })).toBe(false); + expect(isLegacyConhost({ platform: "linux", env: {} })).toBe(false); + }); + + it("treats empty marker variables as absent", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { WT_SESSION: "", TERM_PROGRAM: "" }, + }), + ).toBe(true); + }); + + it("ATOMIC_AGENT_CONHOST_GUARD=1 forces the guard on anywhere", () => { + expect( + isLegacyConhost({ + platform: "darwin", + env: { ATOMIC_AGENT_CONHOST_GUARD: "1" }, + }), + ).toBe(true); + }); + + it("ATOMIC_AGENT_CONHOST_GUARD=0 forces the guard off on a conhost", () => { + expect( + isLegacyConhost({ + platform: "win32", + env: { ATOMIC_AGENT_CONHOST_GUARD: "0" }, + }), + ).toBe(false); + }); +}); + +describe("clampRowsForLegacyConhost", () => { + it("reserves exactly one row on a legacy conhost", () => { + expect(clampRowsForLegacyConhost(24, true)).toBe(23); + expect(clampRowsForLegacyConhost(50, true)).toBe(49); + }); + + it("keeps the full height everywhere else", () => { + expect(clampRowsForLegacyConhost(24, false)).toBe(24); + }); + + it("never reports less than one row", () => { + expect(clampRowsForLegacyConhost(1, true)).toBe(1); + expect(clampRowsForLegacyConhost(0, true)).toBe(1); + }); +}); + +describe("legacyConhostStartupHint", () => { + it("recommends Windows Terminal on a legacy conhost", () => { + const hint = legacyConhostStartupHint({ platform: "win32", env: {} }); + expect(hint).toContain("Windows Terminal"); + expect(hint).toContain("ATOMIC_AGENT_CONHOST_GUARD=0"); + }); + + it("stays silent on a modern host", () => { + expect( + legacyConhostStartupHint({ + platform: "win32", + env: { WT_SESSION: "a-guid" }, + }), + ).toBeNull(); + expect(legacyConhostStartupHint({ platform: "darwin", env: {} })).toBeNull(); + }); +}); diff --git a/src/tui/legacy-conhost.ts b/src/tui/legacy-conhost.ts new file mode 100644 index 00000000..dde3774c --- /dev/null +++ b/src/tui/legacy-conhost.ts @@ -0,0 +1,90 @@ +/** + * Legacy Windows console (conhost) detection, and the one-row guard + * that keeps full-height frames from scrolling it. + * + * The TUI pins its root box to `height={rows}` (see `tui-app.tsx`), so + * every frame is exactly as tall as the terminal. That is safe on a + * VT terminal that defers the end-of-line wrap: painting the last cell + * of the last row leaves the cursor parked, and nothing scrolls. The + * frozen conhost that ships inside Windows 10 is the terminal where + * that guarantee has never held — a write that lands on the bottom + * row can push the viewport up one line, after which the repaint's + * cursor math is off by one: the whole UI "shakes", and the row that + * scrolled away leaves the last row painted twice. Both symptoms are + * the Win10 reports against v0.4.1/v0.4.2 (cmd and PowerShell — the + * shell does not matter, the conhost window hosting it does). + * + * The synchronized-update bracketing (`synchronized-output.ts`) cannot + * help here: conhost ignores DEC 2026, and the scroll is real movement + * of the buffer, not tearing. + * + * So: when the host is a *legacy* conhost, report one row fewer to the + * layout. No frame ever touches the bottom terminal row, so there is + * nothing left to scroll. The cost is one blank row, paid only on the + * one console that cannot be fixed (Win10's inbox conhost is frozen; + * Windows Terminal ships the maintained fork). + * + * Detection is deliberately narrow — Windows, and neither of the two + * variables every modern host sets: + * - `WT_SESSION` — Windows Terminal + * - `TERM_PROGRAM` — VS Code, mintty, and friends + * A plain cmd/PowerShell window on Win10 sets neither. + * + * `ATOMIC_AGENT_CONHOST_GUARD=0` turns the guard off where it + * misfires; `=1` forces it on anywhere, which is how the behaviour is + * verified from a terminal that is not a conhost. + */ + +export interface LegacyConhostOptions { + /** Env source for detection + override; injectable for tests. */ + readonly env?: NodeJS.ProcessEnv; + /** Platform under test; defaults to the live `process.platform`. */ + readonly platform?: NodeJS.Platform; +} + +/** + * True when stdout is (best guess) the frozen Win10 conhost rather + * than a modern VT host. See the module comment for the reasoning and + * the `ATOMIC_AGENT_CONHOST_GUARD` override. + */ +export function isLegacyConhost(options: LegacyConhostOptions = {}): boolean { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const override = env.ATOMIC_AGENT_CONHOST_GUARD; + if (override === "1") return true; + if (override === "0") return false; + if (platform !== "win32") return false; + if (env.WT_SESSION) return false; + if (env.TERM_PROGRAM) return false; + return true; +} + +/** + * The row budget the layout may actually use. One row is reserved on a + * legacy conhost so no frame reaches the terminal's bottom row; every + * other host keeps the full height. Never returns less than 1. + */ +export function clampRowsForLegacyConhost( + rows: number, + legacyConhost: boolean, +): number { + if (!legacyConhost) return rows; + return Math.max(1, rows - 1); +} + +/** + * The one-time startup line for the transcript, or `null` off a legacy + * conhost. Worded as a recommendation, not an error: the guard already + * has the rendering handled — this is where the operator learns that a + * better console exists. + */ +export function legacyConhostStartupHint( + options: LegacyConhostOptions = {}, +): string | null { + if (!isLegacyConhost(options)) return null; + return ( + "legacy Windows console detected — the bottom row is kept clear to " + + "avoid scroll glitches; Windows Terminal (`wt`) renders this UI " + + "properly (ATOMIC_AGENT_CONHOST_GUARD=0 disables the guard)" + ); +} diff --git a/src/tui/llm-panel/llm-panel-actions.ts b/src/tui/llm-panel/llm-panel-actions.ts index 2d0dda0f..28487269 100644 --- a/src/tui/llm-panel/llm-panel-actions.ts +++ b/src/tui/llm-panel/llm-panel-actions.ts @@ -10,10 +10,15 @@ export type LlmPanelAction = | { type: "llm_stop_local_daemons_prompt_closed" } /** Opens (string), edits (string) or closes (`null`) the external URL editor. */ | { type: "llm_external_url_draft_set"; value: string | null } + /** A refused External save probed `openai-compat` at `url`: open the steer prompt. */ + | { type: "llm_external_compat_steer_opened"; url: string } + | { type: "llm_external_compat_steer_closed" } /** Focus/unfocus the Cloud pane's inline `filter:` row. */ | { type: "llm_cloud_filter_focus_set"; focused: boolean } /** Replace the inline filter text (cursor snaps to the top of the result set). */ - | { type: "llm_cloud_filter_set"; value: string }; + | { type: "llm_cloud_filter_set"; value: string } + /** Cycle the inline list's price facet: all → free → paid → all. */ + | { type: "llm_cloud_pricing_cycled" }; export function isLlmPanelAction( action: { type: string }, @@ -27,7 +32,10 @@ export function isLlmPanelAction( action.type === "llm_stop_local_daemons_prompt_opened" || action.type === "llm_stop_local_daemons_prompt_closed" || action.type === "llm_external_url_draft_set" || + action.type === "llm_external_compat_steer_opened" || + action.type === "llm_external_compat_steer_closed" || action.type === "llm_cloud_filter_focus_set" || - action.type === "llm_cloud_filter_set" + action.type === "llm_cloud_filter_set" || + action.type === "llm_cloud_pricing_cycled" ); } diff --git a/src/tui/llm-panel/llm-panel-external.test.ts b/src/tui/llm-panel/llm-panel-external.test.ts index 101ac4ff..6d18cdfd 100644 --- a/src/tui/llm-panel/llm-panel-external.test.ts +++ b/src/tui/llm-panel/llm-panel-external.test.ts @@ -205,3 +205,113 @@ describe("external llama.cpp pane", () => { expect(next?.llmPanel.localCursor).toBe(state.llmPanel.localCursor); }); }); + +describe("openai-compat steer prompt", () => { + function steerState(url = "http://127.0.0.1:11434"): TuiState { + const state = externalState(); + state.llmPanel = { ...state.llmPanel, externalCompatSteerUrl: url }; + return state; + } + + it("opens on the openai-compat verdict action and closes again", () => { + const opened = reduceLlmPanelAction(externalState(), { + type: "llm_external_compat_steer_opened", + url: "http://127.0.0.1:11434", + }); + expect(opened?.llmPanel.externalCompatSteerUrl).toBe( + "http://127.0.0.1:11434", + ); + const closed = reduceLlmPanelAction(opened!, { + type: "llm_external_compat_steer_closed", + }); + expect(closed?.llmPanel.externalCompatSteerUrl).toBeNull(); + }); + + it("y opens the provider wizard on the Ollama preset with the probed URL", () => { + const dispatched = press("y", emptyKey(), steerState()); + expect(dispatched[0]).toEqual({ type: "llm_external_compat_steer_closed" }); + expect(dispatched[1]).toEqual({ type: "llm_mode_set", mode: "cloud" }); + expect(dispatched[2]).toMatchObject({ + type: "providers_wizard_opened", + wizard: { + mode: "add", + kind: "openai-compatible", + presetId: "ollama", + baseUrlLine: "http://127.0.0.1:11434", + phase: "chat_model_line", + }, + }); + }); + + it("prefills the manual compat route for a non-Ollama server", () => { + const dispatched = press( + "", + emptyKey({ return: true }), + steerState("http://127.0.0.1:5001"), + ); + expect(dispatched[2]).toMatchObject({ + type: "providers_wizard_opened", + wizard: { + kind: "openai-compatible", + presetId: null, + baseUrlLine: "http://127.0.0.1:5001", + phase: "base_url", + }, + }); + }); + + it("n and Esc dismiss without opening the wizard", () => { + for (const [input, key] of [ + ["n", emptyKey()], + ["", emptyKey({ escape: true })], + ] as const) { + const dispatched = press(input, key, steerState()); + expect(dispatched).toEqual([{ type: "llm_external_compat_steer_closed" }]); + } + }); + + it("swallows panel hotkeys while the prompt is open", () => { + // Baseline first: without the prompt, `]` cycles the pane. The + // empty dispatch below then proves the steer swallowed the key — + // not that the fixture never wired it. + expect(press("]", emptyKey(), externalState())).toEqual([ + { type: "llm_mode_set", mode: "fallback" }, + ]); + expect(press("]", emptyKey(), steerState())).toEqual([]); + }); + + it("refuses to open while the URL editor is open", () => { + // The steer arrives asynchronously (the refused save's probe); by + // then Enter on the External row may have reopened the editor. The + // reducer skips the steer rather than stacking two modals — the + // editor's next save re-probes and re-offers it. + const editing = externalState(); + editing.llmPanel = { + ...editing.llmPanel, + externalUrlDraft: "http://127.0.0.1:11434", + }; + const next = reduceLlmPanelAction(editing, { + type: "llm_external_compat_steer_opened", + url: "http://127.0.0.1:11434", + }); + expect(next?.llmPanel.externalCompatSteerUrl).toBeNull(); + }); + + it("keeps the keyboard on the URL editor if both are somehow open", () => { + // Built directly (the reducer refuses to create this state) to pin + // the handler's precedence to the render order: the editor is the + // modal on screen, so it must be the one receiving keys — `y` types + // into the URL instead of invisibly accepting the hidden steer. + const state = steerState(); + state.llmPanel = { + ...state.llmPanel, + externalUrlDraft: "http://127.0.0.1:808", + }; + expect(press("8", emptyKey(), state)).toEqual([ + { type: "llm_external_url_draft_set", value: "http://127.0.0.1:8088" }, + ]); + expect(press("y", emptyKey(), state)).toEqual([ + { type: "llm_external_url_draft_set", value: "http://127.0.0.1:808y" }, + ]); + }); +}); diff --git a/src/tui/llm-panel/llm-panel-key-bindings.test.ts b/src/tui/llm-panel/llm-panel-key-bindings.test.ts index 80e45a16..de8fe40d 100644 --- a/src/tui/llm-panel/llm-panel-key-bindings.test.ts +++ b/src/tui/llm-panel/llm-panel-key-bindings.test.ts @@ -161,6 +161,45 @@ describe("handleLlmPanelKey", () => { expect(typed).toEqual([{ type: "llm_cloud_filter_set", value: "c" }]); }); + it("p cycles the price facet on the Cloud pane only, and is text in a focused filter", () => { + const base = seededState(); + // On the Local pane the letter stays unclaimed. + const dispatchedLocal: TuiAction[] = []; + const handledLocal = handleLlmPanelKey("p", emptyKey(), { + state: base, + dispatch: (action) => dispatchedLocal.push(action), + callbacks: callbacks(), + }); + expect(handledLocal).toBe(false); + expect(dispatchedLocal).toEqual([]); + + const cloud = { + ...base, + llmPanel: { ...base.llmPanel, mode: "cloud" as const }, + }; + const dispatched: TuiAction[] = []; + const handled = handleLlmPanelKey("p", emptyKey(), { + state: cloud, + dispatch: (action) => dispatched.push(action), + callbacks: callbacks(), + }); + expect(handled).toBe(true); + expect(dispatched).toEqual([{ type: "llm_cloud_pricing_cycled" }]); + + // Focused filter: "p" is query text, never the facet key. + const focused = { + ...cloud, + llmPanel: { ...cloud.llmPanel, cloudModelFilterFocused: true }, + }; + const typed: TuiAction[] = []; + handleLlmPanelKey("p", emptyKey(), { + state: focused, + dispatch: (action) => typed.push(action), + callbacks: callbacks(), + }); + expect(typed).toEqual([{ type: "llm_cloud_filter_set", value: "p" }]); + }); + it("selects an exact cloud embedding model", () => { const base = seededState(); const state = { diff --git a/src/tui/llm-panel/llm-panel-key-bindings.ts b/src/tui/llm-panel/llm-panel-key-bindings.ts index f3e36f11..37d29323 100644 --- a/src/tui/llm-panel/llm-panel-key-bindings.ts +++ b/src/tui/llm-panel/llm-panel-key-bindings.ts @@ -67,6 +67,15 @@ export function handleLlmPanelKey( return true; } + // `p` cycles the price facet of the inline model list: all → free → + // paid → all. Cloud pane only — the other panes have no priced rows to + // narrow. While the `filter:` row is focused the letter is query text + // instead (the focused branch above already consumed it). + if (input === "p" && state.llmPanel.mode === "cloud") { + dispatch({ type: "llm_cloud_pricing_cycled" }); + return true; + } + // `/` bootstraps the global slash-command palette. The LLM tab keeps // the editor unfocused so single letters act as panel hotkeys, which // means typing `/` never reaches the editor's onChange. Seed the input diff --git a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts index 16e27617..14cc0de8 100644 --- a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts +++ b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts @@ -3,6 +3,7 @@ import type { TuiAction } from "../tui-action.js"; import type { TuiAppCallbacks } from "../tui-app.js"; import type { TuiState } from "../tui-state.js"; import { handleProvidersWizardKey } from "../providers/providers-wizard-key-bindings.js"; +import { wizardForOpenAiCompatUrl } from "../providers/openai-compat-steer.js"; import { normalizeLocalLlmBaseUrl } from "../persist-user-local-models-config.js"; import { filteredPickerModels } from "../providers/providers-panel-state.js"; import { stopLocalDaemonsForCloudSelection } from "./llm-panel-primary-actions.js"; @@ -200,6 +201,33 @@ export function handleLlmModalKey( return true; } + // Checked after the URL draft, matching `LlmPanelModals` render order: + // whichever modal is on screen must be the one holding the keyboard. + // (The reducer refuses to open the steer under an open draft, so both + // being non-null is unreachable today — this order keeps the handler + // correct even if a future path recreates that state.) + const steerUrl = state.llmPanel.externalCompatSteerUrl; + if (steerUrl !== null) { + // `y`/Enter accepts the steer: same two dispatches as the `n` + // hotkey (flip to the Cloud pane, open the add wizard), except the + // wizard opens on the OpenAI-compatible route with the refused URL + // already filled in — Ollama URLs land on the Ollama preset. + if (input.toLowerCase() === "y" || key.return) { + dispatch({ type: "llm_external_compat_steer_closed" }); + dispatch({ type: "llm_mode_set", mode: "cloud" }); + dispatch({ + type: "providers_wizard_opened", + wizard: wizardForOpenAiCompatUrl(steerUrl), + }); + return true; + } + if (input.toLowerCase() === "n" || key.escape) { + dispatch({ type: "llm_external_compat_steer_closed" }); + return true; + } + return true; + } + if (state.llmPanel.stopLocalDaemonsPrompt) { const lower = input.toLowerCase(); if (lower === "y") { diff --git a/src/tui/llm-panel/llm-panel-reducer.test.ts b/src/tui/llm-panel/llm-panel-reducer.test.ts index fbc38ed3..6dc77ba4 100644 --- a/src/tui/llm-panel/llm-panel-reducer.test.ts +++ b/src/tui/llm-panel/llm-panel-reducer.test.ts @@ -59,6 +59,28 @@ describe("llm-panel reducer", () => { expect(refreshed.llmPanel.mode).toBe("cloud"); expect(refreshed.llmPanel.syncModeToActiveRoute).toBe(false); }); + + it("cycles the cloud price facet and snaps the cursor to the top of the model section", () => { + const base = createInitialTuiState(fakeSession()); + const state = { + ...base, + llmPanel: { ...base.llmPanel, mode: "cloud" as const, cloudCursor: 5 }, + providersPanel: { + ...base.providersPanel, + rows: [providerRow("openrouter", "openrouter", true)], + }, + }; + + const free = reduceTuiState(state, { type: "llm_cloud_pricing_cycled" }); + expect(free.llmPanel.cloudModelPricing).toBe("free"); + // One provider row above the model section, so its top is index 1. + expect(free.llmPanel.cloudCursor).toBe(1); + + const paid = reduceTuiState(free, { type: "llm_cloud_pricing_cycled" }); + expect(paid.llmPanel.cloudModelPricing).toBe("paid"); + const all = reduceTuiState(paid, { type: "llm_cloud_pricing_cycled" }); + expect(all.llmPanel.cloudModelPricing).toBe("all"); + }); }); function providerRow(id: string, kind: string, isActiveText: boolean) { diff --git a/src/tui/llm-panel/llm-panel-reducer.ts b/src/tui/llm-panel/llm-panel-reducer.ts index f42d8c08..f2f22ca3 100644 --- a/src/tui/llm-panel/llm-panel-reducer.ts +++ b/src/tui/llm-panel/llm-panel-reducer.ts @@ -1,3 +1,4 @@ +import { nextModelPricingFilter } from "../../llm/provider/model-pricing-filter.js"; import type { TuiAction } from "../tui-action.js"; import type { TuiState } from "../tui-state.js"; import { isLlmPanelAction } from "./llm-panel-actions.js"; @@ -71,6 +72,23 @@ export function reduceLlmPanelAction( ...state, llmPanel: { ...panel, externalUrlDraft: action.value }, }; + case "llm_external_compat_steer_opened": + // The steer arrives asynchronously — the refused save's /health + // probe can take seconds — and the operator may have reopened the + // URL editor meanwhile. Opening underneath it would split the + // modals (the draft renders, a hidden steer would contest the + // keys), so the steer yields: the editor's next save re-probes + // and re-offers it. + if (panel.externalUrlDraft !== null) return state; + return { + ...state, + llmPanel: { ...panel, externalCompatSteerUrl: action.url }, + }; + case "llm_external_compat_steer_closed": + return { + ...state, + llmPanel: { ...panel, externalCompatSteerUrl: null }, + }; case "llm_cloud_filter_focus_set": { if (!action.focused) { return { @@ -117,6 +135,25 @@ export function reduceLlmPanelAction( }, }; } + case "llm_cloud_pricing_cycled": { + // A facet change replaces the list wholesale, so the cursor snaps + // to the top of the new result set exactly like a filter edit. + const next: TuiState = { + ...state, + llmPanel: { + ...panel, + cloudModelPricing: nextModelPricingFilter(panel.cloudModelPricing), + }, + }; + const section = selectCloudModelSection(next); + return { + ...next, + llmPanel: { + ...next.llmPanel, + cloudCursor: section.sectionStart, + }, + }; + } default: return state; } diff --git a/src/tui/llm-panel/llm-panel-row-builders.ts b/src/tui/llm-panel/llm-panel-row-builders.ts index 60634730..e29abfb8 100644 --- a/src/tui/llm-panel/llm-panel-row-builders.ts +++ b/src/tui/llm-panel/llm-panel-row-builders.ts @@ -5,6 +5,7 @@ import type { import { getCachedOpenAiCompatModelsForBaseUrl } from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { getCachedGeminiModelsForPanel } from "../../llm/provider/gemini/fetch-gemini-models.js"; import { GEMINI_DEFAULT_CHAT_MODEL } from "../../llm/provider/gemini/gemini-provider.js"; +import { filterIdsByPricing } from "../../llm/provider/model-pricing-filter.js"; import { filterModelIds, type ProviderRow } from "../providers/providers-panel-state.js"; import { catalogEntryLookupForKind, @@ -101,10 +102,14 @@ export function selectCloudModelSection(state: TuiState): CloudModelSection { }; } const catalog = inlineModelsForProvider(state, provider); + const lookup = catalogEntryLookupForKind(provider.kind); + // The price facet narrows first, the typed filter second — same result + // either way, but this keeps the facet from paying the ranked-search + // cost on the rows it is about to drop. const filtered = filterModelIds( - catalog.models, + filterIdsByPricing(catalog.models, state.llmPanel.cloudModelPricing, lookup), state.llmPanel.cloudModelFilter, - catalogEntryLookupForKind(provider.kind), + lookup, ); return { provider, ...catalog, filtered, sectionStart }; } diff --git a/src/tui/llm-panel/llm-panel-state.ts b/src/tui/llm-panel/llm-panel-state.ts index 6561d53a..bb683add 100644 --- a/src/tui/llm-panel/llm-panel-state.ts +++ b/src/tui/llm-panel/llm-panel-state.ts @@ -1,3 +1,5 @@ +import type { ModelPricingFilter } from "../../llm/provider/model-pricing-filter.js"; + /** * Panes of the LLM tab. `local` browses the managed llama.cpp catalog, * `cloud` the API providers, `external` the single base URL of a @@ -35,6 +37,13 @@ export interface LlmPanelState { * keyboard. */ externalUrlDraft: string | null; + /** + * URL of a refused External save whose probe answered `openai-compat` + * (Ollama, LM Studio, vLLM…). Non-null opens the steer prompt: `y` + * deep-links into the provider wizard prefilled with this URL instead + * of leaving the operator at a dead-end verdict line. + */ + externalCompatSteerUrl: string | null; /** * Typed filter of the Cloud pane's inline model list. Persists when * the filter row loses focus (Esc keeps the text, like the modal did). @@ -46,6 +55,13 @@ export interface LlmPanelState { * `f` or `/model`. */ cloudModelFilterFocused: boolean; + /** + * Price facet of the Cloud pane's inline model list, cycled with `p`. + * Own field rather than a term in `cloudModelFilter` so the two narrow + * independently, and separate from the providers wizard's facet so + * neither screen leaks its state into the other. + */ + cloudModelPricing: ModelPricingFilter; } export function createInitialLlmPanelState(): LlmPanelState { @@ -58,8 +74,10 @@ export function createInitialLlmPanelState(): LlmPanelState { syncModeToActiveRoute: false, stopLocalDaemonsPrompt: null, externalUrlDraft: null, + externalCompatSteerUrl: null, cloudModelFilter: "", cloudModelFilterFocused: false, + cloudModelPricing: "all", }; } diff --git a/src/tui/local-models/local-models-orchestrator-cpu-fallback.test.ts b/src/tui/local-models/local-models-orchestrator-cpu-fallback.test.ts new file mode 100644 index 00000000..a59fd8d6 --- /dev/null +++ b/src/tui/local-models/local-models-orchestrator-cpu-fallback.test.ts @@ -0,0 +1,244 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../local-llm/index.js", async () => { + const actual = + await vi.importActual( + "../../local-llm/index.js", + ); + return { + ...actual, + getDaemonStatus: vi.fn(), + getEmbeddingDaemonStatus: vi.fn(), + startChatAndEmbeddingDaemons: vi.fn(), + fallBackToCpuBackend: vi.fn(), + resolveManagedDevice: vi.fn(), + listVulkanDevices: vi.fn(), + probeNvidiaVramMiB: vi.fn(), + maybeAutoUpdateBackend: vi.fn(), + }; +}); + +import { getConfig, resetConfigCache } from "../../config/index.js"; +import * as localLlm from "../../local-llm/index.js"; +import { + resolveBackendDir, + resolveModelFilePath, + resolveServerBinPath, +} from "../../local-llm/index.js"; +import { writeBackendVersion } from "../../local-llm/backend-version.js"; +import { DaemonHealthError } from "../../local-llm/daemon-lifecycle.js"; +import { resolvePlatformAsset } from "../../local-llm/platform-assets.js"; +import { + WINDOWS_BACKEND_ASSETS, + setConfiguredBackendVariant, +} from "../../local-llm/windows-backend-variant.js"; +import { persistUserLocalModelsConfig } from "../persist-user-local-models-config.js"; +import { LocalModelsOrchestrator } from "./local-models-orchestrator.js"; + +type Emitted = { + type: string; + line?: string; + message?: string; + kind?: string; + percent?: number; +}; + +const healthError = () => + new DaemonHealthError( + "llama-server did not become healthy within 30000ms. Log tail:\n(no log)", + ); + +/** + * Integration tests for the Windows CPU-backend fallback WIRING in + * `startDaemon` — the pure pieces (`shouldFallBackToCpuBackend`, + * `fallBackToCpuBackend`) are covered in `cpu-backend-fallback.test.ts`, + * but only these tests prove the orchestrator actually consults them: + * that an eligible health failure swaps the backend and retries exactly + * once, that the retry cannot recurse, and that the download runs with + * a deadline and live progress (a stalled-open connection must not pin + * the start on "starting" for the life of the process). + */ +describe("LocalModelsOrchestrator CPU-backend fallback", () => { + let stateDir: string; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "local-models-cpufb-")); + process.env.ATOMIC_AGENT_STATE_DIR = stateDir; + // The fallback is Windows-only; the real eligibility gate must see + // win32 or these tests would silently assert nothing. + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + vi.spyOn(process, "arch", "get").mockReturnValue("x64"); + resetConfigCache(); + setConfiguredBackendVariant("auto"); + vi.mocked(localLlm.getDaemonStatus).mockReset(); + vi.mocked(localLlm.getEmbeddingDaemonStatus).mockReset(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockReset(); + vi.mocked(localLlm.fallBackToCpuBackend) + .mockReset() + .mockResolvedValue({ tag: "turboquant-x" }); + vi.mocked(localLlm.resolveManagedDevice).mockReset().mockResolvedValue(undefined); + vi.mocked(localLlm.listVulkanDevices).mockReset().mockResolvedValue([]); + vi.mocked(localLlm.probeNvidiaVramMiB).mockReset().mockResolvedValue(null); + vi.mocked(localLlm.maybeAutoUpdateBackend).mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setConfiguredBackendVariant("auto"); + delete process.env.ATOMIC_AGENT_STATE_DIR; + resetConfigCache(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("swaps in the CPU build, persists the variant and retries once", async () => { + const dataDir = prepareManagedWindowsInstall(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons) + .mockRejectedValueOnce(healthError()) + .mockResolvedValueOnce({ chat: { pid: 4242 }, embedding: { skipped: true } }); + + const { orchestrator, actions } = makeOrchestrator(); + + await expect(orchestrator.startDaemon({ backendAlreadyChecked: true })).resolves.toBe( + true, + ); + + expect(localLlm.fallBackToCpuBackend).toHaveBeenCalledTimes(1); + expect(localLlm.startChatAndEmbeddingDaemons).toHaveBeenCalledTimes(2); + // The loop-guard against auto-update reinstalling the broken GPU + // build: the preference must land in the user config, not just in + // process memory. + expect(getConfig().localModels.managed.backendVariant).toBe("cpu"); + const lines = actions.map((a) => a.line).filter(Boolean); + expect(lines.some((l) => l!.includes("falling back to the CPU build"))).toBe(true); + expect(lines.some((l) => l!.includes('recorded backendVariant "cpu"'))).toBe(true); + // Download surfaced as a regular backend pull so the panel shows it. + expect(actions.some((a) => a.type === "local_models_pull_started")).toBe(true); + expect(actions.some((a) => a.type === "local_models_pull_finished")).toBe(true); + + // The download must carry a deadline and live progress — without + // them a stalled-open connection pins the start forever with zero + // feedback (the exact hazard the auto-update path guards against). + const [calledDataDir, dlOpts] = vi.mocked(localLlm.fallBackToCpuBackend).mock + .calls[0]! as [string, { signal?: AbortSignal; onProgress?: (p: number, t: number, tot: number) => void }]; + expect(calledDataDir).toBe(dataDir); + expect(dlOpts.signal).toBeInstanceOf(AbortSignal); + expect(dlOpts.onProgress).toBeTypeOf("function"); + dlOpts.onProgress!(50, 15_000_000, 30_000_000); + expect( + actions.some((a) => a.type === "local_models_pull_progress" && a.percent === 50), + ).toBe(true); + }); + + it("falls back at most once — a CPU build that also fails to serve stops", async () => { + prepareManagedWindowsInstall(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockRejectedValue(healthError()); + + const { orchestrator, actions } = makeOrchestrator(); + + await expect(orchestrator.startDaemon({ backendAlreadyChecked: true })).resolves.toBe( + false, + ); + + expect(localLlm.fallBackToCpuBackend).toHaveBeenCalledTimes(1); + expect(localLlm.startChatAndEmbeddingDaemons).toHaveBeenCalledTimes(2); + expect(actions.some((a) => a.type === "local_models_daemon_error_set")).toBe(true); + }); + + it("reports the failure and gives up when the CPU download itself fails", async () => { + prepareManagedWindowsInstall(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockRejectedValue(healthError()); + vi.mocked(localLlm.fallBackToCpuBackend).mockRejectedValue(new Error("HTTP 503")); + + const { orchestrator, actions } = makeOrchestrator(); + + await expect(orchestrator.startDaemon({ backendAlreadyChecked: true })).resolves.toBe( + false, + ); + + expect(localLlm.startChatAndEmbeddingDaemons).toHaveBeenCalledTimes(1); + expect(actions.some((a) => a.type === "local_models_pull_failed")).toBe(true); + const err = actions.find((a) => a.type === "local_models_daemon_error_set"); + expect(err?.message).toContain("CPU backend fallback failed — HTTP 503"); + expect(err?.message).toContain("original start failure"); + }); + + it("leaves a non-health start failure alone", async () => { + prepareManagedWindowsInstall(); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockRejectedValue( + new Error("port 19091 already in use"), + ); + + const { orchestrator, actions } = makeOrchestrator(); + + await expect(orchestrator.startDaemon({ backendAlreadyChecked: true })).resolves.toBe( + false, + ); + + expect(localLlm.fallBackToCpuBackend).not.toHaveBeenCalled(); + expect(getConfig().localModels.managed.backendVariant).toBe("auto"); + const err = actions.find((a) => a.type === "local_models_daemon_error_set"); + expect(err?.message).toContain("port 19091 already in use"); + }); + + it("never falls back when the CPU build is already installed", async () => { + prepareManagedWindowsInstall(WINDOWS_BACKEND_ASSETS.cpu); + vi.mocked(localLlm.startChatAndEmbeddingDaemons).mockRejectedValue(healthError()); + + const { orchestrator } = makeOrchestrator(); + + await expect(orchestrator.startDaemon({ backendAlreadyChecked: true })).resolves.toBe( + false, + ); + + expect(localLlm.fallBackToCpuBackend).not.toHaveBeenCalled(); + }); + + function makeOrchestrator(): { + orchestrator: LocalModelsOrchestrator; + actions: Emitted[]; + } { + const actions: Emitted[] = []; + const orchestrator = new LocalModelsOrchestrator({ + emit(a: unknown) { + actions.push(a as Emitted); + }, + subscribe: () => () => {}, + }); + vi.spyOn(orchestrator, "refresh").mockResolvedValue(); + return { orchestrator, actions }; + } + + /** + * Managed mode with a (stub) Windows GPU install + chat model on + * disk, so the real `shouldFallBackToCpuBackend` sees an eligible + * machine: win32, variant `auto`, a GPU asset recorded. + */ + function prepareManagedWindowsInstall( + asset: string = WINDOWS_BACKEND_ASSETS.vulkan, + ): string { + const dataDir = getConfig().paths.localModelsDataDir; + const backendDir = resolveBackendDir(dataDir); + mkdirSync(backendDir, { recursive: true }); + const { binaryName } = resolvePlatformAsset(); + writeFileSync(resolveServerBinPath(dataDir, binaryName), ""); + writeBackendVersion(dataDir, { + tag: "turboquant-win", + downloadedAt: "2026-06-02T00:00:00.000Z", + asset, + releasedAt: "2026-06-01T00:00:00Z", + }); + const def = localLlm.getLocalModelDef("qwen-3.5-4b"); + mkdirSync(join(dataDir, "models", def.id), { recursive: true }); + writeFileSync(resolveModelFilePath(dataDir, def.id, def.filename), "stub"); + persistUserLocalModelsConfig({ + mode: "managed", + managed: { modelId: "qwen-3.5-4b" }, + }); + resetConfigCache(); + return dataDir; + } +}); diff --git a/src/tui/local-models/local-models-orchestrator.ts b/src/tui/local-models/local-models-orchestrator.ts index 8ca8c915..890fdaba 100644 --- a/src/tui/local-models/local-models-orchestrator.ts +++ b/src/tui/local-models/local-models-orchestrator.ts @@ -12,6 +12,8 @@ import { downloadMmproj, downloadModel, EMBEDDING_MODELS_CATALOG, + fallBackToCpuBackend, + getConfiguredBackendVariant, getDaemonStatus, getEmbeddingDaemonStatus, getEmbeddingModelDef, @@ -41,6 +43,7 @@ import { resolvePlatformAsset, resolveHuggingFaceGgufChoices, resolveServerBinPath, + shouldFallBackToCpuBackend, startChatAndEmbeddingDaemons, startEmbeddingDaemon, stopChatAndEmbeddingDaemons, @@ -74,14 +77,20 @@ import type { TuiEventBus } from "../tui-app.js"; * resolved. `configured` is the raw config value (`auto` / `cpu` / a * device id); `resolved` is what `resolveManagedDevice` returned * (`undefined` ⇒ no GPU picked → llama.cpp default / CPU fallback). + * `multiGpu` (a configured tensor split) relabels the unresolved-`auto` + * case: there it means "all GPUs, split", not "no GPU detected". */ function describeDeviceChoice( configured: string, resolved: string | undefined, + multiGpu = false, ): string { if (configured === "cpu") return "cpu (forced)"; if (configured === "auto") { - return resolved ? `auto → ${resolved}` : "auto → CPU (no GPU detected)"; + if (resolved) return `auto → ${resolved}`; + return multiGpu + ? "auto → all GPUs (tensor split)" + : "auto → CPU (no GPU detected)"; } return resolved ?? configured; } @@ -986,6 +995,11 @@ export class LocalModelsOrchestrator { */ async startDaemon(opts?: { backendAlreadyChecked?: boolean; + /** + * Set by the retry the Windows CPU-backend fallback issues, so a + * start that fails even on the CPU build cannot recurse forever. + */ + cpuFallbackAttempted?: boolean; }): Promise { const cfg = getConfig(); if (cfg.localModels.mode !== "managed") { @@ -1053,16 +1067,21 @@ export class LocalModelsOrchestrator { // chat side stays the source of truth for `daemonPhase`. const embedding = this.buildEmbeddingStartOptions(cfg, dataDir); // Resolve the GPU preference once so chat + embedding land on the - // same device and the operator sees which one was picked. + // same device and the operator sees which one was picked. A + // configured tensor split keeps `auto` from pinning one device — + // the chat daemon needs every GPU visible to spread layers. + const tensorSplit = cfg.localModels.managed.tensorSplit; + const multiGpu = tensorSplit.length > 0; const { binaryName } = resolvePlatformAsset(); const binPath = resolveServerBinPath(dataDir, binaryName); const device = await resolveManagedDevice( binPath, cfg.localModels.managed.device, + { multiGpu }, ); this.bus.emit({ type: "runtime_info", - line: `local-llm: device ${describeDeviceChoice(cfg.localModels.managed.device, device)}`, + line: `local-llm: device ${describeDeviceChoice(cfg.localModels.managed.device, device, multiGpu)}`, }); const gpuBudgetGb = await this.resolveGpuBudget(cfg, dataDir); if (classifyVramFit(def, gpuBudgetGb) === "insufficient") { @@ -1081,6 +1100,7 @@ export class LocalModelsOrchestrator { mmprojFile, contextSize: cfg.localModels.managed.contextSize, ...(device ? { device } : {}), + ...(multiGpu ? { tensorSplit } : {}), }, embedding: embedding ? { ...embedding, ...(device ? { device } : {}) } @@ -1112,6 +1132,16 @@ export class LocalModelsOrchestrator { return true; } catch (e) { const msg = e instanceof Error ? e.message : String(e); + if ( + !opts?.cpuFallbackAttempted && + shouldFallBackToCpuBackend({ + installedAsset: readBackendVersion(dataDir)?.asset, + configuredVariant: getConfiguredBackendVariant(), + error: e, + }) + ) { + return await this.fallBackToCpuBackendAndRetry(dataDir, msg); + } this.bus.emit({ type: "local_models_daemon_error_set", message: msg }); this.bus.emit({ type: "runtime_info", line: `local-llm: start failed — ${msg}` }); return false; @@ -1121,6 +1151,88 @@ export class LocalModelsOrchestrator { } } + /** + * Windows iGPU-only rescue: the installed GPU llama.cpp build spawned + * but never became healthy (the compute backend cannot load a model on + * this machine — e.g. Vulkan on an AMD APU), so swap in the CPU build + * the nightly also publishes and retry the start once. The choice is + * persisted as `localModels.managed.backendVariant = "cpu"` — without + * that, the next auto-update's variant-staleness check would reinstall + * the GPU build and re-break the machine. + */ + private async fallBackToCpuBackendAndRetry( + dataDir: string, + failureMsg: string, + ): Promise { + this.bus.emit({ + type: "runtime_info", + line: + "local-llm: the GPU llama.cpp build failed to serve on this machine — " + + "falling back to the CPU build…", + }); + try { + persistUserLocalModelsConfig({ managed: { backendVariant: "cpu" } }); + this.bus.emit({ + type: "runtime_info", + line: + 'local-llm: recorded backendVariant "cpu" in config.json — set it to ' + + '"auto" or "vulkan" to try the GPU build again (e.g. after a driver update)', + }); + } catch (persistErr) { + const msg = + persistErr instanceof Error ? persistErr.message : String(persistErr); + this.bus.emit({ + type: "runtime_info", + line: `local-llm: could not persist backendVariant — ${msg}; the CPU build is used for this session only`, + }); + } + this.bus.emit({ + type: "local_models_pull_started", + pull: { + kind: "backend", + modelId: "_backend", + label: "llama.cpp backend", + percent: 0, + transferredBytes: 0, + totalBytes: 0, + error: null, + }, + }); + try { + await fallBackToCpuBackend(dataDir, { + // Same hazard as the auto-update path above: without a deadline + // a stalled-open connection would pin the start on "starting" + // for the life of the process. + signal: AbortSignal.timeout(BACKEND_DOWNLOAD_TIMEOUT_MS), + onProgress: (percent: number, transferred: number, total: number) => { + this.bus.emit({ + type: "local_models_pull_progress", + kind: "backend", + percent, + transferredBytes: transferred, + totalBytes: total, + }); + }, + }); + this.bus.emit({ type: "local_models_pull_finished", kind: "backend" }); + } catch (dlErr) { + const msg = dlErr instanceof Error ? dlErr.message : String(dlErr); + const combined = `CPU backend fallback failed — ${msg} (original start failure: ${failureMsg})`; + this.bus.emit({ type: "local_models_pull_failed", kind: "backend", error: msg }); + this.bus.emit({ type: "local_models_daemon_error_set", message: combined }); + this.bus.emit({ type: "runtime_info", line: `local-llm: ${combined}` }); + return false; + } + this.bus.emit({ + type: "runtime_info", + line: "local-llm: CPU build installed — retrying start…", + }); + return await this.startDaemon({ + backendAlreadyChecked: true, + cpuFallbackAttempted: true, + }); + } + /** * Stop chat + embedding processes without clearing the operator's * embedding master switch. Used when swapping the active chat model. diff --git a/src/tui/mouse/mouse-app.test.tsx b/src/tui/mouse/mouse-app.test.tsx index 10b00fbc..b3c8112d 100644 --- a/src/tui/mouse/mouse-app.test.tsx +++ b/src/tui/mouse/mouse-app.test.tsx @@ -153,6 +153,8 @@ function mountApp(): { deleted: string[]; /** Clicks the Tasks header's `+ new` chip delivered to the host. */ taskNews: number[]; + /** Provider ids `/model` asked the orchestrator to ensure a catalog for. */ + modelEnsures: Array; unmount: () => void; } { const bus = makeTuiEventBus(); @@ -160,6 +162,7 @@ function mountApp(): { const deleted: string[] = []; const copied: string[] = []; const taskNews: number[] = []; + const modelEnsures: Array = []; const clipboard = { copy: async (text: string) => { copied.push(text); @@ -175,6 +178,8 @@ function mountApp(): { ...noopCallbacks(), onSessionDeleteConfirmed: (sessionId) => deleted.push(sessionId), onTaskNewRequested: () => taskNews.push(taskNews.length), + onProvidersInlineModelsEnsureRequested: (providerId) => + modelEnsures.push(providerId), }} mouse={mouse} /> @@ -186,6 +191,7 @@ function mountApp(): { stdin, deleted, taskNews, + modelEnsures, copied, seedSessions: () => { bus.emit({ @@ -575,6 +581,35 @@ describe("TuiApp mouse", () => { app.unmount(); }); + it("runs the clicked slash-palette completion, not the typed prefix", async () => { + // "/mod" lists /mode (highlighted) and /model. Clicking /model must + // run /model — not submit the raw buffer, which is no command at all + // ("unknown command: /mod"). + const app = mountApp(); + await waitUntil(() => app.frame().includes("R U N"), "the Run screen"); + app.stdin.write("/mod"); + await waitUntil( + () => app.frame().includes("open chat model picker"), + "the /model row in the palette", + ); + await delay(150); + // The /model row is not the highlighted one, so the first click only + // selects it; `clickUntil` keeps clicking until the second one + // activates and the command reaches the orchestrator callback. + await clickUntil( + app.mouse, + () => { + const at = locate(app.frame(), "open chat model picker"); + return { x: at.x + 2, y: at.y }; + }, + () => app.modelEnsures.length > 0, + "click the /model completion", + ); + expect(app.modelEnsures).toEqual([null]); + expect(app.frame()).not.toContain("unknown command"); + app.unmount(); + }); + it("selects composer text by dragging, and copies it with ctrl+c", async () => { // The terminal stops doing its own drag-to-select the moment mouse // reporting is on, so this gesture is the replacement for it. diff --git a/src/tui/mouse/mouse-stdin.test.ts b/src/tui/mouse/mouse-stdin.test.ts index ddf22ef4..509b4a00 100644 --- a/src/tui/mouse/mouse-stdin.test.ts +++ b/src/tui/mouse/mouse-stdin.test.ts @@ -1,8 +1,15 @@ import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; -import { createMouseStdin } from "./mouse-stdin.js"; +import { createMouseStdin, ESC_SPLIT_FLUSH_MS } from "./mouse-stdin.js"; import type { TuiMouseEvent } from "./mouse-event.js"; +/** Long enough for the ESC-split hold to have flushed. */ +function sleepPastEscFlush(): Promise { + return new Promise((resolve) => + setTimeout(resolve, ESC_SPLIT_FLUSH_MS + 20), + ); +} + const ESC = "\u001B"; interface FakeTty extends PassThrough { @@ -66,8 +73,189 @@ describe("createMouseStdin", () => { source as unknown as NodeJS.ReadStream, () => {}, ); - source.write(`hi${ESC}[A${ESC}`); - expect(await collect(stdin)).toBe(`hi${ESC}[A${ESC}`); + source.write(`hi${ESC}[A`); + expect(await collect(stdin)).toBe(`hi${ESC}[A`); + }); + + it("reunites a report whose ESC ended the previous read", async () => { + // ssh re-chunks the stream, so a flood of reports eventually splits + // one right after its ESC. Forwarding that ESC immediately used to + // type `[<0;5;2M` into the composer — the reported coordinate spam. + const source = makeSource(); + const events: TuiMouseEvent[] = []; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + (event) => events.push(event), + ); + source.write(`a${ESC}`); + source.write("[<0;5;2M"); + expect(await collect(stdin)).toBe("a"); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ kind: "press", x: 4, y: 1 }); + }); + + it("still delivers a lone Escape, after the split-hold flush", async () => { + const source = makeSource(); + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + ); + source.write(ESC); + expect(await collect(stdin)).toBe(""); + await sleepPastEscFlush(); + expect(await collect(stdin)).toBe(ESC); + }); + + it("trips the leak breaker on a burst of report-shaped text", async () => { + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + // Reports that lost their ESC somewhere along the way arrive as + // plain text; two in one read is a misreporting terminal, not a + // paste. + source.write("[<0;3;4M[<0;3;5M"); + expect(await collect(stdin)).toBe(""); + expect(leaks).toBe(1); + // Once tripped it stays tripped and keeps stripping the in-flight + // stragglers, without firing again. + source.write(`x[64;9;9My`); + expect(await collect(stdin)).toBe("xy"); + expect(leaks).toBe(1); + }); + + it("keeps stripping a straggler split across reads after the trip", async () => { + // The ssh re-chunking that garbles reports in the first place keeps + // doing it to the in-flight stragglers, so the post-trip stripper + // joins reads too — a remnant must not slip through in halves. + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("[<0;3;4M[<0;3;5M"); + expect(await collect(stdin)).toBe(""); + source.write("[<0;9"); + source.write(";9M"); + expect(await collect(stdin)).toBe(""); + expect(leaks).toBe(1); + }); + + it("strips a post-trip straggler arriving byte by byte", async () => { + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("[<0;3;4M[<0;3;5M"); + expect(await collect(stdin)).toBe(""); + for (const byte of "[64;9;9M") source.write(byte); + expect(await collect(stdin)).toBe(""); + expect(leaks).toBe(1); + }); + + it("withholds a partial straggler on the tripping chunk itself", async () => { + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("[<0;3;4M[<0;3;5M[<0;9"); + source.write(";9M"); + expect(await collect(stdin)).toBe(""); + expect(leaks).toBe(1); + }); + + it("releases withheld text that never becomes a remnant", async () => { + // Post-trip, a chunk-final remnant prefix is held back briefly; if + // nothing completes it, it was ordinary typing and must still land. + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("[<0;3;4M[<0;3;5M"); + expect(await collect(stdin)).toBe(""); + source.write("x[<12"); + expect(await collect(stdin)).toBe("x"); + await sleepPastEscFlush(); + expect(await collect(stdin)).toBe("[<12"); + expect(leaks).toBe(1); + }); + + it("trips the leak breaker on a slow drip of single remnants", async () => { + // A lossy link stalls mid-report for longer than the ESC-split hold + // and leaks one report per stall — never two in a chunk. By the + // third the terminal has proven itself. + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("[<0;1;1M"); + source.write("[<0;2;2M"); + source.write("[<0;3;3M"); + // The first two got through before anything was proven; the third + // trips the breaker and is stripped. + expect(await collect(stdin)).toBe("[<0;1;1M[<0;2;2M"); + expect(leaks).toBe(1); + }); + + it("counts remnants split across reads toward the trip", async () => { + // The same re-chunking that leaks a report can split the leaked + // remnant itself, so no single read ever contains a whole one. + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("[<0;1"); + source.write(";1M[<0;2"); + source.write(";2M[<0;3"); + source.write(";3M"); + await collect(stdin); + expect(leaks).toBe(1); + }); + + it("does not trip on a single report-shaped paste fragment", async () => { + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => true, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("see [<0;3;4M in the log"); + expect(await collect(stdin)).toBe("see [<0;3;4M in the log"); + expect(leaks).toBe(0); + }); + + it("leaves report-shaped text alone while the mouse is off", async () => { + const source = makeSource(); + let leaks = 0; + const { stdin } = createMouseStdin( + source as unknown as NodeJS.ReadStream, + () => {}, + { mouseActive: () => false, onMouseTextLeak: () => (leaks += 1) }, + ); + source.write("[<0;3;4M[<0;3;5M"); + expect(await collect(stdin)).toBe("[<0;3;4M[<0;3;5M"); + expect(leaks).toBe(0); }); it("proxies TTY-ness and raw mode to the real stdin", () => { diff --git a/src/tui/mouse/mouse-stdin.ts b/src/tui/mouse/mouse-stdin.ts index f331e04e..e5ffce8c 100644 --- a/src/tui/mouse/mouse-stdin.ts +++ b/src/tui/mouse/mouse-stdin.ts @@ -12,11 +12,66 @@ * mode plumbing — `isTTY`, `setRawMode`, `ref`/`unref` — so those are * delegated to the real stream. Ink also `unshift()`s bytes back during * its kitty-keyboard probe; a `PassThrough` supports that natively. + * + * Two ssh-hardening layers live here rather than in the pure decoder: + * + * - **The ESC-split hold.** An ssh hop re-chunks the byte stream, so + * during a wheel or drag flood some read eventually ends exactly on + * a report's ESC. The decoder forwards a chunk-final lone ESC as + * text (that is how the Escape key arrives), which used to type the + * rest of the report — `[<64;3;9M` — straight into the composer. So + * a chunk-final ESC is held for {@link ESC_SPLIT_FLUSH_MS} before + * being forwarded: if the rest of a report follows, they rejoin and + * decode; if nothing follows, it was the Escape key and flushes. + * Ink defers a lone Esc ~20ms itself, so the hold is imperceptible. + * - **The leak breaker.** If, despite the decoder, mouse-report-shaped + * text is about to reach Ink (an encoding or a mangling nobody + * anticipated), the shapes are stripped and `onMouseTextLeak` fires + * once so the caller can turn mouse tracking off for the session + * instead of letting the terminal spray coordinates into the chat. */ import { PassThrough } from "node:stream"; import { decodeMouseEvents } from "./parse-mouse-events.js"; import type { TuiMouseEvent } from "./mouse-event.js"; +const ESC = "\u001B"; + +/** + * How long a chunk-final lone ESC waits for the rest of a split mouse + * report. Consecutive ssh channel packets arrive well under a + * millisecond apart; the Escape key pays this once, on top of Ink's own + * ~20ms lone-Esc deferral. + */ +export const ESC_SPLIT_FLUSH_MS = 10; + +/** + * Mouse-report remnants as they look after losing their ESC: an SGR + * body (either case) or a urxvt body (uppercase `M` only — lowercase + * would match the SGR color codes in any pasted shell output). + */ +const REPORT_REMNANT = + /\[(?:<\d{1,4};\d{1,4};\d{1,4}[Mm]|\d{1,4};\d{1,4};\d{1,4}M)/g; + +/** + * A proper prefix of {@link REPORT_REMNANT} at the end of a chunk — the + * head of a remnant the next read may complete. + */ +const REPORT_REMNANT_PREFIX = /\[ boolean; + /** + * Fires once per session, when mouse-report-shaped text was about to + * reach Ink as keystrokes. From then on such shapes are stripped from + * the forwarded stream; the caller should disable mouse tracking and + * tell the operator. + */ + readonly onMouseTextLeak?: () => void; +} + /** * Wraps `source` so mouse reports are delivered to `onMouseEvent` and * every other byte flows through to the returned stream. @@ -31,6 +103,7 @@ export interface MouseStdin { export function createMouseStdin( source: NodeJS.ReadStream, onMouseEvent: (event: TuiMouseEvent) => void, + options: MouseStdinOptions = {}, ): MouseStdin { const passthrough = new PassThrough(); // Ink asks its stdin for raw mode and for TTY-ness; both questions @@ -59,16 +132,112 @@ export function createMouseStdin( return proxy; }; + // Once tripped, stays tripped: the terminal has proven it garbles + // mouse reports, and a few in-flight chunks keep arriving even after + // the caller writes the disable sequence. + let leakTripped = false; + let remnantsSeen = 0; + // A remnant can itself be split across reads (the same re-chunking + // that leaked it), so counting scans the tail of what was already + // forwarded joined to the new text — one character short of the + // longest remnant is enough to complete any spanning match. + let scanTail = ""; + const SCAN_TAIL_LENGTH = 16; + const countFreshRemnants = (text: string): number => { + const joined = scanTail + text; + let fresh = 0; + REPORT_REMNANT.lastIndex = 0; + for (const match of joined.matchAll(REPORT_REMNANT)) { + // Matches ending inside the tail were counted on an earlier read. + if (match.index + match[0].length > scanTail.length) fresh += 1; + } + scanTail = joined.slice(-SCAN_TAIL_LENGTH); + return fresh; + }; + // Stripping needs the same cross-read joining as counting — the ssh + // re-chunking that garbles reports keeps doing it to the in-flight + // stragglers — but unlike the counter it must keep the bytes out of + // Ink, so instead of scanning an already-forwarded tail it withholds + // a chunk-final remnant prefix until the rest arrives (stragglers + // trail each other by well under a millisecond) or a brief timer + // rules it ordinary typing, mirroring the ESC-split hold. + let stripHold = ""; + let stripTimer: NodeJS.Timeout | null = null; + const flushStripHold = (): void => { + stripTimer = null; + if (stripHold.length === 0) return; + const held = stripHold; + stripHold = ""; + passthrough.write(held); + }; + const stripRemnants = (text: string): string => { + if (stripTimer) { + clearTimeout(stripTimer); + stripTimer = null; + } + let out = (stripHold + text).replace(REPORT_REMNANT, ""); + stripHold = REPORT_REMNANT_PREFIX.exec(out)?.[0] ?? ""; + if (stripHold.length > 0) { + out = out.slice(0, -stripHold.length); + stripTimer = setTimeout(flushStripHold, ESC_SPLIT_FLUSH_MS); + stripTimer.unref?.(); + } + return out; + }; + const forwardText = (text: string): void => { + if (text.length === 0) return; + let out = text; + if (leakTripped) { + out = stripRemnants(out); + } else if (options.onMouseTextLeak && (options.mouseActive?.() ?? true)) { + const fresh = countFreshRemnants(text); + if (fresh > 0) { + remnantsSeen += fresh; + if (fresh >= LEAK_TRIP_COUNT || remnantsSeen >= LEAK_TRIP_TOTAL) { + leakTripped = true; + out = stripRemnants(out); + options.onMouseTextLeak(); + } + } + } + if (out.length > 0) passthrough.write(out); + }; + // A report can straddle two reads; `pending` holds the head of a - // truncated sequence until the rest of it arrives. + // truncated sequence until the rest of it arrives. `escHeld` is the + // ESC-split hold described in the module doc — a chunk-final ESC kept + // back briefly in case it is the start of a split report. let pending = ""; + let escHeld = false; + let escTimer: NodeJS.Timeout | null = null; + const flushHeldEsc = (): void => { + escTimer = null; + if (!escHeld) return; + escHeld = false; + forwardText(ESC); + }; const onData = (chunk: Buffer | string): void => { - const decoded = decodeMouseEvents( - pending + (typeof chunk === "string" ? chunk : chunk.toString("utf8")), - ); + if (escTimer) { + clearTimeout(escTimer); + escTimer = null; + } + let raw = + pending + + (escHeld ? ESC : "") + + (typeof chunk === "string" ? chunk : chunk.toString("utf8")); + escHeld = false; + if (raw.endsWith(ESC)) { + raw = raw.slice(0, -1); + escHeld = true; + } + const decoded = decodeMouseEvents(raw); pending = decoded.rest; for (const event of decoded.events) onMouseEvent(event); - if (decoded.text.length > 0) passthrough.write(decoded.text); + forwardText(decoded.text); + if (escHeld) { + escTimer = setTimeout(flushHeldEsc, ESC_SPLIT_FLUSH_MS); + escTimer.unref?.(); + } }; source.on("data", onData); @@ -76,7 +245,17 @@ export function createMouseStdin( stdin: proxy, dispose: () => { source.off("data", onData); + if (escTimer) { + clearTimeout(escTimer); + escTimer = null; + } + if (stripTimer) { + clearTimeout(stripTimer); + stripTimer = null; + } + escHeld = false; pending = ""; + stripHold = ""; }, }; } diff --git a/src/tui/mouse/parse-mouse-events.test.ts b/src/tui/mouse/parse-mouse-events.test.ts index ec982a5f..3c78059e 100644 --- a/src/tui/mouse/parse-mouse-events.test.ts +++ b/src/tui/mouse/parse-mouse-events.test.ts @@ -104,4 +104,56 @@ describe("decodeMouseEvents", () => { expect(events).toEqual([]); expect(rest).toBe(partial); }); + + it("consumes 1005 UTF-8 coordinates past the X10 byte ceiling", () => { + // 1005 shares the `ESC [ M` prefix; stdin is UTF-8-decoded before + // the parser sees it, so column 300 arrives as one character with + // code point 300 + 32. + const utf8 = `${ESC}[M${String.fromCharCode(32, 300 + 32, 40 + 32)}`; + const { events, text } = decodeMouseEvents(utf8); + expect(text).toBe(""); + expect(events[0]).toMatchObject({ kind: "press", x: 299, y: 39 }); + }); + + it("decodes a urxvt/1015 press instead of leaking it as text", () => { + const { events, text, rest } = decodeMouseEvents(`${ESC}[32;62;21M`); + expect(text).toBe(""); + expect(rest).toBe(""); + expect(events[0]).toMatchObject({ + kind: "press", + button: "left", + x: 61, + y: 20, + }); + }); + + it("decodes urxvt releases and wheel reports", () => { + const { events } = decodeMouseEvents(`${ESC}[35;5;4M${ESC}[96;5;4M`); + expect(events.map((event) => event.kind)).toEqual(["release", "wheel"]); + expect(events[1]?.wheel).toBe("up"); + }); + + it("keeps the keyboard bytes around a urxvt report intact", () => { + const { events, text } = decodeMouseEvents(`a${ESC}[64;9;9Mb`); + expect(text).toBe("ab"); + expect(events[0]).toMatchObject({ kind: "motion", button: "left" }); + }); + + it("buffers a truncated urxvt report", () => { + const partial = `${ESC}[32;6`; + const first = decodeMouseEvents(partial); + expect(first.events).toEqual([]); + expect(first.text).toBe(""); + expect(first.rest).toBe(partial); + const second = decodeMouseEvents(first.rest + "2;21M"); + expect(second.events[0]).toMatchObject({ x: 61, y: 20 }); + }); + + it("leaves a three-param CSI below the 1015 button floor to Ink", () => { + // No terminal sends this as input, but if one did it is not a + // mouse report — 1015 button codes start at 32. + const { events, text } = decodeMouseEvents(`${ESC}[1;2;3M`); + expect(events).toEqual([]); + expect(text).toBe(`${ESC}[1;2;3M`); + }); }); diff --git a/src/tui/mouse/parse-mouse-events.ts b/src/tui/mouse/parse-mouse-events.ts index 4c608d76..da9e70a1 100644 --- a/src/tui/mouse/parse-mouse-events.ts +++ b/src/tui/mouse/parse-mouse-events.ts @@ -3,7 +3,7 @@ import type { MouseButton, TuiMouseEvent } from "./mouse-event.js"; /** * Incremental decoder for xterm mouse reports. * - * Two encodings are understood: + * Three encodings are understood: * * - **SGR / 1006** — `ESC [ < b ; col ; row (M|m)`. What we ask for * (`\u001B[?1006h`) and what every modern terminal answers with. @@ -13,7 +13,18 @@ import type { MouseButton, TuiMouseEvent } from "./mouse-event.js"; * - **X10 / legacy** — `ESC [ M b col row` with each field a single * byte offset by 32. Terminals that ignore the 1006 request fall * back to this; decoding it costs ten lines and stops the raw bytes - * from being typed into the chat buffer as mojibake. + * from being typed into the chat buffer as mojibake. The 1005 + * (UTF-8 extended) variant shares the prefix and the +32 offset — + * stdin is decoded from UTF-8 before it reaches here, so its + * multi-byte coordinates arrive as single characters and land in + * the same branch. + * - **urxvt / 1015** — `ESC [ b ; col ; row M` with a decimal + * button-plus-32 and 1-based decimal coordinates. Nothing we + * request should elicit it, but a terminal (or ssh hop) confused + * enough to answer 1002 with it used to spray `32;45;12M` into the + * composer as text. Consuming it is cheap, and the shape — three + * decimal params, a `+32` button code, final uppercase `M` — + * collides with no keyboard sequence a terminal sends. * * The decoder is a pure function so the interesting part — a chunk * boundary splitting a report in half — is unit-testable without a @@ -38,8 +49,19 @@ const MOTION_BIT = 32; const WHEEL_BIT = 64; const SGR_MOUSE = /^\u001B\[<(\d{1,6});(\d{1,6});(\d{1,6})([Mm])/; +const URXVT_MOUSE = /^\u001B\[(\d{1,3});(\d{1,4});(\d{1,4})M/; const TRUNCATED_SGR = /^\u001B\[<\d{0,6}(;\d{0,6}){0,2}$/; const TRUNCATED_X10 = /^\u001B\[M[\s\S]{0,2}$/; +/** + * A CSI head that could still become a 1015 report. Deliberately also + * covers truncated keyboard CSIs (`ESC [ 1 ; 5` waiting for its `C`): + * buffering those until the final byte arrives hands Ink a whole + * sequence instead of a split it would mis-parse, and a real terminal + * always sends the rest of the sequence in the next read. + */ +const TRUNCATED_URXVT = /^\u001B\[\d{0,3}(;\d{0,4}){0,2}$/; +/** 1015 button codes are the X10 button byte as a decimal — 32 is the floor. */ +const URXVT_CODE_OFFSET = 32; export interface DecodedMouseChunk { /** Mouse reports found in this chunk, in arrival order. */ @@ -93,10 +115,20 @@ export function decodeMouseEvents(buffer: string): DecodedMouseChunk { index = esc + 6; continue; } + const urxvt = URXVT_MOUSE.exec(tail); + // The button-code floor keeps this branch honest: a three-param CSI + // ending in `M` whose first parameter could not be a 1015 button + // code is not a mouse report and belongs to Ink. + if (urxvt && Number.parseInt(urxvt[1] ?? "0", 10) >= URXVT_CODE_OFFSET) { + events.push(decodeUrxvt(urxvt)); + index = esc + urxvt[0].length; + continue; + } if ( tail === `${ESC}[` || TRUNCATED_SGR.test(tail) || - TRUNCATED_X10.test(tail) + TRUNCATED_X10.test(tail) || + TRUNCATED_URXVT.test(tail) ) { return { events, text, rest: tail }; } @@ -125,6 +157,15 @@ function decodeX10(tail: string): TuiMouseEvent { return buildEvent(code, column, row, (code & 3) === 3); } +function decodeUrxvt(match: RegExpExecArray): TuiMouseEvent { + // Same button byte as X10, transported as a decimal; coordinates are + // plain 1-based decimals with no offset. + const code = Number.parseInt(match[1] ?? "32", 10) - URXVT_CODE_OFFSET; + const column = Number.parseInt(match[2] ?? "1", 10); + const row = Number.parseInt(match[3] ?? "1", 10); + return buildEvent(code, column, row, (code & 3) === 3); +} + function buildEvent( code: number, column: number, diff --git a/src/tui/providers/openai-compat-steer.test.ts b/src/tui/providers/openai-compat-steer.test.ts new file mode 100644 index 00000000..2faaca78 --- /dev/null +++ b/src/tui/providers/openai-compat-steer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { wizardForOpenAiCompatUrl } from "./openai-compat-steer.js"; + +describe("wizardForOpenAiCompatUrl", () => { + it("lands an Ollama URL on the Ollama preset, skipping the key screen", () => { + // `ollama serve` answers 404 on /health and OpenAI-shape on + // /v1/models, so the External probe reports `openai-compat`; the + // steer must open the same state Enter on the "Ollama (local)" + // pick_kind row builds — no key screen, straight to the model list. + const wizard = wizardForOpenAiCompatUrl("http://127.0.0.1:11434"); + expect(wizard).toMatchObject({ + mode: "add", + kind: "openai-compatible", + presetId: "ollama", + baseUrlLine: "http://127.0.0.1:11434", + phase: "chat_model_line", + }); + }); + + it("keeps a remote Ollama host instead of the preset's localhost", () => { + const wizard = wizardForOpenAiCompatUrl("http://192.168.1.50:11434"); + expect(wizard.presetId).toBe("ollama"); + expect(wizard.baseUrlLine).toBe("http://192.168.1.50:11434"); + }); + + it("opens the manual compat row prefilled for a non-Ollama server", () => { + // LM Studio / vLLM / KoboldCpp: same verdict, no preset identity to + // assume, so the add flow starts on the URL screen with the probed + // URL already typed — Enter confirms it and walks URL → key → model. + const wizard = wizardForOpenAiCompatUrl("http://127.0.0.1:5001"); + expect(wizard).toMatchObject({ + mode: "add", + kind: "openai-compatible", + presetId: null, + baseUrlLine: "http://127.0.0.1:5001", + phase: "base_url", + }); + }); +}); diff --git a/src/tui/providers/openai-compat-steer.ts b/src/tui/providers/openai-compat-steer.ts new file mode 100644 index 00000000..c21f252b --- /dev/null +++ b/src/tui/providers/openai-compat-steer.ts @@ -0,0 +1,41 @@ +import { looksLikeOllamaUrl } from "../../llm/describe-llama-health-failure.js"; +import { presetNeedsKeyScreen } from "./providers-wizard-phases.js"; +import { + createProvidersWizardState, + type ProvidersWizardState, +} from "./providers-wizard-state.js"; + +/** + * The provider wizard, opened where a refused External llama.cpp save + * points: at the OpenAI-compatible route for the URL that answered the + * probe. Users keep aiming the External pane at Ollama (:11434); since + * the `openai-compat` verdict became visible the refusal at least said + * why, but acting on it still meant retyping the URL into a wizard four + * screens away. This builds the exact state picking the row by hand + * would have built — Ollama's URL lands on its preset (entry id, env + * var, no key screen: `ollama serve` has no key), any other compat + * server on the manual row — with the probed URL prefilled. + */ +export function wizardForOpenAiCompatUrl(url: string): ProvidersWizardState { + if (looksLikeOllamaUrl(url)) { + // Mirrors Enter on the "Ollama (local)" pick_kind row, except the + // base URL is the one the operator actually probed — a remote + // Ollama on 192.168.x.x:11434 keeps its host. + return { + ...createProvidersWizardState("add"), + kind: "openai-compatible", + presetId: "ollama", + baseUrlLine: url, + phase: presetNeedsKeyScreen("ollama") ? "api_key" : "chat_model_line", + }; + } + // Manual compat row with the URL already filled in: Enter confirms it + // and walks the normal URL → key → model flow (a loopback URL makes + // the key optional, same as typing it by hand). + return { + ...createProvidersWizardState("add"), + kind: "openai-compatible", + baseUrlLine: url, + phase: "base_url", + }; +} diff --git a/src/tui/providers/provider-presets.ts b/src/tui/providers/provider-presets.ts index 4105e27c..a15eb1f1 100644 --- a/src/tui/providers/provider-presets.ts +++ b/src/tui/providers/provider-presets.ts @@ -197,6 +197,14 @@ export const PROVIDER_PRESETS: readonly ProviderPreset[] = [ baseUrl: "https://ollama.com", envVar: "OLLAMA_CLOUD_API_KEY", listsModelsWithoutKey: true, + // Known upstream quirk (issue #283): Ollama's serving layer corrupts + // literal thinking-tag strings (``/`` and variants) + // that appear inside message content — silently dropping or mangling + // them (ollama/ollama#17248, #17617). atomic-agent no longer injects + // a reasoning prefill into chat-transport prompts, so its own + // requests are clean, but user text or tool output that happens to + // contain those literal strings can still be altered server-side + // until Ollama fixes it. Direct vendor APIs are unaffected. note: "hosted Ollama, models listed without a key", }, { diff --git a/src/tui/providers/providers-wizard-key-bindings.test.ts b/src/tui/providers/providers-wizard-key-bindings.test.ts index 74a3f721..78d27578 100644 --- a/src/tui/providers/providers-wizard-key-bindings.test.ts +++ b/src/tui/providers/providers-wizard-key-bindings.test.ts @@ -868,3 +868,107 @@ function next( } return result.wizard; } + +describe("cloud pick list price facet (p)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * Two free rows and two paid ones, identical scores so the payload + * order survives ranking (same trick as the navigation suite above). + * Fresh module graph per test: the live catalog cache is module-scoped + * and one test's refresh must not leak into the rest of this file. + */ + async function freshMixedPickPhase() { + vi.resetModules(); + const catalog = await import( + "../../llm/provider/openrouter/fetch-openrouter-chat-catalog.js" + ); + const bindings = await import("./providers-wizard-key-bindings.js"); + const phases = await import("./providers-wizard-phases.js"); + const data = [ + { id: "vendor/free-000", price: "0" }, + { id: "vendor/paid-001", price: "0.000001" }, + { id: "vendor/free-002", price: "0" }, + { id: "vendor/paid-003", price: "0.000001" }, + ].map((row, i) => ({ + id: row.id, + name: `Model ${i}`, + context_length: 128_000, + pricing: { prompt: row.price, completion: row.price }, + supported_parameters: ["tools"], + })); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ data }) })), + ); + await catalog.refreshOpenRouterChatCatalogFromApi(); + const wizard: ProvidersWizardState = { + ...createProvidersWizardState("add", { kind: "openrouter" }), + phase: "pick_chat_model", + }; + const step = ( + w: ProvidersWizardState, + input: string, + key: Key, + ): ProvidersWizardState => { + const result = bindings.handleProvidersWizardKey(input, key, w); + if (!result.handled || !("wizard" in result)) { + throw new Error("wizard key was not handled"); + } + return result.wizard; + }; + const visibleIds = (w: ProvidersWizardState): readonly string[] => + phases.visibleRowsForPhase(w).map((row) => row.id); + return { bindings, wizard, step, visibleIds }; + } + + it("p narrows the list to the catalog-proven free rows", async () => { + const { wizard, step, visibleIds } = await freshMixedPickPhase(); + expect(visibleIds(wizard)).toHaveLength(4); + + const filtered = step(wizard, "p", emptyKey()); + expect(filtered.pricingFilter).toBe("free"); + expect(visibleIds(filtered)).toEqual(["vendor/free-000", "vendor/free-002"]); + }); + + it("cycles free → paid → all and back to the full list", async () => { + const { wizard, step, visibleIds } = await freshMixedPickPhase(); + + const paidView = step(step(wizard, "p", emptyKey()), "p", emptyKey()); + expect(paidView.pricingFilter).toBe("paid"); + expect(visibleIds(paidView)).toEqual(["vendor/paid-001", "vendor/paid-003"]); + + const allView = step(paidView, "p", emptyKey()); + expect(allView.pricingFilter).toBe("all"); + expect(visibleIds(allView)).toHaveLength(4); + }); + + it("snaps the cursor to the top when the facet flips", async () => { + const { wizard, step } = await freshMixedPickPhase(); + const deep = { ...wizard, cursor: 3 }; + expect(step(deep, "p", emptyKey()).cursor).toBe(0); + }); + + it("Enter selects from the narrowed list, and the facet does not leak into the embedding screen", async () => { + const { wizard, step } = await freshMixedPickPhase(); + const filtered = step(wizard, "p", emptyKey()); + const moved = step(filtered, "", emptyKey({ downArrow: true })); + + const submitted = step(moved, "", emptyKey({ return: true })); + // Row 1 of the free view is the second free model, not vendor/paid-001. + expect(submitted.selectedChatModelId).toBe("vendor/free-002"); + expect(submitted.phase).toBe("pick_embedding"); + expect(submitted.pricingFilter).toBe("all"); + }); + + it("with the search box open, p is query text, not the facet key", async () => { + const { wizard, step } = await freshMixedPickPhase(); + const searching = step(wizard, "/", emptyKey()); + + const typed = step(searching, "p", emptyKey()); + expect(typed.search).toBe("p"); + expect(typed.pricingFilter).toBe("all"); + }); +}); diff --git a/src/tui/providers/providers-wizard-key-bindings.ts b/src/tui/providers/providers-wizard-key-bindings.ts index e4e9c450..f29a91df 100644 --- a/src/tui/providers/providers-wizard-key-bindings.ts +++ b/src/tui/providers/providers-wizard-key-bindings.ts @@ -1,6 +1,7 @@ import type { Key } from "ink"; import { getCachedOpenAiCompatModels } from "../../llm/provider/openai/fetch-openai-compat-models.js"; import { getCachedGeminiModels } from "../../llm/provider/gemini/fetch-gemini-models.js"; +import { nextModelPricingFilter } from "../../llm/provider/model-pricing-filter.js"; import { findProviderPreset } from "./provider-presets.js"; import { handleWizardSearchKey, @@ -201,6 +202,22 @@ export function handleProvidersWizardKey( return { handled: false }; } + // `p` cycles the price facet of the curated chat-model list: all → + // free → paid → all. Only reachable while the search box is closed — + // open, `handleWizardSearchKey` above consumed every printable key as + // query text, `p` included. Handled before the empty-list bailout so + // a facet that matched nothing can still be cycled away from. + if (wizard.phase === "pick_chat_model" && input === "p" && !key.ctrl && !key.meta) { + return { + handled: true, + wizard: { + ...wizard, + pricingFilter: nextModelPricingFilter(wizard.pricingFilter), + cursor: 0, + }, + }; + } + // One list for the whole screen: the render highlights row `cursor` of // exactly this array, so movement and Enter have to read it too, or a // narrowed list would select something the operator never saw. diff --git a/src/tui/providers/providers-wizard-phases.ts b/src/tui/providers/providers-wizard-phases.ts index 47198b76..8f897dc3 100644 --- a/src/tui/providers/providers-wizard-phases.ts +++ b/src/tui/providers/providers-wizard-phases.ts @@ -1,3 +1,7 @@ +import { + matchesModelPricingFilter, + type ModelPricingFilter, +} from "../../llm/provider/model-pricing-filter.js"; import { findProviderPreset, PROVIDER_PRESETS } from "./provider-presets.js"; import { filterWizardRows, @@ -5,6 +9,7 @@ import { } from "./providers-wizard-filter.js"; import { kindRowId, labelForKindRow } from "./providers-wizard-kind-labels.js"; import { + catalogEntryLookupForKind, listAimlapiChatModels, listAimlapiEmbeddingModels, listOpenRouterChatModels, @@ -187,7 +192,10 @@ export function visibleRowsForPhase( const { phase, kind, search } = wizard; if (phase === "pick_kind") return visibleKindRows(search); if (phase === "pick_chat_model" && kind && isCuratedCatalogKind(kind)) { - return filterWizardRows(listChatModelsForKind(kind), search); + return filterWizardRows( + chatRowsForPricing(kind, wizard.pricingFilter), + search, + ); } if (phase === "pick_embedding" && kind && isCuratedCatalogKind(kind)) { return filterWizardRows(listEmbeddingModelsForKind(kind), search); @@ -195,12 +203,37 @@ export function visibleRowsForPhase( return []; } +/** + * The chat-model rows left after the price facet (`p`). Runs before the + * search box so both narrow one list, and reads only row ids against the + * catalog map — never the lazy labels, which keeps a facet flip linear + * on a 340-row catalog (see `providers-model-options`). + */ +function chatRowsForPricing( + kind: NonNullable, + filter: ModelPricingFilter, +): ReturnType { + const rows = listChatModelsForKind(kind); + if (filter === "all") return rows; + const lookup = catalogEntryLookupForKind(kind); + return rows.filter((row) => + matchesModelPricingFilter(filter, row.id, lookup?.(row.id)), + ); +} + /** * State every phase change resets. A query typed to find one row must * not still be narrowing the next screen's list, where the operator has - * no reason to expect it and nothing they typed is on show. + * no reason to expect it and nothing they typed is on show. The price + * facet resets for the same reason: it belongs to the screen it was + * cycled on, never to the next one. */ -const PHASE_ENTRY = { cursor: 0, search: null, error: null } as const; +const PHASE_ENTRY = { + cursor: 0, + search: null, + pricingFilter: "all", + error: null, +} as const; export function advanceWizardPhase( wizard: ProvidersWizardState, diff --git a/src/tui/providers/providers-wizard-state.ts b/src/tui/providers/providers-wizard-state.ts index 4b90f693..9c583a11 100644 --- a/src/tui/providers/providers-wizard-state.ts +++ b/src/tui/providers/providers-wizard-state.ts @@ -1,4 +1,5 @@ import type { SubscriptionCliName } from "../../config/llm-config.js"; +import type { ModelPricingFilter } from "../../llm/provider/model-pricing-filter.js"; import { presetForEntryId } from "./provider-presets.js"; export type ProvidersWizardKind = @@ -74,6 +75,13 @@ export interface ProvidersWizardState { * screen, and `advanceWizardPhase` clears it on the way out. */ search: string | null; + /** + * Price facet of the curated chat-model screen, cycled with `p` while + * the search box is closed. Reset by `advanceWizardPhase` alongside + * `search`, so a facet picked to find one row cannot silently narrow + * the next screen's list. + */ + pricingFilter: ModelPricingFilter; apiKeyBuffer: string; baseUrlLine: string; chatModelLine: string; @@ -133,6 +141,7 @@ export function createProvidersWizardState( presetId, cursor: 0, search: null, + pricingFilter: "all", apiKeyBuffer: "", baseUrlLine: opts?.baseUrl ?? "", chatModelLine: cliBacked ? (opts?.chatModel ?? "") : "", diff --git a/src/tui/signal-escalation.test.ts b/src/tui/signal-escalation.test.ts new file mode 100644 index 00000000..e5ff53f1 --- /dev/null +++ b/src/tui/signal-escalation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { makeEscalatingSignalHandler } from "./signal-escalation.js"; + +interface Recorded { + quits: number; + restores: number; + exits: number[]; +} + +function makeHandler(): { handler: () => void; seen: Recorded } { + const seen: Recorded = { quits: 0, restores: 0, exits: [] }; + const handler = makeEscalatingSignalHandler({ + quit: () => (seen.quits += 1), + restoreTerminal: () => (seen.restores += 1), + exit: (code) => seen.exits.push(code), + }); + return { handler, seen }; +} + +describe("makeEscalatingSignalHandler", () => { + it("asks for a graceful quit on the first signal only", () => { + const { handler, seen } = makeHandler(); + handler(); + expect(seen).toEqual({ quits: 1, restores: 0, exits: [] }); + }); + + it("restores the terminal before dying on a repeat signal", () => { + // A wedged shutdown killed again must not fall through to Node's + // default handler — that skips `exit` hooks and leaves the shell in + // mouse-reporting mode, printing coordinates on every click. + const { handler, seen } = makeHandler(); + handler(); + handler(); + expect(seen.quits).toBe(1); + expect(seen.restores).toBe(1); + expect(seen.exits).toEqual([130]); + }); + + it("keeps escalating for every further signal", () => { + const { handler, seen } = makeHandler(); + handler(); + handler(); + handler(); + expect(seen.quits).toBe(1); + expect(seen.restores).toBe(2); + expect(seen.exits).toEqual([130, 130]); + }); +}); diff --git a/src/tui/signal-escalation.ts b/src/tui/signal-escalation.ts new file mode 100644 index 00000000..408d31f1 --- /dev/null +++ b/src/tui/signal-escalation.ts @@ -0,0 +1,48 @@ +/** + * Two-stage signal handling for the TUI. + * + * The first SIGINT/SIGTERM/SIGHUP asks the orchestrator for a graceful + * quit — Ink unmounts, the runtime shuts down, the `finally` block in + * `tui-command.ts` hands the terminal back. With `process.once` that was + * also the *only* covered signal: a second one fell through to Node's + * default handler, which kills the process without firing `exit`, so a + * wedged shutdown Ctrl-C'd (or `kill`ed) twice left the terminal in + * mouse-reporting mode — every click at the shell prompt printing + * `[<0;64;21M` — and on the alternate screen. Over ssh, where a hang is + * exactly when people reach for a second signal, this was the reported + * failure mode. + * + * So the second signal escalates instead of asking again: restore every + * registered terminal mode (`terminal-restore.ts`) and exit hard. 130 is + * the shell convention for "killed by SIGINT"; precise per-signal codes + * matter less than leaving a terminal that echoes keystrokes. + */ + +export interface SignalEscalationOptions { + /** Graceful path: ask the app to unmount and shut down. */ + readonly quit: () => void; + /** Hard path: undo mouse reporting, alt screen, and friends now. */ + readonly restoreTerminal: () => void; + /** Ends the process; injected so tests can observe instead of dying. */ + readonly exit: (code: number) => void; +} + +/** + * Returns a handler to register for each fatal signal. First invocation + * (whichever signal carries it) quits gracefully; any invocation after + * that restores the terminal and exits 130. + */ +export function makeEscalatingSignalHandler( + options: SignalEscalationOptions, +): () => void { + let quitRequested = false; + return () => { + if (!quitRequested) { + quitRequested = true; + options.quit(); + return; + } + options.restoreTerminal(); + options.exit(130); + }; +} diff --git a/src/tui/tui-command.mouse.test.ts b/src/tui/tui-command.mouse.test.ts index 0f06f569..0c5561fe 100644 --- a/src/tui/tui-command.mouse.test.ts +++ b/src/tui/tui-command.mouse.test.ts @@ -33,6 +33,7 @@ import type { TuiAction } from "./tui-action.js"; const inkRender = vi.hoisted(() => vi.fn()); const trackingCalls = vi.hoisted(() => ({ enabled: 0, disabled: 0, resumed: 0 })); +const orchestratorCalls = vi.hoisted(() => ({ quits: 0 })); // `sea` is one of the few builtins Node only publishes under the // `node:` prefix, and Vite's builtin check strips that prefix — so the @@ -84,7 +85,9 @@ vi.mock("./chat-orchestrator.js", () => ({ telegram = { forwardStatus: () => {} }; localModels = { autoStartIfReady: async () => {} }; start(): void {} - quit(): void {} + quit(): void { + orchestratorCalls.quits += 1; + } async checkForUpdate(): Promise {} async shutdown(): Promise {} }, @@ -130,6 +133,8 @@ interface Booted { */ readonly selectionDragIntent: () => void; readonly seen: TuiMouseEvent[]; + /** Every bus action emitted after mount — system messages included. */ + readonly actions: TuiAction[]; /** Chat-bound `system_message` texts, in emit order. */ readonly messages: string[]; readonly stop: () => Promise; @@ -168,8 +173,10 @@ async function bootTui(args: string[] = []): Promise { const seen: TuiMouseEvent[] = []; captured.mouse?.subscribe((event) => seen.push(event)); + const actions: TuiAction[] = []; const messages: string[] = []; captured.bus?.subscribe((action) => { + actions.push(action); if (action.type === "system_message") messages.push(action.text); }); const setMouseEnabled = captured.callbacks.onMouseSupportRequested; @@ -182,6 +189,7 @@ async function bootTui(args: string[] = []): Promise { setMouseEnabled, selectionDragIntent, seen, + actions, messages, stop: async () => { releaseExit(); @@ -297,6 +305,36 @@ describe("tuiCommand mouse wiring", () => { await app.stop(); }); + it("auto-disables mouse support when reports leak through as text", async () => { + writeMouseConfig(true); + const app = await bootTui(); + expect(trackingCalls.enabled).toBe(1); + + // Two ESC-less report bodies in one read: the shape of a terminal + // (or ssh hop) answering the tracking request with an encoding the + // decoder cannot consume. + stdin.emit("data", Buffer.from("[<0;3;4M[<0;3;5M")); + + expect(trackingCalls.disabled).toBe(1); + const notice = app.actions.find( + (action) => action.type === "system_message", + ); + expect(notice).toBeDefined(); + if (notice?.type === "system_message") { + expect(notice.variant).toBe("warn"); + expect(notice.text).toContain("/mouse"); + expect(notice.text).toContain("--no-mouse"); + } + // Session-only: the persisted preference is untouched. + expect(getConfig().tui.mouse).toBe(true); + + // Reporting is off, so a straggler event is dropped like after + // `/mouse off`. + stdin.emit("data", sgrPress(2, 2)); + expect(app.seen).toHaveLength(0); + await app.stop(); + }); + it("a shift-modified press opens the selection window instead of clicking", async () => { writeMouseConfig(true); const app = await bootTui(); @@ -314,6 +352,34 @@ describe("tuiCommand mouse wiring", () => { await app.stop(); }); + it("a second signal restores the terminal and exits instead of hanging", async () => { + writeMouseConfig(true); + const before = process.listeners("SIGINT"); + const app = await bootTui(); + const added = process + .listeners("SIGINT") + .filter((listener) => !before.includes(listener)); + expect(added).toHaveLength(1); + const onSignal = added[0] as () => void; + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + try { + orchestratorCalls.quits = 0; + onSignal(); + expect(orchestratorCalls.quits).toBe(1); + expect(exitSpy).not.toHaveBeenCalled(); + + onSignal(); + expect(orchestratorCalls.quits).toBe(1); + expect(exitSpy).toHaveBeenCalledWith(130); + } finally { + exitSpy.mockRestore(); + } + await app.stop(); + }); + it("delivers clicks again once the selection window expires", async () => { // `shouldAdvanceTime` keeps the boot's real 10ms poll loop alive // while the 10s selection window is jumped over synthetically. diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 939caf08..0302cffe 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -18,6 +18,7 @@ import { isKnownLocalModelId } from "../local-llm/index.js"; import { registerSession } from "../local-llm/session-registry.js"; import { enterAltScreen } from "./alt-screen.js"; import { enableSynchronizedOutput } from "./synchronized-output.js"; +import { legacyConhostStartupHint } from "./legacy-conhost.js"; import { ChatOrchestrator } from "./chat-orchestrator.js"; import { parseTuiArgs, nonInteractiveStdinError, @@ -40,6 +41,8 @@ import { } from "./mouse/mouse-tracking.js"; import { createSelectionPassthrough } from "./mouse/selection-passthrough.js"; import { isLocalBackendConfigured } from "./local-backend-readiness.js"; +import { makeEscalatingSignalHandler } from "./signal-escalation.js"; +import { restoreTerminalNow } from "./terminal-restore.js"; import { needsOnboarding } from "./onboarding/needs-onboarding.js"; import { createOnboardingState } from "./onboarding/onboarding-state.js"; import { @@ -239,14 +242,23 @@ export async function tuiCommand(args: string[]): Promise { }); orchestratorForChannelStatus = orchestrator; - const onSignal = (): void => orchestrator.quit(); - process.once("SIGINT", onSignal); - process.once("SIGTERM", onSignal); + // First signal quits gracefully; a repeat (a wedged shutdown being + // Ctrl-C'd again, a `kill` after a hang) restores the terminal — + // mouse reporting off, alt screen left — and exits hard, instead of + // Node's default kill that skips `exit` hooks and leaves the shell + // printing `[<0;64;21M` on every click. See `signal-escalation.ts`. + const onSignal = makeEscalatingSignalHandler({ + quit: () => orchestrator.quit(), + restoreTerminal: restoreTerminalNow, + exit: (code) => process.exit(code), + }); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); // SIGHUP fires when the terminal window is closed. Without a handler // the default action kills the process before `orchestrator.shutdown()` // runs, orphaning the managed llama-server with the model still in // RAM/VRAM — the exact complaint in #52. - process.once("SIGHUP", onSignal); + process.on("SIGHUP", onSignal); // Mark this process as a live TUI session so `stopOnExit` teardown can // tell "last session exits, stop the daemon" from "another window is @@ -305,17 +317,38 @@ export async function tuiCommand(args: string[]): Promise { // this keeps `/mouse off` honest for the cases where it still does: // a multiplexer that swallowed the disable, or a bracketed paste whose // payload happens to contain an SGR report. - const mouseStdin = createMouseStdin(process.stdin, (event) => { - if (!mouseTracking) return; - // The passthrough sees every event before the app does: a consumed - // shift-press must never reach the hit test, and while the window - // is open a report that was already in flight when reporting - // stopped is swallowed too — the operator is selecting, not - // clicking. - if (selectionPassthrough.observe(event)) return; - if (mouseTracking.isSuspended()) return; - mouseSource.emit(event); - }); + const mouseStdin = createMouseStdin( + process.stdin, + (event) => { + if (!mouseTracking) return; + // The passthrough sees every event before the app does: a consumed + // shift-press must never reach the hit test, and while the window + // is open a report that was already in flight when reporting + // stopped is swallowed too — the operator is selecting, not + // clicking. + if (selectionPassthrough.observe(event)) return; + if (mouseTracking.isSuspended()) return; + mouseSource.emit(event); + }, + { + mouseActive: () => mouseTracking !== null, + // The leak breaker: the terminal answered our tracking request + // with reports the decoder could not consume (seen over ssh with + // encoding-confused hops), and coordinates were about to be typed + // into the composer. Stop asking for reports — for this session + // only, so the persisted preference still serves terminals where + // the mouse works. + onMouseTextLeak: () => { + mouseTracking?.disable(); + mouseTracking = null; + bus.emit({ + type: "system_message", + variant: "warn", + text: "this terminal is sending garbled mouse reports — mouse support disabled for this session (/mouse on to retry, or launch with --no-mouse)", + }); + }, + }, + ); const setMouseEnabled = (next: boolean | null): void => { if (next === null) { bus.emit({ @@ -699,6 +732,16 @@ export async function tuiCommand(args: string[]): Promise { }); } + // The frozen Win10 conhost scrolls under full-height frames — the + // layout already reserves its bottom row (see `legacy-conhost.ts`); + // this is where the operator learns why, and that Windows Terminal + // does not need the workaround. Once per session, in the transcript, + // because anything printed before the alt screen is never seen. + const conhostHint = legacyConhostStartupHint(); + if (conhostHint) { + bus.emit({ type: "system_message", text: conhostHint }); + } + // If the user is in managed mode and the backend + model are ready // on disk, start the daemon immediately so there is no extra // "run this command in another terminal" step. No-op in external @@ -886,6 +929,14 @@ function persistLlamaUrl( }); if (!health.reachable) { report(describeLlamaHealthFailure(health, nextUrl)); + // An openai-compat verdict has a real path forward — the same + // server saved as a cloud provider — so beyond naming it, open + // the steer prompt: `y` there deep-links into the provider + // wizard prefilled with this URL (Ollama URLs land on the + // Ollama preset) instead of leaving a dead-end refusal. + if (health.kind === "openai-compat") { + bus.emit({ type: "llm_external_compat_steer_opened", url: nextUrl }); + } return; } persistUserLocalLlmUrl(nextUrl);