mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(config): share canonical deep merge
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **Plugin config merging:** expose the canonical deep-merge policy through the plugin SDK, migrate Voice Call to it, and remove the duplicate merge engine and direct `defu` dependency.
|
||||
- **Skill Workshop history review:** add a manual, newest-first session scan that progressively searches older substantial work for conservative skill ideas, stores only SQLite cursor metadata, and leaves up to three results as pending proposals even when autonomous self-learning is disabled. (#106182)
|
||||
- **SQLite snapshots:** add `openclaw backup sqlite create|list|verify|restore` for compact, verified global and per-agent database artifacts with fresh-target-only restore. (#94805) Thanks @giodl73-repo.
|
||||
- **GPT-5.6 Ultra and runtime switching:** support Sol, Terra, and Luna across OpenClaw and Codex engines; keep model, runtime, and thinking selection atomic through `/model` and fallback; and add live matrix coverage for both harnesses. (#98021) Thanks @anyech.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
9bc00d1b67cf1902b3deb36a79553684cccd0cf5d405e9bf0373065ec1e1005f plugin-sdk-api-baseline.json
|
||||
517cab00f614e15bf68b68468b0398c9faae6a8035dbfd7512e6527d815046c5 plugin-sdk-api-baseline.jsonl
|
||||
e511874a61fa05dc67613e171fb9617102525db3efbe4c7240da0d3e84c270d8 plugin-sdk-api-baseline.json
|
||||
575271760452cbd7da8fb57eef8ffce527254ccb876aaefd850c13b9522dd744 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -153,7 +153,7 @@ SDK.
|
||||
| Need | Import |
|
||||
| --- | --- |
|
||||
| Config types such as `OpenClawConfig` | `openclaw/plugin-sdk/config-contracts` |
|
||||
| Already-loaded config assertions and plugin-entry config lookup | `openclaw/plugin-sdk/plugin-config-runtime` |
|
||||
| Already-loaded config assertions, plugin-entry config lookup, and config merging | `openclaw/plugin-sdk/plugin-config-runtime` |
|
||||
| Current runtime snapshot reads | `openclaw/plugin-sdk/runtime-config-snapshot` |
|
||||
| Config writes | `openclaw/plugin-sdk/config-mutation` |
|
||||
| Session store helpers | `openclaw/plugin-sdk/session-store-runtime` |
|
||||
|
||||
@@ -47,7 +47,7 @@ The mutation helpers return `afterWrite` plus a typed `followUp` summary so call
|
||||
`api.runtime.config.loadConfig()` and `api.runtime.config.writeConfigFile(...)` are deprecated. They warn once per plugin at runtime and remain available only for old external plugins during the migration window. Bundled plugins must not use them: an internal config boundary guard fails the build if plugin code calls them or imports those helpers from plugin SDK subpaths. Use `current()`, a passed-in `cfg`, `mutateConfigFile(...)`, or `replaceConfigFile(...)` instead.
|
||||
</Warning>
|
||||
|
||||
For direct SDK imports, prefer the focused config subpaths over the broad `openclaw/plugin-sdk/config-runtime` compatibility barrel: `config-contracts` for types, `plugin-config-runtime` for already-loaded config assertions and plugin entry lookup, `runtime-config-snapshot` for current process snapshots, and `config-mutation` for writes. Bundled plugin tests should mock these focused subpaths directly instead of mocking the broad compatibility barrel.
|
||||
For direct SDK imports, prefer the focused config subpaths over the broad `openclaw/plugin-sdk/config-runtime` compatibility barrel: `config-contracts` for types, `plugin-config-runtime` for already-loaded config assertions, plugin entry lookup, and canonical config merging, `runtime-config-snapshot` for current process snapshots, and `config-mutation` for writes. Bundled plugin tests should mock these focused subpaths directly instead of mocking the broad compatibility barrel.
|
||||
|
||||
Internal OpenClaw runtime code follows the same direction: load config once at the CLI, gateway, or process boundary, then pass that value through. Successful mutation writes refresh the process runtime snapshot and advance its internal revision; long-lived caches should key off the runtime-owned cache key instead of serializing config locally. Long-lived runtime modules have a zero-tolerance scanner for ambient `loadConfig()` calls; use a passed `cfg`, a request `context.getRuntimeConfig()`, or `getRuntimeConfig()` at an explicit process boundary.
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` |
|
||||
| `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, advertised LAN host resolution, and channel-status patch helpers |
|
||||
| `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types |
|
||||
| `plugin-sdk/plugin-config-runtime` | Runtime plugin-config lookup helpers such as `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` |
|
||||
| `plugin-sdk/plugin-config-runtime` | Runtime plugin-config helpers such as `mergeDeep`, `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` |
|
||||
| `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` |
|
||||
| `plugin-sdk/message-tool-delivery-hints` | Shared message-tool delivery metadata hint strings |
|
||||
| `plugin-sdk/runtime-config-snapshot` | Current process config snapshot helpers such as `getRuntimeConfig`, `getRuntimeConfigSnapshot`, and test snapshot setters |
|
||||
|
||||
-7
@@ -8,7 +8,6 @@
|
||||
"name": "@openclaw/voice-call",
|
||||
"version": "2026.7.2",
|
||||
"dependencies": {
|
||||
"defu": "6.1.5",
|
||||
"typebox": "1.3.3",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
@@ -22,12 +21,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.5",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.5.tgz",
|
||||
"integrity": "sha512-pwdBJxJuJXmqrLO6s0VBmfbRz+G7FUzkjldAsdi9Yrv86mPyzq0ll1o8+8gB4Gsr6GJHbK1Lh3ngllgTInDCjA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typebox": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"defu": "6.1.5",
|
||||
"typebox": "1.3.3",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Voice Call helper module supports config behavior.
|
||||
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { z } from "zod";
|
||||
import { TtsConfigSchema } from "../api.js";
|
||||
import { deepMergeDefined } from "./deep-merge.js";
|
||||
import { TWILIO_REGIONS } from "./providers/twilio-region.js";
|
||||
import { DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS } from "./realtime-defaults.js";
|
||||
|
||||
@@ -550,7 +550,7 @@ function normalizeVoiceCallTtsConfig(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return TtsConfigSchema.parse(deepMergeDefined(defaults ?? {}, overrides ?? {}));
|
||||
return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {}));
|
||||
}
|
||||
|
||||
function normalizePhoneRouteKey(phone: string | undefined): string {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Voice Call tests cover deep merge plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deepMergeDefined } from "./deep-merge.js";
|
||||
|
||||
describe("deepMergeDefined", () => {
|
||||
it("deep merges nested plain objects and preserves base values for undefined overrides", () => {
|
||||
expect(
|
||||
deepMergeDefined(
|
||||
{
|
||||
provider: { voice: "alloy", language: "en" },
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
provider: { voice: "echo", language: undefined, nullable: null },
|
||||
enabled: undefined,
|
||||
introduced: { nullable: null },
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
provider: { voice: "echo", language: "en", nullable: null },
|
||||
enabled: true,
|
||||
introduced: { nullable: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces non-objects directly and blocks dangerous prototype keys", () => {
|
||||
expect(deepMergeDefined(["a"], ["b"])).toEqual(["b"]);
|
||||
expect(deepMergeDefined({ values: ["a"] }, { values: ["b"] })).toEqual({ values: ["b"] });
|
||||
expect(deepMergeDefined("base", undefined)).toBe("base");
|
||||
expect(
|
||||
deepMergeDefined(
|
||||
{ safe: { keep: true } },
|
||||
{
|
||||
safe: { next: true },
|
||||
__proto__: { polluted: true },
|
||||
constructor: { polluted: true },
|
||||
prototype: { polluted: true },
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
safe: { keep: true, next: true },
|
||||
});
|
||||
expect(deepMergeDefined({ value: ["base"] }, { value: { enabled: true } })).toEqual({
|
||||
value: { enabled: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
// Voice Call plugin module implements deep merge behavior.
|
||||
import defu from "defu";
|
||||
import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
// Prototype-safe deep merge for config overrides that ignores undefined values.
|
||||
|
||||
const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const NULL_OVERRIDE = Symbol("null-override");
|
||||
|
||||
class ArrayOverride {
|
||||
constructor(readonly value: unknown[]) {}
|
||||
}
|
||||
|
||||
function prepareObject(
|
||||
value: Record<string, unknown>,
|
||||
encodeNull: boolean,
|
||||
): Record<string, unknown> {
|
||||
const prepared: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (BLOCKED_MERGE_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (encodeNull && entry === null) {
|
||||
prepared[key] = NULL_OVERRIDE;
|
||||
} else if (encodeNull && Array.isArray(entry)) {
|
||||
// Defu concatenates arrays. A non-plain wrapper preserves the plugin's
|
||||
// existing replacement policy until the merged result is decoded.
|
||||
prepared[key] = new ArrayOverride(entry);
|
||||
} else if (isPlainObject(entry)) {
|
||||
prepared[key] = prepareObject(entry, encodeNull);
|
||||
} else {
|
||||
prepared[key] = entry;
|
||||
}
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function decodeNullOverrides(value: unknown): unknown {
|
||||
if (value instanceof ArrayOverride) {
|
||||
return value.value;
|
||||
}
|
||||
if (value === NULL_OVERRIDE) {
|
||||
return null;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, decodeNullOverrides(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Deep-merge plain objects, keeping base values when overrides are undefined. */
|
||||
export function deepMergeDefined(base: unknown, override: unknown): unknown {
|
||||
if (!isPlainObject(base) || !isPlainObject(override)) {
|
||||
return override === undefined ? base : override;
|
||||
}
|
||||
return decodeNullOverrides(defu(prepareObject(override, true), prepareObject(base, false)));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Voice Call plugin module implements telephony tts behavior.
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import {
|
||||
parseTtsDirectives,
|
||||
type SpeechModelOverridePolicy,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/speech";
|
||||
import type { VoiceCallTtsConfig } from "./config.js";
|
||||
import type { CoreConfig } from "./core-bridge.js";
|
||||
import { deepMergeDefined } from "./deep-merge.js";
|
||||
import { convertPcmToMulaw8k } from "./telephony-audio.js";
|
||||
|
||||
// Telephony TTS adapter that applies voice-call overrides and emits 8kHz mulaw audio.
|
||||
@@ -150,7 +150,7 @@ function mergeTtsConfig(
|
||||
if (!base) {
|
||||
return override;
|
||||
}
|
||||
return deepMergeDefined(base, override) as VoiceCallTtsConfig;
|
||||
return mergeDeep(base, override) as VoiceCallTtsConfig;
|
||||
}
|
||||
|
||||
/** Resolve directive override policy for telephony synthesis. */
|
||||
|
||||
Generated
-7
@@ -31,7 +31,6 @@
|
||||
"clawpdf": "0.3.0",
|
||||
"commander": "15.0.0",
|
||||
"croner": "10.0.1",
|
||||
"defu": "6.1.5",
|
||||
"diff": "9.0.0",
|
||||
"dotenv": "17.4.2",
|
||||
"entities": "8.0.0",
|
||||
@@ -1119,12 +1118,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.5",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.5.tgz",
|
||||
"integrity": "sha512-pwdBJxJuJXmqrLO6s0VBmfbRz+G7FUzkjldAsdi9Yrv86mPyzq0ll1o8+8gB4Gsr6GJHbK1Lh3ngllgTInDCjA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
|
||||
@@ -2024,7 +2024,6 @@
|
||||
"clawpdf": "0.3.0",
|
||||
"commander": "15.0.0",
|
||||
"croner": "10.0.1",
|
||||
"defu": "6.1.5",
|
||||
"diff": "9.0.0",
|
||||
"dotenv": "17.4.2",
|
||||
"entities": "8.0.0",
|
||||
|
||||
Generated
-6
@@ -109,9 +109,6 @@ importers:
|
||||
croner:
|
||||
specifier: 10.0.1
|
||||
version: 10.0.1
|
||||
defu:
|
||||
specifier: 6.1.5
|
||||
version: 6.1.5
|
||||
diff:
|
||||
specifier: 9.0.0
|
||||
version: 9.0.0
|
||||
@@ -1803,9 +1800,6 @@ importers:
|
||||
|
||||
extensions/voice-call:
|
||||
dependencies:
|
||||
defu:
|
||||
specifier: 6.1.5
|
||||
version: 6.1.5
|
||||
typebox:
|
||||
specifier: 1.3.3
|
||||
version: 1.3.3
|
||||
|
||||
@@ -204,12 +204,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
),
|
||||
publicExports: readPluginSdkSurfaceBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
|
||||
10648,
|
||||
10649,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
|
||||
5360,
|
||||
5361,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
+23
-62
@@ -1,4 +1,3 @@
|
||||
import defu from "defu";
|
||||
import { isPlainObject } from "./plain-object.js";
|
||||
import { isBlockedObjectKey } from "./prototype-keys.js";
|
||||
|
||||
@@ -7,67 +6,15 @@ type DeepMergeOptions = {
|
||||
undefinedValues?: "skip" | "replace";
|
||||
};
|
||||
|
||||
const NULL_OVERRIDE = Symbol("null-override");
|
||||
const UNDEFINED_OVERRIDE = Symbol("undefined-override");
|
||||
|
||||
class ArrayOverride {
|
||||
constructor(readonly value: unknown[]) {}
|
||||
}
|
||||
|
||||
function prepareObject(
|
||||
value: Record<string, unknown>,
|
||||
undefinedValues: "skip" | "replace",
|
||||
encodeOverrides: boolean,
|
||||
arrays: "replace" | "concat",
|
||||
base?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const prepared: Record<string, unknown> = {};
|
||||
function sanitizePlainObject(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (isBlockedObjectKey(key)) {
|
||||
continue;
|
||||
}
|
||||
const baseEntry = base?.[key];
|
||||
if (encodeOverrides && entry === null) {
|
||||
prepared[key] = NULL_OVERRIDE;
|
||||
} else if (encodeOverrides && entry === undefined && undefinedValues === "replace") {
|
||||
prepared[key] = UNDEFINED_OVERRIDE;
|
||||
} else if (encodeOverrides && Array.isArray(entry)) {
|
||||
// Defu concatenates arrays source-first. A non-plain wrapper carries the
|
||||
// caller's replacement or base-first concatenation policy through recursion.
|
||||
prepared[key] = new ArrayOverride(
|
||||
arrays === "concat" && Array.isArray(baseEntry) ? [...baseEntry, ...entry] : entry,
|
||||
);
|
||||
} else if (isPlainObject(entry)) {
|
||||
prepared[key] = prepareObject(
|
||||
entry,
|
||||
undefinedValues,
|
||||
encodeOverrides,
|
||||
arrays,
|
||||
isPlainObject(baseEntry) ? baseEntry : undefined,
|
||||
);
|
||||
} else {
|
||||
prepared[key] = entry;
|
||||
}
|
||||
sanitized[key] = isPlainObject(entry) ? sanitizePlainObject(entry) : entry;
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function decodeOverrides(value: unknown): unknown {
|
||||
if (value instanceof ArrayOverride) {
|
||||
return value.value;
|
||||
}
|
||||
if (value === NULL_OVERRIDE) {
|
||||
return null;
|
||||
}
|
||||
if (value === UNDEFINED_OVERRIDE) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, decodeOverrides(entry)]),
|
||||
);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** Merge plain objects while preserving OpenClaw's null, undefined, and array policies. */
|
||||
@@ -86,9 +33,23 @@ export function mergeDeep(
|
||||
return override === undefined && undefinedValues === "skip" ? base : override;
|
||||
}
|
||||
|
||||
// defu intentionally treats null/undefined as absent. Sentinels retain explicit
|
||||
// overrides while its recursive merge remains the single merge implementation.
|
||||
const preparedBase = prepareObject(base, undefinedValues, false, arrays);
|
||||
const preparedOverride = prepareObject(override, undefinedValues, true, arrays, base);
|
||||
return decodeOverrides(defu(preparedOverride, preparedBase));
|
||||
// Clone nested records before merging so base-only and override-only branches
|
||||
// enforce the same blocked-key boundary.
|
||||
const merged = sanitizePlainObject(base);
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (isBlockedObjectKey(key) || (value === undefined && undefinedValues === "skip")) {
|
||||
continue;
|
||||
}
|
||||
const current = merged[key];
|
||||
if (isPlainObject(value)) {
|
||||
merged[key] = isPlainObject(current)
|
||||
? mergeDeep(current, value, options)
|
||||
: sanitizePlainObject(value);
|
||||
} else if (arrays === "concat" && Array.isArray(current) && Array.isArray(value)) {
|
||||
merged[key] = [...current, ...value];
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
|
||||
|
||||
export { normalizePluginsConfig, resolveEffectiveEnableState };
|
||||
export { mergeDeep } from "../infra/deep-merge.js";
|
||||
|
||||
/** Requires an already-resolved runtime config at plugin runtime boundaries. */
|
||||
export function requireRuntimeConfig(config: OpenClawConfig, context: string): OpenClawConfig {
|
||||
|
||||
Reference in New Issue
Block a user