dsh-plugin-agentflow 0.3.1 → 0.4.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.
package/lib/observe.js ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * 观察器(阶段 1):只记录,不拦不改。
3
+ *
4
+ * 为什么先观察:在动手做「强制工作流」之前,得先看清模型实际怎么走 ——
5
+ * 每步进了哪些消息、调了哪个工具、参数长什么样、成功还是被拒、哪一步收工。
6
+ * 没这份数据就设计白名单,等于闭着眼画闸门。
7
+ *
8
+ * 挂在三个官方钩子上(都是 waterfall/serial,回调必须原样把下游结果返回):
9
+ * agent/pre-step 看每一步的输入与决策
10
+ * tools/post-execute 看每一次工具调用(含被 deny 的,deny 也过这条管线)
11
+ * agent/turn-stopping 看模型在哪里收工
12
+ *
13
+ * 落盘:`<root>/<agent>.jsonl`,一行一个事件,方便 jq 或脚本直接看。
14
+ * {t:'step', turn, step, msgs:[{kind,form,chars}], decision:{kind,msgs}}
15
+ * {t:'tool', turn, step, name, args, ms, isError, text}
16
+ * {t:'stop', turn}
17
+ *
18
+ * 纪律:所有写入 fire-and-forget,任何异常只 warn。
19
+ * 观察器坏了不能连累对话 —— 钩子里抛错会直接结束当前 turn。
20
+ *
21
+ * @module lib/observe
22
+ */
23
+
24
+ import fsp from 'node:fs/promises';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ /** 单条文本/参数最多记多少字符:够判断意图,又不至于把日志灌爆。 */
29
+ const MAX_TEXT = 240;
30
+ /** 单个 agent 最多记多少行,防止长会话把盘写满。 */
31
+ const MAX_LINES = 5000;
32
+
33
+ /** 默认落盘目录,跟画布数据放一起。 */
34
+ const DEFAULT_ROOT = path.join(os.homedir(), '.dsh', 'agent-flow-data', 'observe');
35
+
36
+ /** 把任意值压成一行可读文本;循环引用/巨对象都不许抛。 */
37
+ function asText(value) {
38
+ if (typeof value === 'string') return value;
39
+ try {
40
+ return JSON.stringify(value);
41
+ } catch {
42
+ try {
43
+ return String(value);
44
+ } catch {
45
+ return '(unserializable)';
46
+ }
47
+ }
48
+ }
49
+
50
+ /** 截断到 MAX_TEXT(或指定长度),末尾加省略号。 */
51
+ function clip(value, limit = MAX_TEXT) {
52
+ const text = asText(value);
53
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
54
+ }
55
+
56
+ /** 一条消息里所有 text block 的字符数 —— 只看体量,不存正文。 */
57
+ function messageChars(message) {
58
+ const content = message && Array.isArray(message.content) ? message.content : [];
59
+ let total = 0;
60
+ for (const block of content) {
61
+ if (block && typeof block.text === 'string') total += block.text.length;
62
+ }
63
+ return total;
64
+ }
65
+
66
+ /**
67
+ * 消息摘要:谁产的(source.kind)、什么形态(form)、多大。
68
+ * 不存正文 —— 对话内容留在 DSH 自己的 session log 里,这里只留形状。
69
+ */
70
+ function describeMessages(messages) {
71
+ if (!Array.isArray(messages)) return [];
72
+ return messages.map((message) => ({
73
+ kind: message?.source?.kind ?? '?',
74
+ form: message?.source?.form,
75
+ chars: messageChars(message),
76
+ }));
77
+ }
78
+
79
+ /** 工具结果的可读摘要:优先纯文本内容,其次 value,最后错误信息。 */
80
+ function describeResult(result) {
81
+ if (!result || typeof result !== 'object') return '';
82
+ if (Array.isArray(result.content)) {
83
+ const text = result.content
84
+ .map((block) => (block && typeof block.text === 'string' ? block.text : ''))
85
+ .join(' ')
86
+ .trim();
87
+ if (text) return text;
88
+ }
89
+ if (result.value !== undefined) return asText(result.value);
90
+ if (result.error && typeof result.error.message === 'string') return result.error.message;
91
+ return '';
92
+ }
93
+
94
+ /** 文件名安全化:agent id 是宿主给的,别让它逃出目录。 */
95
+ function safeFileName(agentId) {
96
+ return String(agentId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120) || 'agent';
97
+ }
98
+
99
+ /** agent 身份:本体 id 就是 session id。 */
100
+ function agentKey(agent) {
101
+ return agent && agent.id !== undefined ? String(agent.id) : 'unknown';
102
+ }
103
+
104
+ /**
105
+ * 每 agent 一个 append 串行队列。
106
+ * 不串行的话并发工具调用会把行写乱(appendFile 本身原子,但顺序会漂)。
107
+ */
108
+ class JsonlWriter {
109
+ constructor(root) {
110
+ this.root = root;
111
+ this.chains = new Map();
112
+ this.counts = new Map();
113
+ this.dirReady = null;
114
+ }
115
+
116
+ async ensureDir() {
117
+ if (this.dirReady === null) {
118
+ this.dirReady = fsp.mkdir(this.root, { recursive: true }).catch((err) => {
119
+ this.dirReady = null; // 允许下一行重试
120
+ throw err;
121
+ });
122
+ }
123
+ return this.dirReady;
124
+ }
125
+
126
+ /** 记一行。同步返回,绝不 await —— 钩子里不能因为落盘慢拖住对话。 */
127
+ append(agentId, event) {
128
+ const key = safeFileName(agentId);
129
+ const count = this.counts.get(key) ?? 0;
130
+ if (count >= MAX_LINES) return;
131
+ this.counts.set(key, count + 1);
132
+
133
+ const file = path.join(this.root, `${key}.jsonl`);
134
+ const line = `${JSON.stringify(event)}\n`;
135
+ const previous = this.chains.get(key) ?? Promise.resolve();
136
+ const next = previous
137
+ .then(async () => {
138
+ await this.ensureDir();
139
+ await fsp.appendFile(file, line);
140
+ })
141
+ .catch((err) => {
142
+ console.warn('[agentflow] 观察记录写失败(已忽略):' + (err && err.message ? err.message : err));
143
+ });
144
+ this.chains.set(key, next);
145
+ return next;
146
+ }
147
+
148
+ /** 测试与卸载用:等所有在途写入落完。 */
149
+ async flush() {
150
+ await Promise.all([...this.chains.values()]);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 挂上观察器。
156
+ *
157
+ * @param {object} ctx 插件上下文(只用 ctx.on)
158
+ * @param {object} [options]
159
+ * @param {string} [options.root] 落盘目录,默认 `~/.dsh/agent-flow-data/observe`
160
+ * @param {(agentId: string, event: object) => void} [options.sink] 替代落盘(测试用)
161
+ * @returns {{ dispose: () => void, flush: () => Promise<void> }}
162
+ */
163
+ export function startObserver(ctx, options = {}) {
164
+ // 没有 ctx.on 的宿主(极简嵌入、测试桩)不该让整个插件加载失败:
165
+ // 观察器只是旁路,缺了这个能力就当它不存在。
166
+ if (typeof ctx?.on !== 'function') {
167
+ console.warn('[agentflow] 宿主没有 ctx.on,观察器不启用');
168
+ return { dispose() {}, flush: async () => {} };
169
+ }
170
+
171
+ const writer = new JsonlWriter(options.root || DEFAULT_ROOT);
172
+ const sink = typeof options.sink === 'function' ? options.sink : null;
173
+ /** exec.token -> 真实执行耗时(由 tools/execute 记,post-execute 消费后删除)。 */
174
+ const timings = new Map();
175
+ /** 当前 agent 走到第几步 —— 工具调用本身不带 turn/step,得靠 pre-step 记住。 */
176
+ const position = new Map();
177
+ const disposers = [];
178
+
179
+ const write = (agentId, event) => {
180
+ if (sink) {
181
+ try {
182
+ sink(agentId, event);
183
+ } catch (err) {
184
+ console.warn('[agentflow] 观察 sink 失败(已忽略):' + (err && err.message ? err.message : err));
185
+ }
186
+ return;
187
+ }
188
+ void writer.append(agentId, event);
189
+ };
190
+
191
+ // ---- 每一步的输入与决策 -------------------------------------------------
192
+ disposers.push(
193
+ ctx.on('agent/pre-step', async (payload, next) => {
194
+ const decision = await next(); // 先让下游决定,观察器永远不改结果
195
+ try {
196
+ const agentId = agentKey(payload?.agent);
197
+ position.set(agentId, { turn: payload?.turn, step: payload?.step });
198
+ write(agentId, {
199
+ t: 'step',
200
+ at: Date.now(),
201
+ agent: agentId,
202
+ turn: payload?.turn,
203
+ step: payload?.step,
204
+ msgs: describeMessages(payload?.messages),
205
+ decision:
206
+ decision && decision.kind === 'reject'
207
+ ? { kind: 'reject' }
208
+ : { kind: decision?.kind ?? '?', msgs: describeMessages(decision?.messages) },
209
+ });
210
+ } catch (err) {
211
+ console.warn('[agentflow] 观察 step 失败(已忽略):' + (err && err.message ? err.message : err));
212
+ }
213
+ return decision;
214
+ }),
215
+ );
216
+
217
+ // ---- 每一次工具调用(deny 也过这条管线) -------------------------------
218
+ // 第二个参数 `_result` 才是工具真实结果(content/isError/error);
219
+ // next() 返回的是管线决策对象,只有 kind,不能当结果记。
220
+ disposers.push(
221
+ // 真实耗时只能在 around-dispatch 这一层量:post-execute 的 next() 只回决策,
222
+ // 它包住的不是工具执行本身(早期版本因此把 ms 全记成了 0)。
223
+ ctx.on('tools/execute', async (exec, next) => {
224
+ const token = exec && exec.token;
225
+ const startedAt = Date.now();
226
+ try {
227
+ return await next();
228
+ } finally {
229
+ if (token !== undefined) timings.set(token, Date.now() - startedAt);
230
+ }
231
+ }),
232
+ ctx.on('tools/post-execute', async (exec, _result, next) => {
233
+ const started = Date.now();
234
+ const downstream = await next();
235
+ try {
236
+ const agentId = agentKey(exec?.agent);
237
+ const spot = position.get(agentId) ?? {};
238
+ const result = _result && typeof _result === 'object' ? _result : {};
239
+ write(agentId, {
240
+ t: 'tool',
241
+ at: started,
242
+ agent: agentId,
243
+ turn: spot.turn,
244
+ step: spot.step,
245
+ name: exec?.name ?? '?',
246
+ args: clip(exec?.arguments, 300),
247
+ ms: (exec && exec.token !== undefined && timings.get(exec.token)) || 0,
248
+ isError: Boolean(result.isError),
249
+ text: clip(describeResult(result), 200),
250
+ });
251
+ if (exec && exec.token !== undefined) timings.delete(exec.token);
252
+ } catch (err) {
253
+ console.warn('[agentflow] 观察 tool 失败(已忽略):' + (err && err.message ? err.message : err));
254
+ }
255
+ return downstream;
256
+ }),
257
+ );
258
+
259
+ // ---- 模型想收工的地方 ---------------------------------------------------
260
+ disposers.push(
261
+ ctx.on('agent/turn-stopping', (payload) => {
262
+ try {
263
+ const agentId = agentKey(payload?.agent);
264
+ write(agentId, { t: 'stop', at: Date.now(), agent: agentId, turn: payload?.turn });
265
+ } catch (err) {
266
+ console.warn('[agentflow] 观察 stop 失败(已忽略):' + (err && err.message ? err.message : err));
267
+ }
268
+ }),
269
+ );
270
+
271
+ return {
272
+ dispose() {
273
+ for (const dispose of disposers) {
274
+ try {
275
+ dispose?.();
276
+ } catch {
277
+ /* 卸载失败没有补救动作 */
278
+ }
279
+ }
280
+ disposers.length = 0;
281
+ },
282
+ flush: () => writer.flush(),
283
+ };
284
+ }
285
+
286
+ export default startObserver;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-agentflow",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "DeepSeek Harness tool plugin: run AgentFlow (material-canvas agent orchestrator) boards from the model",
5
5
  "keywords": [
6
6
  "dsh",
@@ -28,14 +28,16 @@
28
28
  "bugs": "https://github.com/CC19990113/dsh-plugin-agentflow/issues",
29
29
  "main": "index.js",
30
30
  "scripts": {
31
- "check": "node --check index.js && node --check lib/bridge.js && node --check scripts/sync-runtime.mjs && tsc --noEmit",
32
- "test": "node test/bridge.selftest.mjs && node test/client.selftest.mjs",
31
+ "check": "node --check index.js && node --check lib/bridge.js && node --check lib/observe.js && node --check lib/commands.js && node --check lib/flow.js && node --check scripts/sync-runtime.mjs && node --check scripts/observe-report.mjs && tsc --noEmit",
32
+ "test": "node test/bridge.selftest.mjs && node test/client.selftest.mjs && node test/observe.selftest.mjs && node test/commands.selftest.mjs && node test/flow.selftest.mjs",
33
33
  "build": "node scripts/build-client.mjs",
34
+ "observe": "node scripts/observe-report.mjs",
34
35
  "sync": "node scripts/sync-runtime.mjs"
35
36
  },
36
37
  "type": "module",
37
38
  "dependencies": {
38
39
  "@deepseek-ai/cordis": "^4.0.5",
40
+ "@deepseek-ai/dsh-llm": "0.2.1-rc.2",
39
41
  "@deepseek-ai/dsh-tools": "^0.2.1-rc.2"
40
42
  },
41
43
  "files": [
@@ -13,7 +13,7 @@
13
13
  import fs from 'node:fs/promises';
14
14
  import path from 'node:path';
15
15
  import { dataDir, writeJsonAtomic, exclusive } from './store.mjs';
16
- import { orchestratorPersona, orchestratorCriteria } from './preset-gen.mjs';
16
+ import { orchestratorPersona, orchestratorCriteria } from './orchestrator.mjs';
17
17
 
18
18
  export const FLOW_BOARD_ID = 'flow-agentflow';
19
19
 
@@ -428,6 +428,13 @@ export async function deleteBoard(id) {
428
428
  await fs.rm(boardFile(id), { force: true });
429
429
  await removeDraftUnlocked(id);
430
430
  legacyActive.delete(id);
431
+ // 画布没了,指向它的命令也该消失(失败不阻塞删画布)
432
+ try {
433
+ const { deleteCommandsForBoard } = await import('./commands.mjs');
434
+ await deleteCommandsForBoard(id);
435
+ } catch {
436
+ /* 命令清理是附带动作,删画布本身已经成功 */
437
+ }
431
438
  return { ok: true, id };
432
439
  });
433
440
  }
@@ -0,0 +1,195 @@
1
+ // commands.mjs —— 斜杠命令的存储层(一条命令 = 一个画布的对话入口)。
2
+ //
3
+ // 落盘:<DATA_DIR>/commands/<name>.json
4
+ // { version: 1, name: 'agentflow-x', board: '<boardId>', createdAt: <ms> }
5
+ //
6
+ // 命令名同时是 DSH 的斜杠命令和这里的文件名,所以规则由 DSH 侧决定:
7
+ // ^[a-z][a-z0-9_-]*$,长度 <= 48。
8
+ // 本模块只做存储:画布是否存在、节点是否为空这类业务判断在 server.mjs。
9
+
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { dataDir, writeJsonAtomic } from './store.mjs';
13
+
14
+ export const COMMAND_VERSION = 1;
15
+ /** 命令名规则:小写字母开头,只允许 a-z 0-9 _ - */
16
+ export const COMMAND_NAME_RE = /^[a-z][a-z0-9_-]*$/;
17
+ export const COMMAND_NAME_MAX = 48;
18
+
19
+ /** 命令目录:<DATA_DIR>/commands(dataDir 每次现读 env,测试可切临时目录) */
20
+ export function commandsDir() {
21
+ return path.join(dataDir(), 'commands');
22
+ }
23
+
24
+ /** 一条命令的落盘文件;名字必须先过 isValidCommandName(调用方负责) */
25
+ export function commandFile(name) {
26
+ return path.join(commandsDir(), `${name}.json`);
27
+ }
28
+
29
+ /** 严格校验:命令名要能直接当文件名、当斜杠命令 */
30
+ export function isValidCommandName(name) {
31
+ return (
32
+ typeof name === 'string'
33
+ && name.length > 0
34
+ && name.length <= COMMAND_NAME_MAX
35
+ && COMMAND_NAME_RE.test(name)
36
+ );
37
+ }
38
+
39
+ /** 扫目录拿命令名(只认 *.json,且文件名本身合法) */
40
+ async function commandNames() {
41
+ let entries;
42
+ try {
43
+ entries = await fs.readdir(commandsDir());
44
+ } catch (err) {
45
+ if (err.code === 'ENOENT') return [];
46
+ throw err;
47
+ }
48
+ return entries
49
+ .filter((entry) => entry.endsWith('.json'))
50
+ .map((entry) => entry.slice(0, -'.json'.length))
51
+ .filter(isValidCommandName);
52
+ }
53
+
54
+ /**
55
+ * 读一条命令:不存在 / 半截 JSON / 内容不是对象 都返回 null。
56
+ * @returns {Promise<null | {version: number, name: string, board: string, createdAt: number, file: string}>}
57
+ */
58
+ export async function readCommand(name) {
59
+ if (!isValidCommandName(name)) return null;
60
+ let raw;
61
+ try {
62
+ raw = await fs.readFile(commandFile(name), 'utf8');
63
+ } catch (err) {
64
+ if (err.code === 'ENOENT') return null;
65
+ throw err;
66
+ }
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(raw);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
74
+ return {
75
+ version: Number(parsed.version) || COMMAND_VERSION,
76
+ // 文件名是权威:JSON 里的 name 只作展示,不参与定位
77
+ name,
78
+ board: String(parsed.board ?? ''),
79
+ createdAt: Number(parsed.createdAt) || 0,
80
+ file: commandFile(name),
81
+ };
82
+ }
83
+
84
+ /** 全部命令,按创建时间新的在前。 */
85
+ export async function listCommands() {
86
+ const out = [];
87
+ for (const name of await commandNames()) {
88
+ const rec = await readCommand(name);
89
+ if (!rec) continue;
90
+ out.push({ name: rec.name, board: rec.board, createdAt: rec.createdAt, file: rec.file });
91
+ }
92
+ out.sort((a, b) => b.createdAt - a.createdAt || a.name.localeCompare(b.name));
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * 把任意字符串压成合法命令名:非法字符转 '-'、首字符非字母补 'b'、截断 48。
98
+ * 纯函数,不做重名处理(重名是 commandNameFor 的事)。
99
+ */
100
+ export function normalizeCommandName(desired) {
101
+ const cleaned = String(desired ?? '')
102
+ .toLowerCase()
103
+ .replace(/[^a-z0-9_-]+/g, '-') // 非法字符统一转 '-'
104
+ .replace(/-{2,}/g, '-')
105
+ .replace(/^[-_]+|[-_]+$/g, '')
106
+ .slice(0, COMMAND_NAME_MAX)
107
+ .replace(/[-_]+$/g, '');
108
+ // 首字符必须是字母:数字开头补 'b'('9x' → 'b9x',空串 → 'b')
109
+ const head = /^[a-z]/.test(cleaned) ? cleaned : `b${cleaned}`;
110
+ return head || 'b';
111
+ }
112
+
113
+ /** 画布 id → 可读 slug('flow-agentflow' → 'agentflow-flow-agentflow') */
114
+ function commandSlug(boardId) {
115
+ return String(boardId ?? '')
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9]+/g, '-')
118
+ .replace(/-{2,}/g, '-')
119
+ .replace(/^[-_]+|[-_]+$/g, '');
120
+ }
121
+
122
+ /**
123
+ * 按画布 id 生成一个不冲突的命令名:'agentflow-<slug>',重名依次加 '-2'/'-3'…
124
+ * 后缀最多占 3 个字符,所以 base 先截到 45。
125
+ */
126
+ export async function commandNameFor(boardId) {
127
+ const slug = commandSlug(boardId);
128
+ const wanted = normalizeCommandName(`agentflow-${slug || 'board'}`);
129
+ const base = wanted.slice(0, COMMAND_NAME_MAX - 3) || 'agentflow';
130
+ let name = base;
131
+ let n = 2;
132
+ while (await readCommand(name)) name = `${base}-${n++}`;
133
+ return name;
134
+ }
135
+
136
+ /**
137
+ * 写一条命令(同名文件直接覆盖)。
138
+ *
139
+ * @param {{board: string, name?: string}} opts
140
+ * board 必填;name 缺省时按画布 id 生成并自动避开重名。
141
+ * @returns {Promise<{name: string, board: string, file: string, replaced: boolean, createdAt: number}>}
142
+ * replaced=true 表示覆盖了同名旧命令;同一张画布重复导出时 createdAt 沿用第一次,
143
+ * 换绑到别的画布则算新建(时间刷新)。
144
+ */
145
+ export async function writeCommand({ board, name } = {}) {
146
+ const boardId = typeof board === 'string' ? board.trim() : '';
147
+ if (!boardId) throw new Error('writeCommand 需要 board');
148
+ const wanted = typeof name === 'string' ? name.trim() : '';
149
+ // 显式传名字时也归一化:server 已经严格校验过,这里只兜底
150
+ const finalName = wanted ? normalizeCommandName(wanted) : await commandNameFor(boardId);
151
+ const existing = await readCommand(finalName);
152
+ const createdAt =
153
+ existing && existing.board === boardId ? existing.createdAt : Date.now();
154
+ const file = commandFile(finalName);
155
+ await writeJsonAtomic(file, {
156
+ version: COMMAND_VERSION,
157
+ name: finalName,
158
+ board: boardId,
159
+ createdAt,
160
+ });
161
+ return { name: finalName, board: boardId, file, replaced: Boolean(existing), createdAt };
162
+ }
163
+
164
+ /** 删一条命令;返回是否真的删掉了(不存在 / 名字非法都返回 false)。 */
165
+ /**
166
+ * 删画布时级联清掉指向它的命令文件。
167
+ *
168
+ * 不清的话:画布没了命令还在(插件侧会跳过不注册),同名 id 再次出现时旧命令
169
+ * 会"复活";用户也会在命令目录里堆一堆死文件。
170
+ *
171
+ * @param {string} boardId 被删的画布 id
172
+ * @returns {Promise<string[]>} 被删掉的命令名
173
+ */
174
+ export async function deleteCommandsForBoard(boardId) {
175
+ const wanted = String(boardId ?? '');
176
+ if (!wanted) return [];
177
+ const removed = [];
178
+ for (const record of await listCommands()) {
179
+ if (record.board !== wanted) continue;
180
+ if (await deleteCommand(record.name)) removed.push(record.name);
181
+ }
182
+ return removed;
183
+ }
184
+
185
+ export async function deleteCommand(name) {
186
+ if (!isValidCommandName(name)) return false;
187
+ const file = commandFile(name);
188
+ const existed = await fs
189
+ .stat(file)
190
+ .then((st) => st.isFile())
191
+ .catch(() => false);
192
+ if (!existed) return false;
193
+ await fs.rm(file, { force: true });
194
+ return true;
195
+ }
@@ -0,0 +1,57 @@
1
+ // orchestrator.mjs —— 调度根节点(对话入口)的共享规则。
2
+ //
3
+ // 从 preset-gen.mjs 搬出来的最小集合:预设生成已下线,但画布仍然需要这些规则:
4
+ // 1. 新建画布时给根节点一份默认人格与验收标准(boards.mjs 用)
5
+ // 2. 根节点不能有上游(PUT /api/boards/:id 的校验,server.mjs 用)
6
+ //
7
+ // 「调度根节点」= type === 'orchestrator' 的节点,一张画布最多一张 ——
8
+ // 它就是对话入口,也是(历史版本的)persona 来源。
9
+
10
+ /** 所有要嵌进行内文本的字段先压成单行 */
11
+ function flat(value) {
12
+ return String(value ?? '')
13
+ .replace(/\s+/g, ' ')
14
+ .trim();
15
+ }
16
+
17
+ /**
18
+ * 画布上的调度根节点(type === 'orchestrator')。
19
+ * 产品约定:一张画布最多一张 —— 它就是对话入口。
20
+ */
21
+ export function findOrchestrators(board) {
22
+ const nodes = Array.isArray(board?.nodes) ? board.nodes : [];
23
+ return nodes.filter((n) => n && n.type === 'orchestrator');
24
+ }
25
+
26
+ /**
27
+ * 根节点(对话入口)的默认人格:刻意写得很短。
28
+ * 子节点内容随时会变,所以只列一份 label + 一句用途的清单,不展开 criteria;
29
+ * 具体执行交给后续节点的 AgentFlow 流。
30
+ * 用户可以自己改写——只在新建调度卡时用这份默认值。
31
+ */
32
+ export function orchestratorPersona(board) {
33
+ const name = flat(board?.name) || '未命名画布';
34
+ return [
35
+ `你是「${name}」的对话入口。`,
36
+ '',
37
+ '你只负责和用户对话:问清需求、确认关键信息。',
38
+ '',
39
+ '需求明确后,严格按照后续节点的 AgentFlow 流去执行,不要自己直接产出结果。',
40
+ ].join('\n');
41
+ }
42
+
43
+ /** 根节点的默认验收标准 */
44
+ export function orchestratorCriteria() {
45
+ return '用户需求已确认清楚;后续节点按流程顺序全部执行完成,最终产出交给用户。';
46
+ }
47
+
48
+ /** 根节点不能有上游(它是流程起点、对话入口) */
49
+ export function assertRootHasNoUpstream(board) {
50
+ const roots = findOrchestrators(board);
51
+ if (!roots.length) return;
52
+ const incoming = new Set((board?.edges || []).map((e) => String(e.to)));
53
+ const bad = roots.filter((n) => incoming.has(String(n.id)));
54
+ if (bad.length) {
55
+ throw Object.assign(new Error('根节点(调度卡)不能有上游节点'), { status: 400 });
56
+ }
57
+ }