feat: Implement Plan Mode for Safe Code Planning (#658)

This commit is contained in:
tanzhenxin
2025-09-24 14:26:17 +08:00
committed by GitHub
parent 8379bc4d81
commit 4e7a7e2656
43 changed files with 2895 additions and 281 deletions

View File

@@ -0,0 +1,292 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExitPlanModeTool, type ExitPlanModeParams } from './exitPlanMode.js';
import { ApprovalMode, type Config } from '../config/config.js';
import { ToolConfirmationOutcome } from './tools.js';
describe('ExitPlanModeTool', () => {
let tool: ExitPlanModeTool;
let mockConfig: Config;
let approvalMode: ApprovalMode;
beforeEach(() => {
approvalMode = ApprovalMode.PLAN;
mockConfig = {
getApprovalMode: vi.fn(() => approvalMode),
setApprovalMode: vi.fn((mode: ApprovalMode) => {
approvalMode = mode;
}),
} as unknown as Config;
tool = new ExitPlanModeTool(mockConfig);
});
describe('constructor and metadata', () => {
it('should have correct tool name', () => {
expect(tool.name).toBe('exit_plan_mode');
expect(ExitPlanModeTool.Name).toBe('exit_plan_mode');
});
it('should have correct display name', () => {
expect(tool.displayName).toBe('ExitPlanMode');
});
it('should have correct kind', () => {
expect(tool.kind).toBe('think');
});
it('should have correct schema', () => {
expect(tool.schema).toEqual({
name: 'exit_plan_mode',
description: expect.stringContaining(
'Use this tool when you are in plan mode',
),
parametersJsonSchema: {
type: 'object',
properties: {
plan: {
type: 'string',
description: expect.stringContaining('The plan you came up with'),
},
},
required: ['plan'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
});
});
});
describe('validateToolParams', () => {
it('should accept valid parameters', () => {
const params: ExitPlanModeParams = {
plan: 'This is a comprehensive plan for the implementation.',
};
const result = tool.validateToolParams(params);
expect(result).toBeNull();
});
it('should reject missing plan parameter', () => {
const params = {} as ExitPlanModeParams;
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
it('should reject empty plan parameter', () => {
const params: ExitPlanModeParams = {
plan: '',
};
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
it('should reject whitespace-only plan parameter', () => {
const params: ExitPlanModeParams = {
plan: ' \n\t ',
};
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
it('should reject non-string plan parameter', () => {
const params = {
plan: 123,
} as unknown as ExitPlanModeParams;
const result = tool.validateToolParams(params);
expect(result).toBe('Parameter "plan" must be a non-empty string.');
});
});
describe('tool execution', () => {
it('should execute successfully through tool interface after approval', async () => {
const params: ExitPlanModeParams = {
plan: 'This is my implementation plan:\n1. Step 1\n2. Step 2\n3. Step 3',
};
const signal = new AbortController().signal;
// Use the tool's public build method
const invocation = tool.build(params);
expect(invocation).toBeDefined();
expect(invocation.params).toEqual(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
expect(confirmation).toMatchObject({
type: 'plan',
title: 'Would you like to proceed?',
plan: params.plan,
});
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce);
}
const result = await invocation.execute(signal);
const expectedLlmMessage =
'User has approved your plan. You can now start coding. Start with updating your todo list if applicable.';
expect(result).toEqual({
llmContent: expectedLlmMessage,
returnDisplay: {
type: 'plan_summary',
message: 'User approved the plan.',
plan: params.plan,
},
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
expect(approvalMode).toBe(ApprovalMode.DEFAULT);
});
it('should request confirmation with plan details', async () => {
const params: ExitPlanModeParams = {
plan: 'Simple plan',
};
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
if (confirmation) {
expect(confirmation.type).toBe('plan');
if (confirmation.type === 'plan') {
expect(confirmation.plan).toBe(params.plan);
}
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlways);
}
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(approvalMode).toBe(ApprovalMode.AUTO_EDIT);
});
it('should remain in plan mode when confirmation is rejected', async () => {
const params: ExitPlanModeParams = {
plan: 'Remain in planning',
};
const signal = new AbortController().signal;
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(signal);
if (confirmation) {
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);
}
const result = await invocation.execute(signal);
expect(result).toEqual({
llmContent: JSON.stringify({
success: false,
plan: params.plan,
error: 'Plan execution was not approved. Remaining in plan mode.',
}),
returnDisplay:
'Plan execution was not approved. Remaining in plan mode.',
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
);
expect(approvalMode).toBe(ApprovalMode.PLAN);
});
it('should have correct description', () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',
};
const invocation = tool.build(params);
expect(invocation.getDescription()).toBe(
'Present implementation plan for user approval',
);
});
it('should handle execution errors gracefully', async () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',
};
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (confirmation) {
// Don't approve the plan so we go through the rejection path
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);
}
// Create a spy to simulate an error during the execution
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Mock JSON.stringify to throw an error in the rejection path
const originalStringify = JSON.stringify;
vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
throw new Error('JSON stringify error');
});
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({
llmContent: JSON.stringify({
success: false,
error: 'Failed to present plan. Detail: JSON stringify error',
}),
returnDisplay: 'Error presenting plan: JSON stringify error',
});
expect(consoleSpy).toHaveBeenCalledWith(
'[ExitPlanModeTool] Error executing exit_plan_mode: JSON stringify error',
);
// Restore original JSON.stringify
JSON.stringify = originalStringify;
consoleSpy.mockRestore();
});
it('should return empty tool locations', () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',
};
const invocation = tool.build(params);
expect(invocation.toolLocations()).toEqual([]);
});
});
describe('tool description', () => {
it('should contain usage guidelines', () => {
expect(tool.description).toContain(
'Only use this tool when the task requires planning',
);
expect(tool.description).toContain(
'Do not use the exit plan mode tool because you are not planning',
);
expect(tool.description).toContain(
'Use the exit plan mode tool after you have finished planning',
);
});
it('should contain examples', () => {
expect(tool.description).toContain(
'Search for and understand the implementation of vim mode',
);
expect(tool.description).toContain('Help me implement yank mode for vim');
});
});
});

View File

@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ToolPlanConfirmationDetails, ToolResult } from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
} from './tools.js';
import type { FunctionDeclaration } from '@google/genai';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
export interface ExitPlanModeParams {
plan: string;
}
const exitPlanModeToolDescription = `Use this tool when you are in plan mode and have finished presenting your plan and are ready to code. This will prompt the user to exit plan mode.
IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool.
Eg.
1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task.
2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task.
`;
const exitPlanModeToolSchemaData: FunctionDeclaration = {
name: 'exit_plan_mode',
description: exitPlanModeToolDescription,
parametersJsonSchema: {
type: 'object',
properties: {
plan: {
type: 'string',
description:
'The plan you came up with, that you want to run by the user for approval. Supports markdown. The plan should be pretty concise.',
},
},
required: ['plan'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
};
class ExitPlanModeToolInvocation extends BaseToolInvocation<
ExitPlanModeParams,
ToolResult
> {
private wasApproved = false;
constructor(
private readonly config: Config,
params: ExitPlanModeParams,
) {
super(params);
}
getDescription(): string {
return 'Present implementation plan for user approval';
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolPlanConfirmationDetails> {
const details: ToolPlanConfirmationDetails = {
type: 'plan',
title: 'Would you like to proceed?',
plan: this.params.plan,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
switch (outcome) {
case ToolConfirmationOutcome.ProceedAlways:
this.wasApproved = true;
this.setApprovalModeSafely(ApprovalMode.AUTO_EDIT);
break;
case ToolConfirmationOutcome.ProceedOnce:
this.wasApproved = true;
this.setApprovalModeSafely(ApprovalMode.DEFAULT);
break;
case ToolConfirmationOutcome.Cancel:
this.wasApproved = false;
this.setApprovalModeSafely(ApprovalMode.PLAN);
break;
default:
// Treat any other outcome as manual approval to preserve conservative behaviour.
this.wasApproved = true;
this.setApprovalModeSafely(ApprovalMode.DEFAULT);
break;
}
},
};
return details;
}
private setApprovalModeSafely(mode: ApprovalMode): void {
try {
this.config.setApprovalMode(mode);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[ExitPlanModeTool] Failed to set approval mode to "${mode}": ${errorMessage}`,
);
}
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const { plan } = this.params;
try {
if (!this.wasApproved) {
const rejectionMessage =
'Plan execution was not approved. Remaining in plan mode.';
return {
llmContent: JSON.stringify({
success: false,
plan,
error: rejectionMessage,
}),
returnDisplay: rejectionMessage,
};
}
const llmMessage =
'User has approved your plan. You can now start coding. Start with updating your todo list if applicable.';
const displayMessage = 'User approved the plan.';
return {
llmContent: llmMessage,
returnDisplay: {
type: 'plan_summary',
message: displayMessage,
plan,
},
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[ExitPlanModeTool] Error executing exit_plan_mode: ${errorMessage}`,
);
return {
llmContent: JSON.stringify({
success: false,
error: `Failed to present plan. Detail: ${errorMessage}`,
}),
returnDisplay: `Error presenting plan: ${errorMessage}`,
};
}
}
}
export class ExitPlanModeTool extends BaseDeclarativeTool<
ExitPlanModeParams,
ToolResult
> {
static readonly Name: string = exitPlanModeToolSchemaData.name!;
constructor(private readonly config: Config) {
super(
ExitPlanModeTool.Name,
'ExitPlanMode',
exitPlanModeToolDescription,
Kind.Think,
exitPlanModeToolSchemaData.parametersJsonSchema as Record<
string,
unknown
>,
);
}
override validateToolParams(params: ExitPlanModeParams): string | null {
// Validate plan parameter
if (
!params.plan ||
typeof params.plan !== 'string' ||
params.plan.trim() === ''
) {
return 'Parameter "plan" must be a non-empty string.';
}
return null;
}
protected createInvocation(params: ExitPlanModeParams) {
return new ExitPlanModeToolInvocation(this.config, params);
}
}

View File

@@ -777,6 +777,19 @@ describe('ShellTool', () => {
});
describe('shouldConfirmExecute', () => {
it('should not request confirmation for read-only commands', async () => {
const invocation = shellTool.build({
command: 'ls -la',
is_background: false,
});
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).toBe(false);
});
it('should request confirmation for a new command and whitelist it on "Always"', async () => {
const params = { command: 'npm install', is_background: false };
const invocation = shellTool.build(params);

View File

@@ -32,6 +32,7 @@ import { formatMemoryUsage } from '../utils/formatters.js';
import {
getCommandRoots,
isCommandAllowed,
isCommandNeedsPermission,
stripShellWrapper,
} from '../utils/shell-utils.js';
@@ -87,6 +88,11 @@ class ShellToolInvocation extends BaseToolInvocation<
return false; // already approved and whitelisted
}
const permissionCheck = isCommandNeedsPermission(command);
if (!permissionCheck.requiresPermission) {
return false;
}
const confirmationDetails: ToolExecuteConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',

View File

@@ -20,4 +20,5 @@ export const ToolNames = {
TODO_WRITE: 'todo_write',
MEMORY: 'save_memory',
TASK: 'task',
EXIT_PLAN_MODE: 'exit_plan_mode',
} as const;

View File

@@ -464,6 +464,7 @@ export type ToolResultDisplay =
| string
| FileDiff
| TodoResultDisplay
| PlanResultDisplay
| TaskResultDisplay;
export interface FileDiff {
@@ -490,6 +491,12 @@ export interface TodoResultDisplay {
}>;
}
export interface PlanResultDisplay {
type: 'plan_summary';
message: string;
plan: string;
}
export interface ToolEditConfirmationDetails {
type: 'edit';
title: string;
@@ -541,7 +548,15 @@ export type ToolCallConfirmationDetails =
| ToolEditConfirmationDetails
| ToolExecuteConfirmationDetails
| ToolMcpConfirmationDetails
| ToolInfoConfirmationDetails;
| ToolInfoConfirmationDetails
| ToolPlanConfirmationDetails;
export interface ToolPlanConfirmationDetails {
type: 'plan';
title: string;
plan: string;
onConfirm: (outcome: ToolConfirmationOutcome) => Promise<void>;
}
export enum ToolConfirmationOutcome {
ProceedOnce = 'proceed_once',

View File

@@ -10,9 +10,13 @@ import {
Kind,
type ToolInvocation,
type ToolResult,
type ToolCallConfirmationDetails,
type ToolInfoConfirmationDetails,
ToolConfirmationOutcome,
} from './tools.js';
import type { Config } from '../config/config.js';
import { ApprovalMode } from '../config/config.js';
import { getErrorMessage } from '../utils/errors.js';
interface TavilyResultItem {
@@ -61,6 +65,26 @@ class WebSearchToolInvocation extends BaseToolInvocation<
return `Searching the web for: "${this.params.query}"`;
}
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
const confirmationDetails: ToolInfoConfirmationDetails = {
type: 'info',
title: 'Confirm Web Search',
prompt: `Search the web for: "${this.params.query}"`,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
}
async execute(signal: AbortSignal): Promise<WebSearchToolResult> {
const apiKey =
this.config.getTavilyApiKey() || process.env['TAVILY_API_KEY'];