From ae12f2e9d2787ac3e759f1901a36b0aab5570039 Mon Sep 17 00:00:00 2001 From: edxeth Date: Wed, 18 Feb 2026 19:57:35 +0100 Subject: [PATCH] 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 }