/** * Agent Loop — ReAct 状态机引擎 * * 生产级 ReAct Agent Loop,负责: * 1. 状态机管理循环生命周期 * 2. 调用 Provider Adapter 获取 LLM 响应(支持流式) * 3. 解析输出、执行工具、注入观察 * 4. 流式事件推送 UI * 5. 超时、重试、上下文压缩 * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第四章 */ import { EventEmitter } from 'events'; import { resolve } from 'path'; import { nanoid } from 'nanoid'; import { AgentLoopState, TerminationReason, type IterationStep, type Thought, type AgentLoopConfig, type AgentLoopOutput, type TokenUsage, } from './types'; import type { MetonaRequest, MetonaResponse, MetonaMessage, MetonaSystemPrompt, MetonaToolCall, MetonaToolResult, MetonaStreamEvent, IMetonaProviderAdapter, MetonaToolDef, } from '../types'; import { MetonaStreamEventType, MetonaFinishReason, MetonaErrorCode } from '../types'; import { estimateMessagesTokens } from '../utils/token-estimator'; import log from 'electron-log'; /** * v0.3.0: 死循环错误 — 在 executeOneIterationStream 中抛出,主循环捕获后以 DEAD_LOOP 原因终止 */ class DeadLoopError extends Error { constructor(message: string) { super(message); this.name = 'DeadLoopError'; } } const DEFAULT_CONFIG: AgentLoopConfig = { maxIterations: 20, timeoutMs: 120_000, totalTimeoutMs: 600_000, enableReflection: false, compressionThreshold: 0.8, contextWindow: 128_000, retryCount: 3, temperature: 0.0, maxTokens: 63488, thinkingEnabled: true, thinkingEffort: 'high', toolExecutionTimeoutMs: 120_000, }; /** * ReAct Agent Loop 引擎 */ export class AgentLoopEngine extends EventEmitter { private currentState: AgentLoopState = AgentLoopState.INIT; private iterations: IterationStep[] = []; private currentIteration = 0; private startTime = 0; private totalTokens: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; private aborted = false; private config: AgentLoopConfig; private tools: MetonaToolDef[] = []; private currentSessionId: string = ''; private currentRequestId: string = ''; private workspacePath: string = ''; private abortController: AbortController | null = null; private eventSeq = 0; /** 当前 run 的唯一标识(用于前端过滤旧流事件) */ private runId: string = ''; /** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */ private currentRunPromise: Promise | null = null; /** v0.3.0: 工具调用签名历史(用于死循环检测) */ private toolCallHistory: string[] = []; constructor( config: Partial = {}, private adapter: IMetonaProviderAdapter, private toolRegistry?: import('../tools/registry').ToolRegistry, private preToolHooks: import('../hooks/pre-tool').PreToolHook[] = [], private postToolHooks: import('../hooks/post-tool').PostToolHook[] = [], ) { super(); this.config = { ...DEFAULT_CONFIG, ...config }; } /** * 设置工作空间路径(工具执行时传入 context) */ setWorkspacePath(path: string): void { this.workspacePath = path; } /** * 设置可用工具列表 */ setTools(tools: MetonaToolDef[]): void { this.tools = tools; } /** * 获取当前工具列表(用于子任务恢复) */ getTools(): MetonaToolDef[] { return [...this.tools]; } /** * 热切换 Provider Adapter(设置变更时调用) */ setAdapter(adapter: IMetonaProviderAdapter): void { this.adapter = adapter; } /** * 热更新 Engine 配置(设置变更时调用) * * 支持 maxIterations, totalTimeoutMs, thinkingEnabled, thinkingEffort, contextLength */ updateConfig(partial: Partial): void { this.config = { ...this.config, ...partial }; } /** * 执行完整的 ReAct 循环(流式模式) * * @param userInput 用户输入文本 * @param sessionId 会话 ID * @param history 历史消息 * @param systemPrompt System Prompt */ async runStream( userMessage: MetonaMessage, sessionId: string, history: MetonaMessage[], systemPrompt: MetonaSystemPrompt, ): Promise { // C-4/H-6: 等待上一次 run 完全结束,防止并发 run 污染状态和旧 DONE 中断新流 if (this.currentRunPromise) { await this.currentRunPromise.catch(() => {}); } this.currentRunPromise = this.executeRunStream(userMessage, sessionId, history, systemPrompt); try { return await this.currentRunPromise; } finally { this.currentRunPromise = null; } } private async executeRunStream( userMessage: MetonaMessage, sessionId: string, history: MetonaMessage[], systemPrompt: MetonaSystemPrompt, ): Promise { this.startTime = Date.now(); this.aborted = false; this.runId = `run_${nanoid(8)}`; this.iterations = []; this.currentIteration = 0; this.currentSessionId = sessionId; this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }; this.abortController = new AbortController(); // C-2 修复: 将 abortController 的 signal 注入到 adapter, // 使正在进行的 fetch 可被用户中断,避免资源泄漏 if (this.adapter.setAbortSignal) { this.adapter.setAbortSignal(this.abortController.signal); } this.eventSeq = 0; // v0.3.0: 重置工具调用历史(用于死循环检测) this.toolCallHistory = []; try { await this.transitionTo(AgentLoopState.INIT); // 构建消息列表(保留 images 字段) const messages: MetonaMessage[] = [ ...history, { role: 'user', content: userMessage.content, images: userMessage.images, timestamp: Date.now(), }, ]; // === 主循环 === while ( this.currentIteration < this.config.maxIterations && !this.aborted && Date.now() - this.startTime < this.config.totalTimeoutMs ) { this.currentIteration++; // 构建请求 const request: MetonaRequest = { meta: { sessionId, iteration: this.currentIteration, requestId: `r_${nanoid(12)}`, timestamp: Date.now(), agentVersion: '1.0.0', }, systemPrompt, messages, tools: this.tools.length > 0 ? this.tools : undefined, params: { maxTokens: this.config.maxTokens, temperature: this.config.temperature, stream: true, thinkingEnabled: this.config.thinkingEnabled, thinkingEffort: this.config.thinkingEffort, contextLength: this.config.contextLength, }, }; const step = await this.executeOneIterationStream(request, sessionId); this.iterations.push(step); // 将 assistant 回复加入消息历史 if (step.thought) { const assistantMsg: MetonaMessage = { role: 'assistant', content: step.thought.content, reasoningContent: step.thought.reasoningContent, toolCalls: step.toolCalls, timestamp: Date.now(), iteration: this.currentIteration, }; messages.push(assistantMsg); } // 如果没有工具调用,视为最终输出 if (!step.toolCalls || step.toolCalls.length === 0) { return this.finish(TerminationReason.COMPLETED, step.thought?.content); } // v0.3.0: 死循环检测已移入 executeOneIterationStream 的 PARSING 阶段后, // 通过抛出 DeadLoopError 在此处 catch 块捕获处理 // 执行工具并将结果加入消息 if (step.toolResults) { for (const result of step.toolResults) { messages.push({ role: 'tool', // CE-2 修复: 工具失败时 result.result 为 null,LLM 会看到 "null" 而非错误信息 // 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环 content: result.error ? result.error : (typeof result.result === 'string' ? result.result : JSON.stringify(result.result)), toolResult: result, timestamp: Date.now(), iteration: this.currentIteration, }); } } } // 循环退出判断 if (this.aborted) return this.finish(TerminationReason.USER_INTERRUPT); if (this.currentIteration >= this.config.maxIterations) return this.finish(TerminationReason.MAX_ITERATIONS); return this.finish(TerminationReason.TIMEOUT); } catch (error) { // v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止 // P0-1 审查修复: 必须传 error 参数(第三参数),否则 finish() 不会发射 ERROR 事件 // 前端只收到 DONE 会导致 agentStatus 被设为 'idle' 而非 'error',且无错误消息卡片 if (error instanceof DeadLoopError) { return this.finish(TerminationReason.DEAD_LOOP, undefined, error as Error); } const errMsg = (error as Error).message ?? ''; // P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR // Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖 const errMsgLower = errMsg.toLowerCase(); if (this.aborted || errMsgLower.includes('aborted') || errMsgLower.includes('timed out') || errMsgLower.includes('timeout')) { return this.finish(TerminationReason.USER_INTERRUPT); } // v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw, // 导致 finish() 被中断、DONE 事件丢失、前端卡死。改为日志记录,让 finish 正常执行 log.error(`[AgentLoop] Run failed: ${errMsg}`); return this.finish(TerminationReason.ERROR, undefined, error as Error); } } /** 获取当前 Provider Adapter(供 SubAgent 创建独立引擎实例) */ getAdapter(): IMetonaProviderAdapter { return this.adapter; } /** 获取工作空间路径(供 SubAgent 继承) */ getWorkspacePath(): string { return this.workspacePath; } /** 中断循环 */ abort(): void { this.aborted = true; this.abortController?.abort(); this.abortController = null; // C-2 修复: 清除 adapter 的 abort signal,防止旧的已 abort signal 影响后续请求 if (this.adapter.setAbortSignal) { this.adapter.setAbortSignal(undefined); } this.emit('aborted'); } /** * MT-1 修复: 等待当前 run 结束(用于 abortSession IPC handler) * * abort() 只是设置了标志和触发了 abortController, * 但 currentRunPromise 仍在进行中(可能在等待工具超时或 LLM 响应)。 * 如果不等待就返回,用户立即重发会导致新消息卡在 runStream 的 currentRunPromise 等待中。 * * @param timeoutMs 等待超时(默认 5 秒,防止永久挂起) * @returns true 表示 run 已结束,false 表示等待超时 */ async waitForAbort(timeoutMs: number = 5_000): Promise { if (!this.currentRunPromise) return true; try { await Promise.race([ this.currentRunPromise.catch(() => {}), new Promise((resolve) => setTimeout(resolve, timeoutMs)), ]); return true; } catch { return false; } } /** * 销毁引擎,清理所有监听器 */ destroy(): void { this.abort(); this.removeAllListeners(); } /** 获取当前状态 */ getState(): AgentLoopState { return this.currentState; } // ========== 私有方法 ========== /** * 执行单次迭代(流式模式) */ private async executeOneIterationStream( request: MetonaRequest, sessionId: string, ): Promise { this.currentRequestId = request.meta.requestId; const step: IterationStep = { iteration: this.currentIteration, state: AgentLoopState.THINKING, startedAt: Date.now(), }; try { // === THINKING: 流式调用 LLM === // transitionTo 已发射 stateChange 事件,无需重复 emit await this.transitionTo(AgentLoopState.THINKING); let fullContent = ''; let reasoningContent = ''; const toolCallsBuffer = new Map(); let tokenUsage: TokenUsage | undefined; // 流式接收响应 for await (const event of this.chatStreamWithRetry(request)) { if (this.aborted) break; // 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE if (event.type === MetonaStreamEventType.DONE) continue; // 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI // RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支) // H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全 if (event.type === MetonaStreamEventType.ERROR && event.error?.code === MetonaErrorCode.RETRY) { // 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收) fullContent = ''; reasoningContent = ''; toolCallsBuffer.clear(); continue; } // 转发流式事件到渲染进程(注入 runId 供前端过滤旧流) this.emit('streamEvent', { ...event, runId: this.runId }); switch (event.type) { case MetonaStreamEventType.TEXT_DELTA: if (event.delta) fullContent += event.delta; break; case MetonaStreamEventType.REASONING_DELTA: if (event.delta) reasoningContent += event.delta; break; case MetonaStreamEventType.TOOL_CALL_DELTA: if (event.toolCallDelta) { const { index, name, argsDelta } = event.toolCallDelta; if (!toolCallsBuffer.has(index)) { toolCallsBuffer.set(index, { name: name ?? '', argsBuffer: '' }); } const buf = toolCallsBuffer.get(index)!; if (name) buf.name = name; if (argsDelta) buf.argsBuffer += argsDelta; } break; case MetonaStreamEventType.TOOL_CALL_COMPLETE: if (event.toolCall) { // 工具调用完成,记录到 step if (!step.toolCalls) step.toolCalls = []; step.toolCalls.push(event.toolCall); } break; case MetonaStreamEventType.USAGE: if (event.usage) { tokenUsage = { promptTokens: event.usage.inputTokens ?? 0, completionTokens: event.usage.outputTokens ?? 0, totalTokens: event.usage.totalTokens ?? 0, }; } break; case MetonaStreamEventType.ERROR: // RETRY 已在循环入口过滤,此处只处理真正的错误 if (event.error) { throw new Error(event.error.message); } break; } } // === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 === await this.transitionTo(AgentLoopState.PARSING); // L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段) this.finalizeToolCallsFromBuffer(step, toolCallsBuffer); // 记录 Thought if (fullContent || reasoningContent) { step.thought = { id: `thought-${this.currentIteration}`, content: fullContent, reasoningContent, timestamp: Date.now(), iteration: this.currentIteration, }; } // 记录 Token 使用 if (tokenUsage) { step.tokenUsage = tokenUsage; this.accumulateTokens(tokenUsage); } // v0.3.0 修复: 死循环检测 — 在 PARSING 阶段完成后、EXECUTING 阶段开始前检测 // 确保第3轮重复调用的副作用不会产生(工具尚未执行) if (step.toolCalls && step.toolCalls.length > 0) { if (this.detectDeadLoop(step.toolCalls)) { log.warn(`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`); this.emit('deadLoop', { iteration: this.currentIteration, runId: this.runId, sessionId: this.currentSessionId, }); // 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止 throw new DeadLoopError( `Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations. Please refine the approach or provide more specific instructions.`, ); } } // === EXECUTING: 执行工具调用 === if (step.toolCalls && step.toolCalls.length > 0) { // transitionTo 已发射 stateChange 事件,无需重复 emit await this.transitionTo(AgentLoopState.EXECUTING); // L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段) // 返回 null 表示被 abort 中断 const raceResult = await this.executeToolCallsParallel(step.toolCalls, request.meta.requestId, sessionId); if (raceResult === null) { // 被 abort 中断,标记步骤并退出 step.completedAt = Date.now(); step.state = AgentLoopState.TERMINATED; return step; } step.toolResults = raceResult.map((result, idx) => { const tc = step.toolCalls![idx]; if (result.status === 'fulfilled') return result.value; // rejected:构造失败 result const errorResult = { toolCallId: tc.id, toolName: tc.name, result: null, success: false, error: `Tool execution failed: ${(result.reason as Error)?.message ?? String(result.reason)}`, durationMs: 0, timestamp: Date.now(), }; // 转发错误结果到 UI this.emit('streamEvent', { type: MetonaStreamEventType.TOOL_RESULT, requestId: request.meta.requestId, sessionId, iteration: this.currentIteration, seq: this.nextSeq(), timestamp: Date.now(), toolResult: errorResult, runId: this.runId, }); return errorResult; }); } // === OBSERVING === await this.transitionTo(AgentLoopState.OBSERVING); // === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 === // 如果有工具调用且需要后续推理,进入 REFLECTING 状态 if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) { await this.transitionTo(AgentLoopState.REFLECTING); // 检查工具执行是否有错误,如果有严重错误可以提前终止 const hasErrors = step.toolResults?.some((r) => !r.success); if (hasErrors) { log.warn(`[AgentLoop] Iteration ${this.currentIteration} had tool errors`); } } // === 上下文压缩(基于 token 使用率触发) === // 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow; const estimatedTokens = this.estimateMessagesTokens(request.messages); const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow; if (estimatedTokens > compressionThreshold && request.messages.length > 10) { await this.transitionTo(AgentLoopState.COMPRESSING); 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), }); } // 压缩后回到 OBSERVING await this.transitionTo(AgentLoopState.OBSERVING); } step.completedAt = Date.now(); step.state = AgentLoopState.OBSERVING; return step; } catch (error) { step.completedAt = Date.now(); step.state = AgentLoopState.TERMINATED; // v0.3.0 修复: DeadLoopError 抛出时,将 step 加入 iterations 数组, // 确保死循环轮的 LLM thought 内容不丢失(用户可观察 Agent 被终止前的最后思考) if (error instanceof DeadLoopError) { this.iterations.push(step); } throw error; } } /** * L-19 修复: PARSING 阶段 — 解析流式接收期间累积的工具调用缓冲区, * 构造 MetonaToolCall[] 写入 step.toolCalls。 * * 仅在缓冲区非空且 step 尚未通过 TOOL_CALL_COMPLETE 接收到完整调用时生效, * 避免覆盖已就绪的 toolCalls。 */ private finalizeToolCallsFromBuffer( step: IterationStep, toolCallsBuffer: Map, ): void { if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) { step.toolCalls = []; for (const [, buf] of toolCallsBuffer) { let args: Record; try { args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {}; } catch { args = {}; } step.toolCalls.push({ id: `tc_${nanoid(8)}`, name: buf.name, args, iteration: this.currentIteration, timestamp: Date.now(), }); } } } /** * L-19 修复: EXECUTING 阶段 — 并行执行工具调用,转发结果到 UI, * 并与 abort 信号竞速。返回 null 表示被 abort 中断。 * * 注意:rejected 的工具结果由调用方处理(构造失败 result 并转发), * 此处只负责转发 fulfilled 的结果。 */ private async executeToolCallsParallel( toolCalls: MetonaToolCall[], requestId: string, sessionId: string, ): Promise[] | null> { const executeAndForward = async (tc: MetonaToolCall): Promise => { const result = await this.executeToolSafely(tc); // 执行成功后转发结果到 UI(abort 后不再转发,避免污染新会话的流) if (!this.aborted) { this.emit('streamEvent', { type: MetonaStreamEventType.TOOL_RESULT, requestId, sessionId, iteration: this.currentIteration, seq: this.nextSeq(), timestamp: Date.now(), toolResult: result, runId: this.runId, }); } return result; }; // C-2 修复: 使用 Promise.allSettled 而非 Promise.all,确保单个工具失败不影响其他工具 const toolsPromise = Promise.allSettled(toolCalls.map((tc) => executeAndForward(tc))); const controller = this.abortController; let onAbort: (() => void) | null = null; const abortPromise = new Promise((resolve) => { if (this.aborted) { resolve(null); return; } if (controller) { onAbort = () => resolve(null); controller.signal.addEventListener('abort', onAbort, { once: true }); } }); const raceResult = (await Promise.race([toolsPromise, abortPromise])) as | PromiseSettledResult[] | null; // 清理 abort 监听器,避免事件循环中残留 if (onAbort && controller && raceResult !== null) { controller.signal.removeEventListener('abort', onAbort); } return raceResult; } /** * 安全执行工具调用(经过 Hook 管道) */ private async executeToolSafely(toolCall: MetonaToolCall): Promise { const startTs = Date.now(); if (!this.toolRegistry) { return { toolCallId: toolCall.id, toolName: toolCall.name, result: null, success: false, error: `Tool '${toolCall.name}' not available: no ToolRegistry configured`, durationMs: Date.now() - startTs, timestamp: Date.now(), }; } // 前置 Hook 管道 for (const hook of this.preToolHooks) { const result = await hook.beforeExecute(toolCall, this.currentSessionId); if (result.blocked) { return { toolCallId: toolCall.id, toolName: toolCall.name, result: null, success: false, error: `Blocked: ${result.reason}`, durationMs: Date.now() - startTs, timestamp: Date.now(), }; } } // H-5 修复: 精确保护工作空间根目录的 MEMORY.md // @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected; // subdirectory MEMORY.md files are unrestricted // 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md, // 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。 // run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。 if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) { if (this.isTargetingRootMemoryMd(toolCall)) { return { toolCallId: toolCall.id, toolName: toolCall.name, result: null, success: false, error: 'Access to workspace root MEMORY.md is protected by security policy', durationMs: Date.now() - startTs, timestamp: Date.now(), }; } } // 执行工具(带超时) // 兜底超时取 max(配置值, 工具自定义 timeoutMs),确保工具能跑满自己声明的超时 const configuredTimeout = this.config.toolExecutionTimeoutMs ?? 120_000; const toolDef = this.toolRegistry.get(toolCall.name)?.definition; const toolTimeout = Math.max(configuredTimeout, toolDef?.timeoutMs ?? 0); let toolResult: MetonaToolResult; // M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积 // 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer let engineTimer: ReturnType | undefined; try { toolResult = await Promise.race([ this.toolRegistry.execute(toolCall, { sessionId: this.currentSessionId ?? '', workspacePath: this.workspacePath, iteration: this.currentIteration, requestId: this.currentRequestId, }), new Promise((_, reject) => { engineTimer = setTimeout( () => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout, ); }), ]); } catch (err) { toolResult = { toolCallId: toolCall.id, toolName: toolCall.name, result: null, success: false, error: (err as Error).message, durationMs: 0, timestamp: Date.now(), }; // 仍执行 post-hook for (const hook of this.postToolHooks) { await hook.afterExecute(toolCall, toolResult, this.currentSessionId); } return toolResult; } finally { // M-16 修复: 清理未触发的 timeout timer if (engineTimer) clearTimeout(engineTimer); } // 后置 Hook 管道 for (const hook of this.postToolHooks) { await hook.afterExecute(toolCall, toolResult, this.currentSessionId); } return toolResult; } /** * H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md * * @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected; * subdirectory MEMORY.md files are unrestricted * * 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md, * 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。 * * @param toolCall 工具调用 * @returns 是否指向工作空间根目录的 MEMORY.md */ private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean { if (!this.workspacePath) return false; // 提取工具参数中的路径(不同工具使用不同的参数名) const args = toolCall.args; const pathStr = (args.path as string) || (args.file_path as string) || (args.filePath as string) || (args.file as string) || (args.target as string) || (args.destination as string); if (!pathStr || typeof pathStr !== 'string') return false; // 解析路径,判断是否指向工作空间根目录的 MEMORY.md // 使用 toLowerCase 处理 Windows 不区分大小写的文件系统 const resolved = resolve(pathStr).toLowerCase(); const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase(); // 精确匹配:路径必须等于 {workspacePath}/MEMORY.md return resolved === rootMemoryPath; } /** * 带重试的流式调用(v0.2.0: 指数退避) * * 如果 adapter 抛出错误,在 retryCount 次数内重试。 * v0.2.0: 使用指数退避替代固定 1 秒等待 * 等待时间 = baseDelay * 2^attempt(1s, 2s, 4s, 8s...) * 上限 30 秒,加上 ±20% 随机抖动(jitter)避免惊群效应 */ private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable { let lastError: unknown; const baseDelayMs = 1_000; const maxDelayMs = 30_000; for (let attempt = 0; attempt <= this.config.retryCount; attempt++) { try { // 首次尝试直接 yield if (attempt === 0) { yield* this.adapter.sendStream(request); return; } // 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta // H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'RETRY' as never,移除不安全的类型断言 yield { type: MetonaStreamEventType.ERROR, requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: 0, timestamp: Date.now(), error: { code: MetonaErrorCode.RETRY, message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`, retryable: true, }, }; yield* this.adapter.sendStream(request); return; } catch (error) { lastError = error; if (this.aborted) throw error; if (attempt < this.config.retryCount) { // 检查是否为可重试错误 if (!this.isRetryableError(error)) throw error; // 指数退避 + 抖动 const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt)); const jitter = delay * 0.2 * (Math.random() * 2 - 1); // ±20% jitter const waitMs = Math.max(500, delay + jitter); log.warn(`[AgentLoop] Retry ${attempt + 1}/${this.config.retryCount} after ${Math.round(waitMs)}ms: ${(error as Error).message}`); await new Promise((resolve, reject) => { const timer = setTimeout(() => { // v0.3.0 修复: timer 先触发时移除 abort 监听器,避免监听器堆积 if (onAbort && signal) signal.removeEventListener('abort', onAbort); resolve(undefined); }, waitMs); // 支持 abort 中断等待 const signal = this.abortController?.signal; let onAbort: (() => void) | null = null; if (signal) { if (signal.aborted) { clearTimeout(timer); reject(new Error('Aborted')); return; } onAbort = () => { clearTimeout(timer); reject(new Error('Aborted')); }; signal.addEventListener('abort', onAbort, { once: true }); } }); } } } throw lastError; } /** 判断错误是否可重试 */ private isRetryableError(error: unknown): boolean { const err = error as { status?: number; code?: string; message?: string }; // 429 Too Many Requests — 可重试 if (err.status === 429) return true; // 5xx 服务器错误 — 可重试 if (err.status && err.status >= 500 && err.status < 600) return true; // 网络超时/连接错误 — 可重试 if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true; // P2-9 一致性修复: toLowerCase 避免大小写敏感漏判 // SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截) const msg = err.message?.toLowerCase() ?? ''; if (msg.includes('aborted') || msg.includes('socket hang up')) return true; // 其他错误(400/401/403/4xx)不重试 return false; } /** 生成下一个事件序列号 */ private nextSeq(): number { return ++this.eventSeq; } private async transitionTo(state: AgentLoopState): Promise { const previous = this.currentState; this.currentState = state; this.emit('stateChange', { previous, current: state, state, sessionId: this.currentSessionId, iteration: this.currentIteration, runId: this.runId, }); } /** * 估算消息列表的 token 数 * 使用智能字符估算:中文 1.5 token/字,ASCII 0.25 token/字,其他 1 token/字 * @see electron/harness/utils/token-estimator.ts */ private estimateMessagesTokens(messages: MetonaMessage[]): number { return estimateMessagesTokens(messages); } /** * v0.3.0: 死循环检测 * * 检测策略: * 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。 * 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。 * * v0.3.0 修复: * - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报 * * @param toolCalls 当前轮次的工具调用 * @returns 是否检测到死循环 */ private detectDeadLoop(toolCalls: MetonaToolCall[]): boolean { // 将当前轮次的工具调用序列化为签名 // v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名 // v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归 const stableStringify = (obj: unknown, visited: Set = new Set(), depth = 0): string => { if (depth > 10) return '...'; // 深度上限防止过度递归 if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); if (visited.has(obj)) return '"[Circular]"'; // 循环引用防护 visited.add(obj); try { if (Array.isArray(obj)) return `[${obj.map((v) => stableStringify(v, visited, depth + 1)).join(',')}]`; const keys = Object.keys(obj as Record).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record)[k], visited, depth + 1)}`).join(',')}}`; } finally { visited.delete(obj); } }; const signature = toolCalls .map((tc) => `${tc.name}(${stableStringify(tc.args)})`) .join('|'); this.toolCallHistory.push(signature); // 只保留最近5轮的记录(足够检测3轮重复,同时避免内存增长) if (this.toolCallHistory.length > 5) { this.toolCallHistory.shift(); } // 需要至少3轮数据才能检测 if (this.toolCallHistory.length < 3) return false; const len = this.toolCallHistory.length; const r1 = this.toolCallHistory[len - 1]; // 当前轮 const r2 = this.toolCallHistory[len - 2]; // 上一轮 const r3 = this.toolCallHistory[len - 3]; // 上上一轮 // 连续3轮完全相同 → 死循环 return r1 === r2 && r2 === r3; } /** * 上下文压缩 — 将旧消息摘要为一条 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; let toCompress = messages.slice(0, messages.length - keepRecent); let toKeep = messages.slice(messages.length - keepRecent); // MT-3 修复: 确保 toKeep 不以孤立的 tool 消息开头 // OpenAI/DeepSeek API 要求 tool 消息前必须有带 tool_calls 的 assistant 消息 // 如果压缩边界正好切在 assistant(tool_calls) 和 tool 之间,下一轮 LLM 调用会返回 400 if (toKeep.length > 0 && toKeep[0].role === 'tool' && toCompress.length > 0) { // 反向查找最近的带 tool_calls 的 assistant 消息 let assistantIdx = -1; for (let i = toCompress.length - 1; i >= 0; i--) { if (toCompress[i].role === 'assistant' && toCompress[i].toolCalls?.length) { assistantIdx = i; break; } } if (assistantIdx < 0) { // 找不到配对的 assistant 消息,直接丢弃孤立的 tool 消息 toKeep = toKeep.slice(1); } else { // 将配对的 assistant 消息及其之后的所有消息移到 toKeep 开头 const moved = toCompress.slice(assistantIdx); toCompress = toCompress.slice(0, assistantIdx); toKeep = [...moved, ...toKeep]; } } // 如果 toCompress 为空,无法生成摘要 if (toCompress.length === 0) return null; // 构建摘要请求 // 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口 const conversationText = toCompress.map((m) => { const role = m.role.toUpperCase(); return `[${role}] ${m.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.send(summaryRequest); const summary = response.content.trim(); if (!summary) return null; const summaryMessage: MetonaMessage = { // CE-1 修复: 使用 role: 'user' 而非 'system' // adapter 的 buildOpenAICompatibleMessages 会过滤所有 role !== 'system' 的消息, // 只保留 systemPrompt 构建的 system 消息。如果用 'system',摘要会被丢弃,压缩无效。 // 改为 'user' 后 LLM 会看到:system(systemPrompt) → user(摘要) → ...toKeep role: 'user', 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; this.totalTokens.totalTokens += usage.totalTokens; } private finish( reason: TerminationReason, answer?: string, error?: Error, ): AgentLoopOutput { // P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误 // v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见 // 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw if ((reason === TerminationReason.ERROR || reason === TerminationReason.DEAD_LOOP) && error) { this.emit('streamEvent', { type: MetonaStreamEventType.ERROR, requestId: this.currentRequestId, sessionId: this.currentSessionId, iteration: this.currentIteration, seq: this.nextSeq(), timestamp: Date.now(), runId: this.runId, error: { code: MetonaErrorCode.UNKNOWN, message: error.message, retryable: false, }, }); } // 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态 this.emit('streamEvent', { type: MetonaStreamEventType.DONE, requestId: this.currentRequestId, sessionId: this.currentSessionId, iteration: this.currentIteration, seq: this.nextSeq(), timestamp: Date.now(), runId: this.runId, terminationReason: reason, }); // H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI) const prevTerminated = this.currentState; this.currentState = AgentLoopState.TERMINATED; this.emit('stateChange', { previous: prevTerminated, current: AgentLoopState.TERMINATED, state: AgentLoopState.TERMINATED, sessionId: this.currentSessionId, iteration: this.currentIteration, runId: this.runId, }); // 发射 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, iterations: this.iterations, totalTokenUsage: this.totalTokens, durationMs: Date.now() - this.startTime, metadata: { config: this.config, error: error?.message }, }; } }