mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
feat: Implement Plan Mode for Safe Code Planning (#658)
This commit is contained in:
292
packages/core/src/tools/exitPlanMode.test.ts
Normal file
292
packages/core/src/tools/exitPlanMode.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
191
packages/core/src/tools/exitPlanMode.ts
Normal file
191
packages/core/src/tools/exitPlanMode.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -20,4 +20,5 @@ export const ToolNames = {
|
||||
TODO_WRITE: 'todo_write',
|
||||
MEMORY: 'save_memory',
|
||||
TASK: 'task',
|
||||
EXIT_PLAN_MODE: 'exit_plan_mode',
|
||||
} as const;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'];
|
||||
|
||||
Reference in New Issue
Block a user