Merge pull request #4117 from ririnto/fix/plan-subagent-hidden-registry

fix(delegate-task): restore hidden plan subagent delegation
This commit is contained in:
YeonGyu-Kim
2026-05-18 13:30:35 +09:00
committed by GitHub
8 changed files with 352 additions and 28 deletions
+1 -1
View File
@@ -196,7 +196,7 @@ export function buildNonClaudePlannerSection(model: string): string {
Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan.
- Single-file fix or trivial change → proceed directly
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="prometheus", ...)\` FIRST
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST
- Use \`task_id\` to resume the same Plan Agent - ask follow-up questions aggressively
- If ANY part of the task is ambiguous, ask Plan Agent before guessing
+1 -1
View File
@@ -288,7 +288,7 @@ Every implementation task follows this cycle. No exceptions.
Follow \`<explore>\` protocol for tool usage and agent prompts.
2. PLAN - List files to modify, specific changes, dependencies, complexity estimate.
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="prometheus", ...)\`.
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="plan", ...)\`.
Single-step → mental plan is sufficient.
<dependency_checks>
@@ -13,7 +13,7 @@ import {
} from "./validator"
const PROMETHEUS_REJECTION_MESSAGE =
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead."
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead."
function createCategoryMember(name: string): Member {
return {
+2 -2
View File
@@ -136,7 +136,7 @@ describe("team-mode types", () => {
],
[
"prometheus",
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.",
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.",
],
] as const
@@ -286,7 +286,7 @@ describe("team-mode types", () => {
"Agent 'momus' is read-only (plan reviewer). Cannot write to mailbox as team member. Use delegate-task for plan review instead.",
)
expect(AGENT_ELIGIBILITY_REGISTRY.prometheus.rejectionMessage).toBe(
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.",
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.",
)
expect(CategoryMemberSchema).toBeDefined()
expect(SubagentMemberSchema).toBeDefined()
+1 -1
View File
@@ -229,7 +229,7 @@ export const AGENT_ELIGIBILITY_REGISTRY: Readonly<Record<string, {
prometheus: {
verdict: "hard-reject",
rejectionMessage:
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use category: 'plan' instead.",
"Agent 'prometheus' is plan-mode-only; can only write to .omo/*.md (enforced by prometheusMdOnly hook). Cannot write to team mailbox. Use delegate-task with subagent_type: 'plan' instead.",
},
"sisyphus-junior": { verdict: "eligible" },
} as const
@@ -1,10 +1,9 @@
import { loadProjectAgents, loadUserAgents } from "../../features/claude-code-agent-loader"
import {
getAgentConfigKey,
getAgentDisplayName,
stripAgentListSortPrefix,
stripInvisibleAgentCharacters,
} from "../../shared/agent-display-names"
import { loadUserAgents, loadProjectAgents } from "../../features/claude-code-agent-loader"
export type AgentMode = "subagent" | "primary" | "all" | undefined
@@ -16,7 +15,7 @@ export type AgentInfo = {
}
export function sanitizeSubagentType(subagentType: string): string {
return subagentType.trim().replace(/^[\\\/"']+|[\\\/"']+$/g, "").trim()
return subagentType.trim().replace(/^[\\/"']+|[\\/"']+$/g, "").trim()
}
export function mergeWithClaudeCodeAgents(
@@ -69,10 +68,10 @@ export function isTaskCallableAgentMode(mode: AgentMode): boolean {
return mode === "all" || mode === "subagent"
}
function isDemotedPlanAgent(agent: AgentInfo): boolean {
export function isDemotedPlanAgent(agent: AgentInfo): boolean {
return agent.hidden === true
&& agent.mode === "subagent"
&& stripInvisibleAgentCharacters(agent.name).trim().toLowerCase() === "plan"
&& stripAgentListSortPrefix(agent.name).trim().toLowerCase() === "plan"
}
function isVisibleToTask(agent: AgentInfo): boolean {
+77 -16
View File
@@ -1,30 +1,65 @@
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import type { DelegatedModelConfig } from "./types"
import { isPlanFamily } from "./constants"
import { isPlanAgent, isPlanFamily } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { applyCategoryParams } from "./delegated-model-config"
import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveEffectiveFallbackEntry } from "./fallback-entry-resolution"
import { applyFallbackEntrySettings } from "./fallback-entry-settings"
import type { AgentInfo } from "./subagent-discovery"
import {
type AgentInfo,
sanitizeSubagentType,
mergeWithClaudeCodeAgents,
findPrimaryAgentMatch,
findCallableAgentMatch,
sanitizeSubagentType,
listCallableAgentNames,
mergeWithClaudeCodeAgents,
isDemotedPlanAgent,
} from "./subagent-discovery"
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { getAgentConfigKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
import { getAvailableModelsForDelegateTask } from "./available-models"
import type { FallbackEntry } from "../../shared/model-requirements"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { resolveModelForDelegateTask } from "./model-selection"
import { fuzzyMatchModel } from "../../shared/model-availability"
import { getAgentConfigKey, stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { normalizeSDKResponse } from "../../shared"
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { flattenToFallbackModelStrings, normalizeFallbackModels } from "../../shared/model-resolver"
import { log } from "../../shared/logger"
const DEFAULT_PLAN_FALLBACK_AGENT = "plan"
const RESERVED_HIDDEN_NATIVE_AGENTS = new Set(["build"])
function isReservedHiddenNativeAgent(agentName: string): boolean {
return RESERVED_HIDDEN_NATIVE_AGENTS.has(getAgentConfigKey(agentName))
}
function shouldUseHiddenPlanAgent(
requestedAgent: string,
serverPrimaryAgent: AgentInfo | undefined,
serverMatchedAgent: AgentInfo | undefined,
sisyphusAgentConfig: ExecutorContext["sisyphusAgentConfig"],
hasDemotedPlan: boolean,
): boolean {
if (serverPrimaryAgent) {
return false
}
if (hasDemotedPlan) {
return false
}
if (serverMatchedAgent) {
return false
}
if (!isPlanAgent(requestedAgent)) {
return false
}
return sisyphusAgentConfig?.planner_enabled !== false
&& sisyphusAgentConfig?.replace_plan !== false
}
export interface ResolveSubagentExecutionOptions {
allowSisyphusJuniorDirect?: boolean
@@ -74,18 +109,36 @@ Create the work plan directly - that's your job as the planning agent.`,
let agentToUse = agentName
let categoryModel: DelegatedModelConfig | undefined
let fallbackChain: FallbackEntry[] | undefined = undefined
let fallbackChain: FallbackEntry[] | undefined
try {
const agentsResult = await client.app.agents()
const agents = normalizeSDKResponse(agentsResult, [] as AgentInfo[], {
preferResponseOnMissingData: true,
})
const hasDemotedPlan = agents.some(isDemotedPlanAgent)
const serverPrimaryAgent = findPrimaryAgentMatch(agents, agentToUse)
const serverMatchedAgent = findCallableAgentMatch(agents, agentToUse)
const mergedAgents = mergeWithClaudeCodeAgents(agents, executorCtx.directory)
const matchedPrimaryAgent = findPrimaryAgentMatch(mergedAgents, agentToUse)
const useHiddenPlanFallback = shouldUseHiddenPlanAgent(
agentToUse,
serverPrimaryAgent,
serverMatchedAgent,
executorCtx.sisyphusAgentConfig,
hasDemotedPlan,
)
if (matchedPrimaryAgent && !options.allowPrimaryAgentDelegation) {
if (isReservedHiddenNativeAgent(agentToUse) && !serverPrimaryAgent && !serverMatchedAgent) {
return {
agentToUse: "",
categoryModel: undefined,
error: `Unknown agent: "${agentToUse}". Available agents: ${listCallableAgentNames(agents)}`,
}
}
if (matchedPrimaryAgent && !options.allowPrimaryAgentDelegation && !useHiddenPlanFallback) {
return {
agentToUse: "",
categoryModel: undefined,
@@ -94,10 +147,17 @@ Create the work plan directly - that's your job as the planning agent.`,
}
const usePrimary = options.allowPrimaryAgentDelegation && matchedPrimaryAgent !== undefined
const matchedAgent = usePrimary
let matchedAgent = usePrimary
? matchedPrimaryAgent
: findCallableAgentMatch(mergedAgents, agentToUse)
if (useHiddenPlanFallback) {
matchedAgent = {
name: DEFAULT_PLAN_FALLBACK_AGENT,
mode: "subagent",
}
}
if (!matchedAgent) {
return {
agentToUse: "",
@@ -153,7 +213,8 @@ Create the work plan directly - that's your job as the planning agent.`,
categoryModel = applyCategoryParams(resolvedModel, agentCategoryConfig)
}
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
const explicitModel = agentOverride?.model ?? agentCategoryModel
const normalized = explicitModel ? normalizeModelFormat(explicitModel) : undefined
if (normalized) {
const variantToUse = agentOverride?.variant ?? agentCategoryConfig?.variant
const resolvedModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import type { DelegateTaskArgs } from "../types"
import type { ExecutorContext } from "../executor-types"
@@ -282,6 +282,270 @@ describe("resolveSubagentExecution", () => {
expect(result.categoryModel).toBeUndefined()
})
test("preserves hidden sort-prefixed plan agent model instead of using fallback", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { anthropic: ["claude-opus-4-7"] },
connected: ["anthropic"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "plan" })
const executorCtx = createExecutorContext(async () => ([
{ name: "1|plan", mode: "subagent", hidden: true, model: "anthropic/claude-opus-4-7" },
{ name: "oracle", mode: "subagent" },
]))
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("plan")
expect(result.categoryModel).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
})
test("allows OpenCode-hidden-list plan fallback when planner_enabled and replace_plan are true", async () => {
//#given
const args = createBaseArgs({ subagent_type: "plan" })
const executorCtx = createExecutorContext(async () => ([
{ name: "oracle", mode: "subagent" },
]), {
sisyphusAgentConfig: {
planner_enabled: true,
replace_plan: true,
},
})
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("plan")
expect(result.categoryModel).toBeUndefined()
})
test.each([
{ loader: "user", aliasName: "plan" },
{ loader: "user", aliasName: '"plan"' },
{ loader: "project", aliasName: "plan" },
{ loader: "project", aliasName: '"plan"' },
])(
"uses built-in hidden plan fallback when a $loader $aliasName alias exists",
async ({ loader, aliasName }) => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.3-codex"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
loadUserAgentsMock.mockImplementation(() => {
if (loader === "user") {
return {
[aliasName]: {
description: "Colliding plan alias from user agents",
mode: "subagent",
model: "openai/gpt-5.3-codex",
},
} satisfies ClaudeCodeAgentRecord
}
return {}
})
loadProjectAgentsMock.mockImplementation(() => {
if (loader === "project") {
return {
[aliasName]: {
description: "Colliding plan alias from project agents",
mode: "subagent",
model: "openai/gpt-5.3-codex",
},
} satisfies ClaudeCodeAgentRecord
}
return {}
})
const args = createBaseArgs({ subagent_type: "plan" })
const executorCtx = createExecutorContext(async () => ([
{ name: "oracle", mode: "subagent" },
]), {
sisyphusAgentConfig: {
planner_enabled: true,
replace_plan: true,
},
})
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("plan")
expect(result.categoryModel).toBeUndefined()
},
)
test.each([
{ loader: "user", aliasName: "plan" },
{ loader: "user", aliasName: '"plan"' },
{ loader: "project", aliasName: "plan" },
{ loader: "project", aliasName: '"plan"' },
])(
"uses built-in hidden plan fallback when a $loader primary $aliasName alias exists",
async ({ loader, aliasName }) => {
//#given
loadUserAgentsMock.mockImplementation(() => {
if (loader === "user") {
return {
[aliasName]: {
description: "Colliding primary plan alias from user agents",
mode: "primary",
model: "openai/gpt-5.3-codex",
},
} satisfies ClaudeCodeAgentRecord
}
return {}
})
loadProjectAgentsMock.mockImplementation(() => {
if (loader === "project") {
return {
[aliasName]: {
description: "Colliding primary plan alias from project agents",
mode: "primary",
model: "openai/gpt-5.3-codex",
},
} satisfies ClaudeCodeAgentRecord
}
return {}
})
const args = createBaseArgs({ subagent_type: "plan" })
const executorCtx = createExecutorContext(async () => ([
{ name: "oracle", mode: "subagent" },
]), {
sisyphusAgentConfig: {
planner_enabled: true,
replace_plan: true,
},
})
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("plan")
expect(result.categoryModel).toBeUndefined()
},
)
test.each([
{ loader: "user", aliasName: "build" },
{ loader: "user", aliasName: '"build"' },
{ loader: "user", aliasName: "1|build" },
{ loader: "user", aliasName: "\u200Bbuild" },
{ loader: "project", aliasName: "build" },
{ loader: "project", aliasName: '"build"' },
{ loader: "project", aliasName: "1|build" },
{ loader: "project", aliasName: "\u200Bbuild" },
])(
"rejects omitted hidden build when a $loader $aliasName alias exists",
async ({ loader, aliasName }) => {
//#given
loadUserAgentsMock.mockImplementation(() => {
if (loader === "user") {
return {
[aliasName]: {
description: "Colliding hidden build alias from user agents",
mode: "subagent",
model: "openai/gpt-5.3-codex",
},
} satisfies ClaudeCodeAgentRecord
}
return {}
})
loadProjectAgentsMock.mockImplementation(() => {
if (loader === "project") {
return {
[aliasName]: {
description: "Colliding hidden build alias from project agents",
mode: "subagent",
model: "openai/gpt-5.3-codex",
},
} satisfies ClaudeCodeAgentRecord
}
return {}
})
const args = createBaseArgs({ subagent_type: "build" })
const executorCtx = createExecutorContext(async () => ([
{ 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("preserves a visible server plan agent instead of using fallback", async () => {
//#given
readProviderModelsCacheMock.mockReturnValue({
models: { openai: ["gpt-5.3-codex"] },
connected: ["openai"],
updatedAt: "2026-03-03T00:00:00.000Z",
})
const args = createBaseArgs({ subagent_type: "plan" })
const executorCtx = createExecutorContext(async () => ([
{ name: "plan", mode: "subagent", model: "openai/gpt-5.3-codex" },
{ name: "oracle", mode: "subagent" },
]), {
sisyphusAgentConfig: {
planner_enabled: true,
replace_plan: true,
},
})
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.error).toBeUndefined()
expect(result.agentToUse).toBe("plan")
expect(result.categoryModel).toEqual({ providerID: "openai", modelID: "gpt-5.3-codex" })
})
test.each([
[{ planner_enabled: false, replace_plan: true }],
[{ planner_enabled: true, replace_plan: false }],
])(
"does not allow hidden plan fallback when planner config blocks replacement (%j)",
async (sisyphusAgentConfig) => {
//#given
const args = createBaseArgs({ subagent_type: "plan" })
const executorCtx = createExecutorContext(async () => ([
{ name: "oracle", mode: "subagent" },
]), {
sisyphusAgentConfig,
})
//#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 error hints except callable demoted plan", async () => {
//#given
const args = createBaseArgs({ subagent_type: "nonexistent" })