minimal-agent 0.1.8 → 0.2.0

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.
Files changed (51) hide show
  1. package/README.md +405 -122
  2. package/dist/main.js +423 -941
  3. package/package.json +5 -2
  4. package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
  5. package/plugins/ralph-wiggum/.claude-plugin/plugin.json +9 -0
  6. package/plugins/ralph-wiggum/README.md +179 -0
  7. package/plugins/ralph-wiggum/commands/cancel-ralph.md +18 -0
  8. package/plugins/ralph-wiggum/commands/help.md +126 -0
  9. package/plugins/ralph-wiggum/commands/ralph-loop.md +59 -0
  10. package/plugins/ralph-wiggum/hooks/hooks.json +15 -0
  11. package/plugins/ralph-wiggum/hooks/stop-hook.sh +191 -0
  12. package/plugins/ralph-wiggum/plugin.ts +275 -0
  13. package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +203 -0
  14. package/plugins/ralph-wiggum/src/goalState.ts +310 -0
  15. package/plugins/ralph-wiggum/src/sentinels.ts +24 -0
  16. package/plugins/ralph-wiggum/src/stopHookRunner.ts +136 -0
  17. package/plugins/ralph-wiggum/src/verificationGate.ts +252 -0
  18. package/plugins/ralph-wiggum/test/goalState.test.ts +410 -0
  19. package/plugins/ralph-wiggum/test/verificationGate.test.ts +122 -0
  20. package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
  21. package/plugins/workflow-runner/commands/workflow.md +15 -0
  22. package/plugins/workflow-runner/commands/workflows.md +8 -0
  23. package/plugins/workflow-runner/plugin.ts +42 -0
  24. package/plugins/workflow-runner/src/expressions.ts +371 -0
  25. package/plugins/workflow-runner/src/index.ts +194 -0
  26. package/plugins/workflow-runner/src/loader.ts +193 -0
  27. package/plugins/workflow-runner/src/runner.ts +313 -0
  28. package/plugins/workflow-runner/src/stepExecutors/assert.ts +30 -0
  29. package/plugins/workflow-runner/src/stepExecutors/llm.ts +54 -0
  30. package/plugins/workflow-runner/src/stepExecutors/skill.ts +115 -0
  31. package/plugins/workflow-runner/src/stepExecutors/tool.ts +41 -0
  32. package/plugins/workflow-runner/src/types.ts +183 -0
  33. package/plugins/workflow-runner/src/workflowState.ts +65 -0
  34. package/plugins/workflow-runner/test/cli.e2e.test.ts +114 -0
  35. package/plugins/workflow-runner/test/e2e.test.ts +268 -0
  36. package/plugins/workflow-runner/test/expressions.test.ts +140 -0
  37. package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +27 -0
  38. package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +49 -0
  39. package/plugins/workflow-runner/test/graceful.test.ts +139 -0
  40. package/plugins/workflow-runner/test/loader.test.ts +216 -0
  41. package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +230 -0
  42. package/plugins/workflow-runner/test/runner.test.ts +511 -0
  43. package/skills/config/SKILL.md +27 -1
  44. package/skills/image-gen-openrouter/SKILL.md +121 -0
  45. package/skills/subtitle-srt/SKILL.md +134 -0
  46. package/skills/tts-zh/SKILL.md +137 -0
  47. package/skills/video-compose/SKILL.md +139 -0
  48. package/workflows/book-review-short.yaml +99 -0
  49. package/workflows/e2e-write-greet.yaml +27 -0
  50. package/workflows/schema.json +74 -0
  51. package/workflows/youtube-shorts.yaml +171 -0
@@ -0,0 +1,511 @@
1
+ /**
2
+ * ============================================================
3
+ * test/workflows/runner.test.ts
4
+ * ------------------------------------------------------------
5
+ * runner P0 行为:
6
+ * - tool / llm / assert step 成功路径
7
+ * - capture 绑变量到 VarStack
8
+ * - when=false → workflow_step_skipped
9
+ * - onError=halt(默认)→ workflow halt + error
10
+ * - onError=continue → 继续下一步
11
+ * - 工作流完成 yield workflow_done
12
+ *
13
+ * 策略:
14
+ * - tool 步骤用真实 Write 工具(写到 tmpdir,避免 mock executeTool)
15
+ * - llm 步骤 mock.module('../../../src/llm/client.ts')
16
+ * - assert / when 走纯表达式引擎,无需 mock
17
+ * ============================================================
18
+ */
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ mkdtempSync,
25
+ readFileSync,
26
+ rmSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+
32
+ import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.ts';
33
+ import { runWorkflow } from '../src/runner.ts';
34
+ import type {
35
+ LoopEvent,
36
+ Provider,
37
+ } from '../../../src/types.ts';
38
+ import type {
39
+ RunContext,
40
+ WorkflowDef,
41
+ WorkflowEvent,
42
+ } from '../src/types.ts';
43
+
44
+ type WfOrLoop = WorkflowEvent | LoopEvent;
45
+
46
+ const fakeProvider: Provider = {
47
+ name: 'test',
48
+ baseURL: '',
49
+ apiKey: '',
50
+ model: 'm',
51
+ contextWindow: 128000,
52
+ };
53
+
54
+ async function collect(
55
+ gen: AsyncGenerator<WfOrLoop, void, void>,
56
+ ): Promise<WfOrLoop[]> {
57
+ const out: WfOrLoop[] = [];
58
+ for await (const ev of gen) out.push(ev);
59
+ return out;
60
+ }
61
+
62
+ describe('workflows/runner', () => {
63
+ let tmp: string;
64
+ let ctx: RunContext;
65
+
66
+ beforeEach(() => {
67
+ tmp = mkdtempSync(join(tmpdir(), 'wf-runner-'));
68
+ process.env.MINIMAL_AGENT_CWD = tmp;
69
+ _resetWorkingDir();
70
+ // 抵抗 pluginRunner.test.ts 等遗留的 workingDir.ts mock 全局污染
71
+ mock.module('../../../src/bootstrap/workingDir.ts', () => ({
72
+ getWorkingDir: () => tmp,
73
+ initWorkingDir: () => tmp,
74
+ _resetWorkingDir: () => {},
75
+ }));
76
+ ctx = { provider: fakeProvider, history: [] };
77
+ });
78
+
79
+ afterEach(() => {
80
+ delete process.env.MINIMAL_AGENT_CWD;
81
+ _resetWorkingDir();
82
+ rmSync(tmp, { recursive: true, force: true });
83
+ });
84
+
85
+ it('线性 assert step 全通过 → workflow_done', async () => {
86
+ const def: WorkflowDef = {
87
+ name: 'pure-assert',
88
+ description: 'd',
89
+ steps: [
90
+ { id: 's1', type: 'assert', condition: '1 == 1' },
91
+ { id: 's2', type: 'assert', condition: '"a" != "b"' },
92
+ ],
93
+ };
94
+ const events = await collect(runWorkflow(def, {}, ctx));
95
+ const types = events.map((e) => e.type);
96
+ expect(types[0]).toBe('workflow_start');
97
+ expect(types.filter((t) => t === 'workflow_step_end').length).toBe(2);
98
+ expect(types[types.length - 1]).toBe('workflow_done');
99
+ });
100
+
101
+ it('assert 失败 → halt + error 事件', async () => {
102
+ const def: WorkflowDef = {
103
+ name: 'assert-fail',
104
+ description: 'd',
105
+ steps: [
106
+ { id: 's1', type: 'assert', condition: '1 == 2', onFail: 'nope' },
107
+ { id: 's2', type: 'assert', condition: 'true' }, // 不应执行
108
+ ],
109
+ };
110
+ const events = await collect(runWorkflow(def, {}, ctx));
111
+ expect(events.some((e) => e.type === 'error')).toBe(true);
112
+ // s2 不应有 step_start
113
+ const startedIds = events
114
+ .filter((e): e is Extract<WorkflowEvent, { type: 'workflow_step_start' }> => e.type === 'workflow_step_start')
115
+ .map((e) => e.id);
116
+ expect(startedIds).toEqual(['s1']);
117
+ // 没 workflow_done
118
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
119
+ });
120
+
121
+ it('onError=continue → 失败后继续下一步', async () => {
122
+ const def: WorkflowDef = {
123
+ name: 'continue',
124
+ description: 'd',
125
+ steps: [
126
+ { id: 's1', type: 'assert', condition: 'false', onError: 'continue' },
127
+ { id: 's2', type: 'assert', condition: 'true' },
128
+ ],
129
+ };
130
+ const events = await collect(runWorkflow(def, {}, ctx));
131
+ const ends = events.filter(
132
+ (e): e is Extract<WorkflowEvent, { type: 'workflow_step_end' }> => e.type === 'workflow_step_end',
133
+ );
134
+ expect(ends.length).toBe(2);
135
+ expect(ends[0].ok).toBe(false);
136
+ expect(ends[1].ok).toBe(true);
137
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
138
+ });
139
+
140
+ it('when=false → workflow_step_skipped 而不是 step_start', async () => {
141
+ const def: WorkflowDef = {
142
+ name: 'skip',
143
+ description: 'd',
144
+ inputs: [{ name: 'flag', type: 'string' }],
145
+ steps: [
146
+ {
147
+ id: 's1',
148
+ type: 'assert',
149
+ condition: 'true',
150
+ when: 'inputs.flag == "yes"',
151
+ },
152
+ { id: 's2', type: 'assert', condition: 'true' },
153
+ ],
154
+ };
155
+ const events = await collect(runWorkflow(def, { flag: 'no' }, ctx));
156
+ const skipped = events.find((e) => e.type === 'workflow_step_skipped');
157
+ expect(skipped).toBeDefined();
158
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
159
+ });
160
+
161
+ it('tool step 写文件 + ${var} 插值(用真实 Write 工具)', async () => {
162
+ const def: WorkflowDef = {
163
+ name: 'wr',
164
+ description: 'd',
165
+ inputs: [{ name: 'book', type: 'string' }],
166
+ steps: [
167
+ {
168
+ id: 'w',
169
+ tool: 'Write',
170
+ args: {
171
+ file_path: 'videos/${inputs.book}/x.txt',
172
+ content: 'hello ${inputs.book}',
173
+ },
174
+ },
175
+ ],
176
+ };
177
+ const events = await collect(runWorkflow(def, { book: '原则' }, ctx));
178
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
179
+
180
+ const filePath = join(tmp, 'videos', '原则', 'x.txt');
181
+ expect(existsSync(filePath)).toBe(true);
182
+ expect(readFileSync(filePath, 'utf8')).toBe('hello 原则');
183
+ });
184
+
185
+ it('capture 把 tool 结果绑到变量,下一步 assert 能用', async () => {
186
+ const def: WorkflowDef = {
187
+ name: 'cap',
188
+ description: 'd',
189
+ steps: [
190
+ {
191
+ id: 'w',
192
+ tool: 'Write',
193
+ args: { file_path: 'a.txt', content: 'data' },
194
+ capture: { content: 'last_write_content' },
195
+ },
196
+ {
197
+ id: 'check',
198
+ type: 'assert',
199
+ condition: 'length(last_write_content) > 0',
200
+ },
201
+ ],
202
+ };
203
+ const events = await collect(runWorkflow(def, {}, ctx));
204
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
205
+ });
206
+
207
+ it('未知 tool → halt + error', async () => {
208
+ const def: WorkflowDef = {
209
+ name: 'badtool',
210
+ description: 'd',
211
+ steps: [{ id: 'x', tool: 'NoSuchTool9999' }],
212
+ };
213
+ const events = await collect(runWorkflow(def, {}, ctx));
214
+ expect(events.some((e) => e.type === 'error')).toBe(true);
215
+ });
216
+
217
+ it('llm step 拿 chat 累积文本 + capture text', async () => {
218
+ mock.module('../../../src/llm/client.ts', () => ({
219
+ chat: async function* () {
220
+ yield { type: 'text_delta', delta: 'Hello ' };
221
+ yield { type: 'text_delta', delta: 'world' };
222
+ yield { type: 'done', stopReason: 'stop' };
223
+ },
224
+ }));
225
+
226
+ const def: WorkflowDef = {
227
+ name: 'llm-cap',
228
+ description: 'd',
229
+ steps: [
230
+ {
231
+ id: 'g',
232
+ llm: 'irrelevant',
233
+ capture: { text: 'msg' },
234
+ },
235
+ {
236
+ id: 'check',
237
+ type: 'assert',
238
+ condition: 'msg == "Hello world"',
239
+ },
240
+ ],
241
+ };
242
+ const events = await collect(runWorkflow(def, {}, ctx));
243
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
244
+ });
245
+
246
+ it('workflow_step_start 的 index 与 total 与 steps 长度一致', async () => {
247
+ const def: WorkflowDef = {
248
+ name: 'count',
249
+ description: 'd',
250
+ steps: [
251
+ { id: 'a', type: 'assert', condition: 'true' },
252
+ { id: 'b', type: 'assert', condition: 'true' },
253
+ { id: 'c', type: 'assert', condition: 'true' },
254
+ ],
255
+ };
256
+ const events = await collect(runWorkflow(def, {}, ctx));
257
+ const starts = events.filter(
258
+ (e): e is Extract<WorkflowEvent, { type: 'workflow_step_start' }> => e.type === 'workflow_step_start',
259
+ );
260
+ expect(starts.length).toBe(3);
261
+ expect(starts.map((s) => s.index)).toEqual([0, 1, 2]);
262
+ expect(starts.every((s) => s.total === 3)).toBe(true);
263
+ });
264
+
265
+ it('signal.aborted → 立即 interrupted', async () => {
266
+ const ctrl = new AbortController();
267
+ ctrl.abort();
268
+ const def: WorkflowDef = {
269
+ name: 'abort',
270
+ description: 'd',
271
+ steps: [{ id: 'a', type: 'assert', condition: 'true' }],
272
+ };
273
+ const events = await collect(
274
+ runWorkflow(def, {}, { provider: fakeProvider, history: [], signal: ctrl.signal }),
275
+ );
276
+ expect(events.some((e) => e.type === 'interrupted')).toBe(true);
277
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
278
+ });
279
+
280
+ it('P1 branch step:condition=true → 走 then 分支,输出 branch=true', async () => {
281
+ const def: WorkflowDef = {
282
+ name: 'branch-true',
283
+ description: 'd',
284
+ inputs: [{ name: 'x', type: 'number' }],
285
+ steps: [
286
+ {
287
+ id: 'br',
288
+ type: 'branch',
289
+ condition: 'inputs.x > 0',
290
+ then: [
291
+ {
292
+ id: 'then_w',
293
+ tool: 'Write',
294
+ args: { file_path: 'pos.txt', content: 'positive' },
295
+ },
296
+ ],
297
+ else: [
298
+ {
299
+ id: 'else_w',
300
+ tool: 'Write',
301
+ args: { file_path: 'neg.txt', content: 'negative' },
302
+ },
303
+ ],
304
+ },
305
+ ],
306
+ };
307
+ const events = await collect(runWorkflow(def, { x: 5 }, ctx));
308
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
309
+ expect(existsSync(join(tmp, 'pos.txt'))).toBe(true);
310
+ expect(existsSync(join(tmp, 'neg.txt'))).toBe(false);
311
+ const ends = events.filter(
312
+ (e): e is Extract<WorkflowEvent, { type: 'workflow_step_end' }> => e.type === 'workflow_step_end',
313
+ );
314
+ // 至少 then_w (1) + br (1) = 2 个 step_end,且都 ok
315
+ expect(ends.length).toBe(2);
316
+ expect(ends.every((e) => e.ok)).toBe(true);
317
+ const brEnd = ends.find((e) => e.id === 'br');
318
+ expect(brEnd?.output).toBe('branch=true');
319
+ });
320
+
321
+ it('P1 branch step:condition=false → 走 else 分支', async () => {
322
+ const def: WorkflowDef = {
323
+ name: 'branch-false',
324
+ description: 'd',
325
+ inputs: [{ name: 'x', type: 'number' }],
326
+ steps: [
327
+ {
328
+ id: 'br',
329
+ type: 'branch',
330
+ condition: 'inputs.x > 0',
331
+ then: [{ id: 'then_a', type: 'assert', condition: 'true' }],
332
+ else: [
333
+ {
334
+ id: 'else_w',
335
+ tool: 'Write',
336
+ args: { file_path: 'neg.txt', content: 'negative' },
337
+ },
338
+ ],
339
+ },
340
+ ],
341
+ };
342
+ const events = await collect(runWorkflow(def, { x: -3 }, ctx));
343
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
344
+ expect(existsSync(join(tmp, 'neg.txt'))).toBe(true);
345
+ const brEnd = events.find(
346
+ (e): e is Extract<WorkflowEvent, { type: 'workflow_step_end' }> =>
347
+ e.type === 'workflow_step_end' && e.id === 'br',
348
+ );
349
+ expect(brEnd?.output).toBe('branch=false');
350
+ });
351
+
352
+ it('P1 branch step:缺 then/else 也合法(cond 求值后空跳过)', async () => {
353
+ const def: WorkflowDef = {
354
+ name: 'branch-empty',
355
+ description: 'd',
356
+ steps: [
357
+ { id: 'br', type: 'branch', condition: '1 == 1' },
358
+ { id: 's2', type: 'assert', condition: 'true' },
359
+ ],
360
+ };
361
+ const events = await collect(runWorkflow(def, {}, ctx));
362
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
363
+ const ids = events
364
+ .filter((e): e is Extract<WorkflowEvent, { type: 'workflow_step_end' }> => e.type === 'workflow_step_end')
365
+ .map((e) => e.id);
366
+ expect(ids).toEqual(['br', 's2']);
367
+ });
368
+
369
+ it('P1 loop step:over=数组字面量,子步骤用 ${as} 和 ${as}_idx', async () => {
370
+ const def: WorkflowDef = {
371
+ name: 'loop-lit',
372
+ description: 'd',
373
+ steps: [
374
+ {
375
+ id: 'lp',
376
+ type: 'loop',
377
+ over: '["a", "b", "c"]',
378
+ as: 'item',
379
+ do: [
380
+ {
381
+ id: 'w',
382
+ tool: 'Write',
383
+ args: {
384
+ file_path: 'items/${item_idx}-${item}.txt',
385
+ content: 'idx=${item_idx} val=${item}',
386
+ },
387
+ },
388
+ ],
389
+ },
390
+ ],
391
+ };
392
+ const events = await collect(runWorkflow(def, {}, ctx));
393
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
394
+ for (let i = 0; i < 3; i++) {
395
+ const val = ['a', 'b', 'c'][i];
396
+ const filePath = join(tmp, 'items', `${i}-${val}.txt`);
397
+ expect(existsSync(filePath)).toBe(true);
398
+ expect(readFileSync(filePath, 'utf8')).toBe(`idx=${i} val=${val}`);
399
+ }
400
+ const lpEnd = events.find(
401
+ (e): e is Extract<WorkflowEvent, { type: 'workflow_step_end' }> =>
402
+ e.type === 'workflow_step_end' && e.id === 'lp',
403
+ );
404
+ expect(lpEnd?.output).toBe('loop 3 次');
405
+ });
406
+
407
+ it('P1 loop step:迭代变量在循环外不再可见(push/pop 帧)', async () => {
408
+ const def: WorkflowDef = {
409
+ name: 'loop-scope',
410
+ description: 'd',
411
+ steps: [
412
+ {
413
+ id: 'lp',
414
+ type: 'loop',
415
+ over: '[1, 2]',
416
+ as: 'i',
417
+ do: [{ id: 'noop', type: 'assert', condition: 'i > 0' }],
418
+ },
419
+ // 循环外的 ${i} 已 pop —— 这里把它当字符串拿不到,length()=0
420
+ { id: 'check', type: 'assert', condition: 'length(i) == 0' },
421
+ ],
422
+ };
423
+ const events = await collect(runWorkflow(def, {}, ctx));
424
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
425
+ });
426
+
427
+ it('P1 loop step:over 求值不是数组 → halt + error', async () => {
428
+ const def: WorkflowDef = {
429
+ name: 'loop-bad-over',
430
+ description: 'd',
431
+ steps: [
432
+ {
433
+ id: 'lp',
434
+ type: 'loop',
435
+ over: '"not an array"',
436
+ as: 'i',
437
+ do: [{ id: 'inner', type: 'assert', condition: 'true' }],
438
+ },
439
+ ],
440
+ };
441
+ const events = await collect(runWorkflow(def, {}, ctx));
442
+ expect(events.some((e) => e.type === 'error')).toBe(true);
443
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
444
+ });
445
+
446
+ it('P1 pause step:非交互模式直接 skipped + step_end ok', async () => {
447
+ const def: WorkflowDef = {
448
+ name: 'pause-skip',
449
+ description: 'd',
450
+ steps: [
451
+ { id: 'p1', type: 'pause', prompt: '等用户确认' },
452
+ { id: 's2', type: 'assert', condition: 'true' },
453
+ ],
454
+ };
455
+ const events = await collect(runWorkflow(def, {}, ctx));
456
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
457
+ const skipped = events.find(
458
+ (e): e is Extract<WorkflowEvent, { type: 'workflow_step_skipped' }> =>
459
+ e.type === 'workflow_step_skipped' && e.id === 'p1',
460
+ );
461
+ expect(skipped?.reason).toContain('等用户确认');
462
+ });
463
+
464
+ it('P1 skill step:加载 cwd/skills/<name>/SKILL.md,跑 runQuery 累计 text', async () => {
465
+ // 在 tmp 准备一个最小 SKILL.md
466
+ const skillDir = join(tmp, 'skills', 'echo-bot');
467
+ mkdirSync(skillDir, { recursive: true });
468
+ writeFileSync(
469
+ join(skillDir, 'SKILL.md'),
470
+ '---\nname: echo-bot\ndescription: echo\n---\n\n按用户指示原样输出',
471
+ 'utf8',
472
+ );
473
+
474
+ // skill executor 通过 plugin-sdk.runQuery 调子循环;直接 mock loop.ts
475
+ // 避开 chat 层(也免疫其它测试文件遗留的 loop.ts mock 污染)。
476
+ mock.module('../../../src/loop.ts', () => ({
477
+ runQuery: async function* () {
478
+ yield { type: 'text', delta: 'echoed-' };
479
+ yield { type: 'text', delta: 'OK' };
480
+ yield { type: 'turn_done' };
481
+ },
482
+ }));
483
+
484
+ const def: WorkflowDef = {
485
+ name: 'skill-test',
486
+ description: 'd',
487
+ steps: [
488
+ {
489
+ id: 'sk',
490
+ skill: 'echo-bot',
491
+ input: 'say hi',
492
+ capture: { text: 'msg' },
493
+ },
494
+ { id: 'check', type: 'assert', condition: 'msg == "echoed-OK"' },
495
+ ],
496
+ };
497
+ const events = await collect(runWorkflow(def, {}, ctx));
498
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(true);
499
+ });
500
+
501
+ it('P1 skill step:未找到 skill → halt + error', async () => {
502
+ const def: WorkflowDef = {
503
+ name: 'skill-missing',
504
+ description: 'd',
505
+ steps: [{ id: 'sk', skill: 'definitely-not-exists' }],
506
+ };
507
+ const events = await collect(runWorkflow(def, {}, ctx));
508
+ expect(events.some((e) => e.type === 'error')).toBe(true);
509
+ expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
510
+ });
511
+ });
@@ -38,7 +38,26 @@ Read({ file_path: "~/.minimal-agent/config.json" })
38
38
  | `apiKey` | ✅ | API key(敏感信息) |
39
39
  | `model` | ✅ | 模型 id,如 `MiniMax-M2.7` |
40
40
  | `provider` | ⬜ | 显示名(仅用于状态栏),默认 `env` |
41
- | `contextWindow` | ⬜ | 模型上下文窗口(token 数),默认 `128000` |
41
+ | `contextWindow` | ⬜ | 模型上下文窗口(token 数),**推荐值见下方速查表**(按 provider+model 推断) |
42
+ | `tavilyApiKey` | ⬜ | Tavily key(WebSearch 工具用);可留空跳过,免费 key 申请见 https://tavily.com/ |
43
+
44
+ ### Provider 默认值速查表(contextWindow)
45
+
46
+ 权威值见 `src/cli/configWizard.tsx::PRESETS`;本表是它的镜像,发现不一致以代码为准。
47
+
48
+ | Provider | Model | 推荐 contextWindow |
49
+ |---|---|---|
50
+ | minimax | MiniMax-M2.7 / MiniMax-M1 / abab6.5s-chat | 204800 |
51
+ | deepseek | deepseek-chat / deepseek-reasoner | 128000 |
52
+ | openai | gpt-4o / gpt-4o-mini | 128000 |
53
+ | moonshot | moonshot-v1-8k | 8000 |
54
+ | moonshot | moonshot-v1-32k | 32000 |
55
+ | moonshot | moonshot-v1-128k | 128000 |
56
+ | 其它 / custom | — | 128000 |
57
+
58
+ ⚠️ 询问 `contextWindow` 时,**先按上表给出推荐值并明确告诉用户**(例:「按你选的 `MiniMax-M2.7`,推荐 `contextWindow = 204800`,直接回车采纳」)。**不要让用户裸填 token 数**;用户主要关心 `baseURL` / `apiKey` / `model`,contextWindow 应优先按 provider+model 推断。
59
+
60
+ 如果用户改了 model 但没改 contextWindow,**主动重新计算推荐值**并提示;用户旧 contextWindow 与新 model 不匹配时(如把 model 从 `moonshot-v1-8k` 改成 `moonshot-v1-128k`),明确建议同步更新。
42
61
 
43
62
  **对话格式示例**(每次只问一个字段,等用户答完再问下一个):
44
63
 
@@ -62,6 +81,7 @@ Write({
62
81
  model: <...>,
63
82
  provider: <...>,
64
83
  contextWindow: <...>,
84
+ tavilyApiKey: <... or 省略字段>,
65
85
  savedAt: Date.now()
66
86
  }, null, 2)
67
87
  })
@@ -69,6 +89,12 @@ Write({
69
89
 
70
90
  字段 `savedAt` 必须是当前的 unix 毫秒时间戳。
71
91
 
92
+ `tavilyApiKey` 字段策略:
93
+ - 用户没有 Tavily key → **从写入的 JSON 里删掉这个字段**(不要写空串,readSavedConfig 会拒空串)。
94
+ - 用户提供了 key → 写入字符串。
95
+ - 用户没主动改这个字段 → **保留旧值**(不要为了"格式整洁"删除)。
96
+ - 写入后启动注入:下次启动 minimal-agent 时 `applyToolKeysToEnv()`(`src/config.ts`)会把它注入 `process.env.TAVILY_API_KEY`,WebSearch 工具就能用了。
97
+
72
98
  ### Step 4: 提示重启
73
99
 
74
100
  写入成功后,输出(中文):
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: image-gen-openrouter
3
+ description: 当用户说"生成图片"、"画一张图"、"配图"、"OpenRouter 生图"、"nano banana"、"image-gen-openrouter",或在 workflow 里要按提示词生成图片时使用。调用 OpenRouter 的 Chat Completions 接口(OpenAI 兼容 + image modalities),按提示词生成任意比例图片并落盘。
4
+ ---
5
+
6
+ # image-gen-openrouter —— OpenRouter 文生图
7
+
8
+ > 这是一个**通用 skill**,不绑定特定场景。任何需要文生图的 workflow / 用户对话都能调它。
9
+
10
+ ## 输入约定
11
+
12
+ 调用方传一行 `key=value` 字段串,**字段顺序无要求**,例:
13
+
14
+ ```
15
+ prompt=A cat reading a book in cyberpunk neon city output=./assets/img_0.png aspect=9:16 size=4K model=google/gemini-3-pro-image-preview key=sk-or-...
16
+ ```
17
+
18
+ 字段定义:
19
+ - **prompt**(必填):图片提示词(建议英文,效果更好)。可包含空格 —— 用"剩余串"策略解析(先把其它已知字段抠出去,剩下的就是 prompt)
20
+ - **output**(必填):目标图片路径(相对 cwd 或绝对)
21
+ - **aspect**(可选,默认 `1:1`):宽高比;OpenRouter 支持 `1:1` / `2:3` / `3:2` / `3:4` / `4:3` / `4:5` / `5:4` / `9:16` / `16:9` / `21:9` 等
22
+ - **size**(可选,默认 `1K`):分辨率;可选 `0.5K` / `1K` / `2K` / `4K`(部分模型不支持 4K,限流时自动降级)
23
+ - **model**(可选,默认 `google/gemini-3-pro-image-preview`)—— Nano Banana Pro,质量最好;备选 `google/gemini-3.1-flash-image-preview`(便宜)、`google/gemini-2.5-flash-image`(兜底)
24
+ - **key**(可选):API key;不传则用环境变量 `OPENROUTER_API_KEY`
25
+
26
+ ## 标准执行流程
27
+
28
+ ### 1. 解析输入
29
+
30
+ 仔细从 step.input 里提取 4 个字段。注意 prompt 可能包含空格,所以按"先取 output= / model= / key= 之外的部分作为 prompt"的策略最稳妥。
31
+
32
+ 如果你拿不到 prompt 或 output,立刻报错并返回。
33
+
34
+ ### 2. 取 API key
35
+
36
+ 按优先级:
37
+ 1. step.input 里的 `key=` 字段
38
+ 2. 用 Bash 跑 `echo "$OPENROUTER_API_KEY"` 看环境变量
39
+
40
+ 两者都空 → 报错 "OpenRouter API key 未配置(既不在 step.input 也不在 OPENROUTER_API_KEY 环境变量)" 并退出。
41
+
42
+ ### 3. 调 OpenRouter API
43
+
44
+ 用 Bash 跑 curl(**注意把变量替换成实际值**):
45
+
46
+ ```bash
47
+ curl -sS https://openrouter.ai/api/v1/chat/completions \
48
+ -H "Authorization: Bearer <YOUR_KEY>" \
49
+ -H "Content-Type: application/json" \
50
+ -d '{
51
+ "model": "<MODEL>",
52
+ "messages": [{"role": "user", "content": "<PROMPT>"}],
53
+ "modalities": ["image", "text"],
54
+ "image_config": {"aspect_ratio": "<ASPECT>", "image_size": "<SIZE>"}
55
+ }' \
56
+ -o /tmp/openrouter_resp.json
57
+ ```
58
+
59
+ > 💡 prompt 里若含双引号/特殊字符,用 jq 拼 JSON(**推荐**):
60
+ > ```bash
61
+ > jq -n --arg p "$PROMPT" --arg m "$MODEL" --arg a "$ASPECT" --arg s "$SIZE" \
62
+ > '{model:$m, messages:[{role:"user", content:$p}], modalities:["image","text"], image_config:{aspect_ratio:$a, image_size:$s}}' \
63
+ > | curl -sS https://openrouter.ai/api/v1/chat/completions \
64
+ > -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
65
+ > --data-binary @- -o /tmp/openrouter_resp.json
66
+ > ```
67
+
68
+ ### 4. 提取 base64 并解码到 PNG
69
+
70
+ OpenRouter 返回结构(关键字段):
71
+ ```
72
+ .choices[0].message.images[0].image_url.url → "data:image/png;base64,iVBORw0KG..."
73
+ ```
74
+
75
+ **Linux/macOS/git-bash on Windows:**
76
+ ```bash
77
+ mkdir -p "$(dirname <OUTPUT>)"
78
+ jq -r '.choices[0].message.images[0].image_url.url' /tmp/openrouter_resp.json \
79
+ | sed 's|^data:image/[a-z]*;base64,||' \
80
+ | base64 -d > <OUTPUT>
81
+ ```
82
+
83
+ **Windows PowerShell(base64 -d 不存在时):**
84
+ ```powershell
85
+ powershell -NoProfile -Command "$resp = Get-Content /tmp/openrouter_resp.json -Raw | ConvertFrom-Json; $url = $resp.choices[0].message.images[0].image_url.url; $b64 = $url -replace '^data:image/[a-z]+;base64,',''; [IO.File]::WriteAllBytes('<OUTPUT>', [Convert]::FromBase64String($b64))"
86
+ ```
87
+
88
+ ### 5. 校验产物
89
+
90
+ 用 Read 工具读 `<OUTPUT>`(offset=0 limit=1)— 触发文件类型嗅探;如果工具报"二进制"或文件 size > 1KB 就视为成功,否则失败。
91
+
92
+ 或者更直接:用 Bash 跑 `ls -la <OUTPUT> | awk '{print $5}'`,size > 10000 字节算成功。
93
+
94
+ ### 6. 处理错误
95
+
96
+ 如果 curl 失败 / OpenRouter 返回 error / base64 解码失败 / 产物太小:
97
+ - 打印 `/tmp/openrouter_resp.json` 头 500 字节(用 Bash `head -c 500`)帮助 debug
98
+ - 如果是 429 限流,sleep 5s 重试一次
99
+ - 仍然失败 → 输出 "image-gen-openrouter 失败:<原因>" 并返回(不要继续往下走)
100
+
101
+ ### 7. 收尾输出
102
+
103
+ 成功后输出**一行** summary(机器友好,方便上游 capture / regex 提取):
104
+ ```
105
+ saved: <OUTPUT> (model=<MODEL>, aspect=<ASPECT>, size=<BYTES>)
106
+ ```
107
+
108
+ ## 模型选型快速参考
109
+
110
+ | 模型 ID | 代号 | 单图成本 | 备注 |
111
+ |---|---|---|---|
112
+ | `google/gemini-3-pro-image-preview` | Nano Banana Pro | 高 | 4K,质量最好,默认 |
113
+ | `google/gemini-3.1-flash-image-preview` | Nano Banana 2 | 中 | 平衡,限流时降级 |
114
+ | `google/gemini-2.5-flash-image` | Nano Banana | 低 | 兜底 |
115
+
116
+ ## 注意事项
117
+
118
+ - **不要把 API key 打到日志或最终输出里**,curl 错误信息也要小心 grep 掉
119
+ - **不要重试超过 1 次** — 429 / 5xx 各最多 1 次重试,避免烧钱
120
+ - 中文提示词模型可以理解,但效果不如英文 —— 如果调用方给的是中文 prompt,可以保留,但**不要主动翻译**(让上游负责)
121
+ - 调用方决定 `aspect` 和 `size`;本 skill 不做任何场景假设(不绑定竖屏/横屏)