fix: preserve agent provider params on config writes

This commit is contained in:
Peter Steinberger
2026-04-29 13:02:17 +01:00
parent 1424982792
commit 579334f9f8
5 changed files with 276 additions and 4 deletions

View File

@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
- Shared labels: preserve Unicode combining marks and NFC-equivalent accented text in group/channel slug normalization so non-Latin labels no longer lose meaningful characters. Fixes #58932; carries forward #58942 and #58995. Thanks @fengqing-git, @Starhappysh, and @koen666.
- Channels/Telegram: include probed video width and height when sending regular Telegram videos, so portrait clips render with the correct orientation instead of being stretched by clients. (#18915) Thanks @storyarcade.
- Docs/Hetzner: clarify that SSH tunnel access requires `AllowTcpForwarding local` before running `ssh -L`, so hardened VPS sshd configs do not block loopback Gateway access. Fixes #54557; carries forward #54564; refs #54954. Thanks @satishkc7, @blackstrype, and @Aftabbs.
- Agents/config: preserve authored `agents.defaults.params` and per-model `agents.defaults.models[].params` during narrowed internal config writes, so OpenAI transport overrides such as `transport: "sse"` and `openaiWsWarmup: false` are not stripped from `openclaw.json`. Fixes #73607; refs #73428. Thanks @quangtran88.
- Agents/model config: resolve per-model extra params through canonical model keys while preserving legacy double-prefixed fallback entries, so provider-prefixed model ids such as `openrouter/auto` keep their configured runtime params. (#44319) Thanks @HenryXiaoYang.
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
- Control UI: keep Agents Overview and config-form select dropdowns on their configured value after options render while preserving inherited agent model placeholders. Fixes #40352; carries forward #52948. Thanks @xiaoquanidea.

View File

@@ -1988,6 +1988,7 @@ export function createConfigIO(
sourceConfig: snapshot.resolved,
nextConfig: cfg,
rootAuthoredConfig: snapshot.parsed,
unsetPaths,
});
try {
const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, {

View File

@@ -12,7 +12,7 @@ import {
setRuntimeConfigSnapshot,
writeConfigFile,
} from "./io.js";
import type { ConfigFileSnapshot } from "./types.openclaw.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "./types.openclaw.js";
// Mock the plugin manifest registry so we can register a fake channel whose
// AJV JSON Schema carries a `default` value. This lets the #56772 regression
@@ -602,6 +602,86 @@ describe("config io write", () => {
});
});
it("keeps authored agent provider params during narrowed internal agent writes", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
const original = {
gateway: { mode: "local" },
agents: {
defaults: {
params: { transport: "sse", openaiWsWarmup: false },
models: {
"openai/gpt-5.4": {
alias: "GPT",
params: { transport: "sse", openaiWsWarmup: false },
},
},
},
list: [{ id: "main" }],
},
} satisfies ConfigFileSnapshot["sourceConfig"];
const originalRaw = `${JSON.stringify(original, null, 2)}\n`;
await fs.writeFile(configPath, originalRaw, "utf-8");
const io = createConfigIO({
env: { VITEST: "true" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
const baseSnapshot = {
path: configPath,
exists: true,
raw: originalRaw,
parsed: original,
sourceConfig: original,
resolved: original,
valid: true,
runtimeConfig: {
...original,
agents: {
...original.agents,
defaults: {
...original.agents.defaults,
maxConcurrent: 4,
},
},
},
config: {
...original,
agents: {
...original.agents,
defaults: {
...original.agents.defaults,
maxConcurrent: 4,
},
},
},
issues: [],
warnings: [],
legacyIssues: [],
} satisfies ConfigFileSnapshot;
await io.writeConfigFile(
{
gateway: { mode: "local" },
agents: { list: [{ id: "main" }, { id: "ops" }] },
},
{ baseSnapshot },
);
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as OpenClawConfig;
expect(persisted.agents?.defaults?.params).toEqual({
transport: "sse",
openaiWsWarmup: false,
});
expect(persisted.agents?.defaults?.models?.["openai/gpt-5.4"]).toEqual({
alias: "GPT",
params: { transport: "sse", openaiWsWarmup: false },
});
expect(persisted.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]);
});
});
it("preserves parsed source config when snapshot validation throws", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");

View File

@@ -86,6 +86,78 @@ describe("config io write prepare", () => {
expect(persisted.plugins?.installs).toBeUndefined();
});
it("preserves authored agent provider params during narrowed agent-list writes", () => {
const sourceConfig = {
agents: {
defaults: {
params: { transport: "sse", openaiWsWarmup: false },
models: {
"openai/gpt-5.4": {
alias: "GPT",
params: { transport: "sse", openaiWsWarmup: false },
},
},
},
list: [{ id: "main" }],
},
gateway: { mode: "local" },
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {
...sourceConfig,
agents: {
...sourceConfig.agents,
defaults: {
...sourceConfig.agents.defaults,
maxConcurrent: 4,
},
},
},
sourceConfig,
nextConfig: {
agents: { list: [{ id: "main" }, { id: "ops" }] },
gateway: { mode: "local" },
},
}) as OpenClawConfig;
expect(persisted.agents?.defaults?.params).toEqual({
transport: "sse",
openaiWsWarmup: false,
});
expect(persisted.agents?.defaults?.models?.["openai/gpt-5.4"]).toEqual({
alias: "GPT",
params: { transport: "sse", openaiWsWarmup: false },
});
expect(persisted.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]);
});
it("allows explicit unsets to remove authored agent provider params", () => {
const sourceConfig: OpenClawConfig = {
agents: {
defaults: {
params: { transport: "sse", openaiWsWarmup: false },
models: {
"openai/gpt-5.4": {
params: { transport: "sse", openaiWsWarmup: false },
},
},
},
},
};
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: sourceConfig,
sourceConfig,
nextConfig: { agents: { defaults: { models: { "openai/gpt-5.4": {} } } } },
unsetPaths: [
["agents", "defaults", "params"],
["agents", "defaults", "models", "openai/gpt-5.4", "params"],
],
}) as OpenClawConfig;
expect(persisted.agents?.defaults).not.toHaveProperty("params");
expect(persisted.agents?.defaults?.models?.["openai/gpt-5.4"]).not.toHaveProperty("params");
});
it("preserves untouched include-owned subtrees during unrelated writes", () => {
const persisted = resolvePersistCandidateForWrite({
runtimeConfig: {

View File

@@ -123,6 +123,115 @@ function setPathValue(value: unknown, path: string[], nextValue: unknown): unkno
};
}
function pathStartsWith(path: string[], prefix: string[]): boolean {
return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
}
function pathOverlapsAny(path: string[], candidates: readonly string[][] | undefined): boolean {
return Boolean(
candidates?.some(
(candidate) => pathStartsWith(path, candidate) || pathStartsWith(candidate, path),
),
);
}
function isIncludeOwnedPath(rootAuthoredConfig: unknown, path: string[]): boolean {
return collectIncludeOwnedPaths(rootAuthoredConfig).some(
(includePath) => pathStartsWith(path, includePath) || pathStartsWith(includePath, path),
);
}
function setPathValueCreatingParents(value: unknown, path: string[], nextValue: unknown): unknown {
if (path.length === 0) {
return cloneUnknown(nextValue);
}
const [head, ...tail] = path;
const record = isRecord(value) ? value : {};
return {
...record,
[head]: setPathValueCreatingParents(record[head], tail, nextValue),
};
}
function preserveSourceValueAtPath(params: {
persistedCandidate: unknown;
sourceConfig: unknown;
nextConfig: unknown;
rootAuthoredConfig: unknown;
unsetPaths?: readonly string[][];
path: string[];
sourceValue?: unknown;
}): unknown {
if (pathOverlapsAny(params.path, params.unsetPaths)) {
return params.persistedCandidate;
}
if (isIncludeOwnedPath(params.rootAuthoredConfig, params.path)) {
return params.persistedCandidate;
}
if (getPathValue(params.nextConfig, params.path) !== undefined) {
return params.persistedCandidate;
}
const sourceValue = params.sourceValue ?? getPathValue(params.sourceConfig, params.path);
if (
sourceValue === undefined ||
getPathValue(params.persistedCandidate, params.path) !== undefined
) {
return params.persistedCandidate;
}
return setPathValueCreatingParents(params.persistedCandidate, params.path, sourceValue);
}
function preserveAuthoredAgentParams(params: {
persistedCandidate: unknown;
sourceConfig: unknown;
nextConfig: unknown;
rootAuthoredConfig: unknown;
unsetPaths?: readonly string[][];
}): unknown {
const defaults = getPathValue(params.sourceConfig, ["agents", "defaults"]);
if (!isRecord(defaults)) {
return params.persistedCandidate;
}
let next = params.persistedCandidate;
if (Object.prototype.hasOwnProperty.call(defaults, "params")) {
next = preserveSourceValueAtPath({
...params,
persistedCandidate: next,
path: ["agents", "defaults", "params"],
sourceValue: defaults.params,
});
}
const models = defaults.models;
if (!isRecord(models)) {
return next;
}
for (const [modelId, modelEntry] of Object.entries(models)) {
if (!isRecord(modelEntry) || !Object.prototype.hasOwnProperty.call(modelEntry, "params")) {
continue;
}
const modelPath = ["agents", "defaults", "models", modelId];
const paramsPath = [...modelPath, "params"];
if (getPathValue(next, modelPath) === undefined) {
next = preserveSourceValueAtPath({
...params,
persistedCandidate: next,
path: modelPath,
sourceValue: modelEntry,
});
continue;
}
next = preserveSourceValueAtPath({
...params,
persistedCandidate: next,
path: paramsPath,
sourceValue: modelEntry.params,
});
}
return next;
}
function preserveUntouchedIncludes(params: {
patch: unknown;
rootAuthoredConfig: unknown;
@@ -147,19 +256,28 @@ export function resolvePersistCandidateForWrite(params: {
sourceConfig: unknown;
nextConfig: unknown;
rootAuthoredConfig?: unknown;
unsetPaths?: readonly string[][];
}): unknown {
const patch = createMergePatch(params.runtimeConfig, params.nextConfig);
const projectedSource = projectSourceOntoRuntimeShape(params.sourceConfig, params.runtimeConfig);
const rootAuthoredConfig = params.rootAuthoredConfig ?? params.sourceConfig;
const persisted = preserveUntouchedIncludes({
patch,
rootAuthoredConfig: params.rootAuthoredConfig ?? params.sourceConfig,
rootAuthoredConfig,
persistedCandidate: applyMergePatch(projectedSource, patch),
});
return preserveRootSchemaUri({
rootAuthoredConfig: params.rootAuthoredConfig ?? params.sourceConfig,
const withSchema = preserveRootSchemaUri({
rootAuthoredConfig,
nextConfig: params.nextConfig,
persistedCandidate: persisted,
});
return preserveAuthoredAgentParams({
sourceConfig: params.sourceConfig,
nextConfig: params.nextConfig,
rootAuthoredConfig,
persistedCandidate: withSchema,
unsetPaths: params.unsetPaths,
});
}
function readRootSchemaUri(value: unknown): string | undefined {