mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
fix: preserve agent provider params on config writes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1988,6 +1988,7 @@ export function createConfigIO(
|
||||
sourceConfig: snapshot.resolved,
|
||||
nextConfig: cfg,
|
||||
rootAuthoredConfig: snapshot.parsed,
|
||||
unsetPaths,
|
||||
});
|
||||
try {
|
||||
const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user