diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index 0ef0594e464..e31a8baa746 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -35,6 +35,90 @@ describe("mergeOpenClawRestoredConfig", () => { ); }); + it("re-owns the agent primary model from the rebuild after a managed-model switch (#7210)", () => { + // #7210: the backup was captured before the switch (nano); the fresh rebuild + // reflects the new managed model (qwen). The agent routes on + // agents.defaults.model.primary + the main list model, so both must follow + // the fresh rebuild, while durable agent config and intentional per-agent + // pins stay from the backup. + const merged = mergeOpenClawRestoredConfig( + { + agents: { + defaults: { + model: { primary: "inference/nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8" }, + thinkingDefault: "off", + }, + list: [ + { id: "main", model: "inference/nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8", default: true }, + { id: "researcher", model: "inference/pinned-by-user" }, + ], + }, + customAgents: { researcher: { prompt: "be thorough" } }, + }, + { + agents: { + defaults: { model: { primary: "inference/Qwen/Qwen3.6-27B-FP8" } }, + }, + }, + ) as { + agents: { + defaults: { model: Record; thinkingDefault: unknown }; + list: Record[]; + }; + customAgents: unknown; + }; + + // Fresh rebuild owns the primary routing reference and the main agent model. + expect(merged.agents.defaults.model).toEqual({ primary: "inference/Qwen/Qwen3.6-27B-FP8" }); + expect(merged.agents.list[0]).toEqual({ + id: "main", + model: "inference/Qwen/Qwen3.6-27B-FP8", + default: true, + }); + // Durable backup agent config is still inherited. + expect(merged.agents.defaults.thinkingDefault).toBe("off"); + expect(merged.customAgents).toEqual({ researcher: { prompt: "be thorough" } }); + // An intentional non-default per-agent pin is NOT touched. + expect(merged.agents.list[1]).toEqual({ id: "researcher", model: "inference/pinned-by-user" }); + }); + + it("leaves backup agent routing untouched when the rebuild carries no agent primary (#7210)", () => { + const merged = mergeOpenClawRestoredConfig( + { + agents: { + defaults: { model: { primary: "inference/nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8" } }, + list: [{ id: "main", model: "inference/nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8" }], + }, + }, + { gateway: { auth: { token: "fresh" } } }, + ) as { agents: { defaults: { model: { primary: string } }; list: { model: string }[] } }; + + expect(merged.agents.defaults.model.primary).toBe( + "inference/nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8", + ); + expect(merged.agents.list[0].model).toBe("inference/nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8"); + }); + + it("updates the first default agent with a string model when no main agent exists (#7210)", () => { + const merged = mergeOpenClawRestoredConfig( + { + agents: { + defaults: { model: { primary: "inference/stale" } }, + list: [ + { id: "invalid-default", default: true, model: { primary: "inference/stale" } }, + { id: "valid-default", default: true, model: "inference/stale" }, + ], + }, + }, + { agents: { defaults: { model: { primary: "inference/fresh" } } } }, + ) as { agents: { list: { id: string; model: unknown }[] } }; + + expect(merged.agents.list).toEqual([ + { id: "invalid-default", default: true, model: { primary: "inference/stale" } }, + { id: "valid-default", default: true, model: "inference/fresh" }, + ]); + }); + it("keeps rebuilt runtime-owned config while restoring durable backup-only settings", () => { const merged = mergeOpenClawRestoredConfig( { diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index 95162056cfd..6443a57766f 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -33,8 +33,16 @@ export const OPENCLAW_CONFIG_RESTORE_OWNERSHIP = { providerRuntimeOwnedFields: ["baseUrl", "api", "apiKey"], /** A model entry's routing identity is owned by the fresh rebuild. */ modelRuntimeOwnedFields: ["id", "name"], - /** Durable user-owned top-level sections are inherited from the backup. */ + /** + * Durable user-owned top-level sections are inherited from the backup. + * `agents` is durable except its primary model routing reference, which the + * fresh rebuild re-owns (see `agentPrimaryModelPath`) so a managed-model + * switch followed by rebuild does not leave the agent pinned to the old + * model. + */ backupDurableSections: ["mcp", "mcpServers", "customAgents", "agents"], + /** Fresh rebuild owns the agent's primary model routing within `agents`. */ + agentPrimaryModelPath: ["agents", "defaults", "model", "primary"], /** NemoClaw's cross-agent disclosure selection owns this generated key. */ currentGeneratedToolFields: ["toolSearch"], } as const; @@ -422,6 +430,75 @@ export interface OpenClawConfigMergeOptions { previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; } +function ensureMergedObject(record: Record, key: string): Record { + const existing = record[key]; + if (isPlainObject(existing)) return existing as Record; + const created: Record = {}; + record[key] = created; + return created; +} + +/** Read the fresh rebuild's `agents.defaults.model.primary`, or undefined. */ +function readAgentPrimaryModelRef(config: Record): string | undefined { + const agents = config.agents; + if (!isPlainObject(agents)) return undefined; + const defaults = agents.defaults; + if (!isPlainObject(defaults)) return undefined; + const model = defaults.model; + if (!isPlainObject(model)) return undefined; + return typeof model.primary === "string" ? model.primary : undefined; +} + +/** + * Point the merged main/default agent's list model at the fresh primary, + * mirroring `updatePrimaryAgentListModel` in the inference-set path: the agent + * with id `main` wins; otherwise the first `default: true` agent, and only when + * its `model` is a string routing reference. + */ +function updateMainAgentListModel(agents: Record, primaryModelRef: string): void { + const list = agents.list; + if (!Array.isArray(list)) return; + let defaultAgent: Record | undefined; + for (const entry of list) { + if (!isPlainObject(entry)) continue; + if (entry.id === "main") { + if (typeof entry.model === "string") entry.model = primaryModelRef; + return; + } + if (!defaultAgent && entry.default === true && typeof entry.model === "string") { + defaultAgent = entry; + } + } + if (defaultAgent) defaultAgent.model = primaryModelRef; +} + +/** + * Re-own the agent's primary model routing from the fresh rebuild. + * + * `agents` is backup-durable, so the overlay inherits the user's agent config + * from the snapshot — including a stale `model.primary` captured before a + * managed-model switch. `models.providers` routing is already refreshed, but + * the agent routes on `agents.defaults.model.primary` (and the matching + * main/default `agents.list[].model`), so without this the rebuilt sandbox + * keeps labelling/routing the previous model. This is issue #7210 (the + * `rebuild --tool-disclosure progressive` config-binding variant, where an + * MCP-present sandbox is switched via rebuild instead of a full recreate); + * #7011 is the related hard-failure form. Only override when the fresh config + * carries a primary, so backups with no rebuild-owned routing are untouched. + */ +function reconcileAgentPrimaryModel( + merged: Record, + currentConfig: Record, +): void { + const freshPrimary = readAgentPrimaryModelRef(currentConfig); + if (freshPrimary === undefined) return; + const agents = ensureMergedObject(merged, "agents"); + const defaults = ensureMergedObject(agents, "defaults"); + const model = ensureMergedObject(defaults, "model"); + model.primary = freshPrimary; + updateMainAgentListModel(agents, freshPrimary); +} + export function mergeOpenClawRestoredConfig( backedUpConfig: unknown, currentConfig: unknown, @@ -459,6 +536,7 @@ export function mergeOpenClawRestoredConfig( freshOwnership, ); merged.tools = mergeOpenClawTools(backedUpConfig.tools, currentConfig.tools); + reconcileAgentPrimaryModel(merged, currentConfig); return merged; } diff --git a/src/lib/state/sandbox-recreated-openclaw-restore.test.ts b/src/lib/state/sandbox-recreated-openclaw-restore.test.ts index 6fd08d0f1f5..93fbfc5e47c 100644 --- a/src/lib/state/sandbox-recreated-openclaw-restore.test.ts +++ b/src/lib/state/sandbox-recreated-openclaw-restore.test.ts @@ -242,6 +242,35 @@ describe("recreated OpenClaw state restore", () => { expect(result.cleanupCommand).toContain("! -name 'weather'"); }); + it("uses fresh primary-model routing during an ordinary sandbox re-create (#7011)", () => { + const result = runRestoreScenario({ + previousPluginInstalls: [], + freshPluginInstalls: [], + backupExtensionDirs: [], + backupConfig: { + agents: { + defaults: { + model: { primary: "inference/stale-model" }, + thinkingDefault: "off", + }, + list: [{ id: "main", default: true, model: "inference/stale-model" }], + }, + }, + freshConfig: { + agents: { defaults: { model: { primary: "inference/fresh-model" } } }, + }, + }); + + expectSuccessfulRestore(result); + expect(result.restoredConfig.agents).toEqual({ + defaults: { + model: { primary: "inference/fresh-model" }, + thinkingDefault: "off", + }, + list: [{ id: "main", default: true, model: "inference/fresh-model" }], + }); + }); + it("reconciles populated previous and fresh image-plugin provenance during config restore", () => { const previousWeather = imageInstall("weather", "weather-v1"); const freshWeather = imageInstall("weather", "weather-v2");