From a1630685077dd9cbf925e75d8b5b3393ce45ba22 Mon Sep 17 00:00:00 2001 From: ririnto Date: Mon, 18 May 2026 00:53:40 +0900 Subject: [PATCH 1/2] fix(delegate-task): restore hidden plan delegation --- src/tools/delegate-task/subagent-discovery.ts | 9 +- src/tools/delegate-task/subagent-resolver.ts | 93 ++++-- .../subagent-resolver.test.ts | 266 +++++++++++++++++- 3 files changed, 346 insertions(+), 22 deletions(-) diff --git a/src/tools/delegate-task/subagent-discovery.ts b/src/tools/delegate-task/subagent-discovery.ts index 46e2f00e4..9512bb565 100644 --- a/src/tools/delegate-task/subagent-discovery.ts +++ b/src/tools/delegate-task/subagent-discovery.ts @@ -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 { diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 44e789f70..31127a052 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -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 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 6a9fe65e6..7aa4c0509 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 @@ -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" }) From 19aaf5d329e5a7ac052fd2f0465125957d59b948 Mon Sep 17 00:00:00 2001 From: ririnto Date: Mon, 18 May 2026 00:54:01 +0900 Subject: [PATCH 2/2] fix(prompts): point planning guidance at plan subagent --- src/agents/dynamic-agent-core-sections.ts | 2 +- src/agents/sisyphus/gpt-5-4.ts | 2 +- src/features/team-mode/team-registry/validator.test.ts | 2 +- src/features/team-mode/types.test.ts | 4 ++-- src/features/team-mode/types.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/agents/dynamic-agent-core-sections.ts b/src/agents/dynamic-agent-core-sections.ts index 69742ff16..1f91c7a78 100644 --- a/src/agents/dynamic-agent-core-sections.ts +++ b/src/agents/dynamic-agent-core-sections.ts @@ -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 diff --git a/src/agents/sisyphus/gpt-5-4.ts b/src/agents/sisyphus/gpt-5-4.ts index 5f5e5bc2c..964087f13 100644 --- a/src/agents/sisyphus/gpt-5-4.ts +++ b/src/agents/sisyphus/gpt-5-4.ts @@ -288,7 +288,7 @@ Every implementation task follows this cycle. No exceptions. Follow \`\` 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. diff --git a/src/features/team-mode/team-registry/validator.test.ts b/src/features/team-mode/team-registry/validator.test.ts index ffc9ad263..12d64015c 100644 --- a/src/features/team-mode/team-registry/validator.test.ts +++ b/src/features/team-mode/team-registry/validator.test.ts @@ -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 { diff --git a/src/features/team-mode/types.test.ts b/src/features/team-mode/types.test.ts index 1d944eec0..0a047a930 100644 --- a/src/features/team-mode/types.test.ts +++ b/src/features/team-mode/types.test.ts @@ -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() diff --git a/src/features/team-mode/types.ts b/src/features/team-mode/types.ts index 21f7a0d6a..a218cf3c0 100644 --- a/src/features/team-mode/types.ts +++ b/src/features/team-mode/types.ts @@ -229,7 +229,7 @@ export const AGENT_ELIGIBILITY_REGISTRY: Readonly