From ec0316a26e6d859c2c28e316b8ce902fb50f473f Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 04:19:45 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(config):=20llm.runMode=20block=20?= =?UTF-8?q?=E2=80=94=20local=20|=20cloud=20|=20fusion=20with=20a=20fusion?= =?UTF-8?q?=20cloud-share=20dial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the config foundation for an operator run mode without changing any behaviour: nothing calls `resolveRunMode` outside its tests yet. `llm.runMode` sits beside `llm.fallback` rather than under `agent` (loop budgets) or at the top level: `llm` has a single writer, and the mode and `activeTextProvider` must move in one write + cache-reset cycle or they can disagree. `activeTextProvider` stays authoritative — `runMode.mode` is additive. The effective mode is derived from which provider is active, and a stored `fusion` is honoured only when the cloud leg is the active one. So an operator who switches provider by hand in Manage → LLM simply drops out of fusion on the next read: no reconciliation step, and no state that lies about what is running. It also means fusion pins the cloud provider as the fallback chain's primary, so `resolveFallbackChain` hoists it to the head and appends local at the tail with no changes of its own. `fusion.cloudShare` is documented as a dial, not a quota: it moves the cutoff on a bounded per-step complexity score rather than promising that N% of steps reach the cloud. Degradation is explicit and reported rather than silent — cloud/fusion without a cloud provider stays local, fusion without a local provider runs cloud-only, and a pinned `toolTransport` warns without downgrading. USER_CONFIG_VERSION 37 → 38. No migration code: `runMode` is an optional sub-key of an already-optional block, so absence is exactly the v37 behaviour; the bump only records the schema change. Verified: npm run lint and npm run build clean; npm test 4094 passed / 8 failed, the same 6 files / 8 tests that already fail on main @ 667dae1 (stale banner + tui-app fixtures, the `localModels.embeddings.url` fixture, a dev-machine-specific fs-glob path, and send-message-concurrency). --- src/config/config-schema.test.ts | 8 + src/config/config-schema.ts | 17 +- src/config/index.ts | 8 + src/config/llm-config.ts | 17 +- src/config/llm-run-mode-config.test.ts | 107 +++++++++++ src/config/llm-run-mode-config.ts | 170 ++++++++++++++++++ src/config/load-config.ts | 1 + src/llm/index.ts | 9 + src/llm/provider/registry/provider-types.ts | 7 + src/llm/run-mode/index.ts | 7 + src/llm/run-mode/resolve-run-mode.test.ts | 152 ++++++++++++++++ src/llm/run-mode/resolve-run-mode.ts | 131 ++++++++++++++ src/llm/run-mode/run-mode-degradation.test.ts | 44 +++++ src/llm/run-mode/run-mode-degradation.ts | 23 +++ 14 files changed, 695 insertions(+), 6 deletions(-) create mode 100644 src/config/llm-run-mode-config.test.ts create mode 100644 src/config/llm-run-mode-config.ts create mode 100644 src/llm/run-mode/index.ts create mode 100644 src/llm/run-mode/resolve-run-mode.test.ts create mode 100644 src/llm/run-mode/resolve-run-mode.ts create mode 100644 src/llm/run-mode/run-mode-degradation.test.ts create mode 100644 src/llm/run-mode/run-mode-degradation.ts diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 2f72bcf5..848ec16d 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -992,4 +992,12 @@ describe("parseUserConfigFile", () => { }), ).toThrow(/timeoutMs/); }); + it("upgrades a v37 file to the current version untouched", () => { + // v38 only ADDED the optional `llm.runMode` sub-key, so a v37 file + // needs no migration code — absence already is the v37 behaviour. + const parsed = parseUserConfigFile({ version: 37 }); + expect(parsed.version).toBe(USER_CONFIG_VERSION); + expect(USER_CONFIG_VERSION).toBe(38); + expect(parsed.llm?.runMode).toBeUndefined(); + }); }); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index e1102371..a146d807 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -4,6 +4,7 @@ import { parseUserLlmFileConfig, type UserLlmFileConfig, } from "./llm-config.js"; +import type { UserLlmRunModeConfig } from "./llm-run-mode-config.js"; export type { ApprovalLevel } from "../approval/approval-level.js"; import type { DotenvLoadResult } from "./load-dotenv.js"; @@ -768,6 +769,14 @@ export interface AtomicAgentConfig { probeThrottleMs?: number; failureWindowMs?: number; }; + /** + * Operator run mode: `local` (llama-server only), `cloud` (cloud + * provider only) or `fusion` (cloud orchestrates, local executes). + * `activeTextProvider` stays authoritative — this block is additive + * and is reconciled by `resolveRunMode`. See AGENTS.md §"Run modes + * (Local / Cloud / Fusion)". + */ + runMode?: UserLlmRunModeConfig; }; } @@ -1412,7 +1421,12 @@ export interface UserConfigFile { // is absent, a legacy `approvalRequired: false` maps to level 5 and // `true`/absent maps to level 1 — both preserve the old behaviour // exactly. The legacy key is never written back. -export const USER_CONFIG_VERSION = 37 as const; +// v38: new optional `llm.runMode` block — the operator run mode +// (`local` | `cloud` | `fusion`) plus the fusion cloud-share dial and +// the sub-runner target. Absence IS the v37 behaviour: it is an +// optional sub-key of an already-optional block, so no migration code +// exists; the bump only records the schema change. +export const USER_CONFIG_VERSION = 38 as const; /** * Config v21+ flips the full memory-v2 fabric on by default. Upgrades @@ -1529,6 +1543,7 @@ const SUPPORTED_INPUT_VERSIONS: readonly number[] = [ 34, 35, 36, + 37, USER_CONFIG_VERSION, ]; diff --git a/src/config/index.ts b/src/config/index.ts index 312d7bcb..b9073de1 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -41,6 +41,14 @@ export { type UserLlmFallbackConfig, type UserLlmProviderEntry, } from "./llm-config.js"; +export { + DEFAULT_FUSION_CLOUD_SHARE, + parseLlmRunModeConfig, + type RunModeName, + type RunModeSubRunners, + type UserLlmFusionConfig, + type UserLlmRunModeConfig, +} from "./llm-run-mode-config.js"; export type { DotenvLoadResult, DotenvReadFailure, diff --git a/src/config/llm-config.ts b/src/config/llm-config.ts index ed3f3a6c..60abc59d 100644 --- a/src/config/llm-config.ts +++ b/src/config/llm-config.ts @@ -1,4 +1,8 @@ import { ConfigValidationError } from "./config-validation-error.js"; +import { + parseLlmRunModeConfig, + type UserLlmRunModeConfig, +} from "./llm-run-mode-config.js"; export type UserLlmToolTransport = "auto" | "grammar" | "native_tools"; @@ -53,6 +57,7 @@ export type UserLlmFileConfig = { toolTransport: UserLlmToolTransport; providers: UserLlmProviderEntry[]; fallback?: UserLlmFallbackConfig; + runMode?: UserLlmRunModeConfig; }; const PROVIDER_ID_RE = /^[a-z][a-z0-9-]{0,31}$/; @@ -343,14 +348,15 @@ export function parseUserLlmFileConfig( "expected auto|grammar|native_tools", ); } + const providerIds = new Set(providers.map((p) => p.id)); const fallback = obj.fallback === undefined || obj.fallback === null ? undefined - : parseLlmFallbackConfig( - obj.fallback, - new Set(providers.map((p) => p.id)), - "llm.fallback", - ); + : parseLlmFallbackConfig(obj.fallback, providerIds, "llm.fallback"); + const runMode = + obj.runMode === undefined || obj.runMode === null + ? undefined + : parseLlmRunModeConfig(obj.runMode, providerIds, "llm.runMode"); return { activeTextProvider, @@ -358,5 +364,6 @@ export function parseUserLlmFileConfig( toolTransport: toolTransportRaw, providers, ...(fallback ? { fallback } : {}), + ...(runMode ? { runMode } : {}), }; } diff --git a/src/config/llm-run-mode-config.test.ts b/src/config/llm-run-mode-config.test.ts new file mode 100644 index 00000000..81836607 --- /dev/null +++ b/src/config/llm-run-mode-config.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { parseUserConfigFile, USER_CONFIG_VERSION } from "./config-schema.js"; +import { DEFAULT_FUSION_CLOUD_SHARE } from "./llm-run-mode-config.js"; + +/** Two-provider file (one local leg, one cloud leg) plus a runMode block. */ +const withRunMode = (runMode: unknown) => ({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "openrouter", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + { id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" }, + ], + runMode, + }, +}); + +describe("llm-run-mode-config", () => { + it("round-trips a full runMode block", () => { + const parsed = parseUserConfigFile( + withRunMode({ + mode: "fusion", + localProvider: "local-llama", + cloudProvider: "openrouter", + fusion: { cloudShare: 65, subRunners: "follow" }, + }), + ); + expect(parsed.llm?.runMode).toEqual({ + mode: "fusion", + localProvider: "local-llama", + cloudProvider: "openrouter", + fusion: { cloudShare: 65, subRunners: "follow" }, + }); + }); + + it("omits runMode entirely when not configured", () => { + const parsed = parseUserConfigFile({ + version: USER_CONFIG_VERSION, + llm: { + activeTextProvider: "local-llama", + activeEmbeddingProvider: "local-llama", + toolTransport: "auto", + providers: [ + { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:19091" }, + ], + }, + }); + expect(parsed.llm?.runMode).toBeUndefined(); + }); + + it("accepts a bare mode and leaves the dial to its default", () => { + const parsed = parseUserConfigFile(withRunMode({ mode: "local" })); + expect(parsed.llm?.runMode).toEqual({ mode: "local" }); + // The default is applied by `resolveRunMode`, never written into the + // file — an absent dial must stay absent so the default can move. + expect(parsed.llm?.runMode?.fusion).toBeUndefined(); + expect(DEFAULT_FUSION_CLOUD_SHARE).toBe(40); + }); + + it("rejects an unknown mode", () => { + expect(() => parseUserConfigFile(withRunMode({ mode: "hybrid" }))).toThrow( + /llm\.runMode\.mode/, + ); + }); + + it("rejects a pinned leg that names an unconfigured provider", () => { + expect(() => + parseUserConfigFile(withRunMode({ cloudProvider: "anthropic" })), + ).toThrow(/llm\.runMode\.cloudProvider/); + expect(() => + parseUserConfigFile(withRunMode({ localProvider: "ollama" })), + ).toThrow(/llm\.runMode\.localProvider/); + }); + + it("accepts the inclusive cloudShare bounds", () => { + for (const cloudShare of [0, 100]) { + const parsed = parseUserConfigFile(withRunMode({ fusion: { cloudShare } })); + expect(parsed.llm?.runMode?.fusion?.cloudShare).toBe(cloudShare); + } + }); + + it("rejects a cloudShare outside 0-100 or non-integer", () => { + for (const bad of [-1, 101, 42.5, "40", null]) { + expect(() => + parseUserConfigFile(withRunMode({ fusion: { cloudShare: bad } })), + ).toThrow(/llm\.runMode\.fusion\.cloudShare/); + } + }); + + it("rejects an unknown subRunners target", () => { + expect(() => + parseUserConfigFile(withRunMode({ fusion: { subRunners: "remote" } })), + ).toThrow(/llm\.runMode\.fusion\.subRunners/); + }); + + it("rejects a non-object runMode or fusion block", () => { + expect(() => parseUserConfigFile(withRunMode("fusion"))).toThrow( + /llm\.runMode/, + ); + expect(() => parseUserConfigFile(withRunMode({ fusion: [] }))).toThrow( + /llm\.runMode\.fusion/, + ); + }); +}); diff --git a/src/config/llm-run-mode-config.ts b/src/config/llm-run-mode-config.ts new file mode 100644 index 00000000..65535c2d --- /dev/null +++ b/src/config/llm-run-mode-config.ts @@ -0,0 +1,170 @@ +import { ConfigValidationError } from "./config-validation-error.js"; + +/** + * Operator-facing run mode. Names the *pair* of providers a turn is + * allowed to use, not a single model: + * + * - `local` — the configured llama-server provider only. + * - `cloud` — the configured cloud provider only. + * - `fusion` — cloud orchestrates, local executes. See + * AGENTS.md §"Run modes (Local / Cloud / Fusion)". + */ +export type RunModeName = "local" | "cloud" | "fusion"; + +/** + * Where fusion sends the memory sub-runners (reflection, link + * generation, curation votes, query rewriting, distillation). + * + * `local` (default) keeps them on the executor: they are cold-path + * structured-JSON jobs that ride the reserved reflection slot and are + * already KV-warm locally, so routing them to the cloud multiplies + * per-turn cost for no user-visible latency win. `cloud` sends them to + * the orchestrator; `follow` reuses whatever the last main-loop step + * used. + */ +export type RunModeSubRunners = "local" | "cloud" | "follow"; + +export type UserLlmFusionConfig = { + /** + * How much of a turn leans on the cloud orchestrator, 0-100. + * + * This is a DIAL, NOT A QUOTA. It does not promise that N% of steps + * reach the cloud; it moves the cutoff on a bounded per-step + * complexity score (`src/agent/routing/compute-step-complexity.ts`): + * a step routes to the cloud when `score >= 100 - cloudShare`. `0` + * behaves exactly like `local`, `100` exactly like `cloud`. + * + * Resist "fixing" this into a running-counter scheduler — a quota + * necessarily sends some trivial steps to the cloud and keeps some + * hard ones local, which is the opposite of the intent. + */ + cloudShare?: number; + subRunners?: RunModeSubRunners; +}; + +export type UserLlmRunModeConfig = { + mode?: RunModeName; + /** Pin the local leg. Default: the first `llama-server`-kind provider. */ + localProvider?: string; + /** Pin the cloud leg. Default: the first non-`llama-server` provider. */ + cloudProvider?: string; + fusion?: UserLlmFusionConfig; +}; + +/** Default cloud share when fusion is selected without an explicit dial. */ +export const DEFAULT_FUSION_CLOUD_SHARE = 40; + +const RUN_MODE_NAMES: readonly RunModeName[] = ["local", "cloud", "fusion"]; +const SUB_RUNNER_TARGETS: readonly RunModeSubRunners[] = [ + "local", + "cloud", + "follow", +]; + +function parseKnownProviderId( + raw: unknown, + providerIds: ReadonlySet, + field: string, +): string { + if (typeof raw !== "string" || raw.length === 0) { + throw new ConfigValidationError(field, "expected non-empty string"); + } + if (!providerIds.has(raw)) { + throw new ConfigValidationError( + field, + `unknown provider id ${JSON.stringify(raw)}`, + ); + } + return raw; +} + +function parseCloudShare(raw: unknown, field: string): number { + if (typeof raw !== "number" || !Number.isInteger(raw)) { + throw new ConfigValidationError(field, "expected an integer 0-100"); + } + if (raw < 0 || raw > 100) { + throw new ConfigValidationError( + field, + `expected an integer 0-100, got ${raw}`, + ); + } + return raw; +} + +function parseFusion( + raw: unknown, + field: string, +): UserLlmFusionConfig { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const out: UserLlmFusionConfig = {}; + if (obj.cloudShare !== undefined) { + out.cloudShare = parseCloudShare(obj.cloudShare, `${field}.cloudShare`); + } + if (obj.subRunners !== undefined) { + const target = obj.subRunners; + if ( + typeof target !== "string" || + !SUB_RUNNER_TARGETS.includes(target as RunModeSubRunners) + ) { + throw new ConfigValidationError( + `${field}.subRunners`, + `expected ${SUB_RUNNER_TARGETS.join("|")}`, + ); + } + out.subRunners = target as RunModeSubRunners; + } + return out; +} + +/** + * Validate the `llm.runMode` block. `providerIds` is the set of ids the + * sibling `llm.providers` array declares — a pinned leg that names a + * provider which does not exist is a config error, not a silent + * degradation, because the operator meant something specific. + */ +export function parseLlmRunModeConfig( + raw: unknown, + providerIds: ReadonlySet, + field: string, +): UserLlmRunModeConfig { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new ConfigValidationError(field, "expected object"); + } + const obj = raw as Record; + const out: UserLlmRunModeConfig = {}; + + if (obj.mode !== undefined) { + const mode = obj.mode; + if ( + typeof mode !== "string" || + !RUN_MODE_NAMES.includes(mode as RunModeName) + ) { + throw new ConfigValidationError( + `${field}.mode`, + `expected ${RUN_MODE_NAMES.join("|")}`, + ); + } + out.mode = mode as RunModeName; + } + if (obj.localProvider !== undefined) { + out.localProvider = parseKnownProviderId( + obj.localProvider, + providerIds, + `${field}.localProvider`, + ); + } + if (obj.cloudProvider !== undefined) { + out.cloudProvider = parseKnownProviderId( + obj.cloudProvider, + providerIds, + `${field}.cloudProvider`, + ); + } + if (obj.fusion !== undefined) { + out.fusion = parseFusion(obj.fusion, `${field}.fusion`); + } + return out; +} diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 49f9a958..60c3871b 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -507,5 +507,6 @@ function mapUserLlmToRuntime( }; }), ...(llm.fallback ? { fallback: llm.fallback } : {}), + ...(llm.runMode ? { runMode: llm.runMode } : {}), }; } diff --git a/src/llm/index.ts b/src/llm/index.ts index 775baf9a..2ec45121 100644 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -62,3 +62,12 @@ export type { VisionRequest, VisionResult, } from "./provider/index.js"; +export { + describeRunModeDegradation, + resolveRunMode, +} from "./run-mode/index.js"; +export type { + ResolvedRunMode, + RunModeDegradation, + RunModeDegradationReason, +} from "./run-mode/index.js"; diff --git a/src/llm/provider/registry/provider-types.ts b/src/llm/provider/registry/provider-types.ts index 9aa7de62..f2b87cbb 100644 --- a/src/llm/provider/registry/provider-types.ts +++ b/src/llm/provider/registry/provider-types.ts @@ -1,4 +1,5 @@ import type { AtomicAgentConfig } from "../../../config/index.js"; +import type { UserLlmRunModeConfig } from "../../../config/llm-run-mode-config.js"; import type { LlamaServerClient } from "../../llama-server-client.js"; import type { ModelProfile } from "../../model-profile.js"; import type { StructuredLogger } from "../../../tracing/index.js"; @@ -84,6 +85,11 @@ export type ResolvedLlmConfig = { providers: LlmProviderConfigEntry[]; toolTransport: "auto" | "grammar" | "native_tools"; fallback?: LlmFallbackConfig; + /** + * Operator run mode. Absent on the synthesized local-only config + * below, where `local` is the only reachable mode by construction. + */ + runMode?: UserLlmRunModeConfig; }; const factories = new Map(); @@ -112,6 +118,7 @@ export function resolveLlmConfig(config: AtomicAgentConfig): ResolvedLlmConfig { providers: [...llm.providers], toolTransport: llm.toolTransport, ...(llm.fallback ? { fallback: llm.fallback } : {}), + ...(llm.runMode ? { runMode: llm.runMode } : {}), }; } return { diff --git a/src/llm/run-mode/index.ts b/src/llm/run-mode/index.ts new file mode 100644 index 00000000..bb3fa636 --- /dev/null +++ b/src/llm/run-mode/index.ts @@ -0,0 +1,7 @@ +export { resolveRunMode } from "./resolve-run-mode.js"; +export type { + ResolvedRunMode, + RunModeDegradation, + RunModeDegradationReason, +} from "./resolve-run-mode.js"; +export { describeRunModeDegradation } from "./run-mode-degradation.js"; diff --git a/src/llm/run-mode/resolve-run-mode.test.ts b/src/llm/run-mode/resolve-run-mode.test.ts new file mode 100644 index 00000000..8c3bcee9 --- /dev/null +++ b/src/llm/run-mode/resolve-run-mode.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +import type { ResolvedLlmConfig } from "../provider/registry/provider-types.js"; +import { resolveRunMode } from "./resolve-run-mode.js"; + +const LOCAL = { id: "local-llama", kind: "llama-server", url: "http://127.0.0.1:8080" }; +const CLOUD = { id: "openrouter", kind: "openrouter", defaultChatModel: "openai/gpt-4o-mini" }; + +function config(over: Partial = {}): ResolvedLlmConfig { + return { + activeTextProvider: "local-llama", + activeEmbeddingProvider: "local-llama", + providers: [{ ...LOCAL }, { ...CLOUD }], + toolTransport: "auto", + ...over, + }; +} + +describe("resolveRunMode", () => { + it("derives local from the active provider when no runMode block exists", () => { + const r = resolveRunMode(config()); + expect(r.stored).toBeNull(); + expect(r.effective).toBe("local"); + expect(r.primaryProviderId).toBe("local-llama"); + expect(r.degraded).toBeNull(); + }); + + it("derives cloud from a cloud active provider", () => { + const r = resolveRunMode(config({ activeTextProvider: "openrouter" })); + expect(r.effective).toBe("cloud"); + expect(r.primaryProviderId).toBe("openrouter"); + }); + + it("discovers both legs by provider kind", () => { + const r = resolveRunMode(config()); + expect(r.localProviderId).toBe("local-llama"); + expect(r.cloudProviderId).toBe("openrouter"); + }); + + it("honours explicitly pinned legs over kind discovery", () => { + const r = resolveRunMode( + config({ + providers: [{ ...LOCAL }, { ...CLOUD }, { id: "aimlapi", kind: "aimlapi" }], + runMode: { cloudProvider: "aimlapi" }, + }), + ); + expect(r.cloudProviderId).toBe("aimlapi"); + }); + + it("resolves fusion when both legs exist and the cloud leg is active", () => { + const r = resolveRunMode( + config({ activeTextProvider: "openrouter", runMode: { mode: "fusion" } }), + ); + expect(r.effective).toBe("fusion"); + // Fusion pins the cloud leg as primary, which is what makes it the + // fallback chain's head and the local leg its `appendLocal` tail. + expect(r.primaryProviderId).toBe("openrouter"); + expect(r.degraded).toBeNull(); + }); + + it("defaults the fusion dial and sub-runner target", () => { + const r = resolveRunMode( + config({ activeTextProvider: "openrouter", runMode: { mode: "fusion" } }), + ); + expect(r.fusion).toEqual({ cloudShare: 40, subRunners: "local" }); + }); + + it("carries an explicit fusion dial through", () => { + const r = resolveRunMode( + config({ + activeTextProvider: "openrouter", + runMode: { mode: "fusion", fusion: { cloudShare: 0, subRunners: "cloud" } }, + }), + ); + expect(r.fusion).toEqual({ cloudShare: 0, subRunners: "cloud" }); + }); + + // The non-contradiction rule: `activeTextProvider` is authoritative. + it("drops stored fusion back to derived when the operator switched provider by hand", () => { + const r = resolveRunMode( + config({ activeTextProvider: "local-llama", runMode: { mode: "fusion" } }), + ); + expect(r.effective).toBe("local"); + // Not a degradation — nothing is broken, the operator simply moved. + expect(r.degraded).toBeNull(); + }); + + it("drops stored cloud back to local when the local provider is active", () => { + const r = resolveRunMode( + config({ activeTextProvider: "local-llama", runMode: { mode: "cloud" } }), + ); + expect(r.effective).toBe("local"); + expect(r.degraded).toBeNull(); + }); + + it("degrades cloud to local when no cloud provider is configured", () => { + const r = resolveRunMode( + config({ providers: [{ ...LOCAL }], runMode: { mode: "cloud" } }), + ); + expect(r.effective).toBe("local"); + expect(r.cloudProviderId).toBeNull(); + expect(r.degraded).toEqual({ reason: "no-cloud-provider", requested: "cloud" }); + }); + + it("degrades fusion to local when no cloud provider is configured", () => { + const r = resolveRunMode( + config({ providers: [{ ...LOCAL }], runMode: { mode: "fusion" } }), + ); + expect(r.effective).toBe("local"); + expect(r.degraded).toEqual({ reason: "no-cloud-provider", requested: "fusion" }); + }); + + it("degrades fusion to cloud when no local provider is configured", () => { + const r = resolveRunMode( + config({ + providers: [{ ...CLOUD }], + activeTextProvider: "openrouter", + activeEmbeddingProvider: "openrouter", + runMode: { mode: "fusion" }, + }), + ); + expect(r.effective).toBe("cloud"); + expect(r.localProviderId).toBeNull(); + expect(r.degraded).toEqual({ reason: "no-local-provider", requested: "fusion" }); + }); + + it("warns but still runs fusion when the tool transport is pinned", () => { + const r = resolveRunMode( + config({ + activeTextProvider: "openrouter", + toolTransport: "grammar", + runMode: { mode: "fusion" }, + }), + ); + expect(r.effective).toBe("fusion"); + expect(r.degraded).toEqual({ + reason: "tool-transport-pinned", + requested: "fusion", + }); + }); + + it("assumes local when the active provider id resolves to nothing", () => { + // A broken file must never silently start spending cloud tokens. + const r = resolveRunMode(config({ activeTextProvider: "ghost" })); + expect(r.effective).toBe("local"); + }); + + it("never returns an empty primaryProviderId", () => { + const r = resolveRunMode(config({ providers: [], activeTextProvider: "ghost" })); + expect(r.primaryProviderId).toBe("ghost"); + }); +}); diff --git a/src/llm/run-mode/resolve-run-mode.ts b/src/llm/run-mode/resolve-run-mode.ts new file mode 100644 index 00000000..95aac11a --- /dev/null +++ b/src/llm/run-mode/resolve-run-mode.ts @@ -0,0 +1,131 @@ +import type { + RunModeName, + RunModeSubRunners, +} from "../../config/llm-run-mode-config.js"; +import { DEFAULT_FUSION_CLOUD_SHARE } from "../../config/llm-run-mode-config.js"; +import type { ResolvedLlmConfig } from "../provider/registry/provider-types.js"; + +/** Provider kind that identifies the local leg. */ +const LOCAL_PROVIDER_KIND = "llama-server"; + +export type RunModeDegradationReason = + | "no-cloud-provider" + | "no-local-provider" + | "tool-transport-pinned"; + +export type RunModeDegradation = { + reason: RunModeDegradationReason; + /** The mode the operator asked for, before degradation. */ + requested: RunModeName; +}; + +export type ResolvedRunMode = { + /** What the config file says, or `null` when the block is absent. */ + stored: RunModeName | null; + /** What the runtime will actually do. */ + effective: RunModeName; + localProviderId: string | null; + cloudProviderId: string | null; + /** + * The provider that must be `llm.activeTextProvider` for `effective` + * to hold. Never empty — falls back to the configured active provider + * when neither leg resolves. + */ + primaryProviderId: string; + fusion: { cloudShare: number; subRunners: RunModeSubRunners }; + degraded: RunModeDegradation | null; +}; + +/** + * Project the `llm.runMode` block onto the providers that actually + * exist. + * + * `llm.activeTextProvider` stays AUTHORITATIVE — `runMode.mode` is + * purely additive. The effective mode is derived from which provider is + * active, and a stored `fusion` is only honoured when the cloud leg is + * the active one: + * + * ``` + * derived = kindOf(activeTextProvider) === "llama-server" ? "local" : "cloud" + * effective = stored === "fusion" && bothLegsExist && active === cloudId + * ? "fusion" : derived + * ``` + * + * That rule is what keeps the two keys from ever contradicting each + * other: an operator who switches provider by hand in Manage → LLM + * simply drops out of fusion on the next read, with no reconciliation + * step and no state that lies about what is running. It also means + * fusion pins the CLOUD provider as the fallback chain's primary, so + * `resolveFallbackChain` hoists it to the head and appends local at the + * tail with no changes of its own. + */ +export function resolveRunMode( + resolved: ResolvedLlmConfig, +): ResolvedRunMode { + const runMode = resolved.runMode; + const stored = runMode?.mode ?? null; + + const localProviderId = + runMode?.localProvider ?? + resolved.providers.find((p) => p.kind === LOCAL_PROVIDER_KIND)?.id ?? + null; + const cloudProviderId = + runMode?.cloudProvider ?? + resolved.providers.find((p) => p.kind !== LOCAL_PROVIDER_KIND)?.id ?? + null; + + const activeKind = resolved.providers.find( + (p) => p.id === resolved.activeTextProvider, + )?.kind; + // An unresolvable active provider means a broken config; assume local + // so a broken file can never silently start spending cloud tokens. + const derived: RunModeName = + activeKind === undefined || activeKind === LOCAL_PROVIDER_KIND + ? "local" + : "cloud"; + + const fusion = { + cloudShare: runMode?.fusion?.cloudShare ?? DEFAULT_FUSION_CLOUD_SHARE, + subRunners: runMode?.fusion?.subRunners ?? ("local" as RunModeSubRunners), + }; + + let effective: RunModeName = derived; + let degraded: RunModeDegradation | null = null; + + if (stored === "fusion") { + if (cloudProviderId === null) { + degraded = { reason: "no-cloud-provider", requested: stored }; + } else if (localProviderId === null) { + degraded = { reason: "no-local-provider", requested: stored }; + } else if (resolved.activeTextProvider === cloudProviderId) { + // Both legs exist AND the cloud leg is the active provider, which + // is what makes the cloud provider the fallback chain's primary. + effective = "fusion"; + if (resolved.toolTransport !== "auto") { + // Not a downgrade: fusion still runs, but a pinned transport + // sends one leg the wrong wire shape (grammar to a native-tools + // provider or vice versa), so the operator has to know. + degraded = { reason: "tool-transport-pinned", requested: stored }; + } + } + // else: the operator switched the active provider by hand, so we + // simply report `derived`. That is the non-contradiction rule + // working, not a degradation — nothing to warn about. + } else if (stored === "cloud" && cloudProviderId === null) { + degraded = { reason: "no-cloud-provider", requested: stored }; + } + + const primaryProviderId = + (effective === "local" ? localProviderId : cloudProviderId) ?? + resolved.activeTextProvider; + + return { + stored, + effective, + localProviderId, + cloudProviderId, + primaryProviderId, + fusion, + degraded, + }; +} diff --git a/src/llm/run-mode/run-mode-degradation.test.ts b/src/llm/run-mode/run-mode-degradation.test.ts new file mode 100644 index 00000000..ff0f4ceb --- /dev/null +++ b/src/llm/run-mode/run-mode-degradation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { describeRunModeDegradation } from "./run-mode-degradation.js"; + +describe("describeRunModeDegradation", () => { + it("names the orchestrator when fusion has no cloud leg", () => { + const msg = describeRunModeDegradation({ + reason: "no-cloud-provider", + requested: "fusion", + }); + expect(msg).toContain("Fusion needs a cloud orchestrator"); + expect(msg).toContain("Staying on local"); + }); + + it("uses the plain cloud wording when cloud mode has no cloud leg", () => { + const msg = describeRunModeDegradation({ + reason: "no-cloud-provider", + requested: "cloud", + }); + expect(msg).toContain("Cloud mode needs a cloud provider"); + expect(msg).not.toContain("Fusion"); + }); + + it("names the executor when fusion has no local leg", () => { + expect( + describeRunModeDegradation({ reason: "no-local-provider", requested: "fusion" }), + ).toContain("Fusion needs a local executor"); + }); + + it("explains a pinned tool transport as a warning, not a downgrade", () => { + const msg = describeRunModeDegradation({ + reason: "tool-transport-pinned", + requested: "fusion", + }); + expect(msg).toContain("llm.toolTransport"); + expect(msg).not.toContain("Staying on local"); + }); + + it("points every degradation at a way to fix it", () => { + expect( + describeRunModeDegradation({ reason: "no-cloud-provider", requested: "cloud" }), + ).toContain("/llm"); + }); +}); diff --git a/src/llm/run-mode/run-mode-degradation.ts b/src/llm/run-mode/run-mode-degradation.ts new file mode 100644 index 00000000..acdbdb99 --- /dev/null +++ b/src/llm/run-mode/run-mode-degradation.ts @@ -0,0 +1,23 @@ +import type { RunModeDegradation } from "./resolve-run-mode.js"; + +/** + * Operator-visible sentence for a run-mode degradation. + * + * Kept out of `resolveRunMode` so the resolver stays a pure projection + * and the wording can be asserted on its own — and so the TUI, the CLI + * and the HTTP surface all say exactly the same thing. + */ +export function describeRunModeDegradation( + degraded: RunModeDegradation, +): string { + switch (degraded.reason) { + case "no-cloud-provider": + return degraded.requested === "fusion" + ? "Fusion needs a cloud orchestrator — no cloud provider is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm)." + : "Cloud mode needs a cloud provider — none is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm)."; + case "no-local-provider": + return "Fusion needs a local executor — no llama-server provider is configured. Running cloud-only."; + case "tool-transport-pinned": + return 'Fusion works best with llm.toolTransport "auto" — it is pinned, so one leg will get the wrong wire format.'; + } +} From e699a76cfdfdf1011b51ed124e3cd17693dee6c5 Mon Sep 17 00:00:00 2001 From: Valerii Date: Wed, 19 Aug 2026 17:25:27 +0300 Subject: [PATCH 2/2] fix(config): read a newer config version instead of refusing every command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch takes USER_CONFIG_VERSION to 38. Hand testing showed what that does to a machine that also has the installed v0.2.2 release: they share one ~/.atomic-agent/config.json, so the moment the newer build wrote v38, every single v0.2.2 command died with ConfigValidationError: invalid config: version: unsupported config version 38; expected one of 5, 6, ... 37 — models status, config get, the TUI, all of it, with no way out short of hand-editing the file. Running a release and a build under test side by side is normal, and the version bump made them mutually exclusive. Every bump this schema has taken is additive: the parser reads field by field and writeUserConfigFileSync preserves unknown top-level keys. So a newer file already parses correctly — the allow-list only ever needed a floor, not a ceiling. Older-than-supported is still refused, and a non-integer version is now refused explicitly rather than falling through the membership test. ensureUserConfigFileSync gets the other half: it must not rewrite a newer file back down to this build's shape. Reading it is safe; overwriting would delete the newer build's keys, and with a shared file the two builds would take turns destroying each other's config on every launch. Knowingly replaces the "rejects unsupported version" test, which pinned the broken behaviour. --- src/config/config-file.test.ts | 47 ++++++++++++++++++++++++++++++++ src/config/config-file.ts | 11 ++++++++ src/config/config-schema.test.ts | 20 ++++++++++++-- src/config/config-schema.ts | 29 +++++++++++++++++--- 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/config/config-file.test.ts b/src/config/config-file.test.ts index fcb676ee..ed3d03a5 100644 --- a/src/config/config-file.test.ts +++ b/src/config/config-file.test.ts @@ -554,3 +554,50 @@ describe("user config file IO", () => { warn.mockRestore(); }); }); + +describe("a config written by a newer build", () => { + // Regression: two builds share one `~/.atomic-agent/config.json`. The + // 0.3.0 build bumped it to v38 and the installed v0.2.2 release then + // died on every single command with "unsupported config version 38". + // An additive schema has no reason to make version skew fatal. + it("is read, not refused", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-")); + const path = join(dir, "config.json"); + writeFileSync( + path, + JSON.stringify({ + version: USER_CONFIG_VERSION + 5, + localModels: { url: "http://127.0.0.1:9999" }, + somethingFromTheFuture: { enabled: true }, + }), + "utf8", + ); + const parsed = ensureUserConfigFileSync(path); + expect(parsed.localModels.url).toBe("http://127.0.0.1:9999"); + }); + + it("is never rewritten back down to this build's version", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-newer-config-")); + const path = join(dir, "config.json"); + const future = { + version: USER_CONFIG_VERSION + 5, + localModels: { url: "http://127.0.0.1:9999" }, + somethingFromTheFuture: { enabled: true }, + }; + writeFileSync(path, JSON.stringify(future), "utf8"); + ensureUserConfigFileSync(path); + const onDisk = JSON.parse(readFileSync(path, "utf8")) as Record< + string, + unknown + >; + expect(onDisk.version).toBe(USER_CONFIG_VERSION + 5); + expect(onDisk.somethingFromTheFuture).toEqual({ enabled: true }); + }); + + it("still refuses a version older than the oldest supported one", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-old-config-")); + const path = join(dir, "config.json"); + writeFileSync(path, JSON.stringify({ version: 2 }), "utf8"); + expect(() => ensureUserConfigFileSync(path)).toThrow(/unsupported config version/); + }); +}); diff --git a/src/config/config-file.ts b/src/config/config-file.ts index 197db5dc..633ecd60 100644 --- a/src/config/config-file.ts +++ b/src/config/config-file.ts @@ -95,6 +95,17 @@ export function ensureUserConfigFileSync(path: string): UserConfigFile { return USER_CONFIG_DEFAULTS; } const parsed = parseUserConfigFile(raw.parsed); + // A file written by a NEWER build is read and left exactly as it is. + // Rewriting it would silently delete whatever that build added — and + // since both builds share one `config.json`, the two would then take + // turns destroying each other's keys on every launch. Reading is safe + // (the schema is additive); writing is not ours to do. + if ( + raw.originalVersion !== null && + raw.originalVersion > USER_CONFIG_VERSION + ) { + return parsed; + } if (raw.originalVersion !== USER_CONFIG_VERSION) { writeUserConfigFileSync(path, parsed); process.stderr.write( diff --git a/src/config/config-schema.test.ts b/src/config/config-schema.test.ts index 848ec16d..08a7eb82 100644 --- a/src/config/config-schema.test.ts +++ b/src/config/config-schema.test.ts @@ -84,8 +84,24 @@ describe("parseUserConfigFile", () => { ).toBe(1); }); - it("rejects unsupported version", () => { - expect(() => parseUserConfigFile({ version: 99 })).toThrow( + it("reads a version newer than this build instead of refusing it", () => { + // Knowingly replaces "rejects unsupported version", which pinned + // `{version: 99}` as fatal. That is what made two builds sharing one + // `config.json` mutually exclusive: the newer one wrote v38 and the + // installed v0.2.2 then failed every command. The schema is additive, + // so a newer file parses fine — unknown keys are simply not read. + const parsed = parseUserConfigFile({ + version: 99, + localModels: { url: "http://127.0.0.1:9999" }, + }); + expect(parsed.localModels.url).toBe("http://127.0.0.1:9999"); + }); + + it("still rejects a non-integer version", () => { + expect(() => parseUserConfigFile({ version: "38" })).toThrow( + ConfigValidationError, + ); + expect(() => parseUserConfigFile({ version: 38.5 })).toThrow( ConfigValidationError, ); }); diff --git a/src/config/config-schema.ts b/src/config/config-schema.ts index a146d807..de832e94 100644 --- a/src/config/config-schema.ts +++ b/src/config/config-schema.ts @@ -2661,10 +2661,31 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile { } const obj = raw as Record; const version = obj.version ?? USER_CONFIG_VERSION; - if ( - typeof version !== "number" || - !SUPPORTED_INPUT_VERSIONS.includes(version) - ) { + if (typeof version !== "number" || !Number.isInteger(version)) { + throw new ConfigValidationError( + "version", + `expected an integer version; got ${JSON.stringify(version)}`, + ); + } + // A version *newer* than this build is read, not refused. + // + // Every bump this schema has ever taken is additive: new keys arrive + // with defaults and the parser reads field by field, so a file written + // by a newer build parses correctly here — the keys this build does not + // know are simply not read, and `writeUserConfigFileSync` preserves + // unknown top-level keys rather than dropping them. + // + // Refusing was actively harmful. Two builds share one `config.json`, + // so the moment the newer one wrote its version the older one died on + // *every* command — `models status`, `config get`, the TUI — with a + // validation error naming 33 acceptable versions and no way out. + // Running two versions side by side is normal (a release plus a build + // under test), and an additive schema has no reason to make it fatal. + // + // The other half of this contract lives in `ensureUserConfigFileSync`, + // which must not rewrite a newer file back down to this build's shape — + // reading it is safe, overwriting would delete the newer build's keys. + if (version < USER_CONFIG_VERSION && !SUPPORTED_INPUT_VERSIONS.includes(version)) { throw new ConfigValidationError( "version", `unsupported config version ${JSON.stringify(version)}; expected one of ${SUPPORTED_INPUT_VERSIONS.join(", ")}`,