diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index c70da062..defaeecb 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -1,8 +1,8 @@ -import type { AgentPromptMetadata, BuiltinAgentName } from "./types" +import type { AgentPromptMetadata } from "./types" import { truncateDescription } from "../shared/truncate-description" export interface AvailableAgent { - name: BuiltinAgentName + name: string description: string metadata: AgentPromptMetadata } diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 88883feb..dfe9d972 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -249,6 +249,222 @@ describe("createBuiltinAgents with model overrides", () => { expect(agents.sisyphus.prompt).toContain("frontend-ui-ux") expect(agents.sisyphus.prompt).toContain("git-master") }) + + test("includes custom agents in orchestrator prompts when provided via config", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set([ + "anthropic/claude-opus-4-6", + "kimi-for-coding/k2p5", + "opencode/kimi-k2.5-free", + "zai-coding-plan/glm-4.7", + "opencode/glm-4.7-free", + "openai/gpt-5.2", + ]) + ) + + const customAgentSummaries = [ + { + name: "researcher", + description: "Research agent for deep analysis", + hidden: false, + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).toContain("researcher") + expect(agents.hephaestus.prompt).toContain("researcher") + expect(agents.atlas.prompt).toContain("researcher") + } finally { + fetchSpy.mockRestore() + } + }) + + test("excludes hidden custom agents from orchestrator prompts", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { + name: "hidden-agent", + description: "Should never show", + hidden: true, + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).not.toContain("hidden-agent") + expect(agents.hephaestus.prompt).not.toContain("hidden-agent") + expect(agents.atlas.prompt).not.toContain("hidden-agent") + } finally { + fetchSpy.mockRestore() + } + }) + + test("excludes disabled custom agents from orchestrator prompts", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { + name: "disabled-agent", + description: "Should never show", + disabled: true, + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).not.toContain("disabled-agent") + expect(agents.hephaestus.prompt).not.toContain("disabled-agent") + expect(agents.atlas.prompt).not.toContain("disabled-agent") + } finally { + fetchSpy.mockRestore() + } + }) + + test("excludes custom agents when disabledAgents contains their name (case-insensitive)", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const disabledAgents = ["ReSeArChEr"] + const customAgentSummaries = [ + { + name: "researcher", + description: "Should never show", + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + disabledAgents, + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).not.toContain("researcher") + expect(agents.hephaestus.prompt).not.toContain("researcher") + expect(agents.atlas.prompt).not.toContain("researcher") + } finally { + fetchSpy.mockRestore() + } + }) + + test("deduplicates custom agents case-insensitively", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { name: "Researcher", description: "First" }, + { name: "researcher", description: "Second" }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + const matches = agents.sisyphus.prompt.match(/Custom agent: researcher/gi) ?? [] + expect(matches.length).toBe(1) + } finally { + fetchSpy.mockRestore() + } + }) + + test("sanitizes custom agent strings for markdown tables", async () => { + // #given + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]) + ) + + const customAgentSummaries = [ + { + name: "table-agent", + description: "Line1\nAlpha | Beta", + }, + ] + + try { + // #when + const agents = await createBuiltinAgents( + [], + {}, + undefined, + TEST_DEFAULT_MODEL, + undefined, + undefined, + [], + customAgentSummaries + ) + + // #then + expect(agents.sisyphus.prompt).toContain("Line1 Alpha \\| Beta") + } finally { + fetchSpy.mockRestore() + } + }) }) describe("createBuiltinAgents without systemDefaultModel", () => { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 5aac0ebb..55d6187b 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -11,7 +11,18 @@ import { createAtlasAgent, atlasPromptMetadata } from "./atlas" import { createMomusAgent, momusPromptMetadata } from "./momus" import { createHephaestusAgent } from "./hephaestus" import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" -import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable, isAnyFallbackModelAvailable, isAnyProviderConnected, migrateAgentConfig } from "../shared" +import { + deepMerge, + fetchAvailableModels, + resolveModelPipeline, + AGENT_MODEL_REQUIREMENTS, + readConnectedProvidersCache, + isModelAvailable, + isAnyFallbackModelAvailable, + isAnyProviderConnected, + migrateAgentConfig, + truncateDescription, +} from "../shared" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" import { createBuiltinSkills } from "../features/builtin-skills" @@ -52,6 +63,64 @@ function isFactory(source: AgentSource): source is AgentFactory { return typeof source === "function" } +type RegisteredAgentSummary = { + name: string + description: string +} + +function sanitizeMarkdownTableCell(value: string): string { + return value + .replace(/\r?\n/g, " ") + .replace(/\|/g, "\\|") + .replace(/\s+/g, " ") + .trim() +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function parseRegisteredAgentSummaries(input: unknown): RegisteredAgentSummary[] { + if (!Array.isArray(input)) return [] + + const result: RegisteredAgentSummary[] = [] + for (const item of input) { + if (!isRecord(item)) continue + + const name = typeof item.name === "string" ? item.name : undefined + if (!name) continue + + const hidden = item.hidden + if (hidden === true) continue + + const disabled = item.disabled + if (disabled === true) continue + + const enabled = item.enabled + if (enabled === false) continue + + const description = typeof item.description === "string" ? item.description : "" + result.push({ name, description: sanitizeMarkdownTableCell(description) }) + } + + return result +} + +function buildCustomAgentMetadata(agentName: string, description: string): AgentPromptMetadata { + const shortDescription = sanitizeMarkdownTableCell(truncateDescription(description)) + const safeAgentName = sanitizeMarkdownTableCell(agentName) + return { + category: "specialist", + cost: "CHEAP", + triggers: [ + { + domain: `Custom agent: ${safeAgentName}`, + trigger: shortDescription || "Use when this agent's description matches the task", + }, + ], + } +} + export function buildAgent( source: AgentSource, model: string, @@ -233,13 +302,13 @@ export async function createBuiltinAgents( categories?: CategoriesConfig, gitMasterConfig?: GitMasterConfig, discoveredSkills: LoadedSkill[] = [], - client?: any, + customAgentSummaries?: unknown, browserProvider?: BrowserAutomationProvider, uiSelectedModel?: string, disabledSkills?: Set ): Promise> { const connectedProviders = readConnectedProvidersCache() - // IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization. + // IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization. // This function is called from config handler, and calling client API causes deadlock. // See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301 const availableModels = await fetchAvailableModels(undefined, { @@ -279,6 +348,10 @@ export async function createBuiltinAgents( const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable] + const registeredAgents = parseRegisteredAgentSummaries(customAgentSummaries) + const builtinAgentNames = new Set(Object.keys(agentSources).map((n) => n.toLowerCase())) + const disabledAgentNames = new Set(disabledAgents.map((n) => n.toLowerCase())) + // Collect general agents first (for availableAgents), but don't add to result yet const pendingAgentConfigs: Map = new Map() @@ -335,14 +408,27 @@ export async function createBuiltinAgents( // Store for later - will be added after sisyphus and hephaestus pendingAgentConfigs.set(name, config) - const metadata = agentMetadata[agentName] - if (metadata) { - availableAgents.push({ - name: agentName, - description: config.description ?? "", - metadata, - }) - } + const metadata = agentMetadata[agentName] + if (metadata) { + availableAgents.push({ + name: agentName, + description: config.description ?? "", + metadata, + }) + } + } + + for (const agent of registeredAgents) { + const lowerName = agent.name.toLowerCase() + if (builtinAgentNames.has(lowerName)) continue + if (disabledAgentNames.has(lowerName)) continue + if (availableAgents.some((a) => a.name.toLowerCase() === lowerName)) continue + + availableAgents.push({ + name: agent.name, + description: agent.description, + metadata: buildCustomAgentMetadata(agent.name, agent.description), + }) } const sisyphusOverride = agentOverrides["sisyphus"] diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index ed9bf0f7..050adf0e 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -184,19 +184,40 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { // Pass it as uiSelectedModel so it takes highest priority in model resolution const currentModel = config.model as string | undefined; const disabledSkills = new Set(pluginConfig.disabled_skills ?? []); - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - pluginConfig.agents, - ctx.directory, - undefined, // systemDefaultModel - let fallback chain handle this - pluginConfig.categories, - pluginConfig.git_master, - allDiscoveredSkills, - ctx.client, - browserProvider, - currentModel, // uiSelectedModel - takes highest priority - disabledSkills - ); + + type AgentConfig = Record< + string, + Record | undefined + > & { + build?: Record; + plan?: Record; + explore?: { tools?: Record }; + librarian?: { tools?: Record }; + "multimodal-looker"?: { tools?: Record }; + atlas?: { tools?: Record }; + sisyphus?: { tools?: Record }; + }; + const configAgent = config.agent as AgentConfig | undefined; + + function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; + } + + function buildCustomAgentSummaryInput(agents: Record | undefined): unknown[] { + if (!agents) return []; + + const result: unknown[] = []; + for (const [name, value] of Object.entries(agents)) { + if (!isRecord(value)) continue; + + const description = typeof value.description === "string" ? value.description : ""; + const hidden = value.hidden === true; + const disabled = value.disabled === true || value.enabled === false; + result.push({ name, description, hidden, disabled }); + } + + return result; + } // Claude Code agents: Do NOT apply permission migration // Claude Code uses whitelist-based tools format which is semantically different @@ -217,6 +238,27 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { ]) ); + const customAgentSummaries = [ + ...buildCustomAgentSummaryInput(configAgent), + ...buildCustomAgentSummaryInput(userAgents), + ...buildCustomAgentSummaryInput(projectAgents), + ...buildCustomAgentSummaryInput(pluginAgents), + ]; + + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + pluginConfig.agents, + ctx.directory, + undefined, // systemDefaultModel - let fallback chain handle this + pluginConfig.categories, + pluginConfig.git_master, + allDiscoveredSkills, + customAgentSummaries, + browserProvider, + currentModel, // uiSelectedModel - takes highest priority + disabledSkills + ); + const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -225,20 +267,6 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true; const shouldDemotePlan = plannerEnabled && replacePlan; - type AgentConfig = Record< - string, - Record | undefined - > & { - build?: Record; - plan?: Record; - explore?: { tools?: Record }; - librarian?: { tools?: Record }; - "multimodal-looker"?: { tools?: Record }; - atlas?: { tools?: Record }; - sisyphus?: { tools?: Record }; - }; - const configAgent = config.agent as AgentConfig | undefined; - if (isSisyphusEnabled && builtinAgents.sisyphus) { (config as { default_agent?: string }).default_agent = "sisyphus";