diff --git a/README.md b/README.md index 2540e54..cf9313b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -871,7 +871,7 @@ npm run format # Prettier 格式化 # ─── 测试 ───────────────────────────────── npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过) -npm run test:electron # 运行全量单元测试 (Electron Node ABI, 252 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成) +npm run test:electron # 运行全量单元测试 (Electron Node ABI, 259 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成) npm run test:watch # 测试监听模式 # ─── 构建 ───────────────────────────────── diff --git a/electron/harness/adapters/__tests__/openai-format-orphan.test.ts b/electron/harness/adapters/__tests__/openai-format-orphan.test.ts new file mode 100644 index 0000000..3165ef4 --- /dev/null +++ b/electron/harness/adapters/__tests__/openai-format-orphan.test.ts @@ -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>; + 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'); + }); +}); diff --git a/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index c0fca1f..6a4aece 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -149,34 +149,11 @@ export class AgnesAdapter extends BaseAdapter { * - 默认 max_tokens: 65536(1M 上下文,65.5K 最大输出) */ private toNativeRequest(request: MetonaRequest, stream: boolean): Record { - 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> = []; - 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( diff --git a/electron/harness/adapters/anthropic.adapter.ts b/electron/harness/adapters/anthropic.adapter.ts index 09c15fc..3b280f7 100644 --- a/electron/harness/adapters/anthropic.adapter.ts +++ b/electron/harness/adapters/anthropic.adapter.ts @@ -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(); + const convertedRaw: Array<{ role: 'user' | 'assistant'; content: Array>; }> = []; @@ -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> = []; 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> }> = diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index d209a98..cfc1034 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -262,7 +262,9 @@ export class DeepSeekAdapter extends BaseAdapter { * - stream_options: { include_usage: true } — 流式返回 usage */ private toNativeRequest(request: MetonaRequest, stream: boolean): Record { - 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> = []; - 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)`); } diff --git a/electron/harness/adapters/mimo.adapter.ts b/electron/harness/adapters/mimo.adapter.ts index adce04e..3d37534 100644 --- a/electron/harness/adapters/mimo.adapter.ts +++ b/electron/harness/adapters/mimo.adapter.ts @@ -169,32 +169,10 @@ export class MimoAdapter extends BaseAdapter { * 思考模式下 temperature/top_p 会被 API 强制覆盖,因此不传这两个参数。 */ private toNativeRequest(request: MetonaRequest, stream: boolean): Record { - 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> = []; - 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)— diff --git a/electron/harness/adapters/openai.adapter.ts b/electron/harness/adapters/openai.adapter.ts index 5ce00ab..8c21018 100644 --- a/electron/harness/adapters/openai.adapter.ts +++ b/electron/harness/adapters/openai.adapter.ts @@ -177,38 +177,22 @@ export class OpenAIAdapter extends BaseAdapter { * - 思考模式下 temperature 被部分推理模型拒绝,不传 */ private toNativeRequest(request: MetonaRequest, stream: boolean): Record { - 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> = []; - 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 = { model, messages, diff --git a/electron/harness/adapters/shared/openai-format.ts b/electron/harness/adapters/shared/openai-format.ts index 61db1c8..e037b0f 100644 --- a/electron/harness/adapters/shared/openai-format.ts +++ b/electron/harness/adapters/shared/openai-format.ts @@ -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> { 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 = { - 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(); + 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 = { + 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> = []; + 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]; } /** diff --git a/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts b/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts index a9f3e7c..2cab050 100644 --- a/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts +++ b/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts @@ -230,6 +230,37 @@ describe('AgentLoopEngine 工具调用链路(adapter → 引擎 → 真实 Hoo 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); diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index a7e9165..8f3d3a5 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -270,11 +270,17 @@ export class AgentLoopEngine extends EventEmitter { this.iterations.push(step); // 将 assistant 回复加入消息历史 - if (step.thought) { + // 崩溃修复(会话停止根因): 原条件 `if (step.thought)` 在模型发起纯工具调用 + // (零文本、零思考内容 — DeepSeek 高频行为)时跳过 assistant 消息,但下方 + // tool 结果消息照常 push → 下一轮请求出现孤立 tool 消息 → API 400 + // "Messages with role 'tool' must be a response to a preceding message + // with 'tool_calls'" → 会话 ERROR 终止。有 toolCalls 的轮次必须 push + // assistant(content=null,符合 C-6 规范)。 + if (step.thought || (step.toolCalls && step.toolCalls.length > 0)) { const assistantMsg: MetonaMessage = { role: 'assistant', - content: step.thought.content, - reasoningContent: step.thought.reasoningContent, + content: step.thought?.content ?? null, + reasoningContent: step.thought?.reasoningContent, toolCalls: step.toolCalls, timestamp: Date.now(), iteration: this.currentIteration, @@ -299,7 +305,9 @@ export class AgentLoopEngine extends EventEmitter { // 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环 content: result.error ? result.error - : (typeof result.result === 'string' ? result.result : JSON.stringify(result.result)), + : typeof result.result === 'string' + ? result.result + : JSON.stringify(result.result), toolResult: result, timestamp: Date.now(), iteration: this.currentIteration, @@ -324,7 +332,12 @@ 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')) { + if ( + this.aborted || + errMsgLower.includes('aborted') || + errMsgLower.includes('timed out') || + errMsgLower.includes('timeout') + ) { return this.finish(TerminationReason.USER_INTERRUPT); } // v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw, @@ -383,10 +396,7 @@ export class AgentLoopEngine extends EventEmitter { }); try { - await Promise.race([ - this.currentRunPromise.catch(() => {}), - timer, - ]); + await Promise.race([this.currentRunPromise.catch(() => {}), timer]); return !timedOut; // 超时返回 false,run 正常结束返回 true } finally { // 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留 @@ -443,7 +453,10 @@ export class AgentLoopEngine extends EventEmitter { // 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI // RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支) // H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全 - if (event.type === MetonaStreamEventType.ERROR && event.error?.code === MetonaErrorCode.RETRY) { + if ( + event.type === MetonaStreamEventType.ERROR && + event.error?.code === MetonaErrorCode.RETRY + ) { // 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收) fullContent = ''; reasoningContent = ''; @@ -535,7 +548,9 @@ export class AgentLoopEngine extends EventEmitter { // 确保第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)`); + log.warn( + `[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`, + ); this.emit('deadLoop', { iteration: this.currentIteration, runId: this.runId, @@ -555,7 +570,11 @@ export class AgentLoopEngine extends EventEmitter { // L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段) // 返回 null 表示被 abort 中断 - const raceResult = await this.executeToolCallsParallel(step.toolCalls, request.meta.requestId, sessionId); + const raceResult = await this.executeToolCallsParallel( + step.toolCalls, + request.meta.requestId, + sessionId, + ); if (raceResult === null) { // 被 abort 中断,标记步骤并退出 @@ -609,7 +628,8 @@ export class AgentLoopEngine extends EventEmitter { // === 上下文压缩(基于 token 使用率触发) === // 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow // v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发 - const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000; + const effectiveContextWindow = + this.config.contextLength ?? this.config.contextWindow ?? 128_000; const estimatedTokens = this.estimateMessagesTokens(request.messages); // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413 // 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景) @@ -749,10 +769,13 @@ export class AgentLoopEngine extends EventEmitter { if (!this.toolRegistry) { return { - toolCallId: toolCall.id, toolName: toolCall.name, - result: null, success: false, + 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(), + durationMs: Date.now() - startTs, + timestamp: Date.now(), }; } @@ -761,9 +784,13 @@ export class AgentLoopEngine extends EventEmitter { 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(), + toolCallId: toolCall.id, + toolName: toolCall.name, + result: null, + success: false, + error: `Blocked: ${result.reason}`, + durationMs: Date.now() - startTs, + timestamp: Date.now(), }; } } @@ -777,10 +804,13 @@ export class AgentLoopEngine extends EventEmitter { if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) { if (this.isTargetingRootMemoryMd(toolCall)) { return { - toolCallId: toolCall.id, toolName: toolCall.name, - result: null, success: false, + toolCallId: toolCall.id, + toolName: toolCall.name, + result: null, + success: false, error: 'Access to workspace root MEMORY.md is protected by security policy', - durationMs: Date.now() - startTs, timestamp: Date.now(), + durationMs: Date.now() - startTs, + timestamp: Date.now(), }; } } @@ -860,9 +890,13 @@ export class AgentLoopEngine extends EventEmitter { // 提取工具参数中的路径(不同工具使用不同的参数名) const args = toolCall.args; - const pathStr = (args.path as string) || (args.file_path as string) || - (args.filePath as string) || (args.file as string) || - (args.target as string) || (args.destination as string); + const pathStr = + (args.path as string) || + (args.file_path as string) || + (args.filePath as string) || + (args.file as string) || + (args.target as string) || + (args.destination as string); if (!pathStr || typeof pathStr !== 'string') return false; @@ -1003,7 +1037,8 @@ export class AgentLoopEngine extends EventEmitter { // 5xx 服务器错误 — 可重试 if (err.status && err.status >= 500 && err.status < 600) return true; // 网络超时/连接错误 — 可重试 - if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true; + if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') + return true; // P2-9 一致性修复: toLowerCase 避免大小写敏感漏判 // SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截) const msg = err.message?.toLowerCase() ?? ''; @@ -1057,22 +1092,25 @@ export class AgentLoopEngine extends EventEmitter { // 将当前轮次的工具调用序列化为签名 // v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名 // v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归 - const stableStringify = (obj: unknown, visited: Set = new Set(), depth = 0): string => { + const stableStringify = ( + obj: unknown, + visited: Set = 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(',')}]`; + if (Array.isArray(obj)) + return `[${obj.map((v) => stableStringify(v, visited, depth + 1)).join(',')}]`; const keys = Object.keys(obj as Record).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record)[k], visited, depth + 1)}`).join(',')}}`; } finally { visited.delete(obj); } }; - const signature = toolCalls - .map((tc) => `${tc.name}(${stableStringify(tc.args)})`) - .join('|'); + const signature = toolCalls.map((tc) => `${tc.name}(${stableStringify(tc.args)})`).join('|'); this.toolCallHistory.push(signature); @@ -1114,7 +1152,8 @@ export class AgentLoopEngine extends EventEmitter { private async compressMessages(messages: MetonaMessage[]): Promise { // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 // 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN - const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000; + const effectiveContextWindow = + this.config.contextLength ?? this.config.contextWindow ?? 128_000; const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50% const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文 @@ -1165,10 +1204,12 @@ export class AgentLoopEngine extends EventEmitter { // 构建摘要请求 // 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口 - const conversationText = toCompress.map((m) => { - const role = m.role.toUpperCase(); - return `[${role}] ${m.content ?? ''}`; - }).join('\n\n'); + const conversationText = toCompress + .map((m) => { + const role = m.role.toUpperCase(); + return `[${role}] ${m.content ?? ''}`; + }) + .join('\n\n'); const summaryRequest: MetonaRequest = { meta: { @@ -1180,14 +1221,18 @@ export class AgentLoopEngine extends EventEmitter { }, 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.', + 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(), - }], + messages: [ + { + role: 'user', + content: `Please summarize the following conversation history:\n\n${conversationText}`, + timestamp: Date.now(), + }, + ], params: { maxTokens: 2048, temperature: 0.0, @@ -1241,7 +1286,9 @@ export class AgentLoopEngine extends EventEmitter { return msg; }); - log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`); + log.info( + `[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`, + ); // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息 // (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。 @@ -1253,7 +1300,10 @@ export class AgentLoopEngine extends EventEmitter { ...finalKeep, ]; } catch (error) { - log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message); + log.warn( + '[AgentLoop] Context compression failed, keeping original messages:', + (error as Error).message, + ); return null; } } @@ -1264,11 +1314,7 @@ export class AgentLoopEngine extends EventEmitter { this.totalTokens.totalTokens += usage.totalTokens; } - private finish( - reason: TerminationReason, - answer?: string, - error?: Error, - ): AgentLoopOutput { + private finish(reason: TerminationReason, answer?: string, error?: Error): AgentLoopOutput { // P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误 // v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见 // 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw diff --git a/electron/harness/tools/built-in/web-fetch.ts b/electron/harness/tools/built-in/web-fetch.ts index 02f19e4..8c6e2b3 100644 --- a/electron/harness/tools/built-in/web-fetch.ts +++ b/electron/harness/tools/built-in/web-fetch.ts @@ -64,7 +64,10 @@ export class WebFetchTool implements IMetonaTool { category: MetonaToolCategory.NETWORK, riskLevel: MetonaRiskLevel.LOW, requiresPermission: false, - timeoutMs: 120_000, + // v0.6.2: 120s → 240s — v0.6.1 浏览器回退串行化后,web_search 并发 3 个回退 + // 排队最坏 ~127.5s(每个 30s load + 2.5s 渲染 + 10s eval),旧值 120s 会让 + // 排队末位的抓取在队列等待中被工具超时杀掉(表现为抓取不稳定)。 + timeoutMs: 240_000, }; async execute(args: Record, _context: ToolExecutionContext): Promise { diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts index 11a76f0..3c37221 100644 --- a/electron/ipc/agent.ts +++ b/electron/ipc/agent.ts @@ -549,8 +549,13 @@ export function registerAgentHandlers(ctx: IPCContext): void { } // 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用) + // 崩溃修复(会话停止根因的持久化侧): 原 `if (!step.thought) continue;` 把 + // 纯工具调用轮(零文本零思考)整个跳过 — assistant 与 tool 结果都不落库。 + // 后果:① 重启后历史缺失工具上下文(模型"忘记"做过什么,重复调用 → + // 表现为工具调用不稳定);② 与 engine 运行时缺陷同源。现改为: + // 有 thought 或有 toolCalls 的步骤都保存。 for (const step of output.iterations) { - if (!step.thought) continue; + if (!step.thought && !(step.toolCalls && step.toolCalls.length > 0)) continue; const toolCallsWithResults = step.toolCalls?.map((tc) => { const result = step.toolResults?.find((r) => r.toolCallId === tc.id); @@ -567,18 +572,20 @@ export function registerAgentHandlers(ctx: IPCContext): void { // 只有当有内容、思考内容或工具调用时才保存 if ( - step.thought.content || - step.thought.reasoningContent || + step.thought?.content || + step.thought?.reasoningContent || toolCallsWithResults?.length ) { // C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串) const assistantContent = - toolCallsWithResults?.length && !step.thought.content ? null : step.thought.content; + toolCallsWithResults?.length && !step.thought?.content + ? null + : (step.thought?.content ?? null); sessionService.saveMessage({ sessionId, role: 'assistant', content: assistantContent, - reasoningContent: step.thought.reasoningContent || undefined, + reasoningContent: step.thought?.reasoningContent || undefined, toolCalls: toolCallsWithResults, iteration: step.iteration, }); diff --git a/package.json b/package.json index 338fd15..078f7ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.6.1", + "version": "0.6.2", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/hooks/useAgentStream.ts b/src/hooks/useAgentStream.ts index edbde2b..7befabc 100644 --- a/src/hooks/useAgentStream.ts +++ b/src/hooks/useAgentStream.ts @@ -124,6 +124,8 @@ export function useAgentStream(): void { score: number; issues: Array<{ severity: string; type: string; message: string }>; }; + /** DONE 事件的终止原因(completed / max_iterations / timeout / user_interrupt / dead_loop / error) */ + terminationReason?: string; error?: { code: string; message: string }; state?: string; }; @@ -415,7 +417,29 @@ export function useAgentStream(): void { } // 流结束 - case 'done': + case 'done': { + // F-可见性修复: 非 completed 的终止原因此前静默结束(MAX_ITERATIONS/ + // TIMEOUT 无任何提示 — 用户感知为"会话直接停止")。此处显示 system 消息。 + // USER_INTERRUPT 不提示(用户主动触发已有感知);DEAD_LOOP 已有专属 toast。 + const reason = data.terminationReason; + if ( + reason && + reason !== 'completed' && + reason !== 'user_interrupt' && + reason !== 'dead_loop' + ) { + const reasonLabels: Record = { + max_iterations: '已达到最大迭代次数', + timeout: '总执行超时', + error: '执行出错', + }; + getStore().addMessage({ + id: genMsgId('system'), + role: 'system', + content: `⏹ 会话已停止:${reasonLabels[reason] ?? reason}`, + timestamp: Date.now(), + }); + } // F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失 if (textRafId !== null) { cancelAnimationFrame(textRafId); @@ -436,6 +460,7 @@ export function useAgentStream(): void { getStore().updateLastTraceStep({ completedAt: Date.now() }); getStore().saveTraceData(); break; + } // 错误 case 'error':