Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 64 additions & 0 deletions src/lib/state/openclaw-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,70 @@ 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<string, unknown>; thinkingDefault: unknown };
list: Record<string, unknown>[];
};
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("keeps rebuilt runtime-owned config while restoring durable backup-only settings", () => {
const merged = mergeOpenClawRestoredConfig(
{
Expand Down
82 changes: 81 additions & 1 deletion src/lib/state/openclaw-config-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -422,6 +430,77 @@ export interface OpenClawConfigMergeOptions {
previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[];
}

function ensureMergedObject(record: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = record[key];
if (isPlainObject(existing)) return existing as Record<string, unknown>;
const created: Record<string, unknown> = {};
record[key] = created;
return created;
}

/** Read the fresh rebuild's `agents.defaults.model.primary`, or undefined. */
function readAgentPrimaryModelRef(config: Record<string, unknown>): 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<string, unknown>, primaryModelRef: string): void {
const list = agents.list;
if (!Array.isArray(list)) return;
let defaultAgent: Record<string, unknown> | 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) defaultAgent = entry;
}
if (defaultAgent && typeof defaultAgent.model === "string") defaultAgent.model = primaryModelRef;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
* 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.
*
* removalCondition: drop this once the fresh-config generator owns the agent
* primary during restore (i.e. `agents` model routing is no longer inherited
* wholesale from the backup snapshot).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
*/
function reconcileAgentPrimaryModel(
merged: Record<string, unknown>,
currentConfig: Record<string, unknown>,
): 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,
Expand Down Expand Up @@ -459,6 +538,7 @@ export function mergeOpenClawRestoredConfig(
freshOwnership,
);
merged.tools = mergeOpenClawTools(backedUpConfig.tools, currentConfig.tools);
reconcileAgentPrimaryModel(merged, currentConfig);

return merged;
}
Loading