feat(core): mvp port planner to subagent.

This commit is contained in:
Your Name
2026-02-26 22:23:08 +00:00
parent e3df87cf1a
commit f28a2f27cd
19 changed files with 501 additions and 143 deletions
+5
View File
@@ -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
@@ -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');
});
});
+1
View File
@@ -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,
@@ -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',
+142 -55
View File
@@ -103,8 +103,13 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
// 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<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
// 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<TOutput extends z.ZodTypeAny> {
}
}
/**
* 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<TOutput extends z.ZodTypeAny> {
}> {
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<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
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}\`.
@@ -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<typeof PlannerAgentSchema>;
+86
View File
@@ -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<typeof PlannerAgentSchema> => ({
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}',
},
});
+10
View File
@@ -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({
+4
View File
@@ -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.
+10
View File
@@ -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;
}
+14 -1
View File
@@ -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(
@@ -364,6 +364,7 @@ export class ToolExecutor {
errorType: undefined,
outputFile,
contentLength: typeof content === 'string' ? content.length : undefined,
isTaskCompletion: toolResult.isTaskCompletion,
data: toolResult.data,
};
+1
View File
@@ -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.
*/
+41 -75
View File
@@ -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<typeof createMockMessageBus>;
let mockConfig: Partial<Config>;
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<string>;
@@ -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<string>;
},
'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<string>;
},
'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<string>;
@@ -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();
});
});
});
+34 -7
View File
@@ -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<ToolResult> {
async execute(
signal: AbortSignal,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
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);
}
}
@@ -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}
+10 -5
View File
@@ -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) {
+6
View File
@@ -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.
*/
+7
View File
@@ -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",