foliko 2.0.27 → 2.0.29
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/package.json +1 -1
- package/src/agent/chat.js +58 -20
- package/src/agent/tool-loop.js +62 -3
- package/src/common/constants.js +3 -3
- package/tests/core/tool-loop.test.js +3 -4
package/package.json
CHANGED
package/src/agent/chat.js
CHANGED
|
@@ -1028,16 +1028,16 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1028
1028
|
const totalTokens = cachedMsgTokens + toolsTokens + systemPromptTokens;
|
|
1029
1029
|
const limit = this._maxContextTokens * SIMPLE_COMPRESS_TOKEN_RATIO;
|
|
1030
1030
|
|
|
1031
|
-
|
|
1031
|
+
logger.info(`[_prepareSession] BEFORE: messages=${messages.length}, tokens=${totalTokens}/${limit}, threshold=${this._compressionMessageThreshold}`);
|
|
1032
1032
|
|
|
1033
1033
|
if (totalTokens > limit || messages.length > this._compressionMessageThreshold) {
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1034
|
+
logger.info(
|
|
1035
|
+
`Context large (${messages.length} msgs, ${totalTokens}/${this._maxContextTokens} tokens), compressing...`
|
|
1036
|
+
);
|
|
1037
1037
|
await this._compressContext(sessionId, messages, messageStore);
|
|
1038
1038
|
// 压缩后消息被截断/替换,缓存已失效,下次 _getCachedMessageTokens 会重新计算
|
|
1039
1039
|
this._invalidateMessageTokensCache(messageStore);
|
|
1040
|
-
logger.
|
|
1040
|
+
logger.info(`[_prepareSession] AFTER compress: messages=${messages.length}`);
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
1043
|
// 单次遍历完成:修复 invalid tool-call + 修复错误 text + 重映射历史 ID
|
|
@@ -1117,6 +1117,8 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1117
1117
|
|
|
1118
1118
|
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
|
|
1119
1119
|
const msg = messages[msgIdx];
|
|
1120
|
+
|
|
1121
|
+
// ★ assistant 消息:content 数组格式的 tool-call
|
|
1120
1122
|
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
1121
1123
|
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1122
1124
|
const item = msg.content[itemIdx];
|
|
@@ -1124,31 +1126,58 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1124
1126
|
if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
|
|
1125
1127
|
const id = item.toolCallId;
|
|
1126
1128
|
if (firstSeen.has(id)) {
|
|
1127
|
-
// 冲突:生成唯一新 ID(不依赖全局计数器,避免跨调用泄露)
|
|
1128
1129
|
const newId = `${id}_dup${msgIdx}_${itemIdx}`;
|
|
1129
1130
|
item.toolCallId = newId;
|
|
1130
|
-
// 同步修改紧随其后的 tool-result
|
|
1131
1131
|
const renamed = this._renameFollowingToolResult(messages, msgIdx, id, newId);
|
|
1132
1132
|
fixedCount += 1 + (renamed ? 1 : 0);
|
|
1133
|
-
// 不更新 firstSeen(原始 ID 已被第一次声明占用)
|
|
1134
1133
|
} else {
|
|
1135
1134
|
firstSeen.set(id, { msgIdx, itemIdx, kind: 'call' });
|
|
1136
1135
|
}
|
|
1137
1136
|
}
|
|
1138
1137
|
}
|
|
1139
|
-
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ★ assistant 消息:tool_calls 数组格式(OpenAI 格式)
|
|
1141
|
+
if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) {
|
|
1142
|
+
for (let itemIdx = 0; itemIdx < msg.tool_calls.length; itemIdx++) {
|
|
1143
|
+
const tc = msg.tool_calls[itemIdx];
|
|
1144
|
+
if (!tc || !tc.id) continue;
|
|
1145
|
+
const id = tc.id;
|
|
1146
|
+
if (firstSeen.has(id)) {
|
|
1147
|
+
const newId = `${id}_dup${msgIdx}_${itemIdx}`;
|
|
1148
|
+
tc.id = newId;
|
|
1149
|
+
const renamed = this._renameFollowingToolResult(messages, msgIdx, id, newId);
|
|
1150
|
+
fixedCount += 1 + (renamed ? 1 : 0);
|
|
1151
|
+
} else {
|
|
1152
|
+
firstSeen.set(id, { msgIdx, itemIdx, kind: 'call' });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ★ tool 消息:content 数组格式(AI SDK 格式)
|
|
1158
|
+
// 注意:tool-result 与 assistant tool-call 配对使用同一 ID 是正常的,
|
|
1159
|
+
// 此处只记录第一次出现的 ID,不做重命名(重命名由 _renameFollowingToolResult 在上面的 assistant 分支处理)
|
|
1160
|
+
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
1140
1161
|
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1141
1162
|
const item = msg.content[itemIdx];
|
|
1142
1163
|
if (!item) continue;
|
|
1143
1164
|
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId) {
|
|
1144
1165
|
const id = item.toolCallId;
|
|
1145
|
-
// 记录 tool-result 第一次出现(不与 call 比较,因为可能已被上面的分支处理过)
|
|
1146
1166
|
if (!firstSeen.has(id)) {
|
|
1147
1167
|
firstSeen.set(id, { msgIdx, itemIdx, kind: 'result' });
|
|
1148
1168
|
}
|
|
1149
1169
|
}
|
|
1150
1170
|
}
|
|
1151
1171
|
}
|
|
1172
|
+
|
|
1173
|
+
// ★ tool 消息:tool_call_id 字符串格式(OpenAI 格式)
|
|
1174
|
+
// 同样只记录第一次出现,不做重命名
|
|
1175
|
+
if (msg.role === 'tool' && msg.tool_call_id && !Array.isArray(msg.content)) {
|
|
1176
|
+
const id = msg.tool_call_id;
|
|
1177
|
+
if (!firstSeen.has(id)) {
|
|
1178
|
+
firstSeen.set(id, { msgIdx, itemIdx: -1, kind: 'result' });
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1152
1181
|
}
|
|
1153
1182
|
return fixedCount;
|
|
1154
1183
|
}
|
|
@@ -1162,14 +1191,22 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1162
1191
|
for (let i = afterMsgIdx + 1; i < messages.length; i++) {
|
|
1163
1192
|
const m = messages[i];
|
|
1164
1193
|
if (m.role === 'assistant') return false;
|
|
1165
|
-
if (m.role === 'tool'
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
item
|
|
1170
|
-
|
|
1194
|
+
if (m.role === 'tool') {
|
|
1195
|
+
// AI SDK 数组格式
|
|
1196
|
+
if (Array.isArray(m.content)) {
|
|
1197
|
+
for (const item of m.content) {
|
|
1198
|
+
if (!item) continue;
|
|
1199
|
+
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId === oldId) {
|
|
1200
|
+
item.toolCallId = newId;
|
|
1201
|
+
return true;
|
|
1202
|
+
}
|
|
1171
1203
|
}
|
|
1172
1204
|
}
|
|
1205
|
+
// OpenAI 字符串格式
|
|
1206
|
+
if (m.tool_call_id === oldId) {
|
|
1207
|
+
m.tool_call_id = newId;
|
|
1208
|
+
return true;
|
|
1209
|
+
}
|
|
1173
1210
|
}
|
|
1174
1211
|
}
|
|
1175
1212
|
return false;
|
|
@@ -1360,10 +1397,11 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1360
1397
|
* @private
|
|
1361
1398
|
*/
|
|
1362
1399
|
_createPrepareStep() {
|
|
1400
|
+
const self = this;
|
|
1363
1401
|
return async ({ stepNumber, messages: inputMessages }) => {
|
|
1364
1402
|
try {
|
|
1365
|
-
const tokenCount =
|
|
1366
|
-
const tokenLimit =
|
|
1403
|
+
const tokenCount = self._countMessagesTokens(inputMessages);
|
|
1404
|
+
const tokenLimit = self._maxContextTokens * SMART_COMPRESS_TOKEN_RATIO;
|
|
1367
1405
|
|
|
1368
1406
|
// 超过限制时,保留三分之一的消息
|
|
1369
1407
|
if (tokenCount > tokenLimit) {
|
|
@@ -1381,8 +1419,8 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1381
1419
|
// 所以不再需要 _stripStaleToolCalls 提前清理
|
|
1382
1420
|
|
|
1383
1421
|
// 单次遍历完成:修复 invalid tool-call + 修复错误 text + 重映射历史 ID
|
|
1384
|
-
const repairedCount =
|
|
1385
|
-
|
|
1422
|
+
const repairedCount = self._repairInvalidToolCallsInMessages(inputMessages);
|
|
1423
|
+
self._repairAndPrefixHistoricalIds(inputMessages);
|
|
1386
1424
|
|
|
1387
1425
|
// ★ 防御性 sanitize:确保所有 message 符合 AI SDK ModelMessage[] schema
|
|
1388
1426
|
// 每次 LLM step 前都跑一次(messages 数组里可能有 AI SDK 上一步加进来的)
|
package/src/agent/tool-loop.js
CHANGED
|
@@ -183,6 +183,13 @@ class ToolLoop extends EventEmitter {
|
|
|
183
183
|
const api = [];
|
|
184
184
|
if (systemPrompt) api.push({ role: 'system', content: systemPrompt });
|
|
185
185
|
|
|
186
|
+
// ★ 单遍扫描:跟踪"已遇到过的 assistant 中的 tool_call_id",
|
|
187
|
+
// tool-result 只能匹配其之前的 assistant,不能匹配其之后的。
|
|
188
|
+
// 防止历史会话中的 orphaned tool-result 被错误地保留。
|
|
189
|
+
const seenAssistantToolCallIds = new Set();
|
|
190
|
+
// ★ 追踪已输出的 tool_call_id,防止重复
|
|
191
|
+
const seenOutputToolCallIds = new Set();
|
|
192
|
+
|
|
186
193
|
for (const msg of messages) {
|
|
187
194
|
if (msg.role === 'system') {
|
|
188
195
|
api.push({ role: 'system', content: typeof msg.content === 'string' ? msg.content : '' });
|
|
@@ -194,6 +201,25 @@ class ToolLoop extends EventEmitter {
|
|
|
194
201
|
api.push({ role: 'user', content: text || '' });
|
|
195
202
|
}
|
|
196
203
|
} else if (msg.role === 'assistant') {
|
|
204
|
+
// ★ 先收集此 assistant 消息中的 tool_call_id(推入 api 前更新 seen 集合)
|
|
205
|
+
const currentToolCallIds = new Set();
|
|
206
|
+
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
|
207
|
+
for (const tc of msg.tool_calls) {
|
|
208
|
+
if (tc.id) currentToolCallIds.add(tc.id);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(msg.content)) {
|
|
212
|
+
for (const part of msg.content) {
|
|
213
|
+
if (part && (part.type === 'tool-call' || part.type === 'tool-use') && part.toolCallId) {
|
|
214
|
+
currentToolCallIds.add(part.toolCallId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ★ 将这些 ID 加入 seenAssistantToolCallIds,之后的 tool-result 才能匹配
|
|
219
|
+
for (const id of currentToolCallIds) {
|
|
220
|
+
seenAssistantToolCallIds.add(id);
|
|
221
|
+
}
|
|
222
|
+
|
|
197
223
|
// ★ 检测是否有 reasoning 内容(thinking mode 必须携带)
|
|
198
224
|
const hasReasoningInContent = Array.isArray(msg.content) && msg.content.some(p => p && p.type === 'reasoning');
|
|
199
225
|
const hasReasoningTopLevel = msg.reasoning_content || msg.reasoning;
|
|
@@ -237,16 +263,36 @@ class ToolLoop extends EventEmitter {
|
|
|
237
263
|
if (Array.isArray(msg.content)) {
|
|
238
264
|
for (const part of msg.content) {
|
|
239
265
|
if (!part || (part.type !== 'tool-result' && part.type !== 'tool_result')) continue;
|
|
266
|
+
const id = part.toolCallId;
|
|
267
|
+
// ★ 过滤:tool-result 必须匹配已出现过的 assistant 中的 tool_call_id
|
|
268
|
+
if (!seenAssistantToolCallIds.has(id)) {
|
|
269
|
+
log.warn(`[ToolLoop] 过滤无效 tool-result(顺序/无匹配): toolCallId=${id}`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// ★ 过滤重复的 tool_call_id(同一 ID 多次出现)
|
|
273
|
+
if (seenOutputToolCallIds.has(id)) {
|
|
274
|
+
log.warn(`[ToolLoop] 跳过重复 tool-result: toolCallId=${id}`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
seenOutputToolCallIds.add(id);
|
|
240
278
|
const output = part.output;
|
|
241
279
|
let content;
|
|
242
280
|
if (output?.type === 'text') content = output.value;
|
|
243
281
|
else if (output?.type === 'json') content = JSON.stringify(output.value);
|
|
244
282
|
else content = typeof output === 'string' ? output : JSON.stringify(output);
|
|
245
|
-
api.push({ role: 'tool', tool_call_id:
|
|
283
|
+
api.push({ role: 'tool', tool_call_id: id, content });
|
|
246
284
|
}
|
|
247
285
|
} else if (msg.tool_call_id && typeof msg.content === 'string') {
|
|
248
|
-
|
|
249
|
-
|
|
286
|
+
const id = msg.tool_call_id;
|
|
287
|
+
// ★ 过滤:tool-result 必须匹配已出现过的 assistant 中的 tool_call_id
|
|
288
|
+
if (!seenAssistantToolCallIds.has(id)) {
|
|
289
|
+
log.warn(`[ToolLoop] 过滤无效 tool-result(顺序/无匹配): tool_call_id=${id}`);
|
|
290
|
+
} else if (seenOutputToolCallIds.has(id)) {
|
|
291
|
+
log.warn(`[ToolLoop] 跳过重复 tool-result: tool_call_id=${id}`);
|
|
292
|
+
} else {
|
|
293
|
+
seenOutputToolCallIds.add(id);
|
|
294
|
+
api.push(msg);
|
|
295
|
+
}
|
|
250
296
|
}
|
|
251
297
|
}
|
|
252
298
|
}
|
|
@@ -448,6 +494,12 @@ class ToolLoop extends EventEmitter {
|
|
|
448
494
|
|
|
449
495
|
let response;
|
|
450
496
|
try {
|
|
497
|
+
// ★ DEBUG: 记录所有 tool 消息的 ID
|
|
498
|
+
for (const m of apiMessages) {
|
|
499
|
+
if (m.role === 'tool') {
|
|
500
|
+
log.warn(`[ToolLoop] [DEBUG] 发送 tool-result: tool_call_id=${m.tool_call_id}, content_len=${(m.content||'').length}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
451
503
|
response = await client.chat.completions.create({
|
|
452
504
|
model,
|
|
453
505
|
messages: apiMessages,
|
|
@@ -545,6 +597,13 @@ class ToolLoop extends EventEmitter {
|
|
|
545
597
|
|
|
546
598
|
let stream;
|
|
547
599
|
try {
|
|
600
|
+
// ★ DEBUG: 记录所有 tool 消息的 ID
|
|
601
|
+
for (const m of apiMessages) {
|
|
602
|
+
if (m.role === 'tool') {
|
|
603
|
+
log.warn(`[ToolLoop] [DEBUG] 发送 tool-result: tool_call_id=${m.tool_call_id}, content_len=${(m.content||'').length}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
548
607
|
stream = await client.chat.completions.create({
|
|
549
608
|
model,
|
|
550
609
|
messages: apiMessages,
|
package/src/common/constants.js
CHANGED
|
@@ -24,7 +24,7 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
|
|
|
24
24
|
const DEFAULT_TEMPERATURE = 0.3;
|
|
25
25
|
|
|
26
26
|
/** 默认 Max Steps(工具调用最大轮数) */
|
|
27
|
-
const DEFAULT_MAX_STEPS =
|
|
27
|
+
const DEFAULT_MAX_STEPS = 1000;
|
|
28
28
|
|
|
29
29
|
/** 需要禁用 temperature 的 thinking mode 模型列表 */
|
|
30
30
|
const THINKING_MODELS = [
|
|
@@ -51,10 +51,10 @@ const DEFAULT_MAX_MESSAGES_PER_SESSION = 1000;
|
|
|
51
51
|
// ============================================================================
|
|
52
52
|
|
|
53
53
|
/** 保留最近的 N 条消息 */
|
|
54
|
-
const KEEP_RECENT_MESSAGES =
|
|
54
|
+
const KEEP_RECENT_MESSAGES = 20;
|
|
55
55
|
|
|
56
56
|
/** 压缩消息数量阈值 */
|
|
57
|
-
const COMPRESSION_MESSAGE_THRESHOLD =
|
|
57
|
+
const COMPRESSION_MESSAGE_THRESHOLD = 120;
|
|
58
58
|
|
|
59
59
|
/** 智能压缩启用 */
|
|
60
60
|
const ENABLE_SMART_COMPRESS = true;
|
|
@@ -59,14 +59,13 @@ describe('ToolLoop._buildApiMessages 格式转换', () => {
|
|
|
59
59
|
expect(api[3].content).toBe('{"data":"ok"}');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it('tool
|
|
62
|
+
it('tool 消息无对应 assistant 时被过滤(防止 orphaned tool-result)', () => {
|
|
63
63
|
const api = loop._buildApiMessages([
|
|
64
64
|
{ role: 'tool', tool_call_id: 'c1', content: 'done' },
|
|
65
65
|
], 'system prompt');
|
|
66
|
-
//
|
|
66
|
+
// orphaned tool-result 被过滤,只有 system 消息
|
|
67
|
+
expect(api).toHaveLength(1);
|
|
67
68
|
expect(api[0].role).toBe('system');
|
|
68
|
-
expect(api[1].role).toBe('tool');
|
|
69
|
-
expect(api[1].tool_call_id).toBe('c1');
|
|
70
69
|
});
|
|
71
70
|
|
|
72
71
|
it('systemPrompt 为空时不加 system 消息', () => {
|