dsh-plugin-agentflow 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ // engine.mjs —— 纯 DAG 执行器,不碰 IO。
2
+ //
3
+ // 语义(已与用户确认):
4
+ // 1. 节点在「所有前驱都到达」后执行。失败也是「到达」,只是带错误标记。
5
+ // 2. 节点自身失败 → 依次试 fallback 模型 → 全挂则标记 failed,不阻塞其他泳道。
6
+ // 3. 前驱 failed/blocked → 本节点 blocked(直接报错,不走)。
7
+ // 4. 全图跑完(所有节点进入终态)才结束。
8
+ //
9
+ // 节点类型:
10
+ // - 'llm'(或不写):走 callLLM,支持 fallback。
11
+ // - 'mcp':确定性执行,直接调 opts.mcpPool.callTool,不经过模型、没有 fallback。
12
+ //
13
+ // 这里只管调度;调用模型 / MCP、发事件、落盘都由调用方注入。
14
+
15
+ /** 建图索引 */
16
+ export function buildIndex(board) {
17
+ const nodes = new Map(board.nodes.map((n) => [n.id, n]));
18
+ const preds = new Map(board.nodes.map((n) => [n.id, []]));
19
+ const succs = new Map(board.nodes.map((n) => [n.id, []]));
20
+ for (const e of board.edges || []) {
21
+ if (!nodes.has(e.from) || !nodes.has(e.to)) continue;
22
+ preds.get(e.to).push(e.from);
23
+ succs.get(e.from).push(e.to);
24
+ }
25
+ return { nodes, preds, succs };
26
+ }
27
+
28
+ /** 从 root 出发能到达的所有节点(含 root) */
29
+ export function reachableFrom(board, rootId) {
30
+ const { nodes, succs } = buildIndex(board);
31
+ const seen = new Set();
32
+ const stack = [rootId];
33
+ while (stack.length) {
34
+ const id = stack.pop();
35
+ if (seen.has(id) || !nodes.has(id)) continue;
36
+ seen.add(id);
37
+ for (const s of succs.get(id)) stack.push(s);
38
+ }
39
+ return seen;
40
+ }
41
+
42
+ /**
43
+ * 上游物料正文:多份时带【id】标签,空物料过滤。
44
+ * composePrompt(llm 节点)和 mcp 节点的 input 共用这一份「素材收集逻辑」。
45
+ */
46
+ function collectMaterials(predIds, getContent) {
47
+ const materials = predIds
48
+ .map((p) => ({ id: p, text: (getContent(p) || '').trim() }))
49
+ .filter((m) => m.text);
50
+ return materials
51
+ .map((m) => (materials.length > 1 ? `【${m.id}】\n${m.text}` : m.text))
52
+ .join('\n\n');
53
+ }
54
+
55
+ /** 'server.tool' → { server, tool },按第一个点切;不合法返回 null */
56
+ function splitToolName(full) {
57
+ const text = String(full ?? '').trim();
58
+ const dot = text.indexOf('.');
59
+ if (dot <= 0 || dot === text.length - 1) return null;
60
+ return { server: text.slice(0, dot), tool: text.slice(dot + 1) };
61
+ }
62
+
63
+ /**
64
+ * 渲染 MCP 节点的 args 模板:
65
+ * {{input}} → 上游物料拼接结果
66
+ * {{from.<nodeId>}} → 指定节点的内容(只知道画布上存在的 id)
67
+ * 其它 {{...}} → 原样保留
68
+ * 只替换字符串值;对象/数组递归。
69
+ */
70
+ function renderArgs(value, ctx) {
71
+ if (typeof value === 'string') {
72
+ return value.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (raw, name) => {
73
+ const key = name.trim();
74
+ if (key === 'input') return ctx.input;
75
+ if (key.startsWith('from.')) {
76
+ const id = key.slice('from.'.length).trim();
77
+ if (ctx.knownIds.has(id)) return ctx.getContent(id) || '';
78
+ }
79
+ return raw;
80
+ });
81
+ }
82
+ if (Array.isArray(value)) return value.map((item) => renderArgs(item, ctx));
83
+ if (value && typeof value === 'object') {
84
+ const out = {};
85
+ for (const [key, val] of Object.entries(value)) out[key] = renderArgs(val, ctx);
86
+ return out;
87
+ }
88
+ return value;
89
+ }
90
+
91
+ /**
92
+ * 跑一批节点。
93
+ * @param {object} board {nodes:[{id,type,model,fallback,task,input,criteria,...}], edges:[{from,to}]}
94
+ * @param {object} opts
95
+ * - targets: 要跑的节点 id 数组;省略 = 全图
96
+ * - callLLM: async (node, prompt, ctx) => {content, model, usage}
97
+ * - mcpPool: 可选,提供 callTool(server, tool, args)(MCP 节点用;llm 节点里由 caller 自己用)
98
+ * - emit: (event) => void 事件:node:start/delta/done/failed/blocked, run:end
99
+ * - getContent: (id) => string 取上游物料(默认读 node.content)
100
+ * - dry: 只算调度不发请求(给「跑之前先看看」用)
101
+ */
102
+ export async function runBoard(board, opts = {}) {
103
+ const { callLLM, emit = () => {}, dry = false } = opts;
104
+ const { nodes, preds } = buildIndex(board);
105
+
106
+ const targets = opts.targets && opts.targets.length
107
+ ? new Set(opts.targets.map(String))
108
+ : new Set(nodes.keys());
109
+
110
+ const state = new Map(); // id -> pending|running|done|failed|blocked
111
+ const content = new Map(); // id -> 物料文本
112
+ const usedModel = new Map();
113
+ const usage = new Map();
114
+
115
+ for (const id of nodes.keys()) {
116
+ state.set(id, 'pending');
117
+ content.set(id, nodes.get(id).content || '');
118
+ }
119
+ // 不在目标集合里的节点:视为已完成(用已有物料),不再执行
120
+ for (const id of nodes.keys()) {
121
+ if (!targets.has(id)) {
122
+ state.set(id, content.get(id) ? 'done' : 'blocked');
123
+ }
124
+ }
125
+
126
+ const getContent = opts.getContent || ((id) => content.get(id) || '');
127
+
128
+ const terminal = (s) =>
129
+ s === 'done' || s === 'failed' || s === 'blocked' || s === 'review';
130
+
131
+ let guard = 0;
132
+ while (true) {
133
+ if (++guard > 10000) throw new Error('调度循环失控,疑似成环');
134
+
135
+ const ready = [];
136
+ let justBlocked = 0;
137
+
138
+ for (const [id, st] of state) {
139
+ if (st !== 'pending') continue;
140
+ const ps = preds.get(id) || [];
141
+ // 还有前驱没到终态:先不动它,等下一轮
142
+ if (ps.some((p) => !terminal(state.get(p)))) continue;
143
+ // 前驱都到齐了,但其中有失败/阻塞 → 本节点阻塞
144
+ if (ps.some((p) => state.get(p) === 'failed' || state.get(p) === 'blocked')) {
145
+ state.set(id, 'blocked');
146
+ emit({ type: 'node:blocked', id, reason: 'upstream-failed' });
147
+ justBlocked++;
148
+ continue;
149
+ }
150
+ ready.push(id);
151
+ }
152
+
153
+ if (ready.length) {
154
+ await Promise.all(
155
+ ready.map(async (id) => {
156
+ const node = nodes.get(id);
157
+ state.set(id, 'running');
158
+ emit({ type: 'node:start', id });
159
+ if (dry) {
160
+ state.set(id, 'done');
161
+ emit({ type: 'node:done', id, dry: true });
162
+ return;
163
+ }
164
+
165
+ // ---- MCP 节点:确定性执行,不调模型、没有 fallback ----
166
+ if (node.type === 'mcp') {
167
+ const t0 = Date.now();
168
+ try {
169
+ if (!opts.mcpPool) throw new Error('没有可用的 MCP 连接池');
170
+ const parsed = splitToolName(node.tool);
171
+ if (!parsed) {
172
+ throw new Error(`MCP 节点的 tool 必须是 server.tool 形式:${String(node.tool ?? '(空)')}`);
173
+ }
174
+ const input = collectMaterials(preds.get(id) || [], getContent);
175
+ const args = renderArgs(node.args && typeof node.args === 'object' ? node.args : {}, {
176
+ input,
177
+ getContent,
178
+ knownIds: new Set(nodes.keys()),
179
+ });
180
+ const text = await opts.mcpPool.callTool(parsed.server, parsed.tool, args);
181
+ const out = text == null ? '' : String(text);
182
+ content.set(id, out);
183
+ state.set(id, 'done');
184
+ usedModel.set(id, `mcp:${parsed.server}.${parsed.tool}`);
185
+ usage.set(id, { ms: Date.now() - t0 });
186
+ emit({
187
+ type: 'node:done',
188
+ id,
189
+ model: `mcp:${parsed.server}.${parsed.tool}`,
190
+ content: out,
191
+ usage: { ms: Date.now() - t0 },
192
+ verdict: null,
193
+ status: 'done',
194
+ });
195
+ } catch (err) {
196
+ const error = String((err && err.message) || err);
197
+ state.set(id, 'failed');
198
+ emit({
199
+ type: 'node:failed',
200
+ id,
201
+ tried: [{ model: `mcp:${String(node.tool ?? '')}`, error }],
202
+ });
203
+ }
204
+ return;
205
+ }
206
+
207
+ const prompt = composePrompt(node, preds.get(id) || [], getContent);
208
+ const models = [node.model, ...(node.fallback || [])].filter(Boolean);
209
+ const tried = [];
210
+ for (const m of models) {
211
+ try {
212
+ const t0 = Date.now();
213
+ // 流式只是预览:delta 不改变状态,node:done 仍是唯一完成信号
214
+ const onDelta = (chunk) => {
215
+ if (chunk == null || chunk === '') return;
216
+ emit({ type: 'node:delta', id, chunk: String(chunk) });
217
+ };
218
+ // 推理强度按模型各自取:主模型用 node.reasoningEffort,
219
+ // 兜底模型用自己的 fallbackEfforts[provider/model](缺省 = 模型默认)
220
+ const effort = m === node.model
221
+ ? node.reasoningEffort
222
+ : (node.fallbackEfforts && typeof node.fallbackEfforts === 'object'
223
+ ? node.fallbackEfforts[m]
224
+ : '');
225
+ const res = await callLLM(
226
+ // node.onDelta 是兜底:server 的 callLLM 包装层没透传第三个参数时,
227
+ // llm.mjs 仍能从节点对象上拿到流式回调。
228
+ { ...node, model: m, reasoningEffort: effort, onDelta },
229
+ prompt,
230
+ { tried, onDelta },
231
+ );
232
+ const text = typeof res === 'string' ? res : res.content || '';
233
+ if (!text.trim()) throw new Error('空输出');
234
+ content.set(id, text);
235
+ usedModel.set(id, m);
236
+ usage.set(id, { ...(res.usage || {}), ms: Date.now() - t0 });
237
+ state.set(id, 'done');
238
+ usedModel.set(id, m);
239
+ usage.set(id, { ...(res.usage || {}), ms: Date.now() - t0 });
240
+
241
+ // 结果判断:节点配了 verify 时,跑完先过一道检查
242
+ let verdict = null;
243
+ let finalState = 'done';
244
+ if (opts.verifyNode) {
245
+ try {
246
+ const v = await opts.verifyNode(node, text);
247
+ if (v && v.pending) finalState = 'review';
248
+ else if (v) verdict = v;
249
+ } catch (err) {
250
+ verdict = {
251
+ by: node.verify || 'unknown',
252
+ pass: false,
253
+ reason: `判定出错:${(err && err.message) || err}`,
254
+ };
255
+ }
256
+ }
257
+ state.set(id, finalState);
258
+
259
+ emit({
260
+ type: 'node:done',
261
+ id,
262
+ model: m,
263
+ content: text,
264
+ usage: usage.get(id),
265
+ verdict,
266
+ status: finalState,
267
+ tried: tried.slice(),
268
+ });
269
+ return;
270
+ } catch (err) {
271
+ tried.push({ model: m, error: String((err && err.message) || err) });
272
+ emit({ type: 'node:retry', id, model: m, error: tried.at(-1).error });
273
+ }
274
+ }
275
+ state.set(id, 'failed');
276
+ emit({ type: 'node:failed', id, tried });
277
+ })
278
+ );
279
+ continue;
280
+ }
281
+
282
+ // 本轮有节点因上游失败而转 blocked → 必须再跑一轮,
283
+ // 让依赖它的节点被判成 blocked(upstream-failed),而不是掉进下面的死锁分支。
284
+ if (justBlocked > 0) continue;
285
+
286
+ // 没有节点就绪、也没有新阻塞:剩下的都在等永远不会到来的前驱 → 才是真成环。
287
+ let stuck = 0;
288
+ for (const st of state.values()) if (st === 'pending') stuck++;
289
+ if (stuck === 0) break;
290
+ for (const [id, st] of state) {
291
+ if (st === 'pending') {
292
+ state.set(id, 'blocked');
293
+ emit({ type: 'node:blocked', id, reason: 'deadlock' });
294
+ }
295
+ }
296
+ break;
297
+ }
298
+
299
+ const summary = { done: [], failed: [], blocked: [], review: [] };
300
+ for (const [id, st] of state) {
301
+ if (!targets.has(id)) continue;
302
+ if (st === 'done') summary.done.push(id);
303
+ else if (st === 'failed') summary.failed.push(id);
304
+ else if (st === 'blocked') summary.blocked.push(id);
305
+ else if (st === 'review') summary.review.push(id);
306
+ }
307
+ emit({ type: 'run:end', summary });
308
+ return { state, content, usedModel, usage, summary };
309
+ }
310
+
311
+ /**
312
+ * 组装单个节点的 prompt:只有「要做什么 / 吃什么材料 / 做到什么程度」。
313
+ * 这就是上下文边界的全部,不给别支的任何内容。
314
+ */
315
+ export function composePrompt(node, predIds, getContent) {
316
+ const parts = [];
317
+ if (node.task) parts.push(node.task.trim());
318
+ const materials = collectMaterials(predIds, getContent);
319
+ if (materials) parts.push(materials);
320
+ if (node.criteria) parts.push(`完成标准:${node.criteria.trim()}`);
321
+ return parts.join('\n\n');
322
+ }
323
+
324
+ /**
325
+ * 用量预估(只算 token,不估钱 —— 价格会变,编数字比不显示更糟)。
326
+ *
327
+ * 每个节点按 task + criteria + 上游已有 content 的字符数估算 prompt:
328
+ * 中英文混合一律按「字符数 / 2」粗算,再加一个固定输出假设 400 tok。
329
+ *
330
+ * @returns {{ total: number, perNode: Record<string, number> }}
331
+ */
332
+ export function estimateTokens(board) {
333
+ const nodes = Array.isArray(board?.nodes) ? board.nodes : [];
334
+ if (!nodes.length) return { total: 0, perNode: {} };
335
+
336
+ const { preds } = buildIndex(board);
337
+ const byId = new Map(nodes.map((n) => [n.id, n]));
338
+ const perNode = {};
339
+ let total = 0;
340
+
341
+ for (const node of nodes) {
342
+ const upstream = (preds.get(node.id) || [])
343
+ .map((id) => (byId.get(id)?.content || '').trim())
344
+ .join('\n\n');
345
+ const chars = `${node.task || ''}${node.criteria || ''}${upstream}`.length;
346
+ const tokens = Math.ceil(chars / 2) + 400;
347
+ perNode[node.id] = tokens;
348
+ total += tokens;
349
+ }
350
+
351
+ return { total, perNode };
352
+ }