diff --git a/CHANGELOG.md b/CHANGELOG.md index 044469361b9..e3315959676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/config/io.ts b/src/config/io.ts index 5682125a39b..36f38362ed6 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1988,6 +1988,7 @@ export function createConfigIO( sourceConfig: snapshot.resolved, nextConfig: cfg, rootAuthoredConfig: snapshot.parsed, + unsetPaths, }); try { const resolvedIncludes = resolveConfigIncludes(snapshot.parsed, configPath, { diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index fc910a77f16..16095b48372 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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"); diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 71cad4903b4..cce9d584252 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -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: { diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 161f3c0cd60..94ca27d9af6 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -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 {