Files
metona-ai-desktop/electron/harness/agent-loop/engine.ts
T
thzxx 025f00171b feat: 升级至 v0.3.0 — 安全增强、死循环检测、六轮全面审计修复
大版本迭代,新增安全增强、Agent Loop 增强、UI/UX 增强,经六轮全面审计修复所有问题。

新增功能:
- OutputValidator 事实一致性检查 + 幻觉检测
- PromptInjectionDefender 语义级检测(指令性动词密度、角色边界、分隔符嵌套)
- DeadLoopError 死循环检测(连续3轮相同工具调用自动终止)
- PolicyEngine maxFrequency 滑动窗口频率限制
- MemoryManager TF-IDF 语义检索 + IDF 缓存原子替换
- 斜杠命令(/tool /memory /clear /export)+ 快捷键(Ctrl+B/J/Shift+F/N/[/])
- 专注模式(Ctrl+Shift+F)带面板状态快照保存/恢复

六轮审计修复(共修复 3 CRITICAL + 8 HIGH + 13 MEDIUM + 11 LOW):

CRITICAL:
- main.ts 传入 createAdapter 而非 reloadAdapter,导致 Provider 切换完全失效
- Ctrl+N/Ctrl+[/Ctrl+] 不同步 agent-store,导致消息发到错误会话
- engine.ts emit('error') 无监听器导致 DONE 事件丢失、前端卡死

HIGH:
- 死循环检测在工具执行之后(移入 PARSING 后 EXECUTING 前)
- deadLoop 事件前端未处理
- ConfirmationHook.clearPending 从未调用导致定时器泄漏
- engine.ts retry abort listener 未移除导致监听器堆积
- MCPManager JSON.parse 无 try-catch 导致初始化崩溃
- sendMessage 自动创建会话不同步 session-store
- setCurrentSession 竞态导致旧请求覆盖新会话数据

MEDIUM:
- toggleFocusMode 覆盖用户原有面板状态
- 正则检测可被常见词绕过
- 频率限制内存泄漏 + customPolicies 覆盖
- LIKE 回退转义未包含反斜杠
- OutputValidator 新功能未传入 toolResults/context
- MemoryConsolidator LLM 调用无超时保护
- AuditService 每次 log 都查询数据库
- browser-window-manager 超时后未停止页面加载
- output-validator URL 比较大小写敏感
- FileReader 无 onerror 导致 Promise 永久挂起
- /clear /export 不关闭斜杠菜单
- 专注模式下 toggleSidebar/toggleDetail 未恢复另一面板快照

LOW:
- abortPromise 事件监听器堆积
- RateLimitHook Map 内存泄漏
- memory.ts await 同步方法
- PolicyEngine 死代码清理
- Orchestrator sessionDepth 会话结束不清理
- workspace.service.ts 元数据插入边界问题
2026-07-12 19:46:47 +08:00

968 lines
34 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 { nanoid } from 'nanoid';
import {
AgentLoopState,
TerminationReason,
type IterationStep,
type Thought,
type AgentLoopConfig,
type AgentLoopOutput,
type TokenUsage,
} from './types';
import type {
MetonaRequest,
MetonaResponse,
MetonaMessage,
MetonaSystemPrompt,
MetonaToolCall,
MetonaToolResult,
MetonaStreamEvent,
IMetonaProviderAdapter,
MetonaToolDef,
} from '../types';
import { MetonaStreamEventType, MetonaFinishReason } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator';
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,
timeoutMs: 120_000,
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;
/** 当前 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;
}
/**
* 热更新 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 };
this.abortController = new AbortController();
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) {
const assistantMsg: MetonaMessage = {
role: 'assistant',
content: step.thought.content,
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',
content: 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 原因终止
if (error instanceof DeadLoopError) {
return this.finish(TerminationReason.DEAD_LOOP, error.message);
}
const errMsg = (error as Error).message;
if (this.aborted || errMsg.includes('Aborted')) {
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;
this.emit('aborted');
}
/**
* 销毁引擎,清理所有监听器
*/
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 分支)
if (event.type === MetonaStreamEventType.ERROR && (event.error?.code as string) === '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,
};
}
break;
case MetonaStreamEventType.ERROR:
// RETRY 已在循环入口过滤,此处只处理真正的错误
if (event.error) {
throw new Error(event.error.message);
}
break;
}
}
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
await this.transitionTo(AgentLoopState.PARSING);
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接)
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 {
// JSON 解析失败,使用空参数(LLM 仍请求了该工具调用)
args = {};
}
step.toolCalls.push({
id: `tc_${nanoid(8)}`,
name: buf.name,
args,
iteration: this.currentIteration,
timestamp: Date.now(),
});
}
}
// 记录 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. 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);
// 并行执行所有工具调用(独立工具之间无依赖,可安全并发)
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
const result = await this.executeToolSafely(tc);
// H-5: abort 后不转发工具结果(避免 DONE 后到达的残留事件污染新流)
if (!this.aborted) {
// 立即转发工具结果到渲染进程(不等其他工具完成)
this.emit('streamEvent', {
type: MetonaStreamEventType.TOOL_RESULT,
requestId: request.meta.requestId,
sessionId,
iteration: this.currentIteration,
seq: this.nextSeq(),
timestamp: Date.now(),
toolResult: result,
runId: this.runId,
});
}
return result;
};
// H-5: abort 时提前退出工具执行等待,不再阻塞至所有工具完成
const toolsPromise = Promise.allSettled(
step.toolCalls.map((tc) => executeAndForward(tc)),
);
const controller = this.abortController;
let onAbort: (() => void) | null = null;
const abortPromise = new Promise<null>((resolve) => {
if (this.aborted) return resolve(null);
if (controller) {
onAbort = () => resolve(null);
controller.signal.addEventListener('abort', onAbort, { once: true });
}
});
const raceResult = await Promise.race([toolsPromise, abortPromise]) as
| PromiseSettledResult<MetonaToolResult>[]
| null;
// v0.3.0 修复: 若 toolsPromise 先完成(正常执行),手动移除 abort 监听器,避免监听器堆积
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
if (raceResult === null) {
// 被 abort 中断,标记步骤并退出
step.completedAt = Date.now();
step.state = AgentLoopState.TERMINATED;
return step;
}
const settledResults = raceResult;
step.toolResults = settledResults.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 状态 — 观察工具结果,决定是否继续 ===
// 如果有工具调用且需要后续推理,进入 REFLECTING 状态
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
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow;
const estimatedTokens = this.estimateMessagesTokens(request.messages);
const compressionThreshold = this.config.compressionThreshold * effectiveContextWindow;
if (estimatedTokens > compressionThreshold && request.messages.length > 10) {
await this.transitionTo(AgentLoopState.COMPRESSING);
const compressed = await this.compressMessages(request.messages);
if (compressed) {
// 原地替换数组内容,确保外层 messages 引用同步更新
request.messages.splice(0, request.messages.length, ...compressed);
this.emit('compressed', {
iteration: this.currentIteration,
originalTokens: estimatedTokens,
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;
}
}
/**
* 安全执行工具调用(经过 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(),
};
}
}
// 执行工具(带超时)
// 兜底超时取 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;
try {
toolResult = await Promise.race([
this.toolRegistry.execute(toolCall, {
sessionId: this.currentSessionId ?? '',
workspacePath: this.workspacePath,
iteration: this.currentIteration,
requestId: this.currentRequestId,
}),
new Promise<MetonaToolResult>((_, reject) =>
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
for (const hook of this.postToolHooks) {
await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
}
return toolResult;
}
// 后置 Hook 管道
for (const hook of this.postToolHooks) {
await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
}
return toolResult;
}
/**
* 带重试的流式调用(v0.2.0: 指数退避)
*
* 如果 adapter 抛出错误,在 retryCount 次数内重试。
* v0.2.0: 使用指数退避替代固定 1 秒等待
* 等待时间 = baseDelay * 2^attempt1s, 2s, 4s, 8s...
* 上限 30 秒,加上 ±20% 随机抖动(jitter)避免惊群效应
*/
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
let lastError: unknown;
const baseDelayMs = 1_000;
const maxDelayMs = 30_000;
for (let attempt = 0; attempt <= this.config.retryCount; attempt++) {
try {
// 首次尝试直接 yield
if (attempt === 0) {
yield* this.adapter.chatStream(request);
return;
}
// 重试时:先发送一个 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: 'RETRY' as never,
message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`,
retryable: true,
},
} as MetonaStreamEvent;
yield* this.adapter.chatStream(request);
return;
} catch (error) {
lastError = error;
if (this.aborted) throw error;
if (attempt < this.config.retryCount) {
// 检查是否为可重试错误
if (!this.isRetryableError(error)) throw error;
// 指数退避 + 抖动
const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
const jitter = delay * 0.2 * (Math.random() * 2 - 1); // ±20% jitter
const waitMs = Math.max(500, delay + jitter);
log.warn(`[AgentLoop] Retry ${attempt + 1}/${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 });
}
});
}
}
}
throw lastError;
}
/** 判断错误是否可重试 */
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;
// SSE 流中断 — 可重试
if (err.message?.includes('aborted') || err.message?.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.5 token/字,ASCII 0.25 token/字,其他 1 token/字
* @see electron/harness/utils/token-estimator.ts
*/
private estimateMessagesTokens(messages: MetonaMessage[]): number {
return estimateMessagesTokens(messages);
}
/**
* v0.3.0: 死循环检测
*
* 检测策略:
* 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
*
* 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轮重复,同时避免内存增长)
if (this.toolCallHistory.length > 5) {
this.toolCallHistory.shift();
}
// 需要至少3轮数据才能检测
if (this.toolCallHistory.length < 3) return false;
const len = this.toolCallHistory.length;
const r1 = this.toolCallHistory[len - 1]; // 当前轮
const r2 = this.toolCallHistory[len - 2]; // 上一轮
const r3 = this.toolCallHistory[len - 3]; // 上上一轮
// 连续3轮完全相同 → 死循环
return r1 === r2 && r2 === r3;
}
/**
* 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话
*
* 策略:
* 1. 保留最后 keepRecent 条消息(约 3 轮对话)
* 2. 将前面的所有消息交给 LLM 生成摘要
* 3. 用 [Context Summary] system 消息 + 近期消息替换原数组
*
* @returns 压缩后的消息数组,压缩失败时返回 null(调用方保持原数组)
*/
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
const keepRecent = 10; // 保留最近 10 条消息(约 3 轮 user+assistant+tool
if (messages.length <= keepRecent) return null;
const toCompress = messages.slice(0, messages.length - keepRecent);
const toKeep = messages.slice(messages.length - keepRecent);
// 构建摘要请求
// 不截断单条消息——摘要请求是独立 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.chat(summaryRequest);
const summary = response.content.trim();
if (!summary) return null;
const summaryMessage: MetonaMessage = {
role: 'system',
content: `[Context Summary] The following is a summary of earlier conversation:\n\n${summary}`,
timestamp: Date.now(),
};
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${toKeep.length} recent`);
return [summaryMessage, ...toKeep];
} 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 {
// 发送唯一的最终 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,
});
// 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 },
};
}
}