feat(config): add custom_agents overrides and strict agent validation
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
32
src/config/schema-document.test.ts
Normal file
32
src/config/schema-document.test.ts
Normal 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" }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
136
src/plugin-handlers/custom-agent-utils.ts
Normal file
136
src/plugin-handlers/custom-agent-utils.ts
Normal 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()));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user