dsh-plugin-agentflow 0.0.0-stage → 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/index.js ADDED
@@ -0,0 +1,448 @@
1
+ /**
2
+ * dsh-plugin-agentflow —— 把 AgentFlow(物料画布式 agent 编排器)接进 DSH。
3
+ *
4
+ * 两个工具:
5
+ * agentflow_list 看画布上有什么:节点、状态、连线、用了哪个模型
6
+ * agentflow_run 跑画布(或其中几步),把每一步的产出带回来
7
+ *
8
+ * 外加一个本地 llm bridge:AgentFlow server 通过它用 DSH 的 ctx.llm 调模型,
9
+ * 不再自己实现各家 provider 协议(契约见 agent-flow/docs/bridge-contract.md)。
10
+ *
11
+ * 引擎随插件分发:`npm run sync` 把 AgentFlow 源码快照到 runtime/,
12
+ * 安装后不依赖机器上另有 ~/.dsh/agent-flow。开发时仍可用 agentflowDir 或
13
+ * AGENTFLOW_DIR 指回引擎仓库,省得每改一行就重新打包。
14
+ *
15
+ * @module dsh-plugin-agentflow
16
+ */
17
+
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs/promises';
21
+ import { existsSync } from 'node:fs';
22
+ import { spawn } from 'node:child_process';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+ import { defineTool } from '@deepseek-ai/dsh-tools';
25
+ import { startBridge } from './lib/bridge.js';
26
+
27
+ export const name = 'tool-agentflow';
28
+
29
+ /** 需要宿主的工具注册表、系统提示,以及模型服务(llm bridge 用) */
30
+ export const inject = ['tools', 'systemPrompt', 'llm'];
31
+
32
+ const PLUGIN_ROOT = path.dirname(fileURLToPath(import.meta.url));
33
+ /** 随插件分发的引擎快照(npm run sync 生成) */
34
+ const BUNDLED_DIR = path.join(PLUGIN_ROOT, 'runtime');
35
+ /** 开发机上的引擎仓库 */
36
+ const LEGACY_DIR = path.join(os.homedir(), '.dsh', 'agent-flow');
37
+ /** 旧引擎自带的单画布数据目录,只在首次启动时抄一次 */
38
+ const LEGACY_DATA_DIR = path.join(LEGACY_DIR, 'data');
39
+ /** 插件数据目录:不放在安装目录里,升级/重装插件不会丢画布 */
40
+ const DEFAULT_DATA_DIR = path.join(os.homedir(), '.dsh', 'agent-flow-data');
41
+
42
+ /** agentflowDir 配置或 AGENTFLOW_DIR 是否显式指定了引擎目录(开发模式) */
43
+ function hasExplicitDir(config) {
44
+ const fromConfig = config && typeof config.agentflowDir === 'string' ? config.agentflowDir : '';
45
+ return Boolean(fromConfig || process.env.AGENTFLOW_DIR);
46
+ }
47
+
48
+ /**
49
+ * 引擎目录:优先插件自带 runtime/;显式配置时指哪用哪;
50
+ * runtime/ 还没生成(开发机没跑 npm run sync)时退回旧目录。
51
+ */
52
+ function resolveDir(config) {
53
+ const fromConfig = config && typeof config.agentflowDir === 'string' ? config.agentflowDir : '';
54
+ if (fromConfig) return fromConfig;
55
+ if (process.env.AGENTFLOW_DIR) return process.env.AGENTFLOW_DIR;
56
+ if (existsSync(path.join(BUNDLED_DIR, 'server.mjs'))) return BUNDLED_DIR;
57
+ return LEGACY_DIR;
58
+ }
59
+
60
+ /** 数据目录:插件配置 dataDir > 环境变量 AGENTFLOW_DATA_DIR > ~/.dsh/agent-flow-data */
61
+ function resolveDataDir(config) {
62
+ const fromConfig = config && typeof config.dataDir === 'string' ? config.dataDir : '';
63
+ return path.resolve(fromConfig || process.env.AGENTFLOW_DATA_DIR || DEFAULT_DATA_DIR);
64
+ }
65
+
66
+ let dataDirReady = null;
67
+ let dataDirReadyKey = '';
68
+
69
+ /**
70
+ * 数据目录就绪:目录不存在或为空、且旧目录还有 board.json 时,把旧数据抄一份。
71
+ * 每个数据目录只尝试一次;失败只 warn —— 抄不到旧数据也不耽误起服务。
72
+ */
73
+ function ensureDataDir(dataDir) {
74
+ if (dataDirReady && dataDirReadyKey === dataDir) return dataDirReady;
75
+ dataDirReadyKey = dataDir;
76
+ dataDirReady = (async () => {
77
+ try {
78
+ const entries = await fs.readdir(dataDir).catch((err) => {
79
+ if (err && err.code === 'ENOENT') return null;
80
+ throw err;
81
+ });
82
+ if (entries && entries.length) return false; // 已有数据,绝不再覆盖
83
+ const sameDir = path.resolve(dataDir) === path.resolve(LEGACY_DATA_DIR);
84
+ const hasLegacy =
85
+ !sameDir && (await fs.access(path.join(LEGACY_DATA_DIR, 'board.json')).then(() => true, () => false));
86
+ if (!hasLegacy) {
87
+ await fs.mkdir(dataDir, { recursive: true });
88
+ return false;
89
+ }
90
+ await fs.mkdir(dataDir, { recursive: true });
91
+ for (const name of await fs.readdir(LEGACY_DATA_DIR)) {
92
+ // bridge 发现文件带进程级 token,抄过来只会让引擎连错地方
93
+ if (name === 'dsh-bridge.json' || name.endsWith('.tmp')) continue;
94
+ await fs.cp(path.join(LEGACY_DATA_DIR, name), path.join(dataDir, name), {
95
+ recursive: true,
96
+ force: false,
97
+ errorOnExist: false,
98
+ });
99
+ }
100
+ console.log('[agentflow] 首次启动:旧数据已从 ' + LEGACY_DATA_DIR + ' 抄到 ' + dataDir);
101
+ return true;
102
+ } catch (err) {
103
+ console.warn('[agentflow] 旧数据迁移失败(已忽略):' + (err && err.message ? err.message : err));
104
+ return false;
105
+ }
106
+ })();
107
+ return dataDirReady;
108
+ }
109
+
110
+ const DEFAULT_PORT = 8791;
111
+
112
+ /** config 里的端口可能是字符串,非法值一律回落到默认 8791。 */
113
+ function resolvePort(config) {
114
+ const n = Number(config && config.agentflowPort);
115
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_PORT;
116
+ }
117
+
118
+ /**
119
+ * 探测本机后端:800ms 内拿到 HTTP 响应就算活着。
120
+ * 超时/连接被拒/任何异常都视为「没活」,交给调用方决定是否拉起。
121
+ */
122
+ async function backendAlive(port) {
123
+ try {
124
+ const res = await fetch('http://127.0.0.1:' + port + '/', {
125
+ signal: AbortSignal.timeout(800),
126
+ });
127
+ try { await res.body?.cancel(); } catch {}
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ // 一个 DSH 进程只允许一个自建后端:热重载或重复 apply 都不能叠加子进程。
135
+ let backendChild = null;
136
+ let backendStarting = false;
137
+
138
+ function backendRunning() {
139
+ return backendChild !== null && backendChild.exitCode === null && !backendChild.killed;
140
+ }
141
+
142
+ /** 卸载时只杀自己 spawn 的那个;外部已在跑的后端不归插件管。 */
143
+ function stopBackend() {
144
+ const child = backendChild;
145
+ backendChild = null;
146
+ if (child && child.exitCode === null && !child.killed) {
147
+ try { child.kill(); } catch {}
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 按需拉起 AgentFlow 后端(fire-and-forget,绝不阻塞 apply、绝不抛错):
153
+ * 先探测,活着就什么都不做;没活才用当前 Node 起 server.mjs。
154
+ * 失败只 console.warn —— 工具与侧边栏能加载比后端能否起来更重要。
155
+ */
156
+ function startBackend(dir, port, dataDir, bridgeDir) {
157
+ if (backendStarting || backendRunning()) return;
158
+ backendStarting = true;
159
+ void (async () => {
160
+ try {
161
+ if (await backendAlive(port)) return;
162
+ if (backendRunning()) return; // 探测期间可能已被别的实例拉起
163
+ await ensureDataDir(dataDir); // 先准备好数据目录(含首次迁移),再起服务
164
+ const child = spawn(process.execPath, ['server.mjs'], {
165
+ cwd: dir,
166
+ detached: false,
167
+ stdio: 'ignore', // 后端日志不能灌进 DSH 终端
168
+ env: {
169
+ ...process.env,
170
+ PORT: String(port),
171
+ // 画布/配置/草稿写到安装目录之外,插件升级或重装不丢数据
172
+ AGENTFLOW_DATA_DIR: dataDir,
173
+ // 引擎默认在自己的 ROOT 下找 dsh-bridge.json;安装后 ROOT 是 runtime/,
174
+ // 必须显式指到插件 bridge 写文件的那个目录
175
+ AGENTFLOW_BRIDGE_DIR: bridgeDir,
176
+ },
177
+ });
178
+ backendChild = child;
179
+ child.unref(); // 不拖住 DSH 的事件循环
180
+ child.on('error', (err) => {
181
+ if (backendChild === child) backendChild = null;
182
+ console.warn('[agentflow] 后端启动失败:' + (err && err.message ? err.message : err));
183
+ });
184
+ child.on('exit', (code) => {
185
+ if (backendChild === child) backendChild = null;
186
+ if (code !== 0 && code !== null) console.warn('[agentflow] 后端退出,code=' + code);
187
+ });
188
+ } catch (err) {
189
+ console.warn('[agentflow] 后端拉起失败(已忽略):' + (err && err.message ? err.message : err));
190
+ } finally {
191
+ backendStarting = false;
192
+ }
193
+ })();
194
+ }
195
+
196
+ /** 动态加载引擎三件套 */
197
+ async function loadEngine(dir) {
198
+ const at = (f) => pathToFileURL(path.join(dir, f)).href;
199
+ const [engine, store, llm] = await Promise.all([
200
+ import(at('engine.mjs')),
201
+ import(at('store.mjs')),
202
+ import(at('llm.mjs')),
203
+ ]);
204
+ return { ...engine, ...store, ...llm };
205
+ }
206
+
207
+ /** 当前画布文件:多画布布局取索引第一张;老的单画布布局退回 board.json */
208
+ async function resolveBoardFile(dataDir) {
209
+ try {
210
+ const index = JSON.parse(await fs.readFile(path.join(dataDir, 'boards.json'), 'utf8'));
211
+ const id = Array.isArray(index?.order) ? index.order[0] : null;
212
+ if (id) {
213
+ const file = path.join(dataDir, 'boards', `${id}.json`);
214
+ const exists = await fs.access(file).then(() => true, () => false);
215
+ if (exists) return file;
216
+ }
217
+ } catch {
218
+ /* 没有索引就走旧布局 */
219
+ }
220
+ return path.join(dataDir, 'board.json');
221
+ }
222
+
223
+ async function readBoard(dataDir) {
224
+ const file = await resolveBoardFile(dataDir);
225
+ const text = await fs.readFile(file, 'utf8');
226
+ return JSON.parse(text);
227
+ }
228
+
229
+ /** 原子写回画布:临时文件 + rename,避免半个 JSON 落盘 */
230
+ async function writeBoard(dataDir, board) {
231
+ await fs.mkdir(dataDir, { recursive: true });
232
+ const file = await resolveBoardFile(dataDir);
233
+ await fs.mkdir(path.dirname(file), { recursive: true });
234
+ const tmp = `${file}.${process.pid}.tmp`;
235
+ await fs.writeFile(tmp, JSON.stringify(board, null, 2), 'utf8');
236
+ await fs.rename(tmp, file);
237
+ }
238
+
239
+ /** 读引擎配置;没有就按默认值走(normalizeConfig 自己会补) */
240
+ async function readConfig(dataDir) {
241
+ try {
242
+ return JSON.parse(await fs.readFile(path.join(dataDir, 'config.json'), 'utf8'));
243
+ } catch (err) {
244
+ if (err && err.code === 'ENOENT') return null;
245
+ throw err;
246
+ }
247
+ }
248
+
249
+ const STATUS_ZH = {
250
+ idle: '空闲',
251
+ running: '运行中',
252
+ done: '完成',
253
+ failed: '失败',
254
+ blocked: '阻塞',
255
+ review: '待确认',
256
+ };
257
+
258
+ /** 画布摘要:给人看也够,给模型看也能直接理解 */
259
+ function describeBoard(board) {
260
+ const lines = [
261
+ `画布「${board.name || board.id}」:${board.nodes.length} 个节点,${board.edges.length} 条连线`,
262
+ '',
263
+ ];
264
+ for (const n of board.nodes) {
265
+ const ups = board.edges.filter((e) => e.to === n.id).map((e) => e.from);
266
+ const usage = n.usage || {};
267
+ const bits = [
268
+ `- ${n.id}「${n.label}」${STATUS_ZH[n.status] || n.status}`,
269
+ `模型 ${n.model || '未设置'}`,
270
+ ];
271
+ if (ups.length) bits.push(`上游 ${ups.join(',')}`);
272
+ if (n.verify) bits.push(`判断 ${n.verify}`);
273
+ if (n.skills && n.skills.length) bits.push(`技能 ${n.skills.join(',')}`);
274
+ if (n.tools && n.tools.length) bits.push(`工具 ${n.tools.length} 个`);
275
+ if (usage.promptTokens) bits.push(`${usage.promptTokens + (usage.completionTokens || 0)} tok`);
276
+ lines.push(bits.join(' · '));
277
+ if (n.content) {
278
+ const first = String(n.content).split('\n').find((l) => l.trim()) || '';
279
+ lines.push(` 产出:${first.slice(0, 60)}${n.content.length > 60 ? '…' : ''}`);
280
+ }
281
+ }
282
+ return lines.join('\n');
283
+ }
284
+
285
+ export function apply(ctx, config = {}) {
286
+ const dir = resolveDir(config);
287
+ const dataDir = resolveDataDir(config);
288
+ // 开发模式(显式指定引擎目录):bridge 发现文件仍写在引擎目录里,跟引擎自带 data/ 挨着;
289
+ // 安装模式:引擎是只读快照,bridge 与数据都写到数据目录。
290
+ const bridgeDir = hasExplicitDir(config) ? dir : dataDir;
291
+
292
+ // 生命周期:插件加载时按需拉起后端,卸载时把自建子进程收掉。
293
+ // effect 回调本身同步返回;探测与拉起都在后台跑,不阻塞 apply。
294
+ ctx.effect(() => {
295
+ startBackend(dir, resolvePort(config), dataDir, bridgeDir);
296
+ return stopBackend;
297
+ }, 'agentflow: backend');
298
+
299
+ // 模型通道:DSH 进程内的 llm bridge。AgentFlow server 通过发现文件找过来,
300
+ // 再用 ctx.llm 调模型;启动失败只 warn,模型通道不可用不影响两个工具加载。
301
+ ctx.effect(() => startBridge(ctx, { dir: bridgeDir }), 'agentflow: bridge');
302
+
303
+ // ---------- agentflow_list ----------
304
+ ctx.tools.register(
305
+ defineTool({
306
+ name: 'agentflow_list',
307
+ description:
308
+ '列出本机 AgentFlow 画布上的节点:每个节点做了什么任务、什么状态、用了哪个模型、上游是谁、产出了什么。在动手跑之前先用它了解现有编排。',
309
+ parameters: {},
310
+ output: {
311
+ schema: { type: 'object', additionalProperties: true },
312
+ render: (_args, value) => [{ type: 'text', text: String(value.text || '') }],
313
+ },
314
+ async execute() {
315
+ try {
316
+ const board = await readBoard(dataDir);
317
+ return { text: describeBoard(board) };
318
+ } catch (err) {
319
+ return {
320
+ text: `读不到画布:${err && err.message ? err.message : err}\n路径:${await resolveBoardFile(dataDir).catch(() => path.join(dataDir, 'board.json'))}`,
321
+ };
322
+ }
323
+ },
324
+ presentCall: () => ({
325
+ card: 'generic',
326
+ title: 'agentflow_list · 看画布',
327
+ kind: 'other',
328
+ rawInput: {},
329
+ }),
330
+ }),
331
+ );
332
+
333
+ // ---------- agentflow_run ----------
334
+ ctx.tools.register(
335
+ defineTool({
336
+ name: 'agentflow_run',
337
+ description:
338
+ '执行 AgentFlow 画布(或其中几个节点),返回每一步的产出与状态。节点按上游依赖自动排序:多个上游都完成才开始;某节点所有模型失败只影响自己,它的下游标记阻塞。targets 省略时跑整张图。这会真实调用大模型并产生费用。',
339
+ parameters: {
340
+ targets: {
341
+ type: 'array',
342
+ items: { type: 'string' },
343
+ description: '只跑这些节点 id(它们的上游会用已有产出)。省略则跑整张图。',
344
+ },
345
+ dry: {
346
+ type: 'boolean',
347
+ description: '只演练调度、不真的调用模型(零成本),用来检查顺序对不对。',
348
+ },
349
+ save: {
350
+ type: 'boolean',
351
+ description: '是否把产出写回画布文件,默认 true。',
352
+ },
353
+ },
354
+ output: {
355
+ schema: { type: 'object', additionalProperties: true },
356
+ render: (_args, value) => [{ type: 'text', text: String(value.text || '') }],
357
+ },
358
+ timeoutMs: 600000,
359
+ async execute(args, exec) {
360
+ const targets = Array.isArray(args.targets) && args.targets.length ? args.targets : undefined;
361
+ const save = args.save !== false;
362
+ const dry = args.dry === true;
363
+
364
+ let mod;
365
+ try {
366
+ mod = await loadEngine(dir);
367
+ } catch (err) {
368
+ return {
369
+ text: `加载引擎失败:${err && err.message ? err.message : err}\n检查插件 runtime/ 是否已生成(npm run sync),或 agentflowDir 是否指向有效的 AgentFlow 目录`,
370
+ };
371
+ }
372
+
373
+ const board = await readBoard(dataDir);
374
+ // 配置也在数据目录里;不走引擎的 loadConfig(),它按引擎 ROOT 冻结了路径
375
+ const cfg = mod.normalizeConfig(await readConfig(dataDir));
376
+ const callLLM = dry
377
+ ? async () => ({ content: '(dry run)', usage: {} })
378
+ : mod.makeCaller({ bridgeDir, mock: cfg.mock, skillDirs: cfg.skillDirs });
379
+
380
+ const log = [];
381
+ const t0 = Date.now();
382
+ const stamp = () => `${((Date.now() - t0) / 1000).toFixed(1)}s`;
383
+
384
+ const result = await mod.runBoard(board, {
385
+ targets,
386
+ callLLM,
387
+ emit: (ev) => {
388
+ if (ev.type === 'node:start') log.push(`[${stamp()}] ▶ ${ev.id}`);
389
+ else if (ev.type === 'node:done') {
390
+ log.push(`[${stamp()}] ✓ ${ev.id}(${ev.model})`);
391
+ } else if (ev.type === 'node:retry') {
392
+ log.push(`[${stamp()}] ↻ ${ev.id} ${ev.model} 失败:${ev.error}`);
393
+ } else if (ev.type === 'node:failed') {
394
+ log.push(`[${stamp()}] ✗ ${ev.id} 所有模型失败`);
395
+ } else if (ev.type === 'node:blocked') {
396
+ log.push(`[${stamp()}] ⊘ ${ev.id} 阻塞(${ev.reason})`);
397
+ }
398
+ },
399
+ });
400
+
401
+ // 回写画布:产出、状态、用量
402
+ if (save && !dry) {
403
+ const next = {
404
+ ...board,
405
+ nodes: board.nodes.map((n) => {
406
+ const st = result.state.get(n.id);
407
+ if (!st) return n;
408
+ const out = result.content.get(n.id);
409
+ const model = result.usedModel.get(n.id);
410
+ return {
411
+ ...n,
412
+ status: st,
413
+ ...(out && (st === 'done' || st === 'review') ? { content: out } : {}),
414
+ ...(model ? { usage: { model, ...(result.usage.get(n.id) || {}) } } : {}),
415
+ };
416
+ }),
417
+ };
418
+ await writeBoard(dataDir, next).catch(() => {});
419
+ }
420
+
421
+ const { done, failed, blocked, review } = result.summary;
422
+ const sections = [
423
+ `模式:${dry ? 'dry run(未调用模型)' : cfg.mock ? 'mock' : '真实模型'}`,
424
+ `结果:完成 ${done.length} · 失败 ${failed.length} · 阻塞 ${blocked.length}${review?.length ? ` · 待确认 ${review.length}` : ''}`,
425
+ '',
426
+ '执行过程:',
427
+ ...log,
428
+ ];
429
+
430
+ // 每个成功节点的产出摘要,方便模型直接判断要不要接着处理
431
+ for (const id of done) {
432
+ const node = board.nodes.find((n) => n.id === id);
433
+ const text = result.content.get(id) || '';
434
+ if (!text) continue;
435
+ sections.push('', `—— ${node ? node.label : id} ——`, text.slice(0, 800) + (text.length > 800 ? '\n…' : ''));
436
+ }
437
+
438
+ return { text: sections.join('\n') };
439
+ },
440
+ presentCall: (args) => ({
441
+ card: 'generic',
442
+ title: `agentflow_run${Array.isArray(args.targets) && args.targets.length ? ` · ${args.targets.join(',')}` : ' · 全图'}`,
443
+ kind: 'other',
444
+ rawInput: { targets: args.targets, dry: args.dry },
445
+ }),
446
+ }),
447
+ );
448
+ }