From 35d87a04fb02606adce9312c3791fd7cff86e4ae Mon Sep 17 00:00:00 2001 From: kurihada Date: Mon, 15 Dec 2025 20:51:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=A2=9E=E5=BC=BA=20Plan=20Agent?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E7=BB=86=E7=B2=92=E5=BA=A6=20bash=20?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=92=8C=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 工具 --- packages/core/src/agent/presets/plan.ts | 143 ++++++++++++++--- packages/core/src/core/agent.ts | 37 +++++ packages/core/src/tools/index.ts | 6 + packages/core/src/tools/plan/index.ts | 7 + .../core/src/tools/plan/plan_mode_respond.ts | 144 ++++++++++++++++++ packages/server/src/agent/adapter.ts | 2 + packages/server/src/types.ts | 5 +- packages/server/src/ws.ts | 31 +++- 8 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/tools/plan/index.ts create mode 100644 packages/core/src/tools/plan/plan_mode_respond.ts diff --git a/packages/core/src/agent/presets/plan.ts b/packages/core/src/agent/presets/plan.ts index 9aae8a3..f9e735b 100644 --- a/packages/core/src/agent/presets/plan.ts +++ b/packages/core/src/agent/presets/plan.ts @@ -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 = { - 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 = { ## 风险评估 - [风险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 = { 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 = { model: { temperature: 0.5, }, - maxSteps: 15, + maxSteps: 30, // 增加探索步数以支持更深入的调研 }; diff --git a/packages/core/src/core/agent.ts b/packages/core/src/core/agent.ts index 79c2d7f..ad044aa 100644 --- a/packages/core/src/core/agent.ts +++ b/packages/core/src/core/agent.ts @@ -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 模式的自动授权) // ============================================================================ diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index cfaa108..b62a964 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -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 diff --git a/packages/core/src/tools/plan/index.ts b/packages/core/src/tools/plan/index.ts new file mode 100644 index 0000000..0e3fb92 --- /dev/null +++ b/packages/core/src/tools/plan/index.ts @@ -0,0 +1,7 @@ +/** + * Plan 工具模块 + * + * Plan 模式专用工具集合 + */ + +export { planModeRespondTool } from './plan_mode_respond.js'; diff --git a/packages/core/src/tools/plan/plan_mode_respond.ts b/packages/core/src/tools/plan/plan_mode_respond.ts new file mode 100644 index 0000000..fdb059b --- /dev/null +++ b/packages/core/src/tools/plan/plan_mode_respond.ts @@ -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): Promise { + 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', + }, + }; + }, +}; diff --git a/packages/server/src/agent/adapter.ts b/packages/server/src/agent/adapter.ts index 3fff349..7abbbd4 100644 --- a/packages/server/src/agent/adapter.ts +++ b/packages/server/src/agent/adapter.ts @@ -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; } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 0aff4a5..f8c5f0b 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -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; } diff --git a/packages/server/src/ws.ts b/packages/server/src/ws.ts index d945ee0..2cc72b2 100644 --- a/packages/server/src/ws.ts +++ b/packages/server/src/ws.ts @@ -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({