Skip to content
Closed
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
50 changes: 50 additions & 0 deletions src/lib/state/openclaw-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,56 @@ describe("mergeOpenClawRestoredConfig", () => {
).toEqual({ command: "npx", args: ["-y", "fs-server", "/work"] });
});

it("keeps the fresh agent model routing while restoring durable agent tuning (#7011)", () => {
// Reporter scenario: switch inference provider/model, then rebuild. The
// backup (pre-switch config) must not revert agents.defaults.model.primary
// or a per-agent model ref, or the agent keeps routing to the old model
// even though the host route was updated.
const merged = mergeOpenClawRestoredConfig(
{
agents: {
defaults: {
model: { primary: "inference/nvidia/nemotron-3-super-120b-a12b" },
thinkingDefault: "off",
timeoutSeconds: 600,
},
list: [
{ id: "main", default: true, model: "inference/nvidia/nemotron-3-super-120b-a12b" },
],
},
mcp: { servers: { fs: { command: "npx" } } },
},
{
agents: {
defaults: {
model: { primary: "inference/nvidia/nemotron-3-ultra-550b-a55b" },
thinkingDefault: "on",
},
list: [{ id: "main", default: true }],
},
},
);

const agents = (merged as { agents: Record<string, unknown> }).agents;
const defaults = agents.defaults as Record<string, unknown>;
// Routing identity is owned by the fresh rebuild.
expect((defaults.model as Record<string, unknown>).primary).toBe(
"inference/nvidia/nemotron-3-ultra-550b-a55b",
);
// Durable user tuning is restored from the backup.
expect(defaults.thinkingDefault).toBe("off");
expect(defaults.timeoutSeconds).toBe(600);
// The fresh main agent routes via defaults (no per-agent model); the stale
// backup ref must not be resurrected.
const mainAgent = (agents.list as Record<string, unknown>[])[0];
expect("model" in mainAgent).toBe(false);
expect(mainAgent.default).toBe(true);
// Unrelated durable sections survive.
expect((merged as { mcp: { servers: Record<string, unknown> } }).mcp.servers.fs).toEqual({
command: "npx",
});
});

it("keeps current provider and plugin entries for matching keys", () => {
const merged = mergeOpenClawRestoredConfig(
{
Expand Down
66 changes: 65 additions & 1 deletion src/lib/state/openclaw-config-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ 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. Note
* `agents` is durable EXCEPT for its model routing identity
* (`agents.defaults.model` and per-agent `model` refs), which the fresh
* rebuild owns so a provider/model switch is not reverted (see
* mergeOpenClawAgents, issue #7011).
*/
backupDurableSections: ["mcp", "mcpServers", "customAgents", "agents"],
/** NemoClaw's cross-agent disclosure selection owns this generated key. */
currentGeneratedToolFields: ["toolSearch"],
Expand Down Expand Up @@ -368,6 +374,61 @@ function mergeOpenClawModels(backupModels: unknown, currentModels: unknown): unk
return merged;
}

function agentEntryId(entry: unknown): string | null {
if (isPlainObject(entry) && typeof entry.id === "string" && entry.id) return entry.id;
return null;
}

/**
* Merge the `agents` section. The backup restores the user's durable agent
* config (per-agent customizations and non-routing defaults such as
* `thinkingDefault` / `timeoutSeconds` / `compaction`), but the model *routing*
* identity is owned by the fresh rebuild: `agents.defaults.model` (which holds
* `primary`) and each agent-list entry's `model` ref.
*
* Without this, a provider/model switch is silently reverted on rebuild: the
* backup (pre-switch config) wins the routing ref, so the sandbox keeps routing
* to the old model even though the host route and `inference get` show the new
* one (issue #7011). This mirrors how the `models` section already lets the
* fresh rebuild own the routing identity (id/name) while restoring user tuning.
*/
function mergeOpenClawAgents(backupAgents: unknown, currentAgents: unknown): unknown {
if (!isPlainObject(currentAgents)) return cloneJson(backupAgents ?? currentAgents);
const backup = isPlainObject(backupAgents) ? backupAgents : {};
const merged = mergeJsonObjects(currentAgents, backup);

// Fresh rebuild owns agents.defaults.model (the routing block).
if (isPlainObject(currentAgents.defaults) && "model" in currentAgents.defaults) {
const mergedDefaults = isPlainObject(merged.defaults)
? merged.defaults
: ((merged.defaults = {}) as Record<string, unknown>);
mergedDefaults.model = cloneJson(currentAgents.defaults.model);
}

// Fresh rebuild owns each agent-list entry's `model` routing ref (by id). An
// agent that the fresh config routes via defaults (no `model` key) must not
// resurrect a stale per-agent ref from the backup.
if (Array.isArray(merged.list) && Array.isArray(currentAgents.list)) {
const freshById = new Map<string, Record<string, unknown>>();
for (const entry of currentAgents.list) {
const id = agentEntryId(entry);
if (id && isPlainObject(entry) && !freshById.has(id)) freshById.set(id, entry);
}
merged.list = merged.list.map((entry) => {
if (!isPlainObject(entry)) return entry;
const id = agentEntryId(entry);
const fresh = id ? freshById.get(id) : undefined;
if (!fresh) return entry;
const next: Record<string, unknown> = { ...entry };
if ("model" in fresh) next.model = cloneJson(fresh.model);
else delete next.model;
return next;
});
}
Comment on lines +395 to +427

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Complete fresh ownership of the agent configuration.

The backup overlay replaces the fresh list wholesale and only preserves defaults.model when fresh explicitly provides it. This can drop newly rebuilt agents or resurrect stale default routing.

  • src/lib/state/openclaw-config-merge.ts#L395-L427: reconcile agents.list as an ID-based union and make fresh absence of defaults.model authoritative.
  • src/lib/state/openclaw-config-merge.test.ts#L246-L294: cover a fresh-only agent and a fresh config that intentionally omits defaults.model.
📍 Affects 2 files
  • src/lib/state/openclaw-config-merge.ts#L395-L427 (this comment)
  • src/lib/state/openclaw-config-merge.test.ts#L246-L294
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/state/openclaw-config-merge.ts` around lines 395 - 427, Update
mergeOpenClawAgents in src/lib/state/openclaw-config-merge.ts (lines 395-427) so
agents.list is reconciled as an ID-based union that retains fresh-only agents,
and make fresh omission of defaults.model authoritative by removing any backup
value. Add coverage in src/lib/state/openclaw-config-merge.test.ts (lines
246-294) for a fresh-only agent and a fresh configuration that omits
defaults.model.

Source: Path instructions


return merged;
}

function mergeOpenClawPlugins(
backupPlugins: unknown,
currentPlugins: unknown,
Expand Down Expand Up @@ -452,6 +513,9 @@ export function mergeOpenClawRestoredConfig(
previousOwnership.ids,
);
merged.models = mergeOpenClawModels(backedUpConfig.models, currentConfig.models);
if ("agents" in backedUpConfig || "agents" in currentConfig) {
merged.agents = mergeOpenClawAgents(backedUpConfig.agents, currentConfig.agents);
}
merged.plugins = mergeOpenClawPlugins(
backedUpConfig.plugins,
currentConfig.plugins,
Expand Down
Loading