dsh-plugin-agentflow 0.3.1 → 0.4.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 +97 -22
- package/cordis.patch.yml +0 -187
- package/index.js +43 -0
- package/lib/client.js +3 -3
- package/lib/commands.js +1058 -0
- package/lib/flow.js +611 -0
- package/lib/observe.js +286 -0
- package/package.json +5 -3
- package/runtime/boards.mjs +8 -1
- package/runtime/commands.mjs +195 -0
- package/runtime/orchestrator.mjs +57 -0
- package/runtime/server.mjs +67 -96
- package/runtime/web/dist/assets/index-BRBK2BoT.css +1 -0
- package/runtime/web/dist/assets/{index-DhM_avMx.js → index-BxE5pyuf.js} +5 -5
- package/runtime/web/dist/index.html +2 -2
- package/scripts/observe-report.mjs +131 -0
- package/runtime/preset-gen.mjs +0 -1117
- package/runtime/web/dist/assets/index-DEZcpxgr.css +0 -1
package/lib/flow.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 画布执行状态机(纯函数,不碰 fs / 不碰 ctx)。
|
|
3
|
+
*
|
|
4
|
+
* 一次「跟着画布走」的对话 = 一个 run:
|
|
5
|
+
* 1. 用户输入的话进根节点(调度卡);根节点按自己的 task/criteria 干活
|
|
6
|
+
* 2. 根节点显式声明完成后,算出下一层「可做」的节点
|
|
7
|
+
* 3. 这一层有几个节点,就必须建几个 teammate(name 用节点 id),
|
|
8
|
+
* 把它们各自的 task + 上游产出摘要派下去
|
|
9
|
+
* 4. 每个节点都被 agentflow_node_done 显式标记完成后,再算下一层
|
|
10
|
+
* 5. 全部节点完成 → run 收束
|
|
11
|
+
*
|
|
12
|
+
* 这里只负责「该谁做、做完没、下一条指令说什么」,真正的注入/拦工具在
|
|
13
|
+
* lib/commands.js 里接线。保持纯函数是为了能单测:同样的画布 + 同样的
|
|
14
|
+
* 完成顺序,必须推出同样的指令文本。
|
|
15
|
+
*
|
|
16
|
+
* @module lib/flow
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 节点完成必须交出产出,太短的视为没做。 */
|
|
20
|
+
const MIN_OUTPUT = 10;
|
|
21
|
+
/** 单个节点产出上限:画布别把对话上下文灌爆。 */
|
|
22
|
+
const MAX_OUTPUT = 8000;
|
|
23
|
+
/** 一条指令文本的硬上限。 */
|
|
24
|
+
const MAX_INSTRUCTION = 6000;
|
|
25
|
+
|
|
26
|
+
const STATE_PENDING = 'pending';
|
|
27
|
+
const STATE_DONE = 'done';
|
|
28
|
+
/** 没有任务的节点(占位卡)自动跳过,否则整张图会卡死在它身上。 */
|
|
29
|
+
const STATE_SKIPPED = 'skipped';
|
|
30
|
+
|
|
31
|
+
function text(value) {
|
|
32
|
+
return typeof value === 'string' ? value : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function clip(value, limit) {
|
|
36
|
+
const raw = text(value);
|
|
37
|
+
if (raw.length <= limit) return raw;
|
|
38
|
+
return `${raw.slice(0, Math.max(0, limit - 1))}…`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 根节点识别:优先 id/label 带调度含义的卡,其次取唯一无上游的节点。 */
|
|
42
|
+
function pickRoot(nodes) {
|
|
43
|
+
const byDownstream = new Map();
|
|
44
|
+
for (const node of nodes) byDownstream.set(node.id, 0);
|
|
45
|
+
for (const node of nodes) {
|
|
46
|
+
for (const up of node.upstream) {
|
|
47
|
+
if (byDownstream.has(up)) byDownstream.set(up, (byDownstream.get(up) ?? 0) + 1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const scheduler =
|
|
51
|
+
nodes.find((node) => node.type === 'orchestrator') ??
|
|
52
|
+
nodes.find((node) => node.id === 'orchestrator' || /调度|orchestrator/i.test(`${node.id} ${node.label}`));
|
|
53
|
+
if (scheduler) return scheduler;
|
|
54
|
+
|
|
55
|
+
const roots = nodes.filter((node) => node.upstream.filter((up) => byDownstream.has(up)).length === 0);
|
|
56
|
+
if (roots.length === 1) return roots[0];
|
|
57
|
+
if (roots.length === 0) return null;
|
|
58
|
+
// 多个无上游节点:挑下游最多的那张当入口
|
|
59
|
+
let best = roots[0];
|
|
60
|
+
for (const node of roots) {
|
|
61
|
+
if ((byDownstream.get(node.id) ?? 0) > (byDownstream.get(best.id) ?? 0)) best = node;
|
|
62
|
+
}
|
|
63
|
+
return best;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 每次 run 一个随机后缀,保证 teammate 名在 roster 里不会被复用。 */
|
|
67
|
+
export function newRunToken() {
|
|
68
|
+
return Math.random().toString(36).slice(2, 6).padEnd(4, '0');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 建一个 run。节点缺 task 的自动标 skipped(占位卡),避免死锁。
|
|
73
|
+
* @param {object} input
|
|
74
|
+
* @param {object} input.board 已归一化的画布 {id,name,nodes,edges}
|
|
75
|
+
* @param {string} input.seed 用户输入的那句话
|
|
76
|
+
* @param {string} input.ownerId 发起这次 run 的 agent id(只有它能标记完成)
|
|
77
|
+
* @param {number} [input.runIndex] 该 agent 的第几次 run(>1 时 teammate 名带 -rN 后缀)
|
|
78
|
+
* @returns {object} run
|
|
79
|
+
*/
|
|
80
|
+
export function createRun({ board, boardId, boardName, seed, ownerId, runIndex = 1, now = Date.now() }) {
|
|
81
|
+
const nodes = Array.isArray(board?.nodes) ? board.nodes : [];
|
|
82
|
+
const byId = new Map();
|
|
83
|
+
for (const node of nodes) {
|
|
84
|
+
if (!node || !node.id || byId.has(node.id)) continue;
|
|
85
|
+
byId.set(node.id, {
|
|
86
|
+
id: String(node.id),
|
|
87
|
+
label: text(node.label) || String(node.id),
|
|
88
|
+
// 只有空白字符的任务当占位卡:否则它会变成"有任务但没内容"的死节点
|
|
89
|
+
task: text(node.task).trim(),
|
|
90
|
+
criteria: text(node.criteria).trim(),
|
|
91
|
+
// 引擎的权威标记:调度卡 type === 'orchestrator'(旧画布没有这个字段)
|
|
92
|
+
type: text(node.type).trim(),
|
|
93
|
+
model: text(node.model),
|
|
94
|
+
provider: text(node.provider),
|
|
95
|
+
reasoningEffort: text(node.reasoningEffort),
|
|
96
|
+
skills: Array.isArray(node.skills) ? node.skills.map(String).filter(Boolean) : [],
|
|
97
|
+
tools: Array.isArray(node.tools) ? node.tools.map(String).filter(Boolean) : [],
|
|
98
|
+
tool: text(node.tool),
|
|
99
|
+
upstream: Array.isArray(node.upstream) ? node.upstream.map(String) : [],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const status = new Map();
|
|
104
|
+
/** 被跳过的节点与原因:占位卡没人做,MCP 节点命令模式暂不执行 */
|
|
105
|
+
const skipped = new Map();
|
|
106
|
+
for (const [id, node] of byId) {
|
|
107
|
+
if (node.task) {
|
|
108
|
+
status.set(id, STATE_PENDING);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
status.set(id, STATE_SKIPPED);
|
|
112
|
+
skipped.set(id, node.type === 'mcp' ? 'MCP 节点(命令模式暂不执行)' : '占位卡(没有任务)');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const index = Number.isFinite(runIndex) && runIndex > 0 ? Math.floor(runIndex) : 1;
|
|
116
|
+
// roster 名字跨 DSH 重启也不复用(实测 restart 后同名仍报 TEAM_MEMBER_NAME_TAKEN),
|
|
117
|
+
// 而 runIndex 是内存态会归零 —— 所以名字必须自带随机后缀,不依赖计数器。
|
|
118
|
+
const token = newRunToken();
|
|
119
|
+
const run = {
|
|
120
|
+
boardId: String(boardId || board?.id || ''),
|
|
121
|
+
boardName: text(boardName) || text(board?.name) || String(boardId || '画布'),
|
|
122
|
+
seed: text(seed),
|
|
123
|
+
ownerId: String(ownerId || ''),
|
|
124
|
+
nodes: byId,
|
|
125
|
+
status,
|
|
126
|
+
skipped,
|
|
127
|
+
outputs: new Map(),
|
|
128
|
+
rootId: '',
|
|
129
|
+
startedAt: now,
|
|
130
|
+
finishedAt: 0,
|
|
131
|
+
stuck: '',
|
|
132
|
+
/** 已经 steer 过派发指令的节点(只是"要了",不代表真 spawn 了)。 */
|
|
133
|
+
requested: new Set(),
|
|
134
|
+
/** 真的 spawn 成功的节点(由 post-execute 观察到成功的 spawn_teammate 才置位)。 */
|
|
135
|
+
spawned: new Set(),
|
|
136
|
+
/** nodeId -> 实际用的 teammate 名(run 内唯一,避开 roster 名字不复用)。 */
|
|
137
|
+
spawnNames: new Map(),
|
|
138
|
+
/** 本 agent 的第几次 run(仅诊断用,不参与命名)。 */
|
|
139
|
+
runIndex: index,
|
|
140
|
+
/** 这一次 run 的随机后缀:teammate 名 = <节点id清洗>-<token>。 */
|
|
141
|
+
token,
|
|
142
|
+
/** 最后一次有进展(完成/派发/催办)的时间,超时兜底用。 */
|
|
143
|
+
lastAdvanceAt: now,
|
|
144
|
+
};
|
|
145
|
+
const root = pickRoot([...byId.values()]);
|
|
146
|
+
run.rootId = root ? root.id : '';
|
|
147
|
+
// 建 run 的这一刻就没有可执行节点(全是占位卡):直接判死,别留一个永远不动的 run
|
|
148
|
+
if (run.rootId && pendingNodes(run).length === 0) {
|
|
149
|
+
run.finishedAt = now;
|
|
150
|
+
} else if (!run.rootId) {
|
|
151
|
+
run.stuck = '画布没有入口节点(既没有调度卡,也没有无上游的节点)';
|
|
152
|
+
}
|
|
153
|
+
return run;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 节点对应的 teammate 名。
|
|
158
|
+
*
|
|
159
|
+
* Agent Teams 的 roster 名字**永不复用**(同名第二次直接报 TEAM_MEMBER_NAME_TAKEN,
|
|
160
|
+
* 实测连 DSH 重启后都还记着),所以每次 run 都给一个随机后缀:
|
|
161
|
+
* `<节点id清洗>-<token>`。派发、pre-execute 校验、回收都用这个名字,
|
|
162
|
+
* 但 node_done 仍然用真实节点 id。
|
|
163
|
+
*/
|
|
164
|
+
export function spawnNameFor(run, nodeId) {
|
|
165
|
+
const id = String(nodeId ?? '');
|
|
166
|
+
const cached = run.spawnNames.get(id);
|
|
167
|
+
if (cached) return cached;
|
|
168
|
+
// roster 约束:^[a-z0-9]+(-[a-z0-9]+)*$、<=64、不能叫 lead
|
|
169
|
+
let base = id
|
|
170
|
+
.toLowerCase()
|
|
171
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
172
|
+
.replace(/^-+|-+$/g, '');
|
|
173
|
+
if (!base) base = 'node';
|
|
174
|
+
if (/^[0-9]/.test(base)) base = `n${base}`;
|
|
175
|
+
if (base === 'lead') base = 'lead-node';
|
|
176
|
+
const suffix = `-${run.token || 'run'}`;
|
|
177
|
+
let name = `${base.slice(0, 60 - suffix.length)}${suffix}`;
|
|
178
|
+
// 清洗可能撞名("a b" 与 "a-b" 都变 a-b,两个中文 id 都变 node):
|
|
179
|
+
// 同 run 内保证唯一,否则 roster 会拒绝第二个同名 spawn
|
|
180
|
+
const used = new Set(run.spawnNames.values());
|
|
181
|
+
if (used.has(name)) {
|
|
182
|
+
let n = 2;
|
|
183
|
+
while (used.has(`${name}-${n}`)) n += 1;
|
|
184
|
+
name = `${name}-${n}`;
|
|
185
|
+
}
|
|
186
|
+
run.spawnNames.set(id, name);
|
|
187
|
+
return name;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** teammate 名反查节点 id(pre-execute / post-execute 用)。 */
|
|
191
|
+
export function nodeForSpawnName(run, name) {
|
|
192
|
+
const wanted = String(name ?? '');
|
|
193
|
+
for (const [id] of run.nodes) {
|
|
194
|
+
if (spawnNameFor(run, id) === wanted) return id;
|
|
195
|
+
}
|
|
196
|
+
return '';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 是否还在跑(没完成、没卡死)。 */
|
|
200
|
+
export function isActive(run) {
|
|
201
|
+
if (!run || !run.rootId) return false;
|
|
202
|
+
if (run.finishedAt) return false;
|
|
203
|
+
if (run.stuck) return false;
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 节点是否已完成(done 或 skipped)。 */
|
|
208
|
+
function satisfied(run, id) {
|
|
209
|
+
const state = run.status.get(id);
|
|
210
|
+
return state === STATE_DONE || state === STATE_SKIPPED;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 下一层可执行的节点:pending 且所有已知上游都已满足。
|
|
215
|
+
* 未知上游(画布被改过)直接忽略,免得整张图被幽灵节点卡住。
|
|
216
|
+
*/
|
|
217
|
+
export function readyNodes(run) {
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const [id, node] of run.nodes) {
|
|
220
|
+
if (run.status.get(id) !== STATE_PENDING) continue;
|
|
221
|
+
const blocked = node.upstream.some((up) => run.nodes.has(up) && !satisfied(run, up));
|
|
222
|
+
if (!blocked) out.push(node);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 还没完成的节点(含 pending),用于判断是否收束。 */
|
|
228
|
+
export function pendingNodes(run) {
|
|
229
|
+
return [...run.nodes.values()].filter((node) => run.status.get(node.id) === STATE_PENDING);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 已完成节点的产出摘要,派给下游时当上下文用。 */
|
|
233
|
+
function upstreamDigest(run, node, limit) {
|
|
234
|
+
const parts = [];
|
|
235
|
+
for (const up of node.upstream) {
|
|
236
|
+
if (!run.nodes.has(up)) continue;
|
|
237
|
+
const output = run.outputs.get(up);
|
|
238
|
+
if (!output) continue;
|
|
239
|
+
const label = run.nodes.get(up).label;
|
|
240
|
+
parts.push(`- ${up}「${label}」产出:${clip(output, 400)}`);
|
|
241
|
+
}
|
|
242
|
+
return clip(parts.join('\n'), limit);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 标记一个节点完成。
|
|
247
|
+
*
|
|
248
|
+
* 校验(硬):
|
|
249
|
+
* - run 还在跑
|
|
250
|
+
* - 这个节点属于本画布、且当前可做(上游都完成了)
|
|
251
|
+
* - 产出不是空话(长度 >= MIN_OUTPUT)
|
|
252
|
+
* - 不能重复标记
|
|
253
|
+
*
|
|
254
|
+
* @returns {{ok: true, node: object, state: 'running'|'done'|'stuck'} | {ok: false, reason: string}}
|
|
255
|
+
*/
|
|
256
|
+
export function markDone(run, nodeId, output) {
|
|
257
|
+
if (!run) return { ok: false, reason: '当前没有进行中的画布' };
|
|
258
|
+
if (run.finishedAt) return { ok: false, reason: '这次画布已经结束了' };
|
|
259
|
+
if (run.stuck) return { ok: false, reason: `画布已卡住:${run.stuck}` };
|
|
260
|
+
const node = run.nodes.get(String(nodeId ?? ''));
|
|
261
|
+
if (!node) return { ok: false, reason: `画布「${run.boardName}」里没有节点 ${nodeId}` };
|
|
262
|
+
if (run.status.get(node.id) === STATE_DONE) return { ok: false, reason: `节点 ${node.id} 已经完成过了` };
|
|
263
|
+
if (run.status.get(node.id) === STATE_SKIPPED) return { ok: false, reason: `节点 ${node.id} 没有任务,已自动跳过` };
|
|
264
|
+
const ready = readyNodes(run).some((item) => item.id === node.id);
|
|
265
|
+
if (!ready) {
|
|
266
|
+
const waiting = node.upstream.filter((up) => run.nodes.has(up) && !satisfied(run, up));
|
|
267
|
+
return { ok: false, reason: `节点 ${node.id} 的前置还没完成:${waiting.join('、') || '未知'}` };
|
|
268
|
+
}
|
|
269
|
+
const value = text(output).trim();
|
|
270
|
+
if (value.length < MIN_OUTPUT) {
|
|
271
|
+
return { ok: false, reason: `节点 ${node.id} 的产出太短(${value.length} 字),请把实际结果交出来再标记完成` };
|
|
272
|
+
}
|
|
273
|
+
// 非根节点必须先真的派出去过:否则模型可以自己把下游做完,画布退化成单 agent
|
|
274
|
+
if (node.id !== run.rootId && !run.spawned.has(node.id)) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
reason: `节点 ${node.id} 还没派发过 teammate。当前层要按画布派发(teammate 名 ${spawnNameFor(run, node.id)}),拿到它的产出后再标记完成`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
run.outputs.set(node.id, clip(value, MAX_OUTPUT));
|
|
281
|
+
run.status.set(node.id, STATE_DONE);
|
|
282
|
+
run.lastAdvanceAt = Date.now();
|
|
283
|
+
|
|
284
|
+
if (pendingNodes(run).length === 0) {
|
|
285
|
+
run.finishedAt = Date.now();
|
|
286
|
+
return { ok: true, node, state: 'done' };
|
|
287
|
+
}
|
|
288
|
+
const next = readyNodes(run);
|
|
289
|
+
if (next.length === 0) {
|
|
290
|
+
run.stuck = '剩下的节点互相等待,没有可执行的(检查画布连线是否成环)';
|
|
291
|
+
return { ok: true, node, state: 'stuck' };
|
|
292
|
+
}
|
|
293
|
+
return { ok: true, node, state: 'running' };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 当前该派发的节点:pending、可执行、还没要过、也还没派出去。
|
|
298
|
+
* 根节点永远不派发——它就是这段对话自己。
|
|
299
|
+
*/
|
|
300
|
+
export function toDispatch(run) {
|
|
301
|
+
if (!isActive(run)) return [];
|
|
302
|
+
const out = [];
|
|
303
|
+
for (const node of readyNodes(run)) {
|
|
304
|
+
if (node.id === run.rootId) continue;
|
|
305
|
+
if (run.requested.has(node.id) || run.spawned.has(node.id)) continue;
|
|
306
|
+
out.push(node);
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 已经 steer 过派发指令(只是"要了",等 post-execute 确认真的 spawn)。 */
|
|
312
|
+
export function markRequested(run, ids) {
|
|
313
|
+
for (const id of ids ?? []) run.requested.add(String(id));
|
|
314
|
+
run.lastAdvanceAt = Date.now();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** 兼容旧名字:等同 markRequested。 */
|
|
318
|
+
export const markDispatched = markRequested;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 观察到一个 teammate 真的 spawn 成功:这是唯一能证明"派发发生了"的事实。
|
|
322
|
+
* @returns {string} 命中的节点 id(不是本 run 的节点则返回 '')
|
|
323
|
+
*/
|
|
324
|
+
export function markSpawned(run, nodeId) {
|
|
325
|
+
const id = String(nodeId ?? '');
|
|
326
|
+
if (!run || !run.nodes.has(id)) return '';
|
|
327
|
+
run.requested.delete(id);
|
|
328
|
+
run.spawned.add(id);
|
|
329
|
+
run.lastAdvanceAt = Date.now();
|
|
330
|
+
return id;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 撞名兜底:spawn 被 roster 拒(名字已被占用)时,给这个节点换一个新名字。
|
|
335
|
+
*
|
|
336
|
+
* 概率很低(随机后缀 4 位 base36),但真撞上时不能卡死整个 run:
|
|
337
|
+
* 换名后清掉 requested/spawned 标记,turn-stopping 会用新名字重新派发。
|
|
338
|
+
*
|
|
339
|
+
* @returns {string} 新名字;节点不属于本 run 时返回 ''
|
|
340
|
+
*/
|
|
341
|
+
export function regenerateSpawnName(run, nodeId) {
|
|
342
|
+
const id = String(nodeId ?? '');
|
|
343
|
+
if (!run || !run.nodes.has(id)) return '';
|
|
344
|
+
run.spawnNames.delete(id);
|
|
345
|
+
run.requested.delete(id);
|
|
346
|
+
run.spawned.delete(id);
|
|
347
|
+
run.lastAdvanceAt = Date.now();
|
|
348
|
+
// 再取一次名字:token 相同会得到同名,所以这里用节点级 salt 换一个新后缀
|
|
349
|
+
const fresh = `-${Math.random().toString(36).slice(2, 6).padEnd(4, '0')}`;
|
|
350
|
+
const previous = spawnNameFor(run, id);
|
|
351
|
+
const [base] = previous.split(new RegExp(`-${run.token}$`));
|
|
352
|
+
const name = `${(base || previous).slice(0, 60 - fresh.length)}${fresh}`;
|
|
353
|
+
run.spawnNames.set(id, name);
|
|
354
|
+
return name;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 已请求派发、但还没观察到 spawn 成功的节点(超时兜底重试用)。 */
|
|
358
|
+
export function awaitingSpawn(run) {
|
|
359
|
+
if (!isActive(run)) return [];
|
|
360
|
+
return [...run.requested].filter((id) => !run.spawned.has(id) && run.status.get(id) === STATE_PENDING);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** 画布全景(状态行用)。 */
|
|
364
|
+
export function renderStatus(run) {
|
|
365
|
+
const lines = [];
|
|
366
|
+
for (const [id, node] of run.nodes) {
|
|
367
|
+
const state = run.status.get(id);
|
|
368
|
+
const mark =
|
|
369
|
+
state === STATE_DONE
|
|
370
|
+
? '✓'
|
|
371
|
+
: state === STATE_SKIPPED
|
|
372
|
+
? '-'
|
|
373
|
+
: run.spawned.has(id)
|
|
374
|
+
? '…'
|
|
375
|
+
: run.requested.has(id)
|
|
376
|
+
? '~'
|
|
377
|
+
: '·';
|
|
378
|
+
lines.push(`${mark} ${id}「${node.label}」`);
|
|
379
|
+
}
|
|
380
|
+
return lines.join(' ');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function nodeBrief(run, node) {
|
|
384
|
+
const parts = [`- ${node.id}「${node.label}」`];
|
|
385
|
+
if (node.task) parts.push(` 任务:${clip(node.task, 600)}`);
|
|
386
|
+
if (node.criteria) parts.push(` 验收:${clip(node.criteria, 400)}`);
|
|
387
|
+
// 画布上配的模型/技能/工具要传给 teammate:spawn_teammate 没法指定模型,
|
|
388
|
+
// 但把配置写进 prompt 至少能让它按同一套技能与工具干活,而不是一无所知。
|
|
389
|
+
const config = [];
|
|
390
|
+
if (node.model) config.push(`模型 ${node.provider ? `${node.provider}/` : ''}${node.model}${node.reasoningEffort ? `(推理 ${node.reasoningEffort})` : ''}`);
|
|
391
|
+
if (node.skills.length) config.push(`技能 ${node.skills.join('、')}`);
|
|
392
|
+
if (node.tools.length) config.push(`工具 ${node.tools.join('、')}`);
|
|
393
|
+
if (config.length) parts.push(` 画布配置:${clip(config.join(';'), 300)}`);
|
|
394
|
+
const digest = upstreamDigest(run, node, 800);
|
|
395
|
+
if (digest) parts.push(` 上游产出:\n${digest}`);
|
|
396
|
+
return parts.join('\n');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 本 run 里被跳过的节点(给指令末尾提示用)。 */
|
|
400
|
+
function skippedNote(run, limit = 200) {
|
|
401
|
+
if (!run.skipped || run.skipped.size === 0) return '';
|
|
402
|
+
const bits = [...run.skipped].map(([id, reason]) => `${id}(${reason})`);
|
|
403
|
+
return clip(`本次不执行:${bits.join('、')}`, limit);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* 根节点开局指令:用户的话在这里进入画布。
|
|
408
|
+
*
|
|
409
|
+
* 预算纪律:**规则先占固定预算,用户输入单独按剩余额度截断并留提示** ——
|
|
410
|
+
* 早期实现在整段末尾统一 clip,长输入会把「必须调 node_done」这类规则整段截掉。
|
|
411
|
+
*/
|
|
412
|
+
export function startInstruction(run) {
|
|
413
|
+
const root = run.nodes.get(run.rootId);
|
|
414
|
+
if (!root) {
|
|
415
|
+
return `【AgentFlow】画布「${run.boardName}」没有找到入口节点(没有调度卡,也没有无上游的节点),这次工作流没法开始。请先回画布补一张调度卡。`;
|
|
416
|
+
}
|
|
417
|
+
const rules = [
|
|
418
|
+
'【规则(优先于下面一切内容)】',
|
|
419
|
+
'1. 只做这个节点,不要提前做下游节点。',
|
|
420
|
+
`2. 做完立刻调 agentflow_node_done(node_id="${root.id}", output="实际产出")——不调就不算完成。`,
|
|
421
|
+
'3. 不要自己派生节点,也不要自己 spawn 子节点;下一层由我按画布派发。',
|
|
422
|
+
].join('\n');
|
|
423
|
+
|
|
424
|
+
const head = `【AgentFlow · 画布「${run.boardName}」开始】\n你现在在入口节点 ${root.id}「${root.label}」。`;
|
|
425
|
+
const taskPart = [root.task ? `任务:${clip(root.task, 1200)}` : '', root.criteria ? `验收:${clip(root.criteria, 600)}` : '']
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
.join('\n');
|
|
428
|
+
|
|
429
|
+
// 固定部分(规则 + 头 + 任务)算出来,剩下的预算才给用户输入
|
|
430
|
+
const fixed = [head, taskPart, rules].filter(Boolean).join('\n\n');
|
|
431
|
+
const budget = Math.max(200, MAX_INSTRUCTION - fixed.length - 120);
|
|
432
|
+
const seed = run.seed || '(没额外说明)';
|
|
433
|
+
const seedText =
|
|
434
|
+
seed.length > budget
|
|
435
|
+
? `${seed.slice(0, Math.max(0, budget - 20))}…\n(用户输入过长已截断,完整需求请先向用户确认)`
|
|
436
|
+
: seed;
|
|
437
|
+
const text = [head, `用户说的:${seedText}`, taskPart, rules].filter(Boolean).join('\n\n');
|
|
438
|
+
return text.length > MAX_INSTRUCTION + 200 ? clip(text, MAX_INSTRUCTION + 200) : text;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 分派指令:这一层有几个节点就要求建几个 teammate。 */
|
|
442
|
+
export function dispatchInstruction(run) {
|
|
443
|
+
const layer = toDispatch(run);
|
|
444
|
+
if (layer.length === 0) return '';
|
|
445
|
+
const names = layer.map((node) => spawnNameFor(run, node.id));
|
|
446
|
+
// 规则与"必须派谁"先写死,节点详情再按剩余预算摊
|
|
447
|
+
const rules = [
|
|
448
|
+
'【规则(优先于下面一切内容)】',
|
|
449
|
+
`1. 现在必须按上面的节点各建一个 teammate(spawn_teammate),一个都不能少、不能合并。`,
|
|
450
|
+
`2. name 必须逐字使用:${names.map((name, i) => `"${name}"(节点 ${layer[i].id})`).join('、')}。`,
|
|
451
|
+
'3. prompt 里写清:该节点的任务与验收标准 + 上游已完成的产出摘要。',
|
|
452
|
+
'4. 不要自己代做这些节点;派完等他们回报。',
|
|
453
|
+
'5. 每个 teammate 回报后,用它的实际产出调 agentflow_node_done(node_id=真实节点 id, output=产出)。',
|
|
454
|
+
].join('\n');
|
|
455
|
+
const head = `【AgentFlow · 派发下一层(${layer.length} 个节点)】`;
|
|
456
|
+
const budget = Math.max(300, MAX_INSTRUCTION - rules.length - head.length - 80);
|
|
457
|
+
const perNode = Math.max(120, Math.floor(budget / Math.max(1, layer.length)));
|
|
458
|
+
const body = layer
|
|
459
|
+
.map((node) => {
|
|
460
|
+
const brief = nodeBrief(run, node);
|
|
461
|
+
return brief.length > perNode ? `${clip(brief, perNode)}` : brief;
|
|
462
|
+
})
|
|
463
|
+
.join('\n');
|
|
464
|
+
const note = skippedNote(run);
|
|
465
|
+
const text = [head, body, rules, note].filter(Boolean).join('\n\n');
|
|
466
|
+
return text.length > MAX_INSTRUCTION + 400 ? clip(text, MAX_INSTRUCTION + 400) : text;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** 卡住时的说明。 */
|
|
470
|
+
export function stuckInstruction(run) {
|
|
471
|
+
return `【AgentFlow】画布「${run.boardName}」剩下的节点互相等待,没有可执行的(检查连线是否成环):${run.stuck || '无'}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 全部完成时的收束文本。 */
|
|
475
|
+
export function summaryInstruction(run) {
|
|
476
|
+
const lines = [`【AgentFlow · 画布「${run.boardName}」全部完成】`];
|
|
477
|
+
for (const [id, node] of run.nodes) {
|
|
478
|
+
if (run.status.get(id) !== STATE_DONE) continue;
|
|
479
|
+
lines.push(`- ${id}「${node.label}」:${clip(run.outputs.get(id) || '', 300)}`);
|
|
480
|
+
}
|
|
481
|
+
return clip(lines.join('\n'), MAX_INSTRUCTION);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 进行态的签名:只有这个签名变化时才值得再注入一次提示。
|
|
486
|
+
* 每一步都重复注入同样的提示会白烧 token(长流程累积起来很可观)。
|
|
487
|
+
*/
|
|
488
|
+
export function hintSignature(run) {
|
|
489
|
+
if (!run || !isActive(run)) return '';
|
|
490
|
+
const done = [...run.status].filter(([, state]) => state === STATE_DONE).map(([id]) => id);
|
|
491
|
+
const waiting = [...run.spawned].filter((id) => run.status.get(id) === STATE_PENDING);
|
|
492
|
+
const todo = toDispatch(run).map((node) => node.id);
|
|
493
|
+
const rootPending = run.status.get(run.rootId) === STATE_PENDING ? '1' : '0';
|
|
494
|
+
return [rootPending, done.sort().join(','), waiting.sort().join(','), todo.sort().join(',')].join('|');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** 每步注入的进行态提示(很短,防止模型忘了自己在工作流里)。 */
|
|
498
|
+
export function stepHint(run) {
|
|
499
|
+
if (!run || !isActive(run)) return '';
|
|
500
|
+
const done = [...run.status].filter(([, state]) => state === STATE_DONE).map(([id]) => id);
|
|
501
|
+
const waiting = [...run.spawned].filter((id) => run.status.get(id) === STATE_PENDING);
|
|
502
|
+
const todo = toDispatch(run).map((node) => node.id);
|
|
503
|
+
const rootPending = run.status.get(run.rootId) === STATE_PENDING;
|
|
504
|
+
const parts = [`[画布「${run.boardName}」进行中] 已完成:${done.join('、') || '无'}。`];
|
|
505
|
+
if (rootPending) parts.push(`入口节点 ${run.rootId} 还没调 agentflow_node_done。`);
|
|
506
|
+
if (waiting.length) parts.push(`已派发待回报:${waiting.join('、')}。`);
|
|
507
|
+
if (todo.length) parts.push(`待派发:${todo.join('、')}。`);
|
|
508
|
+
if (!waiting.length && !todo.length && !rootPending) parts.push('当前没有可派发的节点。');
|
|
509
|
+
parts.push('只做当前层的节点,做完必须调 agentflow_node_done;不要自己派生节点。');
|
|
510
|
+
return clip(parts.join(''), 700);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** 给工具返回的、人能看的一行。 */
|
|
514
|
+
export function statusLine(run) {
|
|
515
|
+
const done = [...run.status].filter(([, state]) => state === STATE_DONE).length;
|
|
516
|
+
const total = [...run.status].filter(([, state]) => state !== STATE_SKIPPED).length;
|
|
517
|
+
return `画布「${run.boardName}」:${done}/${total} 完成`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export const FLOW_STATES = { STATE_PENDING, STATE_DONE, STATE_SKIPPED };
|
|
521
|
+
export const FLOW_LIMITS = { MIN_OUTPUT, MAX_OUTPUT, MAX_INSTRUCTION };
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// 落盘 / 恢复:run 只在内存里的话,DSH 一重启工作流就断在半路
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 把 run 压成可 JSON 化的普通对象(Map/Set 都要摊平)。
|
|
529
|
+
* @param {object} run
|
|
530
|
+
* @returns {object|null}
|
|
531
|
+
*/
|
|
532
|
+
export function serializeRun(run) {
|
|
533
|
+
if (!run || !run.rootId) return null;
|
|
534
|
+
return {
|
|
535
|
+
v: 1,
|
|
536
|
+
boardId: run.boardId,
|
|
537
|
+
boardName: run.boardName,
|
|
538
|
+
seed: run.seed,
|
|
539
|
+
ownerId: run.ownerId,
|
|
540
|
+
rootId: run.rootId,
|
|
541
|
+
token: run.token,
|
|
542
|
+
runIndex: run.runIndex,
|
|
543
|
+
startedAt: run.startedAt,
|
|
544
|
+
finishedAt: run.finishedAt,
|
|
545
|
+
stuck: run.stuck,
|
|
546
|
+
lastAdvanceAt: run.lastAdvanceAt,
|
|
547
|
+
nodes: [...run.nodes.values()],
|
|
548
|
+
status: [...run.status],
|
|
549
|
+
skipped: [...(run.skipped ?? new Map())],
|
|
550
|
+
outputs: [...run.outputs],
|
|
551
|
+
requested: [...run.requested],
|
|
552
|
+
spawned: [...run.spawned],
|
|
553
|
+
spawnNames: [...run.spawnNames],
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 从落盘数据恢复一个 run(用于 DSH 重启后续跑)。
|
|
559
|
+
*
|
|
560
|
+
* 恢复时**故意清空 spawned / requested 并换一个新 token**:重启后原来的 teammate
|
|
561
|
+
* 已经不存在,而 roster 还记得旧名字;不清的话既派不出去也等不到回报。
|
|
562
|
+
*
|
|
563
|
+
* @param {unknown} data serializeRun 的产物
|
|
564
|
+
* @returns {object|null}
|
|
565
|
+
*/
|
|
566
|
+
export function deserializeRun(data) {
|
|
567
|
+
if (!data || typeof data !== 'object' || !data.rootId || !Array.isArray(data.nodes)) return null;
|
|
568
|
+
const nodes = new Map();
|
|
569
|
+
for (const node of data.nodes) {
|
|
570
|
+
if (!node || !node.id) continue;
|
|
571
|
+
nodes.set(String(node.id), {
|
|
572
|
+
id: String(node.id),
|
|
573
|
+
label: text(node.label) || String(node.id),
|
|
574
|
+
task: text(node.task),
|
|
575
|
+
criteria: text(node.criteria),
|
|
576
|
+
type: text(node.type),
|
|
577
|
+
model: text(node.model),
|
|
578
|
+
provider: text(node.provider),
|
|
579
|
+
reasoningEffort: text(node.reasoningEffort),
|
|
580
|
+
skills: Array.isArray(node.skills) ? node.skills.map(String) : [],
|
|
581
|
+
tools: Array.isArray(node.tools) ? node.tools.map(String) : [],
|
|
582
|
+
tool: text(node.tool),
|
|
583
|
+
upstream: Array.isArray(node.upstream) ? node.upstream.map(String) : [],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
if (!nodes.has(String(data.rootId))) return null;
|
|
587
|
+
const run = {
|
|
588
|
+
boardId: text(data.boardId),
|
|
589
|
+
boardName: text(data.boardName) || '(恢复的画布)',
|
|
590
|
+
seed: text(data.seed),
|
|
591
|
+
ownerId: text(data.ownerId),
|
|
592
|
+
rootId: String(data.rootId),
|
|
593
|
+
token: newRunToken(),
|
|
594
|
+
runIndex: Number.isFinite(data.runIndex) ? data.runIndex : 1,
|
|
595
|
+
nodes,
|
|
596
|
+
status: new Map(Array.isArray(data.status) ? data.status : []),
|
|
597
|
+
skipped: new Map(Array.isArray(data.skipped) ? data.skipped : []),
|
|
598
|
+
outputs: new Map(Array.isArray(data.outputs) ? data.outputs : []),
|
|
599
|
+
requested: new Set(),
|
|
600
|
+
spawned: new Set(),
|
|
601
|
+
spawnNames: new Map(),
|
|
602
|
+
startedAt: Number.isFinite(data.startedAt) ? data.startedAt : Date.now(),
|
|
603
|
+
finishedAt: Number.isFinite(data.finishedAt) ? data.finishedAt : 0,
|
|
604
|
+
stuck: text(data.stuck),
|
|
605
|
+
lastAdvanceAt: Date.now(),
|
|
606
|
+
};
|
|
607
|
+
// 状态表里的节点必须真实存在,防止坏文件造出幽灵节点
|
|
608
|
+
for (const id of [...run.status.keys()]) if (!nodes.has(id)) run.status.delete(id);
|
|
609
|
+
for (const [id, node] of nodes) if (!run.status.has(id)) run.status.set(id, node.task ? STATE_PENDING : STATE_SKIPPED);
|
|
610
|
+
return run;
|
|
611
|
+
}
|