diff --git a/src/agents/agent-builder.ts b/src/agents/agent-builder.ts index 63cf6962..f60f8137 100644 --- a/src/agents/agent-builder.ts +++ b/src/agents/agent-builder.ts @@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk" import type { AgentFactory } from "./types" import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema" import type { BrowserAutomationProvider } from "../config/schema" -import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants" +import { mergeCategories } from "../shared/merge-categories" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" export type AgentSource = AgentFactory | AgentConfig @@ -20,9 +20,7 @@ export function buildAgent( disabledSkills?: Set ): AgentConfig { const base = isFactory(source) ? source(model) : { ...source } - const categoryConfigs: Record = categories - ? { ...DEFAULT_CATEGORIES, ...categories } - : DEFAULT_CATEGORIES + const categoryConfigs: Record = mergeCategories(categories) const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string } if (agentWithCategory.category) { diff --git a/src/agents/atlas/agent.ts b/src/agents/atlas/agent.ts index 7b2c73fc..c4aa65f7 100644 --- a/src/agents/atlas/agent.ts +++ b/src/agents/atlas/agent.ts @@ -15,7 +15,7 @@ import { isGptModel } from "../types" import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder" import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder" import type { CategoryConfig } from "../../config/schema" -import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants" +import { mergeCategories } from "../../shared/merge-categories" import { createAgentToolRestrictions } from "../../shared/permission-compat" import { getDefaultAtlasPrompt } from "./default" @@ -70,7 +70,7 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string { const userCategories = ctx?.userCategories const model = ctx?.model - const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories } + const allCategories = mergeCategories(userCategories) const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({ name, description: getCategoryDescription(name, userCategories), diff --git a/src/agents/atlas/prompt-section-builder.ts b/src/agents/atlas/prompt-section-builder.ts index 3bed073b..570834ce 100644 --- a/src/agents/atlas/prompt-section-builder.ts +++ b/src/agents/atlas/prompt-section-builder.ts @@ -7,7 +7,8 @@ import type { CategoryConfig } from "../../config/schema" import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder" -import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants" +import { CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants" +import { mergeCategories } from "../../shared/merge-categories" import { truncateDescription } from "../../shared/truncate-description" export const getCategoryDescription = (name: string, userCategories?: Record) => @@ -33,7 +34,7 @@ ${rows.join("\n")}` } export function buildCategorySection(userCategories?: Record): string { - const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories } + const allCategories = mergeCategories(userCategories) const categoryRows = Object.entries(allCategories).map(([name, config]) => { const temp = config.temperature ?? 0.5 return `| \`${name}\` | ${temp} | ${getCategoryDescription(name, userCategories)} |` @@ -116,7 +117,7 @@ task(category="[category]", load_skills=["skill-1", "skill-2"], run_in_backgroun } export function buildDecisionMatrix(agents: AvailableAgent[], userCategories?: Record): string { - const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories } + const allCategories = mergeCategories(userCategories) const categoryRows = Object.entries(allCategories).map(([name]) => `| ${getCategoryDescription(name, userCategories)} | \`category="${name}", load_skills=[...]\` |` diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index 3f19dbb0..b20c166e 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -14,7 +14,8 @@ import { createMomusAgent, momusPromptMetadata } from "./momus" import { createHephaestusAgent } from "./hephaestus" import type { AvailableCategory } from "./dynamic-agent-prompt-builder" import { fetchAvailableModels, readConnectedProvidersCache } from "../shared" -import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" +import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" +import { mergeCategories } from "../shared/merge-categories" import { buildAvailableSkills } from "./builtin-agents/available-skills" import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents" import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent" @@ -78,9 +79,7 @@ export async function createBuiltinAgents( const result: Record = {} - const mergedCategories = categories - ? { ...DEFAULT_CATEGORIES, ...categories } - : DEFAULT_CATEGORIES + const mergedCategories = mergeCategories(categories) const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({ name, diff --git a/src/config/schema/categories.ts b/src/config/schema/categories.ts index b8028c57..980b3728 100644 --- a/src/config/schema/categories.ts +++ b/src/config/schema/categories.ts @@ -20,6 +20,8 @@ export const CategoryConfigSchema = z.object({ prompt_append: z.string().optional(), /** Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini/minimax models. */ is_unstable_agent: z.boolean().optional(), + /** Disable this category. Disabled categories are excluded from task delegation. */ + disable: z.boolean().optional(), }) export const BuiltinCategoryNameSchema = z.enum([ diff --git a/src/plugin/available-categories.ts b/src/plugin/available-categories.ts index 0cda4317..dc51836d 100644 --- a/src/plugin/available-categories.ts +++ b/src/plugin/available-categories.ts @@ -1,19 +1,14 @@ import type { AvailableCategory } from "../agents/dynamic-agent-prompt-builder" import type { OhMyOpenCodeConfig } from "../config" - -import { - CATEGORY_DESCRIPTIONS, - DEFAULT_CATEGORIES, -} from "../tools/delegate-task/constants" +import { CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" +import { mergeCategories } from "../shared/merge-categories" export function createAvailableCategories( pluginConfig: OhMyOpenCodeConfig, ): AvailableCategory[] { - const mergedCategories = pluginConfig.categories - ? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories } - : DEFAULT_CATEGORIES + const categories = mergeCategories(pluginConfig.categories) - return Object.entries(mergedCategories).map(([name, categoryConfig]) => { + return Object.entries(categories).map(([name, categoryConfig]) => { const model = typeof categoryConfig.model === "string" ? categoryConfig.model : undefined diff --git a/src/shared/merge-categories.test.ts b/src/shared/merge-categories.test.ts new file mode 100644 index 00000000..e5108f27 --- /dev/null +++ b/src/shared/merge-categories.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "bun:test" +import { mergeCategories } from "./merge-categories" +import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants" + +describe("mergeCategories", () => { + it("returns all default categories when no user config provided", () => { + //#given + const userCategories = undefined + + //#when + const result = mergeCategories(userCategories) + + //#then + expect(Object.keys(result)).toEqual(Object.keys(DEFAULT_CATEGORIES)) + }) + + it("filters out categories with disable: true", () => { + //#given + const userCategories = { + "quick": { disable: true }, + } + + //#when + const result = mergeCategories(userCategories) + + //#then + expect(result["quick"]).toBeUndefined() + expect(Object.keys(result).length).toBe(Object.keys(DEFAULT_CATEGORIES).length - 1) + }) + + it("keeps categories with disable: false", () => { + //#given + const userCategories = { + "quick": { disable: false }, + } + + //#when + const result = mergeCategories(userCategories) + + //#then + expect(result["quick"]).toBeDefined() + }) + + it("allows user to add custom categories", () => { + //#given + const userCategories = { + "my-custom": { model: "openai/gpt-5.2", description: "Custom category" }, + } + + //#when + const result = mergeCategories(userCategories) + + //#then + expect(result["my-custom"]).toBeDefined() + expect(result["my-custom"].model).toBe("openai/gpt-5.2") + }) + + it("allows user to disable custom categories", () => { + //#given + const userCategories = { + "my-custom": { model: "openai/gpt-5.2", disable: true }, + } + + //#when + const result = mergeCategories(userCategories) + + //#then + expect(result["my-custom"]).toBeUndefined() + }) + + it("user overrides merge with defaults", () => { + //#given + const userCategories = { + "ultrabrain": { model: "anthropic/claude-opus-4-6" }, + } + + //#when + const result = mergeCategories(userCategories) + + //#then + expect(result["ultrabrain"]).toBeDefined() + expect(result["ultrabrain"].model).toBe("anthropic/claude-opus-4-6") + }) +}) diff --git a/src/shared/merge-categories.ts b/src/shared/merge-categories.ts new file mode 100644 index 00000000..3d3c0ae8 --- /dev/null +++ b/src/shared/merge-categories.ts @@ -0,0 +1,18 @@ +import type { CategoriesConfig, CategoryConfig } from "../config/schema" +import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants" + +/** + * Merge default and user categories, filtering out disabled ones. + * Single source of truth for category merging across the codebase. + */ +export function mergeCategories( + userCategories?: CategoriesConfig, +): Record { + const merged = userCategories + ? { ...DEFAULT_CATEGORIES, ...userCategories } + : { ...DEFAULT_CATEGORIES } + + return Object.fromEntries( + Object.entries(merged).filter(([, config]) => !config.disable), + ) +} diff --git a/src/tools/delegate-task/categories.ts b/src/tools/delegate-task/categories.ts index a13c2185..f3aa41f3 100644 --- a/src/tools/delegate-task/categories.ts +++ b/src/tools/delegate-task/categories.ts @@ -32,7 +32,10 @@ export function resolveCategoryConfig( const userConfig = userCategories?.[categoryName] const hasExplicitUserConfig = userConfig !== undefined - // Check if category requires a specific model - bypass if user explicitly provides config + if (userConfig?.disable) { + return null + } + const categoryReq = CATEGORY_MODEL_REQUIREMENTS[categoryName] if (categoryReq?.requiresModel && availableModels && !hasExplicitUserConfig) { if (!isModelAvailable(categoryReq.requiresModel, availableModels)) { diff --git a/src/tools/delegate-task/category-resolver.ts b/src/tools/delegate-task/category-resolver.ts index 57d427e4..ff5b9318 100644 --- a/src/tools/delegate-task/category-resolver.ts +++ b/src/tools/delegate-task/category-resolver.ts @@ -1,7 +1,7 @@ import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import type { DelegateTaskArgs } from "./types" import type { ExecutorContext } from "./executor-types" -import { DEFAULT_CATEGORIES } from "./constants" +import { mergeCategories } from "../../shared/merge-categories" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { resolveCategoryConfig } from "./categories" import { parseModelString } from "./model-string-parser" @@ -30,7 +30,8 @@ export async function resolveCategoryExecution( const availableModels = await getAvailableModelsForDelegateTask(client) const categoryName = args.category! - const categoryExists = DEFAULT_CATEGORIES[categoryName] !== undefined || userCategories?.[categoryName] !== undefined + const enabledCategories = mergeCategories(userCategories) + const categoryExists = enabledCategories[categoryName] !== undefined const resolved = resolveCategoryConfig(categoryName, { userCategories, @@ -41,7 +42,7 @@ export async function resolveCategoryExecution( if (!resolved) { const requirement = CATEGORY_MODEL_REQUIREMENTS[categoryName] - const allCategoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ") + const allCategoryNames = Object.keys(enabledCategories).join(", ") if (categoryExists && requirement?.requiresModel) { return { @@ -146,7 +147,7 @@ Available categories: ${allCategoryNames}`, const categoryPromptAppend = resolved.promptAppend || undefined if (!categoryModel && !actualModel) { - const categoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }) + const categoryNames = Object.keys(enabledCategories) return { agentToUse: "", categoryModel: undefined, diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index cd4de649..c2ec6513 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -1,6 +1,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" import type { DelegateTaskArgs, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types" -import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "./constants" +import { CATEGORY_DESCRIPTIONS } from "./constants" +import { mergeCategories } from "../../shared/merge-categories" import { log } from "../../shared/logger" import { buildSystemContent } from "./prompt-builder" import type { @@ -26,7 +27,7 @@ export { buildSystemContent } from "./prompt-builder" export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition { const { userCategories } = options - const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories } + const allCategories = mergeCategories(userCategories) const categoryNames = Object.keys(allCategories) const categoryExamples = categoryNames.map(k => `'${k}'`).join(", ")