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:
thzxx
2026-07-06 22:48:33 +08:00
parent 8cdb93f8bf
commit 6f08759f63
25 changed files with 1044 additions and 675 deletions
+71 -25
View File
@@ -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);