fix: v0.6.2 修复工具调用不稳定与会话停止 — 纯 tool_calls 轮丢失 assistant 消息导致 API 400
【根因(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:
@@ -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=false(DeepSeek 非 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');
|
||||
});
|
||||
});
|
||||
@@ -149,34 +149,11 @@ export class AgnesAdapter extends BaseAdapter {
|
||||
* - 默认 max_tokens: 65536(1M 上下文,65.5K 最大输出)
|
||||
*/
|
||||
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
|
||||
const messages = buildOpenAICompatibleMessages(request);
|
||||
// v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位)
|
||||
const messages = buildOpenAICompatibleMessages(request, true);
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// === Agnes 多模态:将 images 转为 OpenAI content 数组 ===
|
||||
// buildOpenAICompatibleMessages 不处理图片(各 Provider 自行处理)
|
||||
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
|
||||
let imageCount = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
// messages[0] 是 system,非 system 消息从 messages[1] 开始
|
||||
if (i === 0) continue;
|
||||
const origMsg = nonSystemMsgs[i - 1];
|
||||
if (!origMsg?.images?.length) continue;
|
||||
|
||||
imageCount += origMsg.images.length;
|
||||
|
||||
const contentParts: Array<Record<string, unknown>> = [];
|
||||
if (origMsg.content) {
|
||||
contentParts.push({ type: 'text', text: origMsg.content });
|
||||
}
|
||||
for (const img of origMsg.images) {
|
||||
contentParts.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: img.url },
|
||||
});
|
||||
}
|
||||
messages[i].content = contentParts;
|
||||
}
|
||||
|
||||
const imageCount = request.messages.reduce((n, m) => n + (m.images?.length ?? 0), 0);
|
||||
if (imageCount > 0) {
|
||||
const firstUrl = request.messages.find((m) => m.images?.length)?.images?.[0]?.url ?? '';
|
||||
log.info(
|
||||
|
||||
@@ -322,7 +322,10 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
.join('\n\n');
|
||||
|
||||
// 转换消息(非 system)
|
||||
const converted: Array<{
|
||||
// v0.6.2 纵深防御: 过滤孤立 tool 消息 — Anthropic 协议要求 tool_result 块
|
||||
// 必须对应前置 assistant 的 tool_use(违反直接 400)。与 openai-format 同策略。
|
||||
const pendingToolUseIds = new Set<string>();
|
||||
const convertedRaw: Array<{
|
||||
role: 'user' | 'assistant';
|
||||
content: Array<Record<string, unknown>>;
|
||||
}> = [];
|
||||
@@ -330,13 +333,20 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
if (m.role === 'system') continue;
|
||||
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
if (!pendingToolUseIds.has(m.toolResult.toolCallId)) {
|
||||
log.warn(
|
||||
`[Anthropic] Dropped orphan tool_result without matching tool_use: ${m.toolResult.toolCallId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pendingToolUseIds.delete(m.toolResult.toolCallId);
|
||||
// 工具结果 → user 角色 tool_result 块
|
||||
const contentStr = m.toolResult.error
|
||||
? m.toolResult.error
|
||||
: typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result);
|
||||
converted.push({
|
||||
convertedRaw.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr },
|
||||
@@ -349,10 +359,11 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
if (m.content) content.push({ type: 'text', text: m.content });
|
||||
for (const tc of m.toolCalls ?? []) {
|
||||
pendingToolUseIds.add(tc.id);
|
||||
content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args });
|
||||
}
|
||||
if (content.length > 0) {
|
||||
converted.push({ role: 'assistant', content });
|
||||
convertedRaw.push({ role: 'assistant', content });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -365,8 +376,9 @@ export class AnthropicAdapter extends BaseAdapter {
|
||||
if (block) content.push(block);
|
||||
}
|
||||
if (content.length === 0) content.push({ type: 'text', text: '' });
|
||||
converted.push({ role: 'user', content });
|
||||
convertedRaw.push({ role: 'user', content });
|
||||
}
|
||||
const converted = convertedRaw;
|
||||
|
||||
// 合并连续同角色消息(Anthropic 要求 user/assistant 交替)
|
||||
const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> =
|
||||
|
||||
@@ -262,7 +262,9 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
* - stream_options: { include_usage: true } — 流式返回 usage
|
||||
*/
|
||||
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
|
||||
const messages = buildOpenAICompatibleMessages(request);
|
||||
// v0.6.2: images 处理收敛至共享层(includeImages = vision 模型才转换,
|
||||
// 非 vision 静默丢弃——正确行为,见 openai-format.ts #27 记录)
|
||||
const messages = buildOpenAICompatibleMessages(request, this.isVisionModel());
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400
|
||||
@@ -278,29 +280,9 @@ export class DeepSeekAdapter extends BaseAdapter {
|
||||
stream,
|
||||
};
|
||||
|
||||
// v0.5.4: vision 模型的图片处理(OpenAI image_url content parts 格式)
|
||||
// 非 vision 模型保持 images 静默丢弃(共享层行为,避免 API 400)
|
||||
// v0.5.4: vision 模型图片数审计(转换在共享层完成)
|
||||
if (this.isVisionModel()) {
|
||||
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
|
||||
let imageCount = 0;
|
||||
// messages[0] 是 system,非 system 消息从 messages[1] 开始(与 nonSystemMsgs 对齐)
|
||||
for (let i = 1; i < messages.length; i++) {
|
||||
const origMsg = nonSystemMsgs[i - 1];
|
||||
if (!origMsg?.images?.length) continue;
|
||||
|
||||
imageCount += origMsg.images.length;
|
||||
const contentParts: Array<Record<string, unknown>> = [];
|
||||
if (origMsg.content) {
|
||||
contentParts.push({ type: 'text', text: origMsg.content });
|
||||
}
|
||||
for (const img of origMsg.images) {
|
||||
contentParts.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: img.url },
|
||||
});
|
||||
}
|
||||
messages[i].content = contentParts;
|
||||
}
|
||||
const imageCount = request.messages.reduce((n, m) => n + (m.images?.length ?? 0), 0);
|
||||
if (imageCount > 0) {
|
||||
log.info(`[DeepSeek] Vision model processing ${imageCount} image(s)`);
|
||||
}
|
||||
|
||||
@@ -169,32 +169,10 @@ export class MimoAdapter extends BaseAdapter {
|
||||
* 思考模式下 temperature/top_p 会被 API 强制覆盖,因此不传这两个参数。
|
||||
*/
|
||||
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
|
||||
const messages = buildOpenAICompatibleMessages(request);
|
||||
// v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位)
|
||||
const messages = buildOpenAICompatibleMessages(request, true);
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// === MiMo 多模态:将 images 转为 OpenAI content 数组 ===
|
||||
// buildOpenAICompatibleMessages 不处理图片(各 Provider 自行处理)
|
||||
// MiMo 是 OpenAI 兼容 API,多模态格式与 Agnes AI 一致
|
||||
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
// messages[0] 是 system,非 system 消息从 messages[1] 开始
|
||||
if (i === 0) continue;
|
||||
const origMsg = nonSystemMsgs[i - 1];
|
||||
if (!origMsg?.images?.length) continue;
|
||||
|
||||
const contentParts: Array<Record<string, unknown>> = [];
|
||||
if (origMsg.content) {
|
||||
contentParts.push({ type: 'text', text: origMsg.content });
|
||||
}
|
||||
for (const img of origMsg.images) {
|
||||
contentParts.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: img.url },
|
||||
});
|
||||
}
|
||||
messages[i].content = contentParts;
|
||||
}
|
||||
|
||||
// MiMo 使用 max_completion_tokens(非 max_tokens)
|
||||
// #41 修复: thinking 模式下未配置时兜底 32768(thinking 占用 token 配额,API 默认值过小会截断输出)
|
||||
// v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)—
|
||||
|
||||
@@ -177,38 +177,22 @@ export class OpenAIAdapter extends BaseAdapter {
|
||||
* - 思考模式下 temperature 被部分推理模型拒绝,不传
|
||||
*/
|
||||
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
|
||||
const messages = buildOpenAICompatibleMessages(request);
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
// 推理模型检测(o 系列使用新参数名)
|
||||
const model = this.config.defaultModel;
|
||||
const isReasoningModel = /^(o\d|gpt-5)/.test(model);
|
||||
|
||||
// === 多模态:将 images 转为 OpenAI content 数组(与 Agnes/MiMo 一致) ===
|
||||
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system');
|
||||
let imageCount = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (i === 0) continue; // messages[0] 是 system
|
||||
const origMsg = nonSystemMsgs[i - 1];
|
||||
if (!origMsg?.images?.length) continue;
|
||||
|
||||
imageCount += origMsg.images.length;
|
||||
const contentParts: Array<Record<string, unknown>> = [];
|
||||
if (origMsg.content) {
|
||||
contentParts.push({ type: 'text', text: origMsg.content });
|
||||
}
|
||||
for (const img of origMsg.images) {
|
||||
contentParts.push({ type: 'image_url', image_url: { url: img.url } });
|
||||
}
|
||||
messages[i].content = contentParts;
|
||||
}
|
||||
if (imageCount > 0) {
|
||||
// 推理模型当前不支持图片输入
|
||||
if (isReasoningModel) {
|
||||
// 推理模型不支持图片输入 — 前置校验(转换在共享层,此处仅拦截)
|
||||
if (isReasoningModel) {
|
||||
const hasImages = request.messages.some((m) => m.images?.length);
|
||||
if (hasImages) {
|
||||
throw new Error(`Model "${model}" does not support image inputs`);
|
||||
}
|
||||
}
|
||||
|
||||
// v0.6.2: images 处理收敛至共享层(原索引对齐循环在孤立 tool 过滤后会错位)
|
||||
const messages = buildOpenAICompatibleMessages(request, true);
|
||||
const tools = buildOpenAICompatibleTools(request.tools);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* @see apis/mimo-api-docs-20260715.html
|
||||
*/
|
||||
|
||||
import type { MetonaRequest, MetonaToolDef } from '../../types';
|
||||
import log from 'electron-log';
|
||||
import type { MetonaMessage, MetonaRequest, MetonaToolDef } from '../../types';
|
||||
|
||||
/**
|
||||
* 构建 OpenAI 兼容的 messages 数组
|
||||
@@ -19,13 +20,15 @@ import type { MetonaRequest, MetonaToolDef } from '../../types';
|
||||
* - System Prompt 拼接(静态区 + 动态区 + 安全准则)
|
||||
* - 工具调用历史保留(reasoning_content + tool_calls)
|
||||
* - 工具结果注入(tool_call_id + content)
|
||||
* - 孤立 tool 消息过滤(纵深防御,见函数内注释)
|
||||
* - 多模态图片(includeImages=true 时转换为 image_url content parts)
|
||||
*
|
||||
* 注意:图片(多模态)处理不属于此共享函数。
|
||||
* 各 Provider 对多模态的支持不同(DeepSeek 不支持,Agnes/Ollama 支持但格式各异),
|
||||
* 应在各自 Adapter 的 toNativeRequest 中处理。
|
||||
* @param includeImages true 时将消息的 images 转为 OpenAI image_url content parts
|
||||
* (Agnes/MiMo/OpenAI 全系、DeepSeek 仅 vision 模型传 true)
|
||||
*/
|
||||
export function buildOpenAICompatibleMessages(
|
||||
request: MetonaRequest,
|
||||
includeImages = false,
|
||||
): Array<Record<string, unknown>> {
|
||||
const systemContent = [
|
||||
request.systemPrompt.roleDefinition,
|
||||
@@ -36,60 +39,97 @@ export function buildOpenAICompatibleMessages(
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
const nonSystemMessages = request.messages
|
||||
.filter((m) => m.role !== 'system')
|
||||
.map((m) => {
|
||||
// v0.3.0 修复: assistant 消息有 tool_calls 但 content 为空时,content 设为 null
|
||||
// DeepSeek/OpenAI API 要求有 tool_calls 的 assistant 消息 content 必须为 null 而非空字符串
|
||||
const msg: Record<string, unknown> = {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
};
|
||||
|
||||
// 注意:图片(多模态)处理不在此共享函数中。
|
||||
// DeepSeek 非 vision 模型不支持多模态,images 被静默丢弃是正确行为
|
||||
// (vision 模型在 DeepSeekAdapter.toNativeRequest 中独立处理)。
|
||||
// Agnes/MiMo/OpenAI 各自的 toNativeRequest 中有独立的 images 处理。
|
||||
// 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 非 vision 模型会导致 API 400,已撤销。
|
||||
|
||||
// === Assistant 消息 ===
|
||||
if (m.role === 'assistant') {
|
||||
// 工具调用历史
|
||||
if (m.toolCalls?.length) {
|
||||
msg.tool_calls = m.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}));
|
||||
// 有 tool_calls 时 content 必须为 null(API 规范)
|
||||
if (!m.content) msg.content = null;
|
||||
}
|
||||
// 推理内容(无论是否有工具调用,都保留 reasoning_content)
|
||||
if (m.reasoningContent) {
|
||||
msg.reasoning_content = m.reasoningContent;
|
||||
}
|
||||
// 崩溃修复(纵深防御): 过滤孤立 tool 消息 — 其 tool_call_id 不属于任何前置
|
||||
// assistant(tool_calls) 消息。OpenAI/DeepSeek 协议要求 tool 消息必须紧跟带
|
||||
// tool_calls 的 assistant,违反直接 400 且不可重试(会话死锁)。正常链路由
|
||||
// engine 保证配对;此处兜底任何来源的历史污染(旧版本数据/导入/边界场景)。
|
||||
const nonSystem = request.messages.filter((m) => m.role !== 'system');
|
||||
const sanitized: MetonaMessage[] = [];
|
||||
/** 已出现且尚未被 tool 结果回应的 tool_call id 集合 */
|
||||
const pendingToolCallIds = new Set<string>();
|
||||
let droppedOrphans = 0;
|
||||
for (const m of nonSystem) {
|
||||
if (m.role === 'assistant' && m.toolCalls?.length) {
|
||||
for (const tc of m.toolCalls) pendingToolCallIds.add(tc.id);
|
||||
sanitized.push(m);
|
||||
continue;
|
||||
}
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
if (pendingToolCallIds.has(m.toolResult.toolCallId)) {
|
||||
pendingToolCallIds.delete(m.toolResult.toolCallId);
|
||||
sanitized.push(m);
|
||||
} else {
|
||||
droppedOrphans++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
sanitized.push(m);
|
||||
}
|
||||
if (droppedOrphans > 0) {
|
||||
log.warn(
|
||||
`[OpenAIFormat] Dropped ${droppedOrphans} orphan tool message(s) without matching assistant tool_calls`,
|
||||
);
|
||||
}
|
||||
|
||||
// === 工具执行结果 ===
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
msg.tool_call_id = m.toolResult.toolCallId;
|
||||
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
||||
// 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环
|
||||
msg.content = m.toolResult.error
|
||||
? m.toolResult.error
|
||||
: typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result);
|
||||
// #26 修复: 确保 tool 消息 content 不为 undefined
|
||||
// JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失
|
||||
// OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400
|
||||
if (msg.content === undefined) msg.content = '';
|
||||
const messages = sanitized.map((m) => {
|
||||
// v0.3.0 修复: assistant 消息有 tool_calls 但 content 为空时,content 设为 null
|
||||
// DeepSeek/OpenAI API 要求有 tool_calls 的 assistant 消息 content 必须为 null 而非空字符串
|
||||
const msg: Record<string, unknown> = {
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
};
|
||||
|
||||
// === 多模态图片(includeImages=true 时) ===
|
||||
// v0.6.2 收敛: 原先 4 家 adapter 各自按 nonSystemMsgs[i-1] 索引对齐处理 images,
|
||||
// 孤立 tool 过滤引入后索引错位。统一收进共享函数,基于 sanitized 原位转换。
|
||||
// DeepSeek 非 vision 模型传 false(images 静默丢弃是正确行为,见 #27 审查撤销记录)。
|
||||
if (includeImages && m.images?.length) {
|
||||
const contentParts: Array<Record<string, unknown>> = [];
|
||||
if (m.content) contentParts.push({ type: 'text', text: m.content });
|
||||
for (const img of m.images) {
|
||||
contentParts.push({ type: 'image_url', image_url: { url: img.url } });
|
||||
}
|
||||
msg.content = contentParts;
|
||||
}
|
||||
|
||||
return msg;
|
||||
});
|
||||
// === Assistant 消息 ===
|
||||
if (m.role === 'assistant') {
|
||||
// 工具调用历史
|
||||
if (m.toolCalls?.length) {
|
||||
msg.tool_calls = m.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}));
|
||||
// 有 tool_calls 时 content 必须为 null(API 规范)
|
||||
if (!m.content) msg.content = null;
|
||||
}
|
||||
// 推理内容(无论是否有工具调用,都保留 reasoning_content)
|
||||
if (m.reasoningContent) {
|
||||
msg.reasoning_content = m.reasoningContent;
|
||||
}
|
||||
}
|
||||
|
||||
return [{ role: 'system', content: systemContent }, ...nonSystemMessages];
|
||||
// === 工具执行结果 ===
|
||||
if (m.role === 'tool' && m.toolResult) {
|
||||
msg.tool_call_id = m.toolResult.toolCallId;
|
||||
// CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content
|
||||
// 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环
|
||||
msg.content = m.toolResult.error
|
||||
? m.toolResult.error
|
||||
: typeof m.toolResult.result === 'string'
|
||||
? m.toolResult.result
|
||||
: JSON.stringify(m.toolResult.result);
|
||||
// #26 修复: 确保 tool 消息 content 不为 undefined
|
||||
// JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失
|
||||
// OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400
|
||||
if (msg.content === undefined) msg.content = '';
|
||||
}
|
||||
|
||||
return msg;
|
||||
});
|
||||
|
||||
return [{ role: 'system', content: systemContent }, ...messages];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user