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
+2 -2
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.6.1-blue?style=flat-square" alt="Version" /> <img src="https://img.shields.io/badge/version-0.6.2-blue?style=flat-square" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" /> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" /> <img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
@@ -871,7 +871,7 @@ npm run format # Prettier 格式化
# ─── 测试 ───────────────────────────────── # ─── 测试 ─────────────────────────────────
npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过) 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 # 测试监听模式 npm run test:watch # 测试监听模式
# ─── 构建 ───────────────────────────────── # ─── 构建 ─────────────────────────────────
@@ -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');
});
});
+3 -26
View File
@@ -149,34 +149,11 @@ export class AgnesAdapter extends BaseAdapter {
* - 默认 max_tokens: 655361M 上下文,65.5K 最大输出) * - 默认 max_tokens: 655361M 上下文,65.5K 最大输出)
*/ */
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { 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); const tools = buildOpenAICompatibleTools(request.tools);
// === Agnes 多模态:将 images 转为 OpenAI content 数组 === const imageCount = request.messages.reduce((n, m) => n + (m.images?.length ?? 0), 0);
// 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;
}
if (imageCount > 0) { if (imageCount > 0) {
const firstUrl = request.messages.find((m) => m.images?.length)?.images?.[0]?.url ?? ''; const firstUrl = request.messages.find((m) => m.images?.length)?.images?.[0]?.url ?? '';
log.info( log.info(
+16 -4
View File
@@ -322,7 +322,10 @@ export class AnthropicAdapter extends BaseAdapter {
.join('\n\n'); .join('\n\n');
// 转换消息(非 system // 转换消息(非 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'; role: 'user' | 'assistant';
content: Array<Record<string, unknown>>; content: Array<Record<string, unknown>>;
}> = []; }> = [];
@@ -330,13 +333,20 @@ export class AnthropicAdapter extends BaseAdapter {
if (m.role === 'system') continue; if (m.role === 'system') continue;
if (m.role === 'tool' && m.toolResult) { 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 块 // 工具结果 → user 角色 tool_result 块
const contentStr = m.toolResult.error const contentStr = m.toolResult.error
? m.toolResult.error ? m.toolResult.error
: typeof m.toolResult.result === 'string' : typeof m.toolResult.result === 'string'
? m.toolResult.result ? m.toolResult.result
: JSON.stringify(m.toolResult.result); : JSON.stringify(m.toolResult.result);
converted.push({ convertedRaw.push({
role: 'user', role: 'user',
content: [ content: [
{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr }, { 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>> = []; const content: Array<Record<string, unknown>> = [];
if (m.content) content.push({ type: 'text', text: m.content }); if (m.content) content.push({ type: 'text', text: m.content });
for (const tc of m.toolCalls ?? []) { for (const tc of m.toolCalls ?? []) {
pendingToolUseIds.add(tc.id);
content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args }); content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.args });
} }
if (content.length > 0) { if (content.length > 0) {
converted.push({ role: 'assistant', content }); convertedRaw.push({ role: 'assistant', content });
} }
continue; continue;
} }
@@ -365,8 +376,9 @@ export class AnthropicAdapter extends BaseAdapter {
if (block) content.push(block); if (block) content.push(block);
} }
if (content.length === 0) content.push({ type: 'text', text: '' }); 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 交替) // 合并连续同角色消息(Anthropic 要求 user/assistant 交替)
const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> = const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> =
+5 -23
View File
@@ -262,7 +262,9 @@ export class DeepSeekAdapter extends BaseAdapter {
* - stream_options: { include_usage: true } — 流式返回 usage * - stream_options: { include_usage: true } — 流式返回 usage
*/ */
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { 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); const tools = buildOpenAICompatibleTools(request.tools);
// v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400 // v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400
@@ -278,29 +280,9 @@ export class DeepSeekAdapter extends BaseAdapter {
stream, stream,
}; };
// v0.5.4: vision 模型图片处理(OpenAI image_url content parts 格式 // v0.5.4: vision 模型图片数审计(转换在共享层完成
// 非 vision 模型保持 images 静默丢弃(共享层行为,避免 API 400)
if (this.isVisionModel()) { if (this.isVisionModel()) {
const nonSystemMsgs = request.messages.filter((m) => m.role !== 'system'); const imageCount = request.messages.reduce((n, m) => n + (m.images?.length ?? 0), 0);
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;
}
if (imageCount > 0) { if (imageCount > 0) {
log.info(`[DeepSeek] Vision model processing ${imageCount} image(s)`); log.info(`[DeepSeek] Vision model processing ${imageCount} image(s)`);
} }
+2 -24
View File
@@ -169,32 +169,10 @@ export class MimoAdapter extends BaseAdapter {
* 思考模式下 temperature/top_p 会被 API 强制覆盖,因此不传这两个参数。 * 思考模式下 temperature/top_p 会被 API 强制覆盖,因此不传这两个参数。
*/ */
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { 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); 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 // MiMo 使用 max_completion_tokens(非 max_tokens
// #41 修复: thinking 模式下未配置时兜底 32768thinking 占用 token 配额,API 默认值过小会截断输出) // #41 修复: thinking 模式下未配置时兜底 32768thinking 占用 token 配额,API 默认值过小会截断输出)
// v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)— // v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)—
+7 -23
View File
@@ -177,38 +177,22 @@ export class OpenAIAdapter extends BaseAdapter {
* - 思考模式下 temperature 被部分推理模型拒绝,不传 * - 思考模式下 temperature 被部分推理模型拒绝,不传
*/ */
private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> { private toNativeRequest(request: MetonaRequest, stream: boolean): Record<string, unknown> {
const messages = buildOpenAICompatibleMessages(request);
const tools = buildOpenAICompatibleTools(request.tools);
// 推理模型检测(o 系列使用新参数名) // 推理模型检测(o 系列使用新参数名)
const model = this.config.defaultModel; const model = this.config.defaultModel;
const isReasoningModel = /^(o\d|gpt-5)/.test(model); 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`); 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> = { const body: Record<string, unknown> = {
model, model,
messages, messages,
@@ -10,7 +10,8 @@
* @see apis/mimo-api-docs-20260715.html * @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 数组 * 构建 OpenAI 兼容的 messages 数组
@@ -19,13 +20,15 @@ import type { MetonaRequest, MetonaToolDef } from '../../types';
* - System Prompt 拼接(静态区 + 动态区 + 安全准则) * - System Prompt 拼接(静态区 + 动态区 + 安全准则)
* - 工具调用历史保留(reasoning_content + tool_calls * - 工具调用历史保留(reasoning_content + tool_calls
* - 工具结果注入(tool_call_id + content * - 工具结果注入(tool_call_id + content
* - 孤立 tool 消息过滤(纵深防御,见函数内注释)
* - 多模态图片(includeImages=true 时转换为 image_url content parts
* *
* 注意:图片(多模态)处理不属于此共享函数。 * @param includeImages true 时将消息的 images 转为 OpenAI image_url content parts
* 各 Provider 对多模态的支持不同(DeepSeek 不支持,Agnes/Ollama 支持但格式各异), * Agnes/MiMo/OpenAI 全系、DeepSeek 仅 vision 模型传 true
* 应在各自 Adapter 的 toNativeRequest 中处理。
*/ */
export function buildOpenAICompatibleMessages( export function buildOpenAICompatibleMessages(
request: MetonaRequest, request: MetonaRequest,
includeImages = false,
): Array<Record<string, unknown>> { ): Array<Record<string, unknown>> {
const systemContent = [ const systemContent = [
request.systemPrompt.roleDefinition, request.systemPrompt.roleDefinition,
@@ -36,9 +39,39 @@ export function buildOpenAICompatibleMessages(
.filter(Boolean) .filter(Boolean)
.join('\n\n'); .join('\n\n');
const nonSystemMessages = request.messages // 崩溃修复(纵深防御): 过滤孤立 tool 消息 — 其 tool_call_id 不属于任何前置
.filter((m) => m.role !== 'system') // assistant(tool_calls) 消息。OpenAI/DeepSeek 协议要求 tool 消息必须紧跟带
.map((m) => { // 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`,
);
}
const messages = sanitized.map((m) => {
// v0.3.0 修复: assistant 消息有 tool_calls 但 content 为空时,content 设为 null // v0.3.0 修复: assistant 消息有 tool_calls 但 content 为空时,content 设为 null
// DeepSeek/OpenAI API 要求有 tool_calls 的 assistant 消息 content 必须为 null 而非空字符串 // DeepSeek/OpenAI API 要求有 tool_calls 的 assistant 消息 content 必须为 null 而非空字符串
const msg: Record<string, unknown> = { const msg: Record<string, unknown> = {
@@ -46,11 +79,18 @@ export function buildOpenAICompatibleMessages(
content: m.content, content: m.content,
}; };
// 注意:图片(多模态)处理不在此共享函数中。 // === 多模态图片(includeImages=true 时) ===
// DeepSeek 非 vision 模型不支持多模态,images 被静默丢弃是正确行为 // v0.6.2 收敛: 原先 4 家 adapter 各自按 nonSystemMsgs[i-1] 索引对齐处理 images
// vision 模型在 DeepSeekAdapter.toNativeRequest 中独立处理) // 孤立 tool 过滤引入后索引错位。统一收进共享函数,基于 sanitized 原位转换
// Agnes/MiMo/OpenAI 各自的 toNativeRequest 中有独立的 images 处理 // DeepSeek 非 vision 模型传 falseimages 静默丢弃是正确行为,见 #27 审查撤销记录)
// 审查修复: #27 曾在此添加 images 处理,但 DeepSeek 非 vision 模型会导致 API 400,已撤销。 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;
}
// === Assistant 消息 === // === Assistant 消息 ===
if (m.role === 'assistant') { if (m.role === 'assistant') {
@@ -89,7 +129,7 @@ export function buildOpenAICompatibleMessages(
return msg; return msg;
}); });
return [{ role: 'system', content: systemContent }, ...nonSystemMessages]; return [{ role: 'system', content: systemContent }, ...messages];
} }
/** /**
@@ -230,6 +230,37 @@ describe('AgentLoopEngine 工具调用链路(adapter → 引擎 → 真实 Hoo
expect(hook.getPendingConfirmations()).toHaveLength(0); 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 () => { it('HIGH 风险工具经用户批准后执行(pending → approve → 工具运行)', async () => {
executedTools.length = 0; executedTools.length = 0;
const hook = new ConfirmationHook(makeMockWindow(), null); const hook = new ConfirmationHook(makeMockWindow(), null);
+91 -45
View File
@@ -270,11 +270,17 @@ export class AgentLoopEngine extends EventEmitter {
this.iterations.push(step); this.iterations.push(step);
// 将 assistant 回复加入消息历史 // 将 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
// assistantcontent=null,符合 C-6 规范)。
if (step.thought || (step.toolCalls && step.toolCalls.length > 0)) {
const assistantMsg: MetonaMessage = { const assistantMsg: MetonaMessage = {
role: 'assistant', role: 'assistant',
content: step.thought.content, content: step.thought?.content ?? null,
reasoningContent: step.thought.reasoningContent, reasoningContent: step.thought?.reasoningContent,
toolCalls: step.toolCalls, toolCalls: step.toolCalls,
timestamp: Date.now(), timestamp: Date.now(),
iteration: this.currentIteration, iteration: this.currentIteration,
@@ -299,7 +305,9 @@ export class AgentLoopEngine extends EventEmitter {
// 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环 // 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环
content: result.error content: result.error
? 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, toolResult: result,
timestamp: Date.now(), timestamp: Date.now(),
iteration: this.currentIteration, iteration: this.currentIteration,
@@ -324,7 +332,12 @@ export class AgentLoopEngine extends EventEmitter {
// P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR // P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR
// Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖 // Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖
const errMsgLower = errMsg.toLowerCase(); 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); return this.finish(TerminationReason.USER_INTERRUPT);
} }
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw // v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw
@@ -383,10 +396,7 @@ export class AgentLoopEngine extends EventEmitter {
}); });
try { try {
await Promise.race([ await Promise.race([this.currentRunPromise.catch(() => {}), timer]);
this.currentRunPromise.catch(() => {}),
timer,
]);
return !timedOut; // 超时返回 falserun 正常结束返回 true return !timedOut; // 超时返回 falserun 正常结束返回 true
} finally { } finally {
// 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留 // 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留
@@ -443,7 +453,10 @@ export class AgentLoopEngine extends EventEmitter {
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI // 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支) // RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全 // 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 = ''; fullContent = '';
reasoningContent = ''; reasoningContent = '';
@@ -535,7 +548,9 @@ export class AgentLoopEngine extends EventEmitter {
// 确保第3轮重复调用的副作用不会产生(工具尚未执行) // 确保第3轮重复调用的副作用不会产生(工具尚未执行)
if (step.toolCalls && step.toolCalls.length > 0) { if (step.toolCalls && step.toolCalls.length > 0) {
if (this.detectDeadLoop(step.toolCalls)) { 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', { this.emit('deadLoop', {
iteration: this.currentIteration, iteration: this.currentIteration,
runId: this.runId, runId: this.runId,
@@ -555,7 +570,11 @@ export class AgentLoopEngine extends EventEmitter {
// L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段) // L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段)
// 返回 null 表示被 abort 中断 // 返回 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) { if (raceResult === null) {
// 被 abort 中断,标记步骤并退出 // 被 abort 中断,标记步骤并退出
@@ -609,7 +628,8 @@ export class AgentLoopEngine extends EventEmitter {
// === 上下文压缩(基于 token 使用率触发) === // === 上下文压缩(基于 token 使用率触发) ===
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow // 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发 // 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); const estimatedTokens = this.estimateMessagesTokens(request.messages);
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413 // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景) // 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
@@ -749,10 +769,13 @@ export class AgentLoopEngine extends EventEmitter {
if (!this.toolRegistry) { if (!this.toolRegistry) {
return { return {
toolCallId: toolCall.id, toolName: toolCall.name, toolCallId: toolCall.id,
result: null, success: false, toolName: toolCall.name,
result: null,
success: false,
error: `Tool '${toolCall.name}' not available: no ToolRegistry configured`, 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); const result = await hook.beforeExecute(toolCall, this.currentSessionId);
if (result.blocked) { if (result.blocked) {
return { return {
toolCallId: toolCall.id, toolName: toolCall.name, toolCallId: toolCall.id,
result: null, success: false, error: `Blocked: ${result.reason}`, toolName: toolCall.name,
durationMs: Date.now() - startTs, timestamp: Date.now(), 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 (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) {
if (this.isTargetingRootMemoryMd(toolCall)) { if (this.isTargetingRootMemoryMd(toolCall)) {
return { return {
toolCallId: toolCall.id, toolName: toolCall.name, toolCallId: toolCall.id,
result: null, success: false, toolName: toolCall.name,
result: null,
success: false,
error: 'Access to workspace root MEMORY.md is protected by security policy', 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 args = toolCall.args;
const pathStr = (args.path as string) || (args.file_path as string) || const pathStr =
(args.filePath as string) || (args.file as string) || (args.path as string) ||
(args.target as string) || (args.destination 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; if (!pathStr || typeof pathStr !== 'string') return false;
@@ -1003,7 +1037,8 @@ export class AgentLoopEngine extends EventEmitter {
// 5xx 服务器错误 — 可重试 // 5xx 服务器错误 — 可重试
if (err.status && err.status >= 500 && err.status < 600) return true; 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 避免大小写敏感漏判 // P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截) // SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
const msg = err.message?.toLowerCase() ?? ''; const msg = err.message?.toLowerCase() ?? '';
@@ -1057,22 +1092,25 @@ export class AgentLoopEngine extends EventEmitter {
// 将当前轮次的工具调用序列化为签名 // 将当前轮次的工具调用序列化为签名
// v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名 // v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名
// v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归 // v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归
const stableStringify = (obj: unknown, visited: Set<unknown> = new Set(), depth = 0): string => { const stableStringify = (
obj: unknown,
visited: Set<unknown> = new Set(),
depth = 0,
): string => {
if (depth > 10) return '...'; // 深度上限防止过度递归 if (depth > 10) return '...'; // 深度上限防止过度递归
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if (visited.has(obj)) return '"[Circular]"'; // 循环引用防护 if (visited.has(obj)) return '"[Circular]"'; // 循环引用防护
visited.add(obj); visited.add(obj);
try { 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<string, unknown>).sort(); const keys = Object.keys(obj as Record<string, unknown>).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record<string, unknown>)[k], visited, depth + 1)}`).join(',')}}`; return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record<string, unknown>)[k], visited, depth + 1)}`).join(',')}}`;
} finally { } finally {
visited.delete(obj); visited.delete(obj);
} }
}; };
const signature = toolCalls const signature = toolCalls.map((tc) => `${tc.name}(${stableStringify(tc.args)})`).join('|');
.map((tc) => `${tc.name}(${stableStringify(tc.args)})`)
.join('|');
this.toolCallHistory.push(signature); this.toolCallHistory.push(signature);
@@ -1114,7 +1152,8 @@ export class AgentLoopEngine extends EventEmitter {
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> { private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
// 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN // 加默认值 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 keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50%
const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文 const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文
@@ -1165,10 +1204,12 @@ export class AgentLoopEngine extends EventEmitter {
// 构建摘要请求 // 构建摘要请求
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口 // 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
const conversationText = toCompress.map((m) => { const conversationText = toCompress
.map((m) => {
const role = m.role.toUpperCase(); const role = m.role.toUpperCase();
return `[${role}] ${m.content ?? ''}`; return `[${role}] ${m.content ?? ''}`;
}).join('\n\n'); })
.join('\n\n');
const summaryRequest: MetonaRequest = { const summaryRequest: MetonaRequest = {
meta: { meta: {
@@ -1180,14 +1221,18 @@ export class AgentLoopEngine extends EventEmitter {
}, },
systemPrompt: { systemPrompt: {
roleDefinition: 'You are a conversation summarizer.', 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.', outputConstraints:
safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.', '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: [{ messages: [
{
role: 'user', role: 'user',
content: `Please summarize the following conversation history:\n\n${conversationText}`, content: `Please summarize the following conversation history:\n\n${conversationText}`,
timestamp: Date.now(), timestamp: Date.now(),
}], },
],
params: { params: {
maxTokens: 2048, maxTokens: 2048,
temperature: 0.0, temperature: 0.0,
@@ -1241,7 +1286,9 @@ export class AgentLoopEngine extends EventEmitter {
return msg; 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 消息 // 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。 // (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
@@ -1253,7 +1300,10 @@ export class AgentLoopEngine extends EventEmitter {
...finalKeep, ...finalKeep,
]; ];
} catch (error) { } 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; return null;
} }
} }
@@ -1264,11 +1314,7 @@ export class AgentLoopEngine extends EventEmitter {
this.totalTokens.totalTokens += usage.totalTokens; this.totalTokens.totalTokens += usage.totalTokens;
} }
private finish( private finish(reason: TerminationReason, answer?: string, error?: Error): AgentLoopOutput {
reason: TerminationReason,
answer?: string,
error?: Error,
): AgentLoopOutput {
// P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误 // P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见 // v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw // 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
+4 -1
View File
@@ -64,7 +64,10 @@ export class WebFetchTool implements IMetonaTool {
category: MetonaToolCategory.NETWORK, category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW, riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false, 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<string, unknown>, _context: ToolExecutionContext): Promise<unknown> { async execute(args: Record<string, unknown>, _context: ToolExecutionContext): Promise<unknown> {
+12 -5
View File
@@ -549,8 +549,13 @@ export function registerAgentHandlers(ctx: IPCContext): void {
} }
// 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用) // 保存每轮迭代的 assistant 消息到数据库(含思考内容和工具调用)
// 崩溃修复(会话停止根因的持久化侧): 原 `if (!step.thought) continue;` 把
// 纯工具调用轮(零文本零思考)整个跳过 — assistant 与 tool 结果都不落库。
// 后果:① 重启后历史缺失工具上下文(模型"忘记"做过什么,重复调用 →
// 表现为工具调用不稳定);② 与 engine 运行时缺陷同源。现改为:
// 有 thought 或有 toolCalls 的步骤都保存。
for (const step of output.iterations) { 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 toolCallsWithResults = step.toolCalls?.map((tc) => {
const result = step.toolResults?.find((r) => r.toolCallId === tc.id); const result = step.toolResults?.find((r) => r.toolCallId === tc.id);
@@ -567,18 +572,20 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// 只有当有内容、思考内容或工具调用时才保存 // 只有当有内容、思考内容或工具调用时才保存
if ( if (
step.thought.content || step.thought?.content ||
step.thought.reasoningContent || step.thought?.reasoningContent ||
toolCallsWithResults?.length toolCallsWithResults?.length
) { ) {
// C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串) // C-6 修复: assistant 消息仅有 tool_calls 时 content 必须为 null(而非空字符串)
const assistantContent = const assistantContent =
toolCallsWithResults?.length && !step.thought.content ? null : step.thought.content; toolCallsWithResults?.length && !step.thought?.content
? null
: (step.thought?.content ?? null);
sessionService.saveMessage({ sessionService.saveMessage({
sessionId, sessionId,
role: 'assistant', role: 'assistant',
content: assistantContent, content: assistantContent,
reasoningContent: step.thought.reasoningContent || undefined, reasoningContent: step.thought?.reasoningContent || undefined,
toolCalls: toolCallsWithResults, toolCalls: toolCallsWithResults,
iteration: step.iteration, iteration: step.iteration,
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.6.1", "version": "0.6.2",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
+26 -1
View File
@@ -124,6 +124,8 @@ export function useAgentStream(): void {
score: number; score: number;
issues: Array<{ severity: string; type: string; message: string }>; 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 }; error?: { code: string; message: string };
state?: 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<string, string> = {
max_iterations: '已达到最大迭代次数',
timeout: '总执行超时',
error: '执行出错',
};
getStore().addMessage({
id: genMsgId('system'),
role: 'system',
content: `⏹ 会话已停止:${reasonLabels[reason] ?? reason}`,
timestamp: Date.now(),
});
}
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失 // F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
if (textRafId !== null) { if (textRafId !== null) {
cancelAnimationFrame(textRafId); cancelAnimationFrame(textRafId);
@@ -436,6 +460,7 @@ export function useAgentStream(): void {
getStore().updateLastTraceStep({ completedAt: Date.now() }); getStore().updateLastTraceStep({ completedAt: Date.now() });
getStore().saveTraceData(); getStore().saveTraceData();
break; break;
}
// 错误 // 错误
case 'error': case 'error':