From 560eb43e16723067a523b5c54986e19daf696e1e Mon Sep 17 00:00:00 2001 From: edxeth Date: Wed, 18 Feb 2026 19:57:35 +0100 Subject: [PATCH 01/10] feat(config): add custom_agents overrides and strict agent validation --- assets/oh-my-opencode.schema.json | 204 ++++++++++++++++++ src/config/index.ts | 14 ++ src/config/schema-document.test.ts | 32 +++ src/config/schema.test.ts | 73 +++++++ src/config/schema/agent-overrides.ts | 36 +++- src/config/schema/oh-my-opencode-config.ts | 3 +- src/plugin-config.test.ts | 138 +++++++++++- src/plugin-config.ts | 151 ++++++++++++- src/plugin-handlers/agent-config-handler.ts | 83 +++++-- src/plugin-handlers/config-handler.test.ts | 191 ++++++++++++++++ src/plugin-handlers/custom-agent-utils.ts | 136 ++++++++++++ .../prometheus-agent-config-builder.ts | 11 +- .../delegate-task/subagent-resolver.test.ts | 52 +++++ src/tools/delegate-task/subagent-resolver.ts | 14 +- src/tools/delegate-task/tools.ts | 9 +- 15 files changed, 1120 insertions(+), 27 deletions(-) create mode 100644 src/config/schema-document.test.ts create mode 100644 src/plugin-handlers/custom-agent-utils.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index d87cf68cd..3b489819f 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3157,6 +3157,210 @@ }, "additionalProperties": false }, + "custom_agents": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^(?!(?:build|plan|sisyphus|hephaestus|sisyphus-junior|OpenCode-Builder|prometheus|metis|momus|oracle|librarian|explore|multimodal-looker|atlas)$).+" + }, + "additionalProperties": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "model" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "additionalProperties": false + } + }, "categories": { "type": "object", "propertyNames": { diff --git a/src/config/index.ts b/src/config/index.ts index 2f7f98578..ae2ef967f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,11 +1,25 @@ export { OhMyOpenCodeConfigSchema, + AgentOverrideConfigSchema, + AgentOverridesSchema, + CustomAgentOverridesSchema, + McpNameSchema, + AgentNameSchema, + OverridableAgentNameSchema, + HookNameSchema, + BuiltinCommandNameSchema, + SisyphusAgentConfigSchema, + ExperimentalConfigSchema, + RalphLoopConfigSchema, + TmuxConfigSchema, + TmuxLayoutSchema, } from "./schema" export type { OhMyOpenCodeConfig, AgentOverrideConfig, AgentOverrides, + CustomAgentOverrides, McpName, AgentName, HookName, diff --git a/src/config/schema-document.test.ts b/src/config/schema-document.test.ts new file mode 100644 index 000000000..12cc09b87 --- /dev/null +++ b/src/config/schema-document.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { createOhMyOpenCodeJsonSchema } from "../../script/build-schema-document" + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? (value as Record) : undefined +} + +describe("schema document generation", () => { + test("custom_agents schema allows arbitrary custom agent keys with override shape", () => { + // given + const schema = createOhMyOpenCodeJsonSchema() + + // when + const rootProperties = asRecord(schema.properties) + const agentsSchema = asRecord(rootProperties?.agents) + const customAgentsSchema = asRecord(rootProperties?.custom_agents) + const customPropertyNames = asRecord(customAgentsSchema?.propertyNames) + const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) + const customAgentProperties = asRecord(customAdditionalProperties?.properties) + + // then + expect(agentsSchema).toBeDefined() + expect(agentsSchema?.additionalProperties).toBeFalse() + expect(customAgentsSchema).toBeDefined() + expect(customPropertyNames?.pattern).toBeDefined() + expect(customAdditionalProperties).toBeDefined() + expect(customAgentProperties?.model).toEqual({ type: "string" }) + expect(customAgentProperties?.temperature).toEqual( + expect.objectContaining({ type: "number" }), + ) + }) +}) diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 8a83fcd7d..477eaa51b 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -530,6 +530,79 @@ describe("Sisyphus-Junior agent override", () => { expect(result.data.agents?.momus?.category).toBe("quick") } }) + + test("schema accepts custom_agents override keys", () => { + // given + const config = { + custom_agents: { + translator: { + model: "google/gemini-3-flash-preview", + temperature: 0, + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview") + expect(result.data.custom_agents?.translator?.temperature).toBe(0) + } + }) + + test("schema rejects unknown keys under agents", () => { + // given + const config = { + agents: { + sisyphuss: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("schema rejects built-in agent names under custom_agents", () => { + // given + const config = { + custom_agents: { + sisyphus: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("schema rejects built-in agent names under custom_agents case-insensitively", () => { + // given + const config = { + custom_agents: { + Sisyphus: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) }) describe("BrowserAutomationProviderSchema", () => { diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index 7b3aa8d7a..1103bf15a 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -1,5 +1,6 @@ import { z } from "zod" import { FallbackModelsSchema } from "./fallback-models" +import { OverridableAgentNameSchema } from "./agent-names" import { AgentPermissionSchema } from "./internal/permission" export const AgentOverrideConfigSchema = z.object({ @@ -55,7 +56,7 @@ export const AgentOverrideConfigSchema = z.object({ .optional(), }) -export const AgentOverridesSchema = z.object({ +const BuiltinAgentOverridesSchema = z.object({ build: AgentOverrideConfigSchema.optional(), plan: AgentOverrideConfigSchema.optional(), sisyphus: AgentOverrideConfigSchema.optional(), @@ -70,7 +71,38 @@ export const AgentOverridesSchema = z.object({ explore: AgentOverrideConfigSchema.optional(), "multimodal-looker": AgentOverrideConfigSchema.optional(), atlas: AgentOverrideConfigSchema.optional(), -}) +}).strict() + +export const AgentOverridesSchema = BuiltinAgentOverridesSchema + +const RESERVED_CUSTOM_AGENT_NAMES = OverridableAgentNameSchema.options +const RESERVED_CUSTOM_AGENT_NAME_SET = new Set( + RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.toLowerCase()), +) +const RESERVED_CUSTOM_AGENT_NAME_PATTERN = new RegExp( + `^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$).+`, +) + +export const CustomAgentOverridesSchema = z + .record( + z.string().regex( + RESERVED_CUSTOM_AGENT_NAME_PATTERN, + "custom_agents key cannot reuse built-in agent override name", + ), + AgentOverrideConfigSchema, + ) + .superRefine((value, ctx) => { + for (const key of Object.keys(value)) { + if (RESERVED_CUSTOM_AGENT_NAME_SET.has(key.toLowerCase())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: "custom_agents key cannot reuse built-in agent override name", + }) + } + } + }) export type AgentOverrideConfig = z.infer export type AgentOverrides = z.infer +export type CustomAgentOverrides = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index b36e8688f..ceb82d451 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { AnyMcpNameSchema } from "../../mcp/types" import { BuiltinAgentNameSchema, BuiltinSkillNameSchema } from "./agent-names" -import { AgentOverridesSchema } from "./agent-overrides" +import { AgentOverridesSchema, CustomAgentOverridesSchema } from "./agent-overrides" import { BabysittingConfigSchema } from "./babysitting" import { BackgroundTaskConfigSchema } from "./background-task" import { BrowserAutomationConfigSchema } from "./browser-automation" @@ -38,6 +38,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ /** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */ model_fallback: z.boolean().optional(), agents: AgentOverridesSchema.optional(), + custom_agents: CustomAgentOverridesSchema.optional(), categories: CategoriesConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), sisyphus_agent: SisyphusAgentConfigSchema.optional(), diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 9404f7095..549c5f1e8 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { mergeConfigs, parseConfigPartially } from "./plugin-config"; +import { + detectLikelyBuiltinAgentTypos, + detectUnknownBuiltinAgentKeys, + mergeConfigs, + parseConfigPartially, +} from "./plugin-config"; import type { OhMyOpenCodeConfig } from "./config"; describe("mergeConfigs", () => { @@ -115,6 +120,27 @@ describe("mergeConfigs", () => { expect(result.disabled_hooks).toContain("session-recovery"); expect(result.disabled_hooks?.length).toBe(3); }); + + it("should deep merge custom_agents", () => { + const base: OhMyOpenCodeConfig = { + custom_agents: { + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const override: OhMyOpenCodeConfig = { + custom_agents: { + translator: { temperature: 0 }, + "database-architect": { model: "openai/gpt-5.3-codex" }, + }, + } + + const result = mergeConfigs(base, override) + + expect(result.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview") + expect(result.custom_agents?.translator?.temperature).toBe(0) + expect(result.custom_agents?.["database-architect"]?.model).toBe("openai/gpt-5.3-codex") + }) }); }); @@ -165,7 +191,9 @@ describe("parseConfigPartially", () => { expect(result).not.toBeNull(); expect(result!.disabled_hooks).toEqual(["comment-checker"]); - expect(result!.agents).toBeUndefined(); + expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2"); + expect(result!.agents?.momus?.model).toBe("openai/gpt-5.2"); + expect((result!.agents as Record)?.prometheus).toBeUndefined(); }); it("should preserve valid agents when a non-agent section is invalid", () => { @@ -182,6 +210,36 @@ describe("parseConfigPartially", () => { expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2"); expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]); }); + + it("should preserve valid built-in agent entries when agents contains unknown keys", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.3-codex" }, + sisyphuss: { model: "openai/gpt-5.3-codex" }, + }, + }; + + const result = parseConfigPartially(rawConfig); + + expect(result).not.toBeNull(); + expect(result!.agents?.sisyphus?.model).toBe("openai/gpt-5.3-codex"); + expect((result!.agents as Record)?.sisyphuss).toBeUndefined(); + }); + + it("should preserve valid custom_agents entries when custom_agents contains reserved names", () => { + const rawConfig = { + custom_agents: { + translator: { model: "google/gemini-3-flash-preview" }, + sisyphus: { model: "openai/gpt-5.3-codex" }, + }, + }; + + const result = parseConfigPartially(rawConfig); + + expect(result).not.toBeNull(); + expect(result!.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview"); + expect((result!.custom_agents as Record)?.sisyphus).toBeUndefined(); + }); }); describe("completely invalid config", () => { @@ -237,3 +295,79 @@ describe("parseConfigPartially", () => { }); }); }); + +describe("detectLikelyBuiltinAgentTypos", () => { + it("detects near-miss builtin agent keys", () => { + const rawConfig = { + agents: { + sisyphuss: { model: "openai/gpt-5.2" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([ + { + key: "sisyphuss", + suggestion: "sisyphus", + }, + ]) + }) + + it("suggests canonical key casing for OpenCode-Builder typos", () => { + const rawConfig = { + agents: { + "opencode-buildr": { model: "openai/gpt-5.2" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([ + { + key: "opencode-buildr", + suggestion: "OpenCode-Builder", + }, + ]) + }) + + it("does not flag valid custom agent names", () => { + const rawConfig = { + agents: { + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([]) + }) +}) + +describe("detectUnknownBuiltinAgentKeys", () => { + it("returns unknown keys under agents", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig) + + expect(unknownKeys).toEqual(["translator"]) + }) + + it("returns empty array when all keys are built-ins", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.2" }, + prometheus: { model: "openai/gpt-5.2" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig) + + expect(unknownKeys).toEqual([]) + }) +}) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index fa22c5b3c..37b3ff49b 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -1,6 +1,10 @@ import * as fs from "fs"; import * as path from "path"; -import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; +import { + OhMyOpenCodeConfigSchema, + OverridableAgentNameSchema, + type OhMyOpenCodeConfig, +} from "./config"; import { log, deepMerge, @@ -11,6 +15,81 @@ import { migrateConfigFile, } from "./shared"; +const BUILTIN_AGENT_OVERRIDE_KEYS = OverridableAgentNameSchema.options; +const BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER = new Map( + BUILTIN_AGENT_OVERRIDE_KEYS.map((key) => [key.toLowerCase(), key]), +); + +function levenshteinDistance(a: string, b: string): number { + const rows = a.length + 1; + const cols = b.length + 1; + const matrix: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)); + + for (let i = 0; i < rows; i += 1) matrix[i][0] = i; + for (let j = 0; j < cols; j += 1) matrix[0][j] = j; + + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + + return matrix[rows - 1][cols - 1]; +} + +type AgentTypoWarning = { + key: string; + suggestion: string; +}; + +export function detectLikelyBuiltinAgentTypos( + rawConfig: Record, +): AgentTypoWarning[] { + const agents = rawConfig.agents; + if (!agents || typeof agents !== "object") return []; + + const warnings: AgentTypoWarning[] = []; + for (const key of Object.keys(agents)) { + const lowerKey = key.toLowerCase(); + if (BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lowerKey)) { + continue; + } + + let bestMatchLower: string | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + for (const builtinKey of BUILTIN_AGENT_OVERRIDE_KEYS) { + const distance = levenshteinDistance(lowerKey, builtinKey.toLowerCase()); + if (distance < bestDistance) { + bestDistance = distance; + bestMatchLower = builtinKey.toLowerCase(); + } + } + + if (bestMatchLower && bestDistance <= 2) { + const suggestion = BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.get(bestMatchLower) ?? bestMatchLower; + warnings.push({ key, suggestion }); + } + } + + return warnings; +} + +export function detectUnknownBuiltinAgentKeys( + rawConfig: Record, +): string[] { + const agents = rawConfig.agents; + if (!agents || typeof agents !== "object") return []; + + return Object.keys(agents).filter( + (key) => !BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(key.toLowerCase()), + ); +} + export function parseConfigPartially( rawConfig: Record ): OhMyOpenCodeConfig | null { @@ -22,7 +101,52 @@ export function parseConfigPartially( const partialConfig: Record = {}; const invalidSections: string[] = []; + const parseAgentSectionEntries = (sectionKey: "agents" | "custom_agents"): void => { + const rawSection = rawConfig[sectionKey]; + if (!rawSection || typeof rawSection !== "object") return; + + const parsedSection: Record = {}; + const invalidEntries: string[] = []; + + for (const [entryKey, entryValue] of Object.entries(rawSection)) { + const singleEntryResult = OhMyOpenCodeConfigSchema.safeParse({ + [sectionKey]: { [entryKey]: entryValue }, + }); + + if (singleEntryResult.success) { + const parsed = singleEntryResult.data as Record; + const parsedSectionValue = parsed[sectionKey]; + if (parsedSectionValue && typeof parsedSectionValue === "object") { + const typedSection = parsedSectionValue as Record; + if (typedSection[entryKey] !== undefined) { + parsedSection[entryKey] = typedSection[entryKey]; + } + } + continue; + } + + const entryErrors = singleEntryResult.error.issues + .map((issue) => `${entryKey}: ${issue.message}`) + .join(", "); + if (entryErrors) { + invalidEntries.push(entryErrors); + } + } + + if (Object.keys(parsedSection).length > 0) { + partialConfig[sectionKey] = parsedSection; + } + if (invalidEntries.length > 0) { + invalidSections.push(`${sectionKey}: ${invalidEntries.join(", ")}`); + } + }; + for (const key of Object.keys(rawConfig)) { + if (key === "agents" || key === "custom_agents") { + parseAgentSectionEntries(key); + continue; + } + const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] }); if (sectionResult.success) { const parsed = sectionResult.data as Record; @@ -58,6 +182,29 @@ export function loadConfigFromPath( migrateConfigFile(configPath, rawConfig); + const typoWarnings = detectLikelyBuiltinAgentTypos(rawConfig); + if (typoWarnings.length > 0) { + const warningMsg = typoWarnings + .map((warning) => `agents.${warning.key} (did you mean agents.${warning.suggestion}?)`) + .join(", "); + log(`Potential agent override typos in ${configPath}: ${warningMsg}`); + addConfigLoadError({ + path: configPath, + error: `Potential agent override typos detected: ${warningMsg}`, + }); + } + + const unknownAgentKeys = detectUnknownBuiltinAgentKeys(rawConfig); + if (unknownAgentKeys.length > 0) { + const unknownKeysMsg = unknownAgentKeys.map((key) => `agents.${key}`).join(", "); + const migrationHint = "Move custom entries from agents.* to custom_agents.*"; + log(`Unknown built-in agent override keys in ${configPath}: ${unknownKeysMsg}. ${migrationHint}`); + addConfigLoadError({ + path: configPath, + error: `Unknown built-in agent override keys: ${unknownKeysMsg}. ${migrationHint}`, + }); + } + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig); if (result.success) { @@ -98,6 +245,7 @@ export function mergeConfigs( ...base, ...override, agents: deepMerge(base.agents, override.agents), + custom_agents: deepMerge(base.custom_agents, override.custom_agents), categories: deepMerge(base.categories, override.categories), disabled_agents: [ ...new Set([ @@ -170,6 +318,7 @@ export function loadPluginConfig( log("Final merged config", { agents: config.agents, + custom_agents: config.custom_agents, disabled_agents: config.disabled_agents, disabled_mcps: config.disabled_mcps, disabled_hooks: config.disabled_hooks, diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index c5d59e149..088bb1d06 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { log, migrateAgentConfig } from "../shared"; import { AGENT_NAME_MAP } from "../shared/migration"; import { getAgentDisplayName } from "../shared/agent-display-names"; +import { mergeCategories } from "../shared/merge-categories"; import { discoverConfigSourceSkills, discoverOpencodeGlobalSkills, @@ -17,6 +18,13 @@ import { reorderAgentsByPriority } from "./agent-priority-order"; import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; +import { + applyCustomAgentOverrides, + collectCustomAgentSummariesFromRecord, + mergeCustomAgentSummaries, + collectKnownCustomAgentNames, + filterSummariesByKnownNames, +} from "./custom-agent-utils"; type AgentConfigRecord = Record | undefined> & { build?: Record; @@ -78,22 +86,6 @@ export async function applyAgentConfig(params: { const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false; - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - params.pluginConfig.agents, - params.ctx.directory, - currentModel, - params.pluginConfig.categories, - params.pluginConfig.git_master, - allDiscoveredSkills, - params.ctx.client, - browserProvider, - currentModel, - disabledSkills, - useTaskSystem, - disableOmoEnv, - ); - const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; const userAgents = includeClaudeAgents ? loadUserAgents() : {}; const projectAgents = includeClaudeAgents ? loadProjectAgents(params.ctx.directory) : {}; @@ -106,6 +98,44 @@ export async function applyAgentConfig(params: { ]), ); + const configAgent = params.config.agent as AgentConfigRecord | undefined; + const mergedCategories = mergeCategories(params.pluginConfig.categories) + const knownCustomAgentNames = collectKnownCustomAgentNames( + userAgents as Record, + projectAgents as Record, + pluginAgents as Record, + configAgent as Record | undefined, + ) + + const customAgentSummaries = mergeCustomAgentSummaries( + collectCustomAgentSummariesFromRecord(userAgents as Record), + collectCustomAgentSummariesFromRecord(projectAgents as Record), + collectCustomAgentSummariesFromRecord(pluginAgents as Record), + collectCustomAgentSummariesFromRecord(configAgent as Record | undefined), + filterSummariesByKnownNames( + collectCustomAgentSummariesFromRecord( + params.pluginConfig.custom_agents as Record | undefined, + ), + knownCustomAgentNames, + ), + ) + + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + params.pluginConfig.agents, + params.ctx.directory, + currentModel, + params.pluginConfig.categories, + params.pluginConfig.git_master, + allDiscoveredSkills, + customAgentSummaries, + browserProvider, + currentModel, + disabledSkills, + useTaskSystem, + disableOmoEnv, + ); + const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -114,8 +144,6 @@ export async function applyAgentConfig(params: { const shouldDemotePlan = plannerEnabled && replacePlan; const configuredDefaultAgent = getConfiguredDefaultAgent(params.config); - const configAgent = params.config.agent as AgentConfigRecord | undefined; - if (isSisyphusEnabled && builtinAgents.sisyphus) { if (configuredDefaultAgent) { (params.config as { default_agent?: string }).default_agent = @@ -159,6 +187,7 @@ export async function applyAgentConfig(params: { pluginPrometheusOverride: prometheusOverride, userCategories: params.pluginConfig.categories, currentModel, + customAgentSummaries, }); } @@ -211,6 +240,24 @@ export async function applyAgentConfig(params: { }; } + if (params.config.agent) { + const builtinOverrideKeys = new Set([ + ...Object.keys(builtinAgents).map((key) => key.toLowerCase()), + "build", + "plan", + "sisyphus-junior", + "opencode-builder", + ]) + + applyCustomAgentOverrides({ + mergedAgents: params.config.agent as Record, + userOverrides: params.pluginConfig.custom_agents, + builtinOverrideKeys, + mergedCategories, + directory: params.ctx.directory, + }) + } + if (params.config.agent) { params.config.agent = remapAgentKeysToDisplayNames( params.config.agent as Record, diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 875a8cee2..c91752dfc 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -162,6 +162,197 @@ describe("Sisyphus-Junior model inheritance", () => { }) }) +describe("custom agent overrides", () => { + test("passes custom agent summaries into builtin agent prompt builder", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize text", + prompt: "Translate content", + }, + }) + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mock: { calls: unknown[][] } + } + + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const firstCallArgs = createBuiltinAgentsMock.mock.calls[0] + expect(firstCallArgs).toBeDefined() + expect(Array.isArray(firstCallArgs[7])).toBe(true) + expect(firstCallArgs[7]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "translator", + description: "Translate and localize text", + }), + ]), + ) + }) + + test("applies oh-my-opencode agent overrides to custom Claude agents", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "(user) translator", + prompt: "Base translator prompt", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + model: "google/gemini-3-flash-preview", + temperature: 0, + prompt_append: "Always preserve placeholders exactly.", + }, + }, + } + + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentConfig = config.agent as Record + expect(agentConfig.translator).toBeDefined() + expect(agentConfig.translator.model).toBe("google/gemini-3-flash-preview") + expect(agentConfig.translator.temperature).toBe(0) + expect(agentConfig.translator.prompt).toContain("Base translator prompt") + expect(agentConfig.translator.prompt).toContain("Always preserve placeholders exactly.") + }) + + test("prometheus prompt includes custom agent catalog for planning", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("") + expect(agentsConfig[pKey].prompt).toContain("translator") + expect(agentsConfig[pKey].prompt).toContain("Translate and localize locale files") + }) + + test("prometheus prompt excludes unknown custom_agents entries", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + description: "Translate and localize locale files", + }, + ghostwriter: { + description: "This agent does not exist in runtime", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("translator") + expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter") + }) +}) + describe("Plan agent demote behavior", () => { test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => { // #given diff --git a/src/plugin-handlers/custom-agent-utils.ts b/src/plugin-handlers/custom-agent-utils.ts new file mode 100644 index 000000000..de96ad901 --- /dev/null +++ b/src/plugin-handlers/custom-agent-utils.ts @@ -0,0 +1,136 @@ +import type { AgentConfig } from "@opencode-ai/sdk"; +import { applyOverrides } from "../agents/builtin-agents/agent-overrides"; +import type { AgentOverrideConfig } from "../agents/types"; +import type { OhMyOpenCodeConfig } from "../config"; +import { getAgentConfigKey } from "../shared/agent-display-names"; +import { AGENT_NAME_MAP } from "../shared/migration"; +import { mergeCategories } from "../shared/merge-categories"; + +const RESERVED_AGENT_KEYS = new Set( + [ + "build", + "plan", + "sisyphus-junior", + "opencode-builder", + ...Object.keys(AGENT_NAME_MAP), + ...Object.values(AGENT_NAME_MAP), + ].map((key) => getAgentConfigKey(key).toLowerCase()), +); + +export type AgentSummary = { + name: string; + description: string; + hidden?: boolean; + disabled?: boolean; + enabled?: boolean; +}; + +export function applyCustomAgentOverrides(params: { + mergedAgents: Record; + userOverrides: OhMyOpenCodeConfig["custom_agents"] | undefined; + builtinOverrideKeys: Set; + mergedCategories: ReturnType; + directory: string; +}): void { + if (!params.userOverrides) return; + + for (const [overrideKey, override] of Object.entries(params.userOverrides)) { + if (!override) continue; + + const normalizedOverrideKey = getAgentConfigKey(overrideKey).toLowerCase(); + if (params.builtinOverrideKeys.has(normalizedOverrideKey)) continue; + + const existingKey = Object.keys(params.mergedAgents).find( + (key) => key.toLowerCase() === overrideKey.toLowerCase() || key.toLowerCase() === normalizedOverrideKey, + ); + if (!existingKey) continue; + + const existingAgent = params.mergedAgents[existingKey]; + if (!existingAgent || typeof existingAgent !== "object") continue; + + params.mergedAgents[existingKey] = applyOverrides( + existingAgent as AgentConfig, + override as AgentOverrideConfig, + params.mergedCategories, + params.directory, + ); + } +} + +export function collectCustomAgentSummariesFromRecord( + agents: Record | undefined, +): AgentSummary[] { + if (!agents) return []; + + const summaries: AgentSummary[] = []; + for (const [name, value] of Object.entries(agents)) { + const normalizedName = getAgentConfigKey(name).toLowerCase(); + if (RESERVED_AGENT_KEYS.has(normalizedName)) continue; + if (!value || typeof value !== "object") continue; + + const agentValue = value as Record; + const description = typeof agentValue.description === "string" ? agentValue.description : ""; + + summaries.push({ + name, + description, + hidden: agentValue.hidden === true, + disabled: agentValue.disabled === true, + enabled: agentValue.enabled === false ? false : true, + }); + } + + return summaries; +} + +export function mergeCustomAgentSummaries(...summaryGroups: AgentSummary[][]): AgentSummary[] { + const merged = new Map(); + + for (const group of summaryGroups) { + for (const summary of group) { + const key = summary.name.toLowerCase(); + if (!merged.has(key)) { + merged.set(key, summary); + continue; + } + + const existing = merged.get(key); + if (!existing) continue; + + const existingDescription = existing.description.trim(); + const incomingDescription = summary.description.trim(); + if (!existingDescription && incomingDescription) { + merged.set(key, summary); + } + } + } + + return Array.from(merged.values()); +} + +export function collectKnownCustomAgentNames( + ...agentGroups: Array | undefined> +): Set { + const knownNames = new Set(); + + for (const group of agentGroups) { + if (!group) continue; + + for (const [name, value] of Object.entries(group)) { + const normalizedName = getAgentConfigKey(name).toLowerCase(); + if (RESERVED_AGENT_KEYS.has(normalizedName)) continue; + if (!value || typeof value !== "object") continue; + + knownNames.add(normalizedName); + } + } + + return knownNames; +} + +export function filterSummariesByKnownNames( + summaries: AgentSummary[], + knownNames: Set, +): AgentSummary[] { + return summaries.filter((summary) => knownNames.has(summary.name.toLowerCase())); +} diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts index 3c080ed10..8bd674b7e 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -1,6 +1,7 @@ import type { CategoryConfig } from "../config/schema"; import { PROMETHEUS_PERMISSION, getPrometheusPrompt } from "../agents/prometheus"; import { resolvePromptAppend } from "../agents/builtin-agents/resolve-file-uri"; +import { parseRegisteredAgentSummaries } from "../agents/custom-agent-summaries"; import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; import { fetchAvailableModels, @@ -27,6 +28,7 @@ export async function buildPrometheusAgentConfig(params: { pluginPrometheusOverride: PrometheusOverride | undefined; userCategories: Record | undefined; currentModel: string | undefined; + customAgentSummaries?: unknown; }): Promise> { const categoryConfig = params.pluginPrometheusOverride?.category ? resolveCategoryConfig(params.pluginPrometheusOverride.category, params.userCategories) @@ -65,11 +67,18 @@ export async function buildPrometheusAgentConfig(params: { const maxTokensToUse = params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; + const customAgentCatalog = parseRegisteredAgentSummaries(params.customAgentSummaries) + const customAgentBlock = customAgentCatalog.length > 0 + ? `\n\n\nAvailable custom agents for planning/delegation:\n${customAgentCatalog + .map((agent) => `- ${agent.name}: ${agent.description || "No description provided"}`) + .join("\n")}\n` + : "" + const base: Record = { ...(resolvedModel ? { model: resolvedModel } : {}), ...(variantToUse ? { variant: variantToUse } : {}), mode: "all", - prompt: getPrometheusPrompt(resolvedModel), + prompt: getPrometheusPrompt(resolvedModel) + customAgentBlock, permission: PROMETHEUS_PERMISSION, description: `${(params.configAgentPlan?.description as string) ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, color: (params.configAgentPlan?.color as string) ?? "#FF5722", diff --git a/src/tools/delegate-task/subagent-resolver.test.ts b/src/tools/delegate-task/subagent-resolver.test.ts index 8482c6cf6..6c6e78a3f 100644 --- a/src/tools/delegate-task/subagent-resolver.test.ts +++ b/src/tools/delegate-task/subagent-resolver.test.ts @@ -79,4 +79,56 @@ describe("resolveSubagentExecution", () => { error: "network timeout", }) }) + + test("uses inherited model for custom agents without explicit model", async () => { + //#given + const args = createBaseArgs({ subagent_type: "translator" }) + const executorCtx = createExecutorContext(async () => ({ + data: [{ name: "translator", mode: "subagent" }], + })) + + //#when + const result = await resolveSubagentExecution( + args, + executorCtx, + "sisyphus", + "deep", + "openai/gpt-5.3-codex", + "anthropic/claude-opus-4-6", + ) + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("translator") + expect(result.categoryModel).toEqual({ + providerID: "openai", + modelID: "gpt-5.3-codex", + }) + }) + + test("uses system default model when inherited model is unavailable", async () => { + //#given + const args = createBaseArgs({ subagent_type: "translator" }) + const executorCtx = createExecutorContext(async () => ({ + data: [{ name: "translator", mode: "subagent" }], + })) + + //#when + const result = await resolveSubagentExecution( + args, + executorCtx, + "sisyphus", + "deep", + undefined, + "anthropic/claude-opus-4-6", + ) + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("translator") + expect(result.categoryModel).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-6", + }) + }) }) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 043243db2..fe3dd92e7 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -15,7 +15,9 @@ export async function resolveSubagentExecution( args: DelegateTaskArgs, executorCtx: ExecutorContext, parentAgent: string | undefined, - categoryExamples: string + categoryExamples: string, + inheritedModel?: string, + systemDefaultModel?: string, ): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> { const { client, agentOverrides } = executorCtx @@ -123,6 +125,16 @@ Create the work plan directly - that's your job as the planning agent.`, if (!categoryModel && matchedAgent.model) { categoryModel = matchedAgent.model } + + if (!categoryModel) { + const fallbackModel = inheritedModel ?? systemDefaultModel + if (fallbackModel) { + const parsedFallback = parseModelString(fallbackModel) + if (parsedFallback) { + categoryModel = parsedFallback + } + } + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) log("[delegate-task] Failed to resolve subagent execution", { diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 43d1dfd59..0ab4c1baa 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -221,7 +221,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel) } } else { - const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples) + const resolution = await resolveSubagentExecution( + args, + options, + parentContext.agent, + categoryExamples, + inheritedModel, + systemDefaultModel, + ) if (resolution.error) { return resolution.error } From 36f42e00f278e7c842928026266d9abc73e58e28 Mon Sep 17 00:00:00 2001 From: edxeth Date: Wed, 18 Feb 2026 19:59:02 +0100 Subject: [PATCH 02/10] chore(schema): regenerate config schema after rebase --- assets/oh-my-opencode.schema.json | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 3b489819f..5ea581d3e 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3318,21 +3318,6 @@ ], "additionalProperties": false }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "model" - ], - "additionalProperties": false - }, "reasoningEffort": { "type": "string", "enum": [ From 593b722122d27aed0ff487942d6d68b47fc190d3 Mon Sep 17 00:00:00 2001 From: edxeth Date: Wed, 18 Feb 2026 20:14:47 +0100 Subject: [PATCH 03/10] fix(custom-agents): preserve summary flags during description merge --- src/plugin-handlers/config-handler.test.ts | 62 ++++++++++++++++++++++ src/plugin-handlers/custom-agent-utils.ts | 18 ++++--- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index c91752dfc..264460f1d 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -351,6 +351,68 @@ describe("custom agent overrides", () => { expect(agentsConfig[pKey].prompt).toContain("translator") expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter") }) + + test("custom agent summary merge preserves flags when custom_agents adds description", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "", + hidden: true, + disabled: true, + enabled: false, + prompt: "Translate content", + }, + }) + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mock: { calls: unknown[][] } + } + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + description: "Translate and localize locale files", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const firstCallArgs = createBuiltinAgentsMock.mock.calls[0] + const summaries = firstCallArgs[7] as Array<{ + name: string + description: string + hidden?: boolean + disabled?: boolean + enabled?: boolean + }> + const translatorSummary = summaries.find((summary) => summary.name === "translator") + + expect(translatorSummary).toBeDefined() + expect(translatorSummary?.description).toBe("Translate and localize locale files") + expect(translatorSummary?.hidden).toBe(true) + expect(translatorSummary?.disabled).toBe(true) + expect(translatorSummary?.enabled).toBe(false) + }) }) describe("Plan agent demote behavior", () => { diff --git a/src/plugin-handlers/custom-agent-utils.ts b/src/plugin-handlers/custom-agent-utils.ts index de96ad901..eb0568727 100644 --- a/src/plugin-handlers/custom-agent-utils.ts +++ b/src/plugin-handlers/custom-agent-utils.ts @@ -74,9 +74,9 @@ export function collectCustomAgentSummariesFromRecord( summaries.push({ name, description, - hidden: agentValue.hidden === true, - disabled: agentValue.disabled === true, - enabled: agentValue.enabled === false ? false : true, + hidden: typeof agentValue.hidden === "boolean" ? agentValue.hidden : undefined, + disabled: typeof agentValue.disabled === "boolean" ? agentValue.disabled : undefined, + enabled: typeof agentValue.enabled === "boolean" ? agentValue.enabled : undefined, }); } @@ -99,9 +99,15 @@ export function mergeCustomAgentSummaries(...summaryGroups: AgentSummary[][]): A const existingDescription = existing.description.trim(); const incomingDescription = summary.description.trim(); - if (!existingDescription && incomingDescription) { - merged.set(key, summary); - } + + merged.set(key, { + ...existing, + ...summary, + hidden: summary.hidden ?? existing.hidden, + disabled: summary.disabled ?? existing.disabled, + enabled: summary.enabled ?? existing.enabled, + description: incomingDescription || existingDescription, + }); } } From 72875a1ebf3239419f70e4e26e699b6dc0eb0139 Mon Sep 17 00:00:00 2001 From: edxeth Date: Tue, 24 Feb 2026 18:49:34 +0100 Subject: [PATCH 04/10] chore(schema): regenerate schema after rebase conflict resolution --- assets/oh-my-opencode.schema.json | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 5ea581d3e..75a2a26f3 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3169,6 +3169,19 @@ "model": { "type": "string" }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "variant": { "type": "string" }, @@ -3341,6 +3354,30 @@ "type": "string" }, "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false From e06121d2945577150ae93ba38017d33fb4e3dae4 Mon Sep 17 00:00:00 2001 From: edxeth Date: Tue, 24 Feb 2026 19:04:45 +0100 Subject: [PATCH 05/10] test(agents): stabilize provider gating and skill filter tests --- src/agents/utils.test.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 2feb71216..1095fee13 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -242,14 +242,28 @@ describe("createBuiltinAgents with model overrides", () => { test("createBuiltinAgents excludes disabled skills from availableSkills", async () => { // #given const disabledSkills = new Set(["playwright"]) + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set([ + "anthropic/claude-opus-4-6", + "opencode/kimi-k2.5-free", + "zai-coding-plan/glm-5", + "opencode/big-pickle", + ]) + ) - // #when - const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills) + try { + // #when + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills) - // #then - expect(agents.sisyphus.prompt).not.toContain("playwright") - expect(agents.sisyphus.prompt).toContain("frontend-ui-ux") - expect(agents.sisyphus.prompt).toContain("git-master") + // #then + expect(agents.sisyphus.prompt).not.toContain("playwright") + expect(agents.sisyphus.prompt).toContain("frontend-ui-ux") + expect(agents.sisyphus.prompt).toContain("git-master") + } finally { + cacheSpy.mockRestore() + fetchSpy.mockRestore() + } }) test("includes custom agents in orchestrator prompts when provided via config", async () => { @@ -589,20 +603,22 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () => } }) - test("hephaestus is created when github-copilot provider is connected", async () => { + test("hephaestus is not created when only github-copilot provider is connected", async () => { // #given - github-copilot provider has models available const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( new Set(["github-copilot/gpt-5.3-codex"]) ) + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) try { // #when const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {}) // #then - expect(agents.hephaestus).toBeDefined() + expect(agents.hephaestus).toBeUndefined() } finally { fetchSpy.mockRestore() + cacheSpy.mockRestore() } }) From b34f0a50685d9b03bfaf5ca46d24c8bee0358f65 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 20:01:53 +0100 Subject: [PATCH 06/10] docs(config): document custom_agents behavior and delegation flow --- docs/guide/overview.md | 2 ++ docs/reference/configuration.md | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/docs/guide/overview.md b/docs/guide/overview.md index 242caf490..f0d5fa3ca 100644 --- a/docs/guide/overview.md +++ b/docs/guide/overview.md @@ -68,6 +68,8 @@ User Request When Sisyphus delegates to a subagent, it doesn't pick a model name. It picks a **category** — `visual-engineering`, `ultrabrain`, `quick`, `deep`. The category automatically maps to the right model. You touch nothing. +Custom agents are also first-class in this flow. When custom agents are loaded, planning context includes them, so the orchestrator can choose them proactively when appropriate, and you can call them directly on demand via `task(subagent_type="your-agent")`. + For a deep dive into how agents collaborate, see the [Orchestration System Guide](./orchestration.md). --- diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f852fec0d..bb0da5713 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -11,6 +11,7 @@ Complete reference for `oh-my-opencode.jsonc` configuration. This document cover - [Quick Start Example](#quick-start-example) - [Core Concepts](#core-concepts) - [Agents](#agents) + - [Custom Agents (`custom_agents`)](#custom-agents-custom_agents) - [Categories](#categories) - [Model Resolution](#model-resolution) - [Task System](#task-system) @@ -130,6 +131,8 @@ Here's a practical starting configuration: Override built-in agent settings. Available agents: `sisyphus`, `hephaestus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`. +`agents` is intentionally strict and only accepts built-in agent keys. Use `custom_agents` for user-defined agents. + ```json { "agents": { @@ -200,6 +203,59 @@ Control what tools an agent can use: | `doom_loop` | `ask` / `allow` / `deny` | | `external_directory` | `ask` / `allow` / `deny` | +### Custom Agents (`custom_agents`) + +Use `custom_agents` to configure user-defined agents without mixing them into built-in `agents` overrides. + +What this gives you: + +- **Clean separation**: built-ins stay in `agents`, user-defined entries stay in `custom_agents`. +- **Safer config**: keys in `custom_agents` cannot reuse built-in names. +- **First-class orchestration**: loaded custom agents are visible to planner/orchestrator context, so they can be selected proactively during planning and invoked on demand via `task(subagent_type=...)`. +- **Full model controls** for custom agents: `model`, `variant`, `temperature`, `top_p`, `reasoningEffort`, `thinking`, etc. + +Important behavior: + +- `custom_agents` **overrides existing custom agents** loaded at runtime (for example from Claude Code/OpenCode agent sources). +- `custom_agents` does **not** create an agent from thin air by itself; the target custom agent must be present in runtime-loaded agent configs. + +Example: + +```jsonc +{ + "custom_agents": { + "translator": { + "model": "openai/gpt-5.3-codex", + "variant": "high", + "temperature": 0.2, + "prompt_append": "Keep locale placeholders and ICU tokens exactly unchanged." + }, + "reviewer-fast": { + "model": "anthropic/claude-haiku-4-5", + "temperature": 0, + "reasoningEffort": "medium" + } + } +} +``` + +On-demand invocation through task delegation: + +```ts +task( + subagent_type="translator", + load_skills=[], + description="Translate release notes", + prompt="Translate docs/CHANGELOG.md into Korean while preserving markdown structure.", + run_in_background=false, +) +``` + +Migration note: + +- If you previously put custom entries under `agents.*`, move them to `custom_agents.*`. +- Unknown built-in keys under `agents` are reported with migration hints. + ### Categories Domain-specific model delegation used by the `task()` tool. When Sisyphus delegates work, it picks a category, not a model name. From 6f8ec9b49b7d11ccdb236d8d298ae846a0189962 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 20:55:58 +0100 Subject: [PATCH 07/10] docs(config): fix custom_agents examples --- docs/reference/configuration.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index bb0da5713..e1f91a2a1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -233,7 +233,10 @@ Example: "reviewer-fast": { "model": "anthropic/claude-haiku-4-5", "temperature": 0, - "reasoningEffort": "medium" + "thinking": { + "type": "enabled", + "budgetTokens": 20000 + } } } } @@ -243,11 +246,13 @@ On-demand invocation through task delegation: ```ts task( - subagent_type="translator", - load_skills=[], - description="Translate release notes", - prompt="Translate docs/CHANGELOG.md into Korean while preserving markdown structure.", - run_in_background=false, + { + subagent_type: "translator", + load_skills: [], + description: "Translate release notes", + prompt: "Translate docs/CHANGELOG.md into Korean while preserving markdown structure.", + run_in_background: false, + }, ) ``` From e6f7abc523c09b055044855c5941ea67a974b7c0 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 21:14:00 +0100 Subject: [PATCH 08/10] fix(custom-agents): align planner catalog and schema validation --- assets/oh-my-opencode.schema.json | 2 +- src/config/schema-document.test.ts | 2 + src/config/schema/agent-overrides.ts | 21 ++++- src/plugin-handlers/agent-config-handler.ts | 51 ++++++----- src/plugin-handlers/config-handler.test.ts | 88 +++++++++++++++++++ .../prometheus-agent-config-builder.ts | 7 ++ 6 files changed, 147 insertions(+), 24 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 30757523b..df796fb78 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3152,7 +3152,7 @@ "type": "object", "propertyNames": { "type": "string", - "pattern": "^(?!(?:build|plan|sisyphus|hephaestus|sisyphus-junior|OpenCode-Builder|prometheus|metis|momus|oracle|librarian|explore|multimodal-looker|atlas)$).+" + "pattern": "^(?!(?:[bB][uU][iI][lL][dD]|[pP][lL][aA][nN]|[sS][iI][sS][yY][pP][hH][uU][sS]|[hH][eE][pP][hH][aA][eE][sS][tT][uU][sS]|[sS][iI][sS][yY][pP][hH][uU][sS]-[jJ][uU][nN][iI][oO][rR]|[oO][pP][eE][nN][cC][oO][dD][eE]-[bB][uU][iI][lL][dD][eE][rR]|[pP][rR][oO][mM][eE][tT][hH][eE][uU][sS]|[mM][eE][tT][iI][sS]|[mM][oO][mM][uU][sS]|[oO][rR][aA][cC][lL][eE]|[lL][iI][bB][rR][aA][rR][iI][aA][nN]|[eE][xX][pP][lL][oO][rR][eE]|[mM][uU][lL][tT][iI][mM][oO][dD][aA][lL]-[lL][oO][oO][kK][eE][rR]|[aA][tT][lL][aA][sS])$).+" }, "additionalProperties": { "type": "object", diff --git a/src/config/schema-document.test.ts b/src/config/schema-document.test.ts index 12cc09b87..bc6863f91 100644 --- a/src/config/schema-document.test.ts +++ b/src/config/schema-document.test.ts @@ -23,6 +23,8 @@ describe("schema document generation", () => { expect(agentsSchema?.additionalProperties).toBeFalse() expect(customAgentsSchema).toBeDefined() expect(customPropertyNames?.pattern).toBeDefined() + expect(customPropertyNames?.pattern).toContain("[bB][uU][iI][lL][dD]") + expect(customPropertyNames?.pattern).toContain("[pP][lL][aA][nN]") expect(customAdditionalProperties).toBeDefined() expect(customAgentProperties?.model).toEqual({ type: "string" }) expect(customAgentProperties?.temperature).toEqual( diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index eb5429fba..bc40a7313 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -81,8 +81,27 @@ const RESERVED_CUSTOM_AGENT_NAMES = OverridableAgentNameSchema.options const RESERVED_CUSTOM_AGENT_NAME_SET = new Set( RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.toLowerCase()), ) +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function toCaseInsensitiveLiteralPattern(value: string): string { + return value + .split("") + .map((char) => { + if (/^[A-Za-z]$/.test(char)) { + const lower = char.toLowerCase() + const upper = char.toUpperCase() + return `[${lower}${upper}]` + } + + return escapeRegexLiteral(char) + }) + .join("") +} + const RESERVED_CUSTOM_AGENT_NAME_PATTERN = new RegExp( - `^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$).+`, + `^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map(toCaseInsensitiveLiteralPattern).join("|")})$).+`, ) export const CustomAgentOverridesSchema = z diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 7d8893be8..4e61b5c15 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -82,6 +82,15 @@ export async function applyAgentConfig(params: { const browserProvider = params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; const currentModel = params.config.model as string | undefined; + const disabledAgentNames = new Set( + (migratedDisabledAgents ?? []).map((agent) => agent.toLowerCase()), + ); + const filterDisabledAgents = (agents: Record) => + Object.fromEntries( + Object.entries(agents).filter( + ([name]) => !disabledAgentNames.has(name.toLowerCase()), + ), + ); const disabledSkills = new Set(params.pluginConfig.disabled_skills ?? []); const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false; @@ -99,19 +108,25 @@ export async function applyAgentConfig(params: { ); const configAgent = params.config.agent as AgentConfigRecord | undefined; + const filteredUserAgents = filterDisabledAgents(userAgents as Record); + const filteredProjectAgents = filterDisabledAgents(projectAgents as Record); + const filteredPluginAgents = filterDisabledAgents(pluginAgents as Record); + const filteredConfigAgentsForSummary = filterDisabledAgents( + (configAgent as Record | undefined) ?? {}, + ); const mergedCategories = mergeCategories(params.pluginConfig.categories) const knownCustomAgentNames = collectKnownCustomAgentNames( - userAgents as Record, - projectAgents as Record, - pluginAgents as Record, - configAgent as Record | undefined, + filteredUserAgents, + filteredProjectAgents, + filteredPluginAgents, + filteredConfigAgentsForSummary, ) const customAgentSummaries = mergeCustomAgentSummaries( - collectCustomAgentSummariesFromRecord(userAgents as Record), - collectCustomAgentSummariesFromRecord(projectAgents as Record), - collectCustomAgentSummariesFromRecord(pluginAgents as Record), - collectCustomAgentSummariesFromRecord(configAgent as Record | undefined), + collectCustomAgentSummariesFromRecord(filteredUserAgents), + collectCustomAgentSummariesFromRecord(filteredProjectAgents), + collectCustomAgentSummariesFromRecord(filteredPluginAgents), + collectCustomAgentSummariesFromRecord(filteredConfigAgentsForSummary), filterSummariesByKnownNames( collectCustomAgentSummariesFromRecord( params.pluginConfig.custom_agents as Record | undefined, @@ -135,14 +150,6 @@ export async function applyAgentConfig(params: { useTaskSystem, disableOmoEnv, ); - const disabledAgentNames = new Set( - (migratedDisabledAgents ?? []).map(a => a.toLowerCase()) - ); - - const filterDisabledAgents = (agents: Record) => - Object.fromEntries( - Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase())) - ); const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -230,9 +237,9 @@ export async function applyAgentConfig(params: { ...Object.fromEntries( Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), ), - ...filterDisabledAgents(userAgents), - ...filterDisabledAgents(projectAgents), - ...filterDisabledAgents(pluginAgents), + ...filteredUserAgents, + ...filteredProjectAgents, + ...filteredPluginAgents, ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -240,9 +247,9 @@ export async function applyAgentConfig(params: { } else { params.config.agent = { ...builtinAgents, - ...filterDisabledAgents(userAgents), - ...filterDisabledAgents(projectAgents), - ...filterDisabledAgents(pluginAgents), + ...filteredUserAgents, + ...filteredProjectAgents, + ...filteredPluginAgents, ...configAgent, }; } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 6896898c2..3dbe54f4b 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -352,6 +352,94 @@ describe("custom agent overrides", () => { expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter") }) + test("prometheus prompt excludes disabled custom agents from catalog", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + disabled_agents: ["translator"], + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).not.toContain("translator") + }) + + test("prometheus custom prompt override still includes custom agent catalog", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + agents: { + prometheus: { + prompt: "Custom planner prompt", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("Custom planner prompt") + expect(agentsConfig[pKey].prompt).toContain("") + expect(agentsConfig[pKey].prompt).toContain("translator") + }) + test("custom agent summary merge preserves flags when custom_agents adds description", async () => { // #given ;(agentLoader.loadUserAgents as any).mockReturnValue({ diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts index 8bd674b7e..a2d63c84a 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -103,5 +103,12 @@ export async function buildPrometheusAgentConfig(params: { if (prompt_append && typeof merged.prompt === "string") { merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append); } + if ( + customAgentBlock + && typeof merged.prompt === "string" + && !merged.prompt.includes("") + ) { + merged.prompt = merged.prompt + customAgentBlock; + } return merged; } From 42b1e6985caf6c8dd7481e657c344f59d6c6e9e7 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 21:28:00 +0100 Subject: [PATCH 09/10] fix(config): avoid conflicting typo and migration guidance --- src/plugin-config.test.ts | 26 ++++++++++++++++++++++++++ src/plugin-config.ts | 16 ++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 5e2cd08aa..c1bc3441b 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -370,4 +370,30 @@ describe("detectUnknownBuiltinAgentKeys", () => { expect(unknownKeys).toEqual([]) }) + + it("excludes typo keys when explicitly provided", () => { + const rawConfig = { + agents: { + sisyphuss: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"]) + + expect(unknownKeys).toEqual(["translator"]) + }) + + it("excludes typo keys case-insensitively", () => { + const rawConfig = { + agents: { + Sisyphuss: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"]) + + expect(unknownKeys).toEqual(["translator"]) + }) }) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 37b3ff49b..c480f9848 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -81,12 +81,21 @@ export function detectLikelyBuiltinAgentTypos( export function detectUnknownBuiltinAgentKeys( rawConfig: Record, + excludeKeys: string[] = [], ): string[] { const agents = rawConfig.agents; if (!agents || typeof agents !== "object") return []; + const excluded = new Set(excludeKeys.map((key) => key.toLowerCase())); + return Object.keys(agents).filter( - (key) => !BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(key.toLowerCase()), + (key) => { + const lower = key.toLowerCase(); + return ( + !BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lower) + && !excluded.has(lower) + ); + }, ); } @@ -194,7 +203,10 @@ export function loadConfigFromPath( }); } - const unknownAgentKeys = detectUnknownBuiltinAgentKeys(rawConfig); + const unknownAgentKeys = detectUnknownBuiltinAgentKeys( + rawConfig, + typoWarnings.map((warning) => warning.key), + ); if (unknownAgentKeys.length > 0) { const unknownKeysMsg = unknownAgentKeys.map((key) => `agents.${key}`).join(", "); const migrationHint = "Move custom entries from agents.* to custom_agents.*"; From 7729aa520bdfffabde81ad5a78bd263ff4c4e169 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 21:39:04 +0100 Subject: [PATCH 10/10] refactor(schema): dedupe custom agent override with ref --- assets/oh-my-opencode.schema.json | 441 +++++++++++++++-------------- script/build-schema-document.ts | 38 ++- src/config/schema-document.test.ts | 10 +- 3 files changed, 267 insertions(+), 222 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index df796fb78..b0cce0422 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3155,223 +3155,7 @@ "pattern": "^(?!(?:[bB][uU][iI][lL][dD]|[pP][lL][aA][nN]|[sS][iI][sS][yY][pP][hH][uU][sS]|[hH][eE][pP][hH][aA][eE][sS][tT][uU][sS]|[sS][iI][sS][yY][pP][hH][uU][sS]-[jJ][uU][nN][iI][oO][rR]|[oO][pP][eE][nN][cC][oO][dD][eE]-[bB][uU][iI][lL][dD][eE][rR]|[pP][rR][oO][mM][eE][tT][hH][eE][uU][sS]|[mM][eE][tT][iI][sS]|[mM][oO][mM][uU][sS]|[oO][rR][aA][cC][lL][eE]|[lL][iI][bB][rR][aA][rR][iI][aA][nN]|[eE][xX][pP][lL][oO][rR][eE]|[mM][uU][lL][tT][iI][mM][oO][dD][aA][lL]-[lL][oO][oO][kK][eE][rR]|[aA][tT][lL][aA][sS])$).+" }, "additionalProperties": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false + "$ref": "#/$defs/agentOverrideConfig" } }, "categories": { @@ -4070,5 +3854,226 @@ } } }, - "additionalProperties": false + "additionalProperties": false, + "$defs": { + "agentOverrideConfig": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/script/build-schema-document.ts b/script/build-schema-document.ts index 17681dcd9..9180c5af9 100644 --- a/script/build-schema-document.ts +++ b/script/build-schema-document.ts @@ -1,17 +1,53 @@ import * as z from "zod" import { OhMyOpenCodeConfigSchema } from "../src/config/schema" +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? (value as Record) : undefined +} + +function dedupeCustomAgentOverrideSchema(schema: Record): Record { + const rootProperties = asRecord(schema.properties) + const agentsSchema = asRecord(rootProperties?.agents) + const builtInAgentProps = asRecord(agentsSchema?.properties) + const customAgentsSchema = asRecord(rootProperties?.custom_agents) + const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) + + if (!builtInAgentProps || !customAgentsSchema || !customAdditionalProperties) { + return schema + } + + const referenceAgentSchema = asRecord( + builtInAgentProps.build + ?? builtInAgentProps.oracle + ?? builtInAgentProps.explore, + ) + + if (!referenceAgentSchema) { + return schema + } + + const defs = asRecord(schema.$defs) ?? {} + defs.agentOverrideConfig = referenceAgentSchema + schema.$defs = defs + + customAgentsSchema.additionalProperties = { $ref: "#/$defs/agentOverrideConfig" } + + return schema +} + export function createOhMyOpenCodeJsonSchema(): Record { const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, { target: "draft-7", unrepresentable: "any", }) - return { + const schema = { $schema: "http://json-schema.org/draft-07/schema#", $id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", title: "Oh My OpenCode Configuration", description: "Configuration schema for oh-my-opencode plugin", ...jsonSchema, } + + return dedupeCustomAgentOverrideSchema(schema) } diff --git a/src/config/schema-document.test.ts b/src/config/schema-document.test.ts index bc6863f91..80bc6d078 100644 --- a/src/config/schema-document.test.ts +++ b/src/config/schema-document.test.ts @@ -16,7 +16,9 @@ describe("schema document generation", () => { const customAgentsSchema = asRecord(rootProperties?.custom_agents) const customPropertyNames = asRecord(customAgentsSchema?.propertyNames) const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) - const customAgentProperties = asRecord(customAdditionalProperties?.properties) + const defs = asRecord(schema.$defs) + const sharedAgentOverrideSchema = asRecord(defs?.agentOverrideConfig) + const sharedAgentProperties = asRecord(sharedAgentOverrideSchema?.properties) // then expect(agentsSchema).toBeDefined() @@ -26,8 +28,10 @@ describe("schema document generation", () => { expect(customPropertyNames?.pattern).toContain("[bB][uU][iI][lL][dD]") expect(customPropertyNames?.pattern).toContain("[pP][lL][aA][nN]") expect(customAdditionalProperties).toBeDefined() - expect(customAgentProperties?.model).toEqual({ type: "string" }) - expect(customAgentProperties?.temperature).toEqual( + expect(customAdditionalProperties?.$ref).toBe("#/$defs/agentOverrideConfig") + expect(sharedAgentOverrideSchema).toBeDefined() + expect(sharedAgentProperties?.model).toEqual({ type: "string" }) + expect(sharedAgentProperties?.temperature).toEqual( expect.objectContaining({ type: "number" }), ) })