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" })