From ae12f2e9d2787ac3e759f1901a36b0aab5570039 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 d87cf68c..3b489819 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 2f7f9857..ae2ef967 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 00000000..12cc09b8 --- /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 8a83fcd7..477eaa51 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 7b3aa8d7..1103bf15 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 b36e8688..ceb82d45 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 9404f709..549c5f1e 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 fa22c5b3..37b3ff49 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 c5d59e14..088bb1d0 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 875a8cee..c91752df 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 00000000..de96ad90 --- /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 3c080ed1..8bd674b7 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 8482c6cf..6c6e78a3 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 043243db..fe3dd92e 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 43d1dfd5..0ab4c1ba 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 754a2593f9c778cf8bbd65b2612fd70fd76d8020 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 3b489819..5ea581d3 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 fb139a7a0126bbc2f8a9caa17e7ed563668fcbfe 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 c91752df..264460f1 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 de96ad90..eb056872 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 4f212dbaf95257e5c217db15d5ca12fdc1ac0c3d 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 5ea581d3..75a2a26f 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 8836b61aaafb6add28dc1c56bcca20ac10f7d236 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 2feb7121..1095fee1 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 da1e160add55851d63016f4dcae904e0522e16b8 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 242caf49..f0d5fa3c 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 f852fec0..bb0da571 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 922ff7f2bcfbaab7ec357dcc29f701edf8f0efcd 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 bb0da571..e1f91a2a 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 a5749a1392398b42c393e428b15ae98e5d90b92f 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 30757523..df796fb7 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 12cc09b8..bc6863f9 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 eb5429fb..bc40a731 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 7d8893be..4e61b5c1 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 6896898c..3dbe54f4 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 8bd674b7..a2d63c84 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 818fdc490c95dde0b56164da207d763fc50fb6c8 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 5e2cd08a..c1bc3441 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 37b3ff49..c480f984 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 d7ab5c4d7bd4d1d1ee5a49b0fe8f6793b890eed5 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 df796fb7..b0cce042 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 17681dcd..9180c5af 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 bc6863f9..80bc6d07 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" }), ) })