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
@@ -172,7 +172,7 @@ export class DeepSeekAdapter extends BaseAdapter {
} else if (request.params.thinkingEnabled) {
body.thinking = { type: 'enabled' };
const effortMap: Record<string, string> = {
low: 'high', medium: 'high', high: 'high', xhigh: 'max', max: 'max',
low: 'high', medium: 'high', high: 'high', max: 'max',
};
// DeepSeek API 仅支持 high / max 两档,low/medium 映射为 high
body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high';
+22 -10
View File
@@ -56,7 +56,7 @@ export class OllamaAdapter extends BaseAdapter {
}
const data = await response.json() as Record<string, unknown>;
return this.toMetonaResponse(data, request.meta.requestId);
return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration);
}
async *chatStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
@@ -66,7 +66,7 @@ export class OllamaAdapter extends BaseAdapter {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...nativeRequest, stream: true }),
signal: AbortSignal.timeout(300_000),
signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000),
});
if (!response.ok || !response.body) {
@@ -77,9 +77,7 @@ export class OllamaAdapter extends BaseAdapter {
const decoder = new TextDecoder();
let seq = 0;
let buffer = '';
// 工具调用缓冲区
const toolCallsBuffer = new Map<number, { name: string; argsBuffer: string }>();
let streamEndedNormally = false;
while (true) {
const { done, value } = await reader.read();
@@ -147,6 +145,7 @@ export class OllamaAdapter extends BaseAdapter {
// 流结束
if (chunk.done) {
streamEndedNormally = true;
// 发送 usage 信息
yield {
type: MetonaStreamEventType.USAGE,
@@ -177,6 +176,18 @@ export class OllamaAdapter extends BaseAdapter {
}
}
}
// 流未正常结束(连接断开等),补发 DONE 事件防止 Agent Loop 挂起
if (!streamEndedNormally) {
yield {
type: MetonaStreamEventType.DONE,
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
iteration: request.meta.iteration,
seq: seq++,
timestamp: Date.now(),
};
}
}
// ===== POST /api/generate =====
@@ -281,6 +292,7 @@ export class OllamaAdapter extends BaseAdapter {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, stream: true }),
signal: AbortSignal.timeout(600_000), // 模型下载可能较慢,10 分钟超时
});
if (!response.ok || !response.body) throw new Error(`Ollama pull error: ${response.status}`);
@@ -394,9 +406,9 @@ export class OllamaAdapter extends BaseAdapter {
options: {
temperature: request.params.temperature,
num_predict: request.params.maxTokens,
top_p: request.params.topP,
stop: request.params.stopSequences,
num_ctx: request.params.contextLength,
...(request.params.topP != null && { top_p: request.params.topP }),
...(request.params.stopSequences?.length && { stop: request.params.stopSequences }),
...(request.params.contextLength != null && { num_ctx: request.params.contextLength }),
},
};
@@ -421,7 +433,7 @@ export class OllamaAdapter extends BaseAdapter {
return body;
}
private toMetonaResponse(data: Record<string, unknown>, requestId: string): MetonaResponse {
private toMetonaResponse(data: Record<string, unknown>, requestId: string, iteration: number = 0): MetonaResponse {
const message = data.message as Record<string, unknown> | undefined;
const toolCalls = message?.tool_calls as Array<Record<string, unknown>> | undefined;
return {
@@ -455,7 +467,7 @@ export class OllamaAdapter extends BaseAdapter {
id: `tc_${Date.now()}_${i}`,
name: (fn?.name as string) ?? '',
args,
iteration: 0,
iteration,
timestamp: Date.now(),
};
}),
@@ -223,11 +223,16 @@ export function parseOpenAICompatibleResponse(
toolCalls: rawToolCalls?.map((tc) => {
const fn = tc.function as Record<string, unknown>;
let args: Record<string, unknown> = {};
const rawArgs = fn?.arguments;
if (typeof rawArgs === 'string') {
try {
args = JSON.parse(fn.arguments as string);
args = JSON.parse(rawArgs);
} catch {
args = {};
}
} else if (rawArgs && typeof rawArgs === 'object') {
args = rawArgs as Record<string, unknown>;
}
return {
id: tc.id as string,
name: fn.name as string,
+134 -25
View File
@@ -34,6 +34,7 @@ import type {
MetonaToolDef,
} from '../types';
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
import log from 'electron-log';
const DEFAULT_CONFIG: AgentLoopConfig = {
maxIterations: 20,
@@ -224,6 +225,16 @@ export class AgentLoopEngine extends EventEmitter {
}
}
/** 获取当前 Provider Adapter(供 SubAgent 创建独立引擎实例) */
getAdapter(): IMetonaProviderAdapter {
return this.adapter;
}
/** 获取工作空间路径(供 SubAgent 继承) */
getWorkspacePath(): string {
return this.workspacePath;
}
/** 中断循环 */
abort(): void {
this.aborted = true;
@@ -253,12 +264,8 @@ export class AgentLoopEngine extends EventEmitter {
try {
// === THINKING: 流式调用 LLM ===
// transitionTo 已发射 stateChange 事件,无需重复 emit
await this.transitionTo(AgentLoopState.THINKING);
this.emit('stateChange', {
sessionId,
iteration: this.currentIteration,
state: 'THINKING',
});
let fullContent = '';
let reasoningContent = '';
@@ -362,19 +369,14 @@ export class AgentLoopEngine extends EventEmitter {
// === EXECUTING: 执行工具调用 ===
if (step.toolCalls && step.toolCalls.length > 0) {
// transitionTo 已发射 stateChange 事件,无需重复 emit
await this.transitionTo(AgentLoopState.EXECUTING);
this.emit('stateChange', {
sessionId,
iteration: this.currentIteration,
state: 'EXECUTING',
});
step.toolResults = [];
for (const tc of step.toolCalls) {
// 并行执行所有工具调用(独立工具之间无依赖,可安全并发)
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
const result = await this.executeToolSafely(tc);
step.toolResults.push(result);
// 转发工具结果到渲染进程
// 立即转发工具结果到渲染进程(不等其他工具完成)
this.emit('streamEvent', {
type: MetonaStreamEventType.TOOL_RESULT,
requestId: request.meta.requestId,
@@ -384,25 +386,33 @@ export class AgentLoopEngine extends EventEmitter {
timestamp: Date.now(),
toolResult: result,
});
}
return result;
};
// Promise.all 保持结果顺序与 toolCalls 一致
step.toolResults = await Promise.all(
step.toolCalls.map((tc) => executeAndForward(tc)),
);
}
// === OBSERVING ===
await this.transitionTo(AgentLoopState.OBSERVING);
// === 上下文压缩(每 5 轮检查一次 ===
if (this.currentIteration % 5 === 0) {
// === 上下文压缩(基于 token 使用率触发 ===
const estimatedTokens = this.estimateMessagesTokens(request.messages);
const compressionThreshold = this.config.compressionThreshold * this.config.contextWindow;
if (estimatedTokens > compressionThreshold && request.messages.length > 10) {
await this.transitionTo(AgentLoopState.COMPRESSING);
this.emit('stateChange', {
sessionId,
const compressed = await this.compressMessages(request.messages);
if (compressed) {
// 原地替换数组内容,确保外层 messages 引用同步更新
request.messages.splice(0, request.messages.length, ...compressed);
this.emit('compressed', {
iteration: this.currentIteration,
state: 'COMPRESSING',
originalTokens: estimatedTokens,
compressedTokens: this.estimateMessagesTokens(compressed),
});
// 在 context 中标记压缩点
const keepRecent = 3;
if (this.iterations.length > keepRecent) {
this.emit('compressed', { iteration: this.currentIteration, keptRounds: keepRecent });
}
}
@@ -492,6 +502,95 @@ export class AgentLoopEngine extends EventEmitter {
});
}
/**
* 估算消息列表的 token 数(粗略:1 字符 ≈ 0.5 token
*/
private estimateMessagesTokens(messages: MetonaMessage[]): number {
let total = 0;
for (const msg of messages) {
total += Math.ceil(msg.content.length / 2);
if (msg.reasoningContent) total += Math.ceil(msg.reasoningContent.length / 2);
if (msg.toolCalls) {
for (const tc of msg.toolCalls) {
total += Math.ceil(JSON.stringify(tc.args).length / 2);
}
}
}
return total;
}
/**
* 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话
*
* 策略:
* 1. 保留最后 keepRecent 条消息(约 3 轮对话)
* 2. 将前面的所有消息交给 LLM 生成摘要
* 3. 用 [Context Summary] system 消息 + 近期消息替换原数组
*
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组)
*/
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool
if (messages.length <= keepRecent) return null;
const toCompress = messages.slice(0, messages.length - keepRecent);
const toKeep = messages.slice(messages.length - keepRecent);
// 构建摘要请求
const conversationText = toCompress.map((m) => {
const role = m.role.toUpperCase();
const content = m.content.slice(0, 500); // 每条消息最多 500 字符
return `[${role}] ${content}`;
}).join('\n\n');
const summaryRequest: MetonaRequest = {
meta: {
sessionId: this.currentSessionId,
iteration: this.currentIteration,
requestId: `r_${nanoid(12)}`,
timestamp: Date.now(),
agentVersion: '1.0.0',
},
systemPrompt: {
roleDefinition: 'You are a conversation summarizer.',
outputConstraints: 'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.',
safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.',
},
messages: [{
role: 'user',
content: `Please summarize the following conversation history:\n\n${conversationText}`,
timestamp: Date.now(),
}],
params: {
maxTokens: 2048,
temperature: 0.0,
stream: false,
thinkingEnabled: false,
thinkingEffort: 'low',
},
};
try {
const response = await this.adapter.chat(summaryRequest);
const summary = response.content.trim();
if (!summary) return null;
const summaryMessage: MetonaMessage = {
role: 'system',
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
timestamp: Date.now(),
};
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`);
return [summaryMessage, ...toKeep];
} catch (error) {
log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message);
return null;
}
}
private accumulateTokens(usage: TokenUsage): void {
this.totalTokens.promptTokens += usage.promptTokens;
this.totalTokens.completionTokens += usage.completionTokens;
@@ -514,6 +613,16 @@ export class AgentLoopEngine extends EventEmitter {
});
this.currentState = AgentLoopState.TERMINATED;
// 发射 complete 事件(供托盘通知等外部监听器使用)
this.emit('complete', {
sessionId: this.currentSessionId,
durationMs: Date.now() - this.startTime,
terminationReason: reason,
iterations: this.iterations.length,
totalTokens: this.totalTokens.totalTokens,
});
return {
finalAnswer: answer ?? (error ? error.message : 'No answer produced'),
terminationReason: reason,
+6 -2
View File
@@ -34,7 +34,11 @@ export class AuditLogHook implements PostToolHook {
/** 记忆触发钩子 */
export class MemoryTriggerHook implements PostToolHook {
private memorableTools = ['web_search', 'read_file', 'memory_search'];
/** 仅对搜索类工具触发记忆,避免每次 read_file 都产生低价值记忆条目 */
private memorableTools = ['web_search', 'memory_search'];
/** 单次工具结果存储上限(字符),防止过大内容淹没记忆系统 */
private readonly MAX_MEMORY_CONTENT = 500;
constructor(private memoryManager: MemoryManager) {}
@@ -44,7 +48,7 @@ export class MemoryTriggerHook implements PostToolHook {
try {
this.memoryManager.store({
type: 'episodic',
content: `Tool ${toolCall.name} returned: ${content.slice(0, 500)}`,
content: `Tool ${toolCall.name} returned: ${content.slice(0, this.MAX_MEMORY_CONTENT)}`,
source: 'tool_result',
sessionId,
importance: 0.6,
+147 -58
View File
@@ -2,17 +2,26 @@
* Task Orchestrator — 任务编排器
*
* 支持父子委派模式:主 Agent 委派子任务给 SubAgent。
* 每个子任务运行一个独立的 AgentLoopEngine 实例。
* 每个 SubAgent 运行在**独立的 AgentLoopEngine 实例**中,避免状态污染
*
* 安全保障:
* 1. 独立引擎实例 — SubAgent 不共享主 Agent 的引擎状态
* 2. 递归深度限制 — 默认最大 3 层,防止无限递归
* 3. 工具白名单隔离 — SubAgent 默认不继承 delegate_task(防止递归)
* 4. 真正的 abort — 通过引擎引用调用 engine.abort()
* 5. 事件隔离 — SubAgent 的流式事件不直接转发到前端,仅通过 orchestrator 事件通知
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
*/
import { EventEmitter } from 'events';
import { nanoid } from 'nanoid';
import type { AgentLoopEngine } from '../agent-loop/engine';
import type { MetonaMessage, MetonaSystemPrompt } from '../types';
import { AgentLoopEngine } from '../agent-loop/engine';
import type { AgentLoopConfig } from '../agent-loop/types';
import type { MetonaMessage, MetonaSystemPrompt, MetonaToolDef } from '../types';
import type { ToolRegistry } from '../tools/registry';
import type { MetonaToolDef } from '../types';
import type { PreToolHook } from '../hooks/pre-tool';
import type { PostToolHook } from '../hooks/post-tool';
import log from 'electron-log';
export interface SubAgentResult {
@@ -20,25 +29,34 @@ export interface SubAgentResult {
result: string;
success: boolean;
durationMs: number;
iterations: number;
}
interface SubAgentHandle {
taskId: string;
description: string;
status: 'pending' | 'running' | 'completed' | 'error';
depth: number;
engine?: AgentLoopEngine;
result?: SubAgentResult;
abort: () => void;
onComplete: (callback: (result: SubAgentResult) => void) => void;
onError: (callback: (error: Error) => void) => void;
getStatus: () => { taskId: string; status: string; description: string };
getStatus: () => { taskId: string; status: string; description: string; depth: number };
}
/** 默认递归深度限制 */
const MAX_DELEGATION_DEPTH = 3;
export class TaskOrchestrator extends EventEmitter {
private activeSubAgents = new Map<string, SubAgentHandle>();
/** 追踪每个 session 的当前委派深度 */
private sessionDepth = new Map<string, number>();
constructor(
private agentLoopEngine: AgentLoopEngine,
private mainEngine: AgentLoopEngine,
private toolRegistry?: ToolRegistry,
private preToolHooks: PreToolHook[] = [],
private postToolHooks: PostToolHook[] = [],
private defaultConfig?: Partial<AgentLoopConfig>,
) {
super();
}
@@ -46,8 +64,8 @@ export class TaskOrchestrator extends EventEmitter {
/**
* 委派子任务
*
* 创建一个独立的 SubAgent 执行上下文,调用 AgentLoopEngine 完成任务。
* 支持限制可用工具列表(白名单)
* 创建一个独立的 AgentLoopEngine 实例执行子任务。
* SubAgent 不共享主 Agent 的引擎状态,安全隔离
*/
async delegate(params: {
taskId?: string;
@@ -59,25 +77,60 @@ export class TaskOrchestrator extends EventEmitter {
const taskId = params.taskId ?? `sub_${nanoid(8)}`;
const startMs = Date.now();
this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId });
// ===== 递归深度检查 =====
const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0;
if (currentDepth >= MAX_DELEGATION_DEPTH) {
log.warn(`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`);
return {
taskId,
result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`,
success: false,
durationMs: 0,
iterations: 0,
};
}
const depth = currentDepth + 1;
this.sessionDepth.set(params.parentSessionId, depth);
this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId, depth });
// ===== 创建独立的引擎实例 =====
const subEngine = new AgentLoopEngine(
{
maxIterations: params.maxIterations ?? 10,
totalTimeoutMs: 300_000, // 子任务总超时 5 分钟
thinkingEnabled: this.defaultConfig?.thinkingEnabled ?? true,
thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium',
contextLength: this.defaultConfig?.contextLength,
contextWindow: this.defaultConfig?.contextWindow ?? 128_000,
},
this.mainEngine.getAdapter(),
this.toolRegistry,
this.preToolHooks,
this.postToolHooks,
);
subEngine.setWorkspacePath(this.mainEngine.getWorkspacePath());
// ===== 工具白名单设置 =====
const allowedTools = this.resolveTools(params.tools);
subEngine.setTools(allowedTools);
const handle: SubAgentHandle = {
taskId,
description: params.description,
status: 'running',
depth,
engine: subEngine,
abort: () => {
subEngine.abort();
handle.status = 'error';
this.activeSubAgents.delete(taskId);
},
onComplete: (callback) => {
if (handle.result) callback(handle.result);
},
onError: (callback) => { /* errors captured by try/catch below */ },
getStatus: () => ({ taskId, status: handle.status, description: handle.description }),
getStatus: () => ({ taskId, status: handle.status, description: handle.description, depth: handle.depth }),
};
this.activeSubAgents.set(taskId, handle);
this.emit('taskStarted', { taskId });
this.emit('taskStarted', { taskId, depth });
try {
// 构建用户消息
@@ -87,49 +140,16 @@ export class TaskOrchestrator extends EventEmitter {
timestamp: Date.now(),
};
// 构建 System Prompt(子 Agent 简化版
const systemPrompt: MetonaSystemPrompt = {
roleDefinition: '你是一个子任务执行 Agent,负责完成被委派的单一任务。',
outputConstraints: '用中文回答,简洁准确地完成任务。',
safetyGuidelines: '不访问工作空间外的文件,不执行危险命令。',
};
// 保存原始工具列表(子任务完成后恢复)
const savedTools = this.agentLoopEngine.getTools?.() ?? [];
// 如果指定了工具白名单,设置受限工具集
if (params.tools && params.tools.length > 0 && this.toolRegistry) {
const allowedTools: MetonaToolDef[] = [];
for (const toolName of params.tools) {
const tool = this.toolRegistry.get(toolName);
if (tool) {
allowedTools.push({
name: tool.definition.name,
description: tool.definition.description,
parameters: tool.definition.parameters,
category: tool.definition.category,
riskLevel: tool.definition.riskLevel,
requiresPermission: tool.definition.requiresPermission,
timeoutMs: tool.definition.timeoutMs,
});
}
}
this.agentLoopEngine.setTools(allowedTools);
}
// 构建 System Prompt(子 Agent 专用
const systemPrompt = this.buildSubAgentPrompt(params.description, depth);
// 运行 Agent Loop(同步等待完成)
let output;
try {
output = await this.agentLoopEngine.runStream(
const output = await subEngine.runStream(
userMessage,
taskId,
[], // 子 Agent 无历史
systemPrompt,
);
} finally {
// 恢复主 Agent 的原始工具列表
this.agentLoopEngine.setTools(savedTools);
}
const durationMs = Date.now() - startMs;
const success = output.terminationReason === 'completed';
@@ -138,14 +158,16 @@ export class TaskOrchestrator extends EventEmitter {
result: output.finalAnswer,
success,
durationMs,
iterations: output.iterations.length,
};
handle.status = success ? 'completed' : 'error';
handle.result = result;
this.activeSubAgents.delete(taskId);
this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度
this.emit('taskCompleted', result);
log.info(`[Orchestrator] SubAgent "${taskId}" ${success ? 'completed' : 'failed'} in ${durationMs}ms`);
log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`);
return result;
} catch (error) {
@@ -156,40 +178,107 @@ export class TaskOrchestrator extends EventEmitter {
result: errMsg,
success: false,
durationMs,
iterations: 0,
};
handle.status = 'error';
handle.result = result;
this.activeSubAgents.delete(taskId);
this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度
this.emit('taskError', { taskId, error: errMsg });
log.error(`[Orchestrator] SubAgent "${taskId}" error: ${errMsg}`);
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
return result;
}
}
/**
* 完成子任务(外部触发)
* 解析工具白名单
*
* - 如果指定了 tools,使用白名单(自动排除 delegate_task 防止递归)
* - 如果未指定,使用所有已启用工具(同样排除 delegate_task
*/
private resolveTools(toolNames?: string[]): MetonaToolDef[] {
if (!this.toolRegistry) return [];
// 始终排除 delegate_task 防止递归(除非深度为 1 且显式要求)
const EXCLUDE_TOOLS = new Set(['delegate_task']);
if (toolNames && toolNames.length > 0) {
// 使用白名单模式
const resolved: MetonaToolDef[] = [];
const notFound: string[] = [];
for (const name of toolNames) {
if (EXCLUDE_TOOLS.has(name)) continue; // 静默排除
const tool = this.toolRegistry.get(name);
if (tool) {
resolved.push(tool.definition);
} else {
notFound.push(name);
}
}
if (notFound.length > 0) {
log.warn(`[Orchestrator] Tools not found: ${notFound.join(', ')}`);
}
return resolved;
}
// 未指定白名单 — 使用所有已启用工具(排除 delegate_task
return this.toolRegistry.listTools().filter((t) => !EXCLUDE_TOOLS.has(t.name));
}
/**
* 构建 SubAgent 的 System Prompt
*/
private buildSubAgentPrompt(description: string, depth: number): MetonaSystemPrompt {
return {
roleDefinition: `You are a SubAgent (delegation depth: ${depth}) executing a specific sub-task delegated by the parent Agent.\nYour goal is to complete the assigned task efficiently and return a clear, concise result.\nFocus only on the task at hand. Do not delegate further.`,
outputConstraints: `Complete the task and provide a clear summary of your findings or actions.\nRespond in the same language as the task description.\nKeep your response focused and relevant — the parent Agent will use your result to continue its work.`,
safetyGuidelines: `Do not access files outside the workspace.\nDo not execute dangerous commands.\nIf the task cannot be completed, explain why clearly.`,
};
}
/**
* 中断指定子任务
*/
abortTask(taskId: string): boolean {
const handle = this.activeSubAgents.get(taskId);
if (handle && handle.status === 'running') {
handle.abort();
return true;
}
return false;
}
/**
* 完成子任务(外部触发,保留接口兼容)
*/
completeTask(taskId: string, result: string, success: boolean): void {
const handle = this.activeSubAgents.get(taskId);
if (handle && handle.status === 'running') {
handle.status = success ? 'completed' : 'error';
handle.result = { taskId, result, success, durationMs: 0 };
handle.result = { taskId, result, success, durationMs: 0, iterations: 0 };
this.activeSubAgents.delete(taskId);
this.emit('taskCompleted', handle.result);
}
}
getActiveAgentsStatus(): Array<{ taskId: string; status: string; description: string }> {
getActiveAgentsStatus(): Array<{ taskId: string; status: string; description: string; depth: number }> {
return Array.from(this.activeSubAgents.values()).map((a) => a.getStatus());
}
/**
* 中断所有子任务
*/
abortAll(): void {
for (const agent of this.activeSubAgents.values()) {
agent.abort();
}
this.activeSubAgents.clear();
this.sessionDepth.clear();
}
}
+3 -10
View File
@@ -31,16 +31,9 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
// Browser 浏览器工具
{ toolName: 'browser_open', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 10 },
{ toolName: 'browser_screenshot', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
{ toolName: 'browser_evaluate', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 10 },
{ toolName: 'browser_extract', requiredLevel: PermissionLevel.READ },
{ toolName: 'browser_click', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 15 },
{ toolName: 'browser_type', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 15 },
{ toolName: 'browser_scroll', requiredLevel: PermissionLevel.READ },
{ toolName: 'browser_wait', requiredLevel: PermissionLevel.READ },
{ toolName: 'browser_close', requiredLevel: PermissionLevel.READ },
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
// 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION
{ toolName: 'web_browser', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
];
export class PolicyEngine {
+9 -6
View File
@@ -6,6 +6,8 @@
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
*/
import { resolve, sep } from 'path';
export interface SandboxConfig {
allowedPaths?: string[];
networkPolicy?: 'allowall' | 'deny-all' | 'allowlist';
@@ -45,21 +47,22 @@ export class SandboxManager {
/**
* 校验文件路径是否在白名单内
*
* 安全策略:fail-closed — 如果未配置任何白名单路径,拒绝所有访问。
*/
validatePath(requestedPath: string): { allowed: boolean; resolvedPath: string; reason?: string } {
const { resolve } = require('path');
const resolved = resolve(requestedPath);
if (requestedPath.includes('..')) {
return { allowed: false, resolvedPath: resolved, reason: 'Path traversal detected' };
if (this.allowedPaths.size === 0) {
return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' };
}
if (this.allowedPaths.size > 0) {
const isAllowed = Array.from(this.allowedPaths).some((allowed) => resolved.startsWith(allowed));
const isAllowed = Array.from(this.allowedPaths).some(
(allowed) => resolved === allowed || resolved.startsWith(allowed + sep),
);
if (!isAllowed) {
return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' };
}
}
return { allowed: true, resolvedPath: resolved };
}
+141 -255
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',
},
required: ['url'],
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: ['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 action = args.action as string;
if (!action) {
return { success: false, error: 'Missing required parameter: action' };
}
logTool('web_browser', `action=${action}`);
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;
logTool('browser_open', `Opening: ${url}`);
try {
const result = await getManager().open({ url, waitSelector });
return { success: true, ...result };
return { success: true, action, ...result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, action, error: (err as Error).message };
}
}
// ===== 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> {
// ===== screenshot =====
case 'screenshot': {
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,
action,
image: result.data,
width: result.width,
height: result.height,
mime_type: 'image/png',
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, action, 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> {
// ===== evaluate =====
case 'evaluate': {
const script = args.script as string;
if (!script) {
return { success: false, error: 'No script provided' };
return { success: false, action, error: 'No script provided' };
}
logTool('browser_evaluate', `Executing ${script.length} chars of JS`);
try {
const result = await getManager().evaluate(script);
return { success: true, result };
return { success: true, action, result };
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, action, 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> {
// ===== extract =====
case 'extract': {
const selector = args.selector as string | undefined;
logTool('browser_extract', `selector=${selector ?? 'body'}`);
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, error: (err as Error).message };
}
return { success: false, action, 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> {
// ===== click =====
case 'click': {
const selector = args.selector as string;
const wait = (args.wait as boolean) ?? false;
if (!selector) {
return { success: false, error: 'No selector provided' };
return { success: false, action, error: 'No selector provided' };
}
logTool('browser_click', `selector=${selector}, wait=${wait}`);
try {
await getManager().click(selector, wait);
return { success: true, selector, clicked: true };
return { success: true, action, selector, clicked: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, action, 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> {
// ===== 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, error: 'No selector provided' };
return { success: false, action, error: 'No selector provided' };
}
if (text === undefined || text === null) {
return { success: false, error: 'No text provided' };
return { success: false, action, 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 };
return { success: true, action, selector, typed: text.length };
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, action, 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> {
// ===== scroll =====
case 'scroll': {
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 };
await getManager().scroll({
direction: direction as 'down' | 'up' | 'top' | 'bottom',
selector,
});
return { success: true, action, direction, selector };
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, action, 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> {
// ===== wait =====
case 'wait': {
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` };
return { success: true, action, waited_for: selector ?? `${timeMs}ms` };
} catch (err) {
return { success: false, error: (err as Error).message };
}
return { success: false, action, 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');
// ===== close =====
case 'close': {
getManager().close();
return { success: true, closed: true };
}
return { success: true, action, 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(),
];
default:
return { success: false, error: `Unknown action: ${action}` };
}
}
}
+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 {
// 忽略补充失败
+35 -3
View File
@@ -2,6 +2,7 @@
* Tool Registry — 工具注册表
*
* 管理所有可用工具(内置 + MCP),提供查找、注册、注销功能。
* 提供 per-tool 超时强制和结果大小限制,防止卡死和上下文溢出。
*/
import type {
@@ -11,6 +12,9 @@ import type {
} from '../types';
import type { IMetonaTool, ToolRegistryEntry, ToolExecutionContext } from '../types/metona-tool';
/** 工具返回值最大字符数(约 50KB),超过则截断 */
const MAX_RESULT_CHARS = 50_000;
export class ToolRegistry {
private tools = new Map<string, ToolRegistryEntry>();
private disabledTools = new Set<string>();
@@ -75,7 +79,7 @@ export class ToolRegistry {
}
}
/** 执行工具 */
/** 执行工具(带超时强制和结果大小限制) */
async execute(
toolCall: MetonaToolCall,
context: ToolExecutionContext,
@@ -94,12 +98,27 @@ export class ToolRegistry {
}
const startTs = Date.now();
const timeoutMs = tool.definition.timeoutMs;
try {
const result = await tool.execute(toolCall.args, context);
// 带超时执行 — 使用 Promise.race 防止工具卡死阻塞 Agent Loop
const result = await Promise.race([
tool.execute(toolCall.args, context),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error(`Tool execution timed out after ${timeoutMs}ms`)),
timeoutMs,
);
}),
]);
// 结果大小限制 — 防止过大返回值耗尽 LLM 上下文窗口
const safeResult = this.truncateResult(result);
return {
toolCallId: toolCall.id,
toolName: toolCall.name,
result,
result: safeResult,
success: true,
durationMs: Date.now() - startTs,
timestamp: Date.now(),
@@ -117,6 +136,19 @@ export class ToolRegistry {
}
}
/** 截断过大的工具返回值,防止 LLM 上下文溢出 */
private truncateResult(result: unknown): unknown {
const str = typeof result === 'string' ? result : JSON.stringify(result);
if (str.length <= MAX_RESULT_CHARS) return result;
return {
_truncated: true,
_original_size: str.length,
_preview: str.slice(0, MAX_RESULT_CHARS),
_message: `Result truncated: original ${str.length} chars exceeds limit ${MAX_RESULT_CHARS}`,
};
}
/** 获取工具数量 */
get size(): number {
return Array.from(this.tools.values()).filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)).length;
+21 -3
View File
@@ -85,6 +85,23 @@ export function registerAllIPCHandlers(
const workspaceFiles = workspaceService.getFiles();
const systemPrompt = contextBuilder.buildSystemPrompt(workspaceFiles);
// 检索与用户消息相关的记忆,注入到 System Prompt 动态区
try {
const memories = memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3 });
if (memories.length > 0) {
const memorySection = memories.map((m, i) =>
`[${i + 1}] (${m.type}, 重要度: ${m.importance.toFixed(1)}) ${m.content.slice(0, 200)}`,
).join('\n');
const memoryBlock = `## Relevant Memories (Retrieved)\n${memorySection}`;
systemPrompt.dynamicReminders = systemPrompt.dynamicReminders
? `${systemPrompt.dynamicReminders}\n\n---\n\n${memoryBlock}`
: memoryBlock;
log.debug(`[AGENT] Injected ${memories.length} memories into system prompt`);
}
} catch (err) {
log.warn('[AGENT] Memory retrieval failed, proceeding without memories:', err);
}
// 监听 Agent Loop 事件
const onStreamEvent = (event: MetonaStreamEvent) => {
if (!mainWindow.isDestroyed()) {
@@ -502,7 +519,6 @@ export function registerAllIPCHandlers(
ipcMain.handle('data:export', async (_event, sessionId?: string) => {
try {
const db = sessionService.getDB();
if (sessionId) {
// 导出单个会话
const messages = sessionService.getMessages(sessionId);
@@ -524,8 +540,8 @@ export function registerAllIPCHandlers(
});
ipcMain.handle('data:clearSessions', async () => {
try {
const db = sessionService.getDB();
try {
db.exec('BEGIN');
db.exec('DELETE FROM messages');
db.exec('DELETE FROM sessions');
@@ -533,6 +549,7 @@ export function registerAllIPCHandlers(
log.info('[DATA] All sessions cleared');
return { success: true };
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
return { success: false, error: (error as Error).message };
}
});
@@ -551,9 +568,9 @@ export function registerAllIPCHandlers(
});
ipcMain.handle('data:clearAuditLogs', async () => {
try {
// 审计日志是 INSERT-ONLY,需要先禁用触发器
const db = sessionService.getDB();
try {
db.exec('BEGIN');
db.exec('DROP TRIGGER IF EXISTS audit_no_delete');
db.exec('DELETE FROM audit_logs');
@@ -567,6 +584,7 @@ export function registerAllIPCHandlers(
log.info('[DATA] Audit logs cleared');
return { success: true };
} catch (error) {
try { db.exec('ROLLBACK'); } catch { /* 忽略回滚错误 */ }
return { success: false, error: (error as Error).message };
}
});
+25 -8
View File
@@ -41,13 +41,15 @@ import {
WebSearchTool, WebFetchTool,
MemoryStoreTool, MemorySearchTool,
RunCommandTool,
browserTools, cleanupBrowser,
WebBrowserTool, cleanupBrowser,
DelegateTaskTool,
} from './harness/tools/built-in';
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
import { PolicyEngine } from './harness/sandbox/permissions';
import { SandboxManager } from './harness/sandbox/sandbox';
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
import { OutputValidator } from './harness/verification/output-validator';
import { TaskOrchestrator } from './harness/orchestration/orchestrator';
import { UpdateService } from './services/update.service';
// ===== 步骤 1: 初始化日志系统(SYS 层)=====
@@ -148,22 +150,24 @@ async function initialize(): Promise<void> {
// ===== 步骤 5: 工作空间文件 + System Prompt =====
const contextBuilder = new ContextBuilder();
// ===== 步骤 6: 注册 9 个内置工具 =====
// ===== 步骤 6: 注册内置工具 =====
const toolRegistry = new ToolRegistry();
toolRegistry.registerBuiltin(new ReadFileTool());
toolRegistry.registerBuiltin(new WriteFileTool());
toolRegistry.registerBuiltin(new ListDirectoryTool());
toolRegistry.registerBuiltin(new SearchFilesTool());
toolRegistry.registerBuiltin(new WebSearchTool(configService));
toolRegistry.registerBuiltin(new WebFetchTool());
// WebFetchTool 先于 WebSearchTool 构造,注入为依赖
const webFetchTool = new WebFetchTool();
toolRegistry.registerBuiltin(webFetchTool);
toolRegistry.registerBuiltin(new WebSearchTool(configService, webFetchTool));
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
toolRegistry.registerBuiltin(new RunCommandTool());
// 注册 9 个 Browser 浏览器工具
for (const tool of browserTools) {
toolRegistry.registerBuiltin(tool);
}
// 注册 Web Browser 统一浏览器工具
toolRegistry.registerBuiltin(new WebBrowserTool());
log.info(`Registered ${toolRegistry.size} built-in tools`);
// ===== MCP Manager =====
@@ -211,6 +215,19 @@ async function initialize(): Promise<void> {
agentLoop.setTools(toolRegistry.listTools());
agentLoop.setWorkspacePath(workspaceInfo.path);
// ===== Task Orchestrator(子任务委派)=====
const orchestrator = new TaskOrchestrator(
agentLoop, toolRegistry, preToolHooks, postToolHooks,
{
thinkingEnabled: agentThinkingEnabled ?? true,
thinkingEffort: agentThinkingEffort ?? 'high',
contextLength: ollamaNumCtx ?? undefined,
},
);
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
// 重新设置工具列表,包含新注册的 delegate_task
agentLoop.setTools(toolRegistry.listTools());
// ===== 热重载 Adapter 回调(设置变更时触发)=====
const reloadAdapter = () => {
const newAdapter = createAdapter();
+1
View File
@@ -46,6 +46,7 @@ const metonaAPI = {
delete: (sessionId: string) => ipcRenderer.invoke('sessions:delete', sessionId),
getMessages: (sessionId: string) => ipcRenderer.invoke('sessions:getMessages', sessionId),
pin: (sessionId: string, pinned: boolean) => ipcRenderer.invoke('sessions:pin', sessionId, pinned),
archive: (sessionId: string, archived: boolean) => ipcRenderer.invoke('sessions:archive', sessionId, archived),
deleteMessage: (messageId: string) => ipcRenderer.invoke('sessions:deleteMessage', messageId),
clearMessages: (sessionId: string) => ipcRenderer.invoke('sessions:clearMessages', sessionId),
saveTrace: (sessionId: string, data: unknown) => ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ai-desktop",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ai-desktop",
"version": "0.1.1",
"version": "0.1.2",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.1.1",
"version": "0.1.2",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",
+1 -1
View File
@@ -30,7 +30,7 @@ export function TraceViewer(): React.JSX.Element {
) : (
traceSteps.map((step, i) => (
<TraceStep
key={`${step.iteration}-${step.state}-${i}`}
key={step.id}
step={step}
isCurrent={i === traceSteps.length - 1 && agentStatus !== 'idle'}
/>
+135 -67
View File
@@ -1,13 +1,17 @@
/**
* useAgentStream — Agent 流式事件监听 Hook
*
* 监听 window.metona.agent.onStreamEvent,将事件分发到 Zustand Store。
* 负责流式文本追加、工具调用状态更新、思考内容处理。
* 监听 window.metona.agent.onStreamEvent / onStateChange,将事件分发到 Zustand Store。
*
* 架构设计:
* - onStateChange:迭代号追踪 + 消息卡片创建 + Trace 步骤创建 + Agent 状态映射
* (状态变化事件总是先于同迭代的流式内容事件到达,因此在此统一管理迭代边界)
* - onStreamEvent:纯内容更新(reasoning、text、tool_call、tool_result、usage、done、error
* (不再处理迭代号比较,消除竞态条件)
*/
import { useEffect, useRef } from 'react';
import { useAgentStore, type ToolCallInfo } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { useAgentStore, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
/**
* Agent 流式事件监听 Hook
@@ -17,8 +21,8 @@ import { useSessionStore } from '@renderer/stores/session-store';
export function useAgentStream(): void {
const cleanupRef = useRef<(() => void) | null>(null);
// ===== 流式内容事件 =====
useEffect(() => {
// 检查 IPC 桥是否可用
if (!window.metona?.agent?.onStreamEvent) return;
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
@@ -39,39 +43,7 @@ export function useAgentStream(): void {
// 每次都从 store 读取最新状态(避免闭包捕获过期快照)
const getStore = () => useAgentStore.getState();
// 每次收到迭代号时同步更新
if (data.iteration != null && data.iteration !== getStore().currentIteration) {
const prevIteration = getStore().currentIteration;
getStore().setCurrentIteration(data.iteration);
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
if (data.iteration > prevIteration && prevIteration > 0) {
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
// 仅当上一条 assistant 消息已有内容时才创建新消息(避免空消息堆叠)
if (lastMsg?.role === 'assistant' && (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)) {
getStore().addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
timestamp: Date.now(),
iteration: data.iteration,
});
}
}
}
switch (data.type) {
// 思考开始
case 'thinking_start':
getStore().setAgentStatus('thinking');
getStore().addTraceStep({
iteration: data.iteration ?? getStore().currentIteration + 1,
state: 'THINKING',
startedAt: Date.now(),
});
break;
// 推理内容增量
case 'reasoning_delta':
if (data.delta) {
@@ -90,16 +62,18 @@ export function useAgentStream(): void {
content: '',
reasoningContent: data.delta,
timestamp: Date.now(),
iteration: data.iteration ?? getStore().currentIteration,
iteration: getStore().currentIteration || undefined,
});
}
}
break;
// 思考结束
case 'thinking_end':
if (data.iteration) {
getStore().updateTraceStep(data.iteration, { completedAt: Date.now() });
// 同步更新当前 Trace 步骤的 thought 字段
const traceSteps = getStore().traceSteps;
const curStep = traceSteps[traceSteps.length - 1];
if (curStep) {
getStore().updateLastTraceStep({
thought: (curStep.thought ?? '') + data.delta,
});
}
}
break;
@@ -108,6 +82,18 @@ export function useAgentStream(): void {
if (data.delta) {
getStore().setStreaming(true);
getStore().updateLastAssistantMessage(data.delta);
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
// 当 assistant 消息无 reasoningContent 时,持续累积 text delta 到 thought
const messages = getStore().messages;
const lastMsg = messages[messages.length - 1];
const steps = getStore().traceSteps;
const step = steps[steps.length - 1];
if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) {
getStore().updateLastTraceStep({
thought: (step.thought ?? '') + data.delta,
});
}
}
break;
@@ -118,7 +104,7 @@ export function useAgentStream(): void {
id: data.toolCall.id,
name: data.toolCall.name,
args: data.toolCall.args,
status: 'pending',
status: 'executing',
};
// 追加到当前 assistant 消息
@@ -130,15 +116,15 @@ export function useAgentStream(): void {
});
}
getStore().setAgentStatus('executing');
const curIter = getStore().currentIteration;
getStore().addTraceStep({
iteration: data.iteration ?? curIter,
state: 'EXECUTING',
startedAt: Date.now(),
toolCalls: [tc],
// 更新当前 Trace 步骤(添加工具调用信息)
const curSteps = getStore().traceSteps;
const lastStep = curSteps[curSteps.length - 1];
if (lastStep) {
getStore().updateLastTraceStep({
toolCalls: [...(lastStep.toolCalls ?? []), tc],
});
}
}
break;
// 工具执行结果
@@ -160,6 +146,24 @@ export function useAgentStream(): void {
);
getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls });
}
// 同步更新 Trace 步骤中的工具调用状态
const steps = getStore().traceSteps;
const lastTrace = steps[steps.length - 1];
if (lastTrace?.toolCalls) {
const updatedTraceToolCalls = lastTrace.toolCalls.map((tc) =>
tc.id === data.toolResult!.toolCallId
? {
...tc,
status: data.toolResult!.success ? 'success' as const : 'error' as const,
result: data.toolResult!.result,
error: data.toolResult!.error,
durationMs: data.toolResult!.durationMs,
}
: tc,
);
getStore().updateLastTraceStep({ toolCalls: updatedTraceToolCalls });
}
}
break;
}
@@ -173,6 +177,15 @@ export function useAgentStream(): void {
outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0),
totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0),
});
// 同步更新当前 Trace 步骤的 token 用量
getStore().updateLastTraceStep({
tokenUsage: {
promptTokens: data.usage.inputTokens ?? 0,
completionTokens: data.usage.outputTokens ?? 0,
totalTokens: data.usage.totalTokens ?? 0,
},
});
}
break;
@@ -180,6 +193,8 @@ export function useAgentStream(): void {
case 'done':
getStore().setStreaming(false);
getStore().setAgentStatus('idle');
// 标记最后一个 Trace 步骤为已完成
getStore().updateLastTraceStep({ completedAt: Date.now() });
getStore().saveTraceData();
break;
@@ -187,6 +202,7 @@ export function useAgentStream(): void {
case 'error':
getStore().setStreaming(false);
getStore().setAgentStatus('error');
getStore().updateLastTraceStep({ completedAt: Date.now() });
getStore().addMessage({
id: `msg_${Date.now()}_error`,
role: 'system',
@@ -194,17 +210,6 @@ export function useAgentStream(): void {
timestamp: Date.now(),
});
break;
// 状态变化
case 'state_change':
if (data.state) {
getStore().addTraceStep({
iteration: data.iteration ?? getStore().currentIteration,
state: data.state,
startedAt: Date.now(),
});
}
break;
}
});
@@ -215,16 +220,79 @@ export function useAgentStream(): void {
};
}, []);
// 监听状态变化事件(迭代号)
// ===== 状态变化事件(迭代追踪 + Trace 步骤 + 消息卡片) =====
useEffect(() => {
if (!window.metona?.agent?.onStateChange) return;
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
const data = state as { sessionId?: string; iteration?: number; state?: string; previous?: string; current?: string };
if (data.iteration != null) {
const data = state as {
sessionId?: string;
iteration?: number;
state?: string;
previous?: string;
current?: string;
};
const store = useAgentStore.getState();
if (data.iteration !== store.currentIteration) {
// --- 迭代号更新 + 新消息卡片创建 ---
if (data.iteration != null && data.iteration !== store.currentIteration) {
const prevIteration = store.currentIteration;
store.setCurrentIteration(data.iteration);
// 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片
// 仅当上一轮迭代已结束(prevIteration > 0)且上一条 assistant 消息有内容时
if (data.iteration > prevIteration && prevIteration > 0) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (
lastMsg?.role === 'assistant' &&
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)
) {
store.addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
timestamp: Date.now(),
iteration: data.iteration,
});
}
}
}
// --- Trace 步骤管理 ---
if (data.state && data.iteration != null) {
const traceSteps = store.traceSteps;
// 标记上一个 Trace 步骤为已完成
if (traceSteps.length > 0) {
const lastStep = traceSteps[traceSteps.length - 1];
if (!lastStep.completedAt) {
store.updateLastTraceStep({ completedAt: Date.now() });
}
}
// 创建新的 Trace 步骤
store.addTraceStep({
id: `trace_${data.iteration}_${data.state}_${Date.now()}`,
iteration: data.iteration,
state: data.state,
startedAt: Date.now(),
});
// --- Agent 状态映射 ---
const stateToStatus: Record<string, AgentStatus> = {
THINKING: 'thinking',
EXECUTING: 'executing',
PARSING: 'thinking',
OBSERVING: 'thinking',
COMPRESSING: 'thinking',
INIT: 'thinking',
TERMINATED: 'idle',
};
const newStatus = stateToStatus[data.state];
if (newStatus) {
store.setAgentStatus(newStatus);
}
}
});
+18 -1
View File
@@ -56,6 +56,7 @@ export interface TokenUsage {
// ===== Trace 步骤 =====
export interface TraceStep {
id: string;
iteration: number;
state: string;
startedAt: number;
@@ -103,6 +104,7 @@ interface AgentState {
clearStreamingContent: () => void;
addTraceStep: (step: TraceStep) => void;
updateTraceStep: (iteration: number, updates: Partial<TraceStep>) => void;
updateLastTraceStep: (updates: Partial<TraceStep>) => void;
updateTokenUsage: (usage: Partial<TokenUsage>) => void;
setProvider: (provider: string, model: string) => void;
setMaxIterations: (max: number) => void;
@@ -160,7 +162,14 @@ export const useAgentStore = create<AgentState>((set, get) => ({
if (id && window.metona?.sessions?.getTrace) {
window.metona.sessions.getTrace(id).then((data) => {
if (data) {
if (data.traceSteps) set({ traceSteps: data.traceSteps as TraceStep[] });
if (data.traceSteps) {
// 兼容旧数据:为缺少 id 的 trace 步骤生成 id
const steps = (data.traceSteps as TraceStep[]).map((t, i) => ({
...t,
id: t.id ?? `trace_legacy_${t.iteration}_${t.state}_${i}`,
}));
set({ traceSteps: steps });
}
if (data.tokenUsage) set({ tokenUsage: data.tokenUsage as TokenUsage });
}
}).catch((err) => { console.error('[AgentStore]', err); });
@@ -321,6 +330,14 @@ export const useAgentStore = create<AgentState>((set, get) => ({
),
})),
updateLastTraceStep: (updates) =>
set((s) => {
if (s.traceSteps.length === 0) return s;
const steps = [...s.traceSteps];
steps[steps.length - 1] = { ...steps[steps.length - 1], ...updates };
return { traceSteps: steps };
}),
updateTokenUsage: (usage) =>
set((s) => ({ tokenUsage: { ...s.tokenUsage, ...usage } })),