diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 01aaea676f..8ff5e80fd1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1157,6 +1157,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`experimental.plannerSubagent`** (boolean): + - **Description:** Use the new planner subagent for plan mode. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.enableAgents`** (boolean): - **Description:** Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents diff --git a/integration-tests/planner-subagent.test.ts b/integration-tests/planner-subagent.test.ts new file mode 100644 index 0000000000..cb8f89f18e --- /dev/null +++ b/integration-tests/planner-subagent.test.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +describe('Planner Subagent E2E', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => await rig.cleanup()); + + // TODO(joshualitt): Re-enable after planner subagent is working end-to-end. + it.skip('should enter plan mode, create a plan, and exit plan mode', async () => { + // We'll use a real run to see if it works. + // We need to enable the planner agent and the required tools. + rig.setup('planner-subagent-e2e', { + settings: { + experimental: { + plan: true, + agents: true, + }, + tools: { + core: [ + 'enter_plan_mode', + 'exit_plan_mode', + 'write_file', + 'read_file', + 'list_directory', + ], + }, + // Add policy to allow enter_plan_mode without confirmation in yolo mode + // Actually TestRig.run uses --approval-mode=yolo by default if not specified. + // But enter_plan_mode and exit_plan_mode might still ask if not explicitly allowed. + policy: { + rules: [ + { toolName: 'enter_plan_mode', decision: 'ALLOW' }, + { toolName: 'exit_plan_mode', decision: 'ALLOW' }, + { toolName: 'write_file', decision: 'ALLOW' }, + ], + }, + }, + }); + + rig.mkdir('plans'); + rig.createFile('hello.ts', 'console.log("hello");'); + + // Prompt the agent to create a plan for refactoring hello.ts + // We expect: + // 1. Main agent calls enter_plan_mode + // 2. Planner subagent is launched + // 3. Planner subagent reads hello.ts + // 4. Planner subagent writes plans/refactor.md + // 5. Planner subagent calls exit_plan_mode + // 6. Main agent resumes + + await rig.run({ + stdin: + 'Please create a refactoring plan for hello.ts to use a function. Put the plan in plans/refactor.md and then approve it.', + approvalMode: 'yolo', + timeout: 60000, // Planning can take a while + }); + + // 1. Verify enter_plan_mode was called + const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); + expect(enterPlanCalled).toBe(true); + + // 2. Verify write_file was called for the plan + const writeFileCalled = await rig.waitForToolCall( + 'write_file', + 20000, + (args) => { + return args.includes('plans/refactor.md'); + }, + ); + expect(writeFileCalled).toBe(true); + + // 3. Verify exit_plan_mode was called + const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode'); + expect(exitPlanCalled).toBe(true); + + // 4. Verify the plan file exists on disk + const planPath = path.join(rig.testDir!, 'plans/refactor.md'); + expect(fs.existsSync(planPath)).toBe(true); + const planContent = fs.readFileSync(planPath, 'utf-8'); + expect(planContent).toContain('hello.ts'); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ab6a22fb64..9157cbcd90 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -802,6 +802,7 @@ export async function loadCliConfig( extensionLoader: extensionManager, extensionRegistryURI, enableExtensionReloading: settings.experimental?.extensionReloading, + plannerSubagent: settings.experimental?.plannerSubagent, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, tracker: settings.experimental?.taskTracker, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 87fbe98fc3..3c71a9a0c8 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1833,6 +1833,15 @@ const SETTINGS_SCHEMA = { }, }, }, + plannerSubagent: { + type: 'boolean', + label: 'Planner Subagent', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Use the new planner subagent for plan mode.', + showInDialog: false, + }, enableAgents: { type: 'boolean', label: 'Enable Agents', diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index fccd95aed6..57a1403980 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -103,8 +103,13 @@ export class LocalAgentExecutor { private readonly onActivity?: ActivityCallback; private readonly compressionService: ChatCompressionService; private readonly parentCallId?: string; + private readonly completionToolName: string; private hasFailedCompressionAttempt = false; + private usesCustomCompletionTool(): boolean { + return this.completionToolName !== TASK_COMPLETE_TOOL_NAME; + } + private get config(): Config { return this.context.config; } @@ -264,6 +269,8 @@ export class LocalAgentExecutor { this.onActivity = onActivity; this.compressionService = new ChatCompressionService(); this.parentCallId = parentCallId; + this.completionToolName = + definition.runConfig.completionToolName || TASK_COMPLETE_TOOL_NAME; const randomIdPart = Math.random().toString(36).slice(2, 8); // parentPromptId will be undefined if this agent is invoked directly @@ -309,7 +316,7 @@ export class LocalAgentExecutor { // If the model stops calling tools without calling complete_task, it's an error. if (functionCalls.length === 0) { this.emitActivity('ERROR', { - error: `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}' to finalize the session.`, + error: `Agent stopped calling tools but did not call '${this.completionToolName}' to finalize the session.`, context: 'protocol_violation', }); return { @@ -374,7 +381,10 @@ export class LocalAgentExecutor { default: throw new Error(`Unknown terminate reason: ${reason}`); } - return `${explanation} You have one final chance to complete the task with a short grace period. You MUST call \`${TASK_COMPLETE_TOOL_NAME}\` immediately with your best answer and explain that your investigation was interrupted. Do not call any other tools.`; + const explainInterrupted = this.usesCustomCompletionTool() + ? '' + : ' and explain that your investigation was interrupted'; + return `${explanation} You have one final chance to complete the task with a short grace period. You MUST call \`${this.completionToolName}\` immediately with your best answer${explainInterrupted}. Do not call any other tools.`; } /** @@ -641,7 +651,7 @@ export class LocalAgentExecutor { // The finalResult was already set by executeTurn, but we re-emit just in case. finalResult = finalResult || - `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}'.`; + `Agent stopped calling tools but did not call '${this.completionToolName}'.`; this.emitActivity('ERROR', { error: finalResult, context: 'protocol_violation', @@ -900,6 +910,54 @@ export class LocalAgentExecutor { } } + /** + * Validates and processes the final output of the agent. + */ + private validateAndProcessOutput(outputValue: unknown): { + submittedOutput: string; + success: boolean; + error?: string; + } { + const { outputConfig, processOutput } = this.definition; + + if (outputConfig) { + const validationResult = outputConfig.schema.safeParse(outputValue); + + if (!validationResult.success) { + return { + submittedOutput: '', + success: false, + error: `Output validation failed: ${JSON.stringify(validationResult.error.flatten())}`, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const validatedOutput = validationResult.data; + if (processOutput) { + return { + submittedOutput: processOutput(validatedOutput), + success: true, + }; + } else { + return { + submittedOutput: + typeof validatedOutput === 'string' + ? validatedOutput + : JSON.stringify(validatedOutput, null, 2), + success: true, + }; + } + } + + return { + submittedOutput: + typeof outputValue === 'string' + ? outputValue + : JSON.stringify(outputValue, null, 2), + success: true, + }; + } + /** * Executes function calls requested by the model and returns the results. * @@ -918,7 +976,7 @@ export class LocalAgentExecutor { }> { const allowedToolNames = new Set(this.toolRegistry.getAllToolNames()); // Always allow the completion tool - allowedToolNames.add(TASK_COMPLETE_TOOL_NAME); + allowedToolNames.add(this.completionToolName); let submittedOutput: string | null = null; let taskCompleted = false; @@ -959,7 +1017,10 @@ export class LocalAgentExecutor { callId, }); - if (toolName === TASK_COMPLETE_TOOL_NAME) { + if ( + toolName === TASK_COMPLETE_TOOL_NAME && + !this.usesCustomCompletionTool() + ) { if (taskCompleted) { const error = 'Task already marked complete in this turn. Ignoring duplicate call.'; @@ -983,13 +1044,13 @@ export class LocalAgentExecutor { if (outputConfig) { const outputName = outputConfig.outputName; - if (args[outputName] !== undefined) { - const outputValue = args[outputName]; - const validationResult = outputConfig.schema.safeParse(outputValue); + const outputValue = args[outputName]; + if (outputValue !== undefined) { + const result = this.validateAndProcessOutput(outputValue); - if (!validationResult.success) { + if (!result.success) { taskCompleted = false; // Validation failed, revoke completion - const error = `Output validation failed: ${JSON.stringify(validationResult.error.flatten())}`; + const error = result.error!; syncResults.set(callId, { functionResponse: { name: TASK_COMPLETE_TOOL_NAME, @@ -1005,16 +1066,7 @@ export class LocalAgentExecutor { continue; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const validatedOutput = validationResult.data; - if (this.definition.processOutput) { - submittedOutput = this.definition.processOutput(validatedOutput); - } else { - submittedOutput = - typeof outputValue === 'string' - ? outputValue - : JSON.stringify(outputValue, null, 2); - } + submittedOutput = result.submittedOutput; syncResults.set(callId, { functionResponse: { name: TASK_COMPLETE_TOOL_NAME, @@ -1149,6 +1201,34 @@ export class LocalAgentExecutor { id: call.request.callId, output: call.response.resultDisplay, }); + + // Check if this was a custom completion tool and it signaled task completion + if ( + toolName === this.completionToolName && + call.response.isTaskCompletion + ) { + debugLogger.log( + `[LocalAgentExecutor] Custom completion tool '${toolName}' signaled task completion.`, + ); + const outputValue = call.response.data; + const result = this.validateAndProcessOutput(outputValue); + + if (result.success) { + taskCompleted = true; + submittedOutput = result.submittedOutput; + } else { + // If validation fails, we still mark it as completed but use the raw output + // and maybe log a warning. + debugLogger.warn( + `[LocalAgentExecutor] Custom completion tool '${toolName}' returned data that failed output validation: ${result.error}`, + ); + taskCompleted = true; + submittedOutput = + typeof outputValue === 'string' + ? outputValue + : JSON.stringify(outputValue, null, 2); + } + } } else if (call.status === 'error') { this.emitActivity('ERROR', { context: 'tool_call', @@ -1218,42 +1298,44 @@ export class LocalAgentExecutor { toolsList.push(...this.toolRegistry.getFunctionDeclarations()); } - // Always inject complete_task. - // Configure its schema based on whether output is expected. - const completeTool: FunctionDeclaration = { - name: TASK_COMPLETE_TOOL_NAME, - description: outputConfig - ? 'Call this tool to submit your final answer and complete the task. This is the ONLY way to finish.' - : 'Call this tool to submit your final findings and complete the task. This is the ONLY way to finish.', - parameters: { - type: Type.OBJECT, - properties: {}, - required: [], - }, - }; - - if (outputConfig) { - const jsonSchema = zodToJsonSchema(outputConfig.schema); - const { - $schema: _$schema, - definitions: _definitions, - ...schema - } = jsonSchema; - completeTool.parameters!.properties![outputConfig.outputName] = - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - schema as Schema; - completeTool.parameters!.required!.push(outputConfig.outputName); - } else { - completeTool.parameters!.properties!['result'] = { - type: Type.STRING, - description: - 'Your final results or findings to return to the orchestrator. ' + - 'Ensure this is comprehensive and follows any formatting requested in your instructions.', + // Always inject completion tool if it's complete_task. + // If it's a custom tool, it should be in toolConfig. + if (!this.usesCustomCompletionTool()) { + const completeTool: FunctionDeclaration = { + name: TASK_COMPLETE_TOOL_NAME, + description: outputConfig + ? 'Call this tool to submit your final answer and complete the task. This is the ONLY way to finish.' + : 'Call this tool to submit your final findings and complete the task. This is the ONLY way to finish.', + parameters: { + type: Type.OBJECT, + properties: {}, + required: [], + }, }; - completeTool.parameters!.required!.push('result'); - } - toolsList.push(completeTool); + if (outputConfig) { + const jsonSchema = zodToJsonSchema(outputConfig.schema); + const { + $schema: _$schema, + definitions: _definitions, + ...schema + } = jsonSchema; + completeTool.parameters!.properties![outputConfig.outputName] = + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + schema as Schema; + completeTool.parameters!.required!.push(outputConfig.outputName); + } else { + completeTool.parameters!.properties!['result'] = { + type: Type.STRING, + description: + 'Your final results or findings to return to the orchestrator. ' + + 'Ensure this is comprehensive and follows any formatting requested in your instructions.', + }; + completeTool.parameters!.required!.push('result'); + } + + toolsList.push(completeTool); + } return toolsList; } @@ -1279,7 +1361,12 @@ Important Rules: * Work systematically using available tools to complete your task. * Always use absolute paths for file operations. Construct them using the provided "Environment Context".`; - if (this.definition.outputConfig) { + if (this.usesCustomCompletionTool()) { + finalPrompt += ` +* When you have completed your task, you MUST call the \`${this.completionToolName}\` tool to signal completion. +* Do not call any other tools in the same turn as \`${this.completionToolName}\`. +* This is the ONLY way to complete your mission. If you stop calling tools without calling this, you have failed.`; + } else if (this.definition.outputConfig) { finalPrompt += ` * When you have completed your task, you MUST call the \`${TASK_COMPLETE_TOOL_NAME}\` tool with your structured output. * Do not call any other tools in the same turn as \`${TASK_COMPLETE_TOOL_NAME}\`. diff --git a/packages/core/src/agents/planner-schema.ts b/packages/core/src/agents/planner-schema.ts new file mode 100644 index 0000000000..f21205ff1f --- /dev/null +++ b/packages/core/src/agents/planner-schema.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; + +export const PlannerAgentSchema = z.object({ + plan_path: z + .string() + .describe('The path to the finalized and approved plan.'), +}); + +export type PlannerAgentOutput = z.infer; diff --git a/packages/core/src/agents/planner.ts b/packages/core/src/agents/planner.ts new file mode 100644 index 0000000000..085758bf7a --- /dev/null +++ b/packages/core/src/agents/planner.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { getCoreSystemPrompt } from '../core/prompts.js'; +import type { LocalAgentDefinition } from './types.js'; +import { PlannerAgentSchema } from './planner-schema.js'; +import { + GLOB_TOOL_NAME, + GREP_TOOL_NAME, + LS_TOOL_NAME, + READ_FILE_TOOL_NAME, + ASK_USER_TOOL_NAME, + EXIT_PLAN_MODE_TOOL_NAME, + WRITE_FILE_TOOL_NAME, + WEB_SEARCH_TOOL_NAME, + WEB_FETCH_TOOL_NAME, +} from '../tools/tool-names.js'; + +/** + * A specialized subagent for research and planning. + * It operates with read-only tools (mostly) and is restricted to writing plans + * until user approval is received. + */ +export const PlannerAgent = ( + config: Config, +): LocalAgentDefinition => ({ + kind: 'local', + name: 'planner', + displayName: 'Planner Agent', + description: + 'A specialized subagent for research and planning. It explores the codebase, designs solutions, and drafts detailed implementation plans for user approval.', + inputConfig: { + inputSchema: { + type: 'object', + properties: { + reason: { + type: 'string', + description: 'The reason for entering plan mode or the task to plan.', + }, + }, + required: ['reason'], + }, + }, + outputConfig: { + outputName: 'plan_path', + description: 'The path to the finalized and approved plan.', + schema: PlannerAgentSchema, + }, + processOutput: (output) => output.plan_path, + modelConfig: { + model: 'inherit', + }, + runConfig: { + maxTimeMinutes: 10, + maxTurns: 20, + completionToolName: EXIT_PLAN_MODE_TOOL_NAME, + hasCustomEntryPoint: true, + }, + toolConfig: { + tools: [ + LS_TOOL_NAME, + READ_FILE_TOOL_NAME, + GLOB_TOOL_NAME, + GREP_TOOL_NAME, + ASK_USER_TOOL_NAME, + EXIT_PLAN_MODE_TOOL_NAME, + WRITE_FILE_TOOL_NAME, + WEB_SEARCH_TOOL_NAME, + WEB_FETCH_TOOL_NAME, + ], + }, + promptConfig: { + get systemPrompt() { + return getCoreSystemPrompt( + config, + undefined, // userMemory + false, // interactiveOverride + ); + }, + query: 'Start planning for: ${reason}', + }, +}); diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 49786de4b0..f3fc66895a 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -274,6 +274,7 @@ describe('AgentRegistry', () => { codebase_investigator: { enabled: false }, cli_help: { enabled: false }, generalist: { enabled: false }, + planner: { enabled: false }, }, }, }); @@ -335,6 +336,15 @@ describe('AgentRegistry', () => { expect(registry.getDefinition('generalist')).toBeDefined(); }); + it('should NOT register planner agent if plannerSubagent is false', async () => { + const config = makeMockedConfig({ plannerSubagent: false }); + const registry = new TestableAgentRegistry(config); + + await registry.initialize(); + + expect(registry.getDefinition('planner')).toBeUndefined(); + }); + it('should NOT register a non-experimental agent if enabled is false', async () => { // CLI help is NOT experimental, but we explicitly disable it via enabled: false const config = makeMockedConfig({ diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 6eb642da72..701a8f9c1c 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -12,6 +12,7 @@ import { loadAgentsFromDirectory } from './agentLoader.js'; import { CodebaseInvestigatorAgent } from './codebase-investigator.js'; import { CliHelpAgent } from './cli-help-agent.js'; import { GeneralistAgent } from './generalist-agent.js'; +import { PlannerAgent } from './planner.js'; import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js'; import { A2AClientManager } from './a2a-client-manager.js'; import { A2AAuthProviderFactory } from './auth-provider/factory.js'; @@ -243,6 +244,9 @@ export class AgentRegistry { this.registerLocalAgent(CodebaseInvestigatorAgent(this.config)); this.registerLocalAgent(CliHelpAgent(this.config)); this.registerLocalAgent(GeneralistAgent(this.config)); + if (this.config.isPlannerSubagentEnabled()) { + this.registerLocalAgent(PlannerAgent(this.config)); + } // Register the browser agent if enabled in settings. // Tools are configured dynamically at invocation time via browserAgentFactory. diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index b6d0d6212b..2928792286 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -231,4 +231,14 @@ export interface RunConfig { * If not specified, defaults to DEFAULT_MAX_TURNS (30). */ maxTurns?: number; + /** + * The name of the tool that signals task completion. + * Defaults to 'complete_task'. + */ + completionToolName?: string; + /** + * Whether or not this subagent has a custom entry point. + * Defaults to `false`, and wraps subagents in a type-safe entry-point. + */ + hasCustomEntryPoint?: boolean; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 31c2128f31..8ed02d4432 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -618,6 +618,7 @@ export interface ConfigParameters { disabledHooks?: string[]; projectHooks?: { [K in HookEventName]?: HookDefinition[] }; enableAgents?: boolean; + plannerSubagent?: boolean; enableEventDrivenScheduler?: boolean; skillsSupport?: boolean; disabledSkills?: string[]; @@ -837,6 +838,7 @@ export class Config implements McpContext, AgentLoopContext { overageStrategy: OverageStrategy; }; + private readonly plannerSubagent: boolean; private readonly enableAgents: boolean; private agents: AgentSettings; private readonly enableEventDrivenScheduler: boolean; @@ -948,6 +950,7 @@ export class Config implements McpContext, AgentLoopContext { this.model = params.model; this.disableLoopDetection = params.disableLoopDetection ?? false; this._activeModel = params.model; + this.plannerSubagent = params.plannerSubagent ?? false; this.enableAgents = params.enableAgents ?? false; this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? true; @@ -2481,6 +2484,10 @@ export class Config implements McpContext, AgentLoopContext { this.approvedPlanPath = path; } + isPlannerSubagentEnabled(): boolean { + return this.plannerSubagent && this.isPlanEnabled(); + } + isAgentsEnabled(): boolean { return this.enableAgents; } @@ -3128,7 +3135,13 @@ export class Config implements McpContext, AgentLoopContext { for (const definition of definitions) { try { - const tool = new SubagentTool(definition, this, this.messageBus); + if ( + definition.kind === 'local' && + definition.runConfig.hasCustomEntryPoint === true + ) { + continue; + } + const tool = new SubagentTool(definition, this, this._messageBus); registry.registerTool(tool); } catch (e: unknown) { debugLogger.warn( diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 83d77c5a0b..16f28de33c 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -364,6 +364,7 @@ export class ToolExecutor { errorType: undefined, outputFile, contentLength: typeof content === 'string' ? content.length : undefined, + isTaskCompletion: toolResult.isTaskCompletion, data: toolResult.data, }; diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 9fedd48f41..5f3633e94e 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -57,6 +57,7 @@ export interface ToolCallResponseInfo { errorType: ToolErrorType | undefined; outputFile?: string | undefined; contentLength?: number; + isTaskCompletion?: boolean; /** * Optional data payload for passing structured information back to the caller. */ diff --git a/packages/core/src/tools/enter-plan-mode.test.ts b/packages/core/src/tools/enter-plan-mode.test.ts index 48bc5b494e..e0239bc6a8 100644 --- a/packages/core/src/tools/enter-plan-mode.test.ts +++ b/packages/core/src/tools/enter-plan-mode.test.ts @@ -10,25 +10,40 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { ToolConfirmationOutcome } from './tools.js'; -import { ApprovalMode } from '../policy/types.js'; +import { SubagentToolWrapper } from '../agents/subagent-tool-wrapper.js'; +import type { LocalAgentDefinition } from '../agents/types.js'; + +vi.mock('../agents/subagent-tool-wrapper.js'); describe('EnterPlanModeTool', () => { let tool: EnterPlanModeTool; let mockMessageBus: ReturnType; - let mockConfig: Partial; + let mockConfig: Config; + let mockPlannerDefinition: LocalAgentDefinition; beforeEach(() => { mockMessageBus = createMockMessageBus(); vi.mocked(mockMessageBus.publish).mockResolvedValue(undefined); + mockPlannerDefinition = { + kind: 'local', + name: 'planner', + description: 'Mock Planner', + inputConfig: { inputSchema: {} }, + } as LocalAgentDefinition; + mockConfig = { setApprovalMode: vi.fn(), + isPlannerSubagentEnabled: vi.fn().mockReturnValue(true), + getAgentRegistry: vi.fn().mockReturnValue({ + getDefinition: vi.fn().mockReturnValue(mockPlannerDefinition), + }), storage: { getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), } as unknown as Config['storage'], - }; + } as unknown as Config; tool = new EnterPlanModeTool( - mockConfig as Config, + mockConfig, mockMessageBus as unknown as MessageBus, ); }); @@ -41,7 +56,6 @@ describe('EnterPlanModeTool', () => { it('should return info confirmation details when policy says ASK_USER', async () => { const invocation = tool.build({}); - // Mock getMessageBusDecision to return ASK_USER vi.spyOn( invocation as unknown as { getMessageBusDecision: () => Promise; @@ -64,73 +78,41 @@ describe('EnterPlanModeTool', () => { ); } }); - - it('should return false when policy decision is ALLOW', async () => { - const invocation = tool.build({}); - - // Mock getMessageBusDecision to return ALLOW - vi.spyOn( - invocation as unknown as { - getMessageBusDecision: () => Promise; - }, - 'getMessageBusDecision', - ).mockResolvedValue('ALLOW'); - - const result = await invocation.shouldConfirmExecute( - new AbortController().signal, - ); - - expect(result).toBe(false); - }); - - it('should throw error when policy decision is DENY', async () => { - const invocation = tool.build({}); - - // Mock getMessageBusDecision to return DENY - vi.spyOn( - invocation as unknown as { - getMessageBusDecision: () => Promise; - }, - 'getMessageBusDecision', - ).mockResolvedValue('DENY'); - - await expect( - invocation.shouldConfirmExecute(new AbortController().signal), - ).rejects.toThrow(/denied by policy/); - }); }); describe('execute', () => { - it('should set approval mode to PLAN and return message', async () => { - const invocation = tool.build({}); + it('should delegate to planner subagent', async () => { + const invocation = tool.build({ reason: 'test reason' }); + + const mockSubInvocation = { + execute: vi.fn().mockResolvedValue({ + llmContent: 'Plan created.', + returnDisplay: 'Plan Display', + }), + }; + + vi.mocked(SubagentToolWrapper).prototype.build = vi + .fn() + .mockReturnValue(mockSubInvocation); const result = await invocation.execute(new AbortController().signal); - expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.PLAN, + expect(mockConfig.getAgentRegistry().getDefinition).toHaveBeenCalledWith( + 'planner', ); - expect(result.llmContent).toContain('Switching to Plan mode'); - expect(result.returnDisplay).toBe('Switching to Plan mode'); - }); - - it('should include optional reason in output display but not in llmContent', async () => { - const reason = 'Design new database schema'; - const invocation = tool.build({ reason }); - - const result = await invocation.execute(new AbortController().signal); - - expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.PLAN, + expect(SubagentToolWrapper).toHaveBeenCalledWith( + mockPlannerDefinition, + mockConfig, + mockMessageBus, ); - expect(result.llmContent).toBe('Switching to Plan mode.'); - expect(result.llmContent).not.toContain(reason); - expect(result.returnDisplay).toContain(reason); + expect(mockSubInvocation.execute).toHaveBeenCalled(); + expect(result.llmContent).toBe('Plan created.'); + expect(result.returnDisplay).toBe('Plan Display'); }); it('should not enter plan mode if cancelled', async () => { const invocation = tool.build({}); - // Simulate getting confirmation details vi.spyOn( invocation as unknown as { getMessageBusDecision: () => Promise; @@ -141,30 +123,14 @@ describe('EnterPlanModeTool', () => { const details = await invocation.shouldConfirmExecute( new AbortController().signal, ); - expect(details).not.toBe(false); - if (details) { - // Simulate user cancelling await details.onConfirm(ToolConfirmationOutcome.Cancel); } const result = await invocation.execute(new AbortController().signal); - expect(mockConfig.setApprovalMode).not.toHaveBeenCalled(); expect(result.returnDisplay).toBe('Cancelled'); expect(result.llmContent).toContain('User cancelled'); }); }); - - describe('validateToolParams', () => { - it('should allow empty params', () => { - const result = tool.validateToolParams({}); - expect(result).toBeNull(); - }); - - it('should allow reason param', () => { - const result = tool.validateToolParams({ reason: 'test' }); - expect(result).toBeNull(); - }); - }); }); diff --git a/packages/core/src/tools/enter-plan-mode.ts b/packages/core/src/tools/enter-plan-mode.ts index d52c721aae..dfabd6a51c 100644 --- a/packages/core/src/tools/enter-plan-mode.ts +++ b/packages/core/src/tools/enter-plan-mode.ts @@ -11,6 +11,7 @@ import { Kind, type ToolInfoConfirmationDetails, ToolConfirmationOutcome, + type ToolLiveOutput, } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { Config } from '../config/config.js'; @@ -18,6 +19,7 @@ import { ENTER_PLAN_MODE_TOOL_NAME } from './tool-names.js'; import { ApprovalMode } from '../policy/types.js'; import { ENTER_PLAN_MODE_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { SubagentToolWrapper } from '../agents/subagent-tool-wrapper.js'; export interface EnterPlanModeParams { reason?: string; @@ -40,6 +42,8 @@ export class EnterPlanModeTool extends BaseDeclarativeTool< Kind.Plan, ENTER_PLAN_MODE_DEFINITION.base.parametersJsonSchema, messageBus, + /* isOutputMarkdown */ true, + /* canUpdateOutput */ true, ); } @@ -112,7 +116,10 @@ export class EnterPlanModeInvocation extends BaseToolInvocation< }; } - async execute(_signal: AbortSignal): Promise { + async execute( + signal: AbortSignal, + updateOutput?: (output: ToolLiveOutput) => void, + ): Promise { if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) { return { llmContent: 'User cancelled entering Plan Mode.', @@ -122,11 +129,31 @@ export class EnterPlanModeInvocation extends BaseToolInvocation< this.config.setApprovalMode(ApprovalMode.PLAN); - return { - llmContent: 'Switching to Plan mode.', - returnDisplay: this.params.reason - ? `Switching to Plan mode: ${this.params.reason}` - : 'Switching to Plan mode', - }; + if (!this.config.isPlannerSubagentEnabled()) { + return { + llmContent: 'Switching to Plan mode.', + returnDisplay: this.params.reason + ? `Switching to Plan mode: ${this.params.reason}` + : 'Switching to Plan mode', + }; + } + + const plannerDefinition = this.config + .getAgentRegistry() + .getDefinition('planner'); + if (!plannerDefinition) { + throw new Error('Planner agent not found.'); + } + + const wrapper = new SubagentToolWrapper( + plannerDefinition, + this.config, + this.messageBus, + ); + const subInvocation = wrapper.build({ + reason: this.params.reason || 'Requested by main agent', + }); + + return subInvocation.execute(signal, updateOutput); } } diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 4b6b537d00..4f1236236d 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -44,6 +44,7 @@ describe('ExitPlanModeTool', () => { getTargetDir: vi.fn().mockReturnValue(tempRootDir), setApprovalMode: vi.fn(), setApprovedPlanPath: vi.fn(), + isPlannerSubagentEnabled: vi.fn().mockReturnValue(true), storage: { getPlansDir: vi.fn().mockReturnValue(mockPlansDir), } as unknown as Config['storage'], @@ -200,6 +201,10 @@ describe('ExitPlanModeTool', () => { const expectedPath = path.join(mockPlansDir, 'test.md'); expect(result).toEqual({ + data: { + plan_path: expectedPath, + }, + isTaskCompletion: true, llmContent: `Plan approved. Switching to Default mode (edits will require confirmation). The approved implementation plan is stored at: ${expectedPath} @@ -228,6 +233,10 @@ Read and follow the plan strictly during implementation.`, const expectedPath = path.join(mockPlansDir, 'test.md'); expect(result).toEqual({ + data: { + plan_path: expectedPath, + }, + isTaskCompletion: true, llmContent: `Plan approved. Switching to Auto-Edit mode (edits will be applied automatically). The approved implementation plan is stored at: ${expectedPath} diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index b1615b18b4..6eb1a06e07 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -26,10 +26,9 @@ import { PlanExecutionEvent } from '../telemetry/types.js'; import { getExitPlanModeDefinition } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; import { getPlanModeExitMessage } from '../utils/approvalModeUtils.js'; +import { type PlannerAgentOutput } from '../agents/planner-schema.js'; -export interface ExitPlanModeParams { - plan_path: string; -} +export type ExitPlanModeParams = PlannerAgentOutput; export class ExitPlanModeTool extends BaseDeclarativeTool< ExitPlanModeParams, @@ -225,14 +224,20 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< logPlanExecution(this.config, new PlanExecutionEvent(newMode)); const exitMessage = getPlanModeExitMessage(newMode); - - return { + const result: ToolResult = { llmContent: `${exitMessage} The approved implementation plan is stored at: ${resolvedPlanPath} Read and follow the plan strictly during implementation.`, returnDisplay: `Plan approved: ${resolvedPlanPath}`, }; + + if (this.config.isPlannerSubagentEnabled()) { + result.isTaskCompletion = true; + result.data = { plan_path: resolvedPlanPath }; + } + + return result; } else { const feedback = payload?.feedback?.trim(); if (feedback) { diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index c58396adb8..507a108d7e 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -680,6 +680,12 @@ export interface ToolResult { type?: ToolErrorType; // An optional machine-readable error type (e.g., 'FILE_NOT_FOUND'). }; + /** + * If true, this tool result signals an agent indicating they have completed a + * task. + */ + isTaskCompletion?: boolean; + /** * Optional data payload for passing structured information back to the caller. */ diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f482053d9f..2be0634c9f 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1968,6 +1968,13 @@ }, "additionalProperties": false }, + "plannerSubagent": { + "title": "Planner Subagent", + "description": "Use the new planner subagent for plan mode.", + "markdownDescription": "Use the new planner subagent for plan mode.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "enableAgents": { "title": "Enable Agents", "description": "Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents",