foliko 2.0.28 → 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.28",
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
@@ -718,31 +718,44 @@ class AgentChatHandler extends EventEmitter {
718
718
  async *chatStream(message, options = {}) {
719
719
  const sessionId = options.sessionId || this.getDefaultSessionId();
720
720
  const framework = this.agent.framework;
721
+ // ★ 提到外层作用域,catch 中要回滚 userMessage 时需要访问
721
722
  let messageStore = null;
722
723
  let messages = null;
723
724
  let userMessage = null;
724
- let didRetryWithCompression = false;
725
725
 
726
- // 内部异步函数执行真正的流式处理,便于压缩重试时重启
727
- const doStream = async function* (self, sessionId, message, messageStore, messages, userMessage) {
728
- const tools = self._getToolMap();
726
+ try {
727
+ const prepared = await this._prepareSession(message, sessionId);
728
+ messageStore = prepared.messageStore;
729
+ messages = prepared.messages;
730
+ userMessage = prepared.userMessage;
731
+
732
+ if (!this._aiClient) {
733
+ throw new Error('AI 客户端未配置');
734
+ }
735
+ const systemPrompt = framework.prompts.build();
736
+ //await fs.promises.writeFile(`.${sessionId}_systemPrompt.md`, systemPrompt); // 调试用:保存系统提示词
737
+ const tools = this._getToolMap();
729
738
  const toolLoop = new ToolLoop({
730
739
  tools,
731
- maxSteps: self._maxSteps || 1000,
732
- prepareStep: self._createPrepareStep(),
733
- repairToolCall: self._repairToolCall.bind(self),
740
+ maxSteps: this._maxSteps || 1000,
741
+ prepareStep: this._createPrepareStep(),
742
+ repairToolCall: this._repairToolCall.bind(this),
734
743
  abortSignal: framework.getAbortSignal?.(),
735
- thinkingMode: self._thinkingMode,
744
+ thinkingMode: this._thinkingMode,
736
745
  });
737
746
 
747
+ // DeepSeek thinking mode: 收集 reasoning_content
748
+ const isThinking = this._thinkingMode;
738
749
  let reasoningContent = '';
750
+
739
751
  const stream = toolLoop.stream({
740
752
  messages,
741
- systemPrompt: framework.prompts.build(),
742
- model: self.model,
743
- client: self._aiClient,
744
- ...self.providerOptions,
753
+ systemPrompt,
754
+ model: this.model,
755
+ client: this._aiClient,
756
+ ...this.providerOptions,
745
757
  });
758
+ // 遍历流式响应
746
759
  let fullText = '';
747
760
  let usageRecv = {};
748
761
 
@@ -765,94 +778,67 @@ class AgentChatHandler extends EventEmitter {
765
778
  yield { type: 'usage', inputTokens: usageRecv.inputTokens, outputTokens: usageRecv.outputTokens };
766
779
  }
767
780
  }
768
-
769
781
  // 持久化用量
770
782
  messageStore.usage = usageRecv;
771
- if (self.agent && self.agent.framework) {
772
- self.agent.framework.emit('agent:usage', { sessionId, usage: usageRecv });
783
+
784
+ // 同时通过 framework 事件广播(绕开 queue 事件链路)
785
+ if (this.agent && this.agent.framework) {
786
+ this.agent.framework.emit('agent:usage', {
787
+ sessionId,
788
+ usage: usageRecv,
789
+ });
773
790
  }
774
791
 
792
+ // yield usage 数据,让 UI 层能直接获取
793
+ // (usage 已在 stream 循环中 yield,无需重复)
794
+
775
795
  const userMsg = messages.length > 0 ? messages[messages.length - 1] : null;
776
- self.emit('message', { content: fullText, sessionId, userMessage: userMsg });
796
+ this.emit('message', { content: fullText, sessionId: sessionId, userMessage: userMsg });
777
797
 
798
+ // ★ 修复:成功路径才持久化。AI 失败时若 finally 也 save,
799
+ // 会把刚 push 的 userMessage 写盘 → 下次请求 loadHistory 再次
800
+ // load 到,_prepareSession 再 push 一份 → 连续两条相同 user
801
+ // → AI SDK 抛 "Invalid prompt: ModelMessage[] schema"
778
802
  try {
779
803
  await messageStore.save();
780
804
  } catch (err) {
781
805
  logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
782
806
  }
783
- }.bind(null, this);
784
-
785
- // 压缩重试循环
786
- for (let attempt = 0; attempt <= 1; attempt++) {
787
- try {
788
- // 首次尝试前做 prepareSession;压缩重试时跳过
789
- if (attempt === 0) {
790
- const prepared = await this._prepareSession(message, sessionId);
791
- messageStore = prepared.messageStore;
792
- messages = prepared.messages;
793
- userMessage = prepared.userMessage;
794
- } else {
795
- // 重试前重新 build 系统提示词
796
- this._systemPrompt = this.agent._buildSystemPrompt();
797
- this._invalidateMessageTokensCache(messageStore);
798
- }
799
-
800
- if (!this._aiClient) {
801
- throw new Error('AI 客户端未配置');
802
- }
803
-
804
- // 执行流式生成
805
- for await (const chunk of doStream(this, sessionId, message, messageStore, messages, userMessage)) {
806
- yield chunk;
807
- }
808
- return; // 成功完成
809
- } catch (err) {
810
- // ★ 上下文超限:压缩后重试一次
811
- if (!didRetryWithCompression && this._isContextOverflowError(err)) {
812
- logger.warn(`[AgentChat] chatStream context overflow (attempt ${attempt + 1}), compressing and retrying...`);
813
- try {
814
- await this._compressContext(sessionId, messages, messageStore);
815
- this._invalidateMessageTokensCache(messageStore);
816
- logger.info(`[AgentChat] Compression done, retrying stream...`);
817
- didRetryWithCompression = true;
818
- continue; // 重试
819
- } catch (compressErr) {
820
- logger.error(`[AgentChat] Compression failed: ${compressErr.message}`);
807
+ } catch (err) {
808
+ // ★ 修复:失败时打印 InvalidPromptError 的 cause(含 Zod issue 路径),
809
+ // 方便定位是哪条消息、哪个字段不匹配 ModelMessage[] schema
810
+ logger.error('[AgentChat] chatStream error:', err.message);
811
+ if (err.cause) {
812
+ const cause = err.cause;
813
+ if (cause.issues) {
814
+ for (const issue of cause.issues.slice(0, 5)) {
815
+ logger.error(
816
+ `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
817
+ );
821
818
  }
819
+ } else {
820
+ logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
822
821
  }
822
+ }
823
823
 
824
- // 打印错误
825
- logger.error('[AgentChat] chatStream error:', err.message);
826
- if (err.cause) {
827
- const cause = err.cause;
828
- if (cause.issues) {
829
- for (const issue of cause.issues.slice(0, 5)) {
830
- logger.error(
831
- `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
832
- );
833
- }
834
- } else {
835
- logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
824
+ // ★ 修复:失败时 ROLLBACK 掉本轮 push 的 userMessage
825
+ try {
826
+ if (messages && userMessage) {
827
+ const last = messages[messages.length - 1];
828
+ if (last && last.role === 'user' && last === userMessage) {
829
+ messages.pop();
836
830
  }
837
831
  }
832
+ } catch { /* ignore */ }
838
833
 
839
- // 回滚 userMessage
840
- try {
841
- if (messages && userMessage) {
842
- const last = messages[messages.length - 1];
843
- if (last && last.role === 'user' && last === userMessage) {
844
- messages.pop();
845
- }
846
- }
847
- } catch { /* ignore */ }
834
+ // 统一错误处理:提取简洁消息并通过 yield 传递
835
+ const friendlyMessage = err?.message?.split('\n')[0] || '未知错误';
848
836
 
849
- const friendlyMessage = err?.message?.split('\n')[0] || '未知错误';
850
- if (err.name !== 'AbortError') {
851
- this.emit('message:error', { sessionId, error: friendlyMessage, originalError: err });
852
- }
853
- yield { type: 'error', error: friendlyMessage };
854
- return; // 失败返回
837
+ // 用户主动中断(framework.stop())不上报 message:error,避免与上游重复
838
+ if (err.name !== 'AbortError') {
839
+ this.emit('message:error', { sessionId, error: friendlyMessage, originalError: err });
855
840
  }
841
+ yield { type: 'error', error: friendlyMessage };
856
842
  }
857
843
  }
858
844
 
@@ -905,132 +891,109 @@ class AgentChatHandler extends EventEmitter {
905
891
  let messageStore = null;
906
892
  let messages = null;
907
893
  let userMessage = null;
908
- let didRetryWithCompression = false;
909
-
910
- // 最多压缩重试 1 次
911
- const maxCompressionRetries = 1;
912
-
913
- for (let attempt = 0; attempt <= maxCompressionRetries; attempt++) {
914
- try {
915
- // 首次尝试前做 prepareSession;压缩重试时跳过(已压缩过)
916
- if (attempt === 0) {
917
- const prepared = await this._prepareSession(message, sessionId);
918
- messageStore = prepared.messageStore;
919
- messages = prepared.messages;
920
- userMessage = prepared.userMessage;
921
- } else {
922
- // 压缩重试前重新 build 系统提示词(压缩后 messages 变了)
923
- this._systemPrompt = this.agent._buildSystemPrompt();
924
- this._invalidateMessageTokensCache(messageStore);
925
- }
926
894
 
927
- if (!this._aiClient) {
928
- throw new Error('AI 客户端未配置');
929
- }
930
- const systemPrompt = framework.prompts.build();
931
- const tools = this._getToolMap();
932
-
933
- // DeepSeek thinking mode: 处理参数
934
- const apiOptions = { ...this.providerOptions };
935
- if (this._thinkingMode) {
936
- delete apiOptions.temperature;
937
- }
895
+ try {
896
+ const prepared = await this._prepareSession(message, sessionId);
897
+ messageStore = prepared.messageStore;
898
+ messages = prepared.messages;
899
+ userMessage = prepared.userMessage;
938
900
 
939
- const toolLoop = new ToolLoop({
940
- tools,
941
- maxSteps: this._maxSteps || 1000,
942
- prepareStep: this._createPrepareStep(),
943
- repairToolCall: this._repairToolCall.bind(this),
944
- abortSignal: framework.getAbortSignal?.(),
945
- thinkingMode: this._thinkingMode,
946
- });
901
+ if (!this._aiClient) {
902
+ throw new Error('AI 客户端未配置');
903
+ }
904
+ const systemPrompt = framework.prompts.build();
905
+ const tools = this._getToolMap();
947
906
 
948
- const result = await toolLoop.generate({
949
- messages,
950
- systemPrompt,
951
- model: this.model,
952
- client: this._aiClient,
953
- ...apiOptions,
954
- });
955
- // result = { text, response: { messages }, steps, usage, reasoningText }
907
+ // DeepSeek thinking mode: 处理参数
908
+ const apiOptions = { ...this.providerOptions };
909
+ if (this._thinkingMode) {
910
+ delete apiOptions.temperature;
911
+ }
956
912
 
957
- if (result.usage && (result.usage.promptTokens > 0 || result.usage.completionTokens > 0)) {
958
- this._updateMessageStoreUsage(messageStore, result.usage);
959
- } else {
960
- const inputTokens = this._tokenCounter.countMessages(messages);
961
- const outputTokens = this._tokenCounter.countText(result.text || '');
962
- messageStore.usage = { promptTokens: inputTokens, completionTokens: outputTokens };
963
- }
913
+ const toolLoop = new ToolLoop({
914
+ tools,
915
+ maxSteps: this._maxSteps || 1000,
916
+ prepareStep: this._createPrepareStep(),
917
+ repairToolCall: this._repairToolCall.bind(this),
918
+ abortSignal: framework.getAbortSignal?.(),
919
+ thinkingMode: this._thinkingMode,
920
+ });
964
921
 
965
- // ToolLoop 已经直接在 messages 末尾添加了新消息,不需要再 push
966
- // 计算 original 的 user message
967
- const userMsg = result.text
968
- ? messages.slice(-1)[0]
969
- : messages[messages.length - 1];
970
- this.emit('message', { content: result.text, sessionId: sessionId, userMessage: userMsg });
922
+ const result = await toolLoop.generate({
923
+ messages,
924
+ systemPrompt,
925
+ model: this.model,
926
+ client: this._aiClient,
927
+ ...apiOptions,
928
+ });
929
+ // result = { text, response: { messages }, steps, usage, reasoningText }
971
930
 
972
- // 修复:AI 调用成功后才持久化
973
- try {
974
- await messageStore.save();
975
- } catch (err) {
976
- logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
977
- }
931
+ if (result.usage && (result.usage.promptTokens > 0 || result.usage.completionTokens > 0)) {
932
+ this._updateMessageStoreUsage(messageStore, result.usage);
933
+ } else {
934
+ const inputTokens = this._tokenCounter.countMessages(messages);
935
+ const outputTokens = this._tokenCounter.countText(result.text || '');
936
+ messageStore.usage = { promptTokens: inputTokens, completionTokens: outputTokens };
937
+ }
978
938
 
979
- return {
980
- success: true,
981
- message: cleanResponse(result.text || ''),
982
- stepCount: result.stepCount || 1,
983
- };
939
+ // ToolLoop 已经直接在 messages 末尾添加了新消息,不需要再 push
940
+ // 计算 original 的 user message
941
+ const userMsg = result.text
942
+ ? messages.slice(-1)[0]
943
+ : messages[messages.length - 1];
944
+ this.emit('message', { content: result.text, sessionId: sessionId, userMessage: userMsg });
945
+
946
+ // ★ 修复:AI 调用成功后才持久化(之前 finally 总是 save,
947
+ // 会把刚 push 的 userMessage 写盘;下次请求 loadHistory 再次 load 到,
948
+ // _prepareSession 又 push 一份 → 连续两条相同 user 消息 →
949
+ // AI SDK 抛 "Invalid prompt: ModelMessage[] schema")
950
+ try {
951
+ await messageStore.save();
984
952
  } catch (err) {
985
- // 上下文超限:压缩后重试一次
986
- if (!didRetryWithCompression && this._isContextOverflowError(err)) {
987
- logger.warn(`[AgentChat] Context overflow (attempt ${attempt + 1}), compressing and retrying...`);
988
- try {
989
- // 强制压缩(忽略阈值检查)
990
- await this._compressContext(sessionId, messages, messageStore);
991
- this._invalidateMessageTokensCache(messageStore);
992
- logger.info(`[AgentChat] Compression done, retrying...`);
993
- didRetryWithCompression = true;
994
- continue; // 重试
995
- } catch (compressErr) {
996
- logger.error(`[AgentChat] Compression failed: ${compressErr.message}`);
997
- // 压缩失败,继续走下方错误处理
953
+ logger.error(`[${sessionId}] Failed to save message store: ${err.message}`);
954
+ }
955
+
956
+ return {
957
+ success: true,
958
+ message: cleanResponse(result.text || ''),
959
+ stepCount: result.stepCount || 1,
960
+ };
961
+ } catch (err) {
962
+ // ★ 修复:失败时打印 InvalidPromptError 的 cause(含 Zod issue 路径),
963
+ // 方便定位是哪条消息、哪个字段不匹配 ModelMessage[] schema
964
+ logger.error('[AgentChat] _processMessage error:', err.message);
965
+ if (err.cause) {
966
+ const cause = err.cause;
967
+ if (cause.issues) {
968
+ // ZodError:逐条打印
969
+ for (const issue of cause.issues.slice(0, 5)) {
970
+ logger.error(
971
+ `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
972
+ );
998
973
  }
974
+ } else {
975
+ logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
999
976
  }
977
+ }
978
+ const errorMsg = err.message || String(err);
1000
979
 
1001
- // 打印错误信息
1002
- logger.error('[AgentChat] _processMessage error:', err.message);
1003
- if (err.cause) {
1004
- const cause = err.cause;
1005
- if (cause.issues) {
1006
- for (const issue of cause.issues.slice(0, 5)) {
1007
- logger.error(
1008
- `[AgentChat] schema issue: path=${JSON.stringify(issue.path)} code=${issue.code} message=${issue.message}`
1009
- );
1010
- }
1011
- } else {
1012
- logger.error(`[AgentChat] error cause: ${cause.message || String(cause)}`);
980
+ // ★ 修复:失败时 ROLLBACK 掉本轮 push 的 userMessage,
981
+ // 避免下次请求把它从 jsonl load 出来再 push 一遍
982
+ try {
983
+ if (messages && userMessage) {
984
+ const last = messages[messages.length - 1];
985
+ if (last && last.role === 'user' && last === userMessage) {
986
+ messages.pop();
1013
987
  }
1014
988
  }
1015
- const errorMsg = err.message || String(err);
989
+ } catch { /* ignore */ }
1016
990
 
1017
- // ★ 失败时 ROLLBACK 掉本轮 push 的 userMessage
1018
- try {
1019
- if (messages && userMessage) {
1020
- const last = messages[messages.length - 1];
1021
- if (last && last.role === 'user' && last === userMessage) {
1022
- messages.pop();
1023
- }
1024
- }
1025
- } catch { /* ignore */ }
1026
-
1027
- return {
1028
- success: false,
1029
- message: 'AI 服务暂时不可用,请稍后重试。',
1030
- error: errorMsg,
1031
- stepCount: 0,
1032
- };
1033
- }
991
+ return {
992
+ success: false,
993
+ message: 'AI 服务暂时不可用,请稍后重试。',
994
+ error: errorMsg,
995
+ stepCount: 0,
996
+ };
1034
997
  }
1035
998
  }
1036
999
 
@@ -1154,6 +1117,8 @@ class AgentChatHandler extends EventEmitter {
1154
1117
 
1155
1118
  for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
1156
1119
  const msg = messages[msgIdx];
1120
+
1121
+ // ★ assistant 消息:content 数组格式的 tool-call
1157
1122
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
1158
1123
  for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
1159
1124
  const item = msg.content[itemIdx];
@@ -1161,31 +1126,58 @@ class AgentChatHandler extends EventEmitter {
1161
1126
  if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
1162
1127
  const id = item.toolCallId;
1163
1128
  if (firstSeen.has(id)) {
1164
- // 冲突:生成唯一新 ID(不依赖全局计数器,避免跨调用泄露)
1165
1129
  const newId = `${id}_dup${msgIdx}_${itemIdx}`;
1166
1130
  item.toolCallId = newId;
1167
- // 同步修改紧随其后的 tool-result
1168
1131
  const renamed = this._renameFollowingToolResult(messages, msgIdx, id, newId);
1169
1132
  fixedCount += 1 + (renamed ? 1 : 0);
1170
- // 不更新 firstSeen(原始 ID 已被第一次声明占用)
1171
1133
  } else {
1172
1134
  firstSeen.set(id, { msgIdx, itemIdx, kind: 'call' });
1173
1135
  }
1174
1136
  }
1175
1137
  }
1176
- } 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)) {
1177
1161
  for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
1178
1162
  const item = msg.content[itemIdx];
1179
1163
  if (!item) continue;
1180
1164
  if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId) {
1181
1165
  const id = item.toolCallId;
1182
- // 记录 tool-result 第一次出现(不与 call 比较,因为可能已被上面的分支处理过)
1183
1166
  if (!firstSeen.has(id)) {
1184
1167
  firstSeen.set(id, { msgIdx, itemIdx, kind: 'result' });
1185
1168
  }
1186
1169
  }
1187
1170
  }
1188
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
+ }
1189
1181
  }
1190
1182
  return fixedCount;
1191
1183
  }
@@ -1199,14 +1191,22 @@ class AgentChatHandler extends EventEmitter {
1199
1191
  for (let i = afterMsgIdx + 1; i < messages.length; i++) {
1200
1192
  const m = messages[i];
1201
1193
  if (m.role === 'assistant') return false;
1202
- if (m.role === 'tool' && Array.isArray(m.content)) {
1203
- for (const item of m.content) {
1204
- if (!item) continue;
1205
- if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId === oldId) {
1206
- item.toolCallId = newId;
1207
- 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
+ }
1208
1203
  }
1209
1204
  }
1205
+ // OpenAI 字符串格式
1206
+ if (m.tool_call_id === oldId) {
1207
+ m.tool_call_id = newId;
1208
+ return true;
1209
+ }
1210
1210
  }
1211
1211
  }
1212
1212
  return false;
@@ -1397,10 +1397,11 @@ class AgentChatHandler extends EventEmitter {
1397
1397
  * @private
1398
1398
  */
1399
1399
  _createPrepareStep() {
1400
+ const self = this;
1400
1401
  return async ({ stepNumber, messages: inputMessages }) => {
1401
1402
  try {
1402
- const tokenCount = this._countMessagesTokens(inputMessages);
1403
- const tokenLimit = this._maxContextTokens * SMART_COMPRESS_TOKEN_RATIO;
1403
+ const tokenCount = self._countMessagesTokens(inputMessages);
1404
+ const tokenLimit = self._maxContextTokens * SMART_COMPRESS_TOKEN_RATIO;
1404
1405
 
1405
1406
  // 超过限制时,保留三分之一的消息
1406
1407
  if (tokenCount > tokenLimit) {
@@ -1418,8 +1419,8 @@ class AgentChatHandler extends EventEmitter {
1418
1419
  // 所以不再需要 _stripStaleToolCalls 提前清理
1419
1420
 
1420
1421
  // 单次遍历完成:修复 invalid tool-call + 修复错误 text + 重映射历史 ID
1421
- const repairedCount = this._repairInvalidToolCallsInMessages(inputMessages);
1422
- this._repairAndPrefixHistoricalIds(inputMessages);
1422
+ const repairedCount = self._repairInvalidToolCallsInMessages(inputMessages);
1423
+ self._repairAndPrefixHistoricalIds(inputMessages);
1423
1424
 
1424
1425
  // ★ 防御性 sanitize:确保所有 message 符合 AI SDK ModelMessage[] schema
1425
1426
  // 每次 LLM step 前都跑一次(messages 数组里可能有 AI SDK 上一步加进来的)
@@ -1537,33 +1538,6 @@ class AgentChatHandler extends EventEmitter {
1537
1538
  return this._tokenCounter.countTools(this._toolExecutor.getAllTools());
1538
1539
  }
1539
1540
 
1540
- /**
1541
- * 判断是否为上下文超限错误
1542
- * @param {Error} err
1543
- * @returns {boolean}
1544
- * @private
1545
- */
1546
- _isContextOverflowError(err) {
1547
- if (!err) return false;
1548
- const msg = err.message || '';
1549
- const lowerMsg = msg.toLowerCase();
1550
- // DeepSeek / OpenAI 兼容的上下文超限错误关键词
1551
- const keywords = [
1552
- 'context_length',
1553
- 'context length',
1554
- 'maximum context',
1555
- 'max context',
1556
- 'too many tokens',
1557
- 'token limit',
1558
- 'tokens exceed',
1559
- 'exceed maximum',
1560
- 'maximum tokens',
1561
- 'context_exceeded',
1562
- 'context overflow',
1563
- ];
1564
- return keywords.some(k => lowerMsg.includes(k));
1565
- }
1566
-
1567
1541
  /**
1568
1542
  * 获取缓存的工具 token 数(工具列表不变时复用)
1569
1543
  * @private
@@ -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 = [
@@ -54,7 +54,7 @@ const DEFAULT_MAX_MESSAGES_PER_SESSION = 1000;
54
54
  const KEEP_RECENT_MESSAGES = 20;
55
55
 
56
56
  /** 压缩消息数量阈值 */
57
- const COMPRESSION_MESSAGE_THRESHOLD = 80;
57
+ const COMPRESSION_MESSAGE_THRESHOLD = 120;
58
58
 
59
59
  /** 智能压缩启用 */
60
60
  const ENABLE_SMART_COMPRESS = true;
package/src/llm/tokens.js CHANGED
@@ -5,70 +5,7 @@
5
5
  * Ported from pi's compaction/compaction.ts with enhancements
6
6
  */
7
7
 
8
- // CJK 字符 Unicode 范围
9
- const CJK_RANGES = [
10
- [0x4e00, 0x9fff], // CJK Unified Ideographs (中文简体/繁体)
11
- [0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
12
- [0x20000, 0x2a6df], // CJK Unified Ideographs Extension B
13
- [0x3040, 0x309f], // Hiragana (日文平假名)
14
- [0x30a0, 0x30ff], // Katakana (日文片假名)
15
- [0xac00, 0xd7af], // Hangul Syllables (韩文)
16
- [0x1100, 0x11ff], // Hangul Jamo
17
- [0x3000, 0x303f], // CJK Symbols and Punctuation
18
- [0xff00, 0xffef], // Halfwidth and Fullwidth Forms
19
- ];
20
-
21
- function isCJKChar(char) {
22
- const code = char.codePointAt(0);
23
- if (!code) return false;
24
- for (const [start, end] of CJK_RANGES) {
25
- if (code >= start && code <= end) return true;
26
- }
27
- return false;
28
- }
29
-
30
- /**
31
- * 改进的 Token 估算
32
- * 根据文本语言类型采用不同估算系数
33
- *
34
- * @param {string} text - 输入文本
35
- * @returns {number} 估算的 token 数
36
- */
37
- function encode(text) {
38
- if (!text || typeof text !== 'string') return 0;
39
-
40
- let cjkChars = 0;
41
- let asciiChars = 0;
42
- let otherChars = 0;
43
-
44
- // 逐字符统计
45
- for (const char of text) {
46
- if (isCJKChar(char)) {
47
- cjkChars++;
48
- } else if (char.charCodeAt(0) < 128) {
49
- // ASCII 字符 (英文、数字、标点)
50
- asciiChars++;
51
- } else {
52
- otherChars++;
53
- }
54
- }
55
-
56
- // 更精确的估算系数 (基于 tiktoken 统计数据):
57
- // - ASCII/英文: ~0.25 tokens/char (4 chars per token)
58
- // - 中文: ~0.5 tokens/char (2 chars per token, 中文字符是 3 字节 UTF-8 但 tokenizer 输出约 0.5)
59
- // - 其他 (emoji, 特殊符号): ~0.25 tokens/char
60
- const asciiTokens = asciiChars * 0.25;
61
- const cjkTokens = cjkChars * 0.5;
62
- const otherTokens = otherChars * 0.25;
63
-
64
- return Math.ceil(asciiTokens + cjkTokens + otherTokens);
65
- }
66
-
67
- /**
68
- * 旧版 encode(兼容)
69
- * @deprecated 使用新版 encode 替代
70
- */
71
- function encodeOld(text, bytesPerToken = 4) {
8
+ function encode(text, bytesPerToken = 4) {
72
9
  if (!text) return 0;
73
10
  const bytes = Buffer.byteLength(String(text), 'utf8');
74
11
  return Math.ceil(bytes / bytesPerToken);
@@ -88,66 +25,64 @@ function safeJsonStringify(value) {
88
25
  }
89
26
  }
90
27
 
91
- /**
92
- * 估算单条消息的 token 数(使用改进的编码方法)
93
- */
94
28
  function estimateTokens(message) {
95
- if (!message) return 0;
96
-
97
- let total = 4; // message overhead
29
+ let chars = 0;
98
30
 
99
31
  switch (message.role) {
100
- case 'user':
101
- case 'assistant':
102
- case 'custom':
103
- case 'toolResult': {
32
+ case 'user': {
104
33
  const content = message.content;
105
34
  if (typeof content === 'string') {
106
- total += encode(content);
35
+ chars = content.length;
107
36
  } else if (Array.isArray(content)) {
108
37
  for (const block of content) {
109
38
  if (block.type === 'text' && block.text) {
110
- total += encode(block.text);
111
- } else if (block.type === 'tool-call' || block.type === 'toolCall') {
112
- total += encode(block.name || '') + encode(safeJsonStringify(block.arguments || {}));
113
- } else if (block.type === 'tool-result' || block.type === 'toolResult') {
114
- total += encode(typeof block.content === 'string' ? block.content : safeJsonStringify(block.content));
115
- } else if (block.type === 'image') {
116
- total += 85;
117
- } else if (block.type === 'thinking') {
118
- total += encode(block.thinking || '');
39
+ chars += block.text.length;
119
40
  }
120
41
  }
121
42
  }
122
- break;
43
+ return Math.ceil(chars / 4);
44
+ }
45
+ case 'assistant': {
46
+ const assistant = message;
47
+ for (const block of assistant.content || []) {
48
+ if (block.type === 'text') {
49
+ chars += block.text.length;
50
+ } else if (block.type === 'thinking') {
51
+ chars += block.thinking.length;
52
+ } else if (block.type === 'toolCall') {
53
+ chars += block.name.length + safeJsonStringify(block.arguments).length;
54
+ }
55
+ }
56
+ return Math.ceil(chars / 4);
57
+ }
58
+ case 'custom':
59
+ case 'toolResult': {
60
+ if (typeof message.content === 'string') {
61
+ chars = message.content.length;
62
+ } else {
63
+ for (const block of message.content || []) {
64
+ if (block.type === 'text' && block.text) {
65
+ chars += block.text.length;
66
+ }
67
+ if (block.type === 'image') {
68
+ chars += 4800;
69
+ }
70
+ }
71
+ }
72
+ return Math.ceil(chars / 4);
123
73
  }
124
74
  case 'bashExecution': {
125
- total += encode(message.command || '');
126
- total += encode(message.output || '');
127
- break;
75
+ chars = message.command.length + (message.output?.length || 0);
76
+ return Math.ceil(chars / 4);
128
77
  }
129
78
  case 'branchSummary':
130
79
  case 'compactionSummary': {
131
- total += encode(message.summary || '');
132
- break;
133
- }
134
- }
135
-
136
- // tool_calls overhead
137
- if (message.tool_calls && Array.isArray(message.tool_calls)) {
138
- total += 15; // tool_calls overhead
139
- for (const tc of message.tool_calls) {
140
- if (tc.function) {
141
- total += encode(tc.function.name || '') + encode(safeJsonStringify(tc.function.arguments || {}));
142
- }
80
+ chars = message.summary.length;
81
+ return Math.ceil(chars / 4);
143
82
  }
144
83
  }
145
84
 
146
- if (message.tool_call_id) {
147
- total += 15;
148
- }
149
-
150
- return total;
85
+ return 0;
151
86
  }
152
87
 
153
88
  function calculateContextTokens(usage) {
@@ -222,8 +157,8 @@ class TokenCounter {
222
157
  this.toolSchema = config.toolSchema || null;
223
158
  }
224
159
 
225
- countText(text) {
226
- return encode(text);
160
+ countText(text, bytesPerToken = 4) {
161
+ return encode(text, bytesPerToken);
227
162
  }
228
163
 
229
164
  countMessages(messages) {
@@ -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 消息', () => {