foliko 1.1.63 → 1.1.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/sessions/cli_default.json +119 -301
- package/cli/bin/foliko.js +2 -2
- package/cli/src/commands/chat.js +15 -26
- package/cli/src/ui/chat-ui.js +102 -165
- package/cli/src/ui/footer-bar.js +7 -32
- package/cli/src/ui/message-bubble.js +24 -2
- package/cli/src/ui/status-bar.js +177 -0
- package/package.json +1 -2
- package/plugins/qq-plugin.js +1 -1
- package/src/core/agent-chat.js +50 -17
- package/src/core/agent.js +17 -27
- package/src/core/chat-session.js +7 -161
- package/src/core/constants.js +198 -0
- package/src/core/context-compressor.js +6 -181
- package/src/core/framework.js +125 -6
- package/src/core/plugin-base.js +7 -5
- package/src/core/provider.js +6 -0
- package/src/core/subagent.js +16 -135
- package/src/core/tool-executor.js +2 -70
- package/src/executors/mcp-executor.js +1 -1
- package/src/utils/chat-queue.js +11 -22
- package/src/utils/download.js +5 -4
- package/src/utils/message-validator.js +283 -0
- package/src/utils/retry.js +168 -22
- package/src/utils/sandbox.js +60 -207
- package/cli/src/utils/debounce.js +0 -106
package/src/core/subagent.js
CHANGED
|
@@ -8,8 +8,9 @@ const { EventEmitter } = require('../utils/event-emitter');
|
|
|
8
8
|
const { cleanResponse } = require('../utils');
|
|
9
9
|
const { generateText, tool, stepCountIs, isLoopFinished, RetryError, ToolLoopAgent } = require('ai');
|
|
10
10
|
const { z } = require('zod');
|
|
11
|
-
const fs=require('fs/promises')
|
|
12
11
|
const { logger } = require('../utils/logger');
|
|
12
|
+
const { validateAll } = require('../utils/message-validator');
|
|
13
|
+
const { isNetworkError, calculateDelay } = require('../utils/retry');
|
|
13
14
|
|
|
14
15
|
class Subagent extends EventEmitter {
|
|
15
16
|
/**
|
|
@@ -94,64 +95,7 @@ class Subagent extends EventEmitter {
|
|
|
94
95
|
* @private
|
|
95
96
|
*/
|
|
96
97
|
_validateMessagesPairing(messages) {
|
|
97
|
-
|
|
98
|
-
const assistantToolCallIds = new Set();
|
|
99
|
-
for (const msg of messages) {
|
|
100
|
-
if (msg.role === 'assistant') {
|
|
101
|
-
// 格式1: msg.content 中的 tool-call 块
|
|
102
|
-
if (Array.isArray(msg.content)) {
|
|
103
|
-
for (const item of msg.content) {
|
|
104
|
-
if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
|
|
105
|
-
assistantToolCallIds.add(item.toolCallId);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
// 格式2: msg.tool_calls 数组 (OpenAI 格式)
|
|
110
|
-
if (Array.isArray(msg.tool_calls)) {
|
|
111
|
-
for (const tc of msg.tool_calls) {
|
|
112
|
-
if (tc.id) {
|
|
113
|
-
assistantToolCallIds.add(tc.id);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// 检查并删除没有配对的 tool result
|
|
121
|
-
let removedCount = 0;
|
|
122
|
-
for (const msg of messages) {
|
|
123
|
-
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
124
|
-
const originalLength = msg.content.length;
|
|
125
|
-
msg.content = msg.content.filter((item) => {
|
|
126
|
-
if (
|
|
127
|
-
item &&
|
|
128
|
-
(item.type === 'tool-result' || item.type === 'tool_result') &&
|
|
129
|
-
item.toolCallId
|
|
130
|
-
) {
|
|
131
|
-
if (!assistantToolCallIds.has(item.toolCallId)) {
|
|
132
|
-
removedCount++;
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return true;
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
if (msg.content.length === 0 && originalLength > 0) {
|
|
140
|
-
msg._orphaned = true;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const originalLength = messages.length;
|
|
146
|
-
const filtered = messages.filter((msg) => !(msg.role === 'tool' && msg._orphaned));
|
|
147
|
-
|
|
148
|
-
if (removedCount > 0 || filtered.length !== originalLength) {
|
|
149
|
-
logger.debug(
|
|
150
|
-
`[Subagent:${this.name}] _validateMessagesPairing: removed ${removedCount} orphaned tool-results, ${originalLength - filtered.length} orphaned tool messages`
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
return filtered;
|
|
98
|
+
return validateAll(messages);
|
|
155
99
|
}
|
|
156
100
|
|
|
157
101
|
/**
|
|
@@ -159,64 +103,8 @@ class Subagent extends EventEmitter {
|
|
|
159
103
|
* @private
|
|
160
104
|
*/
|
|
161
105
|
_validateToolCalls(messages) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
for (const msg of messages) {
|
|
166
|
-
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
167
|
-
for (const item of msg.content) {
|
|
168
|
-
if (item.type !== 'tool-call' && item.type !== 'tool-use') {
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const input = item.input;
|
|
173
|
-
if (typeof input !== 'string') {
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const trimmed = input.trim();
|
|
178
|
-
if (trimmed === '{' || trimmed === '' || !trimmed.startsWith('{')) {
|
|
179
|
-
if (item.toolCallId) {
|
|
180
|
-
invalidatedToolCallIds.add(item.toolCallId);
|
|
181
|
-
}
|
|
182
|
-
logger.warn(
|
|
183
|
-
`[Subagent:${this.name}] _validateToolCalls: invalid tool-call input="${input}", toolCallId=${item.toolCallId}`
|
|
184
|
-
);
|
|
185
|
-
item.type = 'text';
|
|
186
|
-
item.text = `(工具调用 ${item.toolName} 参数不完整,已跳过)`;
|
|
187
|
-
delete item.toolCallId;
|
|
188
|
-
delete item.toolName;
|
|
189
|
-
delete item.input;
|
|
190
|
-
fixedCount++;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (invalidatedToolCallIds.size > 0) {
|
|
197
|
-
for (const msg of messages) {
|
|
198
|
-
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
199
|
-
const oldLen = msg.content.length;
|
|
200
|
-
msg.content = msg.content.filter((item) => {
|
|
201
|
-
if (item.type !== 'tool-result' && item.type !== 'tool_result') {
|
|
202
|
-
return true;
|
|
203
|
-
}
|
|
204
|
-
if (item.toolCallId && invalidatedToolCallIds.has(item.toolCallId)) {
|
|
205
|
-
logger.warn(
|
|
206
|
-
`[Subagent:${this.name}] _validateToolCalls: removing orphaned tool-result with toolCallId=${item.toolCallId}`
|
|
207
|
-
);
|
|
208
|
-
fixedCount++;
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
return true;
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (fixedCount > 0) {
|
|
218
|
-
logger.info(`[Subagent:${this.name}] _validateToolCalls: Fixed ${fixedCount} incomplete tool calls/results`);
|
|
219
|
-
}
|
|
106
|
+
// validateAll 内部已包含 validateMessagesPairing + validateToolCalls
|
|
107
|
+
// Subagent 的 chat() 中 _validateMessagesPairing 已调用 validateAll,所以这里不再重复
|
|
220
108
|
}
|
|
221
109
|
|
|
222
110
|
/**
|
|
@@ -327,8 +215,9 @@ class Subagent extends EventEmitter {
|
|
|
327
215
|
}
|
|
328
216
|
|
|
329
217
|
// 2. 主Agent的系统提示词
|
|
330
|
-
|
|
331
|
-
|
|
218
|
+
const mainAgent = this.framework?.getMainAgent?.();
|
|
219
|
+
if (mainAgent) {
|
|
220
|
+
const mainPrompt = mainAgent.getOriginalPrompt();
|
|
332
221
|
if (mainPrompt) {
|
|
333
222
|
lines.push('## 主Agent的系统提示词');
|
|
334
223
|
lines.push(mainPrompt);
|
|
@@ -472,18 +361,8 @@ class Subagent extends EventEmitter {
|
|
|
472
361
|
} catch (err) {
|
|
473
362
|
logger.warn(`[Subagent:${this.name}] generateText error: ${err.message}`);
|
|
474
363
|
lastError = err;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const errCode = err?.code || err?.status;
|
|
478
|
-
|
|
479
|
-
// 判断是否是重试错误
|
|
480
|
-
const isRetryError =
|
|
481
|
-
RetryError.isInstance?.(err) ||
|
|
482
|
-
errName === 'AI_RetryError' ||
|
|
483
|
-
errName === 'RetryError' ||
|
|
484
|
-
err?.reason === 'maxRetriesExceeded';
|
|
485
|
-
|
|
486
|
-
if (isRetryError && attempt < maxRetries) {
|
|
364
|
+
// 统一使用 retry.js 的错误判断
|
|
365
|
+
if (isNetworkError(err) && attempt < maxRetries) {
|
|
487
366
|
logger.warn(
|
|
488
367
|
`[Subagent:${this.name}] AI 服务暂时不可用,正在重试 (${attempt + 1}/${maxRetries})`
|
|
489
368
|
);
|
|
@@ -511,11 +390,13 @@ class Subagent extends EventEmitter {
|
|
|
511
390
|
*/
|
|
512
391
|
_getFriendlyError(err) {
|
|
513
392
|
const errName = err?.name || '';
|
|
393
|
+
|
|
394
|
+
// 先用 isNetworkError 统一判断
|
|
395
|
+
if (isNetworkError(err)) {
|
|
396
|
+
return 'AI 服务暂时不可用,请稍后重试';
|
|
397
|
+
}
|
|
398
|
+
|
|
514
399
|
const errorMessages = {
|
|
515
|
-
AI_RetryError: 'AI 服务暂时不可用,请稍后重试',
|
|
516
|
-
AI_APICallError: err?.isRetryable
|
|
517
|
-
? 'AI 服务暂时不可用,请稍后重试'
|
|
518
|
-
: err.message?.split('\n')[0] || 'AI 请求失败',
|
|
519
400
|
AI_NoContentGeneratedError: 'AI 未生成有效内容,请重试',
|
|
520
401
|
AI_NoSuchModelError: '指定的 AI 模型不存在',
|
|
521
402
|
AI_NoSuchProviderError: 'AI 提供商配置错误',
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
const { EventEmitter } = require('../utils/event-emitter');
|
|
11
11
|
const { logger } = require('../utils/logger');
|
|
12
|
+
const { validateToolCalls } = require('../utils/message-validator');
|
|
12
13
|
|
|
13
14
|
class ToolExecutor extends EventEmitter {
|
|
14
15
|
/**
|
|
@@ -183,76 +184,7 @@ class ToolExecutor extends EventEmitter {
|
|
|
183
184
|
* @returns {Array} 验证后的消息
|
|
184
185
|
*/
|
|
185
186
|
validateToolCalls(messages) {
|
|
186
|
-
|
|
187
|
-
// 收集被跳过的 toolCallId,用于清理对应的 tool-result
|
|
188
|
-
const invalidatedToolCallIds = new Set();
|
|
189
|
-
|
|
190
|
-
for (const msg of messages) {
|
|
191
|
-
// 清理 assistant 消息中的不完整 tool-call
|
|
192
|
-
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
193
|
-
for (const item of msg.content) {
|
|
194
|
-
// 兼容 tool-call 和 tool-use 两种类型
|
|
195
|
-
if (item.type !== 'tool-call' && item.type !== 'tool-use') {
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const input = item.input;
|
|
200
|
-
if (typeof input !== 'string') {
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 检查 input 是否是有效的 JSON(不是不完整的)
|
|
205
|
-
const trimmed = input.trim();
|
|
206
|
-
if (trimmed === '{' || trimmed === '' || !trimmed.startsWith('{')) {
|
|
207
|
-
// 不完整的 JSON,移除这个 tool-call
|
|
208
|
-
// 记录 toolCallId,以便后续清理对应的 tool-result
|
|
209
|
-
if (item.toolCallId) {
|
|
210
|
-
invalidatedToolCallIds.add(item.toolCallId);
|
|
211
|
-
}
|
|
212
|
-
logger.warn(
|
|
213
|
-
`_validateToolCalls: invalid tool-call input="${input}", toolCallId=${item.toolCallId}, converting to text`
|
|
214
|
-
);
|
|
215
|
-
item.type = 'text';
|
|
216
|
-
item.text = `(工具调用 ${item.toolName} 参数不完整,已跳过)`;
|
|
217
|
-
delete item.toolCallId;
|
|
218
|
-
delete item.toolName;
|
|
219
|
-
delete item.input;
|
|
220
|
-
fixedCount++;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// 如果有无效的 tool-call,清理对应的 tool-result
|
|
227
|
-
if (invalidatedToolCallIds.size > 0) {
|
|
228
|
-
logger.warn(
|
|
229
|
-
`_validateToolCalls: removing ${invalidatedToolCallIds.size} tool-results with invalidated toolCallIds`
|
|
230
|
-
);
|
|
231
|
-
for (const msg of messages) {
|
|
232
|
-
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
233
|
-
// 过滤掉引用了无效 toolCallId 的 tool-result
|
|
234
|
-
const oldLen = msg.content.length;
|
|
235
|
-
msg.content = msg.content.filter((item) => {
|
|
236
|
-
if (item.type !== 'tool-result' && item.type !== 'tool_result') {
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
// 如果 tool-result 引用的 toolCallId 已被标记为无效,则移除
|
|
240
|
-
if (item.toolCallId && invalidatedToolCallIds.has(item.toolCallId)) {
|
|
241
|
-
logger.warn(
|
|
242
|
-
`_validateToolCalls: removing orphaned tool-result with toolCallId=${item.toolCallId}`
|
|
243
|
-
);
|
|
244
|
-
fixedCount++;
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
return true;
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (fixedCount > 0) {
|
|
254
|
-
logger.info(`_validateToolCalls: Fixed ${fixedCount} incomplete tool calls/results`);
|
|
255
|
-
}
|
|
187
|
+
return validateToolCalls(messages);
|
|
256
188
|
}
|
|
257
189
|
|
|
258
190
|
/**
|
|
@@ -610,7 +610,7 @@ class MCPExecutorPlugin extends Plugin {
|
|
|
610
610
|
*/
|
|
611
611
|
_refreshAgentMCPPrompt(agent) {
|
|
612
612
|
// 检查是否已刷新过(通过检查系统提示词是否已包含 MCP 描述)
|
|
613
|
-
const existingPrompt = agent._originalPrompt || '';
|
|
613
|
+
const existingPrompt = (typeof agent.getOriginalPrompt === 'function' ? agent.getOriginalPrompt() : agent._originalPrompt) || '';
|
|
614
614
|
if (existingPrompt.includes('【MCP Servers】')) {
|
|
615
615
|
return;
|
|
616
616
|
}
|
package/src/utils/chat-queue.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { EventEmitter } = require('./event-emitter');
|
|
2
2
|
const { cleanResponse } = require('./index');
|
|
3
3
|
const { logger } = require('./logger');
|
|
4
|
+
const { isNetworkError, calculateDelay, PRESETS } = require('./retry');
|
|
4
5
|
const log = logger.child('ChatQueue');
|
|
5
6
|
// ChatQueueManager.js
|
|
6
7
|
class ChatQueueManager extends EventEmitter {
|
|
@@ -118,10 +119,9 @@ class ChatQueueManager extends EventEmitter {
|
|
|
118
119
|
if (result.error) {
|
|
119
120
|
lastError = result.error;
|
|
120
121
|
if (attempt < this.retryAttempts && this.isRetryableError(lastError)) {
|
|
121
|
-
await this.sleep(
|
|
122
|
+
await this.sleep(calculateDelay(attempt, { baseDelay: this.retryDelay }));
|
|
122
123
|
continue;
|
|
123
124
|
}
|
|
124
|
-
// 重试耗尽,直接抛出 result.error
|
|
125
125
|
throw lastError;
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -130,7 +130,7 @@ class ChatQueueManager extends EventEmitter {
|
|
|
130
130
|
log.warn('[ChatQueue] executeWithRetry: ', attempt, 'error:', error.message);
|
|
131
131
|
lastError = error;
|
|
132
132
|
if (attempt < this.retryAttempts && this.isRetryableError(error)) {
|
|
133
|
-
await this.sleep(
|
|
133
|
+
await this.sleep(calculateDelay(attempt, { baseDelay: this.retryDelay }));
|
|
134
134
|
continue;
|
|
135
135
|
}
|
|
136
136
|
break;
|
|
@@ -138,9 +138,7 @@ class ChatQueueManager extends EventEmitter {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
// 将最后的错误转换为友好消息
|
|
141
|
-
const
|
|
142
|
-
const isRetryError = errName === 'AI_RetryError' || errName === 'RetryError';
|
|
143
|
-
const friendlyMessage = isRetryError
|
|
141
|
+
const friendlyMessage = isNetworkError(lastError)
|
|
144
142
|
? 'AI 服务暂时不可用,请稍后重试'
|
|
145
143
|
: (lastError?.message || String(lastError)).split('\n')[0];
|
|
146
144
|
|
|
@@ -175,9 +173,7 @@ class ChatQueueManager extends EventEmitter {
|
|
|
175
173
|
} catch (err) {
|
|
176
174
|
// SDK 直接抛出错误(没有通过 chunk 传递)
|
|
177
175
|
// 转换为友好错误消息
|
|
178
|
-
const
|
|
179
|
-
const isRetryError = errName === 'AI_RetryError' || errName === 'RetryError';
|
|
180
|
-
const friendlyMessage = isRetryError
|
|
176
|
+
const friendlyMessage = isNetworkError(err)
|
|
181
177
|
? 'AI 服务暂时不可用,请稍后重试'
|
|
182
178
|
: (err.message || err.toString()).split('\n')[0];
|
|
183
179
|
|
|
@@ -225,20 +221,13 @@ class ChatQueueManager extends EventEmitter {
|
|
|
225
221
|
/**
|
|
226
222
|
* 判断错误是否可重试
|
|
227
223
|
*/
|
|
224
|
+
/**
|
|
225
|
+
* 判断错误是否可重试(委托给统一入口)
|
|
226
|
+
* @param {Error} error
|
|
227
|
+
* @returns {boolean}
|
|
228
|
+
*/
|
|
228
229
|
isRetryableError(error) {
|
|
229
|
-
|
|
230
|
-
return (
|
|
231
|
-
message.includes('负载较高') ||
|
|
232
|
-
message.includes('timeout') ||
|
|
233
|
-
message.includes('network') ||
|
|
234
|
-
message.includes('429') ||
|
|
235
|
-
message.includes('500') ||
|
|
236
|
-
message.includes('502') ||
|
|
237
|
-
message.includes('503') ||
|
|
238
|
-
message.includes('rate limit') ||
|
|
239
|
-
error.name === 'AI_RetryError' ||
|
|
240
|
-
error.name === 'AI_APICallError'
|
|
241
|
-
);
|
|
230
|
+
return isNetworkError(error);
|
|
242
231
|
}
|
|
243
232
|
|
|
244
233
|
/**
|
package/src/utils/download.js
CHANGED
|
@@ -6,6 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const { fileTypeFromFile, fileTypeFromBuffer } = require('file-type');
|
|
8
8
|
const { downloadAndDecryptMedia } = require('@chnak/weixin-bot');
|
|
9
|
+
const { logger } = require('./logger');
|
|
9
10
|
class FileDownloader {
|
|
10
11
|
constructor(options = {}) {
|
|
11
12
|
this.timeout = options.timeout || 30000;
|
|
@@ -318,7 +319,7 @@ class FileDownloader {
|
|
|
318
319
|
});
|
|
319
320
|
} catch (error) {
|
|
320
321
|
lastError = error;
|
|
321
|
-
|
|
322
|
+
logger.warn(`[Download] 失败,第 ${i + 1}/${retries} 次重试...`);
|
|
322
323
|
await this._sleep(1000 * (i + 1));
|
|
323
324
|
}
|
|
324
325
|
}
|
|
@@ -432,8 +433,8 @@ class FileDownloader {
|
|
|
432
433
|
|
|
433
434
|
// 获取 Content-Type
|
|
434
435
|
const contentType = response.headers['content-type'] || '';
|
|
435
|
-
|
|
436
|
-
|
|
436
|
+
logger.debug(`[Download] URL: ${url}`);
|
|
437
|
+
logger.debug(`[Download] Content-Type: ${contentType}`);
|
|
437
438
|
|
|
438
439
|
// 提取文件信息
|
|
439
440
|
const { filename: autoFilename, extension } = this._extractFileInfo(
|
|
@@ -484,7 +485,7 @@ class FileDownloader {
|
|
|
484
485
|
});
|
|
485
486
|
|
|
486
487
|
req.on('error', (err) => {
|
|
487
|
-
|
|
488
|
+
logger.error(`[Download] 请求错误: ${err.message}`);
|
|
488
489
|
reject(err);
|
|
489
490
|
});
|
|
490
491
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息验证工具
|
|
3
|
+
* 统一管理 tool-call / tool-result 配对验证
|
|
4
|
+
*
|
|
5
|
+
* 解决的问题:
|
|
6
|
+
* - 消除 4 处重复的消息配对验证逻辑
|
|
7
|
+
* - 统一 tool-call 输入格式验证
|
|
8
|
+
* - 减少消息数组多次遍历
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { logger } = require('./logger');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 收集所有 assistant 消息中的 tool-call ID
|
|
15
|
+
* @param {Array} messages - 消息数组
|
|
16
|
+
* @returns {Set<string>} tool-call ID 集合
|
|
17
|
+
*/
|
|
18
|
+
function collectToolCallIds(messages) {
|
|
19
|
+
const ids = new Set();
|
|
20
|
+
for (const msg of messages) {
|
|
21
|
+
if (msg.role !== 'assistant') continue;
|
|
22
|
+
|
|
23
|
+
// 格式1: msg.content 数组中的 tool-call 块 (AI SDK 格式)
|
|
24
|
+
if (Array.isArray(msg.content)) {
|
|
25
|
+
for (const item of msg.content) {
|
|
26
|
+
if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
|
|
27
|
+
ids.add(item.toolCallId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 格式2: msg.tool_calls 数组 (OpenAI 兼容格式)
|
|
33
|
+
if (Array.isArray(msg.tool_calls)) {
|
|
34
|
+
for (const tc of msg.tool_calls) {
|
|
35
|
+
if (tc.id) ids.add(tc.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return ids;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 验证消息配对 — 移除 orphaned tool-result(没有对应 tool-call 的)
|
|
44
|
+
* @param {Array} messages - 消息数组(会被修改)
|
|
45
|
+
* @param {Object} [options]
|
|
46
|
+
* @param {boolean} [options.log=true] - 是否记录日志
|
|
47
|
+
* @returns {Array} 过滤后的消息数组
|
|
48
|
+
*/
|
|
49
|
+
function validateMessagesPairing(messages, options = {}) {
|
|
50
|
+
const { log: shouldLog = true } = options;
|
|
51
|
+
const validToolCallIds = collectToolCallIds(messages);
|
|
52
|
+
|
|
53
|
+
let removedToolResultCount = 0;
|
|
54
|
+
|
|
55
|
+
for (const msg of messages) {
|
|
56
|
+
if (msg.role !== 'tool' || !Array.isArray(msg.content)) continue;
|
|
57
|
+
|
|
58
|
+
const originalLength = msg.content.length;
|
|
59
|
+
msg.content = msg.content.filter((item) => {
|
|
60
|
+
if (
|
|
61
|
+
item &&
|
|
62
|
+
(item.type === 'tool-result' || item.type === 'tool_result') &&
|
|
63
|
+
item.toolCallId &&
|
|
64
|
+
!validToolCallIds.has(item.toolCallId)
|
|
65
|
+
) {
|
|
66
|
+
removedToolResultCount++;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// content 全被删除了,标记整个消息待删除
|
|
73
|
+
if (msg.content.length === 0 && originalLength > 0) {
|
|
74
|
+
msg._orphaned = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const originalLen = messages.length;
|
|
79
|
+
const filtered = messages.filter((msg) => !(msg.role === 'tool' && msg._orphaned));
|
|
80
|
+
|
|
81
|
+
if (shouldLog && (removedToolResultCount > 0 || filtered.length !== originalLen)) {
|
|
82
|
+
logger.debug(
|
|
83
|
+
`[validateMessagesPairing] removed ${removedToolResultCount} orphaned tool-results,` +
|
|
84
|
+
` ${originalLen - filtered.length} orphaned tool messages`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return filtered;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 验证 tool-call 格式 — 修复不完整的 JSON 输入
|
|
93
|
+
* @param {Array} messages - 消息数组(会被修改)
|
|
94
|
+
* @param {Object} [options]
|
|
95
|
+
* @param {boolean} [options.log=true] - 是否记录日志
|
|
96
|
+
* @returns {number} 修复/移除的数量
|
|
97
|
+
*/
|
|
98
|
+
function validateToolCalls(messages, options = {}) {
|
|
99
|
+
const { log: shouldLog = true } = options;
|
|
100
|
+
let fixedCount = 0;
|
|
101
|
+
const invalidatedToolCallIds = new Set();
|
|
102
|
+
|
|
103
|
+
for (const msg of messages) {
|
|
104
|
+
if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
|
|
105
|
+
|
|
106
|
+
for (const item of msg.content) {
|
|
107
|
+
if (item.type !== 'tool-call' && item.type !== 'tool-use') continue;
|
|
108
|
+
|
|
109
|
+
const input = item.input;
|
|
110
|
+
if (typeof input !== 'string') continue;
|
|
111
|
+
|
|
112
|
+
const trimmed = input.trim();
|
|
113
|
+
if (trimmed === '{' || trimmed === '' || !trimmed.startsWith('{')) {
|
|
114
|
+
if (item.toolCallId) {
|
|
115
|
+
invalidatedToolCallIds.add(item.toolCallId);
|
|
116
|
+
}
|
|
117
|
+
if (shouldLog) {
|
|
118
|
+
logger.warn(
|
|
119
|
+
`[validateToolCalls] invalid input for "${item.toolName}", toolCallId=${item.toolCallId ||
|
|
120
|
+
'?'}, converting to text`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
// 把无效的 tool-call 转换成 text 文本
|
|
124
|
+
item.type = 'text';
|
|
125
|
+
item.text = `(工具调用 ${item.toolName} 参数不完整,已跳过)`;
|
|
126
|
+
delete item.toolCallId;
|
|
127
|
+
delete item.toolName;
|
|
128
|
+
delete item.input;
|
|
129
|
+
fixedCount++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 清理引用了无效 toolCallId 的 tool-result
|
|
135
|
+
if (invalidatedToolCallIds.size > 0) {
|
|
136
|
+
for (const msg of messages) {
|
|
137
|
+
if (msg.role !== 'tool' || !Array.isArray(msg.content)) continue;
|
|
138
|
+
|
|
139
|
+
msg.content = msg.content.filter((item) => {
|
|
140
|
+
if (
|
|
141
|
+
item &&
|
|
142
|
+
(item.type === 'tool-result' || item.type === 'tool_result') &&
|
|
143
|
+
item.toolCallId &&
|
|
144
|
+
invalidatedToolCallIds.has(item.toolCallId)
|
|
145
|
+
) {
|
|
146
|
+
fixedCount++;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (shouldLog && fixedCount > 0) {
|
|
155
|
+
logger.info(`[validateToolCalls] Fixed ${fixedCount} incomplete tool calls/results`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return fixedCount;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 一次遍历完成配对验证 + tool-call 格式验证
|
|
163
|
+
* 相比分别调用减少消息数组迭代次数
|
|
164
|
+
* @param {Array} messages - 消息数组(会被修改)
|
|
165
|
+
* @returns {Array} 过滤后的消息数组
|
|
166
|
+
*/
|
|
167
|
+
function validateAll(messages) {
|
|
168
|
+
// 1. 先修复 tool-call 格式(避免无效 ID 干扰后续配对验证)
|
|
169
|
+
validateToolCalls(messages);
|
|
170
|
+
// 2. 再移除 orphaned tool-result
|
|
171
|
+
return validateMessagesPairing(messages);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 过滤消息,保留配对的 tool-call → tool-result 链条
|
|
176
|
+
* 用于上下文压缩后保留完整的工具调用链路
|
|
177
|
+
* @param {Array} messages - 消息数组
|
|
178
|
+
* @param {number} [keepRecentAssistant=3] - 保留最近几条无工具调用的 assistant 消息
|
|
179
|
+
* @returns {Array} 过滤后的消息数组
|
|
180
|
+
*/
|
|
181
|
+
function filterPairedMessages(messages, keepRecentAssistant = 3) {
|
|
182
|
+
// 第一遍:收集所有 tool-call 及对应的 result
|
|
183
|
+
const assistantToolCalls = new Map(); // toolCallId → assistant index
|
|
184
|
+
const toolResults = new Map(); // toolCallId → [result indices]
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < messages.length; i++) {
|
|
187
|
+
const msg = messages[i];
|
|
188
|
+
if (msg.role === 'assistant') {
|
|
189
|
+
const content = Array.isArray(msg.content) ? msg.content : [msg.content];
|
|
190
|
+
for (const item of content) {
|
|
191
|
+
if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
|
|
192
|
+
assistantToolCalls.set(item.toolCallId, i);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (Array.isArray(msg.tool_calls)) {
|
|
196
|
+
for (const tc of msg.tool_calls) {
|
|
197
|
+
if (tc.id) assistantToolCalls.set(tc.id, i);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
202
|
+
for (const item of msg.content) {
|
|
203
|
+
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId) {
|
|
204
|
+
if (!toolResults.has(item.toolCallId)) {
|
|
205
|
+
toolResults.set(item.toolCallId, []);
|
|
206
|
+
}
|
|
207
|
+
toolResults.get(item.toolCallId).push(i);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 第二遍:决定哪些 assistant 消息需要保留
|
|
214
|
+
const assistantIndicesToKeep = new Set();
|
|
215
|
+
for (let i = 0; i < messages.length; i++) {
|
|
216
|
+
const msg = messages[i];
|
|
217
|
+
if (msg.role !== 'assistant') continue;
|
|
218
|
+
|
|
219
|
+
// 检查是否有 tool-call
|
|
220
|
+
let hasToolCall = false;
|
|
221
|
+
const content = Array.isArray(msg.content) ? msg.content : [msg.content];
|
|
222
|
+
for (const item of content) {
|
|
223
|
+
if (item.type === 'tool-call' || item.type === 'tool-use') {
|
|
224
|
+
hasToolCall = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!hasToolCall && Array.isArray(msg.tool_calls)) {
|
|
229
|
+
hasToolCall = msg.tool_calls.some((tc) => tc.id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (hasToolCall) {
|
|
233
|
+
assistantIndicesToKeep.add(i);
|
|
234
|
+
} else if (i >= messages.length - keepRecentAssistant) {
|
|
235
|
+
// 保留最近几条非工具调用的 assistant 消息
|
|
236
|
+
assistantIndicesToKeep.add(i);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 保存有 tool-call 但没有 result 的 assistant(防止切掉半截链条)
|
|
241
|
+
for (const [toolCallId, assistantIdx] of assistantToolCalls) {
|
|
242
|
+
if (!toolResults.has(toolCallId)) {
|
|
243
|
+
assistantIndicesToKeep.add(assistantIdx);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 第三遍:决定哪些 tool-result 要保留(只保留有对应 assistant 的)
|
|
248
|
+
const indicesToKeep = new Set();
|
|
249
|
+
for (let i = 0; i < messages.length; i++) {
|
|
250
|
+
const msg = messages[i];
|
|
251
|
+
if (msg.role === 'tool') {
|
|
252
|
+
const content = Array.isArray(msg.content) ? msg.content : [msg.content];
|
|
253
|
+
const hasPairedAssistant = content.some(
|
|
254
|
+
(item) =>
|
|
255
|
+
(item.type === 'tool-result' || item.type === 'tool_result') &&
|
|
256
|
+
item.toolCallId &&
|
|
257
|
+
assistantToolCalls.has(item.toolCallId) &&
|
|
258
|
+
assistantIndicesToKeep.has(assistantToolCalls.get(item.toolCallId))
|
|
259
|
+
);
|
|
260
|
+
if (hasPairedAssistant) {
|
|
261
|
+
indicesToKeep.add(i);
|
|
262
|
+
}
|
|
263
|
+
} else if (msg.role === 'assistant') {
|
|
264
|
+
if (assistantIndicesToKeep.has(i)) {
|
|
265
|
+
indicesToKeep.add(i);
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// user, system 等角色默认保留
|
|
269
|
+
indicesToKeep.add(i);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return Array.from(indicesToKeep)
|
|
274
|
+
.sort((a, b) => a - b)
|
|
275
|
+
.map((i) => messages[i]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
validateMessagesPairing,
|
|
280
|
+
validateToolCalls,
|
|
281
|
+
validateAll,
|
|
282
|
+
filterPairedMessages,
|
|
283
|
+
};
|