diff --git a/src/agents/utils.ts b/src/agents/utils.ts index f0d3519..a45962d 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -5,6 +5,7 @@ import { librarianAgent } from "./librarian" import { exploreAgent } from "./explore" import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer" import { documentWriterAgent } from "./document-writer" +import { deepMerge } from "../shared" const allBuiltinAgents: Record = { oracle: oracleAgent, @@ -18,16 +19,7 @@ function mergeAgentConfig( base: AgentConfig, override: AgentOverrideConfig ): AgentConfig { - return { - ...base, - ...override, - tools: override.tools !== undefined - ? { ...(base.tools ?? {}), ...override.tools } - : base.tools, - permission: override.permission !== undefined - ? { ...(base.permission ?? {}), ...override.permission } - : base.permission, - } + return deepMerge(base, override as Partial) } export function createBuiltinAgents( diff --git a/src/index.ts b/src/index.ts index e3b4602..fdbc8a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools } from "./tools import { BackgroundManager } from "./features/background-agent"; import { createBuiltinMcps } from "./mcp"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; -import { log } from "./shared/logger"; +import { log, deepMerge } from "./shared"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; @@ -89,10 +89,7 @@ function mergeConfigs( return { ...base, ...override, - agents: - override.agents !== undefined - ? { ...(base.agents ?? {}), ...override.agents } - : base.agents, + agents: deepMerge(base.agents, override.agents), disabled_agents: [ ...new Set([ ...(base.disabled_agents ?? []), @@ -105,10 +102,7 @@ function mergeConfigs( ...(override.disabled_mcps ?? []), ]), ], - claude_code: - override.claude_code !== undefined || base.claude_code !== undefined - ? { ...(base.claude_code ?? {}), ...(override.claude_code ?? {}) } - : undefined, + claude_code: deepMerge(base.claude_code, override.claude_code), }; } diff --git a/src/shared/deep-merge.ts b/src/shared/deep-merge.ts new file mode 100644 index 0000000..1f7a79b --- /dev/null +++ b/src/shared/deep-merge.ts @@ -0,0 +1,53 @@ +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const MAX_DEPTH = 50; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +/** + * Deep merges two objects, with override values taking precedence. + * - Objects are recursively merged + * - Arrays are replaced (not concatenated) + * - undefined values in override do not overwrite base values + * + * @example + * deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 }) + * // => { a: 1, b: { c: 10, d: 3 }, e: 5 } + */ +export function deepMerge>(base: T, override: Partial, depth?: number): T; +export function deepMerge>(base: T | undefined, override: T | undefined, depth?: number): T | undefined; +export function deepMerge>( + base: T | undefined, + override: T | undefined, + depth = 0 +): T | undefined { + if (!base && !override) return undefined; + if (!base) return override; + if (!override) return base; + if (depth > MAX_DEPTH) return override ?? base; + + const result = { ...base } as Record; + + for (const key of Object.keys(override)) { + if (DANGEROUS_KEYS.has(key)) continue; + + const baseValue = base[key]; + const overrideValue = override[key]; + + if (overrideValue === undefined) continue; + + if (isPlainObject(baseValue) && isPlainObject(overrideValue)) { + result[key] = deepMerge(baseValue, overrideValue, depth + 1); + } else { + result[key] = overrideValue; + } + } + + return result as T; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 7e87c2a..d96c880 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -7,3 +7,4 @@ export * from "./snake-case" export * from "./tool-name" export * from "./pattern-matcher" export * from "./hook-disabled" +export * from "./deep-merge"