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 +1 -1
- package/src/agent/chat.js +209 -235
- package/src/agent/tool-loop.js +62 -3
- package/src/common/constants.js +2 -2
- package/src/llm/tokens.js +42 -107
- package/tests/core/tool-loop.test.js +3 -4
package/package.json
CHANGED
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
|
-
|
|
728
|
-
|
|
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:
|
|
732
|
-
prepareStep:
|
|
733
|
-
repairToolCall:
|
|
740
|
+
maxSteps: this._maxSteps || 1000,
|
|
741
|
+
prepareStep: this._createPrepareStep(),
|
|
742
|
+
repairToolCall: this._repairToolCall.bind(this),
|
|
734
743
|
abortSignal: framework.getAbortSignal?.(),
|
|
735
|
-
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
|
|
742
|
-
model:
|
|
743
|
-
client:
|
|
744
|
-
...
|
|
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
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
if (
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
826
|
-
if (
|
|
827
|
-
const
|
|
828
|
-
if (
|
|
829
|
-
|
|
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
|
-
|
|
840
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
989
|
+
} catch { /* ignore */ }
|
|
1016
990
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
}
|
|
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'
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
item
|
|
1207
|
-
|
|
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 =
|
|
1403
|
-
const tokenLimit =
|
|
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 =
|
|
1422
|
-
|
|
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
|
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 = [
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
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 消息', () => {
|