minimal-agent 0.1.9 → 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.
- package/README.md +405 -122
- package/dist/main.js +117 -738
- package/package.json +4 -2
- package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
- package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
- package/plugins/ralph-wiggum/plugin.ts +275 -0
- package/plugins/ralph-wiggum/src/goalState.ts +310 -0
- package/plugins/ralph-wiggum/src/sentinels.ts +24 -0
- package/plugins/ralph-wiggum/src/stopHookRunner.ts +136 -0
- package/plugins/ralph-wiggum/src/verificationGate.ts +252 -0
- package/plugins/ralph-wiggum/test/goalState.test.ts +410 -0
- package/plugins/ralph-wiggum/test/verificationGate.test.ts +122 -0
- package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
- package/plugins/workflow-runner/commands/workflow.md +15 -0
- package/plugins/workflow-runner/commands/workflows.md +8 -0
- package/plugins/workflow-runner/plugin.ts +42 -0
- package/plugins/workflow-runner/src/expressions.ts +371 -0
- package/plugins/workflow-runner/src/index.ts +194 -0
- package/plugins/workflow-runner/src/loader.ts +193 -0
- package/plugins/workflow-runner/src/runner.ts +313 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +30 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +54 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +115 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +41 -0
- package/plugins/workflow-runner/src/types.ts +183 -0
- package/plugins/workflow-runner/src/workflowState.ts +65 -0
- package/plugins/workflow-runner/test/cli.e2e.test.ts +114 -0
- package/plugins/workflow-runner/test/e2e.test.ts +268 -0
- package/plugins/workflow-runner/test/expressions.test.ts +140 -0
- package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +27 -0
- package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +49 -0
- package/plugins/workflow-runner/test/graceful.test.ts +139 -0
- package/plugins/workflow-runner/test/loader.test.ts +216 -0
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +230 -0
- package/plugins/workflow-runner/test/runner.test.ts +511 -0
- package/skills/image-gen-openrouter/SKILL.md +121 -0
- package/skills/subtitle-srt/SKILL.md +134 -0
- package/skills/tts-zh/SKILL.md +137 -0
- package/skills/video-compose/SKILL.md +139 -0
- package/workflows/book-review-short.yaml +99 -0
- package/workflows/e2e-write-greet.yaml +27 -0
- package/workflows/schema.json +74 -0
- package/workflows/youtube-shorts.yaml +171 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/workflows/stepExecutors/assert.ts —— assert step 执行
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* evalExpr(condition) → 真值通过;假值抛错(带 onFail 自定义信息)。
|
|
6
|
+
* ============================================================
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { evalExpr, interpolate } from '../expressions.ts';
|
|
10
|
+
import type { StepDef, StepResult, VarStack } from '../types.ts';
|
|
11
|
+
|
|
12
|
+
export function execAssertStep(step: StepDef, vars: VarStack): StepResult {
|
|
13
|
+
if (typeof step.condition !== 'string') {
|
|
14
|
+
throw new Error(`step ${step.id}: assert 缺少 condition`);
|
|
15
|
+
}
|
|
16
|
+
let ok: boolean;
|
|
17
|
+
try {
|
|
18
|
+
const expanded = interpolate(step.condition, vars);
|
|
19
|
+
ok = Boolean(evalExpr(expanded, vars));
|
|
20
|
+
} catch (e) {
|
|
21
|
+
throw new Error(`assert 表达式求值失败: ${(e as Error).message}`);
|
|
22
|
+
}
|
|
23
|
+
if (!ok) {
|
|
24
|
+
const msg = step.onFail
|
|
25
|
+
? interpolate(step.onFail, vars)
|
|
26
|
+
: `assertion failed: ${step.condition}`;
|
|
27
|
+
throw new Error(msg);
|
|
28
|
+
}
|
|
29
|
+
return { raw: { ok: true }, preview: 'assert passed' };
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/workflows/stepExecutors/llm.ts —— llm step 执行
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 单轮 LLM 生成。关键设计:
|
|
6
|
+
* - tools: [] —— 不开任何工具(工具调用应该写成 tool: step)
|
|
7
|
+
* - 自带独立 system + user 两条消息,不污染主 history
|
|
8
|
+
* - 流式累积文本(reasoning 字段在这里忽略;只关心最终文本)
|
|
9
|
+
*
|
|
10
|
+
* capture:
|
|
11
|
+
* - `{ text: var_name }` 绑生成文本
|
|
12
|
+
* - `{ result: var_name }` 同义
|
|
13
|
+
* ============================================================
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { chat, type Message } from '../../../../src/plugin-sdk.ts';
|
|
17
|
+
import { interpolate } from '../expressions.ts';
|
|
18
|
+
import type { RunContext, StepDef, StepResult, VarStack } from '../types.ts';
|
|
19
|
+
|
|
20
|
+
const LLM_STEP_SYSTEM =
|
|
21
|
+
'你正在被一个 workflow 调用。只输出本步骤要求的内容本身,不要寒暄、不要列要求复述、不要工具调用语法。';
|
|
22
|
+
|
|
23
|
+
export async function execLlmStep(
|
|
24
|
+
step: StepDef,
|
|
25
|
+
vars: VarStack,
|
|
26
|
+
ctx: RunContext,
|
|
27
|
+
): Promise<StepResult> {
|
|
28
|
+
if (typeof step.llm !== 'string') throw new Error(`step ${step.id}: 缺少 llm 字段`);
|
|
29
|
+
const prompt = interpolate(step.llm, vars);
|
|
30
|
+
|
|
31
|
+
const messages: Message[] = [
|
|
32
|
+
{ role: 'system', content: LLM_STEP_SYSTEM },
|
|
33
|
+
{ role: 'user', content: prompt },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
let text = '';
|
|
37
|
+
for await (const ev of chat({
|
|
38
|
+
provider: ctx.provider,
|
|
39
|
+
messages,
|
|
40
|
+
tools: [],
|
|
41
|
+
signal: ctx.signal,
|
|
42
|
+
})) {
|
|
43
|
+
if (ev.type === 'text_delta') text += ev.delta;
|
|
44
|
+
if (ev.type === 'done' && ev.stopReason === 'aborted') {
|
|
45
|
+
throw new Error('用户中断');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const trimmed = text.trim();
|
|
50
|
+
return {
|
|
51
|
+
raw: { text: trimmed, result: trimmed },
|
|
52
|
+
preview: trimmed.length > 200 ? `${trimmed.slice(0, 200)}...` : trimmed,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* plugins/workflow-runner/src/stepExecutors/skill.ts —— skill step 执行
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 把一个具名 skill(skills/<name>/SKILL.md)当作"迷你 ReAct 子循环"
|
|
6
|
+
* 跑一次:复用 src/loop.ts::runQuery,让模型在子 history 内 T-A-O-R。
|
|
7
|
+
*
|
|
8
|
+
* 实现要点:
|
|
9
|
+
* - 双源查找:cwd/skills/<name>/SKILL.md 优先,packageRoot/skills/ fallback;
|
|
10
|
+
* 支持按目录名 or 按 frontmatter::name 匹配
|
|
11
|
+
* - 剥掉 frontmatter,body 作 system 指令;step.input 插值后作为 user
|
|
12
|
+
* - 子 history 与外层 ctx.history 隔离,避免污染主对话
|
|
13
|
+
* - 流式转发:runQuery yield 的事件直接 yield 出去(让 UI 看到子循环进度)
|
|
14
|
+
* - 收集 text 事件累计为 raw.text,供 capture 绑变量使用
|
|
15
|
+
* - maxTurns 可在 step 里覆盖,默认走 runQuery 自带(50)
|
|
16
|
+
*
|
|
17
|
+
* capture:{ text: var_name } / { result: var_name }
|
|
18
|
+
* ============================================================
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
getResourceSearchPaths,
|
|
26
|
+
runQuery,
|
|
27
|
+
type LoopEvent,
|
|
28
|
+
type Message,
|
|
29
|
+
} from '../../../../src/plugin-sdk.ts';
|
|
30
|
+
import { interpolate } from '../expressions.ts';
|
|
31
|
+
import type { RunContext, StepDef, StepResult, VarStack } from '../types.ts';
|
|
32
|
+
|
|
33
|
+
function stripFrontmatter(content: string): string {
|
|
34
|
+
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseFrontmatterName(content: string): string | null {
|
|
38
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
const nm = m[1].match(/^name:\s*(.+)$/m);
|
|
41
|
+
if (!nm) return null;
|
|
42
|
+
return nm[1].trim().replace(/^["']|["']$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function findSkillFile(skillName: string): Promise<string | null> {
|
|
46
|
+
const roots = getResourceSearchPaths('skills', import.meta.url);
|
|
47
|
+
for (const root of roots) {
|
|
48
|
+
let entries: { name: string; isDirectory: () => boolean }[];
|
|
49
|
+
try {
|
|
50
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (!e.isDirectory()) continue;
|
|
56
|
+
const skillFile = join(root, e.name, 'SKILL.md');
|
|
57
|
+
if (e.name === skillName) return skillFile;
|
|
58
|
+
try {
|
|
59
|
+
const md = await readFile(skillFile, 'utf8');
|
|
60
|
+
if (parseFrontmatterName(md) === skillName) return skillFile;
|
|
61
|
+
} catch {
|
|
62
|
+
// 该子目录无 SKILL.md,跳过
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function* execSkillStep(
|
|
70
|
+
step: StepDef,
|
|
71
|
+
vars: VarStack,
|
|
72
|
+
ctx: RunContext,
|
|
73
|
+
): AsyncGenerator<LoopEvent, StepResult, void> {
|
|
74
|
+
if (!step.skill) throw new Error(`step ${step.id}: 缺少 skill 字段`);
|
|
75
|
+
const skillName = interpolate(step.skill, vars);
|
|
76
|
+
|
|
77
|
+
const skillFile = await findSkillFile(skillName);
|
|
78
|
+
if (!skillFile) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`step ${step.id}: 未找到 skill "${skillName}"(cwd/skills/ 与 packageRoot/skills/ 双源均未命中)`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const md = await readFile(skillFile, 'utf8');
|
|
84
|
+
const body = stripFrontmatter(md).trim();
|
|
85
|
+
|
|
86
|
+
const userInput = step.input
|
|
87
|
+
? interpolate(step.input, vars)
|
|
88
|
+
: `请按 SKILL "${skillName}" 的指示完成任务。`;
|
|
89
|
+
|
|
90
|
+
const subHistory: Message[] = [
|
|
91
|
+
{
|
|
92
|
+
role: 'system',
|
|
93
|
+
content: `下面是要遵循的 SKILL 指示(来源:${skillFile}):\n\n${body}`,
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
let text = '';
|
|
98
|
+
for await (const ev of runQuery(userInput, {
|
|
99
|
+
provider: ctx.provider,
|
|
100
|
+
history: subHistory,
|
|
101
|
+
signal: ctx.signal,
|
|
102
|
+
maxTurns: step.maxTurns,
|
|
103
|
+
})) {
|
|
104
|
+
if (ev.type === 'text') text += ev.delta;
|
|
105
|
+
if (ev.type === 'interrupted') throw new Error('用户中断');
|
|
106
|
+
if (ev.type === 'error') throw new Error(ev.error);
|
|
107
|
+
yield ev;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const trimmed = text.trim();
|
|
111
|
+
return {
|
|
112
|
+
raw: { text: trimmed, result: trimmed },
|
|
113
|
+
preview: trimmed.length > 200 ? `${trimmed.slice(0, 200)}...` : trimmed,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/workflows/stepExecutors/tool.ts —— tool step 执行
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 把 step.args 递归 ${var} 插值后 JSON.stringify,转发到 executeTool。
|
|
6
|
+
* 失败(ok=false)抛 Error,让 runner 走 onError 处理。
|
|
7
|
+
*
|
|
8
|
+
* capture 字段(支持点路径):
|
|
9
|
+
* - `{ content: var_name }` 绑 ToolResult.content(工具回执文本)
|
|
10
|
+
* - `{ result: var_name }` 同义,给"无结构化字段"工具用
|
|
11
|
+
* - `{ args.content: var_name }` 绑插值后传给工具的入参(如真正写入的内容)
|
|
12
|
+
* - `{ args.<任意字段>: var_name }` 绑入参的具名字段
|
|
13
|
+
* P1 可扩展按工具特定字段(如 Read 的 fileSize)。
|
|
14
|
+
* ============================================================
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { executeTool, getToolByName } from '../../../../src/plugin-sdk.ts';
|
|
18
|
+
import { interpolateDeep } from '../expressions.ts';
|
|
19
|
+
import type { RunContext, StepDef, StepResult, VarStack } from '../types.ts';
|
|
20
|
+
|
|
21
|
+
export async function execToolStep(
|
|
22
|
+
step: StepDef,
|
|
23
|
+
vars: VarStack,
|
|
24
|
+
ctx: RunContext,
|
|
25
|
+
): Promise<StepResult> {
|
|
26
|
+
if (!step.tool) throw new Error(`step ${step.id}: 缺少 tool 字段`);
|
|
27
|
+
const tool = getToolByName(step.tool);
|
|
28
|
+
if (!tool) throw new Error(`step ${step.id}: 未知 tool "${step.tool}"`);
|
|
29
|
+
|
|
30
|
+
const args = interpolateDeep(step.args ?? {}, vars);
|
|
31
|
+
const argsJson = JSON.stringify(args);
|
|
32
|
+
const result = await executeTool(tool.name, argsJson, ctx.signal);
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
throw new Error(result.error);
|
|
35
|
+
}
|
|
36
|
+
const content = result.content;
|
|
37
|
+
return {
|
|
38
|
+
raw: { content, result: content, args },
|
|
39
|
+
preview: content.length > 200 ? `${content.slice(0, 200)}...` : content,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/workflows/types.ts —— workflow runner 核心类型
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* ralph-loop 让 LLM 自规划;workflow 反过来 —— 外层确定性 step 列表
|
|
6
|
+
* 驱动执行,LLM 只在 llm: / skill: 节点出场。本文件定义 YAML 解析
|
|
7
|
+
* 后的内存形态(WorkflowDef / StepDef),以及运行时上下文(VarStack /
|
|
8
|
+
* RunContext)和 UI 事件(WorkflowEvent)。
|
|
9
|
+
*
|
|
10
|
+
* 与 src/types.ts 关系:WorkflowEvent 是插件**私有**事件,结构上满足
|
|
11
|
+
* framework 的 PluginEvent 开放契约(type: string + 任意 payload),
|
|
12
|
+
* 经 plugin.ts -> pluginRunner 原样透传给 UI;UI 不识别即静默忽略。
|
|
13
|
+
* src/types.ts 的 LoopEvent union **不**包含 workflow_*,框架零插件耦合。
|
|
14
|
+
* ============================================================
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Message, Provider } from '../../../src/plugin-sdk.ts';
|
|
18
|
+
|
|
19
|
+
// ---------------- 1. Workflow / Step 定义 ----------------
|
|
20
|
+
|
|
21
|
+
export type InputType = 'string' | 'number' | 'enum';
|
|
22
|
+
|
|
23
|
+
export interface InputDef {
|
|
24
|
+
name: string;
|
|
25
|
+
type: InputType;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
default?: unknown;
|
|
28
|
+
/** type=enum 时必填 */
|
|
29
|
+
values?: string[];
|
|
30
|
+
description?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StepKind = 'tool' | 'llm' | 'skill' | 'assert' | 'branch' | 'loop' | 'pause';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* StepDef 是 YAML 里 steps[] 元素直接反序列化的形态。
|
|
37
|
+
*
|
|
38
|
+
* 动作字段四选一(互斥优先级 type > tool > skill > llm):
|
|
39
|
+
* - tool: 调原子工具
|
|
40
|
+
* - skill: 迷你 ReAct 子循环(P1)
|
|
41
|
+
* - llm: 单轮 LLM 生成(不开工具)
|
|
42
|
+
* - type: 控制流(assert / branch / loop / pause)
|
|
43
|
+
*
|
|
44
|
+
* 其它字段按 step 类型选择性使用,未用到的字段静默忽略。
|
|
45
|
+
*/
|
|
46
|
+
export interface StepDef {
|
|
47
|
+
id: string;
|
|
48
|
+
tool?: string;
|
|
49
|
+
skill?: string;
|
|
50
|
+
llm?: string;
|
|
51
|
+
type?: 'assert' | 'branch' | 'loop' | 'pause';
|
|
52
|
+
|
|
53
|
+
args?: Record<string, unknown>;
|
|
54
|
+
input?: string;
|
|
55
|
+
capture?: Record<string, string>;
|
|
56
|
+
when?: string;
|
|
57
|
+
onError?: 'halt' | 'continue';
|
|
58
|
+
maxTurns?: number;
|
|
59
|
+
|
|
60
|
+
condition?: string;
|
|
61
|
+
onFail?: string;
|
|
62
|
+
then?: StepDef[];
|
|
63
|
+
else?: StepDef[];
|
|
64
|
+
over?: string;
|
|
65
|
+
as?: string;
|
|
66
|
+
do?: StepDef[];
|
|
67
|
+
|
|
68
|
+
prompt?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WorkflowDef {
|
|
72
|
+
name: string;
|
|
73
|
+
description: string;
|
|
74
|
+
version?: string;
|
|
75
|
+
inputs?: InputDef[];
|
|
76
|
+
steps: StepDef[];
|
|
77
|
+
/** 编辑器布局信息;runner 严格忽略 */
|
|
78
|
+
__meta?: unknown;
|
|
79
|
+
/** loader 写入:yaml 来源(cwd / packageRoot),便于排错 */
|
|
80
|
+
__source?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------- 2. 变量栈(loop scope 用) ----------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 简单的多帧变量栈:
|
|
87
|
+
* - 最外层(frame 0)放 inputs + 全局变量
|
|
88
|
+
* - 进入 loop 时 push() 新帧,存当前迭代的 as / as_idx
|
|
89
|
+
* - 退出 loop 时 pop()
|
|
90
|
+
*
|
|
91
|
+
* get(key) 从栈顶往下找第一个匹配的 frame,类似 JS 的 lexical scope。
|
|
92
|
+
*/
|
|
93
|
+
export class VarStack {
|
|
94
|
+
private frames: Record<string, unknown>[] = [{}];
|
|
95
|
+
|
|
96
|
+
set(key: string, val: unknown): void {
|
|
97
|
+
this.frames[this.frames.length - 1][key] = val;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 写到栈底(root frame),用于 inputs / 全局常量 */
|
|
101
|
+
setGlobal(key: string, val: unknown): void {
|
|
102
|
+
this.frames[0][key] = val;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get(key: string): unknown {
|
|
106
|
+
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
107
|
+
if (key in this.frames[i]) return this.frames[i][key];
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
has(key: string): boolean {
|
|
113
|
+
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
114
|
+
if (key in this.frames[i]) return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
push(): void {
|
|
120
|
+
this.frames.push({});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pop(): void {
|
|
124
|
+
if (this.frames.length > 1) this.frames.pop();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars 持久化与事件 */
|
|
128
|
+
snapshot(): Record<string, unknown> {
|
|
129
|
+
return Object.assign({}, ...this.frames);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------- 3. 运行上下文 ----------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* runner 内部传递的运行时上下文。
|
|
137
|
+
*
|
|
138
|
+
* 设计:不让 step executors 直接拿 PluginRunnerOptions,避免把"插件运行机制"
|
|
139
|
+
* 暴露到 workflow 层。RunContext 只暴露 executors 真正需要的东西。
|
|
140
|
+
*/
|
|
141
|
+
export interface RunContext {
|
|
142
|
+
provider: Provider;
|
|
143
|
+
signal?: AbortSignal;
|
|
144
|
+
/**
|
|
145
|
+
* 外部 history(来自 pluginRunner.runWithPlugins 的 baseHistory)。
|
|
146
|
+
* workflow runner 不直接 push 到这里;step executors 只读。
|
|
147
|
+
*/
|
|
148
|
+
history: Message[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------- 4. step 执行结果 ----------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 每个 step executor 的返回。capture 字段会从 raw 上按 capture map 取值。
|
|
155
|
+
*
|
|
156
|
+
* - raw: 完整结构化结果,给 capture 用
|
|
157
|
+
* - preview: 给 UI 展示的短摘要(≤200 char)
|
|
158
|
+
*/
|
|
159
|
+
export interface StepResult {
|
|
160
|
+
raw: Record<string, unknown>;
|
|
161
|
+
preview: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------- 5. workflow 事件(独立类型,便于 step executors 直接 yield) ----------------
|
|
165
|
+
|
|
166
|
+
export type WorkflowEvent =
|
|
167
|
+
| { type: 'workflow_start'; name: string; totalSteps: number }
|
|
168
|
+
| {
|
|
169
|
+
type: 'workflow_step_start';
|
|
170
|
+
id: string;
|
|
171
|
+
kind: StepKind;
|
|
172
|
+
index: number;
|
|
173
|
+
total: number;
|
|
174
|
+
}
|
|
175
|
+
| {
|
|
176
|
+
type: 'workflow_step_end';
|
|
177
|
+
id: string;
|
|
178
|
+
ok: boolean;
|
|
179
|
+
output?: string;
|
|
180
|
+
error?: string;
|
|
181
|
+
}
|
|
182
|
+
| { type: 'workflow_step_skipped'; id: string; reason: string }
|
|
183
|
+
| { type: 'workflow_done'; name: string; vars: Record<string, unknown> };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/workflows/workflowState.ts —— workflow 运行状态持久化
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 与 GoalState 类似的轻量目录模型,但只用于 debug / 中断恢复(P0 不实现
|
|
6
|
+
* 恢复,只写出现场)。目录结构:
|
|
7
|
+
*
|
|
8
|
+
* <cwd>/.minimal-agent-workflow/
|
|
9
|
+
* ├── current.json # { name, startedAt }
|
|
10
|
+
* ├── inputs.json # 用户传入的 inputs 快照
|
|
11
|
+
* ├── vars.json # 最后一次成功 step 后的 VarStack.snapshot()
|
|
12
|
+
* └── progress.md # 时间戳追加的 step 完成日志
|
|
13
|
+
*
|
|
14
|
+
* init() 进 workflow 前 reset 一次;cleanup() 在 finally 把文件删掉
|
|
15
|
+
* + rmdir(保持 /new 扫描 .minimal-agent-* 子目录的语义一致)。
|
|
16
|
+
*
|
|
17
|
+
* 与 GoalState 的核心区别:workflow 不需要 PHASE / completion / learnings 文件,
|
|
18
|
+
* 因为执行流程是确定性的,没有 LLM 自规划过程。
|
|
19
|
+
* ============================================================
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdir, writeFile, appendFile, rm } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const STATE_DIR = '.minimal-agent-workflow';
|
|
26
|
+
|
|
27
|
+
export class WorkflowState {
|
|
28
|
+
readonly dir: string;
|
|
29
|
+
constructor(workingDir: string) {
|
|
30
|
+
this.dir = join(workingDir, STATE_DIR);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async init(name: string, inputs: Record<string, unknown>): Promise<void> {
|
|
34
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
35
|
+
await mkdir(this.dir, { recursive: true });
|
|
36
|
+
await writeFile(
|
|
37
|
+
join(this.dir, 'current.json'),
|
|
38
|
+
JSON.stringify({ name, startedAt: new Date().toISOString() }, null, 2),
|
|
39
|
+
'utf8',
|
|
40
|
+
);
|
|
41
|
+
await writeFile(
|
|
42
|
+
join(this.dir, 'inputs.json'),
|
|
43
|
+
JSON.stringify(inputs, null, 2),
|
|
44
|
+
'utf8',
|
|
45
|
+
);
|
|
46
|
+
await writeFile(join(this.dir, 'progress.md'), `# ${name}\n\n`, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async appendProgress(line: string): Promise<void> {
|
|
50
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
51
|
+
await appendFile(join(this.dir, 'progress.md'), `- ${ts} ${line}\n`, 'utf8').catch(() => {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async writeVars(snapshot: Record<string, unknown>): Promise<void> {
|
|
55
|
+
await writeFile(
|
|
56
|
+
join(this.dir, 'vars.json'),
|
|
57
|
+
JSON.stringify(snapshot, null, 2),
|
|
58
|
+
'utf8',
|
|
59
|
+
).catch(() => {});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async cleanup(): Promise<void> {
|
|
63
|
+
await rm(this.dir, { recursive: true, force: true }).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* plugins/workflow-runner/test/cli.e2e.test.ts
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 最外层 CLI 端到端:拉子进程跑 `bun src/main.tsx -p "/workflow ..."`,
|
|
6
|
+
* 验证 stdout / stderr / 退出码 / 文件副作用 全链路。
|
|
7
|
+
*
|
|
8
|
+
* 覆盖 3 个用例:
|
|
9
|
+
* 1. golden path:/workflow cli-e2e --input topic=world → exit 0 + 文件落盘
|
|
10
|
+
* 2. /workflows 列出 cli-e2e
|
|
11
|
+
* 3. 缺必填 input → exit 1 + 错误信息含 "topic"
|
|
12
|
+
*
|
|
13
|
+
* fake provider env:workflow 是 tool-only,永不调 API;fake env 仅满足
|
|
14
|
+
* main.tsx 的 provider 配置校验。
|
|
15
|
+
* ============================================================
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
19
|
+
import {
|
|
20
|
+
copyFileSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
} from 'node:fs';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join, resolve } from 'node:path';
|
|
29
|
+
|
|
30
|
+
const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..');
|
|
31
|
+
const FIXTURE = join(import.meta.dir, 'fixtures', 'cli-e2e.yaml');
|
|
32
|
+
|
|
33
|
+
const SPAWN_TIMEOUT = 60_000;
|
|
34
|
+
|
|
35
|
+
interface SpawnResult {
|
|
36
|
+
exitCode: number;
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function spawnMain(promptArg: string, cwd: string): Promise<SpawnResult> {
|
|
42
|
+
const proc = Bun.spawn(
|
|
43
|
+
['bun', 'src/main.tsx', '-d', cwd, '-p', promptArg],
|
|
44
|
+
{
|
|
45
|
+
cwd: REPO_ROOT,
|
|
46
|
+
env: {
|
|
47
|
+
...process.env,
|
|
48
|
+
MINIMAL_AGENT_BASE_URL: 'http://fake.invalid',
|
|
49
|
+
MINIMAL_AGENT_API_KEY: 'fake-key',
|
|
50
|
+
MINIMAL_AGENT_MODEL: 'fake-model',
|
|
51
|
+
MINIMAL_AGENT_CONTEXT_WINDOW: '128000',
|
|
52
|
+
},
|
|
53
|
+
stdout: 'pipe',
|
|
54
|
+
stderr: 'pipe',
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
59
|
+
new Response(proc.stdout).text(),
|
|
60
|
+
new Response(proc.stderr).text(),
|
|
61
|
+
proc.exited,
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
return { exitCode, stdout, stderr };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('CLI 端到端 /workflow(拉子进程跑 main.tsx)', () => {
|
|
68
|
+
let tmp: string;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
tmp = mkdtempSync(join(tmpdir(), 'cli-e2e-'));
|
|
72
|
+
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
73
|
+
copyFileSync(FIXTURE, join(tmp, 'workflows', 'cli-e2e.yaml'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it(
|
|
81
|
+
'golden path:跑完 + 文件落盘 + exit 0',
|
|
82
|
+
async () => {
|
|
83
|
+
const r = await spawnMain(
|
|
84
|
+
'/workflow cli-e2e --input topic=world',
|
|
85
|
+
tmp,
|
|
86
|
+
);
|
|
87
|
+
expect(r.exitCode).toBe(0);
|
|
88
|
+
const filePath = join(tmp, 'out', 'world', 'note.txt');
|
|
89
|
+
expect(existsSync(filePath)).toBe(true);
|
|
90
|
+
expect(readFileSync(filePath, 'utf8')).toBe('topic=world ok');
|
|
91
|
+
},
|
|
92
|
+
SPAWN_TIMEOUT,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
it(
|
|
96
|
+
'/workflows 列出 cli-e2e',
|
|
97
|
+
async () => {
|
|
98
|
+
const r = await spawnMain('/workflows', tmp);
|
|
99
|
+
expect(r.exitCode).toBe(0);
|
|
100
|
+
expect(r.stdout).toContain('cli-e2e');
|
|
101
|
+
},
|
|
102
|
+
SPAWN_TIMEOUT,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
it(
|
|
106
|
+
'缺必填 input → exit 1 + 错误信息含 "topic"',
|
|
107
|
+
async () => {
|
|
108
|
+
const r = await spawnMain('/workflow cli-e2e', tmp);
|
|
109
|
+
expect(r.exitCode).toBe(1);
|
|
110
|
+
expect(r.stderr).toContain('topic');
|
|
111
|
+
},
|
|
112
|
+
SPAWN_TIMEOUT,
|
|
113
|
+
);
|
|
114
|
+
});
|