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:
@@ -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=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 最大输出)
|
* - 默认 max_tokens: 65536(1M 上下文,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(
|
||||||
|
|||||||
@@ -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>> }> =
|
||||||
|
|||||||
@@ -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)`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 模式下未配置时兜底 32768(thinking 占用 token 配额,API 默认值过小会截断输出)
|
// #41 修复: thinking 模式下未配置时兜底 32768(thinking 占用 token 配额,API 默认值过小会截断输出)
|
||||||
// v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)—
|
// v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)—
|
||||||
|
|||||||
@@ -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 模型传 false(images 静默丢弃是正确行为,见 #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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
// assistant(content=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; // 超时返回 false,run 正常结束返回 true
|
return !timedOut; // 超时返回 false,run 正常结束返回 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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user