P1 修复面收口: - 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR), 根治"真实网络超时被误报为用户中断" - 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道) - 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 + 前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死) - 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED) - DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制) - IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复 P2 安全纵深: - preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险) - CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步) - MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径) - write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏) - 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库 - ReDoS 检测共享化(search_files/file_editor 统一拦截) - run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command) - MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 + 十六进制映射解析 + 尾点剥离) P3 架构还债: - temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘 - SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async) - 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine) - i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数) - 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释) - 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 + 用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位) P4 能力演进: - 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报) - run-lock 30s 超时强制 abort(旧 run 卡死不无限排队) - RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复) - FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退) - getContextWindow 兜底 1M→128K(未知模型防 413) 测试: - 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、 工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、 纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例) - 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷 - 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 / mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性 版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload, 新增 jsdom/@testing-library(devDependencies 不打包) 回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过; 系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
1549 lines
63 KiB
TypeScript
1549 lines
63 KiB
TypeScript
/**
|
||
* Agent Loop — ReAct 状态机引擎
|
||
*
|
||
* 生产级 ReAct Agent Loop,负责:
|
||
* 1. 状态机管理循环生命周期
|
||
* 2. 调用 Provider Adapter 获取 LLM 响应(支持流式)
|
||
* 3. 解析输出、执行工具、注入观察
|
||
* 4. 流式事件推送 UI
|
||
* 5. 超时、重试、上下文压缩
|
||
*
|
||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第四章
|
||
*/
|
||
|
||
import { EventEmitter } from 'events';
|
||
import { resolve } from 'path';
|
||
import { nanoid } from 'nanoid';
|
||
import {
|
||
AgentLoopState,
|
||
TerminationReason,
|
||
type IterationStep,
|
||
type AgentLoopConfig,
|
||
type AgentLoopOutput,
|
||
type TokenUsage,
|
||
} from './types';
|
||
import type {
|
||
MetonaRequest,
|
||
MetonaMessage,
|
||
MetonaSystemPrompt,
|
||
MetonaToolCall,
|
||
MetonaToolResult,
|
||
MetonaStreamEvent,
|
||
IMetonaProviderAdapter,
|
||
MetonaToolDef,
|
||
} from '../types';
|
||
import { MetonaStreamEventType, MetonaErrorCode } from '../types';
|
||
import { estimateMessagesTokens } from '../utils/token-estimator';
|
||
import { ContentFilterError } from '../adapters/base-adapter';
|
||
import { truncatedArgumentsPayload } from '../adapters/shared/sse-stream';
|
||
import log from 'electron-log';
|
||
|
||
/**
|
||
* v0.3.0: 死循环错误 — 在 executeOneIterationStream 中抛出,主循环捕获后以 DEAD_LOOP 原因终止
|
||
*/
|
||
class DeadLoopError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'DeadLoopError';
|
||
}
|
||
}
|
||
|
||
const DEFAULT_CONFIG: AgentLoopConfig = {
|
||
maxIterations: 20,
|
||
totalTimeoutMs: 600_000,
|
||
enableReflection: false,
|
||
compressionThreshold: 0.8,
|
||
contextWindow: 128_000,
|
||
retryCount: 3,
|
||
temperature: 0.0,
|
||
maxTokens: 63488,
|
||
thinkingEnabled: true,
|
||
thinkingEffort: 'high',
|
||
toolExecutionTimeoutMs: 120_000,
|
||
};
|
||
|
||
/**
|
||
* ReAct Agent Loop 引擎
|
||
*/
|
||
export class AgentLoopEngine extends EventEmitter {
|
||
private currentState: AgentLoopState = AgentLoopState.INIT;
|
||
private iterations: IterationStep[] = [];
|
||
private currentIteration = 0;
|
||
private startTime = 0;
|
||
private totalTokens: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||
private aborted = false;
|
||
private config: AgentLoopConfig;
|
||
private tools: MetonaToolDef[] = [];
|
||
private currentSessionId: string = '';
|
||
private currentRequestId: string = '';
|
||
private workspacePath: string = '';
|
||
private abortController: AbortController | null = null;
|
||
private eventSeq = 0;
|
||
/**
|
||
* v0.3.18 修复: 最近一次 LLM 调用返回的真实输入 token 数
|
||
* 用于校正压缩判断——估算值可能偏低(尤其中文场景),
|
||
* 导致估算没到阈值不压缩,但真实 API 调用已经 413。
|
||
* 压缩判断时取 max(估算值, 真实值) 作为实际占用。
|
||
*/
|
||
private lastRealInputTokens = 0;
|
||
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */
|
||
private runId: string = '';
|
||
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
|
||
private currentRunPromise: Promise<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 = [];
|
||
|
||
try {
|
||
await this.transitionTo(AgentLoopState.INIT);
|
||
|
||
// 构建消息列表(保留 images 字段)
|
||
const messages: MetonaMessage[] = [
|
||
...history,
|
||
{
|
||
role: 'user',
|
||
content: userMessage.content,
|
||
images: userMessage.images,
|
||
timestamp: Date.now(),
|
||
},
|
||
];
|
||
|
||
// === 主循环 ===
|
||
while (
|
||
this.currentIteration < this.config.maxIterations &&
|
||
!this.aborted &&
|
||
Date.now() - this.startTime < this.config.totalTimeoutMs
|
||
) {
|
||
this.currentIteration++;
|
||
|
||
// 构建请求
|
||
const request: MetonaRequest = {
|
||
meta: {
|
||
sessionId,
|
||
iteration: this.currentIteration,
|
||
requestId: `r_${nanoid(12)}`,
|
||
timestamp: Date.now(),
|
||
agentVersion: '1.0.0',
|
||
},
|
||
systemPrompt,
|
||
messages,
|
||
tools: this.tools.length > 0 ? this.tools : undefined,
|
||
params: {
|
||
maxTokens: this.config.maxTokens,
|
||
temperature: this.config.temperature,
|
||
stream: true,
|
||
thinkingEnabled: this.config.thinkingEnabled,
|
||
thinkingEffort: this.config.thinkingEffort,
|
||
contextLength: this.config.contextLength,
|
||
},
|
||
};
|
||
|
||
const step = await this.executeOneIterationStream(request, sessionId);
|
||
this.iterations.push(step);
|
||
|
||
// 将 assistant 回复加入消息历史
|
||
// 崩溃修复(会话停止根因): 原条件 `if (step.thought)` 在模型发起纯工具调用
|
||
// (零文本、零思考内容 — DeepSeek 高频行为)时跳过 assistant 消息,但下方
|
||
// tool 结果消息照常 push → 下一轮请求出现孤立 tool 消息 → API 400
|
||
// "Messages with role 'tool' must be a response to a preceding message
|
||
// with 'tool_calls'" → 会话 ERROR 终止。有 toolCalls 的轮次必须 push
|
||
// assistant(content=null,符合 C-6 规范)。
|
||
if (step.thought || (step.toolCalls && step.toolCalls.length > 0)) {
|
||
const assistantMsg: MetonaMessage = {
|
||
role: 'assistant',
|
||
content: step.thought?.content ?? null,
|
||
reasoningContent: step.thought?.reasoningContent,
|
||
toolCalls: step.toolCalls,
|
||
timestamp: Date.now(),
|
||
iteration: this.currentIteration,
|
||
};
|
||
messages.push(assistantMsg);
|
||
}
|
||
|
||
// 如果没有工具调用,视为最终输出
|
||
if (!step.toolCalls || step.toolCalls.length === 0) {
|
||
return this.finish(TerminationReason.COMPLETED, step.thought?.content);
|
||
}
|
||
|
||
// v0.3.0: 死循环检测已移入 executeOneIterationStream 的 PARSING 阶段后,
|
||
// 通过抛出 DeadLoopError 在此处 catch 块捕获处理
|
||
|
||
// 执行工具并将结果加入消息
|
||
if (step.toolResults) {
|
||
for (const result of step.toolResults) {
|
||
messages.push({
|
||
role: 'tool',
|
||
// CE-2 修复: 工具失败时 result.result 为 null,LLM 会看到 "null" 而非错误信息
|
||
// 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环
|
||
content: result.error
|
||
? result.error
|
||
: typeof result.result === 'string'
|
||
? result.result
|
||
: JSON.stringify(result.result),
|
||
toolResult: result,
|
||
timestamp: Date.now(),
|
||
iteration: this.currentIteration,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 循环退出判断
|
||
if (this.aborted) return this.finish(TerminationReason.USER_INTERRUPT);
|
||
if (this.currentIteration >= this.config.maxIterations)
|
||
return this.finish(TerminationReason.MAX_ITERATIONS);
|
||
return this.finish(TerminationReason.TIMEOUT);
|
||
} catch (error) {
|
||
// v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止
|
||
// P0-1 审查修复: 必须传 error 参数(第三参数),否则 finish() 不会发射 ERROR 事件
|
||
// 前端只收到 DONE 会导致 agentStatus 被设为 'idle' 而非 'error',且无错误消息卡片
|
||
if (error instanceof DeadLoopError) {
|
||
return this.finish(TerminationReason.DEAD_LOOP, undefined, error as Error);
|
||
}
|
||
const errMsg = (error as Error).message ?? '';
|
||
// P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR
|
||
// Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖
|
||
const errMsgLower = errMsg.toLowerCase();
|
||
// v0.7.4 P1-1: 区分三态,修复"真实网络超时被误报为 USER_INTERRUPT"。
|
||
// 旧实现把 'timeout'/'timed out' 一律映射 USER_INTERRUPT,但 fetchWithTimeout
|
||
// 的超时错误("Request timed out after ...")在重试耗尽后也会命中——用户并未
|
||
// 中断,前端却显示"用户中断"语义,SLO 统计与错误诊断全部失真。
|
||
// 判定顺序:用户主动中断 > 真实超时 > 其余错误。
|
||
if (this.aborted) {
|
||
return this.finish(TerminationReason.USER_INTERRUPT);
|
||
}
|
||
const errCode = (error as { code?: string }).code;
|
||
const isTimeout =
|
||
errCode === 'ETIMEDOUT' ||
|
||
errCode === 'UND_ERR_CONNECT_TIMEOUT' ||
|
||
errMsgLower.includes('timed out') ||
|
||
errMsgLower.includes('timeout');
|
||
if (isTimeout) {
|
||
return this.finish(TerminationReason.TIMEOUT, undefined, error as Error);
|
||
}
|
||
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw,
|
||
// 导致 finish() 被中断、DONE 事件丢失、前端卡死。改为日志记录,让 finish 正常执行
|
||
log.error(`[AgentLoop] Run failed: ${errMsg}`);
|
||
return this.finish(TerminationReason.ERROR, undefined, error as Error);
|
||
}
|
||
}
|
||
|
||
/** 获取当前 Provider Adapter(供 SubAgent 创建独立引擎实例) */
|
||
getAdapter(): IMetonaProviderAdapter {
|
||
return this.adapter;
|
||
}
|
||
|
||
/** 获取工作空间路径(供 SubAgent 继承) */
|
||
getWorkspacePath(): string {
|
||
return this.workspacePath;
|
||
}
|
||
|
||
/** 中断循环 */
|
||
abort(): void {
|
||
this.aborted = true;
|
||
this.abortController?.abort();
|
||
this.abortController = null;
|
||
// C-2 修复: 清除 adapter 的 abort signal,防止旧的已 abort signal 影响后续请求
|
||
if (this.adapter.setAbortSignal) {
|
||
this.adapter.setAbortSignal(undefined);
|
||
}
|
||
this.emit('aborted');
|
||
}
|
||
|
||
/**
|
||
* MT-1 修复: 等待当前 run 结束(用于 abortSession IPC handler)
|
||
*
|
||
* abort() 只是设置了标志和触发了 abortController,
|
||
* 但 currentRunPromise 仍在进行中(可能在等待工具超时或 LLM 响应)。
|
||
* 如果不等待就返回,用户立即重发会导致新消息卡在 runStream 的 currentRunPromise 等待中。
|
||
*
|
||
* @param timeoutMs 等待超时(默认 5 秒,防止永久挂起)
|
||
* @returns true 表示 run 已结束,false 表示等待超时
|
||
*/
|
||
async waitForAbort(timeoutMs: number = 5_000): Promise<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; // 超时返回 false,run 正常结束返回 true
|
||
} finally {
|
||
// 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留
|
||
if (timerHandle) clearTimeout(timerHandle);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 销毁引擎,清理所有监听器
|
||
*/
|
||
destroy(): void {
|
||
this.abort();
|
||
this.removeAllListeners();
|
||
}
|
||
|
||
/** 获取当前状态 */
|
||
getState(): AgentLoopState {
|
||
return this.currentState;
|
||
}
|
||
|
||
// ========== 私有方法 ==========
|
||
|
||
/**
|
||
* 执行单次迭代(流式模式)
|
||
*/
|
||
private async executeOneIterationStream(
|
||
request: MetonaRequest,
|
||
sessionId: string,
|
||
): Promise<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;
|
||
|
||
// 流式接收响应
|
||
for await (const event of this.chatStreamWithRetry(request)) {
|
||
if (this.aborted) break;
|
||
|
||
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
|
||
if (event.type === MetonaStreamEventType.DONE) continue;
|
||
|
||
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
|
||
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
|
||
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全
|
||
// v0.7.4 P4-3: 双通道 — 内部清空缓冲的同时,向渲染层广播 STREAM_RESET 信号,
|
||
// 前端据此清空该 runId 已累积的文本/思考增量(重试会从头重建整个响应),
|
||
// 根治"第一段文本 + 重试后第二段文本"拼接重复(依赖 seq 无法去重,
|
||
// 因为 adapter 侧 seq 每次从 0 重启)。
|
||
if (
|
||
event.type === MetonaStreamEventType.ERROR &&
|
||
event.error?.code === MetonaErrorCode.RETRY
|
||
) {
|
||
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
|
||
fullContent = '';
|
||
reasoningContent = '';
|
||
toolCallsBuffer.clear();
|
||
this.emit('streamEvent', {
|
||
type: MetonaStreamEventType.STREAM_RESET,
|
||
requestId: event.requestId,
|
||
sessionId: event.sessionId,
|
||
iteration: event.iteration,
|
||
seq: this.nextSeq(),
|
||
timestamp: Date.now(),
|
||
runId: this.runId,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// 转发流式事件到渲染进程(注入 runId 供前端过滤旧流)
|
||
this.emit('streamEvent', { ...event, runId: this.runId });
|
||
|
||
switch (event.type) {
|
||
case MetonaStreamEventType.TEXT_DELTA:
|
||
if (event.delta) fullContent += event.delta;
|
||
break;
|
||
|
||
case MetonaStreamEventType.REASONING_DELTA:
|
||
if (event.delta) reasoningContent += event.delta;
|
||
break;
|
||
|
||
case MetonaStreamEventType.TOOL_CALL_DELTA:
|
||
if (event.toolCallDelta) {
|
||
const { index, name, argsDelta } = event.toolCallDelta;
|
||
if (!toolCallsBuffer.has(index)) {
|
||
toolCallsBuffer.set(index, { name: name ?? '', argsBuffer: '' });
|
||
}
|
||
const buf = toolCallsBuffer.get(index)!;
|
||
if (name) buf.name = name;
|
||
if (argsDelta) buf.argsBuffer += argsDelta;
|
||
}
|
||
break;
|
||
|
||
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
|
||
if (event.toolCall) {
|
||
// 工具调用完成,记录到 step
|
||
if (!step.toolCalls) step.toolCalls = [];
|
||
step.toolCalls.push(event.toolCall);
|
||
}
|
||
break;
|
||
|
||
case MetonaStreamEventType.USAGE:
|
||
if (event.usage) {
|
||
tokenUsage = {
|
||
promptTokens: event.usage.inputTokens ?? 0,
|
||
completionTokens: event.usage.outputTokens ?? 0,
|
||
totalTokens: event.usage.totalTokens ?? 0,
|
||
};
|
||
// v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断
|
||
// 估算值可能偏低(尤其中文场景),导致不压缩但 API 413
|
||
const realInput = event.usage.inputTokens ?? 0;
|
||
if (realInput > 0) {
|
||
this.lastRealInputTokens = realInput;
|
||
}
|
||
}
|
||
break;
|
||
|
||
case MetonaStreamEventType.ERROR:
|
||
// RETRY 已在循环入口过滤,此处只处理真正的错误。
|
||
// v0.6.4: 保留结构化错误码 —— finish() 据 code 区分 content_filtered 等
|
||
// 类型化终止(原实现全部 new Error(message),信息被压缩为 UNKNOWN)。
|
||
if (event.error) {
|
||
const streamError = new Error(event.error.message);
|
||
(streamError as Error & { code?: string }).code = event.error.code;
|
||
throw streamError;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
|
||
await this.transitionTo(AgentLoopState.PARSING);
|
||
|
||
// L-19 修复: 提取 finalizeToolCallsFromBuffer 子方法(PARSING 阶段)
|
||
this.finalizeToolCallsFromBuffer(step, toolCallsBuffer);
|
||
|
||
// 记录 Thought
|
||
if (fullContent || reasoningContent) {
|
||
step.thought = {
|
||
id: `thought-${this.currentIteration}`,
|
||
content: fullContent,
|
||
reasoningContent,
|
||
timestamp: Date.now(),
|
||
iteration: this.currentIteration,
|
||
};
|
||
}
|
||
|
||
// 记录 Token 使用
|
||
if (tokenUsage) {
|
||
step.tokenUsage = tokenUsage;
|
||
this.accumulateTokens(tokenUsage);
|
||
}
|
||
|
||
// v0.3.0 修复: 死循环检测 — 在 PARSING 阶段完成后、EXECUTING 阶段开始前检测
|
||
// 确保第3轮重复调用的副作用不会产生(工具尚未执行)
|
||
// v0.7.4 P4-1 拆分: 此处仅做"驻留模式"(连续 3 轮相同签名,无结果依赖,
|
||
// 可在执行前安全判定防副作用);"乒乓模式"(ABAB + 进度信号)需要本轮
|
||
// 工具结果做二次确认,移至 EXECUTING 之后(OBSERVING 前)检测。
|
||
if (step.toolCalls && step.toolCalls.length > 0) {
|
||
if (this.detectStuckLoop(step.toolCalls)) {
|
||
log.warn(
|
||
`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`,
|
||
);
|
||
this.emit('deadLoop', {
|
||
iteration: this.currentIteration,
|
||
runId: this.runId,
|
||
sessionId: this.currentSessionId,
|
||
});
|
||
// 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止
|
||
throw new DeadLoopError(
|
||
`Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations, or two alternating call patterns kept cycling (A→B→A→B) without progress. Please refine the approach or provide more specific instructions.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// === EXECUTING: 执行工具调用 ===
|
||
if (step.toolCalls && step.toolCalls.length > 0) {
|
||
// transitionTo 已发射 stateChange 事件,无需重复 emit
|
||
await this.transitionTo(AgentLoopState.EXECUTING);
|
||
|
||
// L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段)
|
||
// 返回 null 表示被 abort 中断
|
||
const raceResult = await this.executeToolCallsParallel(
|
||
step.toolCalls,
|
||
request.meta.requestId,
|
||
sessionId,
|
||
);
|
||
|
||
if (raceResult === null) {
|
||
// 被 abort 中断,标记步骤并退出
|
||
step.completedAt = Date.now();
|
||
step.state = AgentLoopState.TERMINATED;
|
||
return step;
|
||
}
|
||
|
||
step.toolResults = raceResult.map((result, idx) => {
|
||
const tc = step.toolCalls![idx];
|
||
if (result.status === 'fulfilled') return result.value;
|
||
// rejected:构造失败 result
|
||
const errorResult = {
|
||
toolCallId: tc.id,
|
||
toolName: tc.name,
|
||
result: null,
|
||
success: false,
|
||
error: `Tool execution failed: ${(result.reason as Error)?.message ?? String(result.reason)}`,
|
||
durationMs: 0,
|
||
timestamp: Date.now(),
|
||
};
|
||
// 转发错误结果到 UI
|
||
this.emit('streamEvent', {
|
||
type: MetonaStreamEventType.TOOL_RESULT,
|
||
requestId: request.meta.requestId,
|
||
sessionId,
|
||
iteration: this.currentIteration,
|
||
seq: this.nextSeq(),
|
||
timestamp: Date.now(),
|
||
toolResult: errorResult,
|
||
runId: this.runId,
|
||
});
|
||
return errorResult;
|
||
});
|
||
}
|
||
|
||
// v0.7.4 P4-1: 乒乓模式死循环检测(ABAB + 进度信号二次确认)——
|
||
// 移至 EXECUTING 之后:乒乓判定需要本轮工具**结果**做进度比对
|
||
// (两次 A 的结果是否相同、两次 B 的结果是否相同),PARSING 阶段
|
||
// 本轮结果尚不可得。驻留模式仍在执行前检测(防第 3 轮副作用)。
|
||
if (step.toolCalls && step.toolCalls.length > 0) {
|
||
if (this.detectPingPong(step.toolCalls, step.toolResults)) {
|
||
log.warn(
|
||
`[AgentLoop] Ping-pong dead loop detected at iteration ${this.currentIteration} (ABAB with identical results — no progress)`,
|
||
);
|
||
this.emit('deadLoop', {
|
||
iteration: this.currentIteration,
|
||
runId: this.runId,
|
||
sessionId: this.currentSessionId,
|
||
});
|
||
throw new DeadLoopError(
|
||
`Detected a potential infinite loop: two alternating call patterns kept cycling (A→B→A→B) with identical results — no progress. Please refine the approach or provide more specific instructions.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// === OBSERVING ===
|
||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||
|
||
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
|
||
// v0.7.3 接线说明:REFLECTING 分支此前依赖 enableReflection 配置,但该配置
|
||
// 全链路无任何置 true 的路径(死配置)。现由 agent.enableReflection 配置
|
||
// 真实驱动(main.ts baseConfig → updateConfigAll → 本分支),启用后每轮
|
||
// 工具执行完毕会经过 REFLECTING 状态:工具结果存在失败时记录告警日志,
|
||
// 供 SLO 与排障观察(不阻断循环——错误结果已由 CE-2 路径回传模型自愈)。
|
||
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
|
||
await this.transitionTo(AgentLoopState.REFLECTING);
|
||
// 检查工具执行是否有错误,如果有严重错误可以提前终止
|
||
const hasErrors = step.toolResults?.some((r) => !r.success);
|
||
if (hasErrors) {
|
||
log.warn(`[AgentLoop] Iteration ${this.currentIteration} had tool errors`);
|
||
}
|
||
}
|
||
|
||
// === 上下文压缩(基于 token 使用率触发) ===
|
||
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
||
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发
|
||
const effectiveContextWindow =
|
||
this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||
const estimatedTokens = this.estimateMessagesTokens(request.messages);
|
||
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
|
||
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
|
||
// 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确
|
||
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
|
||
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
|
||
// v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
|
||
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
|
||
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
|
||
if (actualTokens > compressionThreshold && request.messages.length >= 4) {
|
||
await this.transitionTo(AgentLoopState.COMPRESSING);
|
||
const compressed = await this.compressMessages(request.messages);
|
||
if (compressed) {
|
||
// 原地替换数组内容,确保外层 messages 引用同步更新
|
||
request.messages.splice(0, request.messages.length, ...compressed);
|
||
// v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
|
||
this.lastRealInputTokens = 0;
|
||
this.emit('compressed', {
|
||
iteration: this.currentIteration,
|
||
originalTokens: actualTokens,
|
||
compressedTokens: this.estimateMessagesTokens(compressed),
|
||
});
|
||
}
|
||
// 压缩后回到 OBSERVING
|
||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||
}
|
||
|
||
step.completedAt = Date.now();
|
||
step.state = AgentLoopState.OBSERVING;
|
||
return step;
|
||
} catch (error) {
|
||
step.completedAt = Date.now();
|
||
step.state = AgentLoopState.TERMINATED;
|
||
// v0.3.0 修复: DeadLoopError 抛出时,将 step 加入 iterations 数组,
|
||
// 确保死循环轮的 LLM thought 内容不丢失(用户可观察 Agent 被终止前的最后思考)
|
||
if (error instanceof DeadLoopError) {
|
||
this.iterations.push(step);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* L-19 修复: PARSING 阶段 — 解析流式接收期间累积的工具调用缓冲区,
|
||
* 构造 MetonaToolCall[] 写入 step.toolCalls。
|
||
*
|
||
* 仅在缓冲区非空且 step 尚未通过 TOOL_CALL_COMPLETE 接收到完整调用时生效,
|
||
* 避免覆盖已就绪的 toolCalls。
|
||
*/
|
||
private finalizeToolCallsFromBuffer(
|
||
step: IterationStep,
|
||
toolCallsBuffer: Map<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-hook(P0-2: 错误结果同样过安全扫描/审计;钩子可返回修改后的结果)
|
||
let errorResult = toolResult;
|
||
for (const hook of this.postToolHooks) {
|
||
const modified = await hook.afterExecute(toolCall, errorResult, this.currentSessionId);
|
||
if (modified) errorResult = modified;
|
||
}
|
||
return errorResult;
|
||
} finally {
|
||
// M-16 修复: 清理未触发的 timeout timer
|
||
if (engineTimer) clearTimeout(engineTimer);
|
||
}
|
||
|
||
// 后置 Hook 管道(P0-2: 钩子可返回修改后的结果——如 SecurityScanHook 对网页内容脱敏)
|
||
let finalResult = toolResult;
|
||
for (const hook of this.postToolHooks) {
|
||
const modified = await hook.afterExecute(toolCall, finalResult, this.currentSessionId);
|
||
if (modified) finalResult = modified;
|
||
}
|
||
|
||
return finalResult;
|
||
}
|
||
|
||
/**
|
||
* H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md
|
||
*
|
||
* @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
|
||
* subdirectory MEMORY.md files are unrestricted
|
||
*
|
||
* 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
|
||
* 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
|
||
*
|
||
* @param toolCall 工具调用
|
||
* @returns 是否指向工作空间根目录的 MEMORY.md
|
||
*/
|
||
private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
|
||
if (!this.workspacePath) return false;
|
||
|
||
// 提取工具参数中的路径(不同工具使用不同的参数名)
|
||
const args = toolCall.args;
|
||
const pathStr =
|
||
(args.path as string) ||
|
||
(args.file_path as string) ||
|
||
(args.filePath as string) ||
|
||
(args.file as string) ||
|
||
(args.target as string) ||
|
||
(args.destination as string);
|
||
|
||
if (!pathStr || typeof pathStr !== 'string') return false;
|
||
|
||
// 解析路径,判断是否指向工作空间根目录的 MEMORY.md
|
||
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
|
||
const resolved = resolve(pathStr).toLowerCase();
|
||
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
|
||
|
||
// 精确匹配:路径必须等于 {workspacePath}/MEMORY.md
|
||
return resolved === rootMemoryPath;
|
||
}
|
||
|
||
/**
|
||
* 带重试的流式调用(v0.2.0: 指数退避;P1: Provider 故障转移)
|
||
*
|
||
* 重试策略:
|
||
* 1. 可重试错误(429/5xx/网络)→ 指数退避重试(1s/2s/4s...,上限 30s,±20% jitter)
|
||
* 2. 重试耗尽或不可重试错误 → 若配置了 fallbackAdapter,切换 Provider 重发本次请求
|
||
* 3. 故障转移仅触发一次(防止主/备 Provider 间乒乓切换)
|
||
*
|
||
* 故障转移后 this.adapter 切换为 fallback,本 run 内后续迭代均使用备用 Provider,
|
||
* 并通过 'providerSwitched' 事件通知上层(IPC → 前端系统消息 + Toast)。
|
||
*/
|
||
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||
const baseDelayMs = 1_000;
|
||
const maxDelayMs = 30_000;
|
||
let attempt = 0;
|
||
let currentAdapter = this.adapter;
|
||
let failoverUsed = false;
|
||
|
||
while (true) {
|
||
try {
|
||
if (attempt > 0) {
|
||
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
|
||
yield {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: request.meta.requestId,
|
||
sessionId: request.meta.sessionId,
|
||
iteration: request.meta.iteration,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: {
|
||
code: MetonaErrorCode.RETRY,
|
||
message: `Retrying after error (attempt ${attempt}/${this.config.retryCount})`,
|
||
retryable: true,
|
||
},
|
||
};
|
||
}
|
||
yield* currentAdapter.sendStream(request);
|
||
return;
|
||
} catch (error) {
|
||
if (this.aborted) throw error;
|
||
const retryable = this.isRetryableError(error);
|
||
|
||
// P1: 故障转移 — 重试耗尽或不可重试错误(如 401 密钥失效)时切换备用 Provider
|
||
if (
|
||
!failoverUsed &&
|
||
this.fallbackAdapter &&
|
||
this.fallbackAdapter !== currentAdapter &&
|
||
(!retryable || attempt >= this.config.retryCount)
|
||
) {
|
||
failoverUsed = true;
|
||
const fromId = currentAdapter.providerId;
|
||
this.adapter = this.fallbackAdapter; // 本 run 内后续迭代均使用 fallback
|
||
currentAdapter = this.fallbackAdapter;
|
||
this.syncContextWindow();
|
||
// 故障转移后重新注入 abort 信号(新 adapter 实例需要关联引擎的中断控制器)
|
||
if (this.abortController && currentAdapter.setAbortSignal) {
|
||
currentAdapter.setAbortSignal(this.abortController.signal);
|
||
}
|
||
log.warn(
|
||
`[AgentLoop] Provider failover: ${fromId} → ${currentAdapter.providerId} (${(error as Error).message})`,
|
||
);
|
||
this.emit('providerSwitched', {
|
||
from: fromId,
|
||
to: currentAdapter.providerId,
|
||
sessionId: this.currentSessionId,
|
||
reason: 'failover',
|
||
});
|
||
attempt = 0;
|
||
yield {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: request.meta.requestId,
|
||
sessionId: request.meta.sessionId,
|
||
iteration: request.meta.iteration,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
error: {
|
||
code: MetonaErrorCode.RETRY,
|
||
message: `Primary provider failed, switching to fallback (${currentAdapter.providerId})`,
|
||
retryable: true,
|
||
},
|
||
};
|
||
continue;
|
||
}
|
||
|
||
if (!retryable || attempt >= this.config.retryCount) throw error;
|
||
attempt++;
|
||
|
||
// 指数退避 + 抖动
|
||
const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt - 1));
|
||
const jitter = delay * 0.2 * (Math.random() * 2 - 1); // ±20% jitter
|
||
const waitMs = Math.max(500, delay + jitter);
|
||
log.warn(
|
||
`[AgentLoop] Retry ${attempt}/${this.config.retryCount} after ${Math.round(waitMs)}ms: ${(error as Error).message}`,
|
||
);
|
||
await new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
// v0.3.0 修复: timer 先触发时移除 abort 监听器,避免监听器堆积
|
||
if (onAbort && signal) signal.removeEventListener('abort', onAbort);
|
||
resolve(undefined);
|
||
}, waitMs);
|
||
// 支持 abort 中断等待
|
||
const signal = this.abortController?.signal;
|
||
let onAbort: (() => void) | null = null;
|
||
if (signal) {
|
||
if (signal.aborted) {
|
||
clearTimeout(timer);
|
||
reject(new Error('Aborted'));
|
||
return;
|
||
}
|
||
onAbort = () => {
|
||
clearTimeout(timer);
|
||
reject(new Error('Aborted'));
|
||
};
|
||
signal.addEventListener('abort', onAbort, { once: true });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 判断错误是否可重试 */
|
||
private isRetryableError(error: unknown): boolean {
|
||
const err = error as { status?: number; code?: string; message?: string };
|
||
// 429 Too Many Requests — 可重试
|
||
if (err.status === 429) return true;
|
||
// 5xx 服务器错误 — 可重试
|
||
if (err.status && err.status >= 500 && err.status < 600) return true;
|
||
// 网络超时/连接错误 — 可重试
|
||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND')
|
||
return true;
|
||
// P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
|
||
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
|
||
// v0.7.4 C-9 修复: 移除对 'aborted' 字样的宽泛匹配——用户主动 abort 路径已由
|
||
// this.aborted 拦截,错误 message 含 'aborted' 的多为 Provider 合法错误文案
|
||
// (如 Anthropic "request aborted" 变体),不应进入重试放大。仅保留网络层中断信号。
|
||
const msg = err.message?.toLowerCase() ?? '';
|
||
if (msg.includes('socket hang up') || msg.includes('fetch failed')) return true;
|
||
// 其他错误(400/401/403/4xx)不重试
|
||
return false;
|
||
}
|
||
|
||
/** 生成下一个事件序列号 */
|
||
private nextSeq(): number {
|
||
return ++this.eventSeq;
|
||
}
|
||
|
||
private async transitionTo(state: AgentLoopState): Promise<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 上下文),一并识别
|
||
const isContentFilter =
|
||
error instanceof ContentFilterError ||
|
||
(error as Error & { code?: string }).code === MetonaErrorCode.CONTENT_FILTERED;
|
||
this.emit('streamEvent', {
|
||
type: MetonaStreamEventType.ERROR,
|
||
requestId: this.currentRequestId,
|
||
sessionId: this.currentSessionId,
|
||
iteration: this.currentIteration,
|
||
seq: this.nextSeq(),
|
||
timestamp: Date.now(),
|
||
runId: this.runId,
|
||
error: {
|
||
code: isContentFilter ? MetonaErrorCode.CONTENT_FILTERED : MetonaErrorCode.UNKNOWN,
|
||
message: isContentFilter
|
||
? '内容被 Provider 安全审核拦截,请修改图片或文本后重试'
|
||
: error.message,
|
||
retryable: false,
|
||
},
|
||
});
|
||
}
|
||
|
||
// 发送唯一的最终 DONE 事件 — UI 只在此处结束流式状态
|
||
this.emit('streamEvent', {
|
||
type: MetonaStreamEventType.DONE,
|
||
requestId: this.currentRequestId,
|
||
sessionId: this.currentSessionId,
|
||
iteration: this.currentIteration,
|
||
seq: this.nextSeq(),
|
||
timestamp: Date.now(),
|
||
runId: this.runId,
|
||
terminationReason: reason,
|
||
});
|
||
|
||
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
|
||
const prevTerminated = this.currentState;
|
||
this.currentState = AgentLoopState.TERMINATED;
|
||
this.emit('stateChange', {
|
||
previous: prevTerminated,
|
||
current: AgentLoopState.TERMINATED,
|
||
state: AgentLoopState.TERMINATED,
|
||
sessionId: this.currentSessionId,
|
||
iteration: this.currentIteration,
|
||
runId: this.runId,
|
||
});
|
||
|
||
// 发射 complete 事件(供托盘通知等外部监听器使用)
|
||
this.emit('complete', {
|
||
sessionId: this.currentSessionId,
|
||
durationMs: Date.now() - this.startTime,
|
||
terminationReason: reason,
|
||
iterations: this.iterations.length,
|
||
totalTokens: this.totalTokens.totalTokens,
|
||
});
|
||
|
||
return {
|
||
finalAnswer: answer ?? (error ? error.message : 'No answer produced'),
|
||
terminationReason: reason,
|
||
iterations: this.iterations,
|
||
totalTokenUsage: this.totalTokens,
|
||
durationMs: Date.now() - this.startTime,
|
||
metadata: { config: this.config, error: error?.message },
|
||
};
|
||
}
|
||
}
|