fix(team-mode): reject coordinator agents as subagent targets (#4027)

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 <noreply@anthropic.com>
This commit is contained in:
ZeyuFu
2026-05-16 02:13:28 -04:00
parent fcb96841f8
commit 7af3007e67
4 changed files with 115 additions and 3 deletions
+19
View File
@@ -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)
}
@@ -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 {}
+9 -1
View File
@@ -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
+2 -2
View File
@@ -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 () => {