feat: v0.10.0 — 全面增强网络搜索、Agent Loop、工具稳定性与性能

网络搜索增强:
- web_search: 4引擎并行(Bing+百度+DuckDuckGo+Google), LRU缓存5min, URL可达性预检, 结构化JSON输出
- web_fetch: 自动重试(指数退避2次), 移动端UA切换, SPA页面自动升级浏览器渲染

Agent Loop增强:
- 流式调用超时保护(默认180s可配置)
- Final Answer多模式检测 + 内容长度验证
- 预算警告改用ephemeral标记(context优先丢弃)
- Token感知动态迭代预算(>80%自动缩减到3轮)
- 工具缓存TTL(搜索5min/网页10min/文件30min)
- 自动子任务拆解(并行关键词检测+spawn子代理)
- 增量记忆提取(每20轮触发)
- 智能批次划分: 16个只读工具加入ALWAYS_PARALLEL白名单
- 旧工具结果超过10轮自动截断到500字符

工具增强:
- read_multiple_files: 串行→并行Promise.all
- diff_files: 三级回退(diff -u → git diff → builtin)
- search_files: 新增use_regex正则搜索
- list_directory: 新增offset分页
- Git: stash参数修复, clone/push/pull超时保护
- browser: 提取ensureBrowserReady()消除重复, browser_open切换URL自动重建

文档更新:
- 帮助面板新增Agent Loop v0.10.0增强章节
- README中英文同步更新(四引擎搜索/工具描述)
- SOUL.md/AGENT.md更新(v0.10.0能力描述/SPA规则翻转)
This commit is contained in:
thzxx
2026-06-05 21:20:03 +08:00
parent 2217113014
commit c9adc764ae
18 changed files with 751 additions and 320 deletions
+120 -61
View File
@@ -4,6 +4,7 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { spawn } from 'child_process';
import { checkPathAllowed } from './tool-security.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
@@ -86,15 +87,17 @@ export async function handleWriteFile(params: { path: string; content: string; e
}
}
export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }): Promise<ToolResult> {
export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; offset?: number }): Promise<ToolResult> {
try {
const dirPath = resolvePath(params.path);
const allowed = checkPathAllowed(dirPath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const maxEntries = 500;
const startOffset = params.offset || 0;
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
let truncated = false;
let skipped = 0;
async function scanDir(dir: string, depth: number): Promise<void> {
if (entries.length >= maxEntries) { truncated = true; return; }
@@ -106,6 +109,9 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
if (!params.include_hidden && item.name.startsWith('.')) continue;
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
// 分页:跳过前 offset 个条目
if (skipped < startOffset) { skipped++; continue; }
const fullPath = path.join(dir, item.name);
const stat = await fs.stat(fullPath);
entries.push({
@@ -122,13 +128,16 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
}
await scanDir(dirPath, 1);
return { success: true, path: dirPath, entries, total: entries.length, truncated };
return {
success: true, path: dirPath, entries, total: entries.length,
truncated, offset: startOffset
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
export async function handleSearchFiles(params: { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }): Promise<ToolResult> {
export async function handleSearchFiles(params: { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[]; use_regex?: boolean }): Promise<ToolResult> {
try {
const rootPath = resolvePath(params.path);
const allowed = checkPathAllowed(rootPath, 'read');
@@ -136,8 +145,24 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const searchType = params.search_type || 'both';
const caseSensitive = params.case_sensitive || false;
const useRegex = params.use_regex || false;
const maxResults = params.max_results || 50;
const query = caseSensitive ? params.query : params.query.toLowerCase();
const query = params.query; // 保持原始,正则用 flags 控制大小写
// 构建搜索匹配器
let contentMatcher: (line: string) => boolean;
if (useRegex) {
try {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(query, flags);
contentMatcher = (line: string) => regex.test(line);
} catch {
return { success: false, error: '无效的正则表达式: ' + query };
}
} else {
const q = caseSensitive ? query : query.toLowerCase();
contentMatcher = (line: string) => (caseSensitive ? line : line.toLowerCase()).includes(q);
}
const results: Array<{ path: string; matches: Array<{ line: number; text: string; column?: number }> }> = [];
let totalMatches = 0;
@@ -191,10 +216,9 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const fileMatches: Array<{ line: number; text: string; column: number }> = [];
for (let i = 0; i < lines.length && fileMatches.length < 10; i++) {
const lineToCheck = caseSensitive ? lines[i] : lines[i].toLowerCase();
const col = lineToCheck.indexOf(query);
if (col !== -1) {
fileMatches.push({ line: i + 1, text: lines[i].trim(), column: col + 1 });
if (contentMatcher(lines[i])) {
const col = useRegex ? 1 : (caseSensitive ? lines[i] : lines[i].toLowerCase()).indexOf(caseSensitive ? query : query.toLowerCase());
fileMatches.push({ line: i + 1, text: lines[i].trim(), column: Math.max(1, col + 1) });
totalMatches++;
}
}
@@ -391,6 +415,18 @@ export async function handleTree(params: { path: string; max_depth?: number; inc
}
/** 运行 shell 命令并返回 stdout/stderr */
function runShell(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 }));
proc.on('error', (err) => resolve({ stdout, stderr: err.message, code: 1 }));
});
}
export async function handleDiffFiles(params: { file1: string; file2: string; context_lines?: number }): Promise<ToolResult> {
try {
const path1 = resolvePath(params.file1);
@@ -400,66 +436,89 @@ export async function handleDiffFiles(params: { file1: string; file2: string; co
const check2 = checkPathAllowed(path2, 'read');
if (!check2.ok) return { success: false, error: check2.reason };
const content1 = await fs.readFile(path1, 'utf-8');
const content2 = await fs.readFile(path2, 'utf-8');
const lines1 = content1.split('\n');
const lines2 = content2.split('\n');
// 简单的统一 diff 实现
const diffLines: string[] = [];
const contextSize = params.context_lines || 3;
let i = 0, j = 0;
let diffOutput = '';
let usedCommand = '';
while (i < lines1.length || j < lines2.length) {
if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) {
if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) {
// 添加上下文
const ctxStart = Math.max(0, i - contextSize);
for (let k = ctxStart; k < i; k++) {
diffLines.push(` ${lines1[k]}`);
}
}
diffLines.push(` ${lines1[i]}`);
i++; j++;
} else {
// 尝试找匹配
let found = false;
for (let look = 1; look <= 5 && i + look < lines1.length; look++) {
if (lines1[i + look] === lines2[j]) {
for (let k = 0; k < look; k++) diffLines.push(`-${lines1[i + k]}`);
i += look; found = true; break;
}
}
if (!found) {
for (let look = 1; look <= 5 && j + look < lines2.length; look++) {
if (lines1[i] === lines2[j + look]) {
for (let k = 0; k < look; k++) diffLines.push(`+${lines2[j + k]}`);
j += look; found = true; break;
}
}
}
if (!found) {
if (i < lines1.length) { diffLines.push(`-${lines1[i]}`); i++; }
if (j < lines2.length) { diffLines.push(`+${lines2[j]}`); j++; }
}
// 1) 优先使用系统 diff 命令
const diffResult = await runShell('diff', ['-u', `-U${contextSize}`, path1, path2]);
if (diffResult.code <= 1) { // diff exits 0=identical, 1=different
diffOutput = diffResult.stdout;
usedCommand = 'diff -u';
}
// 2) diff 不可用,尝试 git diff
if (!usedCommand) {
const gitResult = await runShell('git', ['diff', `--unified=${contextSize}`, '--no-index', path1, path2]);
if (gitResult.code <= 1) {
diffOutput = gitResult.stdout;
usedCommand = 'git diff';
}
}
const hasChanges = diffLines.some(l => l.startsWith('-') || l.startsWith('+'));
// 3) 回退到内置简易 diff
if (!usedCommand) {
diffOutput = builtinDiff(path1, path2, contextSize);
usedCommand = 'builtin';
}
const hasChanges = diffOutput.trim().length > 0;
return {
success: true,
file1: path1,
file2: path2,
diff: diffLines.join('\n'),
diff: diffOutput,
hasChanges,
lines1: lines1.length,
lines2: lines2.length
method: usedCommand
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
/** 内置简易 diff(回退方案) */
function builtinDiff(path1: string, path2: string, contextSize: number): string {
const fsSync = require('fs');
const content1 = fsSync.readFileSync(path1, 'utf-8');
const content2 = fsSync.readFileSync(path2, 'utf-8');
const lines1 = content1.split('\n');
const lines2 = content2.split('\n');
const diffLines: string[] = [];
let i = 0, j = 0;
while (i < lines1.length || j < lines2.length) {
if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) {
if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) {
const ctxStart = Math.max(0, i - contextSize);
for (let k = ctxStart; k < i; k++) diffLines.push(` ${lines1[k]}`);
}
diffLines.push(` ${lines1[i]}`);
i++; j++;
} else {
let found = false;
for (let look = 1; look <= 10 && i + look < lines1.length; look++) {
if (lines1[i + look] === lines2[j]) {
for (let k = 0; k < look; k++) diffLines.push(`-${lines1[i + k]}`);
i += look; found = true; break;
}
}
if (!found) {
for (let look = 1; look <= 10 && j + look < lines2.length; look++) {
if (lines1[i] === lines2[j + look]) {
for (let k = 0; k < look; k++) diffLines.push(`+${lines2[j + k]}`);
j += look; found = true; break;
}
}
}
if (!found) {
if (i < lines1.length) { diffLines.push(`-${lines1[i]}`); i++; }
if (j < lines2.length) { diffLines.push(`+${lines2[j]}`); j++; }
}
}
}
return diffLines.join('\n');
}
export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise<ToolResult> {
try {
const rootPath = resolvePath(params.path);
@@ -522,29 +581,29 @@ export async function handleReplaceInFiles(params: { path: string; glob: string;
export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise<ToolResult> {
try {
const maxChars = params.max_chars_per_file || 2000;
const results: Array<{ path: string; success: boolean; content?: string; error?: string; lines?: number }> = [];
for (const p of params.paths.slice(0, 20)) {
// 并行读取所有文件(最多20个)
const filesToRead = params.paths.slice(0, 20).map(async (p) => {
const filePath = resolvePath(p);
const allowed = checkPathAllowed(filePath, 'read');
if (!allowed.ok) {
results.push({ path: p, success: false, error: allowed.reason });
continue;
return { path: p, success: false, error: allowed.reason };
}
try {
const stat = await fs.stat(filePath);
if (stat.size > 1024 * 1024) {
results.push({ path: p, success: false, error: '文件超过 1MB' });
continue;
return { path: p, success: false, error: '文件超过 1MB' };
}
let content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').length;
if (content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)';
results.push({ path: p, success: true, content, lines });
return { path: p, success: true, content, lines };
} catch (err) {
results.push({ path: p, success: false, error: (err as Error).message });
return { path: p, success: false, error: (err as Error).message };
}
}
});
const results = await Promise.all(filesToRead);
return { success: true, files: results, total: results.length };
} catch (err) {