feat(core): 实现先读后写验证机制

确保 write_file 和 edit_file 修改已存在的文件前必须先调用 read_file

- 在 AgentToolExecutor 中添加 readFiles 状态跟踪已读文件
- 创建 read-before-write.ts hook 拦截写操作并验证
- 在 Agent 初始化时注册验证 hook
- 提供 AI 友好的错误消息引导正确操作
This commit is contained in:
2025-12-17 10:05:07 +08:00
parent 93f6890a04
commit b63b79e51e
4 changed files with 210 additions and 0 deletions
@@ -66,6 +66,7 @@ export interface ToolExecutionContext {
export class AgentToolExecutor {
private registry: ToolRegistry;
private discoveredTools: Set<string> = new Set();
private readFiles: Set<string> = 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();
}
/**
* 获取工具数量统计
*/
+21
View File
@@ -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);
}
/**
+3
View File
@@ -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,
@@ -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<boolean> {
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<void> => {
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<void> => {
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);
}
}
},
};
}