feat(config): add custom_agents overrides and strict agent validation

This commit is contained in:
edxeth
2026-02-18 19:57:35 +01:00
parent 15519b9580
commit ae12f2e9d2
15 changed files with 1120 additions and 27 deletions

View File

@@ -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": {

View File

@@ -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,

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { createOhMyOpenCodeJsonSchema } from "../../script/build-schema-document"
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : 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" }),
)
})
})

View File

@@ -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", () => {

View File

@@ -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<typeof AgentOverrideConfigSchema>
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type CustomAgentOverrides = z.infer<typeof CustomAgentOverridesSchema>

View File

@@ -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(),

View File

@@ -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<string, unknown>)?.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<string, unknown>)?.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<string, unknown>)?.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([])
})
})

View File

@@ -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<string, unknown>,
): 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, unknown>,
): 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<string, unknown>
): OhMyOpenCodeConfig | null {
@@ -22,7 +101,52 @@ export function parseConfigPartially(
const partialConfig: Record<string, unknown> = {};
const invalidSections: string[] = [];
const parseAgentSectionEntries = (sectionKey: "agents" | "custom_agents"): void => {
const rawSection = rawConfig[sectionKey];
if (!rawSection || typeof rawSection !== "object") return;
const parsedSection: Record<string, unknown> = {};
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<string, unknown>;
const parsedSectionValue = parsed[sectionKey];
if (parsedSectionValue && typeof parsedSectionValue === "object") {
const typedSection = parsedSectionValue as Record<string, unknown>;
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<string, unknown>;
@@ -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,

View File

@@ -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<string, Record<string, unknown> | undefined> & {
build?: Record<string, unknown>;
@@ -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<string, unknown>,
projectAgents as Record<string, unknown>,
pluginAgents as Record<string, unknown>,
configAgent as Record<string, unknown> | undefined,
)
const customAgentSummaries = mergeCustomAgentSummaries(
collectCustomAgentSummariesFromRecord(userAgents as Record<string, unknown>),
collectCustomAgentSummariesFromRecord(projectAgents as Record<string, unknown>),
collectCustomAgentSummariesFromRecord(pluginAgents as Record<string, unknown>),
collectCustomAgentSummariesFromRecord(configAgent as Record<string, unknown> | undefined),
filterSummariesByKnownNames(
collectCustomAgentSummariesFromRecord(
params.pluginConfig.custom_agents as Record<string, unknown> | 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<string, unknown>,
userOverrides: params.pluginConfig.custom_agents,
builtinOverrideKeys,
mergedCategories,
directory: params.ctx.directory,
})
}
if (params.config.agent) {
params.config.agent = remapAgentKeysToDisplayNames(
params.config.agent as Record<string, unknown>,

View File

@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, { model?: string; temperature?: number; prompt?: string }>
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<string, unknown> = {
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<string, { prompt?: string }>
const pKey = getAgentDisplayName("prometheus")
expect(agentsConfig[pKey]).toBeDefined()
expect(agentsConfig[pKey].prompt).toContain("<custom_agent_catalog>")
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<string, unknown> = {
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<string, { prompt?: string }>
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

View File

@@ -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<string, unknown>;
userOverrides: OhMyOpenCodeConfig["custom_agents"] | undefined;
builtinOverrideKeys: Set<string>;
mergedCategories: ReturnType<typeof mergeCategories>;
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<string, unknown> | 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<string, unknown>;
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<string, AgentSummary>();
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<Record<string, unknown> | undefined>
): Set<string> {
const knownNames = new Set<string>();
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<string>,
): AgentSummary[] {
return summaries.filter((summary) => knownNames.has(summary.name.toLowerCase()));
}

View File

@@ -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<string, CategoryConfig> | undefined;
currentModel: string | undefined;
customAgentSummaries?: unknown;
}): Promise<Record<string, unknown>> {
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<custom_agent_catalog>\nAvailable custom agents for planning/delegation:\n${customAgentCatalog
.map((agent) => `- ${agent.name}: ${agent.description || "No description provided"}`)
.join("\n")}\n</custom_agent_catalog>`
: ""
const base: Record<string, unknown> = {
...(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",

View File

@@ -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",
})
})
})

View File

@@ -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", {

View File

@@ -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
}