From 34d6dc6367ae36baabc282202630061b7f08588d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 15:35:19 +0300 Subject: [PATCH 01/20] fix(agent): never leak raw reasoning as a reply; transport-aware prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-part fix for issue #285 (verbatim chain-of-thought delivered to the user as the agent's reply, and native_tools having no effect on model behaviour). Guardrail (step-executor): the native_tools reasoning-only salvage no longer wraps an unparseable `reasoning_content` body into a `reply { text }` call. A GBNF-shaped batch embedded in the think channel is still recovered as real tool calls, but anything else now returns a parse failure and routes through the existing one-shot repair (parse_retry + buildToolCallRepairPrompt, REPAIR_MAX_TOKENS-capped). A repair that fails too ends the step as a GrammarError — a parse error, not a CoT leak. Root cause (prompt): the stable prefix was transport-blind — under native_tools the request carried an OpenAI `tools` array with `tool_choice: "auto"` while the prompt simultaneously ordered "Emit a JSON ARRAY of tool calls now" and the persona mandated "exactly one JSON array". `buildStablePrefix` now takes `toolTransport` (threaded from `StepDependencies` through `BuildPromptInput`): under native_tools the persona's emission mandate and the `### instructions` block switch to native function-calling guidance. The `### tools` text catalog stays 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. The grammar-path prefix is byte-identical to before (KV-cache safe; verified by sha256 against the previous implementation across profile/turn-framing/win32/persona variants). Known caveat: on a native-to-grammar mid-session fallover the prompt lacks the JSON-array mandate while the grammar link parses GBNF. This is acceptable because llama-server's GBNF grammar constrains decoding to the array shape regardless of the prompt mandate. Fixes #285 Co-Authored-By: Claude Fable 5 --- src/agent/step-executor.test.ts | 202 +++++++++++++++++++++++++++++-- src/agent/step-executor.ts | 47 +++---- src/prompt/build-prompt-types.ts | 9 ++ src/prompt/build-prompt.test.ts | 65 ++++++++++ src/prompt/build-prompt.ts | 3 + src/prompt/index.ts | 1 + src/prompt/stable-prefix.ts | 84 +++++++++++-- 7 files changed, 364 insertions(+), 47 deletions(-) diff --git a/src/agent/step-executor.test.ts b/src/agent/step-executor.test.ts index 3bb7fde1..3a1926e3 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,164 @@ 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("repairs a native-tools reply call with empty args before execution", async () => { diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index 98636f3d..5d04b66d 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -336,6 +336,13 @@ async function executeStepInner( 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 } + : {}), ...(deps.contextWindow !== undefined ? { contextWindow: deps.contextWindow } : {}), @@ -1054,9 +1061,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() @@ -1206,11 +1215,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() @@ -1230,21 +1243,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 { diff --git a/src/prompt/build-prompt-types.ts b/src/prompt/build-prompt-types.ts index 96cc6110..15b9a3be 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`. diff --git a/src/prompt/build-prompt.test.ts b/src/prompt/build-prompt.test.ts index 6d7ea8e8..3c32dd30 100644 --- a/src/prompt/build-prompt.test.ts +++ b/src/prompt/build-prompt.test.ts @@ -1269,3 +1269,68 @@ 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", + ); + 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", + ); + }); + + 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..53cda6b2 100644 --- a/src/prompt/build-prompt.ts +++ b/src/prompt/build-prompt.ts @@ -130,6 +130,9 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { ...(input.systemPersona !== undefined ? { systemPersona: input.systemPersona } : {}), + ...(input.toolTransport !== undefined + ? { toolTransport: input.toolTransport } + : {}), }); const sessionParts = buildSessionSectionParts( 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..918301a8 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,16 +76,28 @@ 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) and the bias-toward-action + * phrasing (line 2) differ per transport; everything below 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.", +const SYSTEM_PERSONA_SHARED_LINES = [ "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`.", "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.", @@ -94,6 +107,33 @@ 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_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_SHARED_LINES, ].join("\n"); /** @@ -110,7 +150,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[] = []; @@ -167,12 +210,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"); } From 93f6d2888e9f2bac2d559ae79b5424a40695d465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 15:46:02 +0300 Subject: [PATCH 02/20] fix(agent): stop sending the reasoning prefill to native-tools chat providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trailing `` prefill (and Gemma turn-framing tokens) are llama-server text-completion artifacts: the local template expects the open tag pre-typed at the generation point. On the native-tools chat transport the same 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 in message content (ollama/ollama#17248), the trigger for #283. The injection only fired in hybrid configs (a local llama-server probing a think-tag model while completions route to a cloud provider), but there it also mis-parsed clean cloud replies: `normalizeContent` re-prepended the open tag and the stream parser started pre-opened, so a reply that never emitted `` was swallowed whole as reasoning. - build-prompt: new `suppressReasoningPrefill` input drops the trailing reasoning prefill, the Gemma turn framing, and the reasoning system token; the step executor sets it for `toolTransport: "native_tools"`. - step-executor: parsing no longer assumes a prefill that was not sent (`promptCarriesReasoningPrefill` / `completionAssumesOpenReasoning`); grammar-parsed completions keep the legacy prepend — the GBNF prelude root structurally starts mid-think — including on cross-transport fallover. The one-shot repair prompt stops re-appending ``. - profile-invariants: `checkProfilePromptAligned` learns the suppressed shape (the prompt must NOT end with a reasoning prelude). - provider-presets: document the upstream Ollama Cloud corruption next to the preset. The issue's proposed blanket rewrite of thinking-tag strings in all outgoing content for ollama.com endpoints is deliberately NOT implemented: silently mutating user text and tool results is the same silent-corruption class relocated client-side, and the server-side half is Ollama's bug to fix. Fixes #283 Co-Authored-By: Claude Fable 5 --- src/agent/step-executor.test.ts | 231 ++++++++++++++++++++++++++ src/agent/step-executor.ts | 156 ++++++++++++++--- src/llm/profile-invariants.ts | 24 +++ src/prompt/build-prompt-types.ts | 16 ++ src/prompt/build-prompt.test.ts | 32 ++++ src/prompt/build-prompt.ts | 11 +- src/tui/providers/provider-presets.ts | 8 + 7 files changed, 456 insertions(+), 22 deletions(-) diff --git a/src/agent/step-executor.test.ts b/src/agent/step-executor.test.ts index 3bb7fde1..6855fc8f 100644 --- a/src/agent/step-executor.test.ts +++ b/src/agent/step-executor.test.ts @@ -1735,6 +1735,237 @@ 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", + }); + }); +}); + 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 98636f3d..4976142d 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -329,6 +329,10 @@ async function executeStepInner( ctx: StepContext, deps: StepDependencies, ): Promise { + const promptCarriesPrefill = promptCarriesReasoningPrefill( + deps.profile, + deps.toolTransport, + ); const prompt = buildPrompt({ session: ctx.session, toolDescriptors: ctx.toolDescriptors, @@ -336,6 +340,10 @@ async function executeStepInner( skillCatalog: ctx.skillCatalog, currentDate: formatCurrentDate(new Date()), profile: deps.profile, + // 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 } : {}), @@ -358,7 +366,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, @@ -406,12 +416,26 @@ 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) and off whether the prompt carried the prefill at all. + const assumesOpenReasoning = (c: CompletionResult): boolean => + completionAssumesOpenReasoning( + deps.profile, + parseDepsFor(c, deps).toolTransport, + promptCarriesPrefill, + ); + // 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", @@ -598,6 +622,7 @@ async function executeStepInner( completion, deps.profile, parseDepsFor(completion, deps), + promptCarriesPrefill, ); if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); @@ -649,7 +674,12 @@ async function executeStepInner( const retryStartedAt = Date.now(); completion = await deps.llmComplete({ ...llmParams, - prompt: buildToolCallRepairPrompt(prompt.text, parsed.error, deps.profile), + prompt: buildToolCallRepairPrompt( + prompt.text, + parsed.error, + deps.profile, + promptCarriesPrefill, + ), // 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 @@ -686,7 +716,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", @@ -724,6 +758,7 @@ async function executeStepInner( completion, deps.profile, parseDepsFor(completion, deps), + promptCarriesPrefill, ); if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); @@ -760,7 +795,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, @@ -981,6 +1020,7 @@ async function runInitialCompletion( deps.llmCompleteStream(llmParams), ctx.stepIndex, deps.profile, + promptCarriesReasoningPrefill(deps.profile, deps.toolTransport), deps.onEvent, ) : await deps.llmComplete(llmParams); @@ -1003,6 +1043,49 @@ 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, and every consumer that assumes "the open tag was already + * sent" must key off this, not `profile.requiresPromptThinkPrefix` + * alone. + */ +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). + * + * True when the prompt actually carried the prefill — and for any + * grammar-parsed completion regardless: the GBNF prelude root emits + * `body ""` without the open tag, so grammar output always + * starts mid-think even when the prompt did not prefill (cross-transport + * fallover from a native-tools primary to a grammar local link). + */ +function completionAssumesOpenReasoning( + profile: ModelProfile, + parseTransport: ToolCallTransport, + promptCarriedPrefill: boolean, +): boolean { + if (!profile.requiresPromptThinkPrefix) return false; + return promptCarriedPrefill || parseTransport !== "native_tools"; +} + /** * Resolve the reasoning text for a completion, preferring the dedicated * `reasoning_content` channel when present and falling back to inline @@ -1011,13 +1094,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), @@ -1028,8 +1116,9 @@ function resolveReasoning( function normalizeContent( completion: CompletionResult, profile: ModelProfile, + assumeOpenReasoning: boolean, ): string { - return profile.requiresPromptThinkPrefix + return assumeOpenReasoning ? `${getReasoningOpenTagPrefix(profile)}${completion.content}` : completion.content; } @@ -1128,12 +1217,22 @@ function tryParseToolCalls( completion: CompletionResult, profile: ModelProfile, deps: Pick, + promptCarriedPrefill: boolean, ): ToolCallBatchParseResult { + const assumeOpenReasoning = completionAssumesOpenReasoning( + profile, + deps.toolTransport, + promptCarriedPrefill, + ); 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, @@ -1170,7 +1269,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) { @@ -1189,7 +1288,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: { @@ -1255,7 +1358,7 @@ function tryParseToolCalls( }; } const batch = parseToolCalls( - normalizeContent(completion, profile), + normalizeContent(completion, profile, assumeOpenReasoning), getReasoningTagOptions(profile), ); return { ok: true, batch }; @@ -1276,9 +1379,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(); @@ -1288,7 +1392,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: [ @@ -1599,6 +1703,7 @@ function buildToolCallRepairPrompt( promptText: string, error: Error, profile?: ModelProfile, + promptCarriedPrefill = true, ): string { // Strip the trailing reasoning open-tag prefill (e.g. `` for // qwen-think, `<|channel>thought\n` for gemma4-think) before @@ -1618,7 +1723,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(), "", @@ -1642,7 +1754,9 @@ function buildToolCallRepairPrompt( "### respond", "Respond now.", ); - const openReasoning = renderOpenReasoningBlock(profile); + const openReasoning = promptCarriedPrefill + ? renderOpenReasoningBlock(profile) + : ""; if (openReasoning.length > 0) { lines.push(openReasoning); } @@ -1775,15 +1889,17 @@ async function consumeStream( stream: AsyncGenerator, stepIndex: number, profile: ModelProfile, + promptCarriedPrefill: boolean, 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. + // Pre-opened only when the open tag was actually prefilled in the + // prompt (grammar transport). On the native-tools chat transport the + // prefill is suppressed (issue #283), and 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), + promptCarriedPrefill && !reasoningOpenEmittedByModel(profile), ...(profile.reasoningStyle !== "none" ? { reasoningOpenTag: profile.reasoningOpenTag, diff --git a/src/llm/profile-invariants.ts b/src/llm/profile-invariants.ts index 9f96df3b..2a0943e1 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,16 @@ 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", + ); + } + return violations; + } + const framing = getReasoningTurnFraming(profile); if (framing) { // Turn-framed profiles (Gemma 4) end at the model-turn opener; the model diff --git a/src/prompt/build-prompt-types.ts b/src/prompt/build-prompt-types.ts index 96cc6110..3974ee94 100644 --- a/src/prompt/build-prompt-types.ts +++ b/src/prompt/build-prompt-types.ts @@ -41,6 +41,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..1e69e922 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(), diff --git a/src/prompt/build-prompt.ts b/src/prompt/build-prompt.ts index ca93eb67..86591996 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 } @@ -336,6 +342,7 @@ export function buildPrompt(input: BuildPromptInput): BuiltPrompt { ``, ); } else if ( + !suppressPrefill && input.profile?.requiresPromptThinkPrefix && input.profile.reasoningStyle !== "none" ) { 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", }, { From b29105bd7c855cb2bfb83ff7952be55fe13ed492 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Mon, 31 Aug 2026 15:59:56 +0300 Subject: [PATCH 03/20] =?UTF-8?q?fix(agent):=20finish=20the=20transport=20?= =?UTF-8?q?split=20=E2=80=94=20rules=20line,=20repair=20prompt,=20replay?= =?UTF-8?q?=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the issue #285 fix found three residual problems; this commit closes all three. 1. `### rules` still opened with the text-array mandate ("One tool-call array per step ... a solo action is a length-1 array") in BOTH transports, contradicting the native `### instructions` block in the same prefix. The rules line is now transport-variant like the persona and instructions: the grammar line is byte-identical to before (sha256-verified against v0.4.2 across omitted/explicit, win32, persona-override, reasoning-token and turn-framing variants), the native line mandates the function-calling interface. The same sweep caught one more survivor in the shared persona — "if another tool is next, emit that tool JSON" — which likewise becomes "call that tool" under native_tools only. 2. `buildToolCallRepairPrompt` was transport-blind: on the exact parse_retry path the #285 fix routes reasoning-only completions through, it appended "Emit a corrected JSON array only" / "Use a length-1 array" onto a native prefix that forbids text-JSON — the dual mandate recreated at the one retry a failing model gets. The repair mandate now follows `deps.toolTransport`: native repairs order a corrected native tool call, grammar repairs keep the legacy lines byte-for-byte. A new probe test captures the second llmComplete prompt under native transport and asserts no text-array mandate survives anywhere in it. 3. `atomic-agent trace replay` regressed into 100% false drift for native-transport sessions: the prefix now differs by construction per transport, but `replaySession` always rebuilt the grammar variant and traces do not record which transport served the session. The replay is now transport-aware: `ReplayContext.toolTransport` pins the comparison when the caller knows the transport; when omitted (the trace-command case) both variants are built and a recorded hash is clean when it matches either — old (pre-#285) traces keep matching through the byte-identical grammar variant. Each step reports which variant matched (`matchedTransport`). Co-Authored-By: Claude Fable 5 --- src/agent/step-executor.test.ts | 101 ++++++++++++++++++++++++++++++ src/agent/step-executor.ts | 42 ++++++++++--- src/prompt/build-prompt.test.ts | 14 +++++ src/prompt/stable-prefix.ts | 43 +++++++++++-- src/replay/replay-session.test.ts | 65 +++++++++++++++++++ src/replay/replay-session.ts | 58 +++++++++++++---- 6 files changed, 297 insertions(+), 26 deletions(-) diff --git a/src/agent/step-executor.test.ts b/src/agent/step-executor.test.ts index 3a1926e3..772ab737 100644 --- a/src/agent/step-executor.test.ts +++ b/src/agent/step-executor.test.ts @@ -514,6 +514,107 @@ describe("executeStep batch handling", () => { 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 () => { const registry = makeRegistry(); const session = createEmptySessionState({ diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index 5d04b66d..a630cf3c 100644 --- a/src/agent/step-executor.ts +++ b/src/agent/step-executor.ts @@ -656,7 +656,12 @@ async function executeStepInner( const retryStartedAt = Date.now(); completion = await deps.llmComplete({ ...llmParams, - prompt: buildToolCallRepairPrompt(prompt.text, parsed.error, deps.profile), + prompt: buildToolCallRepairPrompt( + prompt.text, + parsed.error, + deps.profile, + deps.toolTransport, + ), // 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 @@ -1604,6 +1609,7 @@ function buildToolCallRepairPrompt( promptText: string, error: Error, profile?: ModelProfile, + toolTransport?: ToolCallTransport, ): string { // Strip the trailing reasoning open-tag prefill (e.g. `` for // qwen-think, `<|channel>thought\n` for gemma4-think) before @@ -1639,14 +1645,32 @@ 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.", - ); + // 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 = renderOpenReasoningBlock(profile); if (openReasoning.length > 0) { lines.push(openReasoning); diff --git a/src/prompt/build-prompt.test.ts b/src/prompt/build-prompt.test.ts index 3c32dd30..4a44c56b 100644 --- a/src/prompt/build-prompt.test.ts +++ b/src/prompt/build-prompt.test.ts @@ -1286,6 +1286,17 @@ describe("buildPrompt tool transport (issue #285)", () => { 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. @@ -1304,6 +1315,9 @@ describe("buildPrompt tool transport (issue #285)", () => { 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", () => { diff --git a/src/prompt/stable-prefix.ts b/src/prompt/stable-prefix.ts index 918301a8..3f3bd02f 100644 --- a/src/prompt/stable-prefix.ts +++ b/src/prompt/stable-prefix.ts @@ -93,13 +93,27 @@ export interface StablePrefixInput { /** * Persona lines shared verbatim between the grammar and native-tools - * variants. Only the emission mandate (line 1) and the bias-toward-action - * phrasing (line 2) differ per transport; everything below is + * 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. */ +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 = [ - "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`.", "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.", @@ -117,6 +131,8 @@ const SYSTEM_PERSONA_SHARED_LINES = [ 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"); @@ -133,9 +149,26 @@ export const 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 @@ -194,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, 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, From 25b4b782c1d9733bc1d22c5885730621a61a5463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 16:18:22 +0300 Subject: [PATCH 04/20] fix(agent): keep live reasoning and prompt shape correct across cross-transport fallover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the prefill-suppression fix (#283) found a real streaming regression in the documented default hybrid chain — cloud (native_tools) primary with a grammar local last resort (`appendLocal`) on a think-tag profile: - consumeStream keyed `preOpenedThink` off the PRIMARY transport, but a grammar-served fallover stream starts mid-`` (the GBNF prelude root emits `body ""` with no open tag), so the parser silently swallowed the reasoning text — no live `reasoning_delta`s for every sticky-override turn of an outage, where main surfaced them. The `servedTransport` stamp on the stream's return value arrives only after the last delta, too late to reconfigure a parser. Fixes, in dependency order: - completion-types: `StreamChunk.servedTransport` — the fallback streamer seam now stamps the serving link's transport on EVERY chunk, not just the final result, so live consumers can adapt up front. - step-executor.consumeStream: the stream parser is created lazily off the first chunk's stamp (primary transport when unstamped, i.e. the direct non-fallback path), restoring live reasoning classification for grammar-served fallover streams. - llm-fallback-seam: per-link prompt substitution. The main prompt for a native-tools primary is prefill-suppressed, which handed the grammar fallover link a prompt/template mismatch (GBNF still forces mid-think output). `LlmStreamParams.grammarPrompt` carries a lazy, memoized prefill-carrying variant built only when a grammar link is actually chosen; the one-shot repair retry rebuilds both variants repair-shaped so a fallover retry never sees the stale base prompt. - completionAssumesOpenReasoning now keys purely off the served/parse transport: grammar-served output always continues an open think block; a chat completion never does. This also stops the (documented- unsupported) grammar-primary -> native-link ordering from swallowing a clean chat reply whole as reasoning; the literal prefill still shipping to the chat link in that reverse ordering remains, matching AGENTS.md's "order native-tools links at or above the first grammar-only link". - profile-invariants: the prefill-suppressed branch also flags leaked Gemma turn-framing tails, not just reasoning open tags. Coverage for the pinned invariant 8 gap that let this slip past CI: fallback-e2e now exercises think-tag profiles through the REAL seam factories, unary and streaming (live deltas + per-link prompt shape), llm-fallback-seam.test pins the per-chunk stamp and the grammarPrompt substitution, and step-executor.test pins both fallover directions. All 8 new tests fail without the src changes (verified by stashing). lint clean; agent+prompt+llm 877/877; runtime+tui/providers 336/336; full suite 6447/6448 — the one failure (sidecar/send-message-concurrency) fails identically on pristine origin/main in a clean worktree. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 6 +- src/agent/step-executor.test.ts | 116 ++++++++++++ src/agent/step-executor.ts | 179 +++++++++++++----- .../fallback/fallback-e2e.integration.test.ts | 173 +++++++++++++++++ src/llm/profile-invariants.test.ts | 33 ++++ src/llm/profile-invariants.ts | 16 ++ src/llm/provider/completion-types.ts | 11 ++ src/runtime/llm-fallback-seam.test.ts | 115 +++++++++++ src/runtime/llm-fallback-seam.ts | 53 +++++- 9 files changed, 648 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab47818c..13026e72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1835,8 +1835,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. @@ -1849,7 +1849,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/src/agent/step-executor.test.ts b/src/agent/step-executor.test.ts index 6855fc8f..206e80c6 100644 --- a/src/agent/step-executor.test.ts +++ b/src/agent/step-executor.test.ts @@ -1964,6 +1964,122 @@ describe("native_tools thinking-profile prompt hygiene (issue #283)", () => { 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", () => { diff --git a/src/agent/step-executor.ts b/src/agent/step-executor.ts index 4976142d..f7ba44b4 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; @@ -333,7 +350,7 @@ async function executeStepInner( deps.profile, deps.toolTransport, ); - const prompt = buildPrompt({ + const promptInput: BuildPromptInput = { session: ctx.session, toolDescriptors: ctx.toolDescriptors, capabilities: ctx.capabilities, @@ -356,7 +373,24 @@ 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, suppressReasoningPrefill: false }) + .text, + ) + : undefined; const slot = deps.supportsSlotAffinity ? deps.slotManager.acquire(ctx.session.id, prompt.stablePrefix) : { @@ -398,14 +432,17 @@ async function executeStepInner( promptTokens: prompt.tokens.total, }); - const llmParams: LlmStreamParams = buildLlmStreamParams({ - promptText: prompt.text, - deps, - slotId: slot.slotId, - sessionId: ctx.session.id, - toolDescriptors: ctx.toolDescriptors, - signal: ctx.signal, - }); + const llmParams: LlmStreamParams = { + ...buildLlmStreamParams({ + promptText: prompt.text, + deps, + slotId: slot.slotId, + sessionId: ctx.session.id, + toolDescriptors: ctx.toolDescriptors, + signal: ctx.signal, + }), + ...(grammarPrompt ? { grammarPrompt } : {}), + }; const firstAttempt = await runInitialCompletion({ ctx, @@ -418,12 +455,11 @@ async function executeStepInner( // Parse-side prefill assumption for a given completion: keyed off the // transport that actually served it (cross-transport fallover swaps - // it) and off whether the prompt carried the prefill at all. + // it), never off the primary's configuration. const assumesOpenReasoning = (c: CompletionResult): boolean => completionAssumesOpenReasoning( deps.profile, parseDepsFor(c, deps).toolTransport, - promptCarriesPrefill, ); // Prefer the dedicated `reasoning_content` channel when the server @@ -622,7 +658,6 @@ async function executeStepInner( completion, deps.profile, parseDepsFor(completion, deps), - promptCarriesPrefill, ); if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); @@ -672,14 +707,30 @@ async function executeStepInner( }); const retryStartedAt = Date.now(); + const repairError = parsed.error; completion = await deps.llmComplete({ ...llmParams, prompt: buildToolCallRepairPrompt( prompt.text, - parsed.error, + repairError, deps.profile, 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. + ...(grammarPrompt + ? { + grammarPrompt: memoizeText(() => + buildToolCallRepairPrompt( + grammarPrompt(), + repairError, + deps.profile, + 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 @@ -758,7 +809,6 @@ async function executeStepInner( completion, deps.profile, parseDepsFor(completion, deps), - promptCarriesPrefill, ); if (parsed.ok) { const validation = validateBatch(parsed.batch, deps.registry); @@ -1020,7 +1070,7 @@ async function runInitialCompletion( deps.llmCompleteStream(llmParams), ctx.stepIndex, deps.profile, - promptCarriesReasoningPrefill(deps.profile, deps.toolTransport), + deps.toolTransport, deps.onEvent, ) : await deps.llmComplete(llmParams); @@ -1055,9 +1105,11 @@ async function runInitialCompletion( * 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, and every consumer that assumes "the open tag was already - * sent" must key off this, not `profile.requiresPromptThinkPrefix` - * alone. + * 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, @@ -1071,19 +1123,36 @@ function promptCarriesReasoningPrefill( * reasoning block (re-prepending the open tag before extraction / * pre-opening the stream parser's think state). * - * True when the prompt actually carried the prefill — and for any - * grammar-parsed completion regardless: the GBNF prelude root emits - * `body ""` without the open tag, so grammar output always - * starts mid-think even when the prompt did not prefill (cross-transport - * fallover from a native-tools primary to a grammar local link). + * 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, - promptCarriedPrefill: boolean, ): boolean { if (!profile.requiresPromptThinkPrefix) return false; - return promptCarriedPrefill || parseTransport !== "native_tools"; + 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()); } /** @@ -1217,12 +1286,10 @@ function tryParseToolCalls( completion: CompletionResult, profile: ModelProfile, deps: Pick, - promptCarriedPrefill: boolean, ): ToolCallBatchParseResult { const assumeOpenReasoning = completionAssumesOpenReasoning( profile, deps.toolTransport, - promptCarriedPrefill, ); try { if (deps.toolTransport === "native_tools") { @@ -1889,24 +1956,37 @@ async function consumeStream( stream: AsyncGenerator, stepIndex: number, profile: ModelProfile, - promptCarriedPrefill: boolean, + primaryTransport: ToolCallTransport, onEvent?: (event: StepEvent) => void, ): Promise { - const parser = createStreamParser({ - // Pre-opened only when the open tag was actually prefilled in the - // prompt (grammar transport). On the native-tools chat transport the - // prefill is suppressed (issue #283), and with model-emitted - // reasoning (Gemma 4 turn-framing) the parser must detect the open - // tag live in the stream instead. - preOpenedThink: - promptCarriedPrefill && !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 @@ -1936,6 +2016,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 @@ -1953,7 +2038,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 @@ -1961,7 +2046,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/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/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 2a0943e1..7e1e2c56 100644 --- a/src/llm/profile-invariants.ts +++ b/src/llm/profile-invariants.ts @@ -74,6 +74,17 @@ export function checkProfilePromptAligned( "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; } @@ -105,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/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 From bc0cf21e98abffca5eef4c8f6ff2764a53f7c28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 16:46:51 +0300 Subject: [PATCH 05/20] fix(skills): honor skills.catalogTokenBudget when building the skill catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config key skills.catalogTokenBudget (env ATOMIC_AGENT_SKILLS_CATALOG_BUDGET, default 512) was parsed and typed but never read: every buildSkillCatalog call site invoked it without options, so the '### skills' catalog was always cut at the hardcoded 4096-char cap and the knob silently did nothing. Thread the configured budget through all three call sites (runtime bootstrap initial build, refreshSkills rebuild, trace replay). The key speaks tokens while the catalog builder caps characters; convert at a named SKILL_CATALOG_CHARS_PER_TOKEN = 8 factor, chosen so the shipped default of 512 tokens maps exactly to the historical 4096-char cap — users who never set the key see byte-identical prompts (and keep their KV cache) across the upgrade. An explicit maxChars still wins over tokenBudget for direct callers. Co-Authored-By: Claude Fable 5 --- src/cli/trace-command.ts | 4 ++- src/config/config-schema.ts | 9 ++++++ src/runtime/bootstrap.ts | 5 ++- src/skills/index.ts | 2 ++ src/skills/skill-catalog.test.ts | 52 +++++++++++++++++++++++++++++++- src/skills/skill-catalog.ts | 32 +++++++++++++++++++- 6 files changed, 100 insertions(+), 4 deletions(-) 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.ts b/src/config/config-schema.ts index cf9403c7..4ce05d82 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -333,6 +333,15 @@ 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. + */ catalogTokenBudget: number; /** * Names of installed skills that should be hidden from the diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index c73326aa..c50a9bfe 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -1245,6 +1245,7 @@ export async function createAgentRuntime( let skillCatalog: readonly SkillCatalogEntry[] = buildSkillCatalog( skillRegistry.list(), + { tokenBudget: config.skills.catalogTokenBudget }, ); let grammar = await buildGrammar(profile, config.paths.grammarsDir, { @@ -2181,7 +2182,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/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..489d378b 100644 --- a/src/skills/skill-catalog.test.ts +++ b/src/skills/skill-catalog.test.ts @@ -1,6 +1,11 @@ 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 type { SkillRecord } from "./skill-loader.js"; function record( @@ -70,4 +75,49 @@ 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 of 512 tokens maps to the historical 4096-char cap", () => { + expect(512 * 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 of 512 tokens. + const records = Array.from({ length: 12 }, (_, i) => + record(`skill-${i}`, "d".repeat(390)), + ); + const legacy = buildSkillCatalog(records); + const configured = buildSkillCatalog(records, { tokenBudget: 512 }); + 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) { From 644d389cbed033794907f47a3ae7ffeae87a8281 Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 17:00:55 +0300 Subject: [PATCH 06/20] test(skills): regression-guard the catalog-budget wiring; clamp the env knob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of #288 proved the fixed bug was not regression- guarded where it lived: reverting only the bootstrap/trace-command call sites to main (the exact reported defect) left all relevant tests green. - bootstrap.test.ts: boot with ATOMIC_AGENT_SKILLS_CATALOG_BUDGET=4 and assert runtime.skillCatalog is built with that budget, both at boot and after refreshSkills() — covering both bootstrap call sites. - trace-command.test.ts: run `trace replay` twice (default vs 4-token budget) against seeded global skills and assert the recomputed stable- prefix hash shifts — covering the replay call site. Both new tests verified to FAIL with the two call-site files reverted to origin/main, and pass with the fix. - skill-catalog.test.ts: the default-budget mapping guard now imports ENV_DEFAULTS.SKILLS_CATALOG_BUDGET instead of hardcoding 512, so a future default change trips the 4096-char back-compat check. - load-config.ts: ATOMIC_AGENT_SKILLS_CATALOG_BUDGET is now read via readBoundedPositiveInt (clamped to [1, 100000]) so a zero/negative value cannot drive maxChars to 0 and collapse the catalog; documented in config-schema.ts and covered in load-config.test.ts. Co-Authored-By: Claude Fable 5 --- src/cli/trace-command.test.ts | 56 +++++++++++++++++++++++ src/config/config-schema.ts | 4 +- src/config/load-config.test.ts | 27 +++++++++++- src/config/load-config.ts | 4 +- src/runtime/bootstrap.test.ts | 76 +++++++++++++++++++++++++++++++- src/skills/skill-catalog.test.ts | 15 +++++-- 6 files changed, 174 insertions(+), 8 deletions(-) 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/config/config-schema.ts b/src/config/config-schema.ts index 4ce05d82..ee8ab0fb 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -340,7 +340,9 @@ export interface AtomicAgentConfig { * 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. + * 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; /** 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..c969cbfa 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -292,9 +292,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/runtime/bootstrap.test.ts b/src/runtime/bootstrap.test.ts index 1827fc5f..faf31684 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"; @@ -33,6 +40,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"; @@ -96,6 +104,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(); }); @@ -127,6 +136,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 @@ -939,6 +1012,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/skills/skill-catalog.test.ts b/src/skills/skill-catalog.test.ts index 489d378b..3459ae9b 100644 --- a/src/skills/skill-catalog.test.ts +++ b/src/skills/skill-catalog.test.ts @@ -6,6 +6,7 @@ import { 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( @@ -91,17 +92,23 @@ describe("buildSkillCatalog", () => { expect(raised.length).toBe(records.length); }); - it("shipped default budget of 512 tokens maps to the historical 4096-char cap", () => { - expect(512 * SKILL_CATALOG_CHARS_PER_TOKEN).toBe(DEFAULT_CATALOG_MAX_CHARS); + 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 of 512 tokens. + // 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: 512 }); + const configured = buildSkillCatalog(records, { + tokenBudget: ENV_DEFAULTS.SKILLS_CATALOG_BUDGET, + }); expect(configured).toEqual(legacy); expect(legacy.length).toBeLessThan(records.length); }); From 4b7986d79d3f66c48b136b0b87ba79cee2d9ff68 Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 17:17:32 +0300 Subject: [PATCH 07/20] fix(tui): clicking a slash-palette row runs that row's command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activating a completion row submitted the raw editor buffer through handleEditorSubmit, and resolveSlashCommand is exact-match only — so with "/mod" typed, clicking the "/model" row errored with "unknown command: /mod" instead of running /model. Run the clicked row's own completion instead, through the same runSlashCommand path the keyboard palette-highlight branch uses on Enter: the row the operator clicked is the choice, whatever prefix is in the buffer. Reported on Discord: a click on a slash-command completion submits the typed buffer instead of the clicked command. Co-Authored-By: Claude Fable 5 --- src/tui/components/slash-palette.tsx | 14 +++++++---- src/tui/mouse/mouse-app.test.tsx | 35 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) 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/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. From bec29e7980e894af028ae4168a5c4afa4e43f3ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 17:17:53 +0300 Subject: [PATCH 08/20] feat(tui): steer an Ollama-shaped External URL into the provider wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users keep pointing the External llama.cpp URL at Ollama (:11434). The /health probe classifies it openai-compat and refuses the save; since 26102be the verdict is at least visible on the panel's status line, but it was a dead end — acting on it meant retyping the same URL into a wizard four screens away. Verified against a live `ollama serve`: /health answers 404 and /v1/models answers the OpenAI list shape, so the probe's verdict for Ollama is exactly `openai-compat` (status 404). - On an openai-compat refusal the External pane now opens a steer prompt naming the server; `y` deep-links into the provider wizard on the OpenAI-compatible route with the probed URL prefilled, `n`/Esc dismisses. A URL on Ollama's default port lands on the "Ollama (local)" preset — same state as picking that row by hand (entry id, env var, no key screen), but keeping the operator's own host — and any other compat server lands on the manual row's URL screen. - describeLlamaHealthFailure names Ollama outright for :11434 URLs and steers to the Ollama preset row, so the onboarding custom-URL branch (which shares the describer) stops calling an Ollama user's server merely "OpenAI-compatible". Tests: verdict-to-steer wizard mapping (preset vs manual row, host kept), the Ollama-named describer line, looksLikeOllamaUrl, and the prompt's reducer + key flow (y opens the prefilled wizard, n/Esc dismisses, hotkeys swallowed). All fail without the change. Co-Authored-By: Claude Fable 5 --- src/llm/describe-llama-health-failure.test.ts | 30 ++++++- src/llm/describe-llama-health-failure.ts | 32 ++++++-- src/tui/app-key-bindings.ts | 1 + src/tui/components/llm-panel-modals.tsx | 24 ++++++ src/tui/llm-panel/llm-panel-actions.ts | 5 ++ src/tui/llm-panel/llm-panel-external.test.ts | 78 +++++++++++++++++++ .../llm-panel/llm-panel-modal-key-bindings.ts | 23 ++++++ src/tui/llm-panel/llm-panel-reducer.ts | 10 +++ src/tui/llm-panel/llm-panel-state.ts | 8 ++ src/tui/providers/openai-compat-steer.test.ts | 39 ++++++++++ src/tui/providers/openai-compat-steer.ts | 41 ++++++++++ src/tui/tui-command.ts | 8 ++ 12 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 src/tui/providers/openai-compat-steer.test.ts create mode 100644 src/tui/providers/openai-compat-steer.ts diff --git a/src/llm/describe-llama-health-failure.test.ts b/src/llm/describe-llama-health-failure.test.ts index 31498a7b..984271f7 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,18 @@ 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("Ollama"); + expect(line).toContain("base URL http://127.0.0.1:11434"); + }); + it("says wait, not reconfigure, while the model is loading", () => { const line = describeLlamaHealthFailure( result({ kind: "llama-loading", status: 503 }), @@ -52,3 +67,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..c6277f1f 100644 --- a/src/llm/describe-llama-health-failure.ts +++ b/src/llm/describe-llama-health-failure.ts @@ -1,5 +1,20 @@ import type { HealthResult } from "./llama-server-health.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 * surface that saves an external llama.cpp URL (LLM tab External pane, @@ -14,12 +29,17 @@ 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. - return ( - `${url} answers like an OpenAI-compatible server, not llama.cpp. ` + - `Add it as a cloud provider instead: LLM tab › Cloud › n › ` + - `openai-compatible, base URL ${url}.` - ); + // 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. + return looksLikeOllamaUrl(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 an OpenAI-compatible server, not llama.cpp. ` + + `Add it as a cloud provider instead: LLM tab › Cloud › n › ` + + `openai-compatible, base URL ${url}.`; case "llama-loading": return ( `${url} is a llama.cpp server still loading its model. ` + 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-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/llm-panel/llm-panel-actions.ts b/src/tui/llm-panel/llm-panel-actions.ts index 2d0dda0f..125b442d 100644 --- a/src/tui/llm-panel/llm-panel-actions.ts +++ b/src/tui/llm-panel/llm-panel-actions.ts @@ -10,6 +10,9 @@ 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). */ @@ -27,6 +30,8 @@ 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" ); diff --git a/src/tui/llm-panel/llm-panel-external.test.ts b/src/tui/llm-panel/llm-panel-external.test.ts index 101ac4ff..b3e64f0a 100644 --- a/src/tui/llm-panel/llm-panel-external.test.ts +++ b/src/tui/llm-panel/llm-panel-external.test.ts @@ -205,3 +205,81 @@ 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", () => { + // `s` is the daemon start/stop hotkey outside the modal. + const onStop = vi.fn(); + const dispatched = press( + "s", + emptyKey(), + steerState(), + callbacks({ onLocalModelsDaemonStopRequested: onStop }), + ); + expect(dispatched).toEqual([]); + expect(onStop).not.toHaveBeenCalled(); + }); +}); 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..c1169c9f 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"; @@ -171,6 +172,28 @@ export function handleLlmModalKey( return true; } + 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; + } + const urlDraft = state.llmPanel.externalUrlDraft; if (urlDraft !== null) { if (key.escape) { diff --git a/src/tui/llm-panel/llm-panel-reducer.ts b/src/tui/llm-panel/llm-panel-reducer.ts index f42d8c08..09ad4482 100644 --- a/src/tui/llm-panel/llm-panel-reducer.ts +++ b/src/tui/llm-panel/llm-panel-reducer.ts @@ -71,6 +71,16 @@ export function reduceLlmPanelAction( ...state, llmPanel: { ...panel, externalUrlDraft: action.value }, }; + case "llm_external_compat_steer_opened": + 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 { diff --git a/src/tui/llm-panel/llm-panel-state.ts b/src/tui/llm-panel/llm-panel-state.ts index 6561d53a..59979fdd 100644 --- a/src/tui/llm-panel/llm-panel-state.ts +++ b/src/tui/llm-panel/llm-panel-state.ts @@ -35,6 +35,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). @@ -58,6 +65,7 @@ export function createInitialLlmPanelState(): LlmPanelState { syncModeToActiveRoute: false, stopLocalDaemonsPrompt: null, externalUrlDraft: null, + externalCompatSteerUrl: null, cloudModelFilter: "", cloudModelFilterFocused: false, }; 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/tui-command.ts b/src/tui/tui-command.ts index 8323c672..bfcdfdf0 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -849,6 +849,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); From aade616bddbf83e9c2eaa96ed8a74c8e150694cb Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Mon, 31 Aug 2026 17:19:49 +0300 Subject: [PATCH 09/20] fix(tui): reserve the bottom row on legacy Win10 conhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.4.2 synchronized-update bracketing (eb690e7) stopped the frame tearing but not the Win10 conhost reports: residual shaking, and a duplicated last row under PowerShell. Both fit one mechanism the bracketing cannot address — the TUI pins its root to `height={rows}`, and the frozen inbox conhost scrolls when a full-height frame writes into its bottom row; DEC 2026 is ignored there, and a scroll is buffer movement, not tearing. Once the viewport slides one line, the repaint cursor math is off by one: the UI shakes and the row that scrolled away leaves the last row painted twice. No Windows machine was available to reproduce, so the change is the conservative guard: when the host looks like a legacy conhost (win32, and neither WT_SESSION nor TERM_PROGRAM set), `useTerminalSize` reports one row fewer, so no frame ever touches the bottom terminal row and there is nothing left to scroll. Every modern host — Windows Terminal, VS Code, anything setting those variables — keeps the full height, and non-TTY streams (tests, pipes, CI) are untouched. A one-time transcript hint on such consoles recommends Windows Terminal and names the escape hatch: ATOMIC_AGENT_CONHOST_GUARD=0 disables the guard, =1 forces it on anywhere — which is also how it was verified: a PTY+pyte run at 80x24 shows a 23-row frame, a never-written bottom row, and the hint; with the variable unset the frame is unchanged from main. Co-Authored-By: Claude Fable 5 --- src/tui/hooks/use-terminal-size.test.ts | 54 ++++++++++++++ src/tui/hooks/use-terminal-size.ts | 26 ++++++- src/tui/legacy-conhost.test.ts | 97 +++++++++++++++++++++++++ src/tui/legacy-conhost.ts | 90 +++++++++++++++++++++++ src/tui/tui-command.ts | 11 +++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/tui/hooks/use-terminal-size.test.ts create mode 100644 src/tui/legacy-conhost.test.ts create mode 100644 src/tui/legacy-conhost.ts 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/tui-command.ts b/src/tui/tui-command.ts index 8323c672..ae5cfeeb 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, @@ -665,6 +666,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 From dab833b2be6cf3dc785d4a87ced31e53e36f8dad Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 17:21:55 +0300 Subject: [PATCH 10/20] fix(llm): stop cutting the live reasoning stream at a brace in the CoT Reported on Discord: the TUI stops showing reasoning tokens partway through long turns. The mechanism is in the grammar stream parser, not the TUI ring buffer: while inside a think block, the first bare "{" or "[" in the buffer was treated as the start of the tool-call payload, which emitted reasoning_close and silenced every later reasoning delta of that step. Chain-of-thought that mentions JSON, code or array notation hits this almost immediately, so the live reasoning display froze until the step's final canonical reasoning event replaced it. The close-sentinel-less handoff the early exit exists for (pre-opened think, model goes straight to the payload) is preserved: a brace now ends the reasoning stream only when it actually begins a '{"tool": "' payload (optional array opener allowed). A candidate cut off by a chunk boundary ('{"to') is held back until it resolves, with a 64-char probe cap so the holdback - and the buffered memory - stays bounded; anything that provably is not a payload streams on as reasoning. Stream end treats an unresolved candidate as reasoning too. Five of the six new parser tests fail without the fix; the sixth pins the new holdback behaviour at chunk boundaries. Co-Authored-By: Claude Fable 5 --- src/llm/grammar/stream-parser.test.ts | 65 +++++++++++++++ src/llm/grammar/stream-parser.ts | 114 +++++++++++++++++++++++--- 2 files changed, 167 insertions(+), 12 deletions(-) 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) { From 81f2fa6a5606783f07bc1c7f3e72a5c4645cd8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 17:25:36 +0300 Subject: [PATCH 11/20] feat(local-llm): CPU llama.cpp fallback for Windows boxes whose GPU build cannot serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows machines without a working GPU compute stack — typically iGPU-only boxes like the AMD 5600G — were unrescuable: backend variant selection only knew the Vulkan and CUDA turboquant zips, so when the Vulkan build crashed or hung on model load there was no CPU build to reach and no way to ask for one. - windows-backend-variant: register the llama-turboquant-windows-x64-cpu.zip asset the nightly repo already publishes, plus a configured-variant preference (auto | cpu | vulkan | cuda-12.4 | cuda-13.3) that bypasses the nvidia-smi probe and its process-wide cache. - config v46: localModels.managed.backendVariant, default "auto"; pushed into the local-llm layer from loadConfig the same way setCustomLocalModels is. - daemon-lifecycle: the chat health-wait failure is now a typed DaemonHealthError, distinguishing "this compute backend cannot serve on this machine" from pre-spawn failures a backend swap cannot fix. - cpu-backend-fallback: shouldFallBackToCpuBackend (pure eligibility: win32 + variant auto + GPU asset installed + DaemonHealthError) and fallBackToCpuBackend (flip preference, stop the half-started daemon, re-download). - TUI start and CLI `models start` both fall back automatically with a surfaced message, persist backendVariant "cpu" so the next auto-update's variant-staleness check cannot reinstall the broken GPU build, and retry the start once on the CPU build. Reported on Discord (l.hk, Win10, AMD 5600G iGPU, still broken on v0.4.2). Co-Authored-By: Claude Fable 5 --- README.md | 2 + src/cli/models-handlers.ts | 78 ++++++++- src/config/config-schema.test.ts | 29 ++++ src/config/config-schema.ts | 48 +++++- src/config/load-config.ts | 5 + src/local-llm/backend-installer.test.ts | 35 ++++ src/local-llm/cpu-backend-fallback.test.ts | 151 ++++++++++++++++++ src/local-llm/cpu-backend-fallback.ts | 73 +++++++++ src/local-llm/daemon-lifecycle.test.ts | 71 +++++++- src/local-llm/daemon-lifecycle.ts | 17 +- src/local-llm/index.ts | 15 +- src/local-llm/windows-backend-variant.test.ts | 74 +++++++++ src/local-llm/windows-backend-variant.ts | 78 ++++++++- .../local-models/local-models-orchestrator.ts | 72 +++++++++ 14 files changed, 738 insertions(+), 10 deletions(-) create mode 100644 src/local-llm/cpu-backend-fallback.test.ts create mode 100644 src/local-llm/cpu-backend-fallback.ts diff --git a/README.md b/README.md index e27c25d6..9b9ff06a 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,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 diff --git a/src/cli/models-handlers.ts b/src/cli/models-handlers.ts index 540d6408..7e518a12 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"; @@ -308,8 +311,8 @@ export async function runLocalModelsStart(): Promise { `device: ${describeDeviceChoice(cfg.localModels.managed.device, device)}\n`, ); - try { - const result = await startChatAndEmbeddingDaemons({ + const startWithDevice = (dev: string | undefined) => + startChatAndEmbeddingDaemons({ chat: { dataDir, modelId: mid, @@ -317,7 +320,7 @@ export async function runLocalModelsStart(): Promise { contextSize: cfg.localModels.managed.contextSize, ...(tpl ? { chatTemplateFile: tpl } : {}), ...(mmprojFile ? { mmprojFile } : {}), - ...(device ? { device } : {}), + ...(dev ? { device: dev } : {}), }, ...(embRequested && embReady ? { @@ -325,11 +328,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 +401,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); diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index b3af5574..4fc52b05 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -734,6 +734,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); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index cf9403c7..7b9f611d 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, @@ -945,6 +950,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 v46; older files transparently get `"auto"`. + */ + backendVariant: BackendVariantPreference; /** * llama-server context window (`--ctx-size`) for the managed chat * daemon. @@ -1599,7 +1620,14 @@ export interface UserConfigFile { // implied. (It was drafted as a second v44, but v44 was already spent on // `customModels` in the same release — the stamp ships as v45 so the two // additive changes keep distinct numbers.) -export const USER_CONFIG_VERSION = 45; +// v46: 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. +export const USER_CONFIG_VERSION = 46; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1733,6 +1761,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 42, 43, 44, + 45, USER_CONFIG_VERSION, ]; @@ -1749,6 +1778,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { autoUpdate: true, stopOnExit: true, device: "auto", + backendVariant: "auto", contextSize: 0, }, embeddings: { @@ -2086,6 +2116,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, @@ -3098,6 +3139,11 @@ 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, diff --git a/src/config/load-config.ts b/src/config/load-config.ts index f8b750c2..0439a973 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( 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..142fe462 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", @@ -371,3 +389,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..bb86d41e 100644 --- a/src/local-llm/daemon-lifecycle.ts +++ b/src/local-llm/daemon-lifecycle.ts @@ -260,6 +260,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 +290,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}`, ); } 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/tui/local-models/local-models-orchestrator.ts b/src/tui/local-models/local-models-orchestrator.ts index 8ca8c915..4d4bc84e 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, @@ -986,6 +989,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") { @@ -1112,6 +1120,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 +1139,60 @@ 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`, + }); + } + try { + await fallBackToCpuBackend(dataDir); + } 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_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. From be76a7a29ff1cdb6084f04fc48c0c1fefc9ffda2 Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 17:33:14 +0300 Subject: [PATCH 12/20] =?UTF-8?q?docs:=20add=20MEMORY=5FGUIDE.md=20?= =?UTF-8?q?=E2=80=94=20memory=20end=20to=20end,=20with=20worked=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engaged users on Discord report the memory subsystem is opaque enough to block commitment to the tool. The existing MEMORY.md is an engineering source-of-truth, not an explainer. Add MEMORY_GUIDE.md, an operator-facing walkthrough verified against src/memory and src/tools/memory: - the five stores (profile facts, notes, links, lessons, procedures) and the two automatic writers (reflection, consolidator) - where the SQLite lives and how to inspect it with sqlite3 - how recall reaches the prompt (### profile/lessons/procedures/ memory-index/recalled, pointer-first with drill-down tools) - three worked example transcripts: a profile fact forming and gating, a note recalled a week later, a lesson distilled from a note cluster - inspecting via the TUI Memory tab and /memory dump - forgetting, per-layer master switches, and the full-wipe recipe - OBSIDIAN_VAULT_PATH and the obsidian starter skill vs agent memory Link it from README (memory section + core docs list) and from the MEMORY.md complements list. Reported on Discord: https://discord.com/channels/1515649306781155428/1515649308161085612/1542988707907248148 Co-Authored-By: Claude Fable 5 --- MEMORY.md | 1 + MEMORY_GUIDE.md | 406 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 5 +- 3 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 MEMORY_GUIDE.md 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..da92f7e2 --- /dev/null +++ b/MEMORY_GUIDE.md @@ -0,0 +1,406 @@ +# 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 (BM25 similarity above + `memory.dedup.fts5Threshold`, 0.85) is merged instead of inserted. +- **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` | `* [tags] activation` one-liners | `memory.lessons.recall { id }` | +| `### procedures` | `> [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. 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, every prompt carries a one-line pointer: + +``` +### lessons +*4 [playwright] When a Playwright click flakes, prefer role-based locators over CSS selectors +``` + +and when the topic actually comes up, the agent drills in: + +``` +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..07d6f0a3 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
@@ -643,7 +645,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 From 0d27ae3cf77137ebe22e2475db5f9f748fc9cd71 Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 17:37:07 +0300 Subject: [PATCH 13/20] feat(tui): free/paid price facet on the cloud model pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cloud catalogs run past 300 rows and the only way to shop by price was typing 'free' into the ranked search, which also subsequence-matches unrelated ids. Both cloud model list surfaces now carry a price facet next to their existing search, cycled with p: all → free → paid → all. - Shared predicate in src/llm/provider/model-pricing-filter.ts: a row is free only when its catalog pricing renders the "free" tag (both prices zero; openrouter/auto stays out — its tag reads "routed" and it bills the routed model). Rows without pricing metadata (aimlapi, live /v1/models ids) cannot be promised free and stay under paid, so the two facets partition the catalog and no row vanishes from both. - LLM pane, Cloud text models: p cycles the facet (visible on a new price: line under filter:, with the key hint); while the filter row is focused p stays query text. Facet flips snap the cursor to the top of the result set, same rule as filter edits. - Providers wizard, curated chat-model screen: p cycles while the search box is closed; the active facet rides on the title ("· free only") and the hint line names the key. advanceWizardPhase resets the facet with the search, so it never leaks into the embedding screen — and the pane and wizard keep separate facet state, so neither screen leaks into the other. Default is all everywhere. Tests: predicate unit tests; wizard key tests against a live-seeded mixed catalog (narrow, cycle, cursor snap, Enter-selects-narrowed-row, no leak into pick_embedding, p-as-query-text with the box open); CloudRows component tests (price line + hint, free/paid narrowing with the filtered-of-total counter); pane key + reducer tests. All 10 new behavioral tests fail with the implementation stashed. Reported on Discord: https://discord.com/channels/1515649306781155428/1515650562430079048/1536840031329853652 Co-Authored-By: Claude Fable 5 --- src/llm/provider/model-pricing-filter.test.ts | 95 ++++++++++++++++ src/llm/provider/model-pricing-filter.ts | 62 +++++++++++ .../components/llm-mode-rows-cloud.test.tsx | 73 ++++++++++++ src/tui/components/llm-mode-rows.tsx | 20 +++- src/tui/components/providers-wizard.tsx | 14 ++- src/tui/llm-panel/llm-panel-actions.ts | 7 +- .../llm-panel/llm-panel-key-bindings.test.ts | 39 +++++++ src/tui/llm-panel/llm-panel-key-bindings.ts | 9 ++ src/tui/llm-panel/llm-panel-reducer.test.ts | 22 ++++ src/tui/llm-panel/llm-panel-reducer.ts | 20 ++++ src/tui/llm-panel/llm-panel-row-builders.ts | 9 +- src/tui/llm-panel/llm-panel-state.ts | 10 ++ .../providers-wizard-key-bindings.test.ts | 104 ++++++++++++++++++ .../providers-wizard-key-bindings.ts | 17 +++ src/tui/providers/providers-wizard-phases.ts | 39 ++++++- src/tui/providers/providers-wizard-state.ts | 9 ++ 16 files changed, 536 insertions(+), 13 deletions(-) create mode 100644 src/llm/provider/model-pricing-filter.test.ts create mode 100644 src/llm/provider/model-pricing-filter.ts 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/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/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/llm-panel/llm-panel-actions.ts b/src/tui/llm-panel/llm-panel-actions.ts index 2d0dda0f..b057c49c 100644 --- a/src/tui/llm-panel/llm-panel-actions.ts +++ b/src/tui/llm-panel/llm-panel-actions.ts @@ -13,7 +13,9 @@ export type LlmPanelAction = /** 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 }, @@ -28,6 +30,7 @@ export function isLlmPanelAction( action.type === "llm_stop_local_daemons_prompt_closed" || action.type === "llm_external_url_draft_set" || 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-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-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..f94a7979 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"; @@ -117,6 +118,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..6c471ac5 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 @@ -46,6 +48,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 { @@ -60,6 +69,7 @@ export function createInitialLlmPanelState(): LlmPanelState { externalUrlDraft: null, cloudModelFilter: "", cloudModelFilterFocused: false, + cloudModelPricing: "all", }; } 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 ?? "") : "", From 8f158fa4ecfecdd3932979f5331a58c386e4ea9b Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 17:38:41 +0300 Subject: [PATCH 14/20] fix(tui): keep ssh-mangled mouse reports out of the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported on Discord: ssh from a Mac into the TUI and the mouse layer "spams coordinates" as visible text while the app looks hung. Four layers of hardening: - mouse-stdin: hold a chunk-final lone ESC for 10ms. An ssh hop re-chunks the stream, and a report split right after its ESC used to type `[<64;3;9M` into the composer — the reported spam. If the rest of a report follows it rejoins and decodes; if nothing does, it was the Escape key and flushes (under Ink's own ~20ms lone-Esc deferral). - decoder: consume urxvt/1015 reports instead of dropping them through as text, buffer truncated CSI heads so split sequences reach Ink whole, and document + cover that 1005 already lands in the X10 branch. - leak breaker: report-shaped text about to reach Ink (a burst in one read, or three drips across the session, counted across read boundaries) trips once — strips the shapes, disables tracking for the session only, and posts a warn notice naming /mouse and --no-mouse. - signals: a second SIGINT/SIGTERM/SIGHUP now restores the terminal (mouse reporting off, alt screen left) and exits 130 instead of Node's default kill that skips exit hooks and leaves the shell printing coordinates on every click. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- src/tui/mouse/mouse-stdin.test.ts | 127 ++++++++++++++++++- src/tui/mouse/mouse-stdin.ts | 148 ++++++++++++++++++++++- src/tui/mouse/parse-mouse-events.test.ts | 52 ++++++++ src/tui/mouse/parse-mouse-events.ts | 47 ++++++- src/tui/signal-escalation.test.ts | 48 ++++++++ src/tui/signal-escalation.ts | 48 ++++++++ src/tui/tui-command.mouse.test.ts | 71 ++++++++++- src/tui/tui-command.ts | 46 +++++-- 9 files changed, 569 insertions(+), 20 deletions(-) create mode 100644 src/tui/signal-escalation.test.ts create mode 100644 src/tui/signal-escalation.ts diff --git a/README.md b/README.md index e27c25d6..d8a13466 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,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, which means its own drag-to-select is unavailable (iTerm2, GNOME Terminal and Windows Terminal let you hold Shift to bypass; Apple Terminal does not). Turn it off whenever you want to select text: `/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, which means its own drag-to-select is unavailable (iTerm2, GNOME Terminal and Windows Terminal let you hold Shift to bypass; Apple Terminal does not). Turn it off whenever you want to select text: `/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. diff --git a/src/tui/mouse/mouse-stdin.test.ts b/src/tui/mouse/mouse-stdin.test.ts index ddf22ef4..226f00da 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,122 @@ 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("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..3538c95c 100644 --- a/src/tui/mouse/mouse-stdin.ts +++ b/src/tui/mouse/mouse-stdin.ts @@ -12,11 +12,60 @@ * 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; + +/** + * Remnants in a single chunk before the breaker trips. One shape alone + * could be a paste that happens to contain it; a terminal misreporting + * the mouse produces bursts. + */ +const LEAK_TRIP_COUNT = 2; +/** + * Remnants across the whole session before the breaker trips anyway. A + * lossy link can stall mid-report for longer than the ESC-split hold, + * leaking one report per stall — never two in a chunk, but not a paste + * either by the third time. + */ +const LEAK_TRIP_TOTAL = 3; + export interface MouseStdin { /** Stream to hand to Ink's `render({ stdin })` — mouse bytes removed. */ readonly stdin: NodeJS.ReadStream; @@ -24,6 +73,23 @@ export interface MouseStdin { dispose(): void; } +export interface MouseStdinOptions { + /** + * Whether mouse reporting is currently requested. The leak breaker + * only arms itself while this returns true — report-shaped text on a + * terminal that was never asked to report is a paste, not a leak. + * Defaults to armed when `onMouseTextLeak` is provided. + */ + readonly mouseActive?: () => 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 +97,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 +126,82 @@ 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; + }; + const forwardText = (text: string): void => { + if (text.length === 0) return; + let out = text; + if (leakTripped) { + out = out.replace(REPORT_REMNANT, ""); + } 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 = out.replace(REPORT_REMNANT, ""); + 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,6 +209,11 @@ export function createMouseStdin( stdin: proxy, dispose: () => { source.off("data", onData); + if (escTimer) { + clearTimeout(escTimer); + escTimer = null; + } + escHeld = false; pending = ""; }, }; 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/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 bd94894d..45c797f0 100644 --- a/src/tui/tui-command.mouse.test.ts +++ b/src/tui/tui-command.mouse.test.ts @@ -28,9 +28,12 @@ import { } from "../config/index.js"; import type { TuiMouseEvent } from "./mouse/mouse-event.js"; import type { MouseSource } from "./mouse/mouse-source.js"; +import type { TuiAction } from "./tui-action.js"; +import type { TuiEventBus } from "./tui-app.js"; const inkRender = vi.hoisted(() => vi.fn()); const trackingCalls = vi.hoisted(() => ({ enabled: 0, disabled: 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 @@ -72,7 +75,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 {} }, @@ -101,6 +106,8 @@ interface Booted { readonly mouse: MouseSource | undefined; readonly setMouseEnabled: (next: boolean | null) => void; readonly seen: TuiMouseEvent[]; + /** Every bus action emitted after mount — system messages included. */ + readonly actions: TuiAction[]; readonly stop: () => Promise; } @@ -128,11 +135,14 @@ async function bootTui(args: string[] = []): Promise { if (props === null) throw new Error("TuiApp never rendered"); const captured = props as { mouse?: MouseSource; + bus: TuiEventBus; callbacks: { onMouseSupportRequested?: (next: boolean | null) => void }; }; const seen: TuiMouseEvent[] = []; captured.mouse?.subscribe((event) => seen.push(event)); + const actions: TuiAction[] = []; + captured.bus.subscribe((action) => actions.push(action)); const setMouseEnabled = captured.callbacks.onMouseSupportRequested; if (!setMouseEnabled) throw new Error("onMouseSupportRequested not wired"); @@ -140,6 +150,7 @@ async function bootTui(args: string[] = []): Promise { mouse: captured.mouse, setMouseEnabled, seen, + actions, stop: async () => { releaseExit(); return finished; @@ -251,4 +262,62 @@ describe("tuiCommand mouse wiring", () => { expect(app.seen).toHaveLength(1); 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 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(); + }); }); diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 8323c672..e46cf143 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -39,6 +39,8 @@ import { type MouseTrackingController, } from "./mouse/mouse-tracking.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 { @@ -238,14 +240,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 @@ -287,9 +298,30 @@ 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) mouseSource.emit(event); - }); + const mouseStdin = createMouseStdin( + process.stdin, + (event) => { + if (mouseTracking) 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({ From efc9ef112e2b75ab5552026b18b9166e138f1a04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 17:42:23 +0300 Subject: [PATCH 15/20] =?UTF-8?q?feat(cli):=20memory=20export=20=E2=80=94?= =?UTF-8?q?=20one-way=20Obsidian=20vault=20export=20of=20the=20memory=20co?= =?UTF-8?q?rpus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `atomic-agent memory` command with an `export` subcommand that mirrors the cross-session memory corpus (notes / lessons / procedures in /memory.sqlite) into an existing Obsidian vault as markdown files with YAML frontmatter (type, created, updated, tags) and [[wikilinks]] along the schema's own edges: memory_links rows, consolidated_into back-pointers, and lesson/procedure parent ids. v1 design decisions: - one-way and read-only: the database is opened readonly and never migrated; older schemas degrade gracefully (missing tables => empty) - stable id-based filenames (note-.md, ...) under a vault subfolder (--folder, default 'atomic-agent') so records keep their Obsidian identity and backlinks across re-exports - idempotent and overwrite-safe: content is a pure function of the rows, unchanged files are not rewritten, and pruning of stale files is restricted to the machine-owned -.md name patterns inside the export subfolders — user files are never touched - soft parent pointers to evicted rows are skipped, not rendered as dangling links - --vault falls back to $OBSIDIAN_VAULT_PATH (including via /.env); no watch mode, no sync-back in v1 The new command implements the 0/1/2 exit-code split (usage errors return 2). Co-Authored-By: Claude Fable 5 --- src/cli/index.ts | 12 +- src/cli/memory-command.test.ts | 122 ++++++++ src/cli/memory-command.ts | 95 +++++++ src/memory/index.ts | 9 + src/memory/obsidian-export.test.ts | 325 +++++++++++++++++++++ src/memory/obsidian-export.ts | 435 +++++++++++++++++++++++++++++ 6 files changed, 995 insertions(+), 3 deletions(-) create mode 100644 src/cli/memory-command.test.ts create mode 100644 src/cli/memory-command.ts create mode 100644 src/memory/obsidian-export.test.ts create mode 100644 src/memory/obsidian-export.ts 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/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; +} From f8106ab707aa09349195dd20684857e644f59c1b Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 17:29:51 +0300 Subject: [PATCH 16/20] feat(local-llm): multi-GPU tensor split for the managed llama-server Add localModels.managed.tensorSplit (config v46, default [] = feature off). Two or more non-negative ratios launch the managed chat daemon with --split-mode layer --tensor-split so the model's layers spread across GPUs proportionally. Previously resolveManagedDevice always pinned exactly one device (pickBestDevice), so no multi-GPU launch could ever be expressed: a pinned --device defeats --tensor-split. With a split configured, the auto device preference now leaves every GPU visible instead of pinning the best one; cpu still wins outright, and an explicit device id passes through unchanged so a comma-separated list (Vulkan0,Vulkan1) can restrict which devices join the split. models use-device now accepts that comma-separated form. Default behavior is byte-identical: with tensorSplit empty the launch args and the single-device auto-pick are unchanged, and the embedding daemon always keeps pinning one device. Ratios are validated at parse time (at least two finite non-negative numbers, at least one positive). Reported on Discord (managed llama-server multi-GPU support): https://discord.com/channels/1515649306781155428/1515649308161085612/1536440638902636574 Co-Authored-By: Claude Fable 5 --- README.md | 2 +- src/cli/models-command.test.ts | 16 +++++ src/cli/models-handlers.ts | 38 +++++++--- src/config/config-schema.test.ts | 65 +++++++++++++++++ src/config/config-schema.ts | 71 ++++++++++++++++++- src/local-llm/daemon-lifecycle.test.ts | 46 ++++++++++++ src/local-llm/daemon-lifecycle.ts | 23 +++++- src/local-llm/gpu-devices.test.ts | 60 ++++++++++++++++ src/local-llm/gpu-devices.ts | 11 +++ .../local-models/local-models-orchestrator.ts | 18 ++++- 10 files changed, 335 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index df6c27b1..c9a4f412 100644 --- a/README.md +++ b/README.md @@ -518,7 +518,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.
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.ts b/src/cli/models-handlers.ts index 540d6408..98236159 100644 --- a/src/cli/models-handlers.ts +++ b/src/cli/models-handlers.ts @@ -300,12 +300,18 @@ 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 { @@ -318,6 +324,7 @@ export async function runLocalModelsStart(): Promise { ...(tpl ? { chatTemplateFile: tpl } : {}), ...(mmprojFile ? { mmprojFile } : {}), ...(device ? { device } : {}), + ...(multiGpu ? { tensorSplit } : {}), }, ...(embRequested && embReady ? { @@ -377,15 +384,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 +410,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 +433,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 +476,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/config/config-schema.test.ts b/src/config/config-schema.test.ts index b3af5574..b1781967 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -803,6 +803,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 cf9403c7..586e0c4a 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -954,6 +954,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 v46; 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 @@ -1599,7 +1614,11 @@ export interface UserConfigFile { // implied. (It was drafted as a second v44, but v44 was already spent on // `customModels` in the same release — the stamp ships as v45 so the two // additive changes keep distinct numbers.) -export const USER_CONFIG_VERSION = 45; +// v46: 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 = 46; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1733,6 +1752,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 42, 43, 44, + 45, USER_CONFIG_VERSION, ]; @@ -1750,6 +1770,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = { stopOnExit: true, device: "auto", contextSize: 0, + tensorSplit: [], }, embeddings: { enabled: false, @@ -2488,6 +2509,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]$/; /** @@ -3103,6 +3168,10 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { USER_CONFIG_DEFAULTS.localModels.managed.contextSize, "localModels.managed.contextSize", ), + tensorSplit: parseTensorSplit( + rawManaged.tensorSplit, + "localModels.managed.tensorSplit", + ), }; const rawEmbeddings = diff --git a/src/local-llm/daemon-lifecycle.test.ts b/src/local-llm/daemon-lifecycle.test.ts index b918efab..afab9e63 100644 --- a/src/local-llm/daemon-lifecycle.test.ts +++ b/src/local-llm/daemon-lifecycle.test.ts @@ -154,6 +154,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( { diff --git a/src/local-llm/daemon-lifecycle.ts b/src/local-llm/daemon-lifecycle.ts index 6f299079..76284926 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); } @@ -301,7 +315,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/tui/local-models/local-models-orchestrator.ts b/src/tui/local-models/local-models-orchestrator.ts index 8ca8c915..799fe59e 100644 --- a/src/tui/local-models/local-models-orchestrator.ts +++ b/src/tui/local-models/local-models-orchestrator.ts @@ -74,14 +74,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; } @@ -1053,16 +1059,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 +1092,7 @@ export class LocalModelsOrchestrator { mmprojFile, contextSize: cfg.localModels.managed.contextSize, ...(device ? { device } : {}), + ...(multiGpu ? { tensorSplit } : {}), }, embedding: embedding ? { ...embedding, ...(device ? { device } : {}) } From 47defe6e70be11943bb0f0f43ed7981979775c4f Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 21:25:11 +0300 Subject: [PATCH 17/20] fix(tui): match the compat-steer's key precedence to its render order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steer prompt's key branch sat before the External URL draft's, while LlmPanelModals renders the draft first. Both could be non-null — the steer opens asynchronously after the save's /health probe, and Enter on the External row reopens the draft in that window — leaving the visible editor keyboard-dead while y invisibly opened the wizard. The handler now checks the steer after the draft (matching what is on screen), and the reducer refuses to open the steer under an open draft in the first place. Also: the vacuous swallow test now proves its baseline, and the :11434 refusal text routes a remote Ollama through the manual compat row (the preset row has no base-URL screen, so by hand it could only save localhost). Co-Authored-By: Claude Fable 5 --- src/llm/describe-llama-health-failure.test.ts | 20 ++++++- src/llm/describe-llama-health-failure.ts | 31 ++++++++--- src/tui/llm-panel/llm-panel-external.test.ts | 52 +++++++++++++++---- .../llm-panel/llm-panel-modal-key-bindings.ts | 49 +++++++++-------- src/tui/llm-panel/llm-panel-reducer.ts | 7 +++ 5 files changed, 118 insertions(+), 41 deletions(-) diff --git a/src/llm/describe-llama-health-failure.test.ts b/src/llm/describe-llama-health-failure.test.ts index 984271f7..271e77dc 100644 --- a/src/llm/describe-llama-health-failure.test.ts +++ b/src/llm/describe-llama-health-failure.test.ts @@ -34,8 +34,24 @@ describe("describeLlamaHealthFailure", () => { result({ kind: "openai-compat", status: 404, error: "http 404" }), "http://127.0.0.1:11434", ); - expect(line).toContain("Ollama"); - expect(line).toContain("base URL 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", () => { diff --git a/src/llm/describe-llama-health-failure.ts b/src/llm/describe-llama-health-failure.ts index c6277f1f..057ac566 100644 --- a/src/llm/describe-llama-health-failure.ts +++ b/src/llm/describe-llama-health-failure.ts @@ -1,4 +1,8 @@ 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 @@ -33,13 +37,26 @@ export function describeLlamaHealthFailure( // 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. - return looksLikeOllamaUrl(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 an OpenAI-compatible server, not llama.cpp. ` + - `Add it as a cloud provider instead: LLM tab › Cloud › n › ` + - `openai-compatible, base URL ${url}.`; + 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 › ` + + `openai-compatible, base URL ${url}.` + ); case "llama-loading": return ( `${url} is a llama.cpp server still loading its model. ` + diff --git a/src/tui/llm-panel/llm-panel-external.test.ts b/src/tui/llm-panel/llm-panel-external.test.ts index b3e64f0a..6d18cdfd 100644 --- a/src/tui/llm-panel/llm-panel-external.test.ts +++ b/src/tui/llm-panel/llm-panel-external.test.ts @@ -271,15 +271,47 @@ describe("openai-compat steer prompt", () => { }); it("swallows panel hotkeys while the prompt is open", () => { - // `s` is the daemon start/stop hotkey outside the modal. - const onStop = vi.fn(); - const dispatched = press( - "s", - emptyKey(), - steerState(), - callbacks({ onLocalModelsDaemonStopRequested: onStop }), - ); - expect(dispatched).toEqual([]); - expect(onStop).not.toHaveBeenCalled(); + // 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-modal-key-bindings.ts b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts index c1169c9f..14cc0de8 100644 --- a/src/tui/llm-panel/llm-panel-modal-key-bindings.ts +++ b/src/tui/llm-panel/llm-panel-modal-key-bindings.ts @@ -172,28 +172,6 @@ export function handleLlmModalKey( return true; } - 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; - } - const urlDraft = state.llmPanel.externalUrlDraft; if (urlDraft !== null) { if (key.escape) { @@ -223,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.ts b/src/tui/llm-panel/llm-panel-reducer.ts index 09ad4482..d7b64051 100644 --- a/src/tui/llm-panel/llm-panel-reducer.ts +++ b/src/tui/llm-panel/llm-panel-reducer.ts @@ -72,6 +72,13 @@ export function reduceLlmPanelAction( 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 }, From e0839015789f28d60f697213b4779abab1d7134a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 21:27:01 +0300 Subject: [PATCH 18/20] docs: fix lesson-recall model and dedup metric in MEMORY_GUIDE Two claims did not survive review against the code: - Worked example 3 said every prompt carries the lesson pointer. ### lessons / ### procedures are query-gated: the per-turn recall query (user message + recent tool-result summaries) is BM25-matched against activation/principle/tags and only the top recallK hits (2 each) render; the unconditional list is the TUI Memory tab. State this in the prompt-sections chapter and in the example. - The dedup bullet called the 0.85 threshold a BM25 similarity. FTS5 only fetches the candidates; the threshold is compared against a Jaccard token-overlap similarity computed in JS (memory-store.ts jaccardSimilarity). Co-Authored-By: Claude Fable 5 --- MEMORY_GUIDE.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/MEMORY_GUIDE.md b/MEMORY_GUIDE.md index da92f7e2..e4934058 100644 --- a/MEMORY_GUIDE.md +++ b/MEMORY_GUIDE.md @@ -136,8 +136,10 @@ ignores them. ### Housekeeping you get for free -- **Dedup** — a near-duplicate of an existing note (BM25 similarity above - `memory.dedup.fts5Threshold`, 0.85) is merged instead of inserted. +- **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 @@ -175,8 +177,8 @@ 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` | `* [tags] activation` one-liners | `memory.lessons.recall { id }` | -| `### procedures` | `> [tags] activation` one-liners | `memory.procedures.recall { id }` | +| `### 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 }` | @@ -191,9 +193,17 @@ Two gates keep the tail small: 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. 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. +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 @@ -314,14 +324,17 @@ older than 24 hours — the cluster is distilled in a single LLM call: - notes #31/#38/#44 are archived: gone from `### memory-index`, still readable by id. -From then on, every prompt carries a one-line pointer: +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 when the topic actually comes up, the agent drills in: +and the agent drills in for the full principle: ``` you › the checkout e2e test is flaky again on the pay button From 5d0a242e8b810f72c7994dc13b54fccb4e0eb5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D0=BB=D0=B5=D1=80=D0=B8=D0=B9=20=D0=91=D1=80?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8E=D0=BA?= Date: Mon, 31 Aug 2026 21:34:27 +0300 Subject: [PATCH 19/20] fix(local-llm): give the TUI CPU-backend fallback download a deadline and progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI path of the CPU fallback called fallBackToCpuBackend(dataDir) bare: no AbortSignal, no onProgress. download-file.ts has no default timeout, so a stalled-open connection pinned the start on phase 'starting' for the life of the process, with zero feedback during the 27-39MB download — the exact hazard the auto-update path (and this feature's own CLI path) already guards against. The download now runs with AbortSignal.timeout(BACKEND_DOWNLOAD_TIMEOUT_MS) and surfaces as a regular backend pull (started/progress/finished/failed) on the bus. Also covers the fallback WIRING with integration tests — previously only the extracted pure pieces were tested, so deleting either caller's retry block left the suite green: - local-models-orchestrator-cpu-fallback.test.ts: eligible health failure swaps + persists + retries exactly once; recursion cap; failed download reported; non-health and cpu-installed cases inert; the download carries a deadline and live progress. - models-handlers.test.ts: CLI retry lands on device 'cpu' (no stale --device Vulkan0), persists the variant, forwards signal/progress, pre-spawn failures and failed downloads exit non-zero untouched. Co-Authored-By: Claude Fable 5 --- src/cli/models-handlers.test.ts | 167 ++++++++++++ ...l-models-orchestrator-cpu-fallback.test.ts | 244 ++++++++++++++++++ .../local-models/local-models-orchestrator.ts | 30 ++- 3 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 src/cli/models-handlers.test.ts create mode 100644 src/tui/local-models/local-models-orchestrator-cpu-fallback.test.ts 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/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 4d4bc84e..a15fdd7d 100644 --- a/src/tui/local-models/local-models-orchestrator.ts +++ b/src/tui/local-models/local-models-orchestrator.ts @@ -1174,11 +1174,39 @@ export class LocalModelsOrchestrator { 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); + 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; From 869bcd3d3b679883bac01ac43dc9d6a4e650f1bb Mon Sep 17 00:00:00 2001 From: Valerii Date: Mon, 31 Aug 2026 21:47:02 +0300 Subject: [PATCH 20/20] fix(tui): join reads in the post-trip leak stripper too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review proved a hole in the tripped path: forwardText ran the remnant replace per-chunk, so a straggler split across reads — the exact ssh re-chunking this PR exists to survive — still leaked into the composer after the breaker tripped (write `[<0;9` then `;9M` and Ink received `[<0;9;9M` verbatim), contradicting the "keeps stripping the in-flight stragglers" contract. Unlike the pre-trip counter, which scans an already-forwarded tail, the stripper has to keep the bytes out of Ink — so it withholds a chunk-final remnant *prefix* until the rest arrives (stragglers trail each other by well under a millisecond) or a 10ms timer rules it ordinary typing, mirroring the ESC-split hold. Four new tests cover the split straggler, a byte-at-a-time straggler, a partial straggler on the tripping chunk itself, and the timer releasing withheld typing; all four fail without the fix. Co-Authored-By: Claude Fable 5 --- src/tui/mouse/mouse-stdin.test.ts | 67 +++++++++++++++++++++++++++++++ src/tui/mouse/mouse-stdin.ts | 45 ++++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/tui/mouse/mouse-stdin.test.ts b/src/tui/mouse/mouse-stdin.test.ts index 226f00da..509b4a00 100644 --- a/src/tui/mouse/mouse-stdin.test.ts +++ b/src/tui/mouse/mouse-stdin.test.ts @@ -127,6 +127,73 @@ describe("createMouseStdin", () => { 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 diff --git a/src/tui/mouse/mouse-stdin.ts b/src/tui/mouse/mouse-stdin.ts index 3538c95c..e5ffce8c 100644 --- a/src/tui/mouse/mouse-stdin.ts +++ b/src/tui/mouse/mouse-stdin.ts @@ -52,6 +52,12 @@ export const ESC_SPLIT_FLUSH_MS = 10; 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 = /\[ { + 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 = out.replace(REPORT_REMNANT, ""); + 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 = out.replace(REPORT_REMNANT, ""); + out = stripRemnants(out); options.onMouseTextLeak(); } } @@ -213,8 +249,13 @@ export function createMouseStdin( clearTimeout(escTimer); escTimer = null; } + if (stripTimer) { + clearTimeout(stripTimer); + stripTimer = null; + } escHeld = false; pending = ""; + stripHold = ""; }, }; }