foliko 2.0.21 → 2.0.23
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/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +3 -5
- package/plugins/core/skill-manager/index.js +34 -23
- package/plugins/core/sub-agent/index.js +103 -14
- 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/plugins/executors/extension/extension-registry.js +72 -1
- package/plugins/executors/extension/index.js +68 -9
- package/plugins/io/file-system/index.js +377 -153
- package/skills/find-skills/SKILL.md +133 -133
- package/src/agent/chat.js +207 -222
- package/src/agent/sub.js +29 -26
- package/src/agent/tool-loop.js +648 -0
- package/src/llm/provider.js +12 -28
- package/src/plugin/base.js +17 -14
- package/src/plugin/manager.js +19 -0
- package/src/tool/router.js +2 -2
- package/src/utils/message-validator.js +186 -0
- package/tests/core/chat-tool.test.js +187 -0
- package/tests/core/disable-thinking.test.js +64 -0
- package/tests/core/edit-file.test.js +194 -0
- package/tests/core/ext-call-empty-args.test.js +136 -0
- package/tests/core/reasoning-content.test.js +129 -0
- package/tests/core/sanitize-for-llm.test.js +152 -0
- package/tests/core/search.test.js +212 -0
- package/tests/core/skill-input-schema.test.js +150 -0
- package/tests/core/strip-stale-tool-calls.test.js +154 -0
- package/tests/core/sub-agent-parse.test.js +247 -0
- package/tests/core/sub-agent-skills.test.js +197 -0
- package/tests/core/tool-loop.test.js +208 -0
- package/weixin_o9cq80zgZqKPA2-s59PN43GdDy1w@im.wechat.jsonl +0 -76
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
const { SubAgentManagerPlugin } = require('../../plugins/core/sub-agent/index.js');
|
|
7
|
+
|
|
8
|
+
function makePlugin() {
|
|
9
|
+
const p = new SubAgentManagerPlugin({});
|
|
10
|
+
p.install({ getCwd: () => process.cwd() });
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('_parseMarkdownConfig 解析 agent .md 文件', () => {
|
|
15
|
+
let tempDir;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-parse-'));
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function writeAgent(name, content) {
|
|
24
|
+
const filePath = path.join(tempDir, `${name}.md`);
|
|
25
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
26
|
+
return filePath;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
it('★ 关键 bug:frontmatter + body 里有 JSON 示例 → 应该用 frontmatter,不用示例', () => {
|
|
30
|
+
// 真实 bug 场景:news-pipeline-agent.md 有 frontmatter,但 body 里有 ```json 示例
|
|
31
|
+
const content = `---
|
|
32
|
+
name: news-pipeline-agent
|
|
33
|
+
description: 热点新闻视频自动化生产专家
|
|
34
|
+
tools: Read, Write, Edit
|
|
35
|
+
model: inherit
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
# 热点新闻视频自动化专家
|
|
39
|
+
|
|
40
|
+
执行流程:
|
|
41
|
+
|
|
42
|
+
\`\`\`json
|
|
43
|
+
{
|
|
44
|
+
"news_list": [
|
|
45
|
+
{ "title": "新闻", "summary": "摘要" }
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
\`\`\`
|
|
49
|
+
`;
|
|
50
|
+
const filePath = writeAgent('news-pipeline-agent', content);
|
|
51
|
+
const p = makePlugin();
|
|
52
|
+
const config = p._parseMarkdownConfig(filePath, 'news-pipeline-agent');
|
|
53
|
+
expect(config.name).toBe('news-pipeline-agent');
|
|
54
|
+
expect(config.description).toContain('热点');
|
|
55
|
+
expect(config.tools).toBe('Read, Write, Edit');
|
|
56
|
+
expect(config.model).toBe('inherit');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('★ 关键 bug:只有 body JSON(无 frontmatter)→ 应该用 JSON 但必须有 name', () => {
|
|
60
|
+
const content = `---
|
|
61
|
+
description: 没 name 的 frontmatter
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
\`\`\`json
|
|
65
|
+
{
|
|
66
|
+
"news_list": [{"title": "x"}]
|
|
67
|
+
}
|
|
68
|
+
\`\`\`
|
|
69
|
+
`;
|
|
70
|
+
const filePath = writeAgent('agent-a', content);
|
|
71
|
+
const p = makePlugin();
|
|
72
|
+
const config = p._parseMarkdownConfig(filePath, 'agent-a');
|
|
73
|
+
// frontmatter 没 name,回退到 JSON,但 JSON 也没 name
|
|
74
|
+
// 最终只有 defaultName,没有 description(因为 JSON 也没 name 被丢弃)
|
|
75
|
+
expect(config.name).toBe('agent-a');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('纯 frontmatter:标准情况', () => {
|
|
79
|
+
const content = `---
|
|
80
|
+
name: simple-agent
|
|
81
|
+
description: 简单
|
|
82
|
+
tools: a, b, c
|
|
83
|
+
---`;
|
|
84
|
+
const filePath = writeAgent('simple', content);
|
|
85
|
+
const p = makePlugin();
|
|
86
|
+
const config = p._parseMarkdownConfig(filePath, 'simple');
|
|
87
|
+
expect(config.name).toBe('simple-agent');
|
|
88
|
+
expect(config.tools).toBe('a, b, c');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('纯 body JSON(有 name):应该用 JSON', () => {
|
|
92
|
+
const content = `# 没有 frontmatter
|
|
93
|
+
|
|
94
|
+
\`\`\`json
|
|
95
|
+
{
|
|
96
|
+
"name": "json-agent",
|
|
97
|
+
"description": "JSON 配置",
|
|
98
|
+
"tools": ["a", "b"]
|
|
99
|
+
}
|
|
100
|
+
\`\`\`
|
|
101
|
+
`;
|
|
102
|
+
const filePath = writeAgent('json-agent', content);
|
|
103
|
+
const p = makePlugin();
|
|
104
|
+
const config = p._parseMarkdownConfig(filePath, 'json-agent');
|
|
105
|
+
expect(config.name).toBe('json-agent');
|
|
106
|
+
expect(config.tools).toEqual(['a', 'b']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('★ 关键 bug:frontmatter 在中间(不在开头)→ 不应被识别为 frontmatter', () => {
|
|
110
|
+
const content = `# 标题
|
|
111
|
+
|
|
112
|
+
\`\`\`
|
|
113
|
+
---
|
|
114
|
+
name: fake-name
|
|
115
|
+
---
|
|
116
|
+
\`\`\`
|
|
117
|
+
`;
|
|
118
|
+
const filePath = writeAgent('edge', content);
|
|
119
|
+
const p = makePlugin();
|
|
120
|
+
const config = p._parseMarkdownConfig(filePath, 'edge');
|
|
121
|
+
// frontmatter 在代码块内,不应被识别
|
|
122
|
+
// body 没有 name 的 JSON,应使用 defaultName
|
|
123
|
+
expect(config.name).toBe('edge');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('★ 真实复现:news-pipeline-agent.md', () => {
|
|
127
|
+
// 复制实际文件测
|
|
128
|
+
const realFile = 'D:/date/20260624/video/.foliko/agents/news-pipeline-agent.md';
|
|
129
|
+
if (!fs.existsSync(realFile)) return; // 跳过(如果文件不存在)
|
|
130
|
+
const p = makePlugin();
|
|
131
|
+
const config = p._parseMarkdownConfig(realFile, 'news-pipeline-agent');
|
|
132
|
+
expect(config.name).toBe('news-pipeline-agent');
|
|
133
|
+
expect(config.tools).toBeTruthy();
|
|
134
|
+
expect(typeof config.tools).toBe('string'); // 逗号分隔的字符串
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('★ 新功能:markdown body 提取为 systemPrompt', () => {
|
|
138
|
+
const content = `---
|
|
139
|
+
name: body-test
|
|
140
|
+
description: 测试 body 提取
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
# 标题行(应被去除)
|
|
144
|
+
|
|
145
|
+
你是一个**测试 agent**。
|
|
146
|
+
|
|
147
|
+
## 章节 1
|
|
148
|
+
内容 1
|
|
149
|
+
|
|
150
|
+
## 章节 2
|
|
151
|
+
内容 2
|
|
152
|
+
`;
|
|
153
|
+
const filePath = writeAgent('body-test', content);
|
|
154
|
+
const p = makePlugin();
|
|
155
|
+
const config = p._parseMarkdownConfig(filePath, 'body-test');
|
|
156
|
+
expect(config.name).toBe('body-test');
|
|
157
|
+
expect(config.systemPrompt).toBeTruthy();
|
|
158
|
+
expect(config.systemPrompt).toContain('测试 agent');
|
|
159
|
+
expect(config.systemPrompt).toContain('章节 1');
|
|
160
|
+
expect(config.systemPrompt).toContain('章节 2');
|
|
161
|
+
// 第一个 # 标题行应被去除
|
|
162
|
+
expect(config.systemPrompt).not.toContain('标题行(应被去除)');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('★ 新功能:太短的 body 不作为 systemPrompt', () => {
|
|
166
|
+
const content = `---
|
|
167
|
+
name: short-body
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
hi
|
|
171
|
+
`;
|
|
172
|
+
const filePath = writeAgent('short-body', content);
|
|
173
|
+
const p = makePlugin();
|
|
174
|
+
const config = p._parseMarkdownConfig(filePath, 'short-body');
|
|
175
|
+
// body 只有 "hi",长度 < 20,不应作为 systemPrompt
|
|
176
|
+
expect(config.systemPrompt).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('★ 新功能:skills 字符串(YAML),由 SubAgentPlugin 构造时分割', () => {
|
|
180
|
+
const content = `---
|
|
181
|
+
name: skill-test
|
|
182
|
+
skills: a, b, c
|
|
183
|
+
---`;
|
|
184
|
+
const filePath = writeAgent('skill-test', content);
|
|
185
|
+
const p = makePlugin();
|
|
186
|
+
const config = p._parseMarkdownConfig(filePath, 'skill-test');
|
|
187
|
+
// _parseYamlLike 把逗号分隔的字符串保留为字符串(不会切分)
|
|
188
|
+
// SubAgentPlugin 构造函数负责字符串→数组转换
|
|
189
|
+
expect(config.skills).toBe('a, b, c');
|
|
190
|
+
|
|
191
|
+
// 验证 SubAgentPlugin 的转换
|
|
192
|
+
const { SubAgentPlugin } = require('../../plugins/core/sub-agent/index.js');
|
|
193
|
+
const sub = new SubAgentPlugin(config);
|
|
194
|
+
expect(sub.skills).toEqual(['a', 'b', 'c']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('★ 新功能:skills 数组形式(来自 .json 配置)保留', () => {
|
|
198
|
+
const content = `---
|
|
199
|
+
name: skill-arr
|
|
200
|
+
skills: [a, b]
|
|
201
|
+
---`;
|
|
202
|
+
const filePath = writeAgent('skill-arr', content);
|
|
203
|
+
const p = makePlugin();
|
|
204
|
+
const config = p._parseMarkdownConfig(filePath, 'skill-arr');
|
|
205
|
+
// _parseYamlLike 把 '[a, b]' 解析成 ['a', 'b']
|
|
206
|
+
expect(config.skills).toEqual(['a', 'b']);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('_loadAgentsFromDir 集成', () => {
|
|
211
|
+
let tempDir;
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-load-'));
|
|
214
|
+
fs.mkdirSync(path.join(tempDir, '.foliko', 'agents'), { recursive: true });
|
|
215
|
+
});
|
|
216
|
+
afterEach(() => {
|
|
217
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('正确加载带 body JSON 示例的 agent', () => {
|
|
221
|
+
const filePath = path.join(tempDir, '.foliko', 'agents', 'pip-a.md');
|
|
222
|
+
fs.writeFileSync(filePath, `---
|
|
223
|
+
name: pip-a
|
|
224
|
+
description: pipeline A
|
|
225
|
+
tools: read, write
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
# 标题
|
|
229
|
+
|
|
230
|
+
执行流程里有一段示例代码:
|
|
231
|
+
|
|
232
|
+
\`\`\`json
|
|
233
|
+
{ "step": "example", "data": 123 }
|
|
234
|
+
\`\`\`
|
|
235
|
+
`, 'utf-8');
|
|
236
|
+
|
|
237
|
+
const p = new SubAgentManagerPlugin({});
|
|
238
|
+
p.install({ getCwd: () => tempDir });
|
|
239
|
+
p._loadAgentsFromDir();
|
|
240
|
+
|
|
241
|
+
expect(p.agents).toHaveLength(1);
|
|
242
|
+
expect(p.agents[0].name).toBe('pip-a');
|
|
243
|
+
expect(p.agents[0].description).toBe('pipeline A');
|
|
244
|
+
expect(p.agents[0].tools).toBe('read, write');
|
|
245
|
+
expect(p.agents[0]._fromDir).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
const { SubAgentPlugin, SubAgentManagerPlugin } = require('../../plugins/core/sub-agent/index.js');
|
|
3
|
+
|
|
4
|
+
// 测试 SubAgentPlugin 接受 systemPrompt + skills 字段
|
|
5
|
+
|
|
6
|
+
function makeFw(opts = {}) {
|
|
7
|
+
const fakeSkills = opts.skills || new Map();
|
|
8
|
+
return {
|
|
9
|
+
getCwd: () => process.cwd(),
|
|
10
|
+
pluginManager: {
|
|
11
|
+
get: (name) => {
|
|
12
|
+
if (name === 'skill-manager') {
|
|
13
|
+
return {
|
|
14
|
+
getAllSkills: () => Array.from(fakeSkills.values()),
|
|
15
|
+
getSkill: (n) => fakeSkills.get(n),
|
|
16
|
+
hasSkill: (n) => fakeSkills.has(n),
|
|
17
|
+
getSkillDetails: (n) => {
|
|
18
|
+
const s = fakeSkills.get(n);
|
|
19
|
+
return s ? { name: s.name, commands: s._commands } : null;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (name === 'extension-executor') {
|
|
24
|
+
return {
|
|
25
|
+
getExtensionTools: (pluginName) => {
|
|
26
|
+
if (pluginName.startsWith('skill:')) {
|
|
27
|
+
const skillName = pluginName.slice(6);
|
|
28
|
+
const s = fakeSkills.get(skillName);
|
|
29
|
+
if (!s) return [];
|
|
30
|
+
return s._commands.map(c => ({ name: c.name, description: c.description }));
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('SubAgentPlugin 接受 systemPrompt 字段', () => {
|
|
43
|
+
it('★ 直接传 systemPrompt 时,_buildBaseSystemPrompt 优先用', () => {
|
|
44
|
+
const p = new SubAgentPlugin({
|
|
45
|
+
name: 'a',
|
|
46
|
+
systemPrompt: '你是专门做 X 的 agent\n\n具体规则...',
|
|
47
|
+
});
|
|
48
|
+
p.install({ getCwd: () => process.cwd() });
|
|
49
|
+
const prompt = p._buildBaseSystemPrompt();
|
|
50
|
+
expect(prompt).toContain('你是专门做 X 的 agent');
|
|
51
|
+
expect(prompt).toContain('具体规则');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('无 systemPrompt 时回退到 SubAgentConfigManager', () => {
|
|
55
|
+
const p = new SubAgentPlugin({ name: 'no-prompt' });
|
|
56
|
+
p.install({
|
|
57
|
+
getCwd: () => process.cwd(),
|
|
58
|
+
_subAgentConfigManager: {
|
|
59
|
+
get: (name) => name === 'no-prompt' ? {
|
|
60
|
+
getSystemPrompt: () => 'from-config-manager',
|
|
61
|
+
} : null,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(p._buildBaseSystemPrompt()).toBe('from-config-manager');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('SubAgentPlugin 接受 skills 字段', () => {
|
|
69
|
+
it('★ 数组形式:保持原样', () => {
|
|
70
|
+
const p = new SubAgentPlugin({ name: 'a', skills: ['x', 'y'] });
|
|
71
|
+
expect(p.skills).toEqual(['x', 'y']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('★ 字符串形式:按逗号分割', () => {
|
|
75
|
+
const p = new SubAgentPlugin({ name: 'a', skills: 'x, y, z' });
|
|
76
|
+
expect(p.skills).toEqual(['x', 'y', 'z']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('缺省时为空数组', () => {
|
|
80
|
+
const p = new SubAgentPlugin({ name: 'a' });
|
|
81
|
+
expect(p.skills).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('★ _buildSkillsSection 从 skill-manager 查询命令并注入', () => {
|
|
85
|
+
const skills = new Map([
|
|
86
|
+
['creator', {
|
|
87
|
+
name: 'creator',
|
|
88
|
+
_commands: [
|
|
89
|
+
{ name: 'addCover', description: '加封面' },
|
|
90
|
+
{ name: 'addSubtitle', description: '加字幕' },
|
|
91
|
+
],
|
|
92
|
+
}],
|
|
93
|
+
]);
|
|
94
|
+
const p = new SubAgentPlugin({ name: 'a', skills: ['creator'] });
|
|
95
|
+
p.install(makeFw({ skills }));
|
|
96
|
+
const section = p._buildSkillsSection();
|
|
97
|
+
expect(section).toContain('## 🛠️ 可用技能');
|
|
98
|
+
expect(section).toContain('### creator');
|
|
99
|
+
expect(section).toContain('**addCover**: 加封面');
|
|
100
|
+
expect(section).toContain('**addSubtitle**: 加字幕');
|
|
101
|
+
expect(section).toContain('ext_call({plugin: "skill:creator"');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('★ _buildSkillsSection:空 skills 时不返回 section', () => {
|
|
105
|
+
const p = new SubAgentPlugin({ name: 'a', skills: ['nonexistent'] });
|
|
106
|
+
p.install(makeFw({ skills: new Map() }));
|
|
107
|
+
// 现在的实现:空 commands 直接跳过(让 _getFullSystemPrompt 决定是否包含)
|
|
108
|
+
const section = p._buildSkillsSection();
|
|
109
|
+
// 没有可用命令时返回空字符串(避免显示 "(该技能尚未注册)" 等无意义内容)
|
|
110
|
+
expect(section).toBe('');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('★ _buildSkillsSection:skills 为空时返回空', () => {
|
|
114
|
+
const p = new SubAgentPlugin({ name: 'a', skills: [] });
|
|
115
|
+
p.install(makeFw());
|
|
116
|
+
expect(p._buildSkillsSection()).toBe('');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('★ _getFullSystemPrompt 叠加 systemPrompt + skills section', () => {
|
|
120
|
+
const skills = new Map([
|
|
121
|
+
['hot-news', {
|
|
122
|
+
name: 'hot-news',
|
|
123
|
+
_commands: [{ name: 'fetch', description: '抓取' }],
|
|
124
|
+
}],
|
|
125
|
+
]);
|
|
126
|
+
const p = new SubAgentPlugin({
|
|
127
|
+
name: 'news',
|
|
128
|
+
description: '新闻专家',
|
|
129
|
+
systemPrompt: '你是一个新闻 agent。\n\n## 规则\n做新闻。',
|
|
130
|
+
skills: ['hot-news'],
|
|
131
|
+
});
|
|
132
|
+
p.install(makeFw({ skills }));
|
|
133
|
+
const prompt = p._getFullSystemPrompt();
|
|
134
|
+
expect(prompt).toContain('你是一个新闻 agent');
|
|
135
|
+
expect(prompt).toContain('## 规则');
|
|
136
|
+
expect(prompt).toContain('## 🛠️ 可用技能');
|
|
137
|
+
expect(prompt).toContain('**fetch**: 抓取');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('SubAgentManagerPlugin 集成', () => {
|
|
142
|
+
it('★ 端到端:news-pipeline-agent.md + skills 自动注入', () => {
|
|
143
|
+
const skills = new Map([
|
|
144
|
+
['hot-news-video', { name: 'hot-news-video', _commands: [{ name: 'fetch', description: '抓热点' }] }],
|
|
145
|
+
['baidu-video-publisher', { name: 'baidu-video-publisher', _commands: [{ name: 'publish', description: '发百度' }] }],
|
|
146
|
+
['creator', { name: 'creator', _commands: [{ name: 'addCover', description: '封面' }] }],
|
|
147
|
+
]);
|
|
148
|
+
const fs = require('fs');
|
|
149
|
+
const os = require('os');
|
|
150
|
+
const path = require('path');
|
|
151
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-int-'));
|
|
152
|
+
fs.writeFileSync(path.join(tempDir, 'news-pipeline.md'), `---
|
|
153
|
+
name: news-pipeline
|
|
154
|
+
description: 视频自动化
|
|
155
|
+
tools: Read, Write, Edit, Bash, fetch, ext_call, subagent_call
|
|
156
|
+
skills: hot-news-video, baidu-video-publisher, creator
|
|
157
|
+
model: inherit
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
# 标题
|
|
161
|
+
|
|
162
|
+
你是一个**视频自动化** agent。
|
|
163
|
+
|
|
164
|
+
## 📋 流程
|
|
165
|
+
1. 抓新闻
|
|
166
|
+
2. 做视频
|
|
167
|
+
3. 发布
|
|
168
|
+
`, 'utf-8');
|
|
169
|
+
|
|
170
|
+
const mgr = new SubAgentManagerPlugin({});
|
|
171
|
+
mgr.install({ ...makeFw({ skills }), getCwd: () => tempDir });
|
|
172
|
+
mgr.config.agentsDir = tempDir; // override
|
|
173
|
+
mgr._loadAgentsFromDir();
|
|
174
|
+
|
|
175
|
+
expect(mgr.agents).toHaveLength(1);
|
|
176
|
+
const cfg = mgr.agents[0];
|
|
177
|
+
expect(cfg.name).toBe('news-pipeline');
|
|
178
|
+
expect(cfg.systemPrompt).toContain('视频自动化');
|
|
179
|
+
// _parseMarkdownConfig 返回字符串,SubAgentPlugin 构造时分割
|
|
180
|
+
expect(cfg.skills).toBe('hot-news-video, baidu-video-publisher, creator');
|
|
181
|
+
|
|
182
|
+
// 模拟创建 SubAgentPlugin
|
|
183
|
+
const subPlugin = new SubAgentPlugin(cfg);
|
|
184
|
+
subPlugin.install({ ...makeFw({ skills }), getCwd: () => tempDir });
|
|
185
|
+
const fullPrompt = subPlugin._getFullSystemPrompt();
|
|
186
|
+
expect(fullPrompt).toContain('视频自动化');
|
|
187
|
+
expect(fullPrompt).toContain('## 🛠️ 可用技能');
|
|
188
|
+
expect(fullPrompt).toContain('### hot-news-video');
|
|
189
|
+
expect(fullPrompt).toContain('### baidu-video-publisher');
|
|
190
|
+
expect(fullPrompt).toContain('### creator');
|
|
191
|
+
expect(fullPrompt).toContain('**fetch**: 抓热点');
|
|
192
|
+
expect(fullPrompt).toContain('**publish**: 发百度');
|
|
193
|
+
expect(fullPrompt).toContain('**addCover**: 封面');
|
|
194
|
+
|
|
195
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { ToolLoop } = require('../../src/agent/tool-loop.js');
|
|
4
|
+
|
|
5
|
+
describe('ToolLoop._buildApiMessages 格式转换', () => {
|
|
6
|
+
let loop;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('user string 消息正确转换', () => {
|
|
12
|
+
const api = loop._buildApiMessages([
|
|
13
|
+
{ role: 'user', content: 'hello' },
|
|
14
|
+
], 'system prompt');
|
|
15
|
+
expect(api).toEqual([
|
|
16
|
+
{ role: 'system', content: 'system prompt' },
|
|
17
|
+
{ role: 'user', content: 'hello' },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('assistant 消息带 tool_calls 的正确转换', () => {
|
|
22
|
+
const api = loop._buildApiMessages([
|
|
23
|
+
{ role: 'user', content: 'hi' },
|
|
24
|
+
{
|
|
25
|
+
role: 'assistant',
|
|
26
|
+
content: [
|
|
27
|
+
{ type: 'text', text: 'I\'ll read the file' },
|
|
28
|
+
{ type: 'tool-call', toolCallId: 'call_xyz', toolName: 'read', input: { path: '/a' } },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
], 'you are a bot');
|
|
32
|
+
expect(api[0].role).toBe('system');
|
|
33
|
+
expect(api[1].role).toBe('user');
|
|
34
|
+
expect(api[2].role).toBe('assistant');
|
|
35
|
+
expect(api[2].content).toBe("I'll read the file");
|
|
36
|
+
expect(api[2].tool_calls).toHaveLength(1);
|
|
37
|
+
expect(api[2].tool_calls[0].id).toBe('call_xyz');
|
|
38
|
+
expect(api[2].tool_calls[0].function.name).toBe('read');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('tool 消息正确转换', () => {
|
|
42
|
+
const api = loop._buildApiMessages([
|
|
43
|
+
{ role: 'user', content: 'hi' },
|
|
44
|
+
{
|
|
45
|
+
role: 'assistant',
|
|
46
|
+
content: 'looks ok',
|
|
47
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
role: 'tool',
|
|
51
|
+
content: [
|
|
52
|
+
{ type: 'tool-result', toolCallId: 'c1', toolName: 'read', output: { type: 'json', value: { data: 'ok' } } },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
], 'sys prompt');
|
|
56
|
+
expect(api).toHaveLength(4);
|
|
57
|
+
expect(api[3].role).toBe('tool');
|
|
58
|
+
expect(api[3].tool_call_id).toBe('c1');
|
|
59
|
+
expect(api[3].content).toBe('{"data":"ok"}');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('tool 消息已经用 OpenAI 格式时保持', () => {
|
|
63
|
+
const api = loop._buildApiMessages([
|
|
64
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'done' },
|
|
65
|
+
], 'system prompt');
|
|
66
|
+
// 已经符合 OpenAI 格式的直接保留
|
|
67
|
+
expect(api[0].role).toBe('system');
|
|
68
|
+
expect(api[1].role).toBe('tool');
|
|
69
|
+
expect(api[1].tool_call_id).toBe('c1');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('systemPrompt 为空时不加 system 消息', () => {
|
|
73
|
+
const api = loop._buildApiMessages([
|
|
74
|
+
{ role: 'user', content: 'hello' },
|
|
75
|
+
], '');
|
|
76
|
+
expect(api).toHaveLength(1);
|
|
77
|
+
expect(api[0].role).toBe('user');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('assistant content 为 string 时原样保留', () => {
|
|
81
|
+
const api = loop._buildApiMessages([
|
|
82
|
+
{ role: 'assistant', content: 'just text, no tool calls' },
|
|
83
|
+
], '');
|
|
84
|
+
expect(api[0].role).toBe('assistant');
|
|
85
|
+
expect(api[0].content).toBe('just text, no tool calls');
|
|
86
|
+
expect(api[0].tool_calls).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('★ thinking mode + 旧消息无 reasoning → 自动补充 placeholder reasoning_content', () => {
|
|
90
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: true });
|
|
91
|
+
// 模拟旧 session 消息:assistant 带 tool_calls 但没有 reasoning_content
|
|
92
|
+
const api = loop._buildApiMessages([
|
|
93
|
+
{ role: 'user', content: 'hi' },
|
|
94
|
+
{
|
|
95
|
+
role: 'assistant',
|
|
96
|
+
content: '',
|
|
97
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
98
|
+
},
|
|
99
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'done' },
|
|
100
|
+
], 'sys');
|
|
101
|
+
const assistant = api[2];
|
|
102
|
+
expect(assistant.reasoning_content).toBe('...');
|
|
103
|
+
expect(assistant.tool_calls).toHaveLength(1);
|
|
104
|
+
// tool 消息紧随其后 → 不会 orphan
|
|
105
|
+
expect(api[3].role).toBe('tool');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('★ thinking mode + 新消息有 reasoning → 保留原始 reasoning_content', () => {
|
|
109
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: true });
|
|
110
|
+
const api = loop._buildApiMessages([
|
|
111
|
+
{ role: 'user', content: 'hi' },
|
|
112
|
+
{
|
|
113
|
+
role: 'assistant',
|
|
114
|
+
content: '',
|
|
115
|
+
reasoning_content: 'I need to read the file first',
|
|
116
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
117
|
+
},
|
|
118
|
+
], 'sys');
|
|
119
|
+
expect(api[2].reasoning_content).toBe('I need to read the file first');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('★ 非 thinking mode → 正常发送 (不添加 reasoning_content)', () => {
|
|
123
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: false });
|
|
124
|
+
const api = loop._buildApiMessages([
|
|
125
|
+
{ role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }] },
|
|
126
|
+
], 'sys');
|
|
127
|
+
expect(api[0].reasoning_content).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('★ 关键:assistant content 为 string 且含 tool_calls(OpenAI 格式)时保留 tool_calls', () => {
|
|
131
|
+
const api = loop._buildApiMessages([
|
|
132
|
+
{ role: 'user', content: 'hi' },
|
|
133
|
+
{
|
|
134
|
+
role: 'assistant',
|
|
135
|
+
content: '',
|
|
136
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
137
|
+
},
|
|
138
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'done' },
|
|
139
|
+
], 'sys');
|
|
140
|
+
// assistant 消息应该同时有 content + tool_calls
|
|
141
|
+
const assistant = api[2];
|
|
142
|
+
expect(assistant.role).toBe('assistant');
|
|
143
|
+
expect(assistant.tool_calls).toBeDefined();
|
|
144
|
+
expect(assistant.tool_calls).toHaveLength(1);
|
|
145
|
+
expect(assistant.tool_calls[0].id).toBe('c1');
|
|
146
|
+
// tool 消息跟在后面,API 不会报 "must be a response to tool_calls"
|
|
147
|
+
const tool = api[3];
|
|
148
|
+
expect(tool.role).toBe('tool');
|
|
149
|
+
expect(tool.tool_call_id).toBe('c1');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('ToolLoop._buildApiTools', () => {
|
|
154
|
+
it('空 tools map 返回 undefined', () => {
|
|
155
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
156
|
+
expect(loop._buildApiTools({})).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('有工具时返回 OpenAI function calling 格式', () => {
|
|
160
|
+
const loop = new ToolLoop({ maxSteps: 1 });
|
|
161
|
+
const tools = {
|
|
162
|
+
read: { name: 'read', description: '读取文件', parameters: { type: 'object', properties: { path: { type: 'string' } } } },
|
|
163
|
+
};
|
|
164
|
+
const apiTools = loop._buildApiTools(tools);
|
|
165
|
+
expect(apiTools).toHaveLength(1);
|
|
166
|
+
expect(apiTools[0].type).toBe('function');
|
|
167
|
+
expect(apiTools[0].function.name).toBe('read');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('ToolLoop._repairArgs', () => {
|
|
172
|
+
let loop;
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
loop = new ToolLoop({ tools: {}, maxSteps: 1, repairToolCall: null });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('空字符串修复为 {}', () => {
|
|
178
|
+
const r = loop._repairArgs('test', '', new Error('parse fail'));
|
|
179
|
+
expect(r).toEqual({});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('缺开头的 { 尝试补全', () => {
|
|
183
|
+
expect(() => loop._repairArgs('test', '"path":"/a"}', new Error('parse fail'))).toThrow();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('不完整的 JSON(缺末尾})尝试补全', () => {
|
|
187
|
+
const r = loop._repairArgs('test', '{"path":"/a"', new Error('parse fail'));
|
|
188
|
+
expect(r).toEqual({ path: '/a' });
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('ToolLoop 完整流程模拟(不调真实 API)', () => {
|
|
193
|
+
it('构造函数不抛错', () => {
|
|
194
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
195
|
+
expect(loop.maxSteps).toBe(1);
|
|
196
|
+
expect(typeof loop.generate).toBe('function');
|
|
197
|
+
expect(typeof loop.stream).toBe('function');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('buildApiMessages 注入 system prompt', () => {
|
|
201
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
202
|
+
const api = loop._buildApiMessages(
|
|
203
|
+
[{ role: 'user', content: 'hi' }],
|
|
204
|
+
'你是一个助手'
|
|
205
|
+
);
|
|
206
|
+
expect(api[0]).toEqual({ role: 'system', content: '你是一个助手' });
|
|
207
|
+
});
|
|
208
|
+
});
|