Files
metona-ai-desktop/electron/harness/agent-loop/engine.ts
T
thzxx ebe45482b0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m47s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 9m55s
feat: v0.7.3 成本收口 · 状态一致 · 死账清理 — Prompt Cache 根治 + SSRF DNS Pinning + 87 用例扩充全量回归
P1 修复面收口: Prompt Cache 根治(日期/记忆/附件三类易变内容出 system 入用户消息
  前置块 user-context.ts, system 跨 run 字节级稳定; Anthropic system 块数组化 +
  cache_control ephemeral 断言, DeepSeek 自动缓存前缀命中 — 多轮对话输入 token
  成本降数量级); 编辑重发/重新生成幽灵 Trace 双侧根治(DB truncateMessagesAfter
  同步过滤 metadata.traceSteps + 前端 trimTraceStepsByAnchor 镜像, 严格小于锚点
  时间戳, 同毫秒等值判废); sessions:deleteMessage 死通道全链路删除(渲染层零调用
  + message_count 漂移面); Ollama vision 能力门控全链路(MetonaModelInfo
  .supportsVision 贯穿 adapter/IPC/store/UI, model-capabilities.ts 三道判定纯函数,
  未知保守放行); 记忆固化节流(consolidation-policy 纯函数: 总开关 + 内容门控
  [回答>=200字符或存在成功工具调用] + 会话级 10 分钟频率窗口, 三 memory.* 配置键)

P2 安全纵深: SSRF DNS Pinning 关闭 rebinding 窗口(ssrf-guard 重构
  resolvePublicAddresses 单源; ssrf-dispatcher 以 undici Agent.connect.lookup
  钉死校验 IP, TLS SNI 保持原域名, 一次性 dispatcher 用后即毁; 代理激活显式
  退化为仅入口校验); web_fetch 重写手动逐跳重定向循环(每跳先校验后连接,
  替代 redirect:follow 内核跟跳的中间跳裸奔, 上限 5 跳); http_request 换用
  pinned fetch; web_search 可达性预检加固(私有 URL 零请求 + 不跟跳, 3xx 视为
  可达); Agent 浏览器 CORS 通配收紧为 Origin 回显 + Vary: Origin;
  ConfirmationHook.forgetSession 会话终态清理(会话删除/abort 联动/SubAgent
  终结三处接线, 根治 rememberedDecisions 泄漏)

P3 架构还债: agent.enableReflection 死配置全链路接线(main→shared→引擎→
  Orchestrator→设置开关, REFLECTING 状态真实可达); AgentLoopConfig.timeoutMs
  死字段删除; MemoryManager.cleanupExpired 挂入健康检查周期(expires_at 回收
  管道真实化); buildSafeEnv 收敛 utils/safe-env.ts 单源(run_command 与 MCP
  stdio 共用, 终结双实现漂移); Trace 生命周期治理(metadata 只保留最近 20 个
  run — keepRecentRuns 纯函数; JSONL 录制启动自动清理保留 200 个 + 设置页
  手动清理); SLO/健康快照可视化(app:healthSnapshot IPC + 设置页只读卡片 +
  审计链一键校验)

P4 能力演进: 会话标题 LLM 自动生成(TitleGenerator — 每会话幂等/并发重入复用
  同一 Promise/自定义标题不覆盖/失败静默回退, Sidebar 经 config:changed 实时
  刷新); MCP 自动重连(5s/15s/60s 退避最多 3 次, reconnecting 状态机,
  teardownConnection 内部拆除保留簿记 — 用户断开/开关关闭即时取消, 设置页
  显示第 N/3 次); 死循环检测 ABAB 乒乓模式(最近4轮 A→B→A→B 交替判定, 补齐
  docs 第五章"两状态反复切换"检测契约); i18n 第三阶段(ChatInput/LLMSettings/
  OnboardingWizard/MemoryViewer 主链路文案出层, zh-CN + en-US 双字典补齐)

测试: 737 → 824 用例(+87, 新增 8 个测试文件 + 扩展 3 个)。新覆盖: user-context
  分组/空值收缩/拼接契约、context-builder 字节级稳定性、Anthropic cache_control
  四态、consolidation-policy 九路判定矩阵、ssrf-dispatcher(pinned lookup/重定向
  解析/IP 校验)、forget-session 会话隔离、trace-lifecycle run 淘汰、
  trace-trim 严格小于边界、safe-env 净化矩阵、mcp-reconnect 退避状态机
  (fake timers)、title-generator 并发重入、SQLite 侧 truncate×TRACE 联动
  (Electron ABI)。测试驱动修复: GIT_*/ 注释终止块注释、重连计数被自身重试
  前置断开重置(拆 teardownConnection 保留簿记)、TitleGenerator 幂等占位与
  并发去重的检查顺序竞态(去重先于幂等)

版本: 0.7.3; README 同步(配置表新增 agent.enableReflection/memory.*/mcp.autoReconnect)

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 771 通过 53 跳过
  (better-sqlite3 ABI); Electron ABI 全量 824/824 零跳过
2026-08-30 09:44:43 +08:00

1414 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Agent Loop — ReAct 状态机引擎
*
* 生产级 ReAct Agent Loop,负责:
* 1. 状态机管理循环生命周期
* 2. 调用 Provider Adapter 获取 LLM 响应(支持流式)
* 3. 解析输出、执行工具、注入观察
* 4. 流式事件推送 UI
* 5. 超时、重试、上下文压缩
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第四章
*/
import { EventEmitter } from 'events';
import { resolve } from 'path';
import { nanoid } from 'nanoid';
import {
AgentLoopState,
TerminationReason,
type IterationStep,
type AgentLoopConfig,
type AgentLoopOutput,
type TokenUsage,
} from './types';
import type {
MetonaRequest,
MetonaMessage,
MetonaSystemPrompt,
MetonaToolCall,
MetonaToolResult,
MetonaStreamEvent,
IMetonaProviderAdapter,
MetonaToolDef,
} from '../types';
import { MetonaStreamEventType, MetonaErrorCode } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator';
import { ContentFilterError } from '../adapters/base-adapter';
import { truncatedArgumentsPayload } from '../adapters/shared/sse-stream';
import log from 'electron-log';
/**
* v0.3.0: 死循环错误 — 在 executeOneIterationStream 中抛出,主循环捕获后以 DEAD_LOOP 原因终止
*/
class DeadLoopError extends Error {
constructor(message: string) {
super(message);
this.name = 'DeadLoopError';
}
}
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[] = [];
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 64K / Agnes 1M / Ollama 4096)。
* 不同步会导致压缩阈值(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 中断新流
if (this.currentRunPromise) {
await this.currentRunPromise.catch(() => {});
}
this.currentRunPromise = this.executeRunStream(userMessage, sessionId, history, systemPrompt);
try {
return await this.currentRunPromise;
} finally {
this.currentRunPromise = null;
}
}
private async executeRunStream(
userMessage: MetonaMessage,
sessionId: string,
history: MetonaMessage[],
systemPrompt: MetonaSystemPrompt,
): Promise<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 = [];
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
// assistantcontent=null,符合 C-6 规范)。
if (step.thought || (step.toolCalls && step.toolCalls.length > 0)) {
const assistantMsg: MetonaMessage = {
role: 'assistant',
content: step.thought?.content ?? null,
reasoningContent: step.thought?.reasoningContent,
toolCalls: step.toolCalls,
timestamp: Date.now(),
iteration: this.currentIteration,
};
messages.push(assistantMsg);
}
// 如果没有工具调用,视为最终输出
if (!step.toolCalls || step.toolCalls.length === 0) {
return this.finish(TerminationReason.COMPLETED, step.thought?.content);
}
// v0.3.0: 死循环检测已移入 executeOneIterationStream 的 PARSING 阶段后,
// 通过抛出 DeadLoopError 在此处 catch 块捕获处理
// 执行工具并将结果加入消息
if (step.toolResults) {
for (const result of step.toolResults) {
messages.push({
role: 'tool',
// CE-2 修复: 工具失败时 result.result 为 nullLLM 会看到 "null" 而非错误信息
// 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环
content: result.error
? result.error
: typeof result.result === 'string'
? result.result
: JSON.stringify(result.result),
toolResult: result,
timestamp: Date.now(),
iteration: this.currentIteration,
});
}
}
}
// 循环退出判断
if (this.aborted) return this.finish(TerminationReason.USER_INTERRUPT);
if (this.currentIteration >= this.config.maxIterations)
return this.finish(TerminationReason.MAX_ITERATIONS);
return this.finish(TerminationReason.TIMEOUT);
} catch (error) {
// v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止
// P0-1 审查修复: 必须传 error 参数(第三参数),否则 finish() 不会发射 ERROR 事件
// 前端只收到 DONE 会导致 agentStatus 被设为 'idle' 而非 'error',且无错误消息卡片
if (error instanceof DeadLoopError) {
return this.finish(TerminationReason.DEAD_LOOP, undefined, error as Error);
}
const errMsg = (error as Error).message ?? '';
// P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR
// Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖
const errMsgLower = errMsg.toLowerCase();
if (
this.aborted ||
errMsgLower.includes('aborted') ||
errMsgLower.includes('timed out') ||
errMsgLower.includes('timeout')
) {
return this.finish(TerminationReason.USER_INTERRUPT);
}
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw
// 导致 finish() 被中断、DONE 事件丢失、前端卡死。改为日志记录,让 finish 正常执行
log.error(`[AgentLoop] Run failed: ${errMsg}`);
return this.finish(TerminationReason.ERROR, undefined, error as Error);
}
}
/** 获取当前 Provider Adapter(供 SubAgent 创建独立引擎实例) */
getAdapter(): IMetonaProviderAdapter {
return this.adapter;
}
/** 获取工作空间路径(供 SubAgent 继承) */
getWorkspacePath(): string {
return this.workspacePath;
}
/** 中断循环 */
abort(): void {
this.aborted = true;
this.abortController?.abort();
this.abortController = null;
// C-2 修复: 清除 adapter 的 abort signal,防止旧的已 abort signal 影响后续请求
if (this.adapter.setAbortSignal) {
this.adapter.setAbortSignal(undefined);
}
this.emit('aborted');
}
/**
* MT-1 修复: 等待当前 run 结束(用于 abortSession IPC handler
*
* abort() 只是设置了标志和触发了 abortController
* 但 currentRunPromise 仍在进行中(可能在等待工具超时或 LLM 响应)。
* 如果不等待就返回,用户立即重发会导致新消息卡在 runStream 的 currentRunPromise 等待中。
*
* @param timeoutMs 等待超时(默认 5 秒,防止永久挂起)
* @returns true 表示 run 已结束,false 表示等待超时
*/
async waitForAbort(timeoutMs: number = 5_000): Promise<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;
// 流式接收响应
for await (const event of this.chatStreamWithRetry(request)) {
if (this.aborted) break;
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
if (event.type === MetonaStreamEventType.DONE) continue;
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全
if (
event.type === MetonaStreamEventType.ERROR &&
event.error?.code === MetonaErrorCode.RETRY
) {
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
fullContent = '';
reasoningContent = '';
toolCallsBuffer.clear();
continue;
}
// 转发流式事件到渲染进程(注入 runId 供前端过滤旧流)
this.emit('streamEvent', { ...event, runId: this.runId });
switch (event.type) {
case MetonaStreamEventType.TEXT_DELTA:
if (event.delta) fullContent += event.delta;
break;
case MetonaStreamEventType.REASONING_DELTA:
if (event.delta) reasoningContent += event.delta;
break;
case MetonaStreamEventType.TOOL_CALL_DELTA:
if (event.toolCallDelta) {
const { index, name, argsDelta } = event.toolCallDelta;
if (!toolCallsBuffer.has(index)) {
toolCallsBuffer.set(index, { name: name ?? '', argsBuffer: '' });
}
const buf = toolCallsBuffer.get(index)!;
if (name) buf.name = name;
if (argsDelta) buf.argsBuffer += argsDelta;
}
break;
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
if (event.toolCall) {
// 工具调用完成,记录到 step
if (!step.toolCalls) step.toolCalls = [];
step.toolCalls.push(event.toolCall);
}
break;
case MetonaStreamEventType.USAGE:
if (event.usage) {
tokenUsage = {
promptTokens: event.usage.inputTokens ?? 0,
completionTokens: event.usage.outputTokens ?? 0,
totalTokens: event.usage.totalTokens ?? 0,
};
// 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轮重复调用的副作用不会产生(工具尚未执行)
if (step.toolCalls && step.toolCalls.length > 0) {
if (this.detectDeadLoop(step.toolCalls)) {
log.warn(
`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`,
);
this.emit('deadLoop', {
iteration: this.currentIteration,
runId: this.runId,
sessionId: this.currentSessionId,
});
// 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止
throw new DeadLoopError(
`Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations, 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;
});
}
// === OBSERVING ===
await this.transitionTo(AgentLoopState.OBSERVING);
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
// v0.7.3 接线说明:REFLECTING 分支此前依赖 enableReflection 配置,但该配置
// 全链路无任何置 true 的路径(死配置)。现由 agent.enableReflection 配置
// 真实驱动(main.ts baseConfig → updateConfigAll → 本分支),启用后每轮
// 工具执行完毕会经过 REFLECTING 状态:工具结果存在失败时记录告警日志,
// 供 SLO 与排障观察(不阻断循环——错误结果已由 CE-2 路径回传模型自愈)。
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
await this.transitionTo(AgentLoopState.REFLECTING);
// 检查工具执行是否有错误,如果有严重错误可以提前终止
const hasErrors = step.toolResults?.some((r) => !r.success);
if (hasErrors) {
log.warn(`[AgentLoop] Iteration ${this.currentIteration} had tool errors`);
}
}
// === 上下文压缩(基于 token 使用率触发) ===
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发
const effectiveContextWindow =
this.config.contextLength ?? this.config.contextWindow ?? 128_000;
const estimatedTokens = this.estimateMessagesTokens(request.messages);
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
// 真实值用于校正——LLM 返回的 inputTokens 是 BPE 真实分词结果,比字符估算准确
const actualTokens = Math.max(estimatedTokens, this.lastRealInputTokens);
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
// v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4"
// 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。
// 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。
if (actualTokens > compressionThreshold && request.messages.length >= 4) {
await this.transitionTo(AgentLoopState.COMPRESSING);
const compressed = await this.compressMessages(request.messages);
if (compressed) {
// 原地替换数组内容,确保外层 messages 引用同步更新
request.messages.splice(0, request.messages.length, ...compressed);
// v0.3.18 修复: 压缩后重置 lastRealInputTokens,下一轮 LLM 调用会返回新的(更小的)真实值
this.lastRealInputTokens = 0;
this.emit('compressed', {
iteration: this.currentIteration,
originalTokens: actualTokens,
compressedTokens: this.estimateMessagesTokens(compressed),
});
}
// 压缩后回到 OBSERVING
await this.transitionTo(AgentLoopState.OBSERVING);
}
step.completedAt = Date.now();
step.state = AgentLoopState.OBSERVING;
return step;
} catch (error) {
step.completedAt = Date.now();
step.state = AgentLoopState.TERMINATED;
// v0.3.0 修复: DeadLoopError 抛出时,将 step 加入 iterations 数组,
// 确保死循环轮的 LLM thought 内容不丢失(用户可观察 Agent 被终止前的最后思考)
if (error instanceof DeadLoopError) {
this.iterations.push(step);
}
throw error;
}
}
/**
* L-19 修复: PARSING 阶段 — 解析流式接收期间累积的工具调用缓冲区,
* 构造 MetonaToolCall[] 写入 step.toolCalls。
*
* 仅在缓冲区非空且 step 尚未通过 TOOL_CALL_COMPLETE 接收到完整调用时生效,
* 避免覆盖已就绪的 toolCalls。
*/
private finalizeToolCallsFromBuffer(
step: IterationStep,
toolCallsBuffer: Map<number, { name: string; argsBuffer: string }>,
): void {
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
step.toolCalls = [];
for (const [, buf] of toolCallsBuffer) {
let args: Record<string, unknown>;
try {
args = buf.argsBuffer ? JSON.parse(buf.argsBuffer) : {};
} catch (err) {
// v0.6.4: 引擎侧兜底缓冲的截断自愈对齐 —— 此处是全链路最后一个
// "解析失败静默 args={}" 的残留点。统一转为 _truncatedArguments
// 保证任何 Provider 路径的截断工具调用都能触发模型自愈而非静默丢失。
const sample = buf.argsBuffer.slice(-120);
args = truncatedArgumentsPayload((err as Error).message, sample);
}
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args,
iteration: this.currentIteration,
timestamp: Date.now(),
});
}
}
}
/**
* L-19 修复: EXECUTING 阶段 — 并行执行工具调用,转发结果到 UI,
* 并与 abort 信号竞速。返回 null 表示被 abort 中断。
*
* 注意:rejected 的工具结果由调用方处理(构造失败 result 并转发),
* 此处只负责转发 fulfilled 的结果。
*/
private async executeToolCallsParallel(
toolCalls: MetonaToolCall[],
requestId: string,
sessionId: string,
): Promise<PromiseSettledResult<MetonaToolResult>[] | null> {
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
const result = await this.executeToolSafely(tc);
// 执行成功后转发结果到 UI(abort 后不再转发,避免污染新会话的流)
if (!this.aborted) {
this.emit('streamEvent', {
type: MetonaStreamEventType.TOOL_RESULT,
requestId,
sessionId,
iteration: this.currentIteration,
seq: this.nextSeq(),
timestamp: Date.now(),
toolResult: result,
runId: this.runId,
});
}
return result;
};
// C-2 修复: 使用 Promise.allSettled 而非 Promise.all,确保单个工具失败不影响其他工具
const toolsPromise = Promise.allSettled(toolCalls.map((tc) => executeAndForward(tc)));
const controller = this.abortController;
let onAbort: (() => void) | null = null;
const abortPromise = new Promise<null>((resolve) => {
if (this.aborted) {
resolve(null);
return;
}
if (controller) {
onAbort = () => resolve(null);
controller.signal.addEventListener('abort', onAbort, { once: true });
}
});
const raceResult = (await Promise.race([toolsPromise, abortPromise])) as
| PromiseSettledResult<MetonaToolResult>[]
| null;
// 清理 abort 监听器,避免事件循环中残留
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
return raceResult;
}
/**
* 安全执行工具调用(经过 Hook 管道)
*/
private async executeToolSafely(toolCall: MetonaToolCall): Promise<MetonaToolResult> {
const startTs = Date.now();
if (!this.toolRegistry) {
return {
toolCallId: toolCall.id,
toolName: toolCall.name,
result: null,
success: false,
error: `Tool '${toolCall.name}' not available: no ToolRegistry configured`,
durationMs: Date.now() - startTs,
timestamp: Date.now(),
};
}
// 前置 Hook 管道
for (const hook of this.preToolHooks) {
const result = await hook.beforeExecute(toolCall, this.currentSessionId);
if (result.blocked) {
return {
toolCallId: toolCall.id,
toolName: toolCall.name,
result: null,
success: false,
error: `Blocked: ${result.reason}`,
durationMs: Date.now() - startTs,
timestamp: Date.now(),
};
}
}
// H-5 修复: 精确保护工作空间根目录的 MEMORY.md
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
// subdirectory MEMORY.md files are unrestricted
// 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
// 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
// run_command 由 permissions.ts 的粗粒度正则保留保护(命令解析复杂)。
if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) {
if (this.isTargetingRootMemoryMd(toolCall)) {
return {
toolCallId: toolCall.id,
toolName: toolCall.name,
result: null,
success: false,
error: 'Access to workspace root MEMORY.md is protected by security policy',
durationMs: Date.now() - startTs,
timestamp: Date.now(),
};
}
}
// 执行工具(带超时)
// 兜底超时取 max(配置值, 工具自定义 timeoutMs),确保工具能跑满自己声明的超时
const configuredTimeout = this.config.toolExecutionTimeoutMs ?? 120_000;
const toolDef = this.toolRegistry.get(toolCall.name)?.definition;
const toolTimeout = Math.max(configuredTimeout, toolDef?.timeoutMs ?? 0);
let toolResult: MetonaToolResult;
// M-16 修复: 使用 try/finally 清理 setTimeout,防止事件循环 timer 堆积
// 默认 120 秒超时下,多轮迭代会堆积大量未触发 timer
let engineTimer: ReturnType<typeof setTimeout> | undefined;
try {
toolResult = await Promise.race([
this.toolRegistry.execute(toolCall, {
sessionId: this.currentSessionId ?? '',
workspacePath: this.workspacePath,
iteration: this.currentIteration,
requestId: this.currentRequestId,
// P0-4: 引擎级 abort 信号透传——用户中断时工具内部(如 run_command 子进程)可自行终止
signal: this.abortController?.signal,
}),
new Promise<MetonaToolResult>((_, reject) => {
engineTimer = setTimeout(
() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)),
toolTimeout,
);
}),
]);
} catch (err) {
toolResult = {
toolCallId: toolCall.id,
toolName: toolCall.name,
result: null,
success: false,
error: (err as Error).message,
durationMs: 0,
timestamp: Date.now(),
};
// 仍执行 post-hookP0-2: 错误结果同样过安全扫描/审计;钩子可返回修改后的结果)
let errorResult = toolResult;
for (const hook of this.postToolHooks) {
const modified = await hook.afterExecute(toolCall, errorResult, this.currentSessionId);
if (modified) errorResult = modified;
}
return errorResult;
} finally {
// M-16 修复: 清理未触发的 timeout timer
if (engineTimer) clearTimeout(engineTimer);
}
// 后置 Hook 管道(P0-2: 钩子可返回修改后的结果——如 SecurityScanHook 对网页内容脱敏)
let finalResult = toolResult;
for (const hook of this.postToolHooks) {
const modified = await hook.afterExecute(toolCall, finalResult, this.currentSessionId);
if (modified) finalResult = modified;
}
return finalResult;
}
/**
* H-5 修复: 检查工具调用是否针对工作空间根目录的 MEMORY.md
*
* @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected;
* subdirectory MEMORY.md files are unrestricted
*
* 之前 permissions.ts 使用 /MEMORY\.md/i 粗粒度正则会误拦子目录的 MEMORY.md,
* 现在改为在工具执行层进行精确校验,只阻止对根目录 MEMORY.md 的读写。
*
* @param toolCall 工具调用
* @returns 是否指向工作空间根目录的 MEMORY.md
*/
private isTargetingRootMemoryMd(toolCall: MetonaToolCall): boolean {
if (!this.workspacePath) return false;
// 提取工具参数中的路径(不同工具使用不同的参数名)
const args = toolCall.args;
const pathStr =
(args.path as string) ||
(args.file_path as string) ||
(args.filePath as string) ||
(args.file as string) ||
(args.target as string) ||
(args.destination as string);
if (!pathStr || typeof pathStr !== 'string') return false;
// 解析路径,判断是否指向工作空间根目录的 MEMORY.md
// 使用 toLowerCase 处理 Windows 不区分大小写的文件系统
const resolved = resolve(pathStr).toLowerCase();
const rootMemoryPath = resolve(this.workspacePath, 'MEMORY.md').toLowerCase();
// 精确匹配:路径必须等于 {workspacePath}/MEMORY.md
return resolved === rootMemoryPath;
}
/**
* 带重试的流式调用(v0.2.0: 指数退避;P1: Provider 故障转移)
*
* 重试策略:
* 1. 可重试错误(429/5xx/网络)→ 指数退避重试(1s/2s/4s...,上限 30s,±20% jitter
* 2. 重试耗尽或不可重试错误 → 若配置了 fallbackAdapter,切换 Provider 重发本次请求
* 3. 故障转移仅触发一次(防止主/备 Provider 间乒乓切换)
*
* 故障转移后 this.adapter 切换为 fallback,本 run 内后续迭代均使用备用 Provider,
* 并通过 'providerSwitched' 事件通知上层(IPC → 前端系统消息 + Toast)。
*/
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const baseDelayMs = 1_000;
const maxDelayMs = 30_000;
let attempt = 0;
let currentAdapter = this.adapter;
let failoverUsed = false;
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 提前拦截)
const msg = err.message?.toLowerCase() ?? '';
if (msg.includes('aborted') || msg.includes('socket hang up')) return true;
// 其他错误(400/401/403/4xx)不重试
return false;
}
/** 生成下一个事件序列号 */
private nextSeq(): number {
return ++this.eventSeq;
}
private async transitionTo(state: AgentLoopState): Promise<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.3 起双模式):
* 1. 驻留模式 — 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
* 2. 乒乓模式(v0.7.3 新增)— 最近4轮构成 ABAB 交替(r1===r3 && r2===r4 && r1!==r2)。
* 典型场景:模型在"读文件 A → 写文件 B"两步之间无限往返(每次读完又改回),
* 单步签名各不相同,驻留模式永不命中;docs/Agentic-Loop详解.md 第五章将
* "两种状态间反复来回切换、毫无进展"列为必须检测的停滞模式。
*
* v0.3.0 修复:
* - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报
*
* @param toolCalls 当前轮次的工具调用
* @returns 是否检测到死循环
*/
private detectDeadLoop(toolCalls: MetonaToolCall[]): boolean {
// 将当前轮次的工具调用序列化为签名
// v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名
// v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归
const stableStringify = (
obj: unknown,
visited: Set<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);
}
};
const signature = toolCalls.map((tc) => `${tc.name}(${stableStringify(tc.args)})`).join('|');
this.toolCallHistory.push(signature);
// 只保留最近5轮的记录(足够检测3轮重复与4轮乒乓,同时避免内存增长)
if (this.toolCallHistory.length > 5) {
this.toolCallHistory.shift();
}
const len = this.toolCallHistory.length;
// 模式 1:连续3轮完全相同 → 死循环
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;
}
// 模式 2v0.7.3):最近4轮 ABAB 交替(A≠B)→ 乒乓死循环
if (len >= 4) {
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 true;
}
return false;
}
/**
* 上下文压缩 — 将旧消息摘要为一条 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 },
};
}
}