【根因(main.log 实证)】 19:04 / 19:05 / 19:06 三次会话终止均为同一报错: DeepSeek 400 "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'" 缺陷链:engine 主循环仅在 step.thought 存在(该轮有文本或思考内容)时才 将 assistant 消息加入请求历史。当模型发起纯工具调用(零文本零思考 — DeepSeek 高频行为)时: - assistant(tool_calls) 消息不进 messages - 但 tool 结果消息照常 push → 下一轮请求出现孤立 tool 消息 → 协议 400(不可重试)→ 会话 ERROR 终止 "不稳定" = 模型每轮是否附带文本是概率性行为:带文本正常,纯调用必崩。 DB 持久化侧同源缺陷(if (!step.thought) continue)导致这些步骤的 assistant 与 tool 结果全部不落库 — 重启后工具上下文丢失,模型重复调用。 【修复】 - engine.ts: 有 toolCalls 的轮次必 push assistant(content=null,C-6 规范) - agent.ts: 持久化条件同步修复(无 thought 但有 toolCalls 的步骤落库) - 回归测试: 纯 tool_calls 轮后第二次请求中 tool 消息前必须是带 tool_calls 的 assistant(请求契约断言,engine-toolchain.test.ts) 【纵深防御 — 孤立 tool 消息过滤】 - openai-format.ts(DeepSeek/Agnes/MiMo/OpenAI 四家共享): 构建请求时 按 tool_call_id 配对过滤孤立 tool 消息(任何来源的历史污染不再 400 死锁) - anthropic.adapter.ts: tool_use/tool_result 同策略配对过滤 - 单测 ×6: 正常配对保留 / 孤立丢弃 / id 不匹配丢弃 / 多轮配对 / includeImages 原位转换 / 非 vision 静默丢弃 【多模态索引对齐收敛】 4 家 adapter 的 images 处理循环原按未过滤的 nonSystemMsgs[i-1] 对齐索引, 孤立 tool 过滤引入后会错位 — 统一收进 buildOpenAICompatibleMessages (includeImages 参数,基于 sanitized 序列原位转换),4 家 adapter 删除 各自的索引对齐循环(DeepSeek vision 判断 / OpenAI 推理模型拒绝保留在 adapter)。 【终止原因可见化】 MAX_ITERATIONS / TIMEOUT 终止此前无任何提示(用户感知"会话直接停止")— 前端 DONE 事件非 completed 终止原因显示为 system 消息。 【v0.6.1 回归缓解】 web_fetch timeoutMs 120s → 240s:浏览器回退串行化后并发 3 个排队最坏 ~127.5s,旧值让排队末位抓取被工具超时杀掉(表现为抓取不稳定)。 【验证】 lint 0/0;typecheck 双工程 0 错误;test:electron 259/259(+7); electron-vite build 成功
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
/**
|
||
* AgentLoopEngine 工具调用链路集成测试(v0.5.1 复检)
|
||
*
|
||
* 验证「模型工具调用能力」全链路:
|
||
* adapter 流式 tool_call 事件 → 引擎 PARSING 组装 → preToolHooks 真实管道
|
||
* (PermissionCheckHook + RateLimitHook + ConfirmationHook)→ ToolRegistry 执行
|
||
* → 工具结果回填消息历史 → 下一轮 LLM 调用 → COMPLETED。
|
||
*
|
||
* 重点覆盖 v0.5.0 会话隔离改动对工具链的影响:
|
||
* - sessionId 从 engine.currentSessionId → hooks → ConfirmationHook 记忆键的正确传递
|
||
* - 会话 A 的拒绝记忆不阻断会话 B 的工具(端到端,非 Hook 单元级)
|
||
* - 工具被 Hook 拦截时结果以 Blocked 错误回传 LLM(不中断循环)
|
||
*
|
||
* 运行:无需 SQLite,系统 Node 即可(npm test)。
|
||
*/
|
||
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
|
||
vi.mock('electron-log', () => ({
|
||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||
}));
|
||
|
||
import type { BrowserWindow } from 'electron';
|
||
import { AgentLoopEngine } from '../engine';
|
||
import { TerminationReason } from '../types';
|
||
import type {
|
||
IMetonaProviderAdapter,
|
||
MetonaRequest,
|
||
MetonaResponse,
|
||
MetonaStreamEvent,
|
||
MetonaToolDef,
|
||
} from '../../types';
|
||
import { MetonaStreamEventType, MetonaToolCategory, MetonaRiskLevel } from '../../types';
|
||
import { ToolRegistry } from '../../tools/registry';
|
||
import type { IMetonaTool } from '../../types/metona-tool';
|
||
import { PermissionCheckHook, RateLimitHook } from '../../hooks/pre-tool';
|
||
import { ConfirmationHook } from '../../hooks/confirmation-hook';
|
||
import { PolicyEngine } from '../../sandbox/permissions';
|
||
|
||
// ===== Mock Adapter(流式 tool_call → 文本收尾;记录请求供契约断言) =====
|
||
// v0.5.2 教训:mock 无条件吐 tool_call 事件会掩盖"请求未携带工具定义"的契约缺陷
|
||
// (模型只有收到 tools 才能真正发起 tool_call)— 此处记录 requests 供断言。
|
||
|
||
const recordedRequests: MetonaRequest[] = [];
|
||
|
||
function createMockAdapter(scripts: MetonaStreamEvent[][]): IMetonaProviderAdapter {
|
||
let call = 0;
|
||
return {
|
||
providerId: 'mock',
|
||
supportedModels: ['mock-model'],
|
||
supportsToolCalling: true,
|
||
supportsThinking: false,
|
||
getContextWindow: () => 1_000_000,
|
||
send: vi.fn(
|
||
async (): Promise<MetonaResponse> => ({
|
||
meta: {
|
||
requestId: 'r_test',
|
||
provider: 'mock',
|
||
model: 'mock-model',
|
||
latencyMs: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
content: 'ok',
|
||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||
finishReason: 'stop' as never,
|
||
}),
|
||
),
|
||
sendStream: vi.fn(async function* (req: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||
recordedRequests.push(req);
|
||
const script = scripts[call % scripts.length];
|
||
call++;
|
||
for (const ev of script) yield ev;
|
||
}),
|
||
setAbortSignal: vi.fn(),
|
||
healthCheck: async () => true,
|
||
};
|
||
}
|
||
|
||
function toolCallEvent(name: string, args: Record<string, unknown>): MetonaStreamEvent[] {
|
||
return [
|
||
{
|
||
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
|
||
requestId: 'r1',
|
||
sessionId: 's1',
|
||
iteration: 1,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
toolCall: {
|
||
id: `tc_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||
name,
|
||
args,
|
||
iteration: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
},
|
||
{
|
||
type: MetonaStreamEventType.DONE,
|
||
requestId: 'r1',
|
||
sessionId: 's1',
|
||
iteration: 1,
|
||
seq: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
];
|
||
}
|
||
|
||
function textDoneEvent(text: string): MetonaStreamEvent[] {
|
||
return [
|
||
{
|
||
type: MetonaStreamEventType.TEXT_DELTA,
|
||
requestId: 'r1',
|
||
sessionId: 's1',
|
||
iteration: 1,
|
||
seq: 0,
|
||
timestamp: Date.now(),
|
||
delta: text,
|
||
},
|
||
{
|
||
type: MetonaStreamEventType.DONE,
|
||
requestId: 'r1',
|
||
sessionId: 's1',
|
||
iteration: 1,
|
||
seq: 1,
|
||
timestamp: Date.now(),
|
||
},
|
||
];
|
||
}
|
||
|
||
// ===== 真实工具(记录执行 + 返回结果) =====
|
||
|
||
const executedTools: Array<{ name: string; args: Record<string, unknown>; sessionId: string }> = [];
|
||
|
||
function makeTool(name: string): IMetonaTool {
|
||
return {
|
||
definition: {
|
||
name,
|
||
description: `${name} (test fixture)`,
|
||
parameters: { type: 'object', properties: {}, required: [] },
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: 5_000,
|
||
},
|
||
async execute(args, context) {
|
||
executedTools.push({ name, args, sessionId: context.sessionId });
|
||
return { ok: true, tool: name };
|
||
},
|
||
};
|
||
}
|
||
|
||
const userMessage = { role: 'user' as const, content: 'run the tool', timestamp: Date.now() };
|
||
const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' };
|
||
|
||
/** run_command 的 HIGH 风险工具定义(供 ConfirmationHook 查询) */
|
||
const HIGH_RISK_DEF: MetonaToolDef = {
|
||
name: 'run_command',
|
||
description: 'Execute shell command (test fixture)',
|
||
parameters: { type: 'object', properties: {}, required: [] },
|
||
category: MetonaToolCategory.CODE_EXECUTION,
|
||
riskLevel: MetonaRiskLevel.HIGH,
|
||
requiresPermission: true,
|
||
timeoutMs: 5_000,
|
||
};
|
||
|
||
function makeMockWindow(): BrowserWindow {
|
||
return {
|
||
isDestroyed: () => false,
|
||
webContents: { send: vi.fn() },
|
||
} as unknown as BrowserWindow;
|
||
}
|
||
|
||
/** 构造带真实 Hook 管道的引擎(PermissionCheck + RateLimit + Confirmation) */
|
||
function makeEngineWithHooks(
|
||
adapterScripts: MetonaStreamEvent[][],
|
||
hook: ConfirmationHook,
|
||
maxIterations = 5,
|
||
): AgentLoopEngine {
|
||
const registry = new ToolRegistry();
|
||
registry.registerBuiltin(makeTool('read_file'));
|
||
registry.registerBuiltin(makeTool('run_command'));
|
||
|
||
const engine = new AgentLoopEngine(
|
||
{ maxIterations },
|
||
createMockAdapter(adapterScripts),
|
||
registry,
|
||
[new PermissionCheckHook(new PolicyEngine()), new RateLimitHook(100), hook],
|
||
[],
|
||
);
|
||
// 模拟 AgentEngineManager.createEngine 的正确接线(v0.5.2 修复):
|
||
// 引擎不自动从 registry 拉取请求工具,必须显式 setTools
|
||
engine.setTools(registry.listTools());
|
||
return engine;
|
||
}
|
||
|
||
describe('AgentLoopEngine 工具调用链路(adapter → 引擎 → 真实 Hook 管道 → registry)', () => {
|
||
it('SAFE 工具免确认直接执行:结果回填 + 工具上下文 sessionId 正确', async () => {
|
||
executedTools.length = 0;
|
||
recordedRequests.length = 0;
|
||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||
|
||
const engine = makeEngineWithHooks(
|
||
[toolCallEvent('read_file', { file_path: 'a.ts' }), textDoneEvent('done after tool')],
|
||
hook,
|
||
);
|
||
|
||
const output = await engine.runStream(userMessage, 'sess-chain-1', [], systemPrompt);
|
||
|
||
// 请求契约:LLM 请求必须携带工具定义(v0.5.2 回归点 —
|
||
// 真实模型只有收到 tools 才能发起 tool_call,缺失即"口头说调工具实际不调")
|
||
expect(recordedRequests.length).toBeGreaterThan(0);
|
||
expect(recordedRequests[0].tools).toBeDefined();
|
||
expect(recordedRequests[0].tools!.map((t) => t.name)).toContain('read_file');
|
||
|
||
// 循环完成:工具轮 → 文本轮
|
||
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
|
||
expect(output.finalAnswer).toBe('done after tool');
|
||
expect(output.iterations).toHaveLength(2);
|
||
|
||
// 工具真实执行,且 ToolExecutionContext.sessionId 传递正确
|
||
expect(executedTools).toHaveLength(1);
|
||
expect(executedTools[0].name).toBe('read_file');
|
||
expect(executedTools[0].sessionId).toBe('sess-chain-1');
|
||
|
||
// 工具结果成功回填到迭代记录
|
||
const toolResult = output.iterations[0].toolResults?.[0];
|
||
expect(toolResult?.success).toBe(true);
|
||
expect(toolResult?.result).toEqual({ ok: true, tool: 'read_file' });
|
||
|
||
// 无确认请求产生(SAFE 工具)
|
||
expect(hook.getPendingConfirmations()).toHaveLength(0);
|
||
});
|
||
|
||
it('v0.6.2 回归:纯 tool_calls 轮(零文本零思考)后,下一轮请求的 tool 消息前必须有带 tool_calls 的 assistant', async () => {
|
||
// 会话停止根因(DeepSeek 400 "Messages with role 'tool' must be a response
|
||
// to a preceding message with 'tool_calls'"):模型纯工具调用轮不产生
|
||
// thought → 原实现跳过 assistant 消息 push → 孤立 tool 消息 → 下一轮 400。
|
||
// 契约断言:第二次 sendStream 收到的 messages 中,tool 消息的前一条
|
||
// 必须是带 tool_calls 的 assistant。
|
||
executedTools.length = 0;
|
||
recordedRequests.length = 0;
|
||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||
|
||
const engine = makeEngineWithHooks(
|
||
[toolCallEvent('read_file', { file_path: 'x.ts' }), textDoneEvent('finished')],
|
||
hook,
|
||
);
|
||
|
||
await engine.runStream(userMessage, 'sess-orphan-tool', [], systemPrompt);
|
||
|
||
expect(recordedRequests.length).toBe(2);
|
||
const second = recordedRequests[1].messages;
|
||
// 找到 tool 消息
|
||
const toolIdx = second.findIndex((m) => m.role === 'tool');
|
||
expect(toolIdx).toBeGreaterThan(0);
|
||
// 前一条必须是带 tool_calls 的 assistant(修复前这里是 user — 孤立 tool)
|
||
const prev = second[toolIdx - 1];
|
||
expect(prev.role).toBe('assistant');
|
||
expect(prev.toolCalls?.length).toBeGreaterThan(0);
|
||
// 且该 assistant 的 toolCalls id 与 tool 消息的 toolCallId 配对
|
||
const toolMsg = second[toolIdx];
|
||
expect(prev.toolCalls!.some((tc) => tc.id === toolMsg.toolResult!.toolCallId)).toBe(true);
|
||
});
|
||
|
||
it('HIGH 风险工具经用户批准后执行(pending → approve → 工具运行)', async () => {
|
||
executedTools.length = 0;
|
||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||
|
||
const engine = makeEngineWithHooks(
|
||
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('approved and done')],
|
||
hook,
|
||
);
|
||
|
||
const runPromise = engine.runStream(userMessage, 'sess-chain-2', [], systemPrompt);
|
||
|
||
// 等待确认请求产生(引擎阻塞在 waitForConfirmation)
|
||
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
|
||
const pending = hook.getPendingConfirmations();
|
||
// pending 携带正确的 sessionId(v0.5.1)
|
||
expect(pending[0].sessionId).toBe('sess-chain-2');
|
||
expect(pending[0].toolName).toBe('run_command');
|
||
|
||
// 用户批准
|
||
hook.resolveConfirmation(pending[0].toolCallId, true, false, false);
|
||
const output = await runPromise;
|
||
|
||
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
|
||
// 工具在批准后执行
|
||
expect(executedTools).toHaveLength(1);
|
||
expect(executedTools[0].name).toBe('run_command');
|
||
expect(output.iterations[0].toolResults?.[0].success).toBe(true);
|
||
});
|
||
|
||
it('用户拒绝后工具以 Blocked 错误回传 LLM(循环继续,不崩溃)', async () => {
|
||
executedTools.length = 0;
|
||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||
|
||
const engine = makeEngineWithHooks(
|
||
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('denied, continuing')],
|
||
hook,
|
||
);
|
||
|
||
const runPromise = engine.runStream(userMessage, 'sess-chain-3', [], systemPrompt);
|
||
|
||
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
|
||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, false, false);
|
||
const output = await runPromise;
|
||
|
||
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
|
||
// 工具未执行
|
||
expect(executedTools).toHaveLength(0);
|
||
// 拒绝以 Blocked 错误回传(LLM 下一轮可见,可调整策略)
|
||
const toolResult = output.iterations[0].toolResults?.[0];
|
||
expect(toolResult?.success).toBe(false);
|
||
expect(toolResult?.error).toContain('User denied');
|
||
});
|
||
|
||
it('会话隔离端到端:会话 A 的拒绝记忆阻断 A 的后续调用,不影响会话 B', async () => {
|
||
executedTools.length = 0;
|
||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||
hook.setToolDefs([HIGH_RISK_DEF]);
|
||
|
||
// --- 会话 A 第 1 次:拒绝 + remember ---
|
||
const engineA1 = makeEngineWithHooks(
|
||
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('A1 done')],
|
||
hook,
|
||
);
|
||
const runA1 = engineA1.runStream(userMessage, 'sess-iso-a', [], systemPrompt);
|
||
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
|
||
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
|
||
const outA1 = await runA1;
|
||
expect(outA1.iterations[0].toolResults?.[0].error).toContain('User denied');
|
||
|
||
// --- 会话 A 第 2 次:拒绝记忆直接阻断(无新 pending、无 LLM 等待) ---
|
||
const engineA2 = makeEngineWithHooks(
|
||
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('A2 done')],
|
||
hook,
|
||
);
|
||
const outA2 = await engineA2.runStream(userMessage, 'sess-iso-a', [], systemPrompt);
|
||
expect(outA2.iterations[0].toolResults?.[0].error).toContain('previously denied');
|
||
// 记忆阻断不产生确认请求
|
||
expect(hook.getPendingConfirmations()).toHaveLength(0);
|
||
|
||
// --- 会话 B:同工具正常询问并批准(A 的拒绝记忆不影响 B) ---
|
||
const engineB = makeEngineWithHooks(
|
||
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('B done')],
|
||
hook,
|
||
);
|
||
const runB = engineB.runStream(userMessage, 'sess-iso-b', [], systemPrompt);
|
||
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
|
||
const pendingB = hook.getPendingConfirmations();
|
||
expect(pendingB[0].sessionId).toBe('sess-iso-b');
|
||
hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false);
|
||
const outB = await runB;
|
||
|
||
expect(outB.iterations[0].toolResults?.[0].success).toBe(true);
|
||
expect(executedTools.filter((t) => t.sessionId === 'sess-iso-b')).toHaveLength(1);
|
||
// A 会话的工具始终未执行
|
||
expect(executedTools.filter((t) => t.sessionId === 'sess-iso-a')).toHaveLength(0);
|
||
});
|
||
|
||
it('工具抛出异常时以错误结果回传(不中断引擎循环)', async () => {
|
||
executedTools.length = 0;
|
||
const registry = new ToolRegistry();
|
||
// read_file 已有 PolicyEngine 策略(SAFE,免确认)— 此处注册抛错实现验证异常路径
|
||
registry.registerBuiltin({
|
||
definition: {
|
||
name: 'read_file',
|
||
description: 'always throws (test fixture)',
|
||
parameters: { type: 'object', properties: {}, required: [] },
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: 5_000,
|
||
},
|
||
async execute() {
|
||
throw new Error('tool exploded');
|
||
},
|
||
});
|
||
|
||
const engine = new AgentLoopEngine(
|
||
{ maxIterations: 3 },
|
||
createMockAdapter([toolCallEvent('read_file', {}), textDoneEvent('recovered')]),
|
||
registry,
|
||
[new PermissionCheckHook(new PolicyEngine()), new RateLimitHook(100)],
|
||
[],
|
||
);
|
||
|
||
const output = await engine.runStream(userMessage, 'sess-chain-4', [], systemPrompt);
|
||
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
|
||
expect(output.finalAnswer).toBe('recovered');
|
||
const toolResult = output.iterations[0].toolResults?.[0];
|
||
expect(toolResult?.success).toBe(false);
|
||
expect(toolResult?.error).toContain('tool exploded');
|
||
});
|
||
|
||
it('PermissionCheckHook 拦截系统路径(read_file /etc 被策略拒绝)', async () => {
|
||
executedTools.length = 0;
|
||
const hook = new ConfirmationHook(makeMockWindow(), null);
|
||
|
||
const engine = makeEngineWithHooks(
|
||
[toolCallEvent('read_file', { file_path: '/etc/passwd' }), textDoneEvent('blocked path')],
|
||
hook,
|
||
);
|
||
|
||
const output = await engine.runStream(userMessage, 'sess-chain-5', [], systemPrompt);
|
||
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
|
||
// 工具未执行(被 deniedPatterns 拦截)
|
||
expect(executedTools).toHaveLength(0);
|
||
expect(output.iterations[0].toolResults?.[0].error).toContain('Blocked');
|
||
expect(output.iterations[0].toolResults?.[0].error).toContain('security policy');
|
||
});
|
||
});
|