foliko 2.0.21 → 2.0.22

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.21",
3
+ "version": "2.0.22",
4
4
  "description": "简约的插件化 Agent 框架",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
@@ -61,6 +61,7 @@
61
61
  "@chnak/weixin-bot": "^1.2.9",
62
62
  "@chnak/zod-to-markdown": "1.0.7",
63
63
  "@earendil-works/pi-tui": "^0.75.3",
64
+ "@frsource/frs-replace": "^5.1.117",
64
65
  "@hono/node-server": "^1.19.11",
65
66
  "@larksuiteoapi/node-sdk": "^1.59.0",
66
67
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -272,12 +272,11 @@ function buildSchemaFromType(type, desc, defaultValue) {
272
272
  */
273
273
  function buildInputSchemaFromOptions(options) {
274
274
  if (!Array.isArray(options) || options.length === 0) {
275
- return { type: 'object', properties: {}, required: [] };
275
+ // 返回 zod schema(即使是空的也返回),确保 _doExecute 走校验路径
276
+ return require('zod').z.object({}).passthrough();
276
277
  }
277
278
 
278
- const properties = {};
279
- const required = [];
280
-
279
+ const shape = {};
281
280
  for (const opt of options) {
282
281
  const name = extractOptionName(opt.flags);
283
282
  if (!name) continue;
@@ -287,33 +286,45 @@ function buildInputSchemaFromOptions(options) {
287
286
  const desc = opt.description || '';
288
287
  const defaultVal = opt.defaultValue;
289
288
 
290
- let jsonType = 'string';
289
+ // 关键修复:返回 zod schema(不是纯 JSON Schema),
290
+ // 这样 _doExecute 里的 toolDef.inputSchema.safeParse 才会真的执行校验。
291
+ // 之前返回 JSON Schema 对象没有 .safeParse 方法,校验被完全跳过。
292
+ // 导致 LLM 传 args:{} 不会报错,直接到达工具 → args.id undefined。
293
+ const z = require('zod').z;
294
+ let zodField;
291
295
  if (opt.schema && typeof opt.schema.parse === 'function') {
292
- // Zod 实例猜测类型
293
- const s = opt.schema;
294
- if (s._def.typeName === 'ZodNumber' || s._def.typeName === 'ZodInt') jsonType = 'number';
295
- else if (s._def.typeName === 'ZodBoolean') jsonType = 'boolean';
296
- else if (s._def.typeName === 'ZodArray') jsonType = 'array';
297
- else if (s._def.typeName === 'ZodObject' || s._def.typeName === 'ZodRecord') jsonType = 'object';
298
- } else if (opt.type) {
299
- const L = opt.type.toLowerCase();
300
- if (L === 'number' || L === 'int' || L === 'integer') jsonType = 'number';
301
- else if (L === 'boolean' || L === 'bool') jsonType = 'boolean';
302
- else if (L === 'array') jsonType = 'array';
303
- else if (L === 'object') jsonType = 'object';
296
+ zodField = opt.schema;
304
297
  } else {
305
- jsonType = hasValue ? 'string' : 'boolean';
298
+ const type = (opt.type || (hasValue ? 'string' : 'boolean')).toLowerCase();
299
+ if (type === 'number' || type === 'int' || type === 'integer') {
300
+ zodField = z.number();
301
+ } else if (type === 'boolean' || type === 'bool') {
302
+ zodField = z.boolean();
303
+ } else if (type === 'array') {
304
+ zodField = z.array(z.any());
305
+ } else if (type === 'object') {
306
+ zodField = z.object({}).passthrough();
307
+ } else {
308
+ zodField = z.string();
309
+ }
306
310
  }
307
311
 
308
- const prop = { type: jsonType, description: desc };
312
+ if (!isRequired) {
313
+ zodField = zodField.optional();
314
+ }
309
315
  if (defaultVal !== undefined && defaultVal !== null) {
310
- prop.default = defaultVal;
316
+ zodField = zodField.default(defaultVal);
311
317
  }
312
- properties[name] = prop;
313
- if (isRequired) required.push(name);
318
+ // 描述里加 (必填) 标记,方便 LLM 看到
319
+ zodField = zodField.describe(desc + (isRequired ? '(必填)' : ''));
320
+
321
+ shape[name] = zodField;
314
322
  }
315
323
 
316
- return { type: 'object', properties, required };
324
+ if (Object.keys(shape).length === 0) {
325
+ return require('zod').z.object({}).passthrough();
326
+ }
327
+ return require('zod').z.object(shape).passthrough();
317
328
  }
318
329
 
319
330
  /**
@@ -26,6 +26,16 @@ class SubAgentPlugin extends Plugin {
26
26
  this.llmConfig = config.llmConfig || null
27
27
  this.hidden = config.hidden || false // 隐藏子Agent,不在指令系统中显示
28
28
 
29
+ // ★ 新增:frontmatter 之外的配置
30
+ // systemPrompt: 来自 markdown body(自动提取)
31
+ // skills: 来自 frontmatter 的 `skills: a, b, c` 字段,技能名数组
32
+ this.systemPrompt = config.systemPrompt || ''
33
+ this.skills = Array.isArray(config.skills)
34
+ ? config.skills
35
+ : (typeof config.skills === 'string'
36
+ ? config.skills.split(',').map(s => s.trim()).filter(Boolean)
37
+ : [])
38
+
29
39
  this.config = {}
30
40
 
31
41
  this._framework = null
@@ -266,6 +276,24 @@ class SubAgentPlugin extends Plugin {
266
276
  }
267
277
 
268
278
  _getFullSystemPrompt() {
279
+ // ★ 优先用 markdown body 提取的 systemPrompt(来自 _parseMarkdownConfig)
280
+ // 然后叠加 skills 列表(自动从 skill-manager 查询命令并注入)
281
+ const base = this._buildBaseSystemPrompt();
282
+ const skillsSection = this._buildSkillsSection();
283
+ if (skillsSection) {
284
+ return base + '\n\n' + skillsSection;
285
+ }
286
+ return base;
287
+ }
288
+
289
+ _buildBaseSystemPrompt() {
290
+ // 1) 显式配置的 systemPrompt 优先
291
+ if (this.systemPrompt && this.systemPrompt.trim()) {
292
+ // 已经是 markdown body(含 "你是..." 这类角色定义)
293
+ const header = `# ${this.name}\n\n${this.description || ''}`.trim();
294
+ return `${header}\n\n${this.systemPrompt.trim()}`;
295
+ }
296
+ // 2) 回退到 subAgentConfigManager
269
297
  const configManager = this._framework._subAgentConfigManager;
270
298
  if (configManager) {
271
299
  const config = configManager.get(this.name);
@@ -273,10 +301,43 @@ class SubAgentPlugin extends Plugin {
273
301
  return config.getSystemPrompt();
274
302
  }
275
303
  }
276
-
304
+ // 3) 最后回退到 _buildSystemPrompt(旧逻辑)
277
305
  return this._buildSystemPrompt();
278
306
  }
279
307
 
308
+ /**
309
+ * 根据 this.skills 列表,从 skill-manager 查询每个 skill 的命令,
310
+ * 生成 "## 可用技能" section 注入 systemPrompt
311
+ * @private
312
+ */
313
+ _buildSkillsSection() {
314
+ if (!this.skills || this.skills.length === 0) return '';
315
+ const extExecutor = this._framework?.pluginManager?.get('extension-executor');
316
+ if (!extExecutor) return '';
317
+
318
+ const lines = ['## 🛠️ 可用技能', ''];
319
+ let hasContent = false;
320
+ for (const skillName of this.skills) {
321
+ // 通过 ext_call 拿 skill 下的所有 tool
322
+ const tools = extExecutor.getExtensionTools(`skill:${skillName}`) || [];
323
+ if (tools.length === 0) {
324
+ lines.push(`### ${skillName}`);
325
+ lines.push('(该技能尚未注册或无可用命令)');
326
+ lines.push('');
327
+ continue;
328
+ }
329
+ hasContent = true;
330
+ lines.push(`### ${skillName}`);
331
+ lines.push('通过 ext_call 调用,格式:`ext_call({plugin: "skill:' + skillName + '", tool: "<命令>", args: {...}})`');
332
+ lines.push('');
333
+ for (const tool of tools) {
334
+ lines.push(`- **${tool.name}**: ${tool.description || '(无描述)'}`);
335
+ }
336
+ lines.push('');
337
+ }
338
+ return hasContent ? lines.join('\n') : '';
339
+ }
340
+
280
341
  _updateAgentSystemPrompt() {
281
342
  if (!this._agent) return;
282
343
  const systemPrompt = this._getFullSystemPrompt();
@@ -545,27 +606,55 @@ class SubAgentManagerPlugin extends Plugin {
545
606
  _parseMarkdownConfig(filePath, defaultName) {
546
607
  const content = fs.readFileSync(filePath, 'utf-8')
547
608
 
548
- const jsonMatch = content.match(/```(?:json)?\s*\n([\s\S]*?)\n\s*```/)
549
- if (jsonMatch) {
550
- try {
551
- return JSON.parse(jsonMatch[1].trim())
552
- } catch (err) {
553
- }
554
- }
555
-
609
+ // 修复:优先解析 frontmatter(如果文件以 --- 开头)
610
+ // 之前的 bug:先匹配第一个 ```json 代码块,导致 body 里的示例 JSON 被当作 config。
611
+ // 例:news-pipeline-agent.md 里有示例 ```json { "news_list": [...] } ```,
612
+ // 这个示例被当成 config 解析,结果没有 name 字段,注册失败。
613
+ let config = null
556
614
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
557
615
  if (frontmatterMatch) {
558
616
  const frontmatterContent = frontmatterMatch[1]
559
617
  const parsed = this._parseYamlLike(frontmatterContent)
560
618
  if (parsed && parsed.name) {
561
- return { name: parsed.name || defaultName, ...parsed }
619
+ config = { name: parsed.name || defaultName, ...parsed }
562
620
  }
621
+ // frontmatter 存在但没有 name,回退到 body JSON 块
563
622
  }
564
623
 
565
- const config = { name: defaultName }
566
- const yamlMatch = content.match(/^(name|role|description|parentTools|tools):\s*(.+)$/m)
567
- if (yamlMatch) {
568
- config[yamlMatch[1]] = yamlMatch[2].trim()
624
+ // 回退:找第一个 JSON 代码块作为 config(仅当无 frontmatter 时)
625
+ if (!config) {
626
+ const jsonMatch = content.match(/```(?:json)?\s*\n([\s\S]*?)\n\s*```/)
627
+ if (jsonMatch) {
628
+ try {
629
+ const parsed = JSON.parse(jsonMatch[1].trim())
630
+ if (parsed && parsed.name) {
631
+ config = parsed
632
+ }
633
+ // JSON 块没有 name 字段 → 视为示例代码,跳过
634
+ } catch (err) {
635
+ // 解析失败也跳过
636
+ }
637
+ }
638
+ }
639
+
640
+ // 最后回退:尝试匹配文件首行 key: value(不带 m flag,避免匹配 body 中间的内容)
641
+ if (!config) {
642
+ config = { name: defaultName }
643
+ const yamlMatch = content.match(/^(name|role|description|parentTools|tools):\s*(.+)$/)
644
+ if (yamlMatch) {
645
+ config[yamlMatch[1]] = yamlMatch[2].trim()
646
+ }
647
+ }
648
+
649
+ // ★ 新增:提取 markdown body 作为 systemPrompt
650
+ // 移除 frontmatter 后剩下的内容(去掉首个 # 标题和首尾空白)
651
+ let body = content
652
+ if (frontmatterMatch) {
653
+ body = content.slice(frontmatterMatch[0].length)
654
+ }
655
+ body = body.replace(/^\s*#\s+[^\n]*\n+/, '').trim()
656
+ if (body && body.length > 20) {
657
+ config.systemPrompt = body
569
658
  }
570
659
 
571
660
  return config
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { z } = require('zod');
4
+
3
5
  /**
4
6
  * ExtensionRegistry - 扩展注册表
5
7
  * 管理扩展插件及其工具的增删改查
@@ -39,7 +41,7 @@ class ExtensionRegistry {
39
41
  const toolEntry = {
40
42
  name: toolDef.name,
41
43
  description: toolDef.description || '',
42
- inputSchema: toolDef.inputSchema,
44
+ inputSchema: toolDef.inputSchema || ExtensionRegistry._buildSchemaFromOptions(toolDef.options),
43
45
  execute: toolDef.execute,
44
46
  };
45
47
  if (toolDef._options) {
@@ -58,6 +60,75 @@ class ExtensionRegistry {
58
60
  return true;
59
61
  }
60
62
 
63
+ /**
64
+ * ★ 关键:legacy `options` 格式(CLI flags 风格)自动转 zod schema
65
+ * 场景:video-creator 等 skill 还在用 `options: [{flags, description, required, ...}]`
66
+ * 之前 framework 收不到 inputSchema,参数校验被完全跳过 → LLM 传 args:{} 不会报错
67
+ * 导致 LLM 反复陷入"我传了 id 但工具说 id undefined"的死循环
68
+ *
69
+ * @param {Array} options legacy 格式数组
70
+ * @returns {Object|null} zod object schema 或 null(无 options 时)
71
+ */
72
+ static _buildSchemaFromOptions(options) {
73
+ if (!Array.isArray(options) || options.length === 0) return null;
74
+ const shape = {};
75
+ for (const opt of options) {
76
+ // 解析 flags 找参数名:'-i, --id <value>' → 'id'
77
+ const flagName = ExtensionRegistry._parseFlagName(opt.flags);
78
+ if (!flagName) continue;
79
+
80
+ // 构造 zod 字段
81
+ let zodField;
82
+ const type = (opt.type || 'string').toLowerCase();
83
+ const isRequired = opt.required === true;
84
+
85
+ if (type === 'number') {
86
+ zodField = z.number();
87
+ if (opt.defaultValue !== undefined) zodField = zodField.default(opt.defaultValue);
88
+ if (!isRequired) zodField = zodField.optional();
89
+ } else if (type === 'boolean') {
90
+ zodField = z.boolean();
91
+ if (opt.defaultValue !== undefined) zodField = zodField.default(opt.defaultValue);
92
+ if (!isRequired) zodField = zodField.optional();
93
+ } else {
94
+ // 字符串
95
+ zodField = z.string();
96
+ if (opt.defaultValue !== undefined && opt.defaultValue !== '') {
97
+ zodField = zodField.default(String(opt.defaultValue));
98
+ }
99
+ if (!isRequired) {
100
+ zodField = zodField.optional();
101
+ }
102
+ }
103
+
104
+ // 加 description(LLM 看得到)
105
+ const desc = opt.description || flagName;
106
+ zodField = zodField.describe(desc + (isRequired ? '(必填)' : ''));
107
+
108
+ shape[flagName] = zodField;
109
+ }
110
+ if (Object.keys(shape).length === 0) return null;
111
+ return z.object(shape);
112
+ }
113
+
114
+ /**
115
+ * 从 flags 字符串提取参数名
116
+ * 格式:'-i, --id <value>' → 'id'
117
+ * '--name <value>' → 'name'
118
+ * '-x, --transition [value]' → 'transition'
119
+ * '-f, --flag'(无 value)→ 'flag'
120
+ */
121
+ static _parseFlagName(flags) {
122
+ if (typeof flags !== 'string') return null;
123
+ // 匹配短或长 flag:-x 或 --xxx
124
+ const match = flags.match(/--([a-zA-Z][\w-]*)/);
125
+ if (match) return match[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase());
126
+ // 短 flag: -x
127
+ const short = flags.match(/(?:^|\s)-([a-zA-Z])(?:\s|,|$)/);
128
+ if (short) return short[1];
129
+ return null;
130
+ }
131
+
61
132
  /**
62
133
  * 注销扩展工具
63
134
  * @returns {boolean} 是否真正移除了扩展
@@ -219,28 +219,35 @@ class ExtensionExecutorPlugin extends Plugin {
219
219
  description:
220
220
  '【⚡ 执行扩展工具】调用某个扩展插件的具体工具。\n' +
221
221
  '\n' +
222
- '**必传参数**:\n' +
222
+ '**三个参数都必须传**(即使 args 是空对象也要写成 `args: {}`):\n' +
223
223
  '- `plugin`:插件名(命名空间支持 4 种:普通插件名 `email` / MCP 服务器 `mcp:<server>` / Skill `skill:<name>` / Python 插件 `python:<plugin>`)\n' +
224
224
  '- `tool`:工具名(来自 ext_list 或 ext_skill 输出)\n' +
225
- '- `args`:参数对象(每个工具的必填项见 ext_skill 输出)\n' +
225
+ '- `args`:**参数对象**。绝大多数工具都需要参数(id、name、path 等),传 `args: {}` 会导致"参数缺失"错误\n' +
226
226
  '\n' +
227
- '**推荐使用顺序**:`ext_list` 查看所有可用插件 → `ext_skill` 查看参数细节 → `ext_call` 执行\n' +
227
+ '**🚨 重要工作流**:\n' +
228
+ '1. 先调 `ext_list` 找到工具名\n' +
229
+ '2. **必须先调 `ext_skill({plugin: "..."})` 拿到该工具的完整参数 schema**(必填项、类型)\n' +
230
+ '3. 按 schema 填好 `args` 再调 `ext_call`\n' +
228
231
  '\n' +
229
- '**调用示例**:\n' +
232
+ '**调用示例**(多参数必填):\n' +
230
233
  '```js\n' +
231
234
  'ext_call({\n' +
232
- ' plugin: "mcp:designmd",\n' +
233
- ' tool: "search_design_kits",\n' +
234
- ' args: { query: "modern" }\n' +
235
+ ' plugin: "skill:video-creator",\n' +
236
+ ' tool: "addCover",\n' +
237
+ ' args: { id: "abc123", title: "视频标题", subtitle: "副标题", duration: 3, background: "#0a0e27" }\n' +
235
238
  '})\n' +
236
239
  '```\n' +
237
240
  '\n' +
238
241
  '**返回值**:`{success: true, data: <工具返回的数据>}` 或 `{success: false, error: "<错误信息>"}`\n' +
239
- '**注意**:调用前必须先调 ext_skill 确认参数格式和必填项,否则可能因参数缺失失败',
242
+ '\n' +
243
+ '**常见错误**:\n' +
244
+ '- `参数缺失:...` → 你传了 `args: {}`,但工具需要参数。先调 `ext_skill` 查必填字段\n' +
245
+ '- `视频项目不存在: undefined` → `args.id` 没传,参考 ext_skill 输出的 `id` 必填项\n',
240
246
  inputSchema: z.object({
241
247
  plugin: z.string().describe('插件名称(如 email, gate-trading, mcp:designmd, skill:my-skill, python:stock)'),
242
248
  tool: z.string().describe('工具名称(来自 ext_list 输出)'),
243
- args: z.record(z.any()).optional().describe('工具参数(对象格式,详见 ext_skill 输出)'),
249
+ // ★ 关键:保持 optional LLM 调用简单,但 description 强调必填
250
+ args: z.record(z.any()).optional().describe('⚠️ 必填!工具参数对象。必须先调 ext_skill({plugin: "..."}) 查必填项,然后传完整对象'),
244
251
  }),
245
252
  execute: async (args) => {
246
253
  const { plugin, tool, args: toolArgs = {} } = args;
@@ -358,6 +365,10 @@ class ExtensionExecutorPlugin extends Plugin {
358
365
  if (toolDef.inputSchema && typeof toolDef.inputSchema.safeParse === 'function') {
359
366
  const parsed = toolDef.inputSchema.safeParse(toolArgs);
360
367
  if (!parsed.success) {
368
+ // ★ 优化:如果 args 是空对象且 schema 有必填项,给 LLM 更明确的提示
369
+ if (toolArgs && typeof toolArgs === 'object' && Object.keys(toolArgs).length === 0) {
370
+ return this._emptyArgsError(plugin, tool, toolArgs, toolDef, parsed.error);
371
+ }
361
372
  return this._paramValidationError(plugin, tool, parsed.error);
362
373
  }
363
374
  }
@@ -432,6 +443,54 @@ class ExtensionExecutorPlugin extends Plugin {
432
443
  };
433
444
  }
434
445
 
446
+ /**
447
+ * 专门处理 args={} 的情况 —— LLM 经常忘记传 args
448
+ * 给一个非常具体的错误,让 LLM 自我纠正
449
+ * @private
450
+ */
451
+ _emptyArgsError(plugin, tool, toolArgs, toolDef, zodError) {
452
+ // 尝试从 toolDef 的 inputSchema 提取必填字段
453
+ let requiredFields = [];
454
+ try {
455
+ const schemaShape = toolDef.inputSchema?.shape || toolDef.inputSchema?._def?.schema?.shape;
456
+ if (schemaShape) {
457
+ for (const [key, value] of Object.entries(schemaShape)) {
458
+ // zod 字段上 isOptional() 返回 false 表示必填
459
+ if (value && typeof value.isOptional === 'function' && !value.isOptional()) {
460
+ requiredFields.push(key);
461
+ }
462
+ }
463
+ }
464
+ } catch { /* ignore */ }
465
+
466
+ const requiredHint = requiredFields.length > 0
467
+ ? `\n 必填字段:${requiredFields.join(', ')}`
468
+ : '';
469
+
470
+ // 直接从 issues 中提取 path 作为必填字段
471
+ if (requiredFields.length === 0 && zodError?.issues) {
472
+ const paths = new Set();
473
+ for (const iss of zodError.issues) {
474
+ if (iss.path && iss.path.length > 0) paths.add(iss.path[0]);
475
+ }
476
+ if (paths.size > 0) {
477
+ requiredFields = Array.from(paths);
478
+ }
479
+ }
480
+
481
+ const finalHint = requiredFields.length > 0
482
+ ? `\n 必填字段:${requiredFields.join(', ')}`
483
+ : '';
484
+
485
+ return {
486
+ success: false,
487
+ error: `参数缺失:'${plugin}:${tool}' 调用时 args 为空对象 {}。该工具需要参数。`,
488
+ receivedArgs: toolArgs,
489
+ hint: `请使用 ext_skill({ plugin: "${plugin}" }) 查看 "${tool}" 工具的完整参数说明(包括必填字段)。${finalHint}`,
490
+ action: 'call_ext_skill_first',
491
+ };
492
+ }
493
+
435
494
  /** 执行时错误 */
436
495
  _executionError(plugin, tool, err) {
437
496
  const message = (err && err.message) || String(err);