foliko 2.0.29 → 2.0.31

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.
@@ -0,0 +1,850 @@
1
+ # Plugin 开发指南
2
+
3
+ > 本指南详细说明如何在 Foliko 框架中开发插件。
4
+
5
+ ---
6
+
7
+ ## 目录
8
+
9
+ - [1. 概述](#1-概述)
10
+ - [2. 插件存放位置](#2-插件存放位置)
11
+ - [3. 用户插件开发](#3-用户插件开发)
12
+ - [4. 内置插件开发](#4-内置插件开发)
13
+ - [5. 插件属性](#5-插件属性)
14
+ - [6. 生命周期](#6-生命周期)
15
+ - [7. 工具注册](#7-工具注册)
16
+ - [8. 扩展工具](#8-扩展工具)
17
+ - [9. 子 Agent](#9-子-agent)
18
+ - [10. 工作流](#10-工作流)
19
+ - [11. Prompt Parts](#11-prompt-parts)
20
+ - [12. Skill 管理](#12-skill-管理)
21
+ - [13. 依赖管理](#13-依赖管理)
22
+ - [14. 系统插件访问](#14-系统插件访问)
23
+ - [15. 最佳实践](#15-最佳实践)
24
+
25
+ ---
26
+
27
+ ## 1. 概述
28
+
29
+ Foliko 是一个基于插件的 Agent 框架,核心保持简单,通过插件扩展功能。
30
+
31
+ ### 插件 vs 技能
32
+
33
+ | 类型 | 存储位置 | 说明 |
34
+ |------|----------|------|
35
+ | **Plugin(插件)** | `.foliko/plugins/` | 扩展功能模块 |
36
+ | **Skill(技能)** | `.foliko/skills/` | 为 Agent 提供指导的知识包 |
37
+
38
+ ---
39
+
40
+ ## 2. 插件存放位置
41
+
42
+ ### 用户插件 `.foliko/plugins/`
43
+
44
+ 用户自定义插件放在项目根目录的 `.foliko/plugins/` 下,**自动加载**,无需手动注册。
45
+
46
+ **推荐使用文件夹结构**(适合复杂插件):
47
+
48
+ ```
49
+ 项目目录/
50
+ └── .foliko/
51
+ └── plugins/
52
+ ├── my-plugin/ ✅ 文件夹结构(推荐)
53
+ │ ├── package.json # 可选,main 字段指定入口
54
+ │ ├── index.js # 默认入口
55
+ │ └── node_modules/ # 可选,插件私有依赖
56
+ ├── another-plugin/ ✅ 另一个文件夹插件
57
+ │ └── index.js
58
+ └── legacy.js ✅ 单文件仍支持
59
+ ```
60
+
61
+ ### 内置插件 `plugins/`
62
+
63
+ 框架内置插件位于仓库根目录的 `plugins/` 目录,需要 `require` Plugin 基类。
64
+
65
+ ---
66
+
67
+ ## 3. 用户插件开发
68
+
69
+ ### 文件夹结构(推荐)
70
+
71
+ ```
72
+ .foliko/plugins/my-plugin/
73
+ ├── package.json # 可选
74
+ └── index.js # 入口
75
+ ```
76
+
77
+ ```javascript
78
+ // .foliko/plugins/my-plugin/index.js
79
+ const { z } = require('zod');
80
+
81
+ module.exports = function (Plugin) {
82
+ return class MyPlugin extends Plugin {
83
+ constructor(config = {}) {
84
+ super();
85
+ this.name = 'my-plugin';
86
+ this.version = '1.0.0';
87
+ this.description = '我的工具插件';
88
+ this.priority = 10;
89
+ }
90
+
91
+ // 方式1:声明式工具定义
92
+ tools = {
93
+ my_tool: {
94
+ description: '我的工具',
95
+ inputSchema: z.object({
96
+ param: z.string().describe('参数描述'),
97
+ }),
98
+ execute: async (args) => {
99
+ return { success: true, result: args.param };
100
+ },
101
+ },
102
+ };
103
+
104
+ // 方式2:生命周期方法
105
+ onStart(framework) {
106
+ // 注册额外工具
107
+ this.tool.register({
108
+ name: 'another_tool',
109
+ description: '另一个工具',
110
+ inputSchema: z.object({}),
111
+ execute: async (args) => ({ success: true }),
112
+ });
113
+ }
114
+
115
+ uninstall(framework) {
116
+ // 清理资源
117
+ }
118
+ };
119
+ };
120
+ ```
121
+
122
+ ### 单文件结构(仍支持)
123
+
124
+ ```javascript
125
+ // .foliko/plugins/my-plugin.js
126
+ const { z } = require('zod');
127
+
128
+ module.exports = function (Plugin) {
129
+ return class MyPlugin extends Plugin {
130
+ constructor(config = {}) {
131
+ super();
132
+ this.name = 'my-plugin';
133
+ this.version = '1.0.0';
134
+ this.description = '我的工具插件';
135
+ this.priority = 10;
136
+ }
137
+
138
+ tools = {
139
+ my_tool: {
140
+ description: '我的工具',
141
+ inputSchema: z.object({
142
+ param: z.string().describe('参数描述'),
143
+ }),
144
+ execute: async (args) => {
145
+ return { success: true, result: args.param };
146
+ },
147
+ },
148
+ };
149
+
150
+ onStart(framework) {}
151
+ uninstall(framework) {}
152
+ };
153
+ };
154
+ ```
155
+
156
+ **注意**:如果文件夹和同名 `.js` 文件同时存在,**文件夹优先**。
157
+
158
+ ---
159
+
160
+ ## 4. 内置插件开发
161
+
162
+ 内置插件需要显式引入 Plugin 基类:
163
+
164
+ ```javascript
165
+ // plugins/my-plugin.js
166
+ const { Plugin } = require('../src/plugin/base');
167
+ const { z } = require('zod');
168
+
169
+ class MyPlugin extends Plugin {
170
+ constructor(config = {}) {
171
+ super();
172
+ this.name = 'my-plugin';
173
+ this.version = '1.0.0';
174
+ this.description = '我的工具插件';
175
+ this.priority = 10;
176
+ }
177
+
178
+ tools = {
179
+ my_tool: {
180
+ description: '我的工具',
181
+ inputSchema: z.object({
182
+ param: z.string().describe('参数描述'),
183
+ }),
184
+ execute: async (args) => {
185
+ return { success: true, result: args.param };
186
+ },
187
+ },
188
+ };
189
+
190
+ onStart(framework) {
191
+ // 注册额外工具
192
+ this.tool.register({
193
+ name: 'another_tool',
194
+ description: '另一个工具',
195
+ inputSchema: z.object({}),
196
+ execute: async (args) => ({ success: true }),
197
+ });
198
+ }
199
+
200
+ uninstall(framework) {}
201
+ }
202
+
203
+ module.exports = MyPlugin;
204
+ ```
205
+
206
+ ---
207
+
208
+ ## 5. 插件属性
209
+
210
+ | 属性 | 类型 | 必须 | 说明 |
211
+ |------|------|------|------|
212
+ | `name` | string | ✅ | 唯一名称 |
213
+ | `version` | string | ❌ | 版本号,默认 '1.0.0' |
214
+ | `description` | string | ❌ | 插件描述 |
215
+ | `priority` | number | ❌ | 加载优先级,默认 100 |
216
+ | `tools` | object | ❌ | 声明式工具定义 |
217
+ | `agents` | array | ❌ | 声明式子 Agent 定义 |
218
+ | `prompts` | array | ❌ | 声明式 prompt parts |
219
+ | `enabled` | boolean | ❌ | 是否启用,默认 true |
220
+ | `system` | boolean | ❌ | 是否系统插件,默认 false |
221
+
222
+ ---
223
+
224
+ ## 6. 生命周期
225
+
226
+ | 方法 | 调用时机 | 必须实现 | 说明 |
227
+ |------|----------|----------|------|
228
+ | `install(framework)` | 插件安装时 | ✅ | 初始化,获取 framework |
229
+ | `onStart(framework)` | start/enable/reload 时 | 推荐 | 注册工具、扩展、子代理 |
230
+ | `onStop()` | disable 时 | 可选 | 清理资源 |
231
+ | `reload(framework)` | 热重载时 | 可选 | prompts 自动清理/注册 |
232
+ | `uninstall(framework)` | 卸载时 | 推荐 | 清理资源 |
233
+
234
+ ### 生命周期流程
235
+
236
+ ```
237
+ install() → onStart() → (reload()) → onStop() → uninstall()
238
+ ```
239
+
240
+ ### onStart / onStop(推荐方式)
241
+
242
+ **推荐使用 `onStart` 注册工具和扩展**,基类自动处理生命周期:
243
+
244
+ ```javascript
245
+ class MyPlugin extends Plugin {
246
+ name = 'my-plugin';
247
+
248
+ onStart(framework) {
249
+ // 注册 AI SDK 工具(直接调用)
250
+ this.tool.register({
251
+ name: 'hello',
252
+ description: '打招呼',
253
+ inputSchema: framework.z.object({
254
+ name: framework.z.string().describe('姓名')
255
+ }),
256
+ execute: async (args) => ({ message: `Hello, ${args.name}!` }),
257
+ });
258
+
259
+ // 注册扩展工具(通过 ext_call 调用)
260
+ this.extension.register({
261
+ name: 'greet_ext',
262
+ description: '打招呼扩展',
263
+ inputSchema: framework.z.object({
264
+ name: framework.z.string()
265
+ }),
266
+ execute: async (args) => ({ message: `Hi, ${args.name}!` }),
267
+ });
268
+ }
269
+
270
+ onStop() {
271
+ // 清理资源(如果需要)
272
+ }
273
+ }
274
+ ```
275
+
276
+ ### 生命周期自动管理
277
+
278
+ | 操作 | 效果 |
279
+ |------|------|
280
+ | `disable` | 自动移除工具/扩展(保留注册记录) |
281
+ | `enable` | 自动重新注册工具/扩展 |
282
+ | `reload` | 移除后重新注册 |
283
+ | `uninstall` | 移除并清空注册记录 |
284
+
285
+ ---
286
+
287
+ ## 7. 工具注册
288
+
289
+ ### 方式1:`this.tools = {}`(声明式)
290
+
291
+ 插件将工具定义在 `this.tools = {}` 中,ExtensionExecutor 会自动扫描并注册:
292
+
293
+ ```javascript
294
+ class MyPlugin extends Plugin {
295
+ name = 'my-plugin';
296
+
297
+ tools = {
298
+ my_tool: {
299
+ description: '我的工具',
300
+ inputSchema: z.object({
301
+ param: z.string().describe('参数描述'),
302
+ }),
303
+ execute: async (args) => {
304
+ return { success: true, result: args.param };
305
+ },
306
+ },
307
+ };
308
+ }
309
+ ```
310
+
311
+ ### 方式2:`this.tool.register()`(推荐)
312
+
313
+ 在 `onStart` 中使用,支持完整的生命周期管理:
314
+
315
+ ```javascript
316
+ onStart(framework) {
317
+ this.tool.register({
318
+ name: 'my_tool',
319
+ description: '我的工具',
320
+ inputSchema: framework.z.object({
321
+ param: framework.z.string().describe('参数描述')
322
+ }),
323
+ execute: async (args) => {
324
+ return { success: true, result: args.param };
325
+ }
326
+ });
327
+ }
328
+ ```
329
+
330
+ ### 方式3:`this.registerTool()`(旧方式)
331
+
332
+ ```javascript
333
+ install(framework) {
334
+ this.registerTool('my_tool', {
335
+ description: '我的工具',
336
+ inputSchema: framework.z.object({
337
+ param: framework.z.string().describe('参数描述')
338
+ }),
339
+ execute: async (args) => ({ success: true })
340
+ });
341
+ return this;
342
+ }
343
+ ```
344
+
345
+ ### 工具操作
346
+
347
+ ```javascript
348
+ // 注册
349
+ this.tool.register({ name: 'xxx', ... });
350
+
351
+ // 移除
352
+ this.tool.remove('xxx');
353
+
354
+ // 执行
355
+ const result = await this.tool.execute('any-tool', { arg: 'value' });
356
+ ```
357
+
358
+ ---
359
+
360
+ ## 8. 扩展工具
361
+
362
+ 扩展工具通过 `ext_call` 间接调用,适合需要额外封装的工具。
363
+
364
+ ### 注册扩展工具
365
+
366
+ ```javascript
367
+ onStart(framework) {
368
+ this.extension.register({
369
+ name: 'my_ext_tool',
370
+ description: '扩展工具',
371
+ inputSchema: framework.z.object({
372
+ param: framework.z.string()
373
+ }),
374
+ execute: async (args) => ({ success: true, data: args.param })
375
+ });
376
+ }
377
+ ```
378
+
379
+ ### 调用扩展工具
380
+
381
+ ```javascript
382
+ // 通过 ext_call 调用
383
+ const result = await ext_call({
384
+ plugin: 'my-plugin',
385
+ tool: 'my_ext_tool',
386
+ args: { param: 'value' }
387
+ });
388
+ ```
389
+
390
+ ### 扩展工具操作
391
+
392
+ ```javascript
393
+ // 注册
394
+ this.extension.register({ name: 'xxx', ... });
395
+
396
+ // 移除
397
+ this.extension.remove('xxx');
398
+
399
+ // 执行(当前插件)
400
+ const r1 = await this.extension.execute('xxx', { arg: 'value' });
401
+
402
+ // 执行(指定插件)
403
+ const r2 = await this.extension.execute('plugin-name', 'xxx', { arg: 'value' });
404
+ ```
405
+
406
+ ---
407
+
408
+ ## 9. 子 Agent
409
+
410
+ ### 配置式注册
411
+
412
+ ```javascript
413
+ class MyPlugin extends Plugin {
414
+ name = 'my-plugin';
415
+
416
+ agents = [
417
+ {
418
+ name: 'code-agent',
419
+ role: '代码专家',
420
+ description: '处理代码开发任务',
421
+ tools: {
422
+ compile: {
423
+ description: '编译代码',
424
+ inputSchema: z.object({
425
+ language: z.string(),
426
+ code: z.string(),
427
+ }),
428
+ execute: async (args) => ({ success: true }),
429
+ },
430
+ },
431
+ parentTools: ['read_file', 'write_file'],
432
+ },
433
+ ];
434
+ }
435
+ ```
436
+
437
+ ### 手动注册
438
+
439
+ ```javascript
440
+ onStart(framework) {
441
+ this.agent.register({
442
+ name: 'code-agent',
443
+ role: '代码专家',
444
+ description: '处理代码开发任务',
445
+ tools: {
446
+ compile: {
447
+ description: '编译代码',
448
+ inputSchema: framework.z.object({
449
+ language: framework.z.string(),
450
+ code: framework.z.string(),
451
+ }),
452
+ execute: async (args) => ({ success: true }),
453
+ },
454
+ },
455
+ parentTools: ['read_file', 'write_file'],
456
+ });
457
+ }
458
+ ```
459
+
460
+ ### 子 Agent 配置说明
461
+
462
+ | 字段 | 类型 | 说明 |
463
+ |------|------|------|
464
+ | `name` | string | 子 Agent 名称(唯一标识) |
465
+ | `role` | string | 角色描述 |
466
+ | `description` | string | 详细描述 |
467
+ | `tools` | object | 自定义工具,只属于此子 Agent |
468
+ | `parentTools` | array | 从父 Agent 继承的工具名称列表 |
469
+
470
+ ### 子 Agent 操作
471
+
472
+ ```javascript
473
+ // 获取
474
+ const agent = this.agent.get('my-agent');
475
+
476
+ // 移除
477
+ this.agent.remove('my-agent');
478
+ ```
479
+
480
+ ---
481
+
482
+ ## 10. 工作流
483
+
484
+ ### 注册工作流
485
+
486
+ ```javascript
487
+ onStart(framework) {
488
+ this.workflow.register('my-workflow', {
489
+ name: 'my-workflow',
490
+ description: '我的工作流',
491
+ inputSchema: framework.z.object({
492
+ msg: framework.z.string()
493
+ }),
494
+ steps: [
495
+ { type: 'tool', tool: 'hello', args: { name: '{{inputs.msg}}' } },
496
+ { type: 'action', code: 'return { result: inputs.msg + "!" }' }
497
+ ]
498
+ });
499
+ }
500
+ ```
501
+
502
+ ### 工作流操作
503
+
504
+ ```javascript
505
+ // 执行
506
+ const result = await this.workflow.execute('my-workflow', { msg: 'hello' });
507
+
508
+ // 移除
509
+ this.workflow.remove('my-workflow');
510
+ ```
511
+
512
+ ---
513
+
514
+ ## 11. Prompt Parts
515
+
516
+ ### 配置式注册
517
+
518
+ ```javascript
519
+ class MyPlugin extends Plugin {
520
+ name = 'my-plugin';
521
+
522
+ prompts = [
523
+ {
524
+ name: 'my-prompt',
525
+ scope: 'global', // 'global' | 'main' | agentId
526
+ priority: 50, // 数字越小越靠前
527
+ description: '我的提示词',
528
+ provider() {
529
+ return '这是动态生成的提示词内容';
530
+ },
531
+ },
532
+ ];
533
+ }
534
+ ```
535
+
536
+ ### 手动注册
537
+
538
+ ```javascript
539
+ onStart(framework) {
540
+ this.prompt.register('my-prompt', () => 'Prompt content', {
541
+ scope: 'global',
542
+ priority: 50
543
+ });
544
+ }
545
+ ```
546
+
547
+ ### Prompt 操作
548
+
549
+ ```javascript
550
+ // 移除
551
+ this.prompt.remove('my-prompt');
552
+ ```
553
+
554
+ ---
555
+
556
+ ## 12. Skill 管理
557
+
558
+ ### 注册 Skill
559
+
560
+ ```javascript
561
+ onStart(framework) {
562
+ this.skill.register(
563
+ 'my-skill',
564
+ '# 我的技能\n\n这是一个技能说明',
565
+ {
566
+ description: '技能描述',
567
+ tags: ['test'],
568
+ },
569
+ {
570
+ commands: [
571
+ {
572
+ name: 'do',
573
+ description: '执行操作',
574
+ options: [
575
+ { flags: '-x <x>', description: '参数', required: true },
576
+ ],
577
+ execute: async (args) => ({ x: args.x }),
578
+ },
579
+ ],
580
+ }
581
+ );
582
+ }
583
+ ```
584
+
585
+ ### Skill 操作
586
+
587
+ ```javascript
588
+ // 检查是否存在
589
+ this.skill.has('my-skill');
590
+
591
+ // 获取详情
592
+ this.skill.get('my-skill');
593
+ this.skill.details('my-skill');
594
+
595
+ // 列出
596
+ this.skill.list();
597
+ this.skill.listOwned(); // 本插件注册的
598
+
599
+ // 执行
600
+ await this.skill.execute('my-skill:do', { x: 1 });
601
+
602
+ // 移除
603
+ this.skill.remove('my-skill');
604
+ ```
605
+
606
+ ---
607
+
608
+ ## 13. 依赖管理
609
+
610
+ ### 依赖安装位置
611
+
612
+ | 插件类型 | 安装位置 | 说明 |
613
+ |----------|----------|------|
614
+ | **文件夹插件** | `.foliko/plugins/插件名/node_modules/` | 插件自包含,可独立迁移 |
615
+ | **单文件插件** | `.foliko/node_modules/` | 共享依赖 |
616
+
617
+ ### 安装工具
618
+
619
+ ```javascript
620
+ // 安装到默认位置 .foliko/node_modules
621
+ install({ package: 'zod' });
622
+
623
+ // 安装到指定目录(文件夹插件)
624
+ install({ package: 'axios', path: '.foliko/plugins/my-plugin' });
625
+
626
+ // 从 package.json 安装
627
+ install({ file: './my-plugin/package.json', path: '.foliko/plugins/my-plugin' });
628
+ ```
629
+
630
+ ### 重要依赖版本要求
631
+
632
+ **zod 必须使用 `"zod": "^3.25.76"` 版本**
633
+
634
+ ---
635
+
636
+ ## 14. 系统插件访问
637
+
638
+ ### 可访问的系统插件
639
+
640
+ | 插件名 | 说明 | 可用方法 |
641
+ |--------|------|----------|
642
+ | `storage` | 键值对存储 | `get(key)`, `set(key, value)`, `delete(key)` |
643
+ | `session` | 会话管理 | `getSession(id)`, `getHistory(id)` |
644
+ | `audit` | 审计日志 | `log(type, data)` |
645
+ | `scheduler` | 定时任务 | `addTask()`, `removeTask()` |
646
+ | `file-system` | 文件操作 | 工具已注册: `read_file`, `write_file` |
647
+ | `tools` | 工具管理 | `list()`, `reload()` |
648
+ | `ai` | AI 配置 | `getAIClient()` |
649
+
650
+ ### 访问示例
651
+
652
+ ```javascript
653
+ install(framework) {
654
+ // 访问 storage 插件
655
+ const storage = this._framework.pluginManager.get('storage');
656
+
657
+ // 访问 session 插件
658
+ const session = this._framework.pluginManager.get('session');
659
+
660
+ // 访问 audit 插件
661
+ const audit = this._framework.pluginManager.get('audit');
662
+ }
663
+ ```
664
+
665
+ ### Storage 插件 API
666
+
667
+ ```javascript
668
+ const storage = this._framework.pluginManager.get('storage');
669
+
670
+ // 方式1:通过工具调用(推荐)
671
+ // storage_get({ key: 'xxx', namespace: 'yyy' })
672
+ // storage_set({ key: 'xxx', value: {...}, namespace: 'yyy' })
673
+
674
+ // 方式2:直接调用内部方法
675
+ const value = storage.getStore().get('key');
676
+ storage.setDirect('key', value);
677
+ storage.deleteDirect('key');
678
+ ```
679
+
680
+ ### Session 插件 API
681
+
682
+ ```javascript
683
+ const session = this._framework.pluginManager.get('session');
684
+
685
+ // 获取会话
686
+ const sess = session.getSession(sessionId);
687
+
688
+ // 获取历史消息
689
+ const messages = session.getHistory(sessionId);
690
+
691
+ // 添加消息
692
+ session.addMessage(sessionId, { role: 'user', content: 'hello' });
693
+ ```
694
+
695
+ ---
696
+
697
+ ## 15. 最佳实践
698
+
699
+ ### 核心规则
700
+
701
+ | 规则 | 说明 |
702
+ |------|------|
703
+ | **优先使用 this.tools = {}** | ExtensionExecutor 自动扫描并注册到系统提示词 |
704
+ | **优先使用文件夹结构** | 复杂插件推荐用文件夹,便于管理依赖和代码 |
705
+ | **必须用 inputSchema** | `inputSchema: z.object({})`(不是 parameters!) |
706
+ | **必须用 zod 定义参数** | `param: z.string().describe('描述')` |
707
+ | **install 必须返回 this** | 确保链式调用 |
708
+ | **文件夹与文件重名时** | 文件夹优先,同名 `.js` 文件会被忽略 |
709
+ | **工具自动出现在系统提示词** | 定义在 `this.tools` 中会自动注册 |
710
+
711
+ ### 开发流程
712
+
713
+ 1. **创建插件**(优先使用文件夹结构)
714
+ 2. **安装依赖**:`install { package: "包名", path: ".foliko/plugins/插件名" }`
715
+ 3. **热重载**:`reload_plugins`
716
+ 4. **检查加载状态**
717
+
718
+ ### 引入第三方库
719
+
720
+ **必须先安装依赖,再热重载!**
721
+
722
+ ```javascript
723
+ // .foliko/plugins/my-plugin/index.js
724
+ const { z } = require('zod');
725
+
726
+ module.exports = function (Plugin) {
727
+ return class MyPlugin extends Plugin {
728
+ name = 'my-plugin';
729
+
730
+ tools = {
731
+ fetch_data: {
732
+ description: '获取远程数据',
733
+ inputSchema: z.object({
734
+ url: z.string().describe('API URL'),
735
+ }),
736
+ execute: async (args) => {
737
+ // 安装到插件自己的目录
738
+ await this._framework.callTool('install', {
739
+ package: 'axios',
740
+ path: '.foliko/plugins/my-plugin',
741
+ });
742
+
743
+ // 引入插件目录下的 node_modules
744
+ const axios = require('.foliko/plugins/my-plugin/node_modules/axios');
745
+ const response = await axios.get(args.url);
746
+ return { success: true, data: response.data };
747
+ },
748
+ },
749
+ };
750
+ };
751
+ };
752
+ ```
753
+
754
+ ### 错误处理
755
+
756
+ ```javascript
757
+ onStart(framework) {
758
+ try {
759
+ this.tool.register({ ... });
760
+ } catch (error) {
761
+ framework.logger.error(`[${this.name}] Failed to register tool: ${error.message}`);
762
+ }
763
+ }
764
+
765
+ uninstall(framework) {
766
+ // 确保清理所有资源
767
+ this.tool.remove('my_tool');
768
+ this.extension.remove('my_ext');
769
+ this.agent.remove('my-agent');
770
+ }
771
+ ```
772
+
773
+ ---
774
+
775
+ ## 示例插件
776
+
777
+ ### 完整示例:天气插件
778
+
779
+ ```javascript
780
+ // .foliko/plugins/weather/index.js
781
+ const { z } = require('zod');
782
+
783
+ module.exports = function (Plugin) {
784
+ return class WeatherPlugin extends Plugin {
785
+ constructor() {
786
+ super();
787
+ this.name = 'weather';
788
+ this.version = '1.0.0';
789
+ this.description = '天气查询插件';
790
+ }
791
+
792
+ tools = {
793
+ get_weather: {
794
+ description: '查询指定城市的天气',
795
+ inputSchema: z.object({
796
+ city: z.string().describe('城市名称'),
797
+ units: z.enum(['celsius', 'fahrenheit']).optional().describe('温度单位,默认 celsius'),
798
+ }),
799
+ execute: async (args) => {
800
+ // 这里应该是真实的 API 调用
801
+ return {
802
+ city: args.city,
803
+ temperature: 25,
804
+ units: args.units || 'celsius',
805
+ condition: 'Sunny',
806
+ };
807
+ },
808
+ },
809
+ };
810
+
811
+ onStart(framework) {
812
+ // 注册额外的工具
813
+ this.tool.register({
814
+ name: 'get_forecast',
815
+ description: '获取天气预报',
816
+ inputSchema: framework.z.object({
817
+ city: framework.z.string().describe('城市名称'),
818
+ days: framework.z.number().min(1).max(7).default(3).describe('预报天数'),
819
+ }),
820
+ execute: async (args) => {
821
+ return {
822
+ city: args.city,
823
+ forecast: Array.from({ length: args.days }, (_, i) => ({
824
+ day: i + 1,
825
+ temp: 20 + Math.floor(Math.random() * 10),
826
+ condition: ['Sunny', 'Cloudy', 'Rainy'][Math.floor(Math.random() * 3)],
827
+ })),
828
+ };
829
+ },
830
+ });
831
+ }
832
+
833
+ onStop() {
834
+ // 清理资源
835
+ }
836
+
837
+ uninstall(framework) {
838
+ // 完全清理
839
+ }
840
+ };
841
+ };
842
+ ```
843
+
844
+ ---
845
+
846
+ ## 相关文档
847
+
848
+ - [扩展系统](./extensions.md) - ext_call、ext_skill 统一调用
849
+ - [Public API](./public-api.md) - 框架公共 API 参考
850
+ - [架构文档](./architecture.md) - 系统架构设计
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foliko",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
4
4
  "description": "简约的插件化 Agent 框架",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
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
- logger.info(`[_prepareSession] BEFORE: messages=${messages.length}, tokens=${totalTokens}/${limit}, threshold=${this._compressionMessageThreshold}`);
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
- // 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
- }
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 = self._countMessagesTokens(inputMessages);
1404
- const tokenLimit = self._maxContextTokens * SMART_COMPRESS_TOKEN_RATIO;
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 = self._repairInvalidToolCallsInMessages(inputMessages);
1423
- self._repairAndPrefixHistoricalIds(inputMessages);
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 上一步加进来的)
@@ -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,40 +194,13 @@ 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
- // ★ 检测是否有 reasoning 内容(thinking mode 必须携带)
224
- const hasReasoningInContent = Array.isArray(msg.content) && msg.content.some(p => p && p.type === 'reasoning');
225
- const hasReasoningTopLevel = msg.reasoning_content || msg.reasoning;
197
+ // ★ 提取 text + tool_calls(兼容 content=string、content=array、顶层 tool_calls 三种格式)
198
+ let text = '';
199
+ const toolCalls = [];
226
200
 
227
201
  if (typeof msg.content === 'string') {
228
- if (msg.tool_calls && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
229
- // thinking mode 下,无 reasoning 则自动补 placeholder
230
- const reasonVal = hasReasoningTopLevel || (this._thinkingMode ? '...' : '');
231
- api.push({ role: 'assistant', content: msg.content || null, tool_calls: msg.tool_calls, ...(reasonVal ? { reasoning_content: reasonVal } : {}) });
232
- } else {
233
- api.push({ role: 'assistant', content: msg.content });
234
- }
202
+ text = msg.content;
235
203
  } else if (Array.isArray(msg.content)) {
236
- let text = '';
237
- const toolCalls = [];
238
204
  for (const part of msg.content) {
239
205
  if (!part) continue;
240
206
  if (part.type === 'text') text += part.text;
@@ -249,50 +215,39 @@ class ToolLoop extends EventEmitter {
249
215
  });
250
216
  }
251
217
  }
218
+ }
252
219
 
253
- if (toolCalls.length > 0) {
254
- const reasonVal = hasReasoningTopLevel || (this._thinkingMode ? '...' : '');
255
- api.push({ role: 'assistant', content: text || null, tool_calls: toolCalls, ...(reasonVal ? { reasoning_content: reasonVal } : {}) });
256
- } else {
257
- api.push({ role: 'assistant', content: text });
220
+ // 合并顶层 tool_calls(OpenAI 格式,来自 session 文件)
221
+ const existingIds = new Set(toolCalls.map(tc => tc.id));
222
+ if (msg.tool_calls && Array.isArray(msg.tool_calls)) {
223
+ for (const tc of msg.tool_calls) {
224
+ if (!existingIds.has(tc.id)) toolCalls.push(tc);
225
+ existingIds.add(tc.id);
258
226
  }
227
+ }
228
+
229
+ if (toolCalls.length > 0) {
230
+ // ★ 核心:thinking mode 下,所有带 tool_calls 的消息都必须有 reasoning_content
231
+ // 无条件注入,不依赖 hasReasoningTopLevel 检测
232
+ const reasonVal = msg.reasoning_content || msg.reasoning || (this._thinkingMode ? '...' : '');
233
+ api.push({ role: 'assistant', content: text || null, tool_calls: toolCalls, ...(reasonVal ? { reasoning_content: reasonVal } : {}) });
259
234
  } else {
260
- api.push({ role: 'assistant', content: String(msg.content ?? '') });
235
+ api.push({ role: 'assistant', content: text || '' });
261
236
  }
262
237
  } else if (msg.role === 'tool') {
263
238
  if (Array.isArray(msg.content)) {
264
239
  for (const part of msg.content) {
265
240
  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
241
  const output = part.output;
279
242
  let content;
280
243
  if (output?.type === 'text') content = output.value;
281
244
  else if (output?.type === 'json') content = JSON.stringify(output.value);
282
245
  else content = typeof output === 'string' ? output : JSON.stringify(output);
283
- api.push({ role: 'tool', tool_call_id: id, content });
246
+ api.push({ role: 'tool', tool_call_id: part.toolCallId, content });
284
247
  }
285
248
  } else if (msg.tool_call_id && typeof msg.content === 'string') {
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
- }
249
+ // 已经是 OpenAI 格式(streaming 完成后的消息)
250
+ api.push(msg);
296
251
  }
297
252
  }
298
253
  }
@@ -494,12 +449,6 @@ class ToolLoop extends EventEmitter {
494
449
 
495
450
  let response;
496
451
  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
452
  response = await client.chat.completions.create({
504
453
  model,
505
454
  messages: apiMessages,
@@ -597,13 +546,6 @@ class ToolLoop extends EventEmitter {
597
546
 
598
547
  let stream;
599
548
  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
549
  stream = await client.chat.completions.create({
608
550
  model,
609
551
  messages: apiMessages,
@@ -24,7 +24,7 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
24
24
  const DEFAULT_TEMPERATURE = 0.3;
25
25
 
26
26
  /** 默认 Max Steps(工具调用最大轮数) */
27
- const DEFAULT_MAX_STEPS = 1000;
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 = 120;
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
- return Math.ceil(chars / 4);
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
- return Math.ceil(chars / 4);
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
- return Math.ceil(chars / 4);
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
- return Math.ceil(chars / 4);
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
- return Math.ceil(chars / 4);
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
  }
@@ -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
- const content = `${this.fileEntries.map(e => JSON.stringify(e)).join('\n')}\n`;
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
- for (const e of this.fileEntries) {
195
- fs.appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
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`);
@@ -136,7 +136,8 @@ class JsonlSessionStorage {
136
136
  }
137
137
 
138
138
  static async _loadJsonlStorage(fsModule, filePath) {
139
- const content = fsModule.readFileSync(filePath, 'utf-8');
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.appendFileSync(this.filePath, JSON.stringify(entry) + '\n');
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
- fs.appendFileSync(this.filePath, JSON.stringify(entry) + '\n');
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 消息无对应 assistant 时被过滤(防止 orphaned tool-result)', () => {
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
- // orphaned tool-result 被过滤,只有 system 消息
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 消息', () => {