foliko 2.0.29 → 2.0.30
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 +19 -54
- package/src/agent/tool-loop.js +3 -62
- package/src/common/constants.js +2 -2
- package/src/llm/tokens.js +43 -5
- package/src/session/session.js +32 -4
- package/src/storage/jsonl.js +5 -3
- package/tests/core/tool-loop.test.js +4 -3
package/package.json
CHANGED
package/src/agent/chat.js
CHANGED
|
@@ -112,6 +112,9 @@ class AgentChatHandler extends EventEmitter {
|
|
|
112
112
|
this._toolsTokensCacheVersion = 0;
|
|
113
113
|
this._systemPromptTokensCache = null;
|
|
114
114
|
this._systemPromptTokensCacheKey = null;
|
|
115
|
+
// 消息 token 缓存(避免重复计算同一条消息)
|
|
116
|
+
this._messageTokenCache = new Map();
|
|
117
|
+
this._messageTokenCacheMaxSize = 200;
|
|
115
118
|
|
|
116
119
|
// ChatQueueManager: 队列管理
|
|
117
120
|
this.queueManager = new ChatQueueManager({
|
|
@@ -1028,7 +1031,7 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1028
1031
|
const totalTokens = cachedMsgTokens + toolsTokens + systemPromptTokens;
|
|
1029
1032
|
const limit = this._maxContextTokens * SIMPLE_COMPRESS_TOKEN_RATIO;
|
|
1030
1033
|
|
|
1031
|
-
|
|
1034
|
+
//gger.info(`[_prepareSession] BEFORE: messages=${messages.length}, tokens=${totalTokens}/${limit}, threshold=${this._compressionMessageThreshold}`);
|
|
1032
1035
|
|
|
1033
1036
|
if (totalTokens > limit || messages.length > this._compressionMessageThreshold) {
|
|
1034
1037
|
logger.info(
|
|
@@ -1117,8 +1120,6 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1117
1120
|
|
|
1118
1121
|
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
|
|
1119
1122
|
const msg = messages[msgIdx];
|
|
1120
|
-
|
|
1121
|
-
// ★ assistant 消息:content 数组格式的 tool-call
|
|
1122
1123
|
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
1123
1124
|
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1124
1125
|
const item = msg.content[itemIdx];
|
|
@@ -1126,58 +1127,31 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1126
1127
|
if ((item.type === 'tool-call' || item.type === 'tool-use') && item.toolCallId) {
|
|
1127
1128
|
const id = item.toolCallId;
|
|
1128
1129
|
if (firstSeen.has(id)) {
|
|
1130
|
+
// 冲突:生成唯一新 ID(不依赖全局计数器,避免跨调用泄露)
|
|
1129
1131
|
const newId = `${id}_dup${msgIdx}_${itemIdx}`;
|
|
1130
1132
|
item.toolCallId = newId;
|
|
1133
|
+
// 同步修改紧随其后的 tool-result
|
|
1131
1134
|
const renamed = this._renameFollowingToolResult(messages, msgIdx, id, newId);
|
|
1132
1135
|
fixedCount += 1 + (renamed ? 1 : 0);
|
|
1136
|
+
// 不更新 firstSeen(原始 ID 已被第一次声明占用)
|
|
1133
1137
|
} else {
|
|
1134
1138
|
firstSeen.set(id, { msgIdx, itemIdx, kind: 'call' });
|
|
1135
1139
|
}
|
|
1136
1140
|
}
|
|
1137
1141
|
}
|
|
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)) {
|
|
1142
|
+
} else if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
1161
1143
|
for (let itemIdx = 0; itemIdx < msg.content.length; itemIdx++) {
|
|
1162
1144
|
const item = msg.content[itemIdx];
|
|
1163
1145
|
if (!item) continue;
|
|
1164
1146
|
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId) {
|
|
1165
1147
|
const id = item.toolCallId;
|
|
1148
|
+
// 记录 tool-result 第一次出现(不与 call 比较,因为可能已被上面的分支处理过)
|
|
1166
1149
|
if (!firstSeen.has(id)) {
|
|
1167
1150
|
firstSeen.set(id, { msgIdx, itemIdx, kind: 'result' });
|
|
1168
1151
|
}
|
|
1169
1152
|
}
|
|
1170
1153
|
}
|
|
1171
1154
|
}
|
|
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
|
-
}
|
|
1181
1155
|
}
|
|
1182
1156
|
return fixedCount;
|
|
1183
1157
|
}
|
|
@@ -1191,22 +1165,14 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1191
1165
|
for (let i = afterMsgIdx + 1; i < messages.length; i++) {
|
|
1192
1166
|
const m = messages[i];
|
|
1193
1167
|
if (m.role === 'assistant') return false;
|
|
1194
|
-
if (m.role === 'tool') {
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
item.toolCallId = newId;
|
|
1201
|
-
return true;
|
|
1202
|
-
}
|
|
1168
|
+
if (m.role === 'tool' && Array.isArray(m.content)) {
|
|
1169
|
+
for (const item of m.content) {
|
|
1170
|
+
if (!item) continue;
|
|
1171
|
+
if ((item.type === 'tool-result' || item.type === 'tool_result') && item.toolCallId === oldId) {
|
|
1172
|
+
item.toolCallId = newId;
|
|
1173
|
+
return true;
|
|
1203
1174
|
}
|
|
1204
1175
|
}
|
|
1205
|
-
// OpenAI 字符串格式
|
|
1206
|
-
if (m.tool_call_id === oldId) {
|
|
1207
|
-
m.tool_call_id = newId;
|
|
1208
|
-
return true;
|
|
1209
|
-
}
|
|
1210
1176
|
}
|
|
1211
1177
|
}
|
|
1212
1178
|
return false;
|
|
@@ -1397,11 +1363,10 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1397
1363
|
* @private
|
|
1398
1364
|
*/
|
|
1399
1365
|
_createPrepareStep() {
|
|
1400
|
-
const self = this;
|
|
1401
1366
|
return async ({ stepNumber, messages: inputMessages }) => {
|
|
1402
1367
|
try {
|
|
1403
|
-
const tokenCount =
|
|
1404
|
-
const tokenLimit =
|
|
1368
|
+
const tokenCount = this._countMessagesTokens(inputMessages);
|
|
1369
|
+
const tokenLimit = this._maxContextTokens * SMART_COMPRESS_TOKEN_RATIO;
|
|
1405
1370
|
|
|
1406
1371
|
// 超过限制时,保留三分之一的消息
|
|
1407
1372
|
if (tokenCount > tokenLimit) {
|
|
@@ -1419,8 +1384,8 @@ class AgentChatHandler extends EventEmitter {
|
|
|
1419
1384
|
// 所以不再需要 _stripStaleToolCalls 提前清理
|
|
1420
1385
|
|
|
1421
1386
|
// 单次遍历完成:修复 invalid tool-call + 修复错误 text + 重映射历史 ID
|
|
1422
|
-
const repairedCount =
|
|
1423
|
-
|
|
1387
|
+
const repairedCount = this._repairInvalidToolCallsInMessages(inputMessages);
|
|
1388
|
+
this._repairAndPrefixHistoricalIds(inputMessages);
|
|
1424
1389
|
|
|
1425
1390
|
// ★ 防御性 sanitize:确保所有 message 符合 AI SDK ModelMessage[] schema
|
|
1426
1391
|
// 每次 LLM step 前都跑一次(messages 数组里可能有 AI SDK 上一步加进来的)
|
package/src/agent/tool-loop.js
CHANGED
|
@@ -183,13 +183,6 @@ 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
|
-
|
|
193
186
|
for (const msg of messages) {
|
|
194
187
|
if (msg.role === 'system') {
|
|
195
188
|
api.push({ role: 'system', content: typeof msg.content === 'string' ? msg.content : '' });
|
|
@@ -201,25 +194,6 @@ class ToolLoop extends EventEmitter {
|
|
|
201
194
|
api.push({ role: 'user', content: text || '' });
|
|
202
195
|
}
|
|
203
196
|
} 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
|
-
|
|
223
197
|
// ★ 检测是否有 reasoning 内容(thinking mode 必须携带)
|
|
224
198
|
const hasReasoningInContent = Array.isArray(msg.content) && msg.content.some(p => p && p.type === 'reasoning');
|
|
225
199
|
const hasReasoningTopLevel = msg.reasoning_content || msg.reasoning;
|
|
@@ -263,36 +237,16 @@ class ToolLoop extends EventEmitter {
|
|
|
263
237
|
if (Array.isArray(msg.content)) {
|
|
264
238
|
for (const part of msg.content) {
|
|
265
239
|
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);
|
|
278
240
|
const output = part.output;
|
|
279
241
|
let content;
|
|
280
242
|
if (output?.type === 'text') content = output.value;
|
|
281
243
|
else if (output?.type === 'json') content = JSON.stringify(output.value);
|
|
282
244
|
else content = typeof output === 'string' ? output : JSON.stringify(output);
|
|
283
|
-
api.push({ role: 'tool', tool_call_id:
|
|
245
|
+
api.push({ role: 'tool', tool_call_id: part.toolCallId, content });
|
|
284
246
|
}
|
|
285
247
|
} else if (msg.tool_call_id && typeof msg.content === 'string') {
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
}
|
|
248
|
+
// 已经是 OpenAI 格式(streaming 完成后的消息)
|
|
249
|
+
api.push(msg);
|
|
296
250
|
}
|
|
297
251
|
}
|
|
298
252
|
}
|
|
@@ -494,12 +448,6 @@ class ToolLoop extends EventEmitter {
|
|
|
494
448
|
|
|
495
449
|
let response;
|
|
496
450
|
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
|
-
}
|
|
503
451
|
response = await client.chat.completions.create({
|
|
504
452
|
model,
|
|
505
453
|
messages: apiMessages,
|
|
@@ -597,13 +545,6 @@ class ToolLoop extends EventEmitter {
|
|
|
597
545
|
|
|
598
546
|
let stream;
|
|
599
547
|
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
|
-
|
|
607
548
|
stream = await client.chat.completions.create({
|
|
608
549
|
model,
|
|
609
550
|
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 = 20;
|
|
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 = 150;
|
|
58
58
|
|
|
59
59
|
/** 智能压缩启用 */
|
|
60
60
|
const ENABLE_SMART_COMPRESS = true;
|
package/src/llm/tokens.js
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* Ported from pi's compaction/compaction.ts with enhancements
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
// 全局 token 缓存(避免重复计算相同内容)
|
|
9
|
+
const _tokenCache = new Map();
|
|
10
|
+
const _TOKEN_CACHE_MAX_SIZE = 500;
|
|
11
|
+
|
|
12
|
+
function _getCacheKey(message) {
|
|
13
|
+
if (message.id) return message.id;
|
|
14
|
+
if (message.role === 'tool') return `tool:${message.tool_call_id || message.toolName}`;
|
|
15
|
+
return `${message.role}:${JSON.stringify(message.content || '').slice(0, 100)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
function encode(text, bytesPerToken = 4) {
|
|
9
19
|
if (!text) return 0;
|
|
10
20
|
const bytes = Buffer.byteLength(String(text), 'utf8');
|
|
@@ -26,6 +36,12 @@ function safeJsonStringify(value) {
|
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
function estimateTokens(message) {
|
|
39
|
+
// 性能优化:使用缓存避免重复计算
|
|
40
|
+
const cacheKey = _getCacheKey(message);
|
|
41
|
+
if (_tokenCache.has(cacheKey)) {
|
|
42
|
+
return _tokenCache.get(cacheKey);
|
|
43
|
+
}
|
|
44
|
+
|
|
29
45
|
let chars = 0;
|
|
30
46
|
|
|
31
47
|
switch (message.role) {
|
|
@@ -40,7 +56,9 @@ function estimateTokens(message) {
|
|
|
40
56
|
}
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
|
-
|
|
59
|
+
const result = Math.ceil(chars / 4);
|
|
60
|
+
_updateCache(cacheKey, result);
|
|
61
|
+
return result;
|
|
44
62
|
}
|
|
45
63
|
case 'assistant': {
|
|
46
64
|
const assistant = message;
|
|
@@ -53,7 +71,9 @@ function estimateTokens(message) {
|
|
|
53
71
|
chars += block.name.length + safeJsonStringify(block.arguments).length;
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
|
-
|
|
74
|
+
const result = Math.ceil(chars / 4);
|
|
75
|
+
_updateCache(cacheKey, result);
|
|
76
|
+
return result;
|
|
57
77
|
}
|
|
58
78
|
case 'custom':
|
|
59
79
|
case 'toolResult': {
|
|
@@ -69,22 +89,40 @@ function estimateTokens(message) {
|
|
|
69
89
|
}
|
|
70
90
|
}
|
|
71
91
|
}
|
|
72
|
-
|
|
92
|
+
const result = Math.ceil(chars / 4);
|
|
93
|
+
_updateCache(cacheKey, result);
|
|
94
|
+
return result;
|
|
73
95
|
}
|
|
74
96
|
case 'bashExecution': {
|
|
75
97
|
chars = message.command.length + (message.output?.length || 0);
|
|
76
|
-
|
|
98
|
+
const result = Math.ceil(chars / 4);
|
|
99
|
+
_updateCache(cacheKey, result);
|
|
100
|
+
return result;
|
|
77
101
|
}
|
|
78
102
|
case 'branchSummary':
|
|
79
103
|
case 'compactionSummary': {
|
|
80
104
|
chars = message.summary.length;
|
|
81
|
-
|
|
105
|
+
const result = Math.ceil(chars / 4);
|
|
106
|
+
_updateCache(cacheKey, result);
|
|
107
|
+
return result;
|
|
82
108
|
}
|
|
83
109
|
}
|
|
84
110
|
|
|
85
111
|
return 0;
|
|
86
112
|
}
|
|
87
113
|
|
|
114
|
+
function _updateCache(key, value) {
|
|
115
|
+
if (_tokenCache.size >= _TOKEN_CACHE_MAX_SIZE) {
|
|
116
|
+
// 删除最旧的 20%
|
|
117
|
+
const keysToDelete = Math.floor(_TOKEN_CACHE_MAX_SIZE * 0.2);
|
|
118
|
+
const iterator = _tokenCache.keys();
|
|
119
|
+
for (let i = 0; i < keysToDelete; i++) {
|
|
120
|
+
_tokenCache.delete(iterator.next().value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
_tokenCache.set(key, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
88
126
|
function calculateContextTokens(usage) {
|
|
89
127
|
return usage.totalTokens || (usage.input || 0) + (usage.output || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0);
|
|
90
128
|
}
|
package/src/session/session.js
CHANGED
|
@@ -123,6 +123,11 @@ class SessionManager {
|
|
|
123
123
|
_loadEntriesFromFile(filePath) {
|
|
124
124
|
if (!fs.existsSync(filePath)) return [];
|
|
125
125
|
|
|
126
|
+
// 性能优化:使用流式读取大文件,减少内存占用
|
|
127
|
+
const stats = fs.statSync(filePath);
|
|
128
|
+
if (stats.size > 5 * 1024 * 1024) { // > 5MB 使用流
|
|
129
|
+
return this._loadEntriesFromFileStream(filePath);
|
|
130
|
+
}
|
|
126
131
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
127
132
|
const entries = [];
|
|
128
133
|
const lines = content.trim().split('\n');
|
|
@@ -152,6 +157,28 @@ class SessionManager {
|
|
|
152
157
|
return entries;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
async _loadEntriesFromFileStream(filePath) {
|
|
161
|
+
// 性能优化:流式读取大文件
|
|
162
|
+
const entries = [];
|
|
163
|
+
const seenIds = new Set();
|
|
164
|
+
const readline = require('readline');
|
|
165
|
+
const rl = readline.createInterface({
|
|
166
|
+
input: fs.createReadStream(filePath),
|
|
167
|
+
crlfDelay: Infinity
|
|
168
|
+
});
|
|
169
|
+
for await (const line of rl) {
|
|
170
|
+
if (!line.trim()) continue;
|
|
171
|
+
try {
|
|
172
|
+
const entry = JSON.parse(line);
|
|
173
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
174
|
+
if (entry.id && seenIds.has(entry.id)) continue;
|
|
175
|
+
if (entry.id) seenIds.add(entry.id);
|
|
176
|
+
entries.push(entry);
|
|
177
|
+
} catch { /* skip */ }
|
|
178
|
+
}
|
|
179
|
+
return entries;
|
|
180
|
+
}
|
|
181
|
+
|
|
155
182
|
_buildIndex() {
|
|
156
183
|
this.byId.clear();
|
|
157
184
|
this.labelsById.clear();
|
|
@@ -175,7 +202,8 @@ class SessionManager {
|
|
|
175
202
|
|
|
176
203
|
_rewriteFile() {
|
|
177
204
|
if (!this.persist || !this.sessionFile) return;
|
|
178
|
-
|
|
205
|
+
// 性能优化:单次写入代替多次追加
|
|
206
|
+
const content = this.fileEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
179
207
|
fs.writeFileSync(this.sessionFile, content);
|
|
180
208
|
}
|
|
181
209
|
|
|
@@ -190,10 +218,10 @@ class SessionManager {
|
|
|
190
218
|
return;
|
|
191
219
|
}
|
|
192
220
|
|
|
221
|
+
// 性能优化:使用批量写入代替多次追加
|
|
193
222
|
if (!this.flushed) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
223
|
+
const batch = this.fileEntries.map(e => `${JSON.stringify(e)}\n`).join('');
|
|
224
|
+
fs.writeFileSync(this.sessionFile, batch);
|
|
197
225
|
this.flushed = true;
|
|
198
226
|
} else {
|
|
199
227
|
fs.appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
package/src/storage/jsonl.js
CHANGED
|
@@ -136,7 +136,8 @@ class JsonlSessionStorage {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
static async _loadJsonlStorage(fsModule, filePath) {
|
|
139
|
-
|
|
139
|
+
// 性能优化:使用异步读取,避免阻塞事件循环
|
|
140
|
+
const content = await fsModule.promises.readFile(filePath, 'utf-8');
|
|
140
141
|
const lines = content.split('\n').filter(line => line.trim());
|
|
141
142
|
if (lines.length === 0) {
|
|
142
143
|
throw invalidSession(filePath, 'missing session header');
|
|
@@ -195,7 +196,7 @@ class JsonlSessionStorage {
|
|
|
195
196
|
timestamp: new Date().toISOString(),
|
|
196
197
|
targetId: leafId
|
|
197
198
|
};
|
|
198
|
-
fs.
|
|
199
|
+
await this.fs.promises.appendFile(this.filePath, JSON.stringify(entry) + '\n');
|
|
199
200
|
this.entries.push(entry);
|
|
200
201
|
this.byId.set(entry.id, entry);
|
|
201
202
|
this.currentLeafId = leafId;
|
|
@@ -206,7 +207,8 @@ class JsonlSessionStorage {
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
async appendEntry(entry) {
|
|
209
|
-
|
|
210
|
+
// 性能优化:使用异步追加,减少阻塞时间
|
|
211
|
+
await this.fs.promises.appendFile(this.filePath, JSON.stringify(entry) + '\n');
|
|
210
212
|
this.entries.push(entry);
|
|
211
213
|
this.byId.set(entry.id, entry);
|
|
212
214
|
updateLabelCache(this.labelsById, entry);
|
|
@@ -59,13 +59,14 @@ describe('ToolLoop._buildApiMessages 格式转换', () => {
|
|
|
59
59
|
expect(api[3].content).toBe('{"data":"ok"}');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it('tool
|
|
62
|
+
it('tool 消息已经用 OpenAI 格式时保持', () => {
|
|
63
63
|
const api = loop._buildApiMessages([
|
|
64
64
|
{ role: 'tool', tool_call_id: 'c1', content: 'done' },
|
|
65
65
|
], 'system prompt');
|
|
66
|
-
//
|
|
67
|
-
expect(api).toHaveLength(1);
|
|
66
|
+
// 已经符合 OpenAI 格式的直接保留
|
|
68
67
|
expect(api[0].role).toBe('system');
|
|
68
|
+
expect(api[1].role).toBe('tool');
|
|
69
|
+
expect(api[1].tool_call_id).toBe('c1');
|
|
69
70
|
});
|
|
70
71
|
|
|
71
72
|
it('systemPrompt 为空时不加 system 消息', () => {
|