From 4bd81d2cdde52397c1be3b19e89f742a2fb7bd30 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Wed, 13 May 2026 19:30:09 +0900 Subject: [PATCH] fix(delegate-task): exclude hidden agents from task delegation discovery (fixes #3957) OpenCode injects native execution agents like build (and a demoted plan in OMO mode) as { mode: 'subagent', hidden: true }. The dynamic agent discovery in subagent-discovery.ts only filtered by mode, so a hidden agent still resolved as a callable target via task(). This created a boundary leak: an OMO orchestrator (sisyphus, prometheus, etc.) could delegate work into the hidden native build/plan path instead of the OMO category/skill pipeline. Add hidden?: boolean to AgentInfo, plumb it through mergeWithClaudeCodeAgents, and skip hidden agents in both findCallableAgentMatch and listCallableAgentNames so hidden natives are neither matched nor advertised in 'Available agents' error messages. The OpenCode SDK Agent type already exposes hidden?: boolean, so no schema work is required. Verified by adding three regression tests in zauc-mocks-subagent-resolver/subagent-resolver.test.ts: hidden 'build' is rejected, hidden 'plan' is rejected, and hidden agents are excluded from the Available agents list. Full delegate-task suite (395 tests) and call-omo-agent suite (57 tests) pass; bun run typecheck is clean. --- src/tools/delegate-task/subagent-discovery.ts | 8 ++- .../subagent-resolver.test.ts | 55 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/tools/delegate-task/subagent-discovery.ts b/src/tools/delegate-task/subagent-discovery.ts index 340ef7c22..118d3adf8 100644 --- a/src/tools/delegate-task/subagent-discovery.ts +++ b/src/tools/delegate-task/subagent-discovery.ts @@ -6,6 +6,7 @@ export type AgentMode = "subagent" | "primary" | "all" | undefined export type AgentInfo = { name: string mode?: "subagent" | "primary" | "all" + hidden?: boolean model?: string | { providerID: string; modelID: string } } @@ -20,10 +21,11 @@ export function mergeWithClaudeCodeAgents( const userAgentsRecord = loadUserAgents() const projectAgentsRecord = loadProjectAgents(directory) - const toAgentInfoList = (record: Record): AgentInfo[] => + const toAgentInfoList = (record: Record): AgentInfo[] => Object.entries(record).map(([name, config]) => ({ name, mode: config.mode as AgentInfo["mode"], + hidden: config.hidden, model: config.model, })) @@ -73,12 +75,12 @@ export function findCallableAgentMatch( agents: AgentInfo[], requestedAgentName: string, ): AgentInfo | undefined { - return agents.find(agent => isTaskCallableAgentMode(agent.mode) && matchesRequestedAgent(agent, requestedAgentName)) + return agents.find(agent => isTaskCallableAgentMode(agent.mode) && agent.hidden !== true && matchesRequestedAgent(agent, requestedAgentName)) } export function listCallableAgentNames(agents: AgentInfo[]): string { return agents - .filter(agent => isTaskCallableAgentMode(agent.mode)) + .filter(agent => isTaskCallableAgentMode(agent.mode) && agent.hidden !== true) .map(agent => stripAgentListSortPrefix(agent.name)) .sort() .join(", ") diff --git a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts index 05e988716..343177ace 100644 --- a/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts +++ b/src/tools/delegate-task/zauc-mocks-subagent-resolver/subagent-resolver.test.ts @@ -248,6 +248,61 @@ describe("resolveSubagentExecution", () => { expect(result.error).toBe('Unknown agent: "custom-worker". Available agents: oracle') }) + test("rejects delegation to hidden native execution agents (regression #3957)", async () => { + //#given + const args = createBaseArgs({ subagent_type: "build" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "build", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "build". Available agents: oracle') + }) + + test("rejects delegation to hidden plan agent demoted to subagent (regression #3957)", async () => { + //#given + const args = createBaseArgs({ subagent_type: "plan" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "plan", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + expect(result.error).toBe('Unknown agent: "plan". Available agents: oracle') + }) + + test("hidden agents are excluded from listCallableAgentNames in error messages (regression #3957)", async () => { + //#given + const args = createBaseArgs({ subagent_type: "nonexistent" }) + const executorCtx = createExecutorContext(async () => ([ + { name: "build", mode: "subagent", hidden: true }, + { name: "plan", mode: "subagent", hidden: true }, + { name: "oracle", mode: "subagent" }, + { name: "explore", mode: "subagent" }, + ])) + + //#when + const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep") + + //#then + expect(result.agentToUse).toBe("") + expect(result.error).toBeDefined() + expect(result.error).toContain('Available agents: explore, oracle') + expect(result.error).not.toContain("build") + expect(result.error).not.toContain("plan") + }) + test("normalizes matched agent model string before returning categoryModel", async () => { //#given readProviderModelsCacheMock.mockReturnValue({