From 7af3007e671d33b1e109d8c695304f0494a19708 Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sat, 16 May 2026 02:13:28 -0400 Subject: [PATCH 1/2] fix(team-mode): reject coordinator agents as subagent targets (#4027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents could select coordinator/meta agents (Prometheus, Atlas, Sisyphus/Ultraworker) as subagent targets via task() / delegation, producing duplicate orchestration loops and conflicting team state. This is the inverse of #3987 / #4065 — symmetric guard on the delegation TARGET side, using the same AGENT_ELIGIBILITY_REGISTRY classification. Add a runtime guard at the delegation entry point that rejects task() calls whose subagent_type resolves to an agent marked as hard-reject / coordinator-only in the eligibility registry, with an actionable error naming the agent. Regression test asserts a prometheus-targeted delegation is rejected before any subagent session spawns. Co-Authored-By: Claude Sonnet 4.6 --- src/tools/delegate-task/constants.ts | 19 +++++ .../coordinator-subagent-guard.test.ts | 85 +++++++++++++++++++ src/tools/delegate-task/subagent-resolver.ts | 10 ++- src/tools/delegate-task/tools.test.ts | 4 +- 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/tools/delegate-task/coordinator-subagent-guard.test.ts diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index d6718762b..c312fce23 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -346,3 +346,22 @@ export function isPlanFamily(category: string | undefined): boolean { const lowerCategory = getAgentConfigKey(category).toLowerCase().trim() return PLAN_FAMILY_NAMES.some((name) => lowerCategory === name) } + +/** + * Coordinator/meta agents that own the orchestration loop and must not be used as + * arbitrary subagent targets via task(). Delegating to these creates duplicate + * orchestration and conflicting team state (issue #4027). + * + * Symmetric guard to the caller-eligibility check added by PR #4065 for team_create. + */ +export const COORDINATOR_AGENT_NAMES = ["prometheus", "atlas", "sisyphus"] + +/** + * Returns true when the given agent name refers to a coordinator/meta agent that + * should not be reachable as a subagent_type target via task(). + */ +export function isCoordinatorAgent(agentName: string | undefined): boolean { + if (!agentName) return false + const normalized = getAgentConfigKey(agentName).toLowerCase().trim() + return COORDINATOR_AGENT_NAMES.some((name) => normalized === name) +} diff --git a/src/tools/delegate-task/coordinator-subagent-guard.test.ts b/src/tools/delegate-task/coordinator-subagent-guard.test.ts new file mode 100644 index 000000000..6ea8dd0dc --- /dev/null +++ b/src/tools/delegate-task/coordinator-subagent-guard.test.ts @@ -0,0 +1,85 @@ +/** + * Regression test for issue #4027: coordinator agents must not be selectable as + * subagent targets via task(). Symmetric guard to PR #4065 (team_create caller + * eligibility) — this covers the TARGET side of delegation. + */ +const { describe, test, expect } = require("bun:test") + +import { resolveSubagentExecution } from "./subagent-resolver" +import { COORDINATOR_AGENT_NAMES } from "./constants" +import type { ExecutorContext } from "./executor-types" + +function makeCtx(): ExecutorContext { + return { + client: { + app: { agents: async () => ({ data: [] }) }, + config: { get: async () => ({ data: {} }) }, + } as unknown as ExecutorContext["client"], + manager: {} as unknown as ExecutorContext["manager"], + directory: "/tmp/test", + } +} + +describe("coordinator subagent guard (#4027)", () => { + for (const coordinatorName of COORDINATOR_AGENT_NAMES) { + test(`#given subagent_type="${coordinatorName}" #when resolveSubagentExecution is called #then it is rejected before spawning`, async () => { + //#given + const ctx = makeCtx() + const args = { + subagent_type: coordinatorName, + prompt: "do something", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await resolveSubagentExecution(args, ctx, "sisyphus", "") + + //#then + expect(result.error).toBeDefined() + expect(result.agentToUse).toBe("") + expect(result.error).toContain(coordinatorName) + expect(result.error).toContain("coordinator agent") + }) + } + + test("#given subagent_type=prometheus #when resolveSubagentExecution is called #then error names the agent and explains the conflict", async () => { + //#given + const ctx = makeCtx() + const args = { + subagent_type: "prometheus", + prompt: "plan something", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await resolveSubagentExecution(args, ctx, "sisyphus", "") + + //#then + expect(result.error).toContain("prometheus") + expect(result.error).toContain("coordinator") + expect(result.error).toContain("duplicate") + expect(result.agentToUse).toBe("") + expect(result.categoryModel).toBeUndefined() + }) + + test("#given subagent_type=hephaestus #when resolveSubagentExecution is called #then it is not blocked by coordinator guard", async () => { + //#given + const ctx = makeCtx() + const args = { + subagent_type: "hephaestus", + prompt: "write some code", + load_skills: [], + run_in_background: false, + } + + //#when + const result = await resolveSubagentExecution(args, ctx, "sisyphus", "") + + //#then — hephaestus may fail for other reasons (API call), but NOT the coordinator guard + expect(result.error).not.toContain("coordinator agent") + }) +}) + +export {} diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 31127a052..3d2fd92bb 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -1,7 +1,7 @@ import type { DelegateTaskArgs } from "./types" import type { ExecutorContext } from "./executor-types" import type { DelegatedModelConfig } from "./types" -import { isPlanAgent, isPlanFamily } from "./constants" +import { isPlanAgent, isPlanFamily, isCoordinatorAgent, COORDINATOR_AGENT_NAMES } from "./constants" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" import { applyCategoryParams } from "./delegated-model-config" import { getAvailableModelsForDelegateTask } from "./available-models" @@ -107,6 +107,14 @@ Create the work plan directly - that's your job as the planning agent.`, } } + if (isCoordinatorAgent(agentName)) { + return { + agentToUse: "", + categoryModel: undefined, + error: `Cannot delegate to coordinator agent "${agentName}" via task(). Coordinator agents (${COORDINATOR_AGENT_NAMES.join(", ")}) own the orchestration loop and must not be used as subagent targets — doing so creates duplicate coordinators and conflicting team state. Select a worker agent (e.g., sisyphus-junior via category, hephaestus, oracle) instead.`, + } + } + let agentToUse = agentName let categoryModel: DelegatedModelConfig | undefined let fallbackChain: FallbackEntry[] | undefined diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 242a567b6..d5667a548 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -4430,8 +4430,8 @@ describe("sisyphus-task", () => { { sessionID: "p", messageID: "m", agent: "sisyphus", abort: new AbortController().signal } ) - //#then - expect(result).toContain('Cannot delegate to primary agent "prometheus" via task. Select that agent directly instead.') + //#then — coordinator guard fires before primary-agent check; message names the agent and explains the conflict + expect(result).toContain('Cannot delegate to coordinator agent "prometheus" via task()') }, { timeout: 20000 }) test("non-plan subagent should NOT have task permission", async () => { From 860c663c8058cb76c16f63541f6442734ff0bdbf Mon Sep 17 00:00:00 2001 From: ZeyuFu Date: Sat, 16 May 2026 05:34:04 -0400 Subject: [PATCH 2/2] fix-up(#4027): narrow coordinator guard to registry hard-reject set Maintainer feedback (#4071 review): the original guard rejected sisyphus and atlas as subagent targets even from team-mode where resolveMember() intentionally calls resolveSubagentExecution with allowPrimaryAgentDelegation: true. Per AGENT_ELIGIBILITY_REGISTRY (src/features/team-mode/types.ts), only prometheus is hard-reject; sisyphus and atlas are explicitly verdict: 'eligible' for team membership. Shrink COORDINATOR_AGENT_NAMES to ['prometheus'] so the guard aligns with the registry's authoritative classification, document the scoping rule in a comment, and add regression tests covering: - sisyphus is NOT blocked by the coordinator guard (registry eligible) - atlas is NOT blocked by the coordinator guard (registry eligible) - prometheus IS blocked even when allowPrimaryAgentDelegation: true (registry hard-reject is authoritative) Fixes the 5 zauc-mocks resolver tests that were locking in the wrong rejection set (including 'allows delegating to a primary agent when allowPrimaryAgentDelegation is enabled'). The one test asserting the literal primary-agent error string for Prometheus display-name was loosened to a regex that accepts either guard's message, since prometheus is now caught by the coordinator path which fires before the primary-agent lookup. --- src/tools/delegate-task/constants.ts | 8 ++- .../coordinator-subagent-guard.test.ts | 59 +++++++++++++++++++ .../subagent-resolver.test.ts | 5 +- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index c312fce23..f2e4f33be 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -352,9 +352,15 @@ export function isPlanFamily(category: string | undefined): boolean { * arbitrary subagent targets via task(). Delegating to these creates duplicate * orchestration and conflicting team state (issue #4027). * + * Scoped to AGENT_ELIGIBILITY_REGISTRY hard-reject entries only — sisyphus and atlas + * are explicitly marked `verdict: "eligible"` for team membership in the registry + * (src/features/team-mode/types.ts), so they are NOT included here. Adding them would + * conflict with the team-mode resolver's intentional `allowPrimaryAgentDelegation: true` + * opt-in. + * * Symmetric guard to the caller-eligibility check added by PR #4065 for team_create. */ -export const COORDINATOR_AGENT_NAMES = ["prometheus", "atlas", "sisyphus"] +export const COORDINATOR_AGENT_NAMES = ["prometheus"] /** * Returns true when the given agent name refers to a coordinator/meta agent that diff --git a/src/tools/delegate-task/coordinator-subagent-guard.test.ts b/src/tools/delegate-task/coordinator-subagent-guard.test.ts index 6ea8dd0dc..12099bce3 100644 --- a/src/tools/delegate-task/coordinator-subagent-guard.test.ts +++ b/src/tools/delegate-task/coordinator-subagent-guard.test.ts @@ -30,6 +30,7 @@ describe("coordinator subagent guard (#4027)", () => { prompt: "do something", load_skills: [], run_in_background: false, + description: "test delegation", } //#when @@ -51,6 +52,7 @@ describe("coordinator subagent guard (#4027)", () => { prompt: "plan something", load_skills: [], run_in_background: false, + description: "test delegation", } //#when @@ -72,6 +74,7 @@ describe("coordinator subagent guard (#4027)", () => { prompt: "write some code", load_skills: [], run_in_background: false, + description: "test delegation", } //#when @@ -80,6 +83,62 @@ describe("coordinator subagent guard (#4027)", () => { //#then — hephaestus may fail for other reasons (API call), but NOT the coordinator guard expect(result.error).not.toContain("coordinator agent") }) + + test("#given subagent_type=sisyphus #when resolveSubagentExecution is called #then sisyphus is NOT blocked by coordinator guard (registry: eligible)", async () => { + //#given — sisyphus is verdict:'eligible' in AGENT_ELIGIBILITY_REGISTRY; it must not be rejected by the coordinator guard + const ctx = makeCtx() + const args = { + subagent_type: "sisyphus", + prompt: "do team-mode work", + load_skills: [], + run_in_background: false, + description: "test delegation", + } + + //#when + const result = await resolveSubagentExecution(args, ctx, "sisyphus", "") + + //#then — sisyphus may fail for primary-agent reasons (separate guard), but NOT the coordinator guard + expect(result.error).not.toContain("coordinator agent") + }) + + test("#given subagent_type=atlas #when resolveSubagentExecution is called #then atlas is NOT blocked by coordinator guard (registry: eligible)", async () => { + //#given — atlas is verdict:'eligible' in AGENT_ELIGIBILITY_REGISTRY; it must not be rejected by the coordinator guard + const ctx = makeCtx() + const args = { + subagent_type: "atlas", + prompt: "do team-mode work", + load_skills: [], + run_in_background: false, + description: "test delegation", + } + + //#when + const result = await resolveSubagentExecution(args, ctx, "sisyphus", "") + + //#then — atlas may fail for primary-agent reasons (separate guard), but NOT the coordinator guard + expect(result.error).not.toContain("coordinator agent") + }) + + test("#given subagent_type=prometheus AND allowPrimaryAgentDelegation=true #when resolveSubagentExecution is called #then prometheus is STILL rejected (registry hard-reject is authoritative)", async () => { + //#given — prometheus is verdict:'hard-reject' in AGENT_ELIGIBILITY_REGISTRY; the coordinator guard must fire even when the team-mode resolver opts into primary-agent delegation + const ctx = makeCtx() + const args = { + subagent_type: "prometheus", + prompt: "plan something", + load_skills: [], + run_in_background: false, + description: "test delegation", + } + + //#when + const result = await resolveSubagentExecution(args, ctx, "sisyphus", "", { allowPrimaryAgentDelegation: true }) + + //#then + expect(result.error).toContain("prometheus") + expect(result.error).toContain("coordinator agent") + expect(result.agentToUse).toBe("") + }) }) export {} 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 7aa4c0509..ca1e8aeba 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 @@ -165,7 +165,10 @@ describe("resolveSubagentExecution", () => { //#then expect(result.agentToUse).toBe("") expect(result.categoryModel).toBeUndefined() - expect(result.error).toBe('Cannot delegate to primary agent "Prometheus - Plan Builder" via task. Select that agent directly instead.') + // Prometheus is registry-hard-reject (AGENT_ELIGIBILITY_REGISTRY); the coordinator guard (#4027 / #4071) fires before + // the primary-agent guard. Either rejection message is acceptable as long as prometheus is blocked from delegation. + expect(result.error).toContain('"Prometheus - Plan Builder"') + expect(result.error).toMatch(/Cannot delegate to (coordinator agent|primary agent)/) }) test("allows delegating to a primary agent when allowPrimaryAgentDelegation is enabled (team-mode path)", async () => {