From b63b79e51efc3940eeccd5445dc0d48eb7a0b7b4 Mon Sep 17 00:00:00 2001 From: kurihada Date: Wed, 17 Dec 2025 10:05:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=AE=9E=E7=8E=B0=E5=85=88?= =?UTF-8?q?=E8=AF=BB=E5=90=8E=E5=86=99=E9=AA=8C=E8=AF=81=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确保 write_file 和 edit_file 修改已存在的文件前必须先调用 read_file - 在 AgentToolExecutor 中添加 readFiles 状态跟踪已读文件 - 创建 read-before-write.ts hook 拦截写操作并验证 - 在 Agent 初始化时注册验证 hook - 提供 AI 友好的错误消息引导正确操作 --- packages/core/src/core/agent-tool-executor.ts | 40 +++++ packages/core/src/core/agent.ts | 21 +++ packages/core/src/hooks/index.ts | 3 + packages/core/src/hooks/read-before-write.ts | 146 ++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 packages/core/src/hooks/read-before-write.ts diff --git a/packages/core/src/core/agent-tool-executor.ts b/packages/core/src/core/agent-tool-executor.ts index 3814384..28fe51a 100644 --- a/packages/core/src/core/agent-tool-executor.ts +++ b/packages/core/src/core/agent-tool-executor.ts @@ -66,6 +66,7 @@ export interface ToolExecutionContext { export class AgentToolExecutor { private registry: ToolRegistry; private discoveredTools: Set = new Set(); + private readFiles: Set = new Set(); private toolDescriptionContext: PromptContext | null = null; private currentAgentMode: AgentInfo | null = null; @@ -393,6 +394,45 @@ export class AgentToolExecutor { this.discoveredTools.clear(); } + // ============================================================================ + // 文件读取跟踪(用于"先读后写"验证) + // ============================================================================ + + /** + * 记录文件已被读取 + */ + recordFileRead(absolutePath: string): void { + this.readFiles.add(absolutePath); + } + + /** + * 检查文件是否已被读取 + */ + hasFileBeenRead(absolutePath: string): boolean { + return this.readFiles.has(absolutePath); + } + + /** + * 获取已读取的文件列表 + */ + getReadFiles(): string[] { + return [...this.readFiles]; + } + + /** + * 设置已读取的文件(用于会话恢复) + */ + setReadFiles(files: string[]): void { + this.readFiles = new Set(files); + } + + /** + * 清除已读取的文件记录 + */ + clearReadFiles(): void { + this.readFiles.clear(); + } + /** * 获取工具数量统计 */ diff --git a/packages/core/src/core/agent.ts b/packages/core/src/core/agent.ts index f0cec59..9198817 100644 --- a/packages/core/src/core/agent.ts +++ b/packages/core/src/core/agent.ts @@ -23,6 +23,8 @@ import { AgentToolExecutor, type ToolStartInfo, type ToolEndInfo, type WaitingFo import { AgentMessageHandler, type DoomLoopInfo } from './agent-message-handler.js'; import { AgentModeManager } from './agent-mode-manager.js'; import { AgentVisionHandler } from './agent-vision-handler.js'; +import { getHookManager } from '../hooks/index.js'; +import { createReadBeforeWriteHook } from '../hooks/read-before-write.js'; // 重新导出类型 export type { ToolStartInfo, ToolEndInfo, DoomLoopInfo, WaitingForInputInfo }; @@ -122,6 +124,25 @@ export class Agent { setRegistry(registry: ToolRegistry): void { this.toolExecutor = new AgentToolExecutor(registry); this.visionHandler.setRegistry(registry); + + // 注册 "先读后写" 验证 Hook + this.registerReadBeforeWriteHook(); + } + + /** + * 注册 "先读后写" 验证 Hook + * 确保 write_file/edit_file 修改已存在的文件前必须先调用 read_file + */ + private registerReadBeforeWriteHook(): void { + const hookManager = getHookManager(); + if (!hookManager || !this.toolExecutor) return; + + const hook = createReadBeforeWriteHook( + () => this.toolExecutor, + () => process.cwd() + ); + + hookManager.registerHooks(hook); } /** diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index d38ccd3..1626806 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -23,6 +23,9 @@ export { type ProjectConfig, } from './config-loader.js'; +// Read-Before-Write Hook +export { createReadBeforeWriteHook } from './read-before-write.js'; + // 类型导出 export type { HookType, diff --git a/packages/core/src/hooks/read-before-write.ts b/packages/core/src/hooks/read-before-write.ts new file mode 100644 index 0000000..b2639eb --- /dev/null +++ b/packages/core/src/hooks/read-before-write.ts @@ -0,0 +1,146 @@ +/** + * Read-Before-Write 验证 Hook + * + * 确保 write_file 和 edit_file 操作修改已存在的文件前, + * 必须先调用 read_file 读取该文件。 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import type { + Hooks, + ToolExecuteBeforeInput, + ToolExecuteBeforeOutput, + ToolExecuteAfterInput, + ToolExecuteAfterOutput, +} from './types.js'; +import type { AgentToolExecutor } from '../core/agent-tool-executor.js'; + +/** + * 规范化文件路径为绝对路径 + */ +function normalizeFilePath(filePath: string, cwd: string): string { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath); + // 解析 '..' 和 '.' 确保路径一致 + return path.resolve(absolutePath); +} + +/** + * 检查文件是否存在 + */ +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * 构建 AI 友好的错误消息 + */ +function buildErrorMessage(tool: string, filePath: string, absolutePath: string): string { + const action = tool === 'write_file' ? '写入' : '编辑'; + const actionEn = tool === 'write_file' ? 'write to' : 'edit'; + const toolName = tool === 'write_file' ? 'write_file' : 'edit_file'; + + return `[Read-Before-Write Error] + +Cannot ${actionEn} existing file without reading it first. + +File: ${filePath} +Absolute path: ${absolutePath} + +Before using ${toolName} on an existing file, you MUST first call read_file to understand its current content. + +Required action: +1. Call read_file with path: "${filePath}" +2. Review the file content +3. Then retry ${toolName} with appropriate changes + +This ensures you don't accidentally overwrite important content.`; +} + +/** + * 创建 Read-Before-Write 验证 Hook + * + * @param getToolExecutor - 获取 AgentToolExecutor 实例的函数 + * @param getCwd - 获取当前工作目录的函数 + */ +export function createReadBeforeWriteHook( + getToolExecutor: () => AgentToolExecutor | null, + getCwd: () => string +): Hooks { + return { + 'tool.execute.before': async ( + input: ToolExecuteBeforeInput, + output: ToolExecuteBeforeOutput + ): Promise => { + const { tool, args } = input; + + // 只验证 write_file 和 edit_file + if (tool !== 'write_file' && tool !== 'edit_file') { + return; + } + + const filePath = args.path as string; + if (!filePath) { + return; + } + + const cwd = getCwd(); + const absolutePath = normalizeFilePath(filePath, cwd); + + // 检查文件是否存在 + const exists = await fileExists(absolutePath); + + // 如果文件不存在,write_file 是创建新文件 - 允许 + if (!exists && tool === 'write_file') { + return; + } + + // 如果文件不存在但调用 edit_file,让工具自己处理这个错误 + if (!exists && tool === 'edit_file') { + return; + } + + // 文件存在 - 检查是否已读取 + const toolExecutor = getToolExecutor(); + if (!toolExecutor) { + return; // 没有 executor 无法验证 + } + + if (!toolExecutor.hasFileBeenRead(absolutePath)) { + // 阻止操作 + output.skip = true; + output.skipResult = { + success: false, + output: '', + error: buildErrorMessage(tool, filePath, absolutePath), + }; + } + }, + + 'tool.execute.after': async ( + input: ToolExecuteAfterInput, + output: ToolExecuteAfterOutput + ): Promise => { + const { tool, args } = input; + + // 跟踪成功的 read_file 调用 + if (tool === 'read_file' && output.result.success) { + const filePath = args.path as string; + if (!filePath) return; + + const cwd = getCwd(); + const absolutePath = normalizeFilePath(filePath, cwd); + + const toolExecutor = getToolExecutor(); + if (toolExecutor) { + toolExecutor.recordFileRead(absolutePath); + } + } + }, + }; +}