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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foliko",
3
- "version": "2.0.27",
3
+ "version": "2.0.29",
4
4
  "description": "简约的插件化 Agent 框架",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
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
- // logger.info(`BEFORE: messages=${messages.length}, tokens=${totalTokens}/${limit}`);
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
- // logger.info(
1035
- // `Context large (${messages.length} msgs, ${totalTokens}/${this._maxContextTokens} tokens), compressing...`
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.debug(`[_prepareSession] AFTER compress: messages=${messages.length}`);
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
- } else if (msg.role === 'tool' && Array.isArray(msg.content)) {
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' && Array.isArray(m.content)) {
1166
- for (const item of m.content) {
1167
- if (!item) continue;
1168
- if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId === oldId) {
1169
- item.toolCallId = newId;
1170
- return true;
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 = this._countMessagesTokens(inputMessages);
1366
- const tokenLimit = this._maxContextTokens * SMART_COMPRESS_TOKEN_RATIO;
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 = this._repairInvalidToolCallsInMessages(inputMessages);
1385
- this._repairAndPrefixHistoricalIds(inputMessages);
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 上一步加进来的)
@@ -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: part.toolCallId, content });
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
- // 已经是 OpenAI 格式(streaming 完成后的消息)
249
- api.push(msg);
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,
@@ -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 = 20;
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 = 30;
54
+ const KEEP_RECENT_MESSAGES = 20;
55
55
 
56
56
  /** 压缩消息数量阈值 */
57
- const COMPRESSION_MESSAGE_THRESHOLD = 200;
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 消息已经用 OpenAI 格式时保持', () => {
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
- // 已经符合 OpenAI 格式的直接保留
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 消息', () => {