Files
metona-ai-desktop/electron/harness/agent-loop/engine.ts
T
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

1848 lines
80 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,
MetonaThinkingBlock,
IMetonaProviderAdapter,
MetonaToolDef,
} from '../types';
import { MetonaStreamEventType, MetonaErrorCode } from '../types';
import { estimateMessagesTokens, estimateStringTokens } 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,
// v0.8.1: contextWindow / maxTokens 不再携带任何写死默认值 —— 唯一合法来源是
// 设置面板 LLM 配置(llm.contextWindow / llm.maxTokens),由 main.ts baseConfig
// 与 applyEngineConfigKey 注入。未配置时压缩判定跳过、输出上限参数不下发。
retryCount: 3,
temperature: 0.0,
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 配置
*
* v0.8.1 契约: 窗口唯一来源是设置面板「上下文长度」—— 引擎 config 与 adapter
* config 同源注入;本同步仅当 adapter 侧返回 >0(用户已配置)时采纳,
* 返回 0(未配置)不覆盖,压缩判定按未配置语义跳过。
*/
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,
// v0.8.2 P1-1: 透传原始思考块(Anthropic extended thinking 工具循环
// 多轮请求必须回传带签名的 thinking 块,否则 400 或丢失推理上下文)
thinkingBlocks: step.thinkingBlocks,
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,
});
}
}
// === v0.8.2 P1-2: 上下文压缩判定(移至本轮消息入列之后、下一轮请求构建前) ===
// 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow。
// v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死
// 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算)。
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413。
// v0.8.2 P1-2: 估算纳入 system promptSOUL + MEMORY.md 注入可达数千 token
// 旧估算只算 messages,system 大时系统性低估 → 压缩迟迟不触发 → API 413)。
if (!this.aborted) {
const effectiveContextWindow =
this.config.contextLength ?? this.config.contextWindow ?? 0;
const estimatedTokens = this.estimateContextTokens(messages, systemPrompt);
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
if (
effectiveContextWindow > 0 &&
actualTokens > compressionThreshold &&
messages.length >= 4
) {
await this.transitionTo(AgentLoopState.COMPRESSING);
const compressed = await this.compressMessages(messages);
if (compressed) {
// 原地替换数组内容,确保下一轮请求构建引用同步更新
messages.splice(0, messages.length, ...compressed);
// 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
this.lastRealInputTokens = 0;
this.emit('compressed', {
iteration: this.currentIteration,
originalTokens: actualTokens,
compressedTokens: this.estimateContextTokens(compressed, systemPrompt),
});
}
// 压缩后回到 OBSERVING(下一轮迭代从 THINKING 重新开始)
await this.transitionTo(AgentLoopState.OBSERVING);
}
}
}
// 循环退出判断
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;
// v0.8.2 P1-1: 本轮流的原始思考块(adapter DONE 携带,含 Provider 签名)
let iterationThinkingBlocks: MetonaThinkingBlock[] | 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;
// v0.8.2 P1-1: 捕获原始思考块(Anthropic extended thinking 协议回传的数据源)
if (event.thinkingBlocks?.length) iterationThinkingBlocks = event.thinkingBlocks;
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.8.1 P1-3: 透传 Prompt Cache 命中字段(前端命中率展示的数据源;
// 此前引擎在此处丢弃 adapter 已采集的 cache 字段,观测链路断头)
cacheHitTokens: event.usage.cacheHitTokens,
cacheMissTokens: event.usage.cacheMissTokens,
};
// 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;
}
// v0.8.2 P1-1: 记录本轮原始思考块(主循环写入 assistant 消息供协议回传)
if (iterationThinkingBlocks?.length) {
step.thinkingBlocks = iterationThinkingBlocks;
}
// 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`);
}
}
// === v0.8.2 P1-2: 上下文压缩判定已移至主循环(本轮 assistant/tool 消息
// 入列之后)—— 旧位置(本方法内、工具执行后)在本轮消息 push 之前,本轮
// 刚产生的大体积工具结果不在压缩输入里,最坏情况"压缩完又立刻装回同等
// 体量"。压缩输入/估算/触发的完整实现见 executeRunStream。
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) {
// v0.8.2 P3-2: 空 name 守卫 —— 纯 DELTA 流丢失 name(上游异常)时,
// name='' 的调用会让 registry 查询 Unknown tool '',产生误导性错误结果。
// 跳过并留痕,避免无效调用进入执行管道。
if (!buf.name) {
log.warn(
`[AgentLoop] Dropping buffered tool call with empty name (args tail: ...${buf.argsBuffer.slice(-80)})`,
);
continue;
}
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 的粗粒度正则保留保护(命令解析复杂)。
//
// v0.8.2 P0-2 根治:不再按工具名单枚举(名单制曾遗漏 delete_file / file_move
// 且对 MCP 文件类工具完全不设防),改为对**任意工具调用**的路径形参数做统一
// 精确匹配 —— 只要某个路径形参数指向工作空间根 MEMORY.md(读/写/删/移动/
// 改名任一方向),一律拦截。子目录 MEMORY.md 不受影响(保持 H-5 语义)。
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;
// v0.8.2 P3-2 根治: 工具级 AbortController —— 超时/引擎中断时真正取消工具执行体。
// 旧实现超时只是引擎侧放弃(Promise.race reject),registry.execute 的 Promise
// 悬挂,不检查 signal 的工具(子进程/网络类)继续完成副作用且结果无人消费。
// 现将引擎信号镜像到独立的 toolAbort:超时触发 toolAbort.abort(),工具内部
// run_command 子进程、fetch、浏览器操作等)据此真正终止。
const toolAbort = new AbortController();
const engineSignal = this.abortController?.signal;
const onEngineAbort = () => toolAbort.abort();
if (engineSignal) {
if (engineSignal.aborted) toolAbort.abort();
else engineSignal.addEventListener('abort', onEngineAbort, { once: true });
}
try {
toolResult = await Promise.race([
this.toolRegistry.execute(toolCall, {
sessionId: this.currentSessionId ?? '',
workspacePath: this.workspacePath,
iteration: this.currentIteration,
requestId: this.currentRequestId,
// 工具级信号:引擎中断镜像 + 超时联动 abortP0-4 / v0.8.2 P3-2
signal: toolAbort.signal,
}),
new Promise<MetonaToolResult>((_, reject) => {
engineTimer = setTimeout(() => {
// 超时即取消执行体(不只是放弃等待)
toolAbort.abort();
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);
// v0.8.2 P3-2: 清理引擎信号镜像监听器
if (engineSignal) engineSignal.removeEventListener('abort', onEngineAbort);
}
// 后置 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;
}
/**
* v0.8.2 P0-2: 路径形参数提取(工具无关)。
*
* 命中两类键名:① 精确集合 { path, file, target, destination, source, dir,
* workdir };② 任意以 path/dir 结尾的键(file_path / dir_path / source_path /
* destination_path / filePath / dirpath 等含下划线与驼峰形态)。`target` 在
* search_files 中是枚举值("content"/"files"),resolve 后不可能与根 MEMORY.md
* 绝对路径精确相等,误报风险为零;反之名单制枚举工具对未来新增工具 / MCP
* 文件类工具存在结构性遗漏。
*/
private static readonly PATH_ARG_KEY_EXACT = new Set([
'path',
'file',
'target',
'destination',
'source',
'dir',
'workdir',
]);
private extractPathArgs(args: Record<string, unknown>): string[] {
const out: string[] = [];
for (const [key, value] of Object.entries(args)) {
if (typeof value !== 'string' || value.length === 0) continue;
const normalized = key.toLowerCase();
const isPathKey =
AgentLoopEngine.PATH_ARG_KEY_EXACT.has(normalized) ||
// 'filepath'/'source_path'/'dirpath' 等任意以 path/dir 结尾的键均为路径形参
normalized.endsWith('path') ||
normalized.endsWith('dir');
if (isPathKey) out.push(value);
}
return out;
}
private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
if (!this.workspacePath) return false;
// 解析路径,判断是否指向工作空间根目录的 MEMORY.md
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
// 精确匹配:任一路径形参数等于 {workspacePath}/MEMORY.md
// 相对路径以 workspacePath 为基解析(与 file-guard/各文件工具语义一致)
return this.extractPathArgs(toolCall.args).some(
(p) => resolve(this.workspacePath, p).toLowerCase() === 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();
// v0.8.2 P1-2: 故障转移后重置真实输入 token 校正值 —— 旧 Provider 的
// 真实值参与了新 Provider(窗口可能不同)的压缩判定,会造成预算失真
this.lastRealInputTokens = 0;
// 故障转移后重新注入 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;
// 网络超时/连接错误 — 可重试
// v0.8.2 P3-2: 补充 ECONNABORTED / EPIPE / ECONNREFUSED / EAI_AGAIN ——
// 分别对应请求中止(undici 超时变体)、流写管道断裂、目标瞬时不可达、
// DNS 临时故障,均属可重试的瞬时网络故障
if (
err.code === 'ECONNRESET' ||
err.code === 'ETIMEDOUT' ||
err.code === 'ENOTFOUND' ||
err.code === 'ECONNABORTED' ||
err.code === 'EPIPE' ||
err.code === 'ECONNREFUSED' ||
err.code === 'EAI_AGAIN'
)
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.8.2 P1-2: 估算"下一轮请求"的总输入 token —— messages + system prompt。
*
* 旧压缩判定只估算 messagessystem promptSOUL.md + 注入的 MEMORY.md 正文 +
* 安全准则,可达数千 token)被完全排除,system 大时系统性低估 → 压缩迟迟不
* 触发 → 首轮流式返回前完全靠估算的场景下直接 413。已知的估算边界:工具定义
* tools JSON Schema)体积不在此估算内(与 Provider 的序列化形态差异大,
* 由 lastRealInputTokens 真实值校正兜底)。
*/
private estimateContextTokens(
messages: MetonaMessage[],
systemPrompt?: MetonaSystemPrompt,
): number {
let total = this.estimateMessagesTokens(messages);
if (systemPrompt) {
const systemText = [
systemPrompt.roleDefinition,
systemPrompt.outputConstraints,
systemPrompt.safetyGuidelines,
systemPrompt.dynamicReminders,
]
.filter(Boolean)
.join('\n\n');
total += estimateStringTokens(systemText);
// system 消息的结构开销
total += 8;
}
return total;
}
/**
* 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),单独二次截断
*
* v0.8.2 P1-2: 摘要调用失败重试一次;最终失败时降级为**纯截断压缩**(仅保留
* 近期消息,无 LLM 摘要)—— token 确定下降优于"放弃压缩 → 下一轮 413"。
*
* @returns 压缩后的消息数组;仅当无法构造任何有效压缩(无可压缩消息)时返回 null
*/
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
// v0.8.1: 窗口唯一来源是设置面板「上下文长度」,无写死兜底 —— 未配置时无法
// 计算保留预算,直接放弃压缩(调用方保持原数组,行为安全)
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0;
if (!(effectiveContextWindow > 0)) return null;
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');
// v0.3.18 修复: 若 toKeep 中仍有单条消息超 keepBudget,对其做二次截断
// 超长 tool_result(如 read_file 5000 行)即使保留也会撑爆上下文
// v0.8.2 P1-2: 该截断提前到摘要调用之前完成 —— 纯截断兜底路径与摘要路径共用
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;
});
// v0.8.2 P1-2: 摘要调用带一次重试 + 最终失败降级为纯截断压缩。
// 旧实现共用主 adapter 且一次失败即放弃压缩(返回 null)—— Provider 抖动时
// 压缩形同虚设,下一轮 LLM 大概率 413。现:① 摘要失败重试一次;② 仍失败则
// 返回"仅保留近期消息"的纯截断结果(无摘要但 token 确定下降),宁可丢历史
// 也不让会话进入 413 死锁。
let summary: string | null = null;
let lastError: unknown = null;
for (let attempt = 0; attempt < 2 && !summary; attempt++) {
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 text = response.content.trim();
if (text) summary = text;
} catch (error) {
lastError = error;
log.warn(
`[AgentLoop] Context summary attempt ${attempt + 1}/2 failed: ${(error as Error).message}`,
);
}
}
if (!summary) {
log.warn(
`[AgentLoop] Context summary unavailable (${(lastError as Error)?.message ?? 'empty summary'}) — falling back to pure truncation compression`,
);
return finalKeep;
}
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(),
};
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,
];
}
private accumulateTokens(usage: TokenUsage): void {
this.totalTokens.promptTokens += usage.promptTokens;
this.totalTokens.completionTokens += usage.completionTokens;
this.totalTokens.totalTokens += usage.totalTokens;
// v0.8.1 P1-3: 累计缓存命中/未命中(未上报的 Provider 保持 undefined
// 不把 undefined 污染为数字 0 —— 前端以 undefined 判定"该 Provider 不上报"
if (usage.cacheHitTokens != null) {
this.totalTokens.cacheHitTokens =
(this.totalTokens.cacheHitTokens ?? 0) + usage.cacheHitTokens;
}
if (usage.cacheMissTokens != null) {
this.totalTokens.cacheMissTokens =
(this.totalTokens.cacheMissTokens ?? 0) + usage.cacheMissTokens;
}
}
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 },
};
}
}