feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

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)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
@@ -17,11 +17,12 @@ import { MetonaStreamEventType } from '../../types';
/** 构造 Mock AdaptersendStream 按脚本产出事件 */
function createMockAdapter(
scripts: MetonaStreamEvent[][],
opts?: { failWith?: Error },
opts?: { failWith?: Error; providerId?: string },
): IMetonaProviderAdapter {
let call = 0;
const providerId = opts?.providerId ?? 'mock';
return {
providerId: 'mock',
providerId,
supportedModels: ['mock-model'],
supportsToolCalling: true,
supportsThinking: false,
@@ -30,7 +31,7 @@ function createMockAdapter(
async (): Promise<MetonaResponse> => ({
meta: {
requestId: 'r_test',
provider: 'mock',
provider: providerId,
model: 'mock-model',
latencyMs: 1,
timestamp: Date.now(),
@@ -170,9 +171,13 @@ describe('AgentLoopEngine', () => {
// 主 adapter 每次都失败(401 不可重试)
const primary = createMockAdapter([], {
failWith: Object.assign(new Error('401 invalid key'), { status: 401 }),
providerId: 'primary-mock',
});
// fallback 正常返回 —— v0.7.4: 使用不同 providerId,修复旧断言 `to==='mock'`
// 无法证明"切换到了正确的 fallback"primary 与 fallback 共用 'mock' 的假阳性)
const fallback = createMockAdapter([textDoneEvent('fallback answer')], {
providerId: 'fallback-mock',
});
// fallback 正常返回
const fallback = createMockAdapter([textDoneEvent('fallback answer')]);
const engine = new AgentLoopEngine({ retryCount: 0 }, primary);
engine.setFallbackAdapter(fallback);
@@ -185,8 +190,8 @@ describe('AgentLoopEngine', () => {
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
expect(output.finalAnswer).toBe('fallback answer');
expect(switchEvents.length).toBe(1);
expect(switchEvents[0].from).toBe('mock');
expect(switchEvents[0].to).toBe('mock');
expect(switchEvents[0].from).toBe('primary-mock');
expect(switchEvents[0].to).toBe('fallback-mock');
// fallback 的 sendStream 被调用
expect(fallback.sendStream).toHaveBeenCalled();
});
@@ -194,9 +199,11 @@ describe('AgentLoopEngine', () => {
it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => {
const primary = createMockAdapter([], {
failWith: Object.assign(new Error('401'), { status: 401 }),
providerId: 'primary-mock',
});
const fallback = createMockAdapter([], {
failWith: Object.assign(new Error('500'), { status: 500 }),
providerId: 'fallback-mock',
});
const engine = new AgentLoopEngine({ retryCount: 0 }, primary);
@@ -206,6 +213,84 @@ describe('AgentLoopEngine', () => {
// fallback 失败 → ERROR(不回切 primary
expect(output.terminationReason).toBe(TerminationReason.ERROR);
});
// ===== v0.7.4 P1-1: 超时误判三态 =====
it('P1-1 真实网络超时(ETIMEDOUT)映射为 TIMEOUT 而非 USER_INTERRUPT', async () => {
// 模拟 fetchWithTimeout 超时:message 含 "timed out" 且 code=ETIMEDOUT
const adapter = createMockAdapter([], {
failWith: Object.assign(new Error('Request timed out after 30000ms'), {
code: 'ETIMEDOUT',
}),
});
const engine = new AgentLoopEngine({ retryCount: 0 }, adapter);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.TIMEOUT);
});
it('P1-1 用户主动 abort 映射为 USER_INTERRUPT(非超时)', async () => {
// 模拟用户中断:sendStream 挂起(模拟 LLM 长响应),run 进行中调用 abort()
// 引擎 chatStreamWithRetry 的 catch 首先检查 this.aborted → 抛错 →
// executeRunStream catch 中 this.aborted=true → USER_INTERRUPT。
// (真实实现中 adapter 的 fetch 会因 abortController.abort() 而 reject
const hangingAdapter = {
providerId: 'mock',
supportedModels: ['mock-model'],
supportsToolCalling: true,
supportsThinking: false,
getContextWindow: () => 1_000_000,
send: vi.fn(async () => ({
meta: {
requestId: 'r1',
provider: 'mock',
model: 'm',
latencyMs: 1,
timestamp: Date.now(),
},
content: '',
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
finishReason: 'stop' as never,
})),
sendStream: vi.fn(async function* (): AsyncIterable<MetonaStreamEvent> {
// 挂起直到 abort 信号触发(真实流读取被 abort 打断的表现)
// 先 yield 一个占位值满足 generator 契约(require-yield),随后挂起
await new Promise<void>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('The operation was aborted')), 30);
// 让外层 abort() 有机会先执行;abort 后 adapter 的 setAbortSignal 收到信号
(t as unknown as { unref?: () => void }).unref?.();
});
// 不可达——上方 Promise 永不 resolve(只 reject 或挂起);此处 yield 仅为满足 generator 契约
yield {
type: MetonaStreamEventType.TEXT_DELTA,
requestId: 'r1',
sessionId: 's1',
iteration: 1,
seq: 0,
timestamp: Date.now(),
delta: '',
};
}),
setAbortSignal: vi.fn(),
healthCheck: async () => true,
} as unknown as IMetonaProviderAdapter;
const engine = new AgentLoopEngine({ retryCount: 0 }, hangingAdapter);
// 启动 run,稍后 abort(模拟用户在响应期间点击停止)
const runPromise = engine.runStream(userMessage, 's1', [], systemPrompt);
await new Promise((r) => setTimeout(r, 10));
engine.abort();
const output = await runPromise;
expect(output.terminationReason).toBe(TerminationReason.USER_INTERRUPT);
});
it('P1-1 其他错误(401)映射为 ERROR', async () => {
const adapter = createMockAdapter([], {
failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }),
});
const engine = new AgentLoopEngine({ retryCount: 0 }, adapter);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.ERROR);
});
});
// ===== v0.7.3 P4-4 / P3-1: 死循环乒乓检测 + REFLECTING 状态接线 =====
@@ -246,6 +331,64 @@ describe('AgentLoopEngine — 死循环乒乓检测(ABABP4-4', () => {
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
});
// ===== v0.7.4 P4-1: 乒乓检测进度信号二次确认 =====
it('P4-1 ABAB 但工具结果有差异(进度推进)不误报死循环', async () => {
// A=read_file B=write_file,参数相同构成 ABAB;但 write 的**结果**每轮变化
// (如写入内容随文件内容推进而不同)→ 进度信号存在 → 不应判定死循环
const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
// 工具执行结果由 ToolRegistry 提供;此处用"伪造 registry 返回变化结果"的方式:
// 引擎的 detectPingPong 在 EXECUTING 之后用 step.toolResults 做进度比对,
// 而 toolResults 来自 registry.execute —— 注入一个结果变化的 registry。
let execCounter = 0;
const registry = {
execute: vi.fn(async (tc: { id: string; name: string; args: Record<string, unknown> }) => {
// 每次执行返回递增 revision(模拟结果随推进变化)
execCounter++;
return {
toolCallId: tc.id,
toolName: tc.name,
result: { ok: true, revision: execCounter },
success: true,
durationMs: 1,
timestamp: Date.now(),
};
}),
get: () => ({ definition: { timeoutMs: 5000 } }),
listTools: () => [],
} as never;
const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]);
const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter, registry);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
// 结果在推进 → 不判定死循环 → 因 maxIterations=6 且持续调用工具,最终 MAX_ITERATIONS
expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS);
});
it('P4-1 ABAB 且工具结果完全相同(空转)仍判定死循环', async () => {
const readScript = toolCallEvent('read_file', { file_path: 'x.ts' });
const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' });
// 结果恒相同:模拟 read 返回同一内容、write 返回同一字节数(空转无推进)
const registry = {
execute: vi.fn(async (tc: { id: string; name: string; args: Record<string, unknown> }) => ({
toolCallId: tc.id,
toolName: tc.name,
result: { ok: true, hash: 'same' },
success: true,
durationMs: 1,
timestamp: Date.now(),
})),
get: () => ({ definition: { timeoutMs: 5000 } }),
listTools: () => [],
} as never;
const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]);
const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter, registry);
const output = await engine.runStream(userMessage, 's1', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP);
});
});
describe('AgentLoopEngine — REFLECTING 状态接线(P3-1 enableReflection', () => {
+190 -55
View File
@@ -94,6 +94,16 @@ export class AgentLoopEngine extends EventEmitter {
/** 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,
@@ -139,7 +149,8 @@ export class AgentLoopEngine extends EventEmitter {
* #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置
*
* Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的
* 上下文窗口差异巨大(DeepSeek 64K / Agnes 1M / Ollama 4096)。
* 上下文窗口差异巨大(DeepSeek 1M / Agnes 1M / MiMo 1M / OpenAI 128K~1M /
* Anthropic 200K / Ollama 4096 起,v0.7.4 修正注释——此前误写 64K)。
* 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。
*/
private syncContextWindow(): void {
@@ -183,8 +194,43 @@ export class AgentLoopEngine extends EventEmitter {
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) {
await this.currentRunPromise.catch(() => {});
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 {
@@ -221,6 +267,8 @@ export class AgentLoopEngine extends EventEmitter {
this.eventSeq = 0;
// v0.3.0: 重置工具调用历史(用于死循环检测)
this.toolCallHistory = [];
// v0.7.4 P4-1: 重置工具结果历史(进度信号,与调用历史按轮对应)
this.toolResultHistory = [];
try {
await this.transitionTo(AgentLoopState.INIT);
@@ -332,14 +380,23 @@ export class AgentLoopEngine extends EventEmitter {
// 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')
) {
// 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}`);
@@ -453,6 +510,10 @@ export class AgentLoopEngine extends EventEmitter {
// 过滤掉 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
@@ -461,6 +522,15 @@ export class AgentLoopEngine extends EventEmitter {
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;
}
@@ -550,8 +620,11 @@ export class AgentLoopEngine extends EventEmitter {
// v0.3.0 修复: 死循环检测 — 在 PARSING 阶段完成后、EXECUTING 阶段开始前检测
// 确保第3轮重复调用的副作用不会产生(工具尚未执行)
// v0.7.4 P4-1 拆分: 此处仅做"驻留模式"(连续 3 轮相同签名,无结果依赖,
// 可在执行前安全判定防副作用);"乒乓模式"(ABAB + 进度信号)需要本轮
// 工具结果做二次确认,移至 EXECUTING 之后(OBSERVING 前)检测。
if (step.toolCalls && step.toolCalls.length > 0) {
if (this.detectDeadLoop(step.toolCalls)) {
if (this.detectStuckLoop(step.toolCalls)) {
log.warn(
`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`,
);
@@ -615,6 +688,26 @@ export class AgentLoopEngine extends EventEmitter {
});
}
// 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);
@@ -1053,8 +1146,11 @@ export class AgentLoopEngine extends EventEmitter {
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('aborted') || msg.includes('socket hang up')) return true;
if (msg.includes('socket hang up') || msg.includes('fetch failed')) return true;
// 其他错误(400/401/403/4xx)不重试
return false;
}
@@ -1088,26 +1184,93 @@ export class AgentLoopEngine extends EventEmitter {
}
/**
* v0.3.0: 死循环检测
* v0.3.0: 死循环检测(驻留模式)—— v0.7.4 P4-1 拆分自 detectDeadLoop。
*
* 检测策略(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 键顺序不一致导致漏报
* 连续 3 轮调用签名完全相同(工具名 + 稳定序列化参数)→ 死循环。
* 在 PARSING 后、EXECUTING 前检测:无结果依赖,可安全地在副作用发生前终止
*
* @param toolCalls 当前轮次的工具调用
* @returns 是否检测到死循环
* @returns 是否检测到驻留死循环
*/
private detectDeadLoop(toolCalls: MetonaToolCall[]): boolean {
// 将当前轮次的工具调用序列化为签名
// v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名
// v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归
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(),
@@ -1126,35 +1289,7 @@ export class AgentLoopEngine extends EventEmitter {
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;
return stableStringify;
}
/**