v0.1.2: 全项目代码审查修复 + 子代理编排器 + 上下文压缩 + 并行工具执行
后端修复: Ollama adapter 移除死代码/修复超时硬编码/iteration硬编码/pullModel无超时/流异常断开补发DONE; SSE解析器支持非字符串arguments; Sandbox fail-closed安全加固; IPC移除未使用变量 新增功能: TaskOrchestrator子任务编排器; DelegateTaskTool委派工具; Agent Loop并行工具执行; 上下文自动压缩; 记忆检索注入System Prompt 前端修复: useAgentStream text_delta thought累积bug; 工具调用状态正确流转; 修复重复stateChange事件; TraceStep.thought正确填充
This commit is contained in:
@@ -12,8 +12,8 @@
|
||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, readdir, stat, appendFile } from 'fs/promises';
|
||||
import { join, relative, resolve } from 'path';
|
||||
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open } from 'fs/promises';
|
||||
import { join, relative, resolve, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
@@ -34,6 +34,29 @@ function safeResolvePath(filePath: string, workspacePath: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** 共享 glob 匹配(简易通配符 → 正则) */
|
||||
function matchGlob(name: string, glob: string): boolean {
|
||||
const pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||
}
|
||||
|
||||
/** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */
|
||||
async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const buffer = Buffer.alloc(8192);
|
||||
const fd = await open(filePath, 'r');
|
||||
await fd.read(buffer, 0, 8192, 0);
|
||||
await fd.close();
|
||||
// 含 NULL 字节 → 二进制
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (buffer[i] === 0) return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 1. read_file =====
|
||||
|
||||
export class ReadFileTool implements IMetonaTool {
|
||||
@@ -60,6 +83,18 @@ export class ReadFileTool implements IMetonaTool {
|
||||
const offset = Math.max(1, (args.offset as number) ?? 1);
|
||||
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
||||
|
||||
// 二进制文件检测 — 避免读取图片/可执行文件产生乱码
|
||||
if (await isBinaryFile(filePath)) {
|
||||
return {
|
||||
content: '',
|
||||
total_lines: 0,
|
||||
returned_lines: 0,
|
||||
truncated: false,
|
||||
file_size: (await stat(filePath)).size,
|
||||
error: 'Binary file detected. Use web_browser screenshot or other tools for binary content.',
|
||||
};
|
||||
}
|
||||
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
|
||||
@@ -69,7 +104,7 @@ export class ReadFileTool implements IMetonaTool {
|
||||
total_lines: lines.length,
|
||||
returned_lines: slicedLines.length,
|
||||
truncated: lines.length > offset - 1 + limit,
|
||||
file_size: content.length,
|
||||
file_size: Buffer.byteLength(content, 'utf-8'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -100,6 +135,12 @@ export class WriteFileTool implements IMetonaTool {
|
||||
const content = args.content as string;
|
||||
const mode = (args.mode as string) ?? 'overwrite';
|
||||
|
||||
// 自动创建父目录(递归)
|
||||
const parentDir = dirname(filePath);
|
||||
if (!existsSync(parentDir)) {
|
||||
await mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (mode === 'append') {
|
||||
await appendFile(filePath, content, 'utf-8');
|
||||
} else {
|
||||
@@ -137,11 +178,17 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
|
||||
const glob = args.glob as string | undefined;
|
||||
|
||||
const entries = await this.listDir(dirPath, depth, glob, 0);
|
||||
const entries = await this.listDir(dirPath, dirPath, depth, glob, 0);
|
||||
return { entries, count: entries.length };
|
||||
}
|
||||
|
||||
private async listDir(dirPath: string, maxDepth: number, glob: string | undefined, currentDepth: number): Promise<Array<{ name: string; path: string; type: string; size?: number }>> {
|
||||
private async listDir(
|
||||
rootPath: string,
|
||||
dirPath: string,
|
||||
maxDepth: number,
|
||||
glob: string | undefined,
|
||||
currentDepth: number,
|
||||
): Promise<Array<{ name: string; path: string; type: string; size?: number }>> {
|
||||
const results: Array<{ name: string; path: string; type: string; size?: number }> = [];
|
||||
|
||||
try {
|
||||
@@ -152,18 +199,19 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
|
||||
const fullPath = join(dirPath, entry.name);
|
||||
const relativePath = relative(dirPath, fullPath);
|
||||
|
||||
// glob 过滤
|
||||
if (glob && !this.matchGlob(entry.name, glob)) continue;
|
||||
// 相对路径始终以根请求目录为基准
|
||||
const relativePath = relative(rootPath, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// 目录始终列出(不受 glob 过滤),保证递归可进入子目录
|
||||
results.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||
if (currentDepth < maxDepth - 1) {
|
||||
const subEntries = await this.listDir(fullPath, maxDepth, glob, currentDepth + 1);
|
||||
const subEntries = await this.listDir(rootPath, fullPath, maxDepth, glob, currentDepth + 1);
|
||||
results.push(...subEntries);
|
||||
}
|
||||
} else {
|
||||
// glob 过滤仅适用于文件
|
||||
if (glob && !matchGlob(entry.name, glob)) continue;
|
||||
const stats = await stat(fullPath);
|
||||
results.push({ name: entry.name, path: relativePath, type: 'file', size: stats.size });
|
||||
}
|
||||
@@ -174,11 +222,6 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private matchGlob(name: string, glob: string): boolean {
|
||||
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 4. search_files =====
|
||||
@@ -221,12 +264,10 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
|
||||
private async searchByFilename(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
|
||||
const results: Array<{ path: string; name: string }> = [];
|
||||
const globPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
const regex = new RegExp(globPattern, 'i');
|
||||
|
||||
await this.walkDir(dirPath, async (filePath, name) => {
|
||||
if (results.length >= limit) return;
|
||||
if (regex.test(name)) {
|
||||
if (matchGlob(name, pattern)) {
|
||||
results.push({ path: relative(dirPath, filePath), name });
|
||||
}
|
||||
}, fileGlob);
|
||||
@@ -236,7 +277,17 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
|
||||
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number, workspacePath: string): Promise<unknown> {
|
||||
const results: Array<{ path: string; line: number; match: string }> = [];
|
||||
const regex = new RegExp(pattern, 'gi');
|
||||
|
||||
// 正则安全加固:限制 pattern 长度 + try-catch 防止 ReDoS
|
||||
if (pattern.length > 500) {
|
||||
return { results: [], count: 0, error: 'Search pattern too long (max 500 chars)' };
|
||||
}
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(pattern, 'gi');
|
||||
} catch {
|
||||
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}` };
|
||||
}
|
||||
|
||||
await this.walkDir(dirPath, async (filePath) => {
|
||||
if (results.length >= limit) return;
|
||||
@@ -280,7 +331,7 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
if (entry.isDirectory()) {
|
||||
await this.walkDir(fullPath, callback, fileGlob);
|
||||
} else {
|
||||
if (fileGlob && !this.matchGlob(entry.name, fileGlob)) continue;
|
||||
if (fileGlob && !matchGlob(entry.name, fileGlob)) continue;
|
||||
await callback(fullPath, entry.name);
|
||||
}
|
||||
}
|
||||
@@ -289,11 +340,6 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
}
|
||||
}
|
||||
|
||||
private matchGlob(name: string, glob: string): boolean {
|
||||
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||
}
|
||||
|
||||
/** search_files 的路径解析:仅需遍历防护,不需要 MEMORY.md 拦截(搜索时单独跳过) */
|
||||
private resolveSearchPath(filePath: string, workspacePath: string): string {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
|
||||
Reference in New Issue
Block a user