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:
@@ -270,11 +270,17 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.iterations.push(step);
|
||||
|
||||
// 将 assistant 回复加入消息历史
|
||||
if (step.thought) {
|
||||
// 崩溃修复(会话停止根因): 原条件 `if (step.thought)` 在模型发起纯工具调用
|
||||
// (零文本、零思考内容 — DeepSeek 高频行为)时跳过 assistant 消息,但下方
|
||||
// tool 结果消息照常 push → 下一轮请求出现孤立 tool 消息 → API 400
|
||||
// "Messages with role 'tool' must be a response to a preceding message
|
||||
// with 'tool_calls'" → 会话 ERROR 终止。有 toolCalls 的轮次必须 push
|
||||
// assistant(content=null,符合 C-6 规范)。
|
||||
if (step.thought || (step.toolCalls && step.toolCalls.length > 0)) {
|
||||
const assistantMsg: MetonaMessage = {
|
||||
role: 'assistant',
|
||||
content: step.thought.content,
|
||||
reasoningContent: step.thought.reasoningContent,
|
||||
content: step.thought?.content ?? null,
|
||||
reasoningContent: step.thought?.reasoningContent,
|
||||
toolCalls: step.toolCalls,
|
||||
timestamp: Date.now(),
|
||||
iteration: this.currentIteration,
|
||||
@@ -299,7 +305,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 优先使用 error 字段,让 LLM 知道失败原因,避免重复调用导致死循环
|
||||
content: result.error
|
||||
? result.error
|
||||
: (typeof result.result === 'string' ? result.result : JSON.stringify(result.result)),
|
||||
: typeof result.result === 'string'
|
||||
? result.result
|
||||
: JSON.stringify(result.result),
|
||||
toolResult: result,
|
||||
timestamp: Date.now(),
|
||||
iteration: this.currentIteration,
|
||||
@@ -324,7 +332,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// P2-9 修复: toLowerCase 避免大小写敏感导致超时误判为 ERROR
|
||||
// Node fetch 超时错误 "The operation timed out" / abort "Aborted" 都需覆盖
|
||||
const errMsgLower = errMsg.toLowerCase();
|
||||
if (this.aborted || errMsgLower.includes('aborted') || errMsgLower.includes('timed out') || errMsgLower.includes('timeout')) {
|
||||
if (
|
||||
this.aborted ||
|
||||
errMsgLower.includes('aborted') ||
|
||||
errMsgLower.includes('timed out') ||
|
||||
errMsgLower.includes('timeout')
|
||||
) {
|
||||
return this.finish(TerminationReason.USER_INTERRUPT);
|
||||
}
|
||||
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw,
|
||||
@@ -383,10 +396,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
this.currentRunPromise.catch(() => {}),
|
||||
timer,
|
||||
]);
|
||||
await Promise.race([this.currentRunPromise.catch(() => {}), timer]);
|
||||
return !timedOut; // 超时返回 false,run 正常结束返回 true
|
||||
} finally {
|
||||
// 审查修复: 无论 race 谁先完成,都清理 timer 防止事件循环残留
|
||||
@@ -443,7 +453,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
|
||||
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
|
||||
// H-11 修复: 使用 MetonaErrorCode.RETRY 替代 'as string' 强制转换,确保类型安全
|
||||
if (event.type === MetonaStreamEventType.ERROR && event.error?.code === MetonaErrorCode.RETRY) {
|
||||
if (
|
||||
event.type === MetonaStreamEventType.ERROR &&
|
||||
event.error?.code === MetonaErrorCode.RETRY
|
||||
) {
|
||||
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
|
||||
fullContent = '';
|
||||
reasoningContent = '';
|
||||
@@ -535,7 +548,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 确保第3轮重复调用的副作用不会产生(工具尚未执行)
|
||||
if (step.toolCalls && step.toolCalls.length > 0) {
|
||||
if (this.detectDeadLoop(step.toolCalls)) {
|
||||
log.warn(`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`);
|
||||
log.warn(
|
||||
`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`,
|
||||
);
|
||||
this.emit('deadLoop', {
|
||||
iteration: this.currentIteration,
|
||||
runId: this.runId,
|
||||
@@ -555,7 +570,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
// L-19 修复: 提取 executeToolCallsParallel 子方法(EXECUTING 阶段)
|
||||
// 返回 null 表示被 abort 中断
|
||||
const raceResult = await this.executeToolCallsParallel(step.toolCalls, request.meta.requestId, sessionId);
|
||||
const raceResult = await this.executeToolCallsParallel(
|
||||
step.toolCalls,
|
||||
request.meta.requestId,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
if (raceResult === null) {
|
||||
// 被 abort 中断,标记步骤并退出
|
||||
@@ -609,7 +628,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// === 上下文压缩(基于 token 使用率触发) ===
|
||||
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
||||
// v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
const effectiveContextWindow =
|
||||
this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
const estimatedTokens = this.estimateMessagesTokens(request.messages);
|
||||
// v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413
|
||||
// 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景)
|
||||
@@ -749,10 +769,13 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
if (!this.toolRegistry) {
|
||||
return {
|
||||
toolCallId: toolCall.id, toolName: toolCall.name,
|
||||
result: null, success: false,
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: `Tool '${toolCall.name}' not available: no ToolRegistry configured`,
|
||||
durationMs: Date.now() - startTs, timestamp: Date.now(),
|
||||
durationMs: Date.now() - startTs,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -761,9 +784,13 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
const result = await hook.beforeExecute(toolCall, this.currentSessionId);
|
||||
if (result.blocked) {
|
||||
return {
|
||||
toolCallId: toolCall.id, toolName: toolCall.name,
|
||||
result: null, success: false, error: `Blocked: ${result.reason}`,
|
||||
durationMs: Date.now() - startTs, timestamp: Date.now(),
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: `Blocked: ${result.reason}`,
|
||||
durationMs: Date.now() - startTs,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -777,10 +804,13 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
if (['read_file', 'write_file', 'file_editor'].includes(toolCall.name)) {
|
||||
if (this.isTargetingRootMemoryMd(toolCall)) {
|
||||
return {
|
||||
toolCallId: toolCall.id, toolName: toolCall.name,
|
||||
result: null, success: false,
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: 'Access to workspace root MEMORY.md is protected by security policy',
|
||||
durationMs: Date.now() - startTs, timestamp: Date.now(),
|
||||
durationMs: Date.now() - startTs,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -860,9 +890,13 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
// 提取工具参数中的路径(不同工具使用不同的参数名)
|
||||
const args = toolCall.args;
|
||||
const pathStr = (args.path as string) || (args.file_path as string) ||
|
||||
(args.filePath as string) || (args.file as string) ||
|
||||
(args.target as string) || (args.destination as string);
|
||||
const pathStr =
|
||||
(args.path as string) ||
|
||||
(args.file_path as string) ||
|
||||
(args.filePath as string) ||
|
||||
(args.file as string) ||
|
||||
(args.target as string) ||
|
||||
(args.destination as string);
|
||||
|
||||
if (!pathStr || typeof pathStr !== 'string') return false;
|
||||
|
||||
@@ -1003,7 +1037,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 5xx 服务器错误 — 可重试
|
||||
if (err.status && err.status >= 500 && err.status < 600) return true;
|
||||
// 网络超时/连接错误 — 可重试
|
||||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
||||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND')
|
||||
return true;
|
||||
// P2-9 一致性修复: toLowerCase 避免大小写敏感漏判
|
||||
// SSE 流中断 — 可重试(注意:用户主动 abort 已在 chatStreamWithRetry 入口由 this.aborted 提前拦截)
|
||||
const msg = err.message?.toLowerCase() ?? '';
|
||||
@@ -1057,22 +1092,25 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 将当前轮次的工具调用序列化为签名
|
||||
// v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名
|
||||
// v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归
|
||||
const stableStringify = (obj: unknown, visited: Set<unknown> = new Set(), depth = 0): string => {
|
||||
const stableStringify = (
|
||||
obj: unknown,
|
||||
visited: Set<unknown> = new Set(),
|
||||
depth = 0,
|
||||
): string => {
|
||||
if (depth > 10) return '...'; // 深度上限防止过度递归
|
||||
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
||||
if (visited.has(obj)) return '"[Circular]"'; // 循环引用防护
|
||||
visited.add(obj);
|
||||
try {
|
||||
if (Array.isArray(obj)) return `[${obj.map((v) => stableStringify(v, visited, depth + 1)).join(',')}]`;
|
||||
if (Array.isArray(obj))
|
||||
return `[${obj.map((v) => stableStringify(v, visited, depth + 1)).join(',')}]`;
|
||||
const keys = Object.keys(obj as Record<string, unknown>).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record<string, unknown>)[k], visited, depth + 1)}`).join(',')}}`;
|
||||
} finally {
|
||||
visited.delete(obj);
|
||||
}
|
||||
};
|
||||
const signature = toolCalls
|
||||
.map((tc) => `${tc.name}(${stableStringify(tc.args)})`)
|
||||
.join('|');
|
||||
const signature = toolCalls.map((tc) => `${tc.name}(${stableStringify(tc.args)})`).join('|');
|
||||
|
||||
this.toolCallHistory.push(signature);
|
||||
|
||||
@@ -1114,7 +1152,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
private async compressMessages(messages: MetonaMessage[]): Promise<MetonaMessage[] | null> {
|
||||
// v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限
|
||||
// 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
const effectiveContextWindow =
|
||||
this.config.contextLength ?? this.config.contextWindow ?? 128_000;
|
||||
const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50%
|
||||
const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文
|
||||
|
||||
@@ -1165,10 +1204,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
|
||||
// 构建摘要请求
|
||||
// 不截断单条消息——摘要请求是独立 API 调用,不共享主对话上下文窗口
|
||||
const conversationText = toCompress.map((m) => {
|
||||
const role = m.role.toUpperCase();
|
||||
return `[${role}] ${m.content ?? ''}`;
|
||||
}).join('\n\n');
|
||||
const conversationText = toCompress
|
||||
.map((m) => {
|
||||
const role = m.role.toUpperCase();
|
||||
return `[${role}] ${m.content ?? ''}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const summaryRequest: MetonaRequest = {
|
||||
meta: {
|
||||
@@ -1180,14 +1221,18 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a conversation summarizer.',
|
||||
outputConstraints: 'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.',
|
||||
safetyGuidelines: 'Do not include sensitive data like passwords or API keys in the summary.',
|
||||
outputConstraints:
|
||||
'Summarize the following conversation history concisely. Preserve key facts, decisions, tool results, and context needed for future reasoning. Output in the same language as the conversation. Maximum 300 words.',
|
||||
safetyGuidelines:
|
||||
'Do not include sensitive data like passwords or API keys in the summary.',
|
||||
},
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Please summarize the following conversation history:\n\n${conversationText}`,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Please summarize the following conversation history:\n\n${conversationText}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
params: {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.0,
|
||||
@@ -1241,7 +1286,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return msg;
|
||||
});
|
||||
|
||||
log.info(`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`);
|
||||
log.info(
|
||||
`[AgentLoop] Context compressed: ${toCompress.length} messages → 1 summary, kept ${finalKeep.length} recent (${keepTokens} tokens budget)`,
|
||||
);
|
||||
|
||||
// 审查修复: #30 修复将摘要改为 assistant 角色,可能导致连续两个 assistant 消息
|
||||
// (summary + 带 tool_calls 的 assistant),部分 Provider 会返回 400。
|
||||
@@ -1253,7 +1300,10 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
...finalKeep,
|
||||
];
|
||||
} catch (error) {
|
||||
log.warn('[AgentLoop] Context compression failed, keeping original messages:', (error as Error).message);
|
||||
log.warn(
|
||||
'[AgentLoop] Context compression failed, keeping original messages:',
|
||||
(error as Error).message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1264,11 +1314,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
this.totalTokens.totalTokens += usage.totalTokens;
|
||||
}
|
||||
|
||||
private finish(
|
||||
reason: TerminationReason,
|
||||
answer?: string,
|
||||
error?: Error,
|
||||
): AgentLoopOutput {
|
||||
private finish(reason: TerminationReason, answer?: string, error?: Error): AgentLoopOutput {
|
||||
// P0-1 修复: ERROR/DEAD_LOOP 终止时先发 ERROR 流式事件,让前端能看到错误
|
||||
// v0.3.0 删除了 emit('error') 导致所有 adapter 错误对前端不可见
|
||||
// 此处用 emit('streamEvent', { type: ERROR }) 不会触发 EventEmitter 的同步 throw
|
||||
|
||||
Reference in New Issue
Block a user