foliko 2.0.30 → 2.0.32
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/.editorconfig +56 -56
- package/.lintstagedrc +7 -7
- package/.prettierignore +29 -29
- package/.prettierrc +11 -11
- package/Dockerfile +63 -63
- package/docs/plugin-dev-guide.md +850 -0
- package/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +1 -1
- package/plugins/core/workflow/context.js +941 -941
- package/plugins/core/workflow/engine.js +66 -66
- package/plugins/core/workflow/js-runner.js +318 -318
- package/plugins/core/workflow/json-runner.js +323 -323
- package/plugins/core/workflow/stages/choice.js +74 -74
- package/plugins/core/workflow/stages/each.js +123 -123
- package/plugins/core/workflow/stages/parallel.js +69 -69
- package/skills/find-skills/SKILL.md +133 -133
- package/src/agent/chat.js +31 -9
- package/src/agent/sub.js +17 -3
- package/src/agent/tool-loop.js +132 -37
- package/src/common/atomic-write.js +33 -0
- package/src/context/compressor.js +22 -18
- package/src/framework/framework.js +19 -1
- package/src/plugin/manager.js +12 -2
- package/src/session/session.js +9 -4
- package/src/storage/manager.js +3 -1
- package/tests/core/chat-tool.test.js +187 -187
- package/tests/core/latest-message-not-filtered.test.js +143 -0
- package/tests/core/p0-fixes.test.js +129 -0
- package/tests/core/p1-fixes.test.js +76 -0
package/src/agent/tool-loop.js
CHANGED
|
@@ -24,6 +24,20 @@ class ToolLoop extends EventEmitter {
|
|
|
24
24
|
this.abortSignal = config.abortSignal || null;
|
|
25
25
|
this._repairFn = config.repairToolCall || null;
|
|
26
26
|
this._thinkingMode = config.thinkingMode === true;
|
|
27
|
+
// ★ 自愈开关:一旦服务端报"reasoning_content must be passed back",
|
|
28
|
+
// 置 true 后本 loop 的所有请求都强制给每条 assistant 补 reasoning_content。
|
|
29
|
+
// 彻底兜底掉主/子 Agent、白名单漏判、跨轮持久化丢失等一切场景。
|
|
30
|
+
this._forceReasoning = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 判断是否是"thinking 模式必须回传 reasoning_content"这一类服务端 400。
|
|
35
|
+
* 兼容不同网关的措辞。
|
|
36
|
+
*/
|
|
37
|
+
_isReasoningPassbackError(err) {
|
|
38
|
+
const msg = (err && (err.message || err.error?.message || '')) || '';
|
|
39
|
+
const s = String(msg).toLowerCase();
|
|
40
|
+
return s.includes('reasoning_content') && (s.includes('thinking') || s.includes('passed back') || s.includes('must be'));
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
_checkAbort() {
|
|
@@ -183,6 +197,20 @@ class ToolLoop extends EventEmitter {
|
|
|
183
197
|
const api = [];
|
|
184
198
|
if (systemPrompt) api.push({ role: 'system', content: systemPrompt });
|
|
185
199
|
|
|
200
|
+
// ★ 动态判定是否处于 thinking 模式。
|
|
201
|
+
// isThinkingModel 是硬编码白名单,覆盖不到"服务端强制思考但模型名不在名单"的场景,
|
|
202
|
+
// 导致 this._thinkingMode=false → 不补 reasoning_content → 报
|
|
203
|
+
// "reasoning_content in the thinking mode must be passed back"。
|
|
204
|
+
// 只要本批消息里任意一条 assistant 已带 reasoning(服务端回过),就认定思考已激活,
|
|
205
|
+
// 给缺失的 assistant 消息(压缩摘要、上一轮纯文本答复等)补占位 reasoning_content。
|
|
206
|
+
const msgHasReasoning = (m) => {
|
|
207
|
+
if (!m || m.role !== 'assistant') return false;
|
|
208
|
+
if (m.reasoning_content || m.reasoning) return true;
|
|
209
|
+
if (Array.isArray(m.content)) return m.content.some(c => c && c.type === 'reasoning' && c.text);
|
|
210
|
+
return false;
|
|
211
|
+
};
|
|
212
|
+
const thinkingActive = this._thinkingMode || this._forceReasoning || messages.some(msgHasReasoning);
|
|
213
|
+
|
|
186
214
|
for (const msg of messages) {
|
|
187
215
|
if (msg.role === 'system') {
|
|
188
216
|
api.push({ role: 'system', content: typeof msg.content === 'string' ? msg.content : '' });
|
|
@@ -194,24 +222,18 @@ class ToolLoop extends EventEmitter {
|
|
|
194
222
|
api.push({ role: 'user', content: text || '' });
|
|
195
223
|
}
|
|
196
224
|
} else if (msg.role === 'assistant') {
|
|
197
|
-
// ★
|
|
198
|
-
|
|
199
|
-
|
|
225
|
+
// ★ 提取 text + reasoning + tool_calls(兼容 content=string、content=array、顶层字段)
|
|
226
|
+
let text = '';
|
|
227
|
+
let reasoningText = '';
|
|
228
|
+
const toolCalls = [];
|
|
200
229
|
|
|
201
230
|
if (typeof msg.content === 'string') {
|
|
202
|
-
|
|
203
|
-
// thinking mode 下,无 reasoning 则自动补 placeholder
|
|
204
|
-
const reasonVal = hasReasoningTopLevel || (this._thinkingMode ? '...' : '');
|
|
205
|
-
api.push({ role: 'assistant', content: msg.content || null, tool_calls: msg.tool_calls, ...(reasonVal ? { reasoning_content: reasonVal } : {}) });
|
|
206
|
-
} else {
|
|
207
|
-
api.push({ role: 'assistant', content: msg.content });
|
|
208
|
-
}
|
|
231
|
+
text = msg.content;
|
|
209
232
|
} else if (Array.isArray(msg.content)) {
|
|
210
|
-
let text = '';
|
|
211
|
-
const toolCalls = [];
|
|
212
233
|
for (const part of msg.content) {
|
|
213
234
|
if (!part) continue;
|
|
214
235
|
if (part.type === 'text') text += part.text;
|
|
236
|
+
else if (part.type === 'reasoning') reasoningText += part.text || '';
|
|
215
237
|
else if (part.type === 'tool-call' || part.type === 'tool-use') {
|
|
216
238
|
toolCalls.push({
|
|
217
239
|
id: part.toolCallId,
|
|
@@ -223,15 +245,28 @@ class ToolLoop extends EventEmitter {
|
|
|
223
245
|
});
|
|
224
246
|
}
|
|
225
247
|
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 顶层 reasoning_content / reasoning 兜底(content 数组里没提取到时)
|
|
251
|
+
if (!reasoningText) reasoningText = msg.reasoning_content || msg.reasoning || '';
|
|
226
252
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
// ★ 合并顶层 tool_calls(OpenAI 格式,来自 session 文件)
|
|
254
|
+
const existingIds = new Set(toolCalls.map(tc => tc.id));
|
|
255
|
+
if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
|
|
256
|
+
for (const tc of msg.tool_calls) {
|
|
257
|
+
if (!existingIds.has(tc.id)) toolCalls.push(tc);
|
|
258
|
+
existingIds.add(tc.id);
|
|
232
259
|
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// thinking 激活时,缺失 reasoning 一律补 '...' 占位,确保被服务端接受
|
|
263
|
+
const reasonVal = reasoningText || (thinkingActive ? '...' : '');
|
|
264
|
+
const reasonField = reasonVal ? { reasoning_content: reasonVal } : {};
|
|
265
|
+
|
|
266
|
+
if (toolCalls.length > 0) {
|
|
267
|
+
api.push({ role: 'assistant', content: text || null, tool_calls: toolCalls, ...reasonField });
|
|
233
268
|
} else {
|
|
234
|
-
api.push({ role: 'assistant', content:
|
|
269
|
+
api.push({ role: 'assistant', content: text || '', ...reasonField });
|
|
235
270
|
}
|
|
236
271
|
} else if (msg.role === 'tool') {
|
|
237
272
|
if (Array.isArray(msg.content)) {
|
|
@@ -432,6 +467,7 @@ class ToolLoop extends EventEmitter {
|
|
|
432
467
|
let fullText = '';
|
|
433
468
|
let fullReasoning = '';
|
|
434
469
|
let totalUsage = null;
|
|
470
|
+
let finishedNaturally = false; // 是否自然结束(模型给出最终答复),用于区分 maxSteps 耗尽
|
|
435
471
|
|
|
436
472
|
while (step < this.maxSteps) {
|
|
437
473
|
this._checkAbort();
|
|
@@ -442,7 +478,7 @@ class ToolLoop extends EventEmitter {
|
|
|
442
478
|
}
|
|
443
479
|
|
|
444
480
|
// 2. 构建 API 请求
|
|
445
|
-
|
|
481
|
+
let apiMessages = this._buildApiMessages(messages, systemPrompt);
|
|
446
482
|
const apiTools = this._buildApiTools(this.tools);
|
|
447
483
|
const apiOptions = this._prepareApiOptions(options);
|
|
448
484
|
|
|
@@ -456,12 +492,22 @@ class ToolLoop extends EventEmitter {
|
|
|
456
492
|
stream: false,
|
|
457
493
|
});
|
|
458
494
|
} catch (apiErr) {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
495
|
+
// ★ 自愈:服务端要求回传 reasoning_content → 置强制开关、重建消息、重试一次
|
|
496
|
+
if (!this._forceReasoning && this._isReasoningPassbackError(apiErr)) {
|
|
497
|
+
this._forceReasoning = true;
|
|
498
|
+
log.warn('[ToolLoop] reasoning_content required by server → force-inject & retry');
|
|
499
|
+
apiMessages = this._buildApiMessages(messages, systemPrompt);
|
|
500
|
+
response = await client.chat.completions.create({
|
|
501
|
+
model, messages: apiMessages, tools: apiTools, ...apiOptions, stream: false,
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
log.error(`[ToolLoop] API call failed: ${apiErr.message}`);
|
|
505
|
+
const recent = apiMessages.slice(-3).map(m => ({
|
|
506
|
+
role: m.role, hasTc: !!(m.tool_calls?.length), hasReason: !!m.reasoning_content,
|
|
507
|
+
}));
|
|
508
|
+
log.error(`[ToolLoop] last msgs: ${JSON.stringify(recent)}`);
|
|
509
|
+
throw apiErr;
|
|
510
|
+
}
|
|
465
511
|
}
|
|
466
512
|
|
|
467
513
|
// 3. 解析响应
|
|
@@ -482,7 +528,10 @@ class ToolLoop extends EventEmitter {
|
|
|
482
528
|
if (reasoningContent) fullReasoning += reasoningContent;
|
|
483
529
|
|
|
484
530
|
// 4. 处理工具调用
|
|
485
|
-
|
|
531
|
+
// ★ 只要模型产出了 tool_calls 就执行,不依赖 finish_reason === 'tool_calls'。
|
|
532
|
+
// 部分 OpenAI 兼容 / reasoning 供应商即使有 tool_calls 也会返回
|
|
533
|
+
// finish_reason: 'stop',若严格判断会把工具调用丢弃 → 循环提前退出、任务未完成。
|
|
534
|
+
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
486
535
|
// 插入 assistant 消息(保留原格式,后面的 _processMessage 会存盘)
|
|
487
536
|
const assistantMsg = {
|
|
488
537
|
role: 'assistant',
|
|
@@ -505,6 +554,14 @@ class ToolLoop extends EventEmitter {
|
|
|
505
554
|
} else {
|
|
506
555
|
// 没有工具调用 → 完成
|
|
507
556
|
fullText = message.content || '';
|
|
557
|
+
// ★ 把最终回复也写进 messages,否则持久化历史缺失助手每轮答复、
|
|
558
|
+
// 下一轮模型看不到自己上次说了什么(多轮记忆丢失)。
|
|
559
|
+
if (fullText || reasoningContent) {
|
|
560
|
+
const finalMsg = { role: 'assistant', content: fullText };
|
|
561
|
+
if (reasoningContent) finalMsg.reasoning_content = reasoningContent;
|
|
562
|
+
messages.push(finalMsg);
|
|
563
|
+
}
|
|
564
|
+
finishedNaturally = true;
|
|
508
565
|
break;
|
|
509
566
|
}
|
|
510
567
|
}
|
|
@@ -515,6 +572,9 @@ class ToolLoop extends EventEmitter {
|
|
|
515
572
|
steps: step,
|
|
516
573
|
usage: totalUsage,
|
|
517
574
|
reasoningText: fullReasoning || undefined,
|
|
575
|
+
// ★ 区分正常完成与 maxSteps 耗尽:耗尽时最后一步是工具调用、无最终答复,
|
|
576
|
+
// 调用方(SubAgent 等)不应把它当成功。
|
|
577
|
+
stopReason: finishedNaturally ? 'stop' : 'max_steps',
|
|
518
578
|
};
|
|
519
579
|
}
|
|
520
580
|
|
|
@@ -529,6 +589,7 @@ class ToolLoop extends EventEmitter {
|
|
|
529
589
|
let fullText = '';
|
|
530
590
|
let fullReasoning = '';
|
|
531
591
|
let totalUsage = null;
|
|
592
|
+
let finishedNaturally = false;
|
|
532
593
|
|
|
533
594
|
while (step < this.maxSteps) {
|
|
534
595
|
this._checkAbort();
|
|
@@ -539,7 +600,7 @@ class ToolLoop extends EventEmitter {
|
|
|
539
600
|
}
|
|
540
601
|
|
|
541
602
|
// 2. 构建请求
|
|
542
|
-
|
|
603
|
+
let apiMessages = this._buildApiMessages(messages, systemPrompt);
|
|
543
604
|
const apiTools = this._buildApiTools(this.tools);
|
|
544
605
|
const apiOptions = this._prepareApiOptions(options);
|
|
545
606
|
|
|
@@ -554,13 +615,24 @@ class ToolLoop extends EventEmitter {
|
|
|
554
615
|
stream_options: { include_usage: true },
|
|
555
616
|
});
|
|
556
617
|
} catch (apiErr) {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
618
|
+
// ★ 自愈:服务端要求回传 reasoning_content → 置强制开关、重建消息、重试一次
|
|
619
|
+
if (!this._forceReasoning && this._isReasoningPassbackError(apiErr)) {
|
|
620
|
+
this._forceReasoning = true;
|
|
621
|
+
log.warn('[ToolLoop] reasoning_content required by server → force-inject & retry (stream)');
|
|
622
|
+
apiMessages = this._buildApiMessages(messages, systemPrompt);
|
|
623
|
+
stream = await client.chat.completions.create({
|
|
624
|
+
model, messages: apiMessages, tools: apiTools, ...apiOptions,
|
|
625
|
+
stream: true, stream_options: { include_usage: true },
|
|
626
|
+
});
|
|
627
|
+
} else {
|
|
628
|
+
log.error(`[ToolLoop] stream call failed: ${apiErr.message}`);
|
|
629
|
+
log.error(`[ToolLoop] model=${model} msgs=${apiMessages.length}`);
|
|
630
|
+
const recent = apiMessages.slice(-3).map(m => ({
|
|
631
|
+
role: m.role, hasTc: !!(m.tool_calls?.length), hasReason: !!m.reasoning_content,
|
|
632
|
+
}));
|
|
633
|
+
log.error(`[ToolLoop] last msgs: ${JSON.stringify(recent)}`);
|
|
634
|
+
throw apiErr;
|
|
635
|
+
}
|
|
564
636
|
}
|
|
565
637
|
|
|
566
638
|
// 3. 处理流式响应(累计 tool_calls delta)
|
|
@@ -584,8 +656,14 @@ class ToolLoop extends EventEmitter {
|
|
|
584
656
|
}
|
|
585
657
|
|
|
586
658
|
// Reasoning content
|
|
659
|
+
// ★ 与 text-delta 对称:用 fullReasoning 作为"已发射长度"游标,只发新增部分。
|
|
660
|
+
// 否则每个 chunk 都 slice(旧游标) → 把本 step 从头到现在的 reasoning 重复发出。
|
|
587
661
|
if (result.hasReasoning) {
|
|
588
|
-
|
|
662
|
+
const delta = state.fullReasoning.slice(fullReasoning.length);
|
|
663
|
+
if (delta) {
|
|
664
|
+
fullReasoning += delta;
|
|
665
|
+
yield { type: 'thinking', text: delta };
|
|
666
|
+
}
|
|
589
667
|
}
|
|
590
668
|
|
|
591
669
|
// Text delta
|
|
@@ -605,9 +683,12 @@ class ToolLoop extends EventEmitter {
|
|
|
605
683
|
totalUsage = state.totalUsage;
|
|
606
684
|
fullReasoning = state.fullReasoning;
|
|
607
685
|
|
|
608
|
-
const toolCalls = Object.values(toolCallAccum).filter(tc => tc.id);
|
|
686
|
+
const toolCalls = Object.values(toolCallAccum).filter(tc => tc.id && tc.function?.name);
|
|
609
687
|
|
|
610
|
-
|
|
688
|
+
// ★ 只要累积到有效 tool_calls 就执行,不依赖 finishReason === 'tool_calls'。
|
|
689
|
+
// 部分供应商(尤其内联 <think> 的 reasoning 模型)即使调用工具也返回
|
|
690
|
+
// finish_reason: 'stop',严格判断会丢弃工具调用 → 提前退出、任务未完成。
|
|
691
|
+
if (toolCalls.length > 0) {
|
|
611
692
|
// 插入 assistant 消息(含 tool_calls)
|
|
612
693
|
const assistantMsg = { role: 'assistant', content: fullText || '', tool_calls: toolCalls };
|
|
613
694
|
// ★ 保存 reasoning_content(下次 API 调用时需要传回)
|
|
@@ -624,6 +705,14 @@ class ToolLoop extends EventEmitter {
|
|
|
624
705
|
} else {
|
|
625
706
|
// 无工具调用 → 完成(与 generate() 保持一致:确保 fullText 有值)
|
|
626
707
|
fullText = state.fullText || fullText;
|
|
708
|
+
// ★ 把最终回复也写进 messages,否则持久化历史缺失助手每轮答复、
|
|
709
|
+
// 下一轮模型看不到自己上次说了什么(多轮记忆丢失)。
|
|
710
|
+
if (fullText || fullReasoning) {
|
|
711
|
+
const finalMsg = { role: 'assistant', content: fullText };
|
|
712
|
+
if (fullReasoning) finalMsg.reasoning_content = fullReasoning;
|
|
713
|
+
messages.push(finalMsg);
|
|
714
|
+
}
|
|
715
|
+
finishedNaturally = true;
|
|
627
716
|
break;
|
|
628
717
|
}
|
|
629
718
|
}
|
|
@@ -635,12 +724,18 @@ class ToolLoop extends EventEmitter {
|
|
|
635
724
|
outputTokens: totalUsage?.completionTokens || 0,
|
|
636
725
|
};
|
|
637
726
|
|
|
727
|
+
// ★ maxSteps 耗尽时提示调用方(区分正常完成)
|
|
728
|
+
if (!finishedNaturally) {
|
|
729
|
+
yield { type: 'max-steps', steps: step };
|
|
730
|
+
}
|
|
731
|
+
|
|
638
732
|
return {
|
|
639
733
|
text: fullText,
|
|
640
734
|
response: { messages },
|
|
641
735
|
steps: step,
|
|
642
736
|
usage: totalUsage,
|
|
643
737
|
reasoningText: fullReasoning || undefined,
|
|
738
|
+
stopReason: finishedNaturally ? 'stop' : 'max_steps',
|
|
644
739
|
};
|
|
645
740
|
}
|
|
646
741
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 原子写文件:写 tmp → fsync → rename。
|
|
7
|
+
*
|
|
8
|
+
* 直接 fs.writeFileSync 会先把目标文件截断再写;进程被杀 / 断电落在中途时,
|
|
9
|
+
* 磁盘上会留下被截断的半文件(甚至 0 字节),加载时按行解析要么整段丢、
|
|
10
|
+
* 要么把坏行静默跳过 → 静默丢数据。
|
|
11
|
+
*
|
|
12
|
+
* rename 在同一文件系统上是原子的:崩溃时要么看到旧文件、要么看到完整新文件,
|
|
13
|
+
* 不会出现半截。fsync 进一步保证内容真正落盘(防 rename 先于数据落盘的重排)。
|
|
14
|
+
*
|
|
15
|
+
* @param {string} filePath 目标路径
|
|
16
|
+
* @param {string|Buffer} content 完整内容
|
|
17
|
+
*/
|
|
18
|
+
function atomicWriteFileSync(filePath, content) {
|
|
19
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
20
|
+
let fd;
|
|
21
|
+
try {
|
|
22
|
+
fd = fs.openSync(tmp, 'w');
|
|
23
|
+
fs.writeFileSync(fd, content);
|
|
24
|
+
try { fs.fsyncSync(fd); } catch { /* 某些文件系统/平台不支持 fsync,忽略 */ }
|
|
25
|
+
} finally {
|
|
26
|
+
if (fd !== undefined) {
|
|
27
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
fs.renameSync(tmp, filePath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { atomicWriteFileSync };
|
|
@@ -194,9 +194,10 @@ class ContextCompressor {
|
|
|
194
194
|
this._compactionSettings = config.compactionSettings || DEFAULT_COMPACTION_SETTINGS;
|
|
195
195
|
|
|
196
196
|
this._compressionCount = 0;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this.
|
|
197
|
+
// ★ 按 sessionId 隔离在途压缩:旧实现用单个全局 promise/flag,
|
|
198
|
+
// session B 在 A 压缩进行中调用会拿到 A 的 promise、对着 A 的 messages → 串味。
|
|
199
|
+
this._compressionPromises = new Map(); // sessionId -> 在途 promise
|
|
200
|
+
this._compressionTimeouts = new Map(); // sessionId -> setTimeout 句柄
|
|
200
201
|
this._maxToolResultSize = config.maxToolResultSize || 4000;
|
|
201
202
|
this._tokenCounter = new TokenCounter();
|
|
202
203
|
}
|
|
@@ -213,20 +214,22 @@ class ContextCompressor {
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
async compress(sessionId, messages, messageStore) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
// 同一 session 的并发压缩复用同一 promise;不同 session 互不干扰。
|
|
218
|
+
const existing = this._compressionPromises.get(sessionId);
|
|
219
|
+
if (existing) {
|
|
220
|
+
logger.debug(`Compression already in progress for ${sessionId}, waiting...`);
|
|
221
|
+
return existing;
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
if (messages.length <= this._keepRecentMessages) return;
|
|
222
225
|
|
|
223
|
-
this.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.
|
|
227
|
-
if (this._compressionTimeoutId) { clearTimeout(this._compressionTimeoutId); this._compressionTimeoutId = null; }
|
|
226
|
+
const p = this._executeWithTimeout(sessionId, messages, messageStore).finally(() => {
|
|
227
|
+
this._compressionPromises.delete(sessionId);
|
|
228
|
+
const t = this._compressionTimeouts.get(sessionId);
|
|
229
|
+
if (t) { clearTimeout(t); this._compressionTimeouts.delete(sessionId); }
|
|
228
230
|
});
|
|
229
|
-
|
|
231
|
+
this._compressionPromises.set(sessionId, p);
|
|
232
|
+
return p;
|
|
230
233
|
}
|
|
231
234
|
|
|
232
235
|
async _executeWithTimeout(sessionId, messages, messageStore) {
|
|
@@ -234,10 +237,11 @@ class ContextCompressor {
|
|
|
234
237
|
return await Promise.race([
|
|
235
238
|
this._doCompress(sessionId, messages, messageStore),
|
|
236
239
|
new Promise((_, reject) => {
|
|
237
|
-
|
|
238
|
-
this.
|
|
240
|
+
const tid = setTimeout(() => {
|
|
241
|
+
this._compressionTimeouts.delete(sessionId);
|
|
239
242
|
reject(new Error(`Compression timeout (${COMPRESSION_TIMEOUT_MS}ms)`));
|
|
240
243
|
}, COMPRESSION_TIMEOUT_MS);
|
|
244
|
+
this._compressionTimeouts.set(sessionId, tid);
|
|
241
245
|
}),
|
|
242
246
|
]);
|
|
243
247
|
} catch (err) {
|
|
@@ -247,9 +251,9 @@ class ContextCompressor {
|
|
|
247
251
|
}
|
|
248
252
|
|
|
249
253
|
cancelCompression() {
|
|
250
|
-
|
|
251
|
-
this.
|
|
252
|
-
this.
|
|
254
|
+
for (const t of this._compressionTimeouts.values()) clearTimeout(t);
|
|
255
|
+
this._compressionTimeouts.clear();
|
|
256
|
+
this._compressionPromises.clear();
|
|
253
257
|
}
|
|
254
258
|
|
|
255
259
|
async _doCompress(sessionId, messages, messageStore) {
|
|
@@ -390,7 +394,7 @@ class ContextCompressor {
|
|
|
390
394
|
}
|
|
391
395
|
|
|
392
396
|
getStats() {
|
|
393
|
-
return { compressionCount: this._compressionCount, inProgress: this.
|
|
397
|
+
return { compressionCount: this._compressionCount, inProgress: this._compressionPromises.size > 0 };
|
|
394
398
|
}
|
|
395
399
|
}
|
|
396
400
|
|
|
@@ -1076,7 +1076,25 @@ class Framework extends EventEmitter {
|
|
|
1076
1076
|
}
|
|
1077
1077
|
if (!manager) {
|
|
1078
1078
|
const { SessionManager } = require('../session/session');
|
|
1079
|
-
|
|
1079
|
+
const fs = require('fs');
|
|
1080
|
+
try {
|
|
1081
|
+
manager = new SessionManager(cwd, sessionDir, sessionFile, true);
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
// ★ 恢复分支同样可能因坏文件在构造时(_loadEntriesFromFile)抛错。
|
|
1084
|
+
// 之前这里不在 try 内 → 直接崩。改为:隔离坏文件后重新新建,最后兜底内存 session。
|
|
1085
|
+
this.logger.warn(`[Framework] Session '${sessionId}' file unreadable (${err.message}); quarantining and starting fresh`);
|
|
1086
|
+
try {
|
|
1087
|
+
if (fs.existsSync(sessionFile)) fs.renameSync(sessionFile, `${sessionFile}.corrupt-${Date.now()}`);
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
this.logger.warn(`[Framework] quarantine failed: ${e.message}`);
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
manager = new SessionManager(cwd, sessionDir, sessionFile, true);
|
|
1093
|
+
} catch (e2) {
|
|
1094
|
+
this.logger.warn(`[Framework] fresh session create failed: ${e2.message}; using in-memory`);
|
|
1095
|
+
manager = SessionManager.inMemory(cwd);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1080
1098
|
}
|
|
1081
1099
|
this._sessionContexts.set(sessionId, manager);
|
|
1082
1100
|
this.touchSession(sessionId);
|
package/src/plugin/manager.js
CHANGED
|
@@ -594,8 +594,18 @@ class PluginManager {
|
|
|
594
594
|
try {
|
|
595
595
|
const stateFile = this._getStateFile();
|
|
596
596
|
if (fs.existsSync(stateFile)) {
|
|
597
|
-
|
|
598
|
-
return this._stateCache;
|
|
597
|
+
const raw = fs.readFileSync(stateFile, 'utf-8');
|
|
598
|
+
if (raw.trim() === '') { this._stateCache = {}; return this._stateCache; }
|
|
599
|
+
try {
|
|
600
|
+
this._stateCache = JSON.parse(raw);
|
|
601
|
+
return this._stateCache;
|
|
602
|
+
} catch (parseErr) {
|
|
603
|
+
// ★ 坏文件不能静默当空对象 → 否则下次 _saveState 覆盖它、
|
|
604
|
+
// 插件启停/配置全丢。先隔离坏文件保留现场,再以默认值启动。
|
|
605
|
+
this._log.error(`Plugin state file corrupt (${parseErr.message}); quarantining`);
|
|
606
|
+
try { fs.renameSync(stateFile, `${stateFile}.corrupt-${Date.now()}`); }
|
|
607
|
+
catch (e) { this._log.error('quarantine state file failed:', e.message); }
|
|
608
|
+
}
|
|
599
609
|
}
|
|
600
610
|
} catch (err) {
|
|
601
611
|
this._log.error('Failed to load state:', err.message);
|
package/src/session/session.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const { JsonlSessionStorage } = require('../storage/jsonl');
|
|
10
|
+
const { atomicWriteFileSync } = require('../common/atomic-write');
|
|
10
11
|
const {
|
|
11
12
|
EntryTypes,
|
|
12
13
|
generateEntryId,
|
|
@@ -202,9 +203,9 @@ class SessionManager {
|
|
|
202
203
|
|
|
203
204
|
_rewriteFile() {
|
|
204
205
|
if (!this.persist || !this.sessionFile) return;
|
|
205
|
-
//
|
|
206
|
+
// 性能优化:单次写入代替多次追加。原子写避免崩溃时留下半截文件。
|
|
206
207
|
const content = this.fileEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
207
|
-
|
|
208
|
+
atomicWriteFileSync(this.sessionFile, content);
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
_persist(entry) {
|
|
@@ -221,7 +222,7 @@ class SessionManager {
|
|
|
221
222
|
// 性能优化:使用批量写入代替多次追加
|
|
222
223
|
if (!this.flushed) {
|
|
223
224
|
const batch = this.fileEntries.map(e => `${JSON.stringify(e)}\n`).join('');
|
|
224
|
-
|
|
225
|
+
atomicWriteFileSync(this.sessionFile, batch);
|
|
225
226
|
this.flushed = true;
|
|
226
227
|
} else {
|
|
227
228
|
fs.appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
|
|
@@ -440,7 +441,11 @@ class SessionManager {
|
|
|
440
441
|
* Build the session context (what gets sent to the LLM)
|
|
441
442
|
*/
|
|
442
443
|
buildSessionContext() {
|
|
443
|
-
|
|
444
|
+
// ★ 走活跃分支(leafId 到根的路径),而不是把所有分支的 entries 拍平。
|
|
445
|
+
// 否则被丢弃分支的消息会混进上下文,compaction/model 也可能取到废弃分支的值。
|
|
446
|
+
// entry.js 的 buildSessionContext 只接收单个数组参数,之前多传的 leafId/byId 被静默忽略。
|
|
447
|
+
const pathEntries = this.leafId ? this.getBranch(this.leafId) : this.getEntries();
|
|
448
|
+
return buildSessionContext(pathEntries);
|
|
444
449
|
}
|
|
445
450
|
|
|
446
451
|
/**
|
package/src/storage/manager.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { generateEntryId } = require('../common/id');
|
|
9
|
+
const { atomicWriteFileSync } = require('../common/atomic-write');
|
|
9
10
|
|
|
10
11
|
class StorageEntry {
|
|
11
12
|
constructor(key, value, timestamp) {
|
|
@@ -94,7 +95,8 @@ class StorageManager {
|
|
|
94
95
|
_rewrite() {
|
|
95
96
|
if (!this.persist || !this.filePath) return;
|
|
96
97
|
const lines = this.allEntries.map(e => JSON.stringify(e)).join('\n') + '\n';
|
|
97
|
-
|
|
98
|
+
// 原子写:compaction 全量重写时崩溃不会留下半截文件(否则内存已"压缩"、磁盘却残缺)。
|
|
99
|
+
atomicWriteFileSync(this.filePath, lines);
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/**
|