fix: v0.6.2 修复工具调用不稳定与会话停止 — 纯 tool_calls 轮丢失 assistant 消息导致 API 400
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m20s
CI / 产物编译验证 (push) Successful in 10m5s

【根因(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 成功
This commit is contained in:
2026-08-22 19:34:16 +08:00
parent 80cf5b482c
commit a7090214b1
14 changed files with 524 additions and 214 deletions
@@ -0,0 +1,225 @@
/**
* openai-format 孤立 tool 消息过滤测试(v0.6.2 会话停止根因的纵深防御)
*
* 背景:OpenAI/DeepSeek 协议要求 role='tool' 消息必须紧跟带 tool_calls 的
* assistant 消息,违反直接 400 且不可重试。engine 侧已保证配对,此处验证
* 共享构建函数对任何来源历史污染的兜底过滤。
*/
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 { buildOpenAICompatibleMessages } from '../shared/openai-format';
import type { MetonaRequest } from '../../types';
const systemPrompt = { roleDefinition: 'r', outputConstraints: 'o', safetyGuidelines: 's' };
function makeRequest(messages: MetonaRequest['messages']): MetonaRequest {
return {
meta: {
sessionId: 's',
iteration: 1,
requestId: 'r',
timestamp: Date.now(),
agentVersion: 't',
},
systemPrompt,
messages,
params: { stream: false },
};
}
describe('buildOpenAICompatibleMessages — 孤立 tool 过滤', () => {
it('正常配对序列完整保留(assistant(tool_calls) → tool', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_1', name: 'read_file', args: {}, iteration: 1, timestamp: Date.now() },
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_1',
toolName: 'read_file',
result: 'data',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
// system + user + assistant + tool
expect(out).toHaveLength(4);
expect(out[2].role).toBe('assistant');
expect(out[2].tool_calls).toHaveLength(1);
expect(out[3].role).toBe('tool');
expect(out[3].tool_call_id).toBe('tc_1');
});
it('孤立 tool 消息(前面无 assistant tool_calls)被丢弃,不产生 400 序列', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_orphan',
toolName: 'read_file',
result: 'x',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
// system + user(孤立 tool 被过滤)
expect(out).toHaveLength(2);
expect(out.every((m) => m.role !== 'tool')).toBe(true);
});
it('tool_call_id 不匹配最近 assistant 的 tool 消息也被过滤', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_a', name: 'read_file', args: {}, iteration: 1, timestamp: Date.now() },
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
// id 不匹配 tc_a
toolResult: {
toolCallId: 'tc_other',
toolName: 'read_file',
result: 'x',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
expect(out).toHaveLength(3);
expect(out.every((m) => m.role !== 'tool')).toBe(true);
});
it('多轮工具配对全部保留', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_1', name: 'a', args: {}, iteration: 1, timestamp: Date.now() },
{ id: 'tc_2', name: 'b', args: {}, iteration: 1, timestamp: Date.now() },
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_2',
toolName: 'b',
result: 'r2',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_1',
toolName: 'a',
result: 'r1',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
{
role: 'assistant',
content: null,
toolCalls: [{ id: 'tc_3', name: 'c', args: {}, iteration: 2, timestamp: Date.now() }],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_3',
toolName: 'c',
result: 'r3',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
expect(out).toHaveLength(7);
expect(out.filter((m) => m.role === 'tool')).toHaveLength(3);
});
it('includeImages=true 时 images 转 content parts(原位转换,索引与过滤后序列对齐)', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{
role: 'user',
content: 'look',
images: [{ url: 'data:image/png;base64,xxx' }],
timestamp: Date.now(),
},
]),
true,
);
const content = out[1].content as Array<Record<string, unknown>>;
expect(Array.isArray(content)).toBe(true);
expect(content[0]).toEqual({ type: 'text', text: 'look' });
expect(content[1]).toEqual({
type: 'image_url',
image_url: { url: 'data:image/png;base64,xxx' },
});
});
it('includeImages=falseDeepSeek 非 vision)时 images 静默丢弃', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{
role: 'user',
content: 'look',
images: [{ url: 'data:image/png;base64,xxx' }],
timestamp: Date.now(),
},
]),
);
expect(out[1].content).toBe('look');
});
});