Files
metona-ai-desktop/electron/harness/agent-loop/engine.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

1710 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
}
}
/**
* v0.8.0 P0-2: 空响应错误 — 流结束但零产出(无 reasoning、无正文、无工具调用)。
* 属可重试的瞬时故障(上游断流被 [DONE] 兜底合成 / 网关干净空响应),携带
* status=503 使 engine.isRetryableError 的 5xx 分支命中,走指数退避重试;
* 重试耗尽后以 ERROR 收尾并给用户明确文案(此前该形态被静默判定 COMPLETED,
* 即"模型思考着会话就停止"的根因之一)。
*/
export class EmptyResponseError extends Error {
readonly status = 503;
constructor(message: string) {
super(message);
this.name = 'EmptyResponseError';
}
}
/**
* v0.8.0 P0-2: 输出截断错误 — 思考耗尽型截断(reasoning 烧满输出预算、正文零字节,
* finish_reason=length),一次性降级重试(关闭思考)后仍复现时抛出。
* 不可重试(status=400),携带 code=OUTPUT_LENGTH_EXCEEDEDIR 预留错误码首次
* 投产),engine.finish() 据此向前端发送结构化错误;非可重试语义同时触发
* Provider 故障转移(备用 Provider 输出预算更大时即构成自愈)。
*/
export class OutputTruncatedError extends Error {
readonly status = 400;
readonly code = MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED;
constructor(message: string) {
super(message);
this.name = 'OutputTruncatedError';
}
}
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 = '';
/**
* v0.8.0 P0-1: 最近一轮 LLM 流的 Provider 原生停止原因(归一化 OpenAI 语义)。
* 由 adapter DONE 事件携带、引擎捕获;finish() 据此在最终 DONE 事件上转发,
* 前端/TRACE 据此区分自然完成与输出上限截断。
*/
private lastIterationFinishReason: string | undefined;
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
private currentRunPromise: Promise<AgentLoopOutput> | 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<AgentLoopConfig> = {},
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<AgentLoopConfig>): 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<AgentLoopOutput> {
// 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<void>((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<boolean>((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<AgentLoopOutput> {
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 = [];
// v0.8.0 P0-1: 重置最近一轮停止原因
this.lastIterationFinishReason = undefined;
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);
// v0.8.0 P0-1: 记录最近一轮停止原因(供 finish() 的 DONE 事件转发)
this.lastIterationFinishReason = step.finishReason;
// v0.8.0 P0-2 回归根治: abort 优先级 —— 旧实现该检查位于 while 循环
// 末尾,若 abort 恰逢"零工具调用轮"(如思考中点停止),空轮会先命中
// 下方 COMPLETED 分支,把 USER_INTERRUPT 静默吞成 completed(与新
// 回归测试 engine-stream-reliability 同时发现)。此处前置检查修复。
if (this.aborted) {
return this.finish(TerminationReason.USER_INTERRUPT);
}
// 将 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
// assistantcontent=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 为 nullLLM 会看到 "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<boolean> {
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<void>((resolve) => {
timerHandle = setTimeout(() => {
timedOut = true;
resolve();
}, timeoutMs);
});
try {
await Promise.race([this.currentRunPromise.catch(() => {}), timer]);
return !timedOut; // 超时返回 falserun 正常结束返回 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<IterationStep> {
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<number, { name: string; argsBuffer: string }>();
let tokenUsage: TokenUsage | undefined;
// v0.8.0 P0-1: 本轮流的 Provider 原生停止原因(adapter DONE 携带)
let iterationFinishReason: string | undefined;
// 流式接收响应
for await (const event of this.chatStreamWithRetry(request)) {
if (this.aborted) break;
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
// v0.8.0 P0-1: 先捕获 adapter 携带的 Provider 原生停止原因再过滤 ——
// 引擎与 TRACE 据此区分自然完成与输出上限截断(length 此前完全不可见)
if (event.type === MetonaStreamEventType.DONE) {
if (event.finishReason) iterationFinishReason = event.finishReason;
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);
// v0.8.0 P0-1: 记录本轮停止原因到迭代步骤(TRACE/审计/前端提示的输入)
if (iterationFinishReason) {
step.finishReason = iterationFinishReason;
}
// 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<number, { name: string; argsBuffer: string }>,
): void {
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = [];
for (const [, buf] of toolCallsBuffer) {
let args: Record<string, unknown>;
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<PromiseSettledResult<MetonaToolResult>[] | null> {
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
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<null>((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<MetonaToolResult>[]
| null;
// 清理 abort 监听器,避免事件循环中残留
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
return raceResult;
}
/**
* 安全执行工具调用(经过 Hook 管道)
*/
private async executeToolSafely(toolCall: MetonaToolCall): Promise<MetonaToolResult> {
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<typeof setTimeout> | 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<MetonaToolResult>((_, 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-hookP0-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<MetonaStreamEvent> {
const baseDelayMs = 1_000;
const maxDelayMs = 30_000;
let attempt = 0;
let currentAdapter = this.adapter;
let failoverUsed = false;
// v0.8.0 P0-2: 降级重试状态。思考耗尽型截断(reasoning 烧满输出预算、正文
// 零字节)时以 thinkingEnabled=false 重发一次(每 run 最多一次);activeRequest
// 被替换后,后续的重试与故障转移均使用降级版请求(降级跨故障转移保持)。
let degradedThinkingRetryUsed = false;
let activeRequest = request;
while (true) {
try {
if (attempt > 0) {
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
yield {
type: MetonaStreamEventType.ERROR,
requestId: activeRequest.meta.requestId,
sessionId: activeRequest.meta.sessionId,
iteration: activeRequest.meta.iteration,
seq: 0,
timestamp: Date.now(),
error: {
code: MetonaErrorCode.RETRY,
message: `Retrying after error (attempt ${attempt}/${this.config.retryCount})`,
retryable: true,
},
};
}
// v0.8.0 P0-2: 逐事件跟踪内容产出与停止原因(原 yield* 直通改显式转发)。
// yield* 正常返回后执行流结束契约检查;异常路径不进入检查(走 catch 重试)。
// - sawThought: 思考有产出(REASONING_DELTA
// - sawOutput: 正文/工具调用有产出(TEXT_DELTA / TOOL_CALL_*
// - lastFinishReason: adapter DONE 携带的 Provider 原生停止原因
let sawThought = false;
let sawOutput = false;
let lastFinishReason: string | undefined;
for await (const event of currentAdapter.sendStream(activeRequest)) {
if (event.type === MetonaStreamEventType.REASONING_DELTA) {
sawThought = true;
}
if (
event.type === MetonaStreamEventType.TEXT_DELTA ||
event.type === MetonaStreamEventType.TOOL_CALL_DELTA ||
event.type === MetonaStreamEventType.TOOL_CALL_COMPLETE
) {
sawOutput = true;
}
if (event.type === MetonaStreamEventType.DONE && event.finishReason) {
lastFinishReason = event.finishReason;
}
yield event;
}
// ===== 流结束契约检查(根治"空回复 → 静默 COMPLETED → 会话无声停止"=====
if (!sawOutput) {
if (!sawThought) {
// 零产出流:连接中断被 [DONE] 兜底合成 / 上游干净空响应 —— 可重试
// 瞬时故障(status=503 命中 5xx 重试分支),重试耗尽后 ERROR 收尾
throw new EmptyResponseError(
'Model returned an empty stream (no reasoning, no text, no tool calls). ' +
'This is usually caused by an upstream connection drop or an output-limit cutoff.',
);
}
if (lastFinishReason === 'length') {
// 思考耗尽型截断(v0.7.4 生产事故形态:finish_reason=length、正文零
// 字节)—— 先做一次性降级重试(临时关闭思考重发),仍复现则终止。
if (!degradedThinkingRetryUsed && activeRequest.params.thinkingEnabled === true) {
degradedThinkingRetryUsed = true;
attempt = 0;
activeRequest = {
...activeRequest,
params: { ...activeRequest.params, thinkingEnabled: false },
};
log.warn(
'[AgentLoop] Output budget exhausted by reasoning (finish_reason=length, empty content) — retrying once with thinking disabled',
);
yield {
type: MetonaStreamEventType.ERROR,
requestId: activeRequest.meta.requestId,
sessionId: activeRequest.meta.sessionId,
iteration: activeRequest.meta.iteration,
seq: 0,
timestamp: Date.now(),
error: {
code: MetonaErrorCode.RETRY,
message: 'Thinking consumed the output budget — retrying with thinking disabled',
retryable: true,
},
};
continue;
}
// 降级重试不可用(已用 / 思考本就关闭)仍截断 —— 终止:携带 IR 预留
// 的 OUTPUT_LENGTH_EXCEEDED 码,finish() 映射为结构化错误事件,
// 非 retryable 同时触发 Provider 故障转移(备用预算更大即自愈)。
throw new OutputTruncatedError(
'The model consumed the entire output token budget on reasoning and produced no answer ' +
'(finish_reason=length). Raise the max output limit, disable thinking, or switch to a model with a larger output budget.',
);
}
// 思考有产出但自然结束且零正文(罕见的服务端异常收尾)—— 与零产出流
// 同按可重试空响应处理(retryCount 兜底防循环)
throw new EmptyResponseError(
'Model stream ended after reasoning without any answer content.',
);
}
// sawOutput=true:正文/工具调用已有产出。length 截断场景不再重试 ——
// 工具参数截断由 _truncatedArguments 自愈(v0.6.3 契约)、正文截断由
// DONE.finishReason='length' 驱动前端截断提醒(P0-4)。
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 {
// v0.8.0 P0-2: 流结束契约错误的显式归类(EmptyResponseError 自带 status=503、
// OutputTruncatedError 自带 status=400,本可由下方 5xx/4xx 分支覆盖;此处
// 显式 instanceof 使两类新错误的语义不依赖 status 字段约定)
if (error instanceof EmptyResponseError) return true;
if (error instanceof OutputTruncatedError) return false;
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<void> {
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<unknown>, depth?: number) => string {
const stableStringify = (
obj: unknown,
visited: Set<unknown> = 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<string, unknown>).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record<string, unknown>)[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<MetonaMessage[] | null> {
// 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 上下文),一并识别
// v0.8.0 P0-2: 透传流结束契约错误的结构化错误码(OUTPUT_LENGTH_EXCEEDED 等),
// 前端据此展示针对性文案而非泛化 UNKNOWN
const isContentFilter =
error instanceof ContentFilterError ||
(error as Error & { code?: string }).code === MetonaErrorCode.CONTENT_FILTERED;
const rawErrorCode = (error as Error & { code?: string }).code;
const errorCode = isContentFilter
? MetonaErrorCode.CONTENT_FILTERED
: rawErrorCode === MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED
? MetonaErrorCode.OUTPUT_LENGTH_EXCEEDED
: MetonaErrorCode.UNKNOWN;
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: errorCode,
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,
// v0.8.0 P0-1: 转发最后一轮的 Provider 原生停止原因(前端据此展示
// "回答可能因输出上限被截断"等提示;无流式轮次时为 undefined)
...(this.lastIterationFinishReason ? { finishReason: this.lastIterationFinishReason } : {}),
});
// 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 },
};
}
}