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
+225 -339
View File
@@ -1,19 +1,18 @@
/**
* browser — 浏览器交互工具集(9 个函数)
* web_browser — 统一浏览器交互工具
*
* 基于单例 BrowserWindowManager 实现网页加载、截图、JS 执行、
* 内容提取、点击、输入、滚动、等待、关闭等操作。
* 将 9 个浏览器操作合并为单个工具,通过 `action` 参数路由:
* 1. open — 打开 URL
* 2. screenshot — 截图(视口/元素/全页三模式)
* 3. evaluate — 执行 JavaScript
* 4. extract — 提取页面文本与链接
* 5. click — 点击元素
* 6. type — 输入文本
* 7. scroll — 滚动页面
* 8. wait — 等待条件
* 9. close — 关闭窗口
*
* 工具列表:
* 1. browser_open — 打开 URL
* 2. browser_screenshot — 截图(视口/元素/全页三模式)
* 3. browser_evaluate — 执行 JavaScript
* 4. browser_extract — 提取页面文本与链接
* 5. browser_click — 点击元素
* 6. browser_type — 输入文本
* 7. browser_scroll — 滚动页面
* 8. browser_wait — 等待条件
* 9. browser_close — 关闭窗口
* 基于单例 BrowserWindowManager 实现。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 4 章 browser 浏览器设计
*/
@@ -35,354 +34,241 @@ function getManager(): BrowserWindowManager {
return managerInstance;
}
/** 获取共享浏览器管理器单例(供 web_fetch 等工具复用) */
export function getBrowserManager(): BrowserWindowManager {
return getManager();
}
/** 应用退出时清理(由 main.ts 调用) */
export function cleanupBrowser(): void {
BrowserWindowManager.cleanup(managerInstance);
managerInstance = null;
}
// ===== 1. browser_open =====
// ===== WebBrowserTool =====
export class BrowserOpenTool implements IMetonaTool {
export class WebBrowserTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_open',
description: 'Open a URL in a hidden browser window. Loads the page and optionally waits for a selector to appear. Returns the page title and URL.',
name: 'web_browser',
description: [
'Control a hidden browser window to navigate, inspect, and interact with web pages.',
'Use the "action" parameter to specify the operation:',
' open — Load a URL; optionally wait for a CSS selector before returning. Returns page title and URL.',
' screenshot — Capture the viewport (default), full page, or a specific element by CSS selector. Returns base64 PNG.',
' evaluate — Execute JavaScript in the page context. Returns the result of the last expression.',
' extract — Extract clean text and up to 50 links from the page or a specific element.',
' click — Click an element by CSS selector. Scrolls into view first. Can optionally wait for the selector.',
' type — Type text into an input element. Optionally clear first and submit the form.',
' scroll — Scroll the page: down/up (500px), top/bottom, or scroll to an element by selector.',
' wait — Wait for a CSS selector to appear (with timeout) or a fixed duration.',
' close — Close the browser window and release resources.',
].join('\n'),
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Target URL (http/https only)' },
wait_selector: { type: 'string', description: 'CSS selector to wait for before returning (optional)' },
action: {
type: 'string',
enum: ['open', 'screenshot', 'evaluate', 'extract', 'click', 'type', 'scroll', 'wait', 'close'],
description: 'Browser operation to perform',
},
url: {
type: 'string',
description: '[open] Target URL (http/https only)',
},
wait_selector: {
type: 'string',
description: '[open] CSS selector to wait for before returning (optional)',
},
script: {
type: 'string',
description: '[evaluate] JavaScript code to execute (must return a value)',
},
selector: {
type: 'string',
description: '[screenshot|extract|click|type|scroll] CSS selector of the target element',
},
full_page: {
type: 'boolean',
description: '[screenshot] Capture entire scrollable page (default false)',
},
text: {
type: 'string',
description: '[type] Text to type into the element',
},
clear: {
type: 'boolean',
description: '[type] Clear the field before typing (default true)',
},
submit: {
type: 'boolean',
description: '[type] Submit the form after typing (default false)',
},
direction: {
type: 'string',
enum: ['down', 'up', 'top', 'bottom'],
description: '[scroll] Scroll direction (default down). Ignored if selector is provided.',
},
wait: {
type: 'boolean',
description: '[click] Wait for selector to appear before clicking (default false)',
},
time_ms: {
type: 'number',
description: '[wait] Fixed wait time in milliseconds (default 1000). Used as timeout when selector is also provided.',
},
},
required: ['url'],
required: ['action'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.MEDIUM,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 60_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const url = args.url as string;
if (!url || !/^https?:\/\//i.test(url)) {
return { success: false, error: 'URL must start with http:// or https://' };
const action = args.action as string;
if (!action) {
return { success: false, error: 'Missing required parameter: action' };
}
const waitSelector = args.wait_selector as string | undefined;
logTool('browser_open', `Opening: ${url}`);
logTool('web_browser', `action=${action}`);
try {
const result = await getManager().open({ url, waitSelector });
return { success: true, ...result };
} catch (err) {
return { success: false, error: (err as Error).message };
switch (action) {
// ===== open =====
case 'open': {
const url = args.url as string;
if (!url || !/^https?:\/\//i.test(url)) {
return { success: false, error: 'URL must start with http:// or https://' };
}
const waitSelector = args.wait_selector as string | undefined;
try {
const result = await getManager().open({ url, waitSelector });
return { success: true, action, ...result };
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== screenshot =====
case 'screenshot': {
const fullPage = (args.full_page as boolean) ?? false;
const selector = args.selector as string | undefined;
try {
const result = await getManager().screenshot({ fullPage, selector });
return {
success: true,
action,
image: result.data,
width: result.width,
height: result.height,
mime_type: 'image/png',
};
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== evaluate =====
case 'evaluate': {
const script = args.script as string;
if (!script) {
return { success: false, action, error: 'No script provided' };
}
try {
const result = await getManager().evaluate(script);
return { success: true, action, result };
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== extract =====
case 'extract': {
const selector = args.selector as string | undefined;
try {
const result = await getManager().extract(selector);
return {
success: true,
action,
text: result.text,
links: result.links,
link_count: result.links.length,
};
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== click =====
case 'click': {
const selector = args.selector as string;
const wait = (args.wait as boolean) ?? false;
if (!selector) {
return { success: false, action, error: 'No selector provided' };
}
try {
await getManager().click(selector, wait);
return { success: true, action, selector, clicked: true };
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== type =====
case 'type': {
const selector = args.selector as string;
const text = args.text as string;
const clear = (args.clear as boolean) ?? true;
const submit = (args.submit as boolean) ?? false;
if (!selector) {
return { success: false, action, error: 'No selector provided' };
}
if (text === undefined || text === null) {
return { success: false, action, error: 'No text provided' };
}
try {
await getManager().type(selector, text, { clear, submit });
return { success: true, action, selector, typed: text.length };
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== scroll =====
case 'scroll': {
const direction = (args.direction as string) ?? 'down';
const selector = args.selector as string | undefined;
try {
await getManager().scroll({
direction: direction as 'down' | 'up' | 'top' | 'bottom',
selector,
});
return { success: true, action, direction, selector };
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== wait =====
case 'wait': {
const selector = args.selector as string | undefined;
const timeMs = (args.time_ms as number) ?? 1_000;
try {
await getManager().wait({ selector, timeMs });
return { success: true, action, waited_for: selector ?? `${timeMs}ms` };
} catch (err) {
return { success: false, action, error: (err as Error).message };
}
}
// ===== close =====
case 'close': {
getManager().close();
return { success: true, action, closed: true };
}
default:
return { success: false, error: `Unknown action: ${action}` };
}
}
}
// ===== 2. browser_screenshot =====
export class BrowserScreenshotTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_screenshot',
description: 'Capture a screenshot of the current browser window. Three modes: viewport (default), full page, or specific element by CSS selector. Returns base64-encoded PNG image.',
parameters: {
type: 'object',
properties: {
full_page: { type: 'boolean', description: 'Capture entire scrollable page (default false)' },
selector: { type: 'string', description: 'CSS selector to capture a specific element (overrides full_page)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const fullPage = (args.full_page as boolean) ?? false;
const selector = args.selector as string | undefined;
logTool('browser_screenshot', `full_page=${fullPage}, selector=${selector ?? 'none'}`);
try {
const result = await getManager().screenshot({ fullPage, selector });
return {
success: true,
image: result.data,
width: result.width,
height: result.height,
mime_type: 'image/png',
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 3. browser_evaluate =====
export class BrowserEvaluateTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_evaluate',
description: 'Execute JavaScript code in the current browser page context. Returns the result of the last expression. Use for data extraction, DOM queries, or triggering page actions.',
parameters: {
type: 'object',
properties: {
script: { type: 'string', description: 'JavaScript code to execute (must return a value)' },
},
required: ['script'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const script = args.script as string;
if (!script) {
return { success: false, error: 'No script provided' };
}
logTool('browser_evaluate', `Executing ${script.length} chars of JS`);
try {
const result = await getManager().evaluate(script);
return { success: true, result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 4. browser_extract =====
export class BrowserExtractTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_extract',
description: 'Extract text content and links from the current browser page. Optionally target a specific element by CSS selector. Strips scripts, styles, and navigation elements. Returns clean text and up to 50 links.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector to extract from (default: entire body)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string | undefined;
logTool('browser_extract', `selector=${selector ?? 'body'}`);
try {
const result = await getManager().extract(selector);
return {
success: true,
text: result.text,
links: result.links,
link_count: result.links.length,
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 5. browser_click =====
export class BrowserClickTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_click',
description: 'Click an element on the page by CSS selector. Scrolls the element into view before clicking. Optionally wait for the selector to appear first.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector of the element to click' },
wait: { type: 'boolean', description: 'Wait for selector to appear before clicking (default false)' },
},
required: ['selector'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string;
const wait = (args.wait as boolean) ?? false;
if (!selector) {
return { success: false, error: 'No selector provided' };
}
logTool('browser_click', `selector=${selector}, wait=${wait}`);
try {
await getManager().click(selector, wait);
return { success: true, selector, clicked: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 6. browser_type =====
export class BrowserTypeTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_type',
description: 'Type text into an input element by CSS selector. Focuses the element, optionally clears it first, and dispatches input/change events for React/Vue compatibility. Can optionally submit the form.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector of the input element' },
text: { type: 'string', description: 'Text to type into the element' },
clear: { type: 'boolean', description: 'Clear the field before typing (default true)' },
submit: { type: 'boolean', description: 'Submit the form after typing (default false)' },
},
required: ['selector', 'text'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string;
const text = args.text as string;
const clear = (args.clear as boolean) ?? true;
const submit = (args.submit as boolean) ?? false;
if (!selector) {
return { success: false, error: 'No selector provided' };
}
if (text === undefined || text === null) {
return { success: false, error: 'No text provided' };
}
logTool('browser_type', `selector=${selector}, len=${text.length}, submit=${submit}`);
try {
await getManager().type(selector, text, { clear, submit });
return { success: true, selector, typed: text.length };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 7. browser_scroll =====
export class BrowserScrollTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_scroll',
description: 'Scroll the browser page. Modes: down/up (500px increment), top/bottom (absolute), or scroll to a specific element by CSS selector.',
parameters: {
type: 'object',
properties: {
direction: { type: 'string', description: 'Scroll direction: down, up, top, bottom (default down)' },
selector: { type: 'string', description: 'CSS selector to scroll to (overrides direction)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const direction = (args.direction as string) ?? 'down';
const selector = args.selector as string | undefined;
logTool('browser_scroll', `direction=${direction}, selector=${selector ?? 'none'}`);
try {
await getManager().scroll({ direction: direction as 'down' | 'up' | 'top' | 'bottom', selector });
return { success: true, direction, selector };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 8. browser_wait =====
export class BrowserWaitTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_wait',
description: 'Wait for a condition on the browser page. Either wait for a CSS selector to appear (with timeout) or wait for a fixed duration.',
parameters: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector to wait for (mutually exclusive with time_ms)' },
time_ms: { type: 'number', description: 'Fixed wait time in milliseconds (default 1000)' },
},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 30_000,
};
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const selector = args.selector as string | undefined;
const timeMs = (args.time_ms as number) ?? 1_000;
logTool('browser_wait', `selector=${selector ?? 'none'}, time_ms=${timeMs}`);
try {
await getManager().wait({ selector, timeMs });
return { success: true, waited_for: selector ?? `${timeMs}ms` };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
// ===== 9. browser_close =====
export class BrowserCloseTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'browser_close',
description: 'Close the current browser window and release resources. Call this when browser interaction is complete to free memory.',
parameters: {
type: 'object',
properties: {},
required: [],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
timeoutMs: 10_000,
};
async execute(_args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
logTool('browser_close', 'Closing browser window');
getManager().close();
return { success: true, closed: true };
}
}
// ===== 导出所有 Browser 工具 =====
export const browserTools: IMetonaTool[] = [
new BrowserOpenTool(),
new BrowserScreenshotTool(),
new BrowserEvaluateTool(),
new BrowserExtractTool(),
new BrowserClickTool(),
new BrowserTypeTool(),
new BrowserScrollTool(),
new BrowserWaitTool(),
new BrowserCloseTool(),
];
+18 -3
View File
@@ -9,10 +9,11 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { resolve } from 'path';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import { commandTouchesProtectedFile } from './file-guard';
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
const execAsync = promisify(exec);
@@ -42,7 +43,13 @@ export class RunCommandTool implements IMetonaTool {
const workdir = (args.workdir as string) ?? context.workspacePath;
const timeout = Math.min(300_000, Math.max(1_000, (args.timeout as number) ?? 120_000));
// 安全校验
// 安全校验workdir 必须在工作空间内
const resolvedWorkdir = resolve(context.workspacePath, workdir);
if (!isPathWithinWorkspace(workdir, context.workspacePath)) {
return { success: false, error: `Working directory must be within workspace: ${workdir}`, command };
}
// 命令安全校验
const validation = this.validateCommand(command);
if (!validation.allowed) {
return { success: false, error: validation.reason, command };
@@ -50,7 +57,7 @@ export class RunCommandTool implements IMetonaTool {
try {
const { stdout, stderr } = await execAsync(command, {
cwd: workdir,
cwd: resolvedWorkdir,
timeout,
maxBuffer: 1024 * 1024, // 1MB
env: { ...process.env, NODE_ENV: 'production' },
@@ -99,6 +106,14 @@ export class RunCommandTool implements IMetonaTool {
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
// Windows 危险命令
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' },
{ pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' },
{ pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' },
// 后台进程与管道炸弹
{ pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' },
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
];
for (const block of hardBlocks) {
@@ -0,0 +1,77 @@
/**
* 子任务委派工具
*
* 允许主 Agent 将子任务委派给独立的 SubAgent 执行。
* SubAgent 运行在独立的 AgentLoopEngine 实例中,可限制可用工具。
*
* @see electron/harness/orchestration/orchestrator.ts — TaskOrchestrator
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import type { TaskOrchestrator } from '../../orchestration/orchestrator';
export class DelegateTaskTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'delegate_task',
description: 'Delegate a sub-task to an independent SubAgent for parallel or isolated execution. The SubAgent runs its own ReAct loop and returns the final result. Use this for complex sub-tasks that benefit from focused reasoning.',
parameters: {
type: 'object',
properties: {
description: {
type: 'string',
description: 'Clear and detailed description of the sub-task to delegate. This will be the SubAgent\'s user message.',
},
tools: {
type: 'array',
items: { type: 'string' },
description: 'Optional whitelist of tool names the SubAgent can use (e.g. ["read_file", "web_search"]). If omitted, SubAgent inherits all tools.',
},
maxIterations: {
type: 'number',
description: 'Maximum iterations for the SubAgent (default: 10)',
},
},
required: ['description'],
},
category: MetonaToolCategory.CUSTOM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: false,
timeoutMs: 300_000, // 5 分钟,子任务可能较复杂
};
constructor(private orchestrator: TaskOrchestrator) {}
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const description = args.description as string;
const tools = args.tools as string[] | undefined;
const maxIterations = (args.maxIterations as number) ?? 10;
if (!description) {
return { success: false, error: 'Missing required parameter: description' };
}
try {
const result = await this.orchestrator.delegate({
description,
parentSessionId: context.sessionId,
maxIterations,
tools,
});
return {
success: result.success,
result: result.result,
durationMs: result.durationMs,
taskId: result.taskId,
iterations: result.iterations,
};
} catch (error) {
return {
success: false,
error: `SubAgent execution failed: ${(error as Error).message}`,
};
}
}
}
+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);
+2 -13
View File
@@ -8,16 +8,5 @@ export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from
export { WebSearchTool, WebFetchTool } from './network';
export { MemoryStoreTool, MemorySearchTool } from './memory';
export { RunCommandTool } from './command';
export {
BrowserOpenTool,
BrowserScreenshotTool,
BrowserEvaluateTool,
BrowserExtractTool,
BrowserClickTool,
BrowserTypeTool,
BrowserScrollTool,
BrowserWaitTool,
BrowserCloseTool,
browserTools,
cleanupBrowser,
} from './browser';
export { WebBrowserTool, cleanupBrowser, getBrowserManager } from './browser';
export { DelegateTaskTool } from './delegate-task';
+4 -6
View File
@@ -23,8 +23,7 @@ export class MemoryStoreTool implements IMetonaTool {
content: { type: 'string', description: 'The memory content to store' },
type: { type: 'string', description: 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', enum: ['episodic', 'semantic', 'working'] },
importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' },
source: { type: 'string', description: 'Source identifier (default "agent")' },
tags: { type: 'array', description: 'Tag list for categorization', items: { type: 'string', description: 'Tag' } },
source: { type: 'string', description: 'Source of the memory', enum: ['user_input', 'tool_result', 'agent_thought', 'imported'] },
},
required: ['content', 'type'],
},
@@ -40,13 +39,12 @@ export class MemoryStoreTool implements IMetonaTool {
const content = args.content as string;
const type = args.type as 'episodic' | 'semantic' | 'working';
const importance = (args.importance as number) ?? 0.5;
const source = (args.source as string) ?? 'agent';
const tags = (args.tags as string[]) ?? [];
const source = (args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ?? 'agent_thought';
const id = await this.memoryManager.store({
type,
content,
source: source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported',
source,
importance,
sessionId: context.sessionId,
});
@@ -67,7 +65,7 @@ export class MemorySearchTool implements IMetonaTool {
query: { type: 'string', description: 'Search query or keywords' },
type: { type: 'string', description: 'Filter by memory type', enum: ['episodic', 'semantic', 'working'] },
topK: { type: 'number', description: 'Number of results (default 5)' },
threshold: { type: 'number', description: 'Minimum relevance score 0-1 (default 0.7)' },
threshold: { type: 'number', description: 'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.' },
},
required: ['query'],
},
+36 -46
View File
@@ -4,12 +4,14 @@
* 三阶段回退策略:
* Phase 1: HTTP 抓取(UA 轮换 + 反爬请求头 + 指数退避重试 + 拦截检测)
* Phase 2: 内容过短自动升级(< 200 字符 → 浏览器渲染)
* Phase 3: 浏览器回退(隐藏 BrowserWindow + JS 渲染 + 内容提取)
* Phase 3: 浏览器回退(共享 BrowserWindowManager + JS 渲染 + 内容提取)
*
* 浏览器回退使用与 web_browser 相同的 BrowserWindowManager 单例,
* 避免创建多个独立浏览器窗口,支持窗口复用。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
*/
import { BrowserWindow } from 'electron';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
@@ -22,6 +24,7 @@ import {
readBodyWithLimit,
logTool,
} from './network-utils';
import { getBrowserManager } from './browser';
// ===== 跳过重试的状态码 =====
@@ -57,6 +60,13 @@ export class WebFetchTool implements IMetonaTool {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
}
// 先查缓存(HTTP 和浏览器阶段共享同一缓存)
const cached = fetchCache.get(url);
if (cached) {
logTool('web_fetch', `Cache hit: ${url}`);
return { url, content: cached, success: true, method: 'cache', length: cached.length };
}
logTool('web_fetch', `Fetching: ${url}`);
// ===== Phase 1: HTTP 抓取 =====
@@ -71,6 +81,8 @@ export class WebFetchTool implements IMetonaTool {
return this.buildSuccess(url, browserResult, 'browser');
}
}
// 写入缓存
fetchCache.set(url, phase1Result.text);
return this.buildSuccess(url, phase1Result.text, 'http');
}
@@ -144,7 +156,7 @@ export class WebFetchTool implements IMetonaTool {
return { success: false, text: '', intercepted: false, reason: 'All retries exhausted' };
}
// ===== Phase 2/3: 浏览器回退 =====
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
private async browserFetch(url: string): Promise<string | null> {
// 查缓存
@@ -154,59 +166,50 @@ export class WebFetchTool implements IMetonaTool {
return cached;
}
let win: BrowserWindow | null = null;
let cleanup: (() => void) | null = null;
try {
win = new BrowserWindow({
width: 1280,
height: 800,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: false, // 允许跨域(截图需要)
},
});
const manager = getBrowserManager();
cleanup = () => {
try {
if (win && !win.isDestroyed()) win.close();
} catch {
// 忽略关闭错误
}
};
await this.loadURLWithTimeout(win, url, 30_000);
// 通过 manager 打开 URL(复用已打开的同 URL 窗口,避免重复创建)
await manager.open({ url });
// 等待 JS 渲染
await this.sleep(2_500);
// 提取页面正文
const text = await win.webContents.executeJavaScript(`
const text = await manager.evaluate(`
(function() {
var clone = document.body.cloneNode(true);
var noise = clone.querySelectorAll('script, style, noscript, nav, header, footer, aside, iframe, svg');
noise.forEach(function(el) { el.remove(); });
return clone.innerText || '';
})();
`, true);
`) as string;
if (text && text.trim().length >= 80) {
// 拦截检测(浏览器渲染后仍可能是验证码挑战页)
if (isInterceptedPage(text)) {
logTool('web_fetch', `Browser fetch detected intercepted page: ${url}`);
return null;
}
// 内容大小限制(与 HTTP 阶段一致,防止超大页面耗尽上下文)
const MAX_BROWSER_TEXT = 500_000; // 500K chars
const safeText = text.length > MAX_BROWSER_TEXT
? text.slice(0, MAX_BROWSER_TEXT) + '\n\n[... content truncated ...]'
: text;
// 写缓存
fetchCache.set(url, text);
logTool('web_fetch', `Browser fetch success: ${text.length} chars`);
return text;
fetchCache.set(url, safeText);
logTool('web_fetch', `Browser fetch success: ${safeText.length} chars`);
return safeText;
}
return null;
} catch (err) {
logTool('web_fetch', `Browser fetch failed: ${(err as Error).message}`);
return null;
} finally {
if (cleanup) cleanup();
}
// 注意:不关闭窗口 — manager 是单例,窗口由 web_browser 或 cleanupBrowser 管理
}
// ===== 辅助方法 =====
@@ -224,17 +227,4 @@ export class WebFetchTool implements IMetonaTool {
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 带超时的 loadURLElectron 原生不支持 timeout 选项) */
private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`Page load timeout after ${timeoutMs}ms: ${url}`)), timeoutMs);
});
try {
await Promise.race([win.loadURL(url), timeoutPromise]);
} finally {
if (timer) clearTimeout(timer);
}
}
}
+28 -28
View File
@@ -20,6 +20,7 @@ import {
buildSearXNGAuthHeaders,
logTool,
} from './network-utils';
import type { WebFetchTool } from './web-fetch';
// ===== 类型定义 =====
@@ -240,7 +241,10 @@ export class WebSearchTool implements IMetonaTool {
timeoutMs: 300_000,
};
constructor(private configService: ConfigService) {}
constructor(
private configService: ConfigService,
private webFetchTool: WebFetchTool,
) {}
async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
const query = args.query as string;
@@ -463,7 +467,7 @@ export class WebSearchTool implements IMetonaTool {
return Array.from(seen.values());
}
// ===== 摘要增强 =====
// ===== 摘要增强(委托给 WebFetchTool =====
private async enhanceSnippets(results: SearchResult[], maxEnhance: number): Promise<void> {
let enhanced = 0;
@@ -471,14 +475,13 @@ export class WebSearchTool implements IMetonaTool {
if (enhanced >= maxEnhance) break;
if (r.snippet.length < 30 && r.reachable) {
try {
const resp = await fetchWithTimeout(r.url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36' },
}, 5_000);
if (resp.ok) {
const html = await resp.text();
// 延迟导入 htmlToText 以避免循环依赖
const { htmlToText } = await import('./network-utils');
const text = htmlToText(html).slice(0, 200);
const fetchResult = await this.webFetchTool.execute(
{ url: r.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string };
if (fetchResult.success && fetchResult.content) {
const text = fetchResult.content.slice(0, 200);
if (text.length > r.snippet.length) {
r.snippet = text;
r._enhanced = true;
@@ -492,7 +495,7 @@ export class WebSearchTool implements IMetonaTool {
}
}
// ===== 自动抓取完整内容 =====
// ===== 自动抓取完整内容(委托给 WebFetchTool =====
private async autoFetch(
query: string,
@@ -524,21 +527,17 @@ export class WebSearchTool implements IMetonaTool {
}
const fetched: Array<{ url: string; title: string; content: string }> = [];
const { htmlToText } = await import('./network-utils');
for (const item of toFetch) {
try {
const resp = await fetchWithTimeout(item.result.url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
}, 15_000);
// 委托给 WebFetchTool — 享受三阶段回退策略(HTTP + 反爬 + 浏览器渲染)
const fetchResult = await this.webFetchTool.execute(
{ url: item.result.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string; method?: string };
if (resp.ok) {
const html = await resp.text();
const content = htmlToText(html);
fetched.push({ url: item.result.url, title: item.result.title, content });
if (fetchResult.success && fetchResult.content) {
fetched.push({ url: item.result.url, title: item.result.title, content: fetchResult.content });
}
} catch (err) {
logTool('web_search', `Auto-fetch failed for ${item.result.url}: ${(err as Error).message}`);
@@ -547,12 +546,13 @@ export class WebSearchTool implements IMetonaTool {
if (remaining.length > 0) {
const randomPick = remaining[Math.floor(Math.random() * remaining.length)];
try {
const resp2 = await fetchWithTimeout(randomPick.result.url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36' },
}, 10_000);
if (resp2.ok) {
const html2 = await resp2.text();
fetched.push({ url: randomPick.result.url, title: randomPick.result.title, content: htmlToText(html2) });
const fetchResult2 = await this.webFetchTool.execute(
{ url: randomPick.result.url },
{ sessionId: '', workspacePath: '', iteration: 0, requestId: '' },
) as { success: boolean; content?: string };
if (fetchResult2.success && fetchResult2.content) {
fetched.push({ url: randomPick.result.url, title: randomPick.result.title, content: fetchResult2.content });
}
} catch {
// 忽略补充失败