diff --git a/packages/core/src/agent/executor.ts b/packages/core/src/agent/executor.ts index c136183..bba962b 100644 --- a/packages/core/src/agent/executor.ts +++ b/packages/core/src/agent/executor.ts @@ -15,7 +15,7 @@ import type { AgentExecutionResult, ImageData, } from './types.js'; -import { checkBashPermission } from './permission-merger.js'; +import { checkBashPermission, isPathInAllowedWritePaths } from './permission-merger.js'; import { getProviderRegistry } from '../provider/index.js'; /** @@ -261,6 +261,17 @@ export class AgentExecutor { if (action === 'deny') { return { allowed: false, reason: `${operation} 操作被禁止` }; } + + // 检查 allowedWritePaths 限制(仅对 write 操作) + if (operation === 'write' && filePermission.allowedWritePaths) { + const filePath = params.path as string; + if (filePath && !isPathInAllowedWritePaths(filePath, filePermission.allowedWritePaths)) { + return { + allowed: false, + reason: `写入路径不在允许列表中: ${filePath}。只能写入: ${filePermission.allowedWritePaths.join(', ')}`, + }; + } + } } } diff --git a/packages/core/src/agent/permission-merger.ts b/packages/core/src/agent/permission-merger.ts index 73371f3..53d7ab4 100644 --- a/packages/core/src/agent/permission-merger.ts +++ b/packages/core/src/agent/permission-merger.ts @@ -82,6 +82,9 @@ function mergeFilePermission( global: AgentFilePermission | undefined, agent: AgentFilePermission | undefined ): AgentFilePermission { + // allowedWritePaths: Agent 优先,否则用 global,不继承 system + const allowedWritePaths = agent?.allowedWritePaths ?? global?.allowedWritePaths; + return { read: mergeAction(system?.read, global?.read, agent?.read), write: mergeAction(system?.write, global?.write, agent?.write), @@ -92,6 +95,7 @@ function mergeFilePermission( global?.sensitivePaths, agent?.sensitivePaths ), + allowedWritePaths, }; } @@ -219,3 +223,46 @@ export function checkFilePathPermission( return null; } + +/** + * 展开路径中的 ~ 符号 + */ +function expandTilde(targetPath: string): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + if (targetPath.startsWith('~/')) { + return targetPath.replace('~/', homeDir + '/'); + } + if (targetPath === '~') { + return homeDir; + } + return targetPath; +} + +/** + * 检查文件路径是否在允许的写入路径列表中 + * @param filePath 要检查的文件路径 + * @param allowedPaths 允许的路径列表(支持通配符和 ~) + * @returns true 表示允许,false 表示不允许 + */ +export function isPathInAllowedWritePaths( + filePath: string, + allowedPaths: string[] | undefined +): boolean { + // 如果没有配置 allowedWritePaths,表示不限制 + if (!allowedPaths || allowedPaths.length === 0) { + return true; + } + + // 展开文件路径中的 ~ + const expandedFilePath = expandTilde(filePath); + + // 检查是否匹配任一允许的路径 + for (const allowedPath of allowedPaths) { + const expandedAllowedPath = expandTilde(allowedPath); + if (matchRule(expandedFilePath, expandedAllowedPath)) { + return true; + } + } + + return false; +} diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index c15b912..85a48a2 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -35,6 +35,13 @@ export interface AgentFilePermission { delete?: PermissionAction; /** 敏感路径规则 */ sensitivePaths?: PermissionRule[]; + /** + * 允许写入的路径列表(支持通配符和 ~ 展开) + * 当 write='allow' 时,如果设置此项则只允许写入这些路径 + * 写入其他路径会被拒绝 + * 示例: ['~/.ai-terminal-assistant/plan/*'] + */ + allowedWritePaths?: string[]; } /** diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index b62a964..71340b2 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -60,7 +60,11 @@ import { } from './checkpoint/index.js'; // Plan 工具 -import { planModeRespondTool } from './plan/index.js'; +import { + askUserQuestionTool, + enterPlanModeTool, + exitPlanModeTool, +} from './plan/index.js'; // 所有工具列表(用于注册) const allToolsWithMetadata: ToolWithMetadata[] = [ @@ -116,8 +120,10 @@ const allToolsWithMetadata: ToolWithMetadata[] = [ checkpointDiffTool, checkpointRestoreTool, - // Plan 工具 (deferLoading: true - 仅 Plan 模式) - planModeRespondTool, + // Plan 工具 + enterPlanModeTool, // deferLoading: false - Act 模式可用 + exitPlanModeTool, // deferLoading: true - 仅 Plan 模式 + askUserQuestionTool, // deferLoading: false - 通用 ]; // 注册所有工具到 registry diff --git a/packages/core/src/tools/plan/ask_user_question.ts b/packages/core/src/tools/plan/ask_user_question.ts new file mode 100644 index 0000000..39a0a6a --- /dev/null +++ b/packages/core/src/tools/plan/ask_user_question.ts @@ -0,0 +1,227 @@ +/** + * Ask User Question Tool + * + * 向用户提问以澄清需求或获取决策。 + * 参考 Claude Code 的 AskUserQuestion 实现。 + * + * 特性: + * - 支持 1-4 个问题 + * - 每个问题支持 2-4 个选项 + * - 支持单选/多选 + * - 系统自动添加 "Other" 选项供自由输入 + */ + +import type { ToolWithMetadata } from '../types.js'; +import type { ToolResult } from '../../types/index.js'; + +/** + * 选项定义 + */ +export interface QuestionOption { + /** 选项标签(1-5个词) */ + label: string; + /** 选项说明 */ + description?: string; +} + +/** + * 问题定义 + */ +export interface Question { + /** 问题内容(应以问号结尾) */ + question: string; + /** 简短标签(最多12字符) */ + header?: string; + /** 选项列表(2-4个) */ + options?: QuestionOption[]; + /** 是否允许多选,默认 false */ + multiSelect?: boolean; +} + +/** + * 工具参数 + */ +export interface AskUserQuestionParams { + questions: Question[]; +} + +/** + * 验证问题结构 + */ +function validateQuestions(questions: Question[]): string | null { + if (!Array.isArray(questions) || questions.length === 0) { + return 'questions 必须是非空数组'; + } + + if (questions.length > 4) { + return '最多只能包含 4 个问题'; + } + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + + if (!q.question || typeof q.question !== 'string') { + return `问题 ${i + 1}: question 是必需的字符串`; + } + + if (q.header && q.header.length > 12) { + return `问题 ${i + 1}: header 最多 12 个字符`; + } + + if (q.options) { + if (!Array.isArray(q.options)) { + return `问题 ${i + 1}: options 必须是数组`; + } + + if (q.options.length < 2 || q.options.length > 4) { + return `问题 ${i + 1}: options 必须包含 2-4 个选项`; + } + + for (let j = 0; j < q.options.length; j++) { + const opt = q.options[j]; + if (!opt.label || typeof opt.label !== 'string') { + return `问题 ${i + 1} 选项 ${j + 1}: label 是必需的字符串`; + } + } + } + } + + return null; +} + +/** + * 格式化问题为可读输出 + */ +function formatQuestions(questions: Question[]): string { + const output: string[] = []; + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const questionNumber = questions.length > 1 ? `[${i + 1}/${questions.length}] ` : ''; + const header = q.header ? `【${q.header}】` : ''; + + output.push(`${questionNumber}${header}${q.question}`); + + if (q.options && q.options.length > 0) { + output.push(''); + const selectHint = q.multiSelect ? '(可多选)' : '(单选)'; + output.push(`选项 ${selectHint}:`); + + q.options.forEach((opt, idx) => { + const optionKey = String.fromCharCode(65 + idx); // A, B, C, D + const description = opt.description ? ` - ${opt.description}` : ''; + output.push(` ${optionKey}. ${opt.label}${description}`); + }); + + // 自动添加 Other 选项 + const otherKey = String.fromCharCode(65 + q.options.length); + output.push(` ${otherKey}. Other - 自定义输入`); + } + + if (i < questions.length - 1) { + output.push(''); + output.push('---'); + output.push(''); + } + } + + return output.join('\n'); +} + +/** + * Ask User Question 工具 + */ +export const askUserQuestionTool: ToolWithMetadata = { + name: 'ask_user_question', + description: `向用户提问以澄清需求或获取决策。 + +使用场景: +- 需要用户在多个方案中做选择 +- 需要澄清不明确的需求 +- 需要用户确认关键决策 + +参数结构: +{ + "questions": [ + { + "question": "问题内容(以问号结尾)", + "header": "简短标签(最多12字符,可选)", + "options": [ + { "label": "选项1", "description": "说明(可选)" }, + { "label": "选项2", "description": "说明" } + ], + "multiSelect": false + } + ] +} + +约束: +- questions: 1-4 个问题 +- options: 每个问题 2-4 个选项(系统自动添加 "Other" 选项) +- header: 最多 12 字符 +- label: 建议 1-5 个词 + +示例: +ask_user_question({ + questions: [{ + question: "您希望使用哪种状态管理方案?", + header: "状态管理", + options: [ + { label: "Redux", description: "成熟稳定,生态丰富" }, + { label: "Zustand", description: "轻量简洁,学习曲线低" }, + { label: "Jotai", description: "原子化状态,适合细粒度更新" } + ], + multiSelect: false + }] +})`, + + metadata: { + name: 'ask_user_question', + category: 'agent', + description: '向用户提问以澄清需求或获取决策', + keywords: ['ask', 'question', 'clarify', 'decision', '提问', '澄清', '决策', '选择'], + deferLoading: false, // 通用工具,始终可用 + }, + + parameters: { + questions: { + type: 'array', + description: '问题列表(1-4 个问题)', + required: true, + }, + }, + + async execute(params: Record): Promise { + const questions = params.questions as Question[]; + + // 验证参数 + const validationError = validateQuestions(questions); + if (validationError) { + return { + success: false, + output: '', + error: validationError, + }; + } + + // 格式化输出 + const formattedOutput = formatQuestions(questions); + + return { + success: true, + output: formattedOutput, + metadata: { + type: 'ask_user_question', + questionCount: questions.length, + questions: questions.map((q, i) => ({ + index: i, + header: q.header, + optionCount: q.options?.length ?? 0, + multiSelect: q.multiSelect ?? false, + })), + // 标记需要用户输入 + requiresUserInput: true, + }, + }; + }, +}; diff --git a/packages/core/src/tools/plan/enter_plan_mode.ts b/packages/core/src/tools/plan/enter_plan_mode.ts new file mode 100644 index 0000000..43a0b22 --- /dev/null +++ b/packages/core/src/tools/plan/enter_plan_mode.ts @@ -0,0 +1,82 @@ +/** + * Enter Plan Mode Tool + * + * 进入计划模式的工具。 + * 参考 Claude Code 的 EnterPlanMode 实现。 + * + * 在开始非平凡的实现任务前主动使用,让用户在编写代码之前先批准方案。 + * + * 使用场景: + * - 新功能实现 + * - 多种可行方案需要选择 + * - 代码修改影响范围较大 + * - 架构决策 + * - 多文件变更 + * - 需求不明确需要探索 + * + * 不适用场景: + * - 单行修复(错别字、明显 bug) + * - 用户给出了非常具体详细的指令 + * - 纯研究/探索任务 + */ + +import type { ToolWithMetadata } from '../types.js'; +import type { ToolResult } from '../../types/index.js'; + +/** + * Enter Plan Mode 工具 + */ +export const enterPlanModeTool: ToolWithMetadata = { + name: 'enter_plan_mode', + description: `进入计划模式,在开始非平凡的实现任务前主动使用。 + +使用场景: +- 新功能实现:如 "添加登出按钮"、"实现用户认证" +- 多种可行方案:如 "给 API 添加缓存"(Redis/内存/文件) +- 代码修改:如 "更新登录流程"、"重构组件" +- 架构决策:如 "添加实时更新功能"(WebSocket/SSE/轮询) +- 多文件变更:如 "重构认证系统" +- 需求不明确:如 "让应用更快"、"修复 checkout 的 bug" + +何时不使用: +- 单行修复(错别字、明显 bug、小调整) +- 用户给出了非常具体详细的指令 +- 纯研究/探索任务(使用 Task 工具的 explore agent) + +计划模式中的限制: +进入后只能使用只读工具: +✅ read_file, list_directory, search_files, grep_content +✅ web_search, web_extract +✅ task(探索代理) +✅ ask_user_question +❌ write_file, edit_file, bash(执行命令) + +完成计划后使用 exit_plan_mode 退出并提交方案供用户审批。`, + + metadata: { + name: 'enter_plan_mode', + category: 'agent', + description: '进入计划模式,用于规划实现方案', + keywords: ['plan', 'mode', 'enter', '计划', '模式', '规划', '方案'], + deferLoading: false, // 始终可用 + }, + + parameters: { + // 无参数 + }, + + async execute(_params: Record): Promise { + return { + success: true, + output: '已进入计划模式。现在可以使用只读工具探索代码库,完成后使用 exit_plan_mode 提交方案。', + metadata: { + type: 'enter_plan_mode', + // 标记模式切换 + modeTransition: { + from: 'act', + to: 'plan', + }, + }, + }; + }, +}; diff --git a/packages/core/src/tools/plan/exit_plan_mode.ts b/packages/core/src/tools/plan/exit_plan_mode.ts new file mode 100644 index 0000000..762b2d4 --- /dev/null +++ b/packages/core/src/tools/plan/exit_plan_mode.ts @@ -0,0 +1,136 @@ +/** + * Exit Plan Mode Tool + * + * 退出计划模式,将方案提交给用户审批。 + * 参考 Claude Code 的 ExitPlanMode 实现。 + * + * 使用条件: + * 1. 已将计划写入计划文件 + * 2. 计划清晰无歧义 + * 3. 如有多种方案或不明确的需求,先用 AskUserQuestion 澄清 + * + * 可选参数: + * - launchSwarm: 是否启动多代理协作来实施方案 + * - teammateCount: 协作代理的数量 + */ + +import type { ToolWithMetadata } from '../types.js'; +import type { ToolResult } from '../../types/index.js'; + +/** + * 工具参数 + */ +export interface ExitPlanModeParams { + /** 是否启动 swarm 来实施方案 */ + launchSwarm?: boolean; + /** swarm 中的队友数量 */ + teammateCount?: number; +} + +/** + * Exit Plan Mode 工具 + */ +export const exitPlanModeTool: ToolWithMetadata = { + name: 'exit_plan_mode', + description: `退出计划模式,将方案提交给用户审批。 + +使用条件(调用前必须满足): +1. 已完成方案设计 +2. 计划清晰无歧义 +3. 如有多种方案或不明确的需求,先用 ask_user_question 澄清 + +何时使用: +- 当任务需要规划实现步骤并编写代码时使用 +- 纯研究/探索任务不要使用此工具 + +参数说明: +- launchSwarm (可选): 是否启动多代理协作来实施方案 +- teammateCount (可选): 协作代理的数量(需要 launchSwarm=true) + +示例: +1. 简单退出(用户手动执行): + exit_plan_mode({}) + +2. 启动 swarm 协作实施: + exit_plan_mode({ + launchSwarm: true, + teammateCount: 3 + })`, + + metadata: { + name: 'exit_plan_mode', + category: 'agent', + description: '退出计划模式,提交方案供用户审批', + keywords: ['plan', 'mode', 'exit', 'submit', '计划', '模式', '退出', '提交', '审批'], + deferLoading: true, // 仅 Plan 模式下可用 + }, + + parameters: { + launchSwarm: { + type: 'boolean', + description: '是否启动多代理协作来实施方案', + required: false, + }, + teammateCount: { + type: 'number', + description: '协作代理的数量(需要 launchSwarm=true)', + required: false, + }, + }, + + async execute(params: Record): Promise { + const launchSwarm = params.launchSwarm as boolean | undefined; + const teammateCount = params.teammateCount as number | undefined; + + // 验证参数 + if (teammateCount !== undefined) { + if (typeof teammateCount !== 'number' || teammateCount < 1) { + return { + success: false, + output: '', + error: 'teammateCount 必须是大于 0 的数字', + }; + } + if (!launchSwarm) { + return { + success: false, + output: '', + error: '使用 teammateCount 时需要设置 launchSwarm: true', + }; + } + } + + // 构建输出 + const output: string[] = ['计划模式已结束,方案已提交审批。']; + + if (launchSwarm) { + const count = teammateCount ?? 2; + output.push(''); + output.push(`配置: 启动 Swarm 协作模式,${count} 个代理将并行实施方案。`); + } + + return { + success: true, + output: output.join('\n'), + metadata: { + type: 'exit_plan_mode', + // 标记模式切换 + modeTransition: { + from: 'plan', + to: 'act', + }, + // Swarm 配置 + swarm: launchSwarm + ? { + enabled: true, + teammateCount: teammateCount ?? 2, + } + : { + enabled: false, + }, + // 标记需要用户审批 + requiresApproval: true, + }, + }; + }, +}; diff --git a/packages/core/src/tools/plan/index.ts b/packages/core/src/tools/plan/index.ts index 0e3fb92..6092173 100644 --- a/packages/core/src/tools/plan/index.ts +++ b/packages/core/src/tools/plan/index.ts @@ -4,4 +4,8 @@ * Plan 模式专用工具集合 */ -export { planModeRespondTool } from './plan_mode_respond.js'; +export { askUserQuestionTool } from './ask_user_question.js'; +export { enterPlanModeTool } from './enter_plan_mode.js'; +export { exitPlanModeTool } from './exit_plan_mode.js'; +export type { Question, QuestionOption, AskUserQuestionParams } from './ask_user_question.js'; +export type { ExitPlanModeParams } from './exit_plan_mode.js'; diff --git a/packages/core/src/tools/plan/plan_mode_respond.ts b/packages/core/src/tools/plan/plan_mode_respond.ts deleted file mode 100644 index fdb059b..0000000 --- a/packages/core/src/tools/plan/plan_mode_respond.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * 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', - }, - }; - }, -};