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.
This commit is contained in:
MoerAI
2026-05-13 19:30:09 +09:00
parent 61ba4e3b41
commit 4bd81d2cdd
2 changed files with 60 additions and 3 deletions
@@ -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<string, { mode?: string; model?: AgentInfo["model"] }>): AgentInfo[] =>
const toAgentInfoList = (record: Record<string, { mode?: string; hidden?: boolean; model?: AgentInfo["model"] }>): 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(", ")
@@ -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({