diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index b8013b5..0477a41 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -172,7 +172,7 @@ export class DeepSeekAdapter extends BaseAdapter { } else if (request.params.thinkingEnabled) { body.thinking = { type: 'enabled' }; const effortMap: Record = { - 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'; diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index 0804a14..ede7075 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -56,7 +56,7 @@ export class OllamaAdapter extends BaseAdapter { } const data = await response.json() as Record; - return this.toMetonaResponse(data, request.meta.requestId); + return this.toMetonaResponse(data, request.meta.requestId, request.meta.iteration); } async *chatStream(request: MetonaRequest): AsyncIterable { @@ -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(); + 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, requestId: string): MetonaResponse { + private toMetonaResponse(data: Record, requestId: string, iteration: number = 0): MetonaResponse { const message = data.message as Record | undefined; const toolCalls = message?.tool_calls as Array> | 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(), }; }), diff --git a/electron/harness/adapters/shared/sse-stream.ts b/electron/harness/adapters/shared/sse-stream.ts index e66121a..0911255 100644 --- a/electron/harness/adapters/shared/sse-stream.ts +++ b/electron/harness/adapters/shared/sse-stream.ts @@ -223,10 +223,15 @@ export function parseOpenAICompatibleResponse( toolCalls: rawToolCalls?.map((tc) => { const fn = tc.function as Record; let args: Record = {}; - try { - args = JSON.parse(fn.arguments as string); - } catch { - args = {}; + const rawArgs = fn?.arguments; + if (typeof rawArgs === 'string') { + try { + args = JSON.parse(rawArgs); + } catch { + args = {}; + } + } else if (rawArgs && typeof rawArgs === 'object') { + args = rawArgs as Record; } return { id: tc.id as string, diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 3902b71..d98d721 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -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 => { 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, - iteration: this.currentIteration, - state: 'COMPRESSING', - }); - - // 在 context 中标记压缩点 - const keepRecent = 3; - if (this.iterations.length > keepRecent) { - this.emit('compressed', { iteration: this.currentIteration, keptRounds: keepRecent }); + const compressed = await this.compressMessages(request.messages); + if (compressed) { + // 原地替换数组内容,确保外层 messages 引用同步更新 + request.messages.splice(0, request.messages.length, ...compressed); + this.emit('compressed', { + iteration: this.currentIteration, + originalTokens: estimatedTokens, + compressedTokens: this.estimateMessagesTokens(compressed), + }); } } @@ -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 { + 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, diff --git a/electron/harness/hooks/post-tool.ts b/electron/harness/hooks/post-tool.ts index 8ad9f7c..177927f 100644 --- a/electron/harness/hooks/post-tool.ts +++ b/electron/harness/hooks/post-tool.ts @@ -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, diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index 07f1025..5ffc4b3 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -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(); + /** 追踪每个 session 的当前委派深度 */ + private sessionDepth = new Map(); constructor( - private agentLoopEngine: AgentLoopEngine, + private mainEngine: AgentLoopEngine, private toolRegistry?: ToolRegistry, + private preToolHooks: PreToolHook[] = [], + private postToolHooks: PostToolHook[] = [], + private defaultConfig?: Partial, ) { 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( - userMessage, - taskId, - [], // 子 Agent 无历史 - systemPrompt, - ); - } finally { - // 恢复主 Agent 的原始工具列表 - this.agentLoopEngine.setTools(savedTools); - } + const output = await subEngine.runStream( + userMessage, + taskId, + [], // 子 Agent 无历史 + systemPrompt, + ); 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(); } } diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index 7307f3c..2f187f7 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -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 { diff --git a/electron/harness/sandbox/sandbox.ts b/electron/harness/sandbox/sandbox.ts index 7c1cbc6..8793ccd 100644 --- a/electron/harness/sandbox/sandbox.ts +++ b/electron/harness/sandbox/sandbox.ts @@ -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,20 +47,21 @@ 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)); - if (!isAllowed) { - return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' }; - } + 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 }; diff --git a/electron/harness/tools/built-in/browser.ts b/electron/harness/tools/built-in/browser.ts index f49b800..5b697a8 100644 --- a/electron/harness/tools/built-in/browser.ts +++ b/electron/harness/tools/built-in/browser.ts @@ -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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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, _context: ToolExecutionContext): Promise { - 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(), -]; diff --git a/electron/harness/tools/built-in/command.ts b/electron/harness/tools/built-in/command.ts index e7048f2..b4c3a23 100644 --- a/electron/harness/tools/built-in/command.ts +++ b/electron/harness/tools/built-in/command.ts @@ -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) { diff --git a/electron/harness/tools/built-in/delegate-task.ts b/electron/harness/tools/built-in/delegate-task.ts new file mode 100644 index 0000000..078371c --- /dev/null +++ b/electron/harness/tools/built-in/delegate-task.ts @@ -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, context: ToolExecutionContext): Promise { + 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}`, + }; + } + } +} diff --git a/electron/harness/tools/built-in/filesystem.ts b/electron/harness/tools/built-in/filesystem.ts index 152138e..16ea5fb 100644 --- a/electron/harness/tools/built-in/filesystem.ts +++ b/electron/harness/tools/built-in/filesystem.ts @@ -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 { + 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> { + private async listDir( + rootPath: string, + dirPath: string, + maxDepth: number, + glob: string | undefined, + currentDepth: number, + ): Promise> { 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 { 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 { 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); diff --git a/electron/harness/tools/built-in/index.ts b/electron/harness/tools/built-in/index.ts index af8702e..aa7bde1 100644 --- a/electron/harness/tools/built-in/index.ts +++ b/electron/harness/tools/built-in/index.ts @@ -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'; diff --git a/electron/harness/tools/built-in/memory.ts b/electron/harness/tools/built-in/memory.ts index b376e9b..4532798 100644 --- a/electron/harness/tools/built-in/memory.ts +++ b/electron/harness/tools/built-in/memory.ts @@ -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'], }, diff --git a/electron/harness/tools/built-in/web-fetch.ts b/electron/harness/tools/built-in/web-fetch.ts index 49723ba..e127b7a 100644 --- a/electron/harness/tools/built-in/web-fetch.ts +++ b/electron/harness/tools/built-in/web-fetch.ts @@ -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 { // 查缓存 @@ -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 { return new Promise((resolve) => setTimeout(resolve, ms)); } - - /** 带超时的 loadURL(Electron 原生不支持 timeout 选项) */ - private async loadURLWithTimeout(win: BrowserWindow, url: string, timeoutMs: number): Promise { - let timer: NodeJS.Timeout | null = null; - const timeoutPromise = new Promise((_, 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); - } - } } diff --git a/electron/harness/tools/built-in/web-search.ts b/electron/harness/tools/built-in/web-search.ts index 2e0f208..6c17ed7 100644 --- a/electron/harness/tools/built-in/web-search.ts +++ b/electron/harness/tools/built-in/web-search.ts @@ -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, _context: ToolExecutionContext): Promise { 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 { 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 { // 忽略补充失败 diff --git a/electron/harness/tools/registry.ts b/electron/harness/tools/registry.ts index 688ce61..8e52090 100644 --- a/electron/harness/tools/registry.ts +++ b/electron/harness/tools/registry.ts @@ -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(); private disabledTools = new Set(); @@ -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((_, 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; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 22a04dd..6748b0e 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 () => { + const db = sessionService.getDB(); try { - const db = sessionService.getDB(); 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 () => { + // 审计日志是 INSERT-ONLY,需要先禁用触发器 + const db = sessionService.getDB(); try { - // 审计日志是 INSERT-ONLY,需要先禁用触发器 - const db = sessionService.getDB(); 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 }; } }); diff --git a/electron/main.ts b/electron/main.ts index 01898c4..8547402 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 { // ===== 步骤 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 { 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(); diff --git a/electron/preload.ts b/electron/preload.ts index d052df6..0ba2cd0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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), diff --git a/package-lock.json b/package-lock.json index 3afe7eb..ea4c561 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e545817..90bcc73 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/trace/TraceViewer.tsx b/src/components/trace/TraceViewer.tsx index c779ac6..72604ac 100644 --- a/src/components/trace/TraceViewer.tsx +++ b/src/components/trace/TraceViewer.tsx @@ -30,7 +30,7 @@ export function TraceViewer(): React.JSX.Element { ) : ( traceSteps.map((step, i) => ( diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index 3550770..bfb5ad5 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -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,14 +116,14 @@ 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 store = useAgentStore.getState(); - if (data.iteration !== store.currentIteration) { - store.setCurrentIteration(data.iteration); + const data = state as { + sessionId?: string; + iteration?: number; + state?: string; + previous?: string; + current?: string; + }; + + const store = useAgentStore.getState(); + + // --- 迭代号更新 + 新消息卡片创建 --- + 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 = { + THINKING: 'thinking', + EXECUTING: 'executing', + PARSING: 'thinking', + OBSERVING: 'thinking', + COMPRESSING: 'thinking', + INIT: 'thinking', + TERMINATED: 'idle', + }; + const newStatus = stateToStatus[data.state]; + if (newStatus) { + store.setAgentStatus(newStatus); } } }); diff --git a/src/stores/agent-store.ts b/src/stores/agent-store.ts index 2212138..b6d1430 100644 --- a/src/stores/agent-store.ts +++ b/src/stores/agent-store.ts @@ -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) => void; + updateLastTraceStep: (updates: Partial) => void; updateTokenUsage: (usage: Partial) => void; setProvider: (provider: string, model: string) => void; setMaxIterations: (max: number) => void; @@ -160,7 +162,14 @@ export const useAgentStore = create((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((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 } })),