/** * 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 AgentLoopConfig, type AgentLoopOutput, type TokenUsage, } from './types'; import type { MetonaRequest, MetonaMessage, MetonaSystemPrompt, MetonaToolCall, MetonaToolResult, MetonaStreamEvent, IMetonaProviderAdapter, MetonaToolDef, } from '../types'; import { MetonaStreamEventType, MetonaErrorCode } from '../types'; import { estimateMessagesTokens } from '../utils/token-estimator'; import { ContentFilterError } from '../adapters/base-adapter'; import { truncatedArgumentsPayload } from '../adapters/shared/sse-stream'; 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, 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; /** * v0.3.18 修复: 最近一次 LLM 调用返回的真实输入 token 数 * 用于校正压缩判断——估算值可能偏低(尤其中文场景), * 导致估算没到阈值不压缩,但真实 API 调用已经 413。 * 压缩判断时取 max(估算值, 真实值) 作为实际占用。 */ private lastRealInputTokens = 0; /** 当前 run 的唯一标识(用于前端过滤旧流事件) */ private runId: string = ''; /** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */ private currentRunPromise: Promise | null = null; /** v0.3.0: 工具调用签名历史(用于死循环检测) */ private toolCallHistory: string[] = []; /** * v0.7.4 P4-1: 每轮工具执行结果的签名历史(与 toolCallHistory 按轮一一对应)。 * 用于乒乓模式(ABAB)的"进度信号"二次确认——若 ABAB 周期内两次 A 的调用与 * 结果均相同、两次 B 的调用与结果均相同,说明模型在完全相同的操作间空转, * 判定死循环;若结果有差异(模型获得了新信息),放行避免误报合法交替工作流 * (如"读→写→读验证→写修复"在写入内容变化时本身不构成 ABAB,此处防御 * 结果变化但参数不变的外界状态型交替)。 */ private toolResultHistory: 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; // #3 修复: 热切换 adapter 时同步 contextWindow,防止压缩阈值计算错误 this.syncContextWindow(); } /** * #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置 * * Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的 * 上下文窗口差异巨大(DeepSeek 1M / Agnes 1M / MiMo 1M / OpenAI 128K~1M / * Anthropic 200K / Ollama 4096 起,v0.7.4 修正注释——此前误写 64K)。 * 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。 */ private syncContextWindow(): void { const adapterCtx = this.adapter.getContextWindow?.(); if (adapterCtx && adapterCtx > 0) { this.config.contextWindow = adapterCtx; } } /** * P1: 故障转移 Provider(主 Provider 重试耗尽后切换,见 chatStreamWithRetry) * 由 AgentEngineManager 在创建引擎时注入;null 表示未配置故障转移。 */ private fallbackAdapter: IMetonaProviderAdapter | null = null; setFallbackAdapter(adapter: IMetonaProviderAdapter | null): void { this.fallbackAdapter = 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 中断新流 // v0.7.4 P4-2: 等待加 30s 超时——上一 run 若卡在流空闲/不响应 abort 的环节 // (P1-2 已加流空闲超时兜底,此处为最后一道防线),新消息不再无限排队。 // 超时后强制 abort 旧 run 并再等待 5s;仍不结束则抛错(上层保证 ERROR+DONE 收尾)。 if (this.currentRunPromise) { const prevRun = this.currentRunPromise; let timedOut = false; let timerHandle: NodeJS.Timeout | undefined; const timer = new Promise((resolve) => { timerHandle = setTimeout(() => { timedOut = true; resolve(); }, 30_000); }); try { await Promise.race([prevRun.catch(() => {}), timer]); } finally { if (timerHandle) clearTimeout(timerHandle); } if (timedOut) { log.warn( '[AgentLoop] Previous run did not finish within 30s — forcing abort before new run', ); this.abort(); const forced = await Promise.race([ prevRun.catch(() => {}).then(() => true), new Promise((resolve) => { const t = setTimeout(() => resolve(false), 5_000); // 避免 timer 悬挂 (t as unknown as { unref?: () => void }).unref?.(); }), ]); if (!forced) { throw new Error( 'Previous run did not finish even after force abort (5s). Please restart the app or wait for the stuck operation to complete.', ); } } } 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 }; // v0.3.18 修复: 每个 run 重置 lastRealInputTokens,避免跨 run 污染 this.lastRealInputTokens = 0; this.abortController = new AbortController(); // C-2 修复: 将 abortController 的 signal 注入到 adapter, // 使正在进行的 fetch 可被用户中断,避免资源泄漏 if (this.adapter.setAbortSignal) { this.adapter.setAbortSignal(this.abortController.signal); } // #3 修复: 每次 runStream 开始时从 adapter 同步 contextWindow, // 确保压缩阈值基于当前 Provider 的实际上下文窗口 this.syncContextWindow(); this.eventSeq = 0; // v0.3.0: 重置工具调用历史(用于死循环检测) this.toolCallHistory = []; // v0.7.4 P4-1: 重置工具结果历史(进度信号,与调用历史按轮对应) this.toolResultHistory = []; 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)` 在模型发起纯工具调用 // (零文本、零思考内容 — DeepSeek 高频行为)时跳过 assistant 消息,但下方 // tool 结果消息照常 push → 下一轮请求出现孤立 tool 消息 → API 400 // "Messages with role 'tool' must be a response to a preceding message // with 'tool_calls'" → 会话 ERROR 终止。有 toolCalls 的轮次必须 push // assistant(content=null,符合 C-6 规范)。 if (step.thought || (step.toolCalls && step.toolCalls.length > 0)) { const assistantMsg: MetonaMessage = { role: 'assistant', content: step.thought?.content ?? null, 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(); // v0.7.4 P1-1: 区分三态,修复"真实网络超时被误报为 USER_INTERRUPT"。 // 旧实现把 'timeout'/'timed out' 一律映射 USER_INTERRUPT,但 fetchWithTimeout // 的超时错误("Request timed out after ...")在重试耗尽后也会命中——用户并未 // 中断,前端却显示"用户中断"语义,SLO 统计与错误诊断全部失真。 // 判定顺序:用户主动中断 > 真实超时 > 其余错误。 if (this.aborted) { return this.finish(TerminationReason.USER_INTERRUPT); } const errCode = (error as { code?: string }).code; const isTimeout = errCode === 'ETIMEDOUT' || errCode === 'UND_ERR_CONNECT_TIMEOUT' || errMsgLower.includes('timed out') || errMsgLower.includes('timeout'); if (isTimeout) { return this.finish(TerminationReason.TIMEOUT, undefined, error as Error); } // 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; // #29 修复: 原实现 Promise.race 永不抛错(currentRunPromise.catch 已吞异常), // 导致 try/catch 永远走不到,总是返回 true。调用方误以为 abort 完成, // 立即重发的新消息会卡在 runStream 的 currentRunPromise 等待中。 // 改为用 timedOut 标志检测超时,超时返回 false 让调用方提示用户"上一次操作未完成"。 let timedOut = false; // 审查修复: 用 finally 块清理 timer,避免 currentRunPromise 先完成时 setTimeout 残留 let timerHandle: NodeJS.Timeout | undefined; const timer = new Promise((resolve) => { timerHandle = setTimeout(() => { timedOut = true; resolve(); }, timeoutMs); }); try { await Promise.race([this.currentRunPromise.catch(() => {}), timer]); return !timedOut; // 超时返回 false,run 正常结束返回 true } finally { // 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留 if (timerHandle) clearTimeout(timerHandle); } } /** * 销毁引擎,清理所有监听器 */ 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' 强制转换,确保类型安全 // v0.7.4 P4-3: 双通道 — 内部清空缓冲的同时,向渲染层广播 STREAM_RESET 信号, // 前端据此清空该 runId 已累积的文本/思考增量(重试会从头重建整个响应), // 根治"第一段文本 + 重试后第二段文本"拼接重复(依赖 seq 无法去重, // 因为 adapter 侧 seq 每次从 0 重启)。 if ( event.type === MetonaStreamEventType.ERROR && event.error?.code === MetonaErrorCode.RETRY ) { // 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收) fullContent = ''; reasoningContent = ''; toolCallsBuffer.clear(); this.emit('streamEvent', { type: MetonaStreamEventType.STREAM_RESET, requestId: event.requestId, sessionId: event.sessionId, iteration: event.iteration, seq: this.nextSeq(), timestamp: Date.now(), runId: this.runId, }); 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, }; // v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断 // 估算值可能偏低(尤其中文场景),导致不压缩但 API 413 const realInput = event.usage.inputTokens ?? 0; if (realInput > 0) { this.lastRealInputTokens = realInput; } } break; case MetonaStreamEventType.ERROR: // RETRY 已在循环入口过滤,此处只处理真正的错误。 // v0.6.4: 保留结构化错误码 —— finish() 据 code 区分 content_filtered 等 // 类型化终止(原实现全部 new Error(message),信息被压缩为 UNKNOWN)。 if (event.error) { const streamError = new Error(event.error.message); (streamError as Error & { code?: string }).code = event.error.code; throw streamError; } 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轮重复调用的副作用不会产生(工具尚未执行) // v0.7.4 P4-1 拆分: 此处仅做"驻留模式"(连续 3 轮相同签名,无结果依赖, // 可在执行前安全判定防副作用);"乒乓模式"(ABAB + 进度信号)需要本轮 // 工具结果做二次确认,移至 EXECUTING 之后(OBSERVING 前)检测。 if (step.toolCalls && step.toolCalls.length > 0) { if (this.detectStuckLoop(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, or two alternating call patterns kept cycling (A→B→A→B) without progress. 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; }); } // v0.7.4 P4-1: 乒乓模式死循环检测(ABAB + 进度信号二次确认)—— // 移至 EXECUTING 之后:乒乓判定需要本轮工具**结果**做进度比对 // (两次 A 的结果是否相同、两次 B 的结果是否相同),PARSING 阶段 // 本轮结果尚不可得。驻留模式仍在执行前检测(防第 3 轮副作用)。 if (step.toolCalls && step.toolCalls.length > 0) { if (this.detectPingPong(step.toolCalls, step.toolResults)) { log.warn( `[AgentLoop] Ping-pong dead loop detected at iteration ${this.currentIteration} (ABAB with identical results — no progress)`, ); this.emit('deadLoop', { iteration: this.currentIteration, runId: this.runId, sessionId: this.currentSessionId, }); throw new DeadLoopError( `Detected a potential infinite loop: two alternating call patterns kept cycling (A→B→A→B) with identical results — no progress. Please refine the approach or provide more specific instructions.`, ); } } // === OBSERVING === await this.transitionTo(AgentLoopState.OBSERVING); // === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 === // v0.7.3 接线说明:REFLECTING 分支此前依赖 enableReflection 配置,但该配置 // 全链路无任何置 true 的路径(死配置)。现由 agent.enableReflection 配置 // 真实驱动(main.ts baseConfig → updateConfigAll → 本分支),启用后每轮 // 工具执行完毕会经过 REFLECTING 状态:工具结果存在失败时记录告警日志, // 供 SLO 与排障观察(不阻断循环——错误结果已由 CE-2 路径回传模型自愈)。 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 // v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发 const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000; const estimatedTokens = this.estimateMessagesTokens(request.messages); // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413 // 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景) // 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确 const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens); const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow; // v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4" // 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。 // 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。 if (actualTokens > compressionThreshold && request.messages.length >= 4) { await this.transitionTo(AgentLoopState.COMPRESSING); const compressed = await this.compressMessages(request.messages); if (compressed) { // 原地替换数组内容,确保外层 messages 引用同步更新 request.messages.splice(0, request.messages.length, ...compressed); // v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值 this.lastRealInputTokens = 0; this.emit('compressed', { iteration: this.currentIteration, originalTokens: actualTokens, 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 (err) { // v0.6.4: 引擎侧兜底缓冲的截断自愈对齐 —— 此处是全链路最后一个 // "解析失败静默 args={}" 的残留点。统一转为 _truncatedArguments, // 保证任何 Provider 路径的截断工具调用都能触发模型自愈而非静默丢失。 const sample = buf.argsBuffer.slice(-120); args = truncatedArgumentsPayload((err as Error).message, sample); } 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, // P0-4: 引擎级 abort 信号透传——用户中断时工具内部(如 run_command 子进程)可自行终止 signal: this.abortController?.signal, }), 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(P0-2: 错误结果同样过安全扫描/审计;钩子可返回修改后的结果) let errorResult = toolResult; for (const hook of this.postToolHooks) { const modified = await hook.afterExecute(toolCall, errorResult, this.currentSessionId); if (modified) errorResult = modified; } return errorResult; } finally { // M-16 修复: 清理未触发的 timeout timer if (engineTimer) clearTimeout(engineTimer); } // 后置 Hook 管道(P0-2: 钩子可返回修改后的结果——如 SecurityScanHook 对网页内容脱敏) let finalResult = toolResult; for (const hook of this.postToolHooks) { const modified = await hook.afterExecute(toolCall, finalResult, this.currentSessionId); if (modified) finalResult = modified; } return finalResult; } /** * 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: 指数退避;P1: Provider 故障转移) * * 重试策略: * 1. 可重试错误(429/5xx/网络)→ 指数退避重试(1s/2s/4s...,上限 30s,±20% jitter) * 2. 重试耗尽或不可重试错误 → 若配置了 fallbackAdapter,切换 Provider 重发本次请求 * 3. 故障转移仅触发一次(防止主/备 Provider 间乒乓切换) * * 故障转移后 this.adapter 切换为 fallback,本 run 内后续迭代均使用备用 Provider, * 并通过 'providerSwitched' 事件通知上层(IPC → 前端系统消息 + Toast)。 */ private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable { const baseDelayMs = 1_000; const maxDelayMs = 30_000; let attempt = 0; let currentAdapter = this.adapter; let failoverUsed = false; while (true) { try { if (attempt > 0) { // 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta 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}/${this.config.retryCount})`, retryable: true, }, }; } yield* currentAdapter.sendStream(request); return; } catch (error) { if (this.aborted) throw error; const retryable = this.isRetryableError(error); // P1: 故障转移 — 重试耗尽或不可重试错误(如 401 密钥失效)时切换备用 Provider if ( !failoverUsed && this.fallbackAdapter && this.fallbackAdapter !== currentAdapter && (!retryable || attempt >= this.config.retryCount) ) { failoverUsed = true; const fromId = currentAdapter.providerId; this.adapter = this.fallbackAdapter; // 本 run 内后续迭代均使用 fallback currentAdapter = this.fallbackAdapter; this.syncContextWindow(); // 故障转移后重新注入 abort 信号(新 adapter 实例需要关联引擎的中断控制器) if (this.abortController && currentAdapter.setAbortSignal) { currentAdapter.setAbortSignal(this.abortController.signal); } log.warn( `[AgentLoop] Provider failover: ${fromId} → ${currentAdapter.providerId} (${(error as Error).message})`, ); this.emit('providerSwitched', { from: fromId, to: currentAdapter.providerId, sessionId: this.currentSessionId, reason: 'failover', }); attempt = 0; 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: `Primary provider failed, switching to fallback (${currentAdapter.providerId})`, retryable: true, }, }; continue; } if (!retryable || attempt >= this.config.retryCount) throw error; attempt++; // 指数退避 + 抖动 const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt - 1)); const jitter = delay * 0.2 * (Math.random() * 2 - 1); // ±20% jitter const waitMs = Math.max(500, delay + jitter); log.warn( `[AgentLoop] Retry ${attempt}/${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 }); } }); } } } /** 判断错误是否可重试 */ 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 提前拦截) // v0.7.4 C-9 修复: 移除对 'aborted' 字样的宽泛匹配——用户主动 abort 路径已由 // this.aborted 拦截,错误 message 含 'aborted' 的多为 Provider 合法错误文案 // (如 Anthropic "request aborted" 变体),不应进入重试放大。仅保留网络层中断信号。 const msg = err.message?.toLowerCase() ?? ''; if (msg.includes('socket hang up') || msg.includes('fetch failed')) 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.0 token/字,ASCII 0.25 token/字,其他 1 token/字 * v0.3.18: CJK 系数从 1.5 调整为 1.0,更贴近 BPE 实际值 * @see electron/harness/utils/token-estimator.ts */ private estimateMessagesTokens(messages: MetonaMessage[]): number { return estimateMessagesTokens(messages); } /** * v0.3.0: 死循环检测(驻留模式)—— v0.7.4 P4-1 拆分自 detectDeadLoop。 * * 连续 3 轮调用签名完全相同(工具名 + 稳定序列化参数)→ 死循环。 * 在 PARSING 后、EXECUTING 前检测:无结果依赖,可安全地在副作用发生前终止。 * * @param toolCalls 当前轮次的工具调用 * @returns 是否检测到驻留死循环 */ private detectStuckLoop(toolCalls: MetonaToolCall[]): boolean { const stableStringify = this.makeStableStringify(); const signature = toolCalls.map((tc) => `${tc.name}(${stableStringify(tc.args)})`).join('|'); this.toolCallHistory.push(signature); // 本轮结果未知(执行前)——push 占位,乒乓检测在 EXECUTING 后补记真实结果 this.toolResultHistory.push(''); // 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长) if (this.toolCallHistory.length > 5) { this.toolCallHistory.shift(); this.toolResultHistory.shift(); } const len = this.toolCallHistory.length; if (len >= 3) { const r1 = this.toolCallHistory[len - 1]; const r2 = this.toolCallHistory[len - 2]; const r3 = this.toolCallHistory[len - 3]; if (r1 === r2 && r2 === r3) return true; } return false; } /** * v0.7.4 P4-1: 乒乓模式死循环检测(ABAB + 进度信号)。 * * 最近 4 轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)且**结果无推进**时 * 判定死循环。在 EXECUTING 之后调用(本轮工具结果已可用)。 * * 进度信号:比对两次 A 轮的**工具结果签名**与两次 B 轮的结果签名—— * 若 A 两次结果相同、B 两次结果相同,说明模型在完全相同的操作间空转 * (外界状态未变,结果无新信息),判定死循环;任一结果有差异则放行 * (合法交替工作流如"读→写→读验证→写修复"结果随写入推进变化)。 * 结果签名缺失(工具失败/无成功结果)且四轮签名均为空时回退纯调用签名判定。 * * @param toolCalls 当前轮次的工具调用(用于记录签名;驻留检测已 push 过调用签名) * @param toolResults 当前轮次的工具执行结果(进度信号) * @returns 是否检测到乒乓死循环 */ private detectPingPong(toolCalls: MetonaToolCall[], toolResults?: MetonaToolResult[]): boolean { // 补记本轮真实结果签名(覆盖 detectStuckLoop 的占位空串) const stableStringify = this.makeStableStringify(); const resultSignature = (toolResults ?? []) .filter((r) => r.success) .map((r) => stableStringify(r.result)) .join('|'); if (this.toolResultHistory.length > 0) { this.toolResultHistory[this.toolResultHistory.length - 1] = resultSignature; } const len = this.toolCallHistory.length; if (len < 4) return false; const a1 = this.toolCallHistory[len - 4]; const b1 = this.toolCallHistory[len - 3]; const a2 = this.toolCallHistory[len - 2]; const b2 = this.toolCallHistory[len - 1]; if (!(a1 === a2 && b1 === b2 && a1 !== b1)) return false; // 进度信号:两次 A 的结果必须相同、两次 B 的结果必须相同,才判定空转死循环 const ra1 = this.toolResultHistory[len - 4]; const rb1 = this.toolResultHistory[len - 3]; const ra2 = this.toolResultHistory[len - 2]; const rb2 = this.toolResultHistory[len - 1]; // 四轮结果都可用且完全相同 → 空转死循环 if (ra1 !== '' && rb1 !== '' && ra2 !== '' && rb2 !== '' && ra1 === ra2 && rb1 === rb2) { return true; } // 四轮结果签名全部缺失(工具失败/无成功结果)→ 回退纯调用签名判定 // (调用签名 ABAB 空转本身已构成停滞特征) if (ra1 === '' && rb1 === '' && ra2 === '' && rb2 === '') { return true; } return false; } /** 稳定序列化工厂(键排序 + 防循环引用 + 深度上限) */ private makeStableStringify(): (obj: unknown, visited?: Set, depth?: number) => string { 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); } }; return stableStringify; } /** * 上下文压缩 — 将旧消息摘要为一条 assistant 消息,保留近期完整对话 * * v0.3.18 修复: 保留策略从"固定 10 条"改为"按 token 预算动态截断" * 之前固定保留最近 10 条,若 10 条里有超长 tool_result(如 read_file 读了 5000 行), * 压缩后仍超 token 限制,下一轮 LLM 调用会返回 413。 * 现在按 effectiveContextWindow 的 50% 预算从末尾反向累加消息, * 直到预算用尽或只剩 1 条可压缩消息。 * * 策略: * 1. 计算 keepBudget = effectiveContextWindow * 0.5(保留区预算) * 2. 从末尾反向累加消息到 toKeep,直到累加 token 超过 keepBudget * 3. 将前面的所有消息交给 LLM 生成摘要 * 4. 用 [Context Summary] assistant 消息 + 占位 user + 近期消息替换原数组 * 5. 若单条消息超 keepBudget(超长 tool_result),单独二次截断 * * @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组) */ private async compressMessages(messages: MetonaMessage[]): Promise { // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 // 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000; const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50% const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文 // 从末尾反向累加,确定 toKeep 范围 let keepStartIdx = messages.length; let keepTokens = 0; for (let i = messages.length - 1; i >= 0; i--) { const msgTokens = this.estimateMessagesTokens([messages[i]]); if (keepTokens + msgTokens > keepBudget && i < messages.length - minKeepCount) { break; } keepTokens += msgTokens; keepStartIdx = i; } // 至少保留 minKeepCount 条,且至少要有 1 条可压缩 if (keepStartIdx < minKeepCount) keepStartIdx = minKeepCount; if (keepStartIdx >= messages.length) return null; let toCompress = messages.slice(0, keepStartIdx); let toKeep = messages.slice(keepStartIdx); // 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 = { // #30 修复: 改用 assistant 角色注入摘要,避免语义混淆 // 原 CE-1 修复用 'user' 角色,会导致 LLM 将摘要误视为新的用户指令, // 可能基于"Summary of previous conversation"字面意思执行奇怪操作。 // 工单建议方案 A(system 角色)不可行:buildOpenAICompatibleMessages 会 // 过滤所有 role === 'system' 的消息(只保留 systemPrompt 构建的 system 消息), // 用 system 角色摘要会被丢弃,压缩无效。 // 采用 assistant 角色:既不会被过滤,又保持语义中立(摘要是 AI 生成的总结), // LLM 不会将其视为新的用户指令。 role: 'assistant', content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`, timestamp: Date.now(), }; // v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断 // 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文 const finalKeep = toKeep.map((msg) => { const msgTokens = this.estimateMessagesTokens([msg]); if (msgTokens > keepBudget && msg.content) { // 截断内容,保留头部和尾部,中间用省略标记 const halfBudget = Math.floor(keepBudget / 2); // v0.3.18 修复: charsPerToken 从 2 调整为 1.0(与 CJK_TOKEN_RATIO 一致) // 原值 2 对中文偏激进(2 字符/token),实际中文约 1 字符/token, // 导致截断后保留字符过多,实际 token 仍超 keepBudget const charsPerToken = 1.0; const keepChars = Math.floor(halfBudget * charsPerToken); if (msg.content.length > keepChars * 2) { const head = msg.content.slice(0, keepChars); const tail = msg.content.slice(-keepChars); return { ...msg, content: `${head}\n\n... [truncated, ${msgTokens} tokens] ...\n\n${tail}`, }; } } return msg; }); log.info( `[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`, ); // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息 // (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。 // 插入占位 user 消息保证对话流清晰。 return [ summaryMessage, // 审查修复: 插入占位 user 消息避免连续 assistant 消息 { role: 'user', content: '[Continue from the summary above.]', timestamp: Date.now() }, ...finalKeep, ]; } 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) { // v0.3.17: 识别 ContentFilterError,映射为 CONTENT_FILTERED 错误码 + 友好消息 // v0.6.4: 流式路径的拦截以 err.code='content_filtered' 抵达(无 instanceof 上下文),一并识别 const isContentFilter = error instanceof ContentFilterError || (error as Error & { code?: string }).code === MetonaErrorCode.CONTENT_FILTERED; 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: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN, message: isContentFilter ? '内容被 Provider 安全审核拦截,请修改图片或文本后重试' : 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 }, }; } }