feat(core): 增强 Plan Agent 支持细粒度 bash 权限和模式切换

- Plan Agent 细粒度 bash 权限:允许只读命令 (ls, grep, git log 等),
  禁止危险操作 (rm, mv, git commit 等)
- 新增 plan_mode_respond 工具:结构化输出计划、进度和探索状态
- Agent.switchMode() 方法:支持 Build ↔ Plan 模式切换保留对话历史
- WebSocket mode_switch 消息:支持运行时动态切换模式
- 更新 Plan Agent prompt 引导使用 plan_mode_respond 工具
This commit is contained in:
2025-12-15 20:51:57 +08:00
parent f238368f87
commit 35d87a04fb
8 changed files with 355 additions and 20 deletions
+126 -17
View File
@@ -2,25 +2,41 @@ import type { AgentInfo } from '../types.js';
/**
* 计划 Agent
* 主模式,设计实现方案(不执行修改)
* 主模式,设计实现方案(只读探索,不执行修改)
*
* 特性:
* - 细粒度 bash 权限:允许只读命令(ls, grep, git log 等)
* - plan_mode_respond 工具:结构化输出计划和进度
* - 完整探索能力:read + 只读 bash + search
*/
export const planAgent: Omit<AgentInfo, 'name'> = {
description: '计划模式,设计实现方案(不执行修改)',
description: '计划模式,设计实现方案(只读探索,不执行修改)',
mode: 'primary',
prompt: `你是一个软件架构师。你的任务是设计实现方案,而不是直接执行
prompt: `你是一个软件架构师和计划专家。你的任务是探索代码库并设计实现方案。
工作流程:
1. 理解需求:分析用户的需求和目标
2. 调研现状:阅读相关代码,了解现有架构
3. 设计方案:提出详细的实现计划
4. 评估风险:识别潜在问题和挑战
## 模式说明
你处于 **Plan 模式**(只读):
- ✅ 可以:读取文件、搜索代码、执行只读 bash 命令
- ❌ 禁止:创建/修改/删除文件、执行写操作
规则:
- 可以读取代码来了解现状
- 只输出计划,不要实际执行修改
- 提供具体、可操作的步骤
## 可用工具
- read_file, grep_content, search_files: 代码搜索
- bash: 只读命令 (ls, grep, find, git log/diff/status...)
- plan_mode_respond: 输出结构化计划
输出格式:
## 工作流程
1. **理解需求** - 分析用户目标
2. **深度探索** - 使用工具调研代码
3. **设计方案** - 使用 plan_mode_respond 输出计划
4. **迭代完善** - 根据反馈调整
## 输出格式
使用 plan_mode_respond 工具输出计划,包含:
- response: 计划内容
- needs_more_exploration: 是否需要更多探索
- task_progress: 当前进度 (0-100)
计划内容格式:
## 需求分析
[对需求的理解]
@@ -38,25 +54,32 @@ export const planAgent: Omit<AgentInfo, 'name'> = {
## 风险评估
- [风险1]: [应对方案]
- [风险2]: [应对方案]
## 测试计划
- [测试项1]
- [测试项2]`,
tools: {
disabled: [
// 文件写入操作
'write_file',
'edit_file',
'delete_file',
'move_file',
'copy_file',
'create_directory',
'bash',
'multi_edit',
// 注意:bash 不再禁用,改用细粒度权限控制
// Git 写操作
'git_add',
'git_commit',
'git_push',
'git_pull',
'git_checkout',
'git_stash',
// checkpoint 操作(会修改状态)
'checkpoint_create',
'checkpoint_restore',
'undo',
],
},
permission: {
@@ -67,7 +90,93 @@ export const planAgent: Omit<AgentInfo, 'name'> = {
delete: 'deny',
},
bash: {
enabled: false,
enabled: true,
rules: [
// ============ 文件查看 - 允许 ============
{ pattern: 'ls', action: 'allow' },
{ pattern: 'ls *', action: 'allow' },
{ pattern: 'pwd', action: 'allow' },
{ pattern: 'cat *', action: 'allow' },
{ pattern: 'head *', action: 'allow' },
{ pattern: 'tail *', action: 'allow' },
{ pattern: 'less *', action: 'allow' },
{ pattern: 'more *', action: 'allow' },
// ============ 搜索 - 允许 ============
{ pattern: 'find *', action: 'allow' },
{ pattern: 'grep *', action: 'allow' },
{ pattern: 'rg *', action: 'allow' },
{ pattern: 'tree', action: 'allow' },
{ pattern: 'tree *', action: 'allow' },
// ============ 文件信息 - 允许 ============
{ pattern: 'wc *', action: 'allow' },
{ pattern: 'stat *', action: 'allow' },
{ pattern: 'file *', action: 'allow' },
{ pattern: 'du *', action: 'allow' },
{ pattern: 'diff *', action: 'allow' },
{ pattern: 'which *', action: 'allow' },
{ pattern: 'whereis *', action: 'allow' },
// ============ 文本处理(只读)- 允许 ============
{ pattern: 'sort *', action: 'allow' },
{ pattern: 'uniq *', action: 'allow' },
{ pattern: 'cut *', action: 'allow' },
{ pattern: 'awk *', action: 'allow' },
{ pattern: 'sed -n *', action: 'allow' }, // 只允许 -n (不修改)
// ============ Git 只读 - 允许 ============
{ pattern: 'git status', action: 'allow' },
{ pattern: 'git status *', action: 'allow' },
{ pattern: 'git diff', action: 'allow' },
{ pattern: 'git diff *', action: 'allow' },
{ pattern: 'git log', action: 'allow' },
{ pattern: 'git log *', action: 'allow' },
{ pattern: 'git show *', action: 'allow' },
{ pattern: 'git branch', action: 'allow' },
{ pattern: 'git branch -v*', action: 'allow' },
{ pattern: 'git branch -a*', action: 'allow' },
{ pattern: 'git branch --list*', action: 'allow' },
{ pattern: 'git remote -v', action: 'allow' },
{ pattern: 'git tag', action: 'allow' },
{ pattern: 'git tag *', action: 'allow' },
{ pattern: 'git blame *', action: 'allow' },
{ pattern: 'git ls-files*', action: 'allow' },
{ pattern: 'git rev-parse *', action: 'allow' },
// ============ 危险命令 - 拒绝 ============
{ pattern: 'rm *', action: 'deny' },
{ pattern: 'rmdir *', action: 'deny' },
{ pattern: 'mv *', action: 'deny' },
{ pattern: 'cp *', action: 'deny' },
{ pattern: 'mkdir *', action: 'deny' },
{ pattern: 'touch *', action: 'deny' },
{ pattern: 'chmod *', action: 'deny' },
{ pattern: 'chown *', action: 'deny' },
{ pattern: 'sudo *', action: 'deny' },
{ pattern: 'su *', action: 'deny' },
// ============ Git 写操作 - 拒绝 ============
{ pattern: 'git add *', action: 'deny' },
{ pattern: 'git commit *', action: 'deny' },
{ pattern: 'git push *', action: 'deny' },
{ pattern: 'git pull *', action: 'deny' },
{ pattern: 'git checkout *', action: 'deny' },
{ pattern: 'git reset *', action: 'deny' },
{ pattern: 'git rebase *', action: 'deny' },
{ pattern: 'git merge *', action: 'deny' },
{ pattern: 'git stash *', action: 'deny' },
{ pattern: 'git clean *', action: 'deny' },
// ============ find 危险操作 - 拒绝 ============
{ pattern: 'find * -delete*', action: 'deny' },
{ pattern: 'find * -exec*', action: 'deny' },
// ============ 重定向操作 - 拒绝 ============
{ pattern: '* > *', action: 'deny' },
{ pattern: '* >> *', action: 'deny' },
],
default: 'ask', // 其他命令询问用户
},
git: {
read: 'allow',
@@ -78,5 +187,5 @@ export const planAgent: Omit<AgentInfo, 'name'> = {
model: {
temperature: 0.5,
},
maxSteps: 15,
maxSteps: 30, // 增加探索步数以支持更深入的调研
};
+37
View File
@@ -770,6 +770,43 @@ export class Agent {
}
}
/**
* 切换 Agent 模式(保留对话历史)
*
* 与 setAgentMode 不同,switchMode 会保留对话历史,
* 适用于会话中动态切换 Build ↔ Plan 模式。
*
* @param mode 目标模式
* @param preserveHistory 是否保留对话历史(默认 true)
*/
switchMode(mode: AgentInfo | 'build' | 'plan' | null, preserveHistory = true): void {
// 保存当前对话历史
const currentHistory = preserveHistory ? [...this.conversationHistory] : [];
// 执行模式切换
this.setAgentMode(mode);
// 恢复对话历史
if (preserveHistory) {
this.conversationHistory = currentHistory;
}
// 重置 doom loop 检测器(新模式重新计数)
this.doomLoopDetector.reset();
}
/**
* 检查当前是否为只读模式
*
* 只读模式:file.write === 'deny' && file.edit === 'deny'
*/
isReadOnlyMode(): boolean {
const permission = this.currentAgentMode?.permission;
if (!permission) return false;
return permission.file?.write === 'deny' && permission.file?.edit === 'deny';
}
// ============================================================================
// Auto-approve 功能(用于前端 Build 模式的自动授权)
// ============================================================================
+6
View File
@@ -59,6 +59,9 @@ import {
undoTool,
} from './checkpoint/index.js';
// Plan 工具
import { planModeRespondTool } from './plan/index.js';
// 所有工具列表(用于注册)
const allToolsWithMetadata: ToolWithMetadata[] = [
// 核心工具 (deferLoading: false)
@@ -112,6 +115,9 @@ const allToolsWithMetadata: ToolWithMetadata[] = [
checkpointListTool,
checkpointDiffTool,
checkpointRestoreTool,
// Plan 工具 (deferLoading: true - 仅 Plan 模式)
planModeRespondTool,
];
// 注册所有工具到 registry
+7
View File
@@ -0,0 +1,7 @@
/**
* Plan 工具模块
*
* Plan 模式专用工具集合
*/
export { planModeRespondTool } from './plan_mode_respond.js';
@@ -0,0 +1,144 @@
/**
* Plan Mode Respond Tool
*
* Plan 模式专用响应工具,用于结构化输出计划和进度信息。
* 参考 OpenCode 的 plan_mode_respond 实现。
*
* 特性:
* - response: 输出计划内容
* - needs_more_exploration: 标记是否需要更多探索
* - task_progress: 报告任务进度 (0-100)
*/
import type { ToolWithMetadata } from '../types.js';
import type { ToolResult } from '../../types/index.js';
/**
* 生成进度条字符串
*/
function generateProgressBar(progress: number): string {
const total = 20;
const filled = Math.round((progress / 100) * total);
const empty = total - filled;
return '[' + '='.repeat(filled) + ' '.repeat(empty) + ']';
}
/**
* Plan Mode 响应工具
*/
export const planModeRespondTool: ToolWithMetadata = {
name: 'plan_mode_respond',
description: `Plan 模式专用响应工具。使用此工具输出结构化的计划和进度信息。
使用场景:
- 完成探索后输出实现计划
- 报告需要更多探索(设置 needs_more_exploration: true
- 更新任务进度
参数说明:
- response (必需): 计划内容、分析结果或回复
- needs_more_exploration (可选): 是否需要继续探索。设为 true 表示当前信息不足
- task_progress (可选): 任务完成进度 (0-100)。0=刚开始,50=进行中,100=完成
示例:
1. 输出初步分析:
plan_mode_respond({
response: "## 现状分析\\n代码结构如下...",
needs_more_exploration: true,
task_progress: 30
})
2. 输出最终计划:
plan_mode_respond({
response: "## 实现方案\\n### 步骤 1: ...",
needs_more_exploration: false,
task_progress: 100
})`,
metadata: {
name: 'plan_mode_respond',
category: 'agent',
description: 'Plan 模式结构化响应工具',
keywords: ['plan', 'respond', 'response', 'progress', '计划', '响应', '进度'],
deferLoading: true, // 仅在 Plan 模式下加载
},
parameters: {
response: {
type: 'string',
description: '计划内容、分析结果或回复',
required: true,
},
needs_more_exploration: {
type: 'boolean',
description: '是否需要更多探索。设为 true 表示当前信息不足,需要继续调研',
required: false,
},
task_progress: {
type: 'number',
description: '任务完成进度 (0-100)。0=刚开始,50=进行中,100=完成',
required: false,
},
},
async execute(params: Record<string, unknown>): Promise<ToolResult> {
const {
response,
needs_more_exploration = false,
task_progress,
} = params as {
response: string;
needs_more_exploration?: boolean;
task_progress?: number;
};
// 验证参数
if (!response || typeof response !== 'string') {
return {
success: false,
output: '',
error: 'response 参数是必需的,且必须是字符串',
};
}
if (task_progress !== undefined) {
if (typeof task_progress !== 'number' || task_progress < 0 || task_progress > 100) {
return {
success: false,
output: '',
error: 'task_progress 必须是 0-100 之间的数字',
};
}
}
// 构建结构化输出
const output: string[] = [];
// 添加进度信息
if (task_progress !== undefined) {
const progressBar = generateProgressBar(task_progress);
output.push(`[进度: ${task_progress}%] ${progressBar}`);
output.push('');
}
// 添加响应内容
output.push(response);
// 添加探索状态
if (needs_more_exploration) {
output.push('');
output.push('---');
output.push('[状态: 需要更多探索]');
}
return {
success: true,
output: output.join('\n'),
metadata: {
needs_more_exploration,
task_progress,
type: 'plan_response',
},
};
},
};
+2
View File
@@ -123,6 +123,8 @@ interface AgentInstance {
};
getHistory(): unknown[];
setAgentMode?(mode: 'build' | 'plan'): void;
/** 切换模式(保留对话历史) */
switchMode?(mode: 'build' | 'plan', preserveHistory?: boolean): void;
setAutoApprove?(config: { file?: { write?: 'allow'; edit?: 'allow' } }): void;
clearAutoApprove?(): void;
}
+3 -2
View File
@@ -92,7 +92,7 @@ export type AgentModeType = 'build' | 'plan';
// 客户端发送的消息
export interface ClientMessage {
type: 'message' | 'cancel' | 'tool_response' | 'permission_response' | 'config_update';
type: 'message' | 'cancel' | 'tool_response' | 'permission_response' | 'config_update' | 'mode_switch';
sessionId: string;
payload?: {
content?: string;
@@ -122,7 +122,8 @@ export interface ServerMessage {
| 'cancelled'
| 'error'
| 'session_updated'
| 'permission_request';
| 'permission_request'
| 'mode_switched'; // 模式切换完成
sessionId: string;
payload?: unknown;
}
+30 -1
View File
@@ -6,7 +6,7 @@
import type { WSContext } from 'hono/ws';
import { getSessionManager } from './session/manager.js';
import { processMessage, cancelProcessing } from './agent/index.js';
import { processMessage, cancelProcessing, getOrCreateAgent } from './agent/index.js';
import { handlePermissionResponse, setSessionAutoApprove } from './permission/handler.js';
import type { ClientMessage, ServerMessage } from './types.js';
@@ -172,6 +172,35 @@ export async function handleWebSocketMessage(
break;
}
case 'mode_switch': {
// 动态模式切换(Build ↔ Plan
const mode = message.payload?.agentMode as 'build' | 'plan' | undefined;
if (mode === 'build' || mode === 'plan') {
try {
const agent = await getOrCreateAgent(sessionId);
if (agent && typeof agent.switchMode === 'function') {
agent.switchMode(mode, true); // 保留对话历史
broadcastToSession(sessionId, {
type: 'mode_switched',
sessionId,
payload: { mode },
});
console.log(`[WS] Mode switched for session ${sessionId}: ${mode}`);
} else {
console.warn(`[WS] Agent does not support switchMode for session ${sessionId}`);
}
} catch (error) {
console.error(`[WS] Failed to switch mode for session ${sessionId}:`, error);
broadcastToSession(sessionId, {
type: 'error',
sessionId,
payload: { message: 'Failed to switch mode' },
});
}
}
break;
}
default:
ws.send(
JSON.stringify({