foliko 2.0.8 → 2.0.10

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.8",
3
+ "version": "2.0.10",
4
4
  "description": "简约的插件化 Agent 框架",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.2
4
+
5
+ ### 修复 (bug fixes)
6
+
7
+ - **B1 [安全] shell 命令注入** — 移除 `child_process.exec` 兜底路径。shell 任务必须通过 `shell_exec` / `shell` 工具执行,工具不可用时返回错误而非 fallback 到任意命令执行
8
+ - **B2 [功能] enable: true 重新启用后调度失效** — 新增 `task.stopped` 字段显式跟踪 `stop()` 状态(croner 的 `stop()` 不清 handle 引用)。enable 路径检查 `stopped` 强制重建
9
+ - **B3 [功能] `_updateTask` 改坏 cron 表达式后任务半残化** — 改为二阶段提交:先 `new Cron(...)` 验证表达式合法,验证通过才 commit 到 `task`。避免半残状态
10
+ - **B4 [功能] cancel 取消不掉 catchup 触发** — catchup 路径从 `setTimeout` 改为 `new Cron(pastDate, ...)`,croner 的 `stop()` 能真正取消未来触发
11
+ - **B5 [功能] catchup + update 双触发** — 同上修复,统一走 croner handle;update 走 `stop()+new Cron()` 自然替换
12
+ - **B6 [并发] 共享 subagent 并发不安全** — LLM 任务改用 promise-chain 互斥(`_llmMutex`),不同任务排队执行,保护 subagent 内部状态
13
+
14
+ ### 改进
15
+
16
+ - **M1 DRY** — `_createTask` 复用 `isCronString` helper,消除内联正则重复
17
+ - **M3** — `reload` 清掉未触发的 `_persistTimer` 和重置 `_llmMutex`,避免旧 timer 用空 `_tasks` 写盘
18
+
19
+ ### 行为变更
20
+
21
+ - `_updateTask` 失败时不再修改 `task` 任何字段(包括 `task.cronExpression`),失败语义更安全
22
+ - `updated` 字段现在返回 `message` / `shell` 的真实值(之前是 `'[updated]'` 误导)
23
+ - `updated.task` 返回包含 `stopped` 字段
24
+
25
+ ## 2.0.1
26
+
27
+ ### 新增
28
+
29
+ - `schedule_get` 工具:按 ID 获取单个任务的完整详情(与 `schedule_list` 不同,会返回所有内部字段含 `running` / `createdAt` / `runAt` 等)
30
+ - `schedule_update` 工具:修改已存在的任务,可改 `name` / `message` / `scheduleTime` / `cronExpression` / `sessionId` / `llm` / `notify` / `shell` / `enabled`
31
+ - 改 `scheduleTime` / `cronExpression` 会自动 `stop()` 旧 croner handle 并重建
32
+ - 改 `shell` 会自动把 `llm` 置为 false(互斥)
33
+ - 跨类型切换(once → cron / cron → once)会被拒绝,提示先 cancel 再 create
34
+ - `runCount` / `lastRun` / `createdAt` / `id` 始终保留
35
+ - `enabled: false` 只停止调度不删除任务;`enabled: true` 会在必要时重建 handle
36
+
3
37
  ## 2.0.0
4
38
 
5
39
  ### Breaking Changes
@@ -138,6 +138,11 @@ function detectLLM(message, explicitFlag) {
138
138
  return LLM_KEYWORDS.some((kw) => message.includes(kw) || lower.includes(kw.toLowerCase()));
139
139
  }
140
140
 
141
+ function isCronString(str) {
142
+ if (!str) return false;
143
+ return /^[\d*,\/-\s]+$/.test(str) && str.trim().split(/\s+/).length >= 5;
144
+ }
145
+
141
146
  // ============================================================
142
147
  // SchedulerPlugin
143
148
  // ============================================================
@@ -145,8 +150,8 @@ class SchedulerPlugin extends Plugin {
145
150
  constructor(config = {}) {
146
151
  super();
147
152
  this.name = 'scheduler';
148
- this.version = '2.0.0';
149
- this.description = '定时任务调度 v2(croner 引擎,事件循环阻塞自愈 + 持久化 + 重叠保护)';
153
+ this.version = '2.0.2';
154
+ this.description = '定时任务调度 v2(croner 引擎,事件循环阻塞自愈 + 持久化 + 重叠保护 + 增删改查 + 并发安全)';
150
155
  this.priority = 15;
151
156
  this.system = true;
152
157
 
@@ -161,6 +166,8 @@ class SchedulerPlugin extends Plugin {
161
166
  this._taskStore = new TaskStore(this.config.persistencePath);
162
167
  // 复用的 LLM 执行 subagent(避免每次 tick 新建导致 _agents 数组漏内存)
163
168
  this._schedulerSubAgent = null;
169
+ // LLM 任务互斥链:B6 修复,避免不同任务的 subagent.chat() 并发污染 subagent 内部状态
170
+ this._llmMutex = Promise.resolve();
164
171
  // debounce 写入
165
172
  this._persistTimer = null;
166
173
  this._persistDirty = false;
@@ -260,6 +267,58 @@ class SchedulerPlugin extends Plugin {
260
267
  execute: async (args) => this._cancelTask(args.taskId),
261
268
  });
262
269
 
270
+ framework.registerTool({
271
+ name: 'schedule_get',
272
+ description: '按 ID 获取单个定时任务的完整详情',
273
+ inputSchema: z.object({ taskId: z.string().describe('任务 ID') }),
274
+ execute: async (args) => {
275
+ const task = this._tasks.get(args.taskId);
276
+ if (!task) return { success: false, error: 'Task not found' };
277
+ let nextRun = null;
278
+ try { nextRun = task.scheduleHandle?.nextRun?.() || task.runAt || null; } catch {}
279
+ return {
280
+ success: true,
281
+ task: {
282
+ id: task.id,
283
+ name: task.name,
284
+ type: task.type,
285
+ message: task.message,
286
+ shell: task.shell || null,
287
+ enabled: task.enabled,
288
+ running: task.running,
289
+ nextRun,
290
+ runCount: task.runCount,
291
+ lastRun: task.lastRun,
292
+ createdAt: task.createdAt,
293
+ cronExpression: task.cronExpression,
294
+ runAt: task.runAt,
295
+ timezone: task.timezone,
296
+ llm: task.llm,
297
+ notify: task.notify,
298
+ sessionId: task.sessionId,
299
+ },
300
+ };
301
+ },
302
+ });
303
+
304
+ framework.registerTool({
305
+ name: 'schedule_update',
306
+ description: '修改已存在的定时任务。可以修改:name/message/scheduleTime/cronExpression/sessionId/llm/notify/shell。如果改了 scheduleTime 或 cronExpression,会自动重新创建调度。runCount/lastRun/createdAt 保持不变。',
307
+ inputSchema: z.object({
308
+ taskId: z.string().describe('要修改的任务 ID'),
309
+ name: z.string().optional().describe('新任务名称'),
310
+ message: z.string().optional().describe('新提醒消息'),
311
+ scheduleTime: z.string().optional().describe('新时间(覆盖原 scheduleTime)。格式同 schedule_task'),
312
+ cronExpression: z.string().optional().describe('新 Cron 表达式(仅 cron 任务有效,优先级高于 scheduleTime)'),
313
+ sessionId: z.string().optional().describe('新会话 ID'),
314
+ llm: z.boolean().optional().describe('是否需要 LLM 处理'),
315
+ notify: z.boolean().optional().describe('是否发送通知'),
316
+ shell: z.string().optional().describe('新的 shell 命令(覆盖旧值)'),
317
+ enabled: z.boolean().optional().describe('启用/禁用任务(不删除)'),
318
+ }),
319
+ execute: async (args) => this._updateTask(args),
320
+ });
321
+
263
322
  framework.registerTool({
264
323
  name: 'cron_examples',
265
324
  description: '获取常用 Cron 表达式示例',
@@ -293,7 +352,7 @@ class SchedulerPlugin extends Plugin {
293
352
  const { scheduleTime, message, repeat, cronExpression, sessionId, shell } = args;
294
353
 
295
354
  // 解析时间
296
- const isCron = /^[\d*,\/-\s]+$/.test(scheduleTime) && scheduleTime.split(/\s+/).length >= 5;
355
+ const isCron = isCronString(scheduleTime);
297
356
  let taskType; // 'cron' | 'once'
298
357
  let runAt = null;
299
358
  let cron = null;
@@ -316,9 +375,10 @@ class SchedulerPlugin extends Plugin {
316
375
  runAt = new Date(Date.now() + delayMs);
317
376
  }
318
377
 
319
- // shell 任务必须能用 shell 工具,否则提示
378
+ // shell 任务的实际执行受 _runShell 内的安全策略控制:必须通过已注册的 shell 工具
320
379
  if (shell && !this._hasShellTool()) {
321
- // 不阻塞:fallback child_process 执行
380
+ // 创建时仍允许;执行时会被 _runShell 拒绝(fail-closed)
381
+ log.warn(` Shell 任务 ${args.name} 创建时未检测到 shell_exec/shell 工具,触发时将失败`);
322
382
  }
323
383
 
324
384
  // 解析 sessionId
@@ -360,6 +420,7 @@ class SchedulerPlugin extends Plugin {
360
420
  llm: llmMode,
361
421
  notify: args.notify !== false,
362
422
  running: false,
423
+ stopped: false, // 显式跟踪被 stop() 的状态(croner stop() 不清 handle 引用)
363
424
  scheduleHandle: null,
364
425
  };
365
426
 
@@ -472,9 +533,26 @@ class SchedulerPlugin extends Plugin {
472
533
  }
473
534
 
474
535
  async _runShell(task) {
475
- let result;
536
+ // 安全要求:shell 命令必须通过已注册的 shell 工具执行(这些工具应实现沙箱/白名单)
537
+ // 不再 fallback 到 child_process.exec,避免任意命令执行漏洞
476
538
  const shellTool = this._hasShellTool() ? this._pickShellTool() : null;
477
- if (shellTool) {
539
+ if (!shellTool) {
540
+ const errMsg = '未检测到 shell_exec/shell 工具,拒绝执行 shell 任务(安全策略)';
541
+ log.error(` ${task.name}: ${errMsg}`);
542
+ this._framework?.emit('notification', {
543
+ title: `任务失败: ${task.name}`,
544
+ message: errMsg,
545
+ source: 'scheduler',
546
+ level: 'error',
547
+ sessionId: task.sessionId,
548
+ timestamp: new Date(),
549
+ });
550
+ this._framework?.emit('scheduler:task_failed', { taskId: task.id, error: errMsg });
551
+ return { error: errMsg };
552
+ }
553
+
554
+ let result;
555
+ try {
478
556
  const raw = await this._framework.executeTool(shellTool, { command: task.shell });
479
557
  // 适配不同 shell 工具的返回格式
480
558
  if (raw?.error) {
@@ -485,17 +563,8 @@ class SchedulerPlugin extends Plugin {
485
563
  } else {
486
564
  result = { output: raw.stdout || raw.output || '', stdout: raw.stdout, stderr: raw.stderr };
487
565
  }
488
- } else {
489
- // 兜底:直接 child_process
490
- const { exec } = require('child_process');
491
- const util = require('util');
492
- const execAsync = util.promisify(exec);
493
- try {
494
- const { stdout, stderr } = await execAsync(task.shell, { timeout: 60000 });
495
- result = { output: stdout.trim(), stdout: stdout.trim(), stderr: (stderr || '').trim() };
496
- } catch (e) {
497
- result = { error: e.message, stderr: e.stderr || '' };
498
- }
566
+ } catch (e) {
567
+ result = { error: e.message || String(e) };
499
568
  }
500
569
 
501
570
  const output = result.error
@@ -521,28 +590,49 @@ class SchedulerPlugin extends Plugin {
521
590
  if (!subAgent) {
522
591
  return { error: '无法创建 scheduler subagent' };
523
592
  }
524
- const result = await subAgent.chat(task.message);
525
- const responseText = result?.message;
526
- if (task.notify !== false && responseText && responseText.trim()) {
527
- this._framework?.emit('notification', {
528
- title: task.name,
529
- message: responseText,
530
- source: 'scheduler',
531
- level: 'info',
532
- sessionId: task.sessionId,
533
- timestamp: new Date(),
534
- });
535
- this._framework?.emit('scheduler:reminder', {
536
- taskId: task.id,
537
- message: task.message,
538
- time: task.runAt || null,
539
- });
540
- }
541
- this._framework?.emit('scheduler:task_completed', {
542
- taskId: task.id,
543
- result: responseText || task.message,
544
- });
545
- return { success: true };
593
+ // B6 修复:用 promise-chain 互斥,把 LLM 任务串行化
594
+ // 即使两个不同任务同时触发 _onFire,它们的 subagent.chat() 也会排队执行
595
+ const work = async () => {
596
+ try {
597
+ const result = await subAgent.chat(task.message);
598
+ const responseText = result?.message;
599
+ if (task.notify !== false && responseText && responseText.trim()) {
600
+ this._framework?.emit('notification', {
601
+ title: task.name,
602
+ message: responseText,
603
+ source: 'scheduler',
604
+ level: 'info',
605
+ sessionId: task.sessionId,
606
+ timestamp: new Date(),
607
+ });
608
+ this._framework?.emit('scheduler:reminder', {
609
+ taskId: task.id,
610
+ message: task.message,
611
+ time: task.runAt || null,
612
+ });
613
+ }
614
+ this._framework?.emit('scheduler:task_completed', {
615
+ taskId: task.id,
616
+ result: responseText || task.message,
617
+ });
618
+ return { success: true };
619
+ } catch (e) {
620
+ const errMsg = e?.message || String(e);
621
+ log.error(` _runLLM ${task.name}:`, errMsg);
622
+ this._framework?.emit('notification', {
623
+ title: `任务失败: ${task.name}`,
624
+ message: errMsg,
625
+ source: 'scheduler',
626
+ level: 'error',
627
+ sessionId: task.sessionId,
628
+ timestamp: new Date(),
629
+ });
630
+ this._framework?.emit('scheduler:task_failed', { taskId: task.id, error: errMsg });
631
+ return { error: errMsg };
632
+ }
633
+ };
634
+ this._llmMutex = this._llmMutex.then(work, work);
635
+ return this._llmMutex;
546
636
  }
547
637
 
548
638
  async _runNotification(task) {
@@ -580,6 +670,7 @@ class SchedulerPlugin extends Plugin {
580
670
  task.scheduleHandle?.stop();
581
671
  } catch {}
582
672
  task.enabled = false;
673
+ task.stopped = true;
583
674
  this._tasks.delete(taskId);
584
675
  this._schedulePersist();
585
676
 
@@ -595,6 +686,169 @@ class SchedulerPlugin extends Plugin {
595
686
  return { success: true, cancelled: taskId };
596
687
  }
597
688
 
689
+ /**
690
+ * 修改任务。
691
+ * 关键规则:
692
+ * 1. 改 scheduleTime / cronExpression → 二阶段提交:先构造验证新 handle 成功,再 commit
693
+ * 2. 改 message / shell / llm / notify / sessionId / name / enabled → 仅更新字段
694
+ * 3. runCount / lastRun / createdAt / id 永远不变
695
+ * 4. type 字段(cron/once)不能跨类型切换——例如不能把 once 改成 cron(语义差异大),
696
+ * 这种情况下提示用户先 cancel 再 create
697
+ * 5. enable: false → set stopped=true + stop() handle;enable: true → 检查 stopped 重建 handle
698
+ */
699
+ _updateTask(args) {
700
+ const { taskId } = args;
701
+ const task = this._tasks.get(taskId);
702
+ if (!task) return { success: false, error: 'Task not found' };
703
+
704
+ const updated = {};
705
+ // 收集"非时间"变更(这些变更先做;时间变更走二阶段提交)
706
+ if (args.name !== undefined) { task.name = args.name; updated.name = args.name; }
707
+ if (args.message !== undefined) { task.message = args.message; updated.message = task.message; }
708
+ if (args.sessionId !== undefined) { task.sessionId = args.sessionId || null; updated.sessionId = task.sessionId; }
709
+ if (args.notify !== undefined) { task.notify = !!args.notify; updated.notify = task.notify; }
710
+ if (args.llm !== undefined) { task.llm = !!args.llm; updated.llm = task.llm; }
711
+ if (args.shell !== undefined) {
712
+ task.shell = args.shell || null;
713
+ // shell 与 llm 互斥
714
+ if (task.shell) { task.llm = false; updated.llm = false; }
715
+ updated.shell = task.shell;
716
+ }
717
+
718
+ // enabled 处理(影响 stopped 状态和后续重建)
719
+ if (args.enabled !== undefined) {
720
+ const wasEnabled = task.enabled;
721
+ task.enabled = !!args.enabled;
722
+ updated.enabled = task.enabled;
723
+ if (!task.enabled) {
724
+ // 暂停:stop handle,标记 stopped=true
725
+ try { task.scheduleHandle?.stop(); } catch {}
726
+ task.stopped = true;
727
+ } else if (!wasEnabled) {
728
+ // 从暂停恢复:标记为需要重建(在时间变更之后统一重建)
729
+ task.stopped = true; // 强制让下面"重建"分支执行
730
+ }
731
+ }
732
+
733
+ // 时间变更(cronExpression / scheduleTime)— 二阶段提交
734
+ let newCron = task.cronExpression;
735
+ let newRunAt = task.runAt;
736
+ let typeChanged = false;
737
+ let timeValidationError = null;
738
+
739
+ if (args.cronExpression !== undefined) {
740
+ // 显式 cron 表达式:必须是 cron 任务
741
+ if (task.type !== 'cron') {
742
+ timeValidationError = `无法把 ${task.type} 任务改为 cron 表达式(类型不兼容)。请先取消再创建。`;
743
+ } else {
744
+ newCron = args.cronExpression;
745
+ }
746
+ } else if (args.scheduleTime !== undefined) {
747
+ const st = args.scheduleTime;
748
+ const isCron = isCronString(st);
749
+ if (isCron) {
750
+ if (task.type !== 'cron') {
751
+ timeValidationError = `无法把 ${task.type} 任务改为 cron 表达式(类型不兼容)。请先取消再创建。`;
752
+ } else {
753
+ newCron = st;
754
+ }
755
+ } else {
756
+ if (task.type !== 'once') {
757
+ timeValidationError = `无法把 ${task.type} 任务改为一次性时间(类型不兼容)。请先取消再创建。`;
758
+ } else {
759
+ // 解析 runAt
760
+ let parsed;
761
+ if (st.includes(':')) {
762
+ parsed = parseAtTime(st);
763
+ } else {
764
+ const delayMs = parseDelay(st);
765
+ if (!delayMs) {
766
+ timeValidationError = `无效的时间格式: ${st}`;
767
+ } else {
768
+ parsed = new Date(Date.now() + delayMs);
769
+ }
770
+ }
771
+ if (parsed && isNaN(parsed.getTime())) {
772
+ timeValidationError = `无法解析时间: ${st}`;
773
+ } else if (parsed) {
774
+ if (task.runCount > 0) {
775
+ timeValidationError = '一次性任务已执行过,无法修改时间。请取消后重建。';
776
+ } else {
777
+ newRunAt = parsed;
778
+ }
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ if (timeValidationError) {
785
+ return { success: false, error: timeValidationError };
786
+ }
787
+
788
+ // 二阶段提交:先尝试构造新 handle 验证
789
+ let newHandle = null;
790
+ const handleChanged =
791
+ newCron !== task.cronExpression ||
792
+ newRunAt !== task.runAt ||
793
+ (args.enabled !== undefined && task.enabled === true && task.stopped);
794
+
795
+ if (handleChanged && task.enabled) {
796
+ try {
797
+ if (task.type === 'cron') {
798
+ newHandle = new Cron(newCron, this._cronOptions(task), () => this._onFire(task));
799
+ } else {
800
+ newHandle = new Cron(newRunAt, this._cronOptions(task), () => this._onFire(task));
801
+ }
802
+ } catch (err) {
803
+ return { success: false, error: `新调度表达式无效: ${err.message}` };
804
+ }
805
+ }
806
+
807
+ // 验证通过,commit
808
+ if (newHandle) {
809
+ try { task.scheduleHandle?.stop(); } catch {}
810
+ task.scheduleHandle = newHandle;
811
+ task.cronExpression = newCron;
812
+ task.runAt = newRunAt;
813
+ task.stopped = false;
814
+ if (args.cronExpression !== undefined || (args.scheduleTime !== undefined && isCronString(args.scheduleTime))) {
815
+ updated.cronExpression = task.cronExpression;
816
+ }
817
+ if (args.scheduleTime !== undefined && !isCronString(args.scheduleTime)) {
818
+ updated.runAt = task.runAt.toISOString();
819
+ }
820
+ } else if (args.enabled !== undefined && task.enabled) {
821
+ // enable: true 但 handle 没变(任务本来就是 enabled);无需操作
822
+ }
823
+
824
+ this._tasks.set(task.id, task);
825
+ this._schedulePersist();
826
+
827
+ let nextRun = null;
828
+ try { nextRun = task.scheduleHandle?.nextRun?.() || task.runAt || null; } catch {}
829
+
830
+ return {
831
+ success: true,
832
+ taskId: task.id,
833
+ updated,
834
+ task: {
835
+ id: task.id,
836
+ name: task.name,
837
+ type: task.type,
838
+ message: task.message,
839
+ shell: task.shell || null,
840
+ enabled: task.enabled,
841
+ stopped: task.stopped,
842
+ llm: task.llm,
843
+ notify: task.notify,
844
+ sessionId: task.sessionId,
845
+ cronExpression: task.cronExpression,
846
+ runAt: task.runAt,
847
+ nextRun,
848
+ },
849
+ };
850
+ }
851
+
598
852
  _cleanupTask(taskId) {
599
853
  const task = this._tasks.get(taskId);
600
854
  if (!task) return;
@@ -638,7 +892,6 @@ class SchedulerPlugin extends Plugin {
638
892
  const savedTasks = this._taskStore.loadTasks();
639
893
  if (!savedTasks || savedTasks.length === 0) return;
640
894
  const now = Date.now();
641
- const expiredImmediate = []; // 需要立即补触发的过期任务
642
895
 
643
896
  for (const saved of savedTasks) {
644
897
  // 已完成的 once 任务不再恢复
@@ -650,6 +903,7 @@ class SchedulerPlugin extends Plugin {
650
903
  lastRun: saved.lastRun ? new Date(saved.lastRun) : null,
651
904
  runAt: saved.runAt ? new Date(saved.runAt) : null,
652
905
  running: false,
906
+ stopped: false,
653
907
  scheduleHandle: null,
654
908
  };
655
909
 
@@ -661,16 +915,19 @@ class SchedulerPlugin extends Plugin {
661
915
  if (!task.runAt) continue;
662
916
  const fireAt = task.runAt.getTime();
663
917
  if (fireAt <= now) {
664
- // 已过期:超过 grace period 就丢弃,否则排队立即补触发
918
+ // 已过期:超过 grace period 就丢弃
665
919
  if (now - fireAt > ONCE_EXPIRY_GRACE_MS) {
666
920
  log.warn(` 丢弃过期任务 ${task.name}(已过期 ${Math.round((now - fireAt) / 1000)}s,超出 24h 容忍期)`);
667
921
  continue;
668
922
  }
669
- // 安排一个微小延迟的补触发(避免与启动逻辑抢资源)
670
- setTimeout(() => this._onFire(task), 100).unref?.();
671
- // 不放入 _tasks scheduleHandle 路径(因为是一次性补触发),
672
- // 但保留在 _tasks 里供 schedule_list 查看
673
- this._tasks.set(task.id, { ...task, expiredCatchup: true });
923
+ // B4/B5 修复:catchup 也用 croner 调度过去时间,croner 会立即触发;
924
+ // stop() 能真正取消,update 走 stop()+new Cron() 不会双触发
925
+ // protect:false 是因为"补触发"不应被 startup 期间的并发阻塞
926
+ task.scheduleHandle = new Cron(task.runAt, {
927
+ ...this._cronOptions(task),
928
+ protect: false,
929
+ }, () => this._onFire(task));
930
+ this._tasks.set(task.id, task);
674
931
  } else {
675
932
  // 未来触发
676
933
  task.scheduleHandle = new Cron(task.runAt, this._cronOptions(task), () => this._onFire(task));
@@ -713,6 +970,13 @@ class SchedulerPlugin extends Plugin {
713
970
  }
714
971
  this._tasks.clear();
715
972
  this._schedulerSubAgent = null;
973
+ // 清掉未触发的 debounce,避免 reload 后旧 timer 用空 _tasks 写盘
974
+ if (this._persistTimer) {
975
+ clearTimeout(this._persistTimer);
976
+ this._persistTimer = null;
977
+ }
978
+ this._persistDirty = false;
979
+ this._llmMutex = Promise.resolve();
716
980
  this._framework = framework;
717
981
  this._agent = this._getAgent();
718
982
  this._loadPersistedTasks();
package/src/agent/chat.js CHANGED
@@ -555,6 +555,9 @@ class AgentChatHandler extends EventEmitter {
555
555
  }
556
556
  }
557
557
  }
558
+ // 写入端 sanitize:AI SDK v6 响应里允许 error-text/error-json(工具失败标记),
559
+ // 但 ModelMessage 输入 schema 不接受;持久化前必须规范化
560
+ this._normalizeToolOutputs(finishMessages);
558
561
  messages.push(...finishMessages);
559
562
 
560
563
  // 获取或估算 token 用量
@@ -795,12 +798,56 @@ class AgentChatHandler extends EventEmitter {
795
798
  messages.push(userMessage);
796
799
  this._validateToolCalls(messages);
797
800
 
801
+ // 读取端 sanitize:修复历史 chat log 中可能存在的 error-text/error-json
802
+ this._normalizeToolOutputs(messages);
803
+
798
804
  // 最终兜底:暴力清理所有 orphaned tool 消息,确保 AI SDK 不报错
799
805
  this._stripOrphanedToolMessages(messages);
800
806
 
801
807
  return { messageStore, messages };
802
808
  }
803
809
 
810
+ /**
811
+ * 规范化 tool-result 的 output 字段。
812
+ * AI SDK v6 响应中允许 'error-text' / 'error-json'(表示工具执行失败),
813
+ * 但 ModelMessage 输入 schema(AI SDK 发送 prompt 时使用)不接受这两类。
814
+ * 必须在写入磁盘和读取发送前都做转换。
815
+ * @private
816
+ */
817
+ _normalizeToolOutputs(messages) {
818
+ const VALID_OUTPUT_TYPES = new Set(['text', 'json', 'execution-denied', 'content']);
819
+ let fixed = 0;
820
+ for (const msg of messages) {
821
+ if (msg.role !== 'tool' || !Array.isArray(msg.content)) continue;
822
+ for (const part of msg.content) {
823
+ if (!part || part.type !== 'tool-result') continue;
824
+ const out = part.output;
825
+ if (out === undefined || out === null || typeof out !== 'object') {
826
+ const v = out === undefined ? 'undefined' : (typeof out === 'string' ? out : JSON.stringify(out));
827
+ part.output = { type: 'text', value: String(v) };
828
+ fixed++;
829
+ continue;
830
+ }
831
+ if (VALID_OUTPUT_TYPES.has(out.type)) continue;
832
+ if (out.type === 'error-text' || out.type === 'error_text') {
833
+ part.output = { type: 'text', value: typeof out.value === 'string' ? out.value : String(out.value ?? '') };
834
+ fixed++;
835
+ continue;
836
+ }
837
+ if (out.type === 'error-json' || out.type === 'error_json') {
838
+ part.output = { type: 'json', value: out.value ?? null };
839
+ fixed++;
840
+ continue;
841
+ }
842
+ // 未知 type 降级为 text
843
+ try { part.output = { type: 'text', value: JSON.stringify(out) }; }
844
+ catch { part.output = { type: 'text', value: String(out) }; }
845
+ fixed++;
846
+ }
847
+ }
848
+ return fixed;
849
+ }
850
+
804
851
  /**
805
852
  * 更新消息存储的使用量
806
853
  * @private
package/src/agent/main.js CHANGED
@@ -401,6 +401,8 @@ class MainAgent extends BaseAgent {
401
401
  const sessionCtx = this.framework?.getSessionContext(sessionId);
402
402
  if (!messages?.length && sessionCtx) {
403
403
  messages = sessionCtx.getMessages();
404
+ // 同样规范化:避免压缩器处理到坏数据
405
+ require('../utils/message-validator').normalizeToolOutputs(messages);
404
406
  messageStore.messages = messages;
405
407
  }
406
408
  if (!messages?.length) throw new Error('No messages to compress');
@@ -10,6 +10,7 @@
10
10
 
11
11
  const { EventEmitter } = require('../common/events');
12
12
  const { logger } = require('../common/logger');
13
+ const { normalizeToolOutputs } = require('../utils/message-validator');
13
14
 
14
15
  /**
15
16
  * Session 作用域的事件监听器
@@ -222,6 +223,13 @@ class ChatSession extends EventEmitter {
222
223
  const messages = allMessages.length > MAX_INITIAL_LOAD
223
224
  ? allMessages.slice(-MAX_INITIAL_LOAD)
224
225
  : allMessages;
226
+ // 聊天前过滤:AI SDK v6 响应中允许 error-text/error-json(工具失败),
227
+ // 但 ModelMessage 输入 schema 不接受。加载到内存时立即规范化,
228
+ // 这样显示/统计/压缩/持久化回写都看到干净数据。
229
+ const fixed = normalizeToolOutputs(messages);
230
+ if (fixed > 0) {
231
+ logger.info(`[loadHistory] 规范化 ${fixed} 条 tool-result(error-text/error-json → text/json)`);
232
+ }
225
233
  messageStore.messages = messages;
226
234
  messageStore.historyLoaded = true;
227
235
  messageStore._hasMoreHistory = allMessages.length > MAX_INITIAL_LOAD;