Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 33 additions & 28 deletions desktop/main/agent-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,7 +1072,7 @@ export interface UserConfigShape {
localModels?: {
url?: string;
mode?: string;
managed?: { modelId?: string | null; port?: number };
managed?: { modelId?: string | null; port?: number; parallel?: number | string };
// r5 item 7 (setup wizard): the custom-endpoint branch writes modelId
// as persistUserRemoteLlmUrls does, so the field has to exist here.
embeddings?: { url?: string; enabled?: boolean; modelId?: string | null };
Expand Down Expand Up @@ -1158,11 +1158,20 @@ async function syncLocalLlamaProviderUrlInFileNow(): Promise<WriteResult> {
* the TUI does when it is absent (url only — no baseUrl), refuses an id
* that names no provider, writes ONLY llm.activeTextProvider.
*/
export function setActiveTextProvider(id: string): Promise<WriteResult> {
return withConfigLock(() => setActiveTextProviderNow(id));
export function setActiveTextProvider(id: string, opts: { leaveFusion?: boolean } = {}): Promise<WriteResult> {
return withConfigLock(() => setActiveTextProviderNow(id, opts));
}

async function setActiveTextProviderNow(id: string): Promise<WriteResult> {
/**
* `leaveFusion`: the TUI's activateCloud / activateLocal. A plain route
* switch writes `llm.activeTextProvider` alone, and `resolveRunMode` keeps
* honouring a stored `runMode.mode: "fusion"` for as long as the active
* provider is the orchestrator — which is exactly the provider "cloud"
* picks. So a switch that means to leave Fusion writes the stored mode in
* the same write (`RunModeOrchestrator.setMode`), or the window lands in
* effective Fusion while its chip says cloud.
*/
async function setActiveTextProviderNow(id: string, opts: { leaveFusion?: boolean } = {}): Promise<WriteResult> {
if (!/^[\w.-]{1,48}$/.test(id)) return { ok: false, changed: false, error: `not a provider id: ${id}` };
const read = await readWholeConfig();
if (!read.ok || !read.config) return { ok: false, changed: false, error: read.error };
Expand All @@ -1181,8 +1190,13 @@ async function setActiveTextProviderNow(id: string): Promise<WriteResult> {
if (!providers.some((p) => p.id === id)) {
return { ok: false, changed: false, error: `provider "${id}" is not configured` };
}
if (llm.activeTextProvider === id && !synthesized) return { ok: true, changed: false };
const run = llm.runMode;
const leaving = opts.leaveFusion === true && run?.mode === "fusion";
if (llm.activeTextProvider === id && !synthesized && !leaving) return { ok: true, changed: false };
llm.activeTextProvider = id;
if (leaving && run) {
run.mode = providers.find((p) => p.id === id)?.kind === "llama-server" ? "local" : "cloud";
}
const w = await writeWholeConfig(cfg);
return w.ok ? { ok: true, changed: true } : { ok: false, changed: false, error: w.error };
}
Expand Down Expand Up @@ -1375,38 +1389,29 @@ export function providerHasKey(entry: ProviderEntry, names: KeyEnvNames = keyNam
return false;
}

/** Ids of the configured cloud providers that have a usable key, for the selector's row copy. */
/**
* The run mode, as the TUI's `/runmode` writes it.
* Read, plan and write the whole file under ONE hold of the config lock.
*
* The desktop had no way to reach this at all: three run modes in the TUI
* (local, cloud, and fusion — a cloud model orchestrating local workers) and
* a window that could only pick a provider. It is ordinary config, so it goes
* through the same whole-file path every other write here uses; there is no
* route for it and inventing one would be a second source of truth.
* The run-mode writes (main/run-mode.ts) decide what to write from what the
* file says — which leg is active, which provider is pinned — so reading it
* outside the lock and writing it inside would let a write that landed in
* between be silently undone. `plan` mutates the object it is handed and
* says whether it did; nothing is written when it did not.
*/
export async function setRunMode(
mode: "local" | "cloud" | "fusion",
fusion?: { workers?: number },
): Promise<WriteResult> {
export function rewriteWholeConfig<V extends { write: boolean }>(
plan: (cfg: UserConfigShape) => V,
): Promise<{ ok: boolean; changed: boolean; error?: string; verdict?: V }> {
return withConfigLock(async () => {
const read = await readWholeConfig();
if (!read.ok || !read.config) return { ok: false, changed: false, error: read.error };
const cfg = read.config;
const llm = (cfg.llm ??= {});
const run = (llm.runMode ??= {});
const before = JSON.stringify(run);
run.mode = mode;
if (fusion?.workers !== undefined) {
const f = (run.fusion ??= {});
f.workers = Math.max(1, Math.min(16, Math.floor(fusion.workers)));
}
if (JSON.stringify(run) === before) return { ok: true, changed: false };
const w = await writeWholeConfig(cfg);
return w.ok ? { ok: true, changed: true } : { ok: false, changed: false, error: w.error };
const verdict = plan(read.config);
if (!verdict.write) return { ok: true, changed: false, verdict };
const w = await writeWholeConfig(read.config);
return w.ok ? { ok: true, changed: true, verdict } : { ok: false, changed: false, error: w.error, verdict };
});
}

/** Ids of the configured cloud providers that have a usable key, for the selector's row copy. */
export async function providersReady(): Promise<{ ok: boolean; ids?: string[]; error?: string }> {
const read = await readWholeConfig();
if (!read.ok || !read.config) return { ok: false, error: read.error };
Expand Down
182 changes: 176 additions & 6 deletions desktop/main/backend-switch.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import {
chatModelsList,
keyNamesAvailable,
localDaemonRunning,
modelsList,
modelsStart,
modelsStop,
modelsUse,
providerHasKey,
readWholeConfig,
rewriteWholeConfig,
setActiveTextProvider,
setMemoryEmbeddingsEnabled,
setProviderModel,
useManagedMode,
type ProviderEntry,
} from "./agent-cli.js";
import {
describeRunMode,
planEnterFusion,
planFusionWorkers,
planSwapLegs,
resolveRunMode,
type RunModeProvider,
type RunModeVerdict,
} from "./run-mode.js";

/**
* Lane B — backend switch.
Expand Down Expand Up @@ -65,6 +76,12 @@ export interface SwitchResult {
/** selectLocalModel on a model that is not on disk — pull it first. */
needsDownload?: boolean;
error?: string;
/** Run-mode switches: the one sentence a refused change is told in (nothing was written). */
refusal?: string;
/** Run-mode switches: the effective mode either side of the write, and the TUI's `run mode: …` line. */
runMode?: { before: string; after: string; line: string; enteredFusion: boolean };
/** setFusionWorkers: the TUI's `fusion: N workers …` line. */
notice?: string;
}

const LOCAL_ID = "local-llama";
Expand Down Expand Up @@ -92,7 +109,7 @@ function readyLine(stdout: string): string | undefined {
* only a successful stop is followed by write 2 (memory.embeddings.enabled
* = false), which is the order the TUI's stopDaemon does it.
*/
export async function activateProvider(id: string): Promise<SwitchResult> {
export async function activateProvider(id: string, opts: { leaveFusion?: boolean } = {}): Promise<SwitchResult> {
const read = await readWholeConfig();
if (!read.ok || !read.config) return { ok: false, error: read.error };
const entry = (read.config.llm?.providers ?? []).find((p) => p.id === id);
Expand All @@ -101,15 +118,22 @@ export async function activateProvider(id: string): Promise<SwitchResult> {
if (cloud && !providerHasKey(entry)) {
return { ok: false, needsKey: true, providerId: id, error: "no API key" };
}
const w = await setActiveTextProvider(id);
/* Under effective Fusion the orchestrator IS the active provider, and its
own model chip re-activates it: that keeps the mode (the TUI's
selectChatModel on the active provider), and it must not stop the local
daemon the workers run on. Every other activation — another provider,
or the backend row's `cloud` — leaves Fusion in the same write. */
const rm = resolveRunMode(read.config);
const keepFusion = !opts.leaveFusion && rm.effective === "fusion" && rm.orchestratorProviderId === id;
const w = await setActiveTextProvider(id, { leaveFusion: !keepFusion });
if (!w.ok) return { ok: false, error: w.error };
// `restart` says the file moved. main.ts also restarts when the file did
// NOT move but `atag serve` booted on another route (the TUI or a hand
// edit changed the file while this window was open) — see applySwitch.
let restart = w.changed;
let daemon: DaemonEffect = "untouched";
let daemonLine: string | undefined;
if (cloud && (await localDaemonRunning())) {
if (cloud && !keepFusion && (await localDaemonRunning())) {
const s = await modelsStop();
if (s.ok) {
daemon = "stopped";
Expand Down Expand Up @@ -150,7 +174,7 @@ async function routeToLocal(modelId: string): Promise<SwitchResult> {
if (!used.ok) return { ok: false, error: used.error };
restart = true;
}
const w = await setActiveTextProvider(LOCAL_ID);
const w = await setActiveTextProvider(LOCAL_ID, { leaveFusion: true });
if (!w.ok) return { ok: false, error: w.error };
if (w.changed) restart = true;

Expand Down Expand Up @@ -200,7 +224,9 @@ export async function switchBackend(kind: "cloud" | "local"): Promise<SwitchResu
cloud.find((p) => providerHasKey(p)) ??
cloud[0];
if (!provider) return { ok: false, needsProvider: true, error: "add a provider first" };
return activateProvider(provider.id);
// Under Fusion the active provider is the orchestrator, so "cloud" picks
// it — and without leaveFusion the stored mode would keep it in Fusion.
return activateProvider(provider.id, { leaveFusion: true });
}

// Embedding models are a separate daemon; the chat route never picks
Expand All @@ -213,7 +239,7 @@ export async function switchBackend(kind: "cloud" | "local"): Promise<SwitchResu
// Nothing on disk: point the route at local-llama and make the mode
// managed so the control does not read `custom` on the next frame;
// the renderer opens the model pane.
const w = await setActiveTextProvider(LOCAL_ID);
const w = await setActiveTextProvider(LOCAL_ID, { leaveFusion: true });
if (!w.ok) return { ok: false, error: w.error };
const m = await useManagedMode();
if (!m.ok) return { ok: false, error: m.error };
Expand Down Expand Up @@ -252,6 +278,150 @@ export async function selectCloudModel(providerId: string, modelId: string): Pro
return { ...res, model: modelId.trim(), restart: res.restart || modelChanged };
}

/* ---------------------------------------------------------------
Run mode — Fusion. RunModeOrchestrator's writes, main-process side.

Each one is ONE whole-file write under one hold of the config lock
(rewriteWholeConfig + a planner from run-mode.ts), then the same
`restart` the other switches return: `atag serve` reads its config once
(getConfig is cached in-process), so neither the active provider nor the
run mode reaches a running agent any other way.
--------------------------------------------------------------- */

function keyed(): (p: RunModeProvider) => boolean {
const names = keyNamesAvailable();
return (p) => providerHasKey(p as ProviderEntry, names);
}

/** Start the managed daemon when it is down (restart it when the model moved). */
async function bringUpLocalDaemon(modelChanged: boolean): Promise<{ daemon: DaemonEffect; daemonLine?: string; error?: string }> {
const running = await localDaemonRunning();
if (running && !modelChanged) return { daemon: "untouched" };
if (running) {
const s = await modelsStop();
if (!s.ok) return { daemon: "stop-failed", daemonLine: `local-llm: stop failed — ${s.error ?? "unknown error"}` };
const st = await modelsStart();
return st.ok ? { daemon: "restarted", daemonLine: readyLine(st.stdout) } : { daemon: "start-failed", error: st.error };
}
const st = await modelsStart();
return st.ok ? { daemon: "started", daemonLine: readyLine(st.stdout) } : { daemon: "start-failed", error: st.error };
}

async function afterRunModeWrite(res: {
ok: boolean;
changed: boolean;
error?: string;
verdict?: RunModeVerdict;
}): Promise<SwitchResult> {
if (!res.ok) return { ok: false, error: res.error };
const v = res.verdict;
if (v?.refusal) return { ok: false, refusal: v.refusal, error: v.refusal };
const read = await readWholeConfig();
if (!read.ok || !read.config) return { ok: false, error: read.error };
const now = resolveRunMode(read.config);
const leg = v?.leg ?? now.primaryProviderId;
const entry = (read.config.llm?.providers ?? []).find((p) => p.id === leg);
/* The worker daemon. autoStartIfReady keys on local-llama being the ACTIVE
provider, and under Fusion the active provider is the orchestrator — so
nothing else would bring a local leg up. Only for a model that is on
disk: a start for a file that is not there is a failure line about
nothing the operator chose. */
let up: { daemon: DaemonEffect; daemonLine?: string; error?: string } = { daemon: "untouched" };
const lm = read.config.localModels ?? {};
const localLeg = now.effective === "fusion" && (now.workerProviderId === LOCAL_ID || now.orchestratorProviderId === LOCAL_ID);
if (localLeg && lm.mode === "managed" && lm.managed?.modelId) {
const list = await chatModelsList();
if (list.ok && (list.models ?? []).some((m) => m.id === lm.managed?.modelId && m.downloaded)) {
up = await bringUpLocalDaemon(false);
}
}
return {
ok: true,
providerId: leg,
model: entry ? (entry.defaultChatModel ?? entry.model ?? null) : null,
transport: transportFor(leg),
...up,
restart: res.changed,
runMode: {
before: v?.before.effective ?? now.effective,
after: now.effective,
line: `run mode: ${describeRunMode(now)}`,
enteredFusion: now.effective === "fusion" && (v?.before.effective ?? now.effective) !== "fusion",
},
};
}

/**
* setMode("fusion", {fusion: pins}) — the backend row's `fusion`, the
* provider control under Fusion (orchestrator pin) and a cloud row in the
* workers control (worker pin).
*/
export async function enterFusion(pins: { orchestratorProvider?: string; workerProvider?: string } = {}): Promise<SwitchResult> {
const isKeyed = keyed();
return afterRunModeWrite(await rewriteWholeConfig((cfg) => planEnterFusion(cfg, pins, isKeyed)));
}

/** swapLegs — the composer's ⇄ and `/runmode swap`. */
export async function swapFusionLegs(): Promise<SwitchResult> {
const isKeyed = keyed();
return afterRunModeWrite(await rewriteWholeConfig((cfg) => planSwapLegs(cfg, isKeyed)));
}

/**
* setWorkers — `fusion.workers` and `managed.parallel` together. The agent
* took the count at boot, so it restarts only while Fusion is what runs;
* off Fusion the count is remembered for the next time it is picked.
*/
export async function setFusionWorkers(workers: number): Promise<SwitchResult> {
const res = await rewriteWholeConfig((cfg) => planFusionWorkers(cfg, workers));
if (!res.ok) return { ok: false, error: res.error };
const v = res.verdict;
if (v?.refusal) return { ok: false, refusal: v.refusal, error: v.refusal };
return { ok: true, notice: v?.notice, restart: res.changed && v?.after?.effective === "fusion" };
}

/**
* The workers control's model rows: activateComposerSwitchRow
* `fusionWorkerModel`. Picking a model for the worker slot claims the slot
* for the local provider, then the managed daemon moves to it through
* `models use` — which writes localModels.* only, never activeTextProvider,
* so Fusion survives the pick (the TUI deliberately avoids
* triggerLlmPrimary here for the same reason).
*/
export async function selectFusionWorkerModel(modelId: string): Promise<SwitchResult> {
if (!/^[\w.-]{1,96}$/.test(modelId)) return { ok: false, error: `not a model id: ${modelId}` };
const list = await chatModelsList();
if (!list.ok || !list.models) return { ok: false, error: list.error };
const row = list.models.find((m) => m.id === modelId);
if (!row) return { ok: false, error: `unknown model id: ${modelId}` };
if (!row.downloaded) {
return { ok: false, needsDownload: true, modelId, error: `local model ${modelId} is not downloaded` };
}
const isKeyed = keyed();
const pin = await rewriteWholeConfig((cfg): RunModeVerdict => {
const rm = resolveRunMode(cfg);
if (rm.effective === "fusion" && rm.workerProviderId === LOCAL_ID) return { write: false, before: rm };
return planEnterFusion(cfg, { workerProvider: LOCAL_ID }, isKeyed);
});
const settled = await afterRunModeWrite(pin);
if (!settled.ok) return settled;
const read = await readWholeConfig();
if (!read.ok || !read.config) return { ok: false, error: read.error };
const lm = read.config.localModels ?? {};
const changed = lm.mode !== "managed" || (lm.managed?.modelId ?? null) !== modelId;
if (changed) {
const used = await modelsUse(modelId);
if (!used.ok) return { ok: false, error: used.error };
}
const up = await bringUpLocalDaemon(changed);
return {
...settled,
...up,
modelId,
restart: !!settled.restart || changed,
};
}

/** triggerLocalChatModel for a downloaded model; a pull is the renderer's job. */
export async function selectLocalModel(modelId: string): Promise<SwitchResult> {
if (!/^[\w.-]{1,64}$/.test(modelId)) return { ok: false, error: `not a model id: ${modelId}` };
Expand Down
Loading