dsh-plugin-agentflow 0.3.0 → 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 +42 -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/commands.js
ADDED
|
@@ -0,0 +1,1058 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令热注册:把 AgentFlow 画布变成 DSH 里的 /agentflow-xxx 命令。
|
|
3
|
+
*
|
|
4
|
+
* 引擎侧(AgentFlow web 的「生成命令」)只写一个契约文件:
|
|
5
|
+
* <dataDir>/commands/<name>.json = {version:1, name, board, createdAt}
|
|
6
|
+
* 插件这边不 import 引擎代码,直接读命令目录 + 画布文件,所以:
|
|
7
|
+
* - 引擎随时可写、可删;插件 10s 轮询 + fs.watch(200ms debounce)全量 diff,
|
|
8
|
+
* 新增注册、消失注销、描述/画布变化重注册 —— 用户不用重启 DSH。
|
|
9
|
+
* - runtime/commands.mjs 还没生成时也能跑:这里不依赖它。
|
|
10
|
+
*
|
|
11
|
+
* 命令行为(全部直接由命令 registry 执行,不经过模型调度):
|
|
12
|
+
* /agentflow-x 只看画布:画布名 + 每个节点的 id/label/task/上游 + 用法
|
|
13
|
+
* /agentflow-x 要做的事 按画布 DAG 开一个 run:根节点先干,agentflow_node_done 推动分层派发
|
|
14
|
+
* /agentflow-x off 退出该画布工作流
|
|
15
|
+
*
|
|
16
|
+
* run 的推进由四个钩子接力(和命令同一个 ctx.inject 作用域):
|
|
17
|
+
* agent/pre-step 每步追加当前层提示(先 next(),reject 原样返回)
|
|
18
|
+
* agent/turn-stopping 该派发就催 spawn_teammate;请求过但没 spawn 成功就超时催办;
|
|
19
|
+
* 根节点没完成就催 node_done;完成后不再 steer
|
|
20
|
+
* tools/pre-execute 只放行本 run 已请求/已 spawn 的 teammate 名(名字必须走 spawnNameFor)
|
|
21
|
+
* tools/post-execute 观察到成功的 spawn_teammate 才 markSpawned —— dispatched 的唯一真实来源
|
|
22
|
+
*
|
|
23
|
+
* 纪律:画布/命令文件坏了只 warn 跳过;命令 handler 与钩子绝不 throw 到宿主 ——
|
|
24
|
+
* 命令是会话入口,坏了不能连累当前对话。同一 agent 同时只允许一个 active run。
|
|
25
|
+
*
|
|
26
|
+
* @module lib/commands
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import fsp from 'node:fs/promises';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
33
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
34
|
+
import {
|
|
35
|
+
createRun,
|
|
36
|
+
isActive,
|
|
37
|
+
markDone,
|
|
38
|
+
toDispatch,
|
|
39
|
+
markRequested,
|
|
40
|
+
markSpawned,
|
|
41
|
+
awaitingSpawn,
|
|
42
|
+
spawnNameFor,
|
|
43
|
+
nodeForSpawnName,
|
|
44
|
+
regenerateSpawnName,
|
|
45
|
+
startInstruction,
|
|
46
|
+
dispatchInstruction,
|
|
47
|
+
summaryInstruction,
|
|
48
|
+
stepHint,
|
|
49
|
+
hintSignature,
|
|
50
|
+
serializeRun,
|
|
51
|
+
deserializeRun,
|
|
52
|
+
statusLine,
|
|
53
|
+
FLOW_STATES,
|
|
54
|
+
} from './flow.js';
|
|
55
|
+
|
|
56
|
+
/** 原子写 JSON:先写 .tmp 再 rename,避免重启读到半个文件。 */
|
|
57
|
+
async function writeJsonAtomic(file, value) {
|
|
58
|
+
const tmp = `${file}.tmp`;
|
|
59
|
+
await fsp.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
60
|
+
await fsp.rename(tmp, file);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 已请求派发但没看到 spawn 成功时的重试间隔:不要每步都催。 */
|
|
64
|
+
const SPAWN_RETRY_MS = 120_000;
|
|
65
|
+
|
|
66
|
+
/** DSH 命令名约束:小写字母开头,只含小写字母/数字/下划线/连字符,<=48 字符(与引擎对齐)。 */
|
|
67
|
+
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
68
|
+
const COMMAND_NAME_MAX = 48;
|
|
69
|
+
/** 轮询兜底间隔:fs.watch 在某些文件系统(网络盘/容器挂载)上会漏事件。 */
|
|
70
|
+
const DEFAULT_POLL_MS = 10_000;
|
|
71
|
+
/** fs.watch 防抖:一次保存往往连着好几个事件。 */
|
|
72
|
+
const DEFAULT_DEBOUNCE_MS = 200;
|
|
73
|
+
/** steer 进对话的文本总长上限:画布再大也不许把上下文灌爆。 */
|
|
74
|
+
const MAX_TEXT = 4000;
|
|
75
|
+
/** 命令输入提示(客户端输入框里的 placeholder)。 */
|
|
76
|
+
const INPUT_HINT = '[off|要做的事] 默认只看画布';
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// 纯函数:容错读取 + 摘要 + 截断(全部可单测,不碰 fs)
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function asString(value) {
|
|
83
|
+
return typeof value === 'string' ? value : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 命令名是否满足 DSH 的注册约束(正则 + 长度 <= 48,和引擎的 normalizeCommandName 对齐)。 */
|
|
87
|
+
export function isCommandName(name) {
|
|
88
|
+
return typeof name === 'string' && name.length <= COMMAND_NAME_MAX && COMMAND_NAME.test(name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 截断到 limit 个字符(含省略号),保证返回值长度 <= limit。
|
|
93
|
+
* 非字符串一律先压成字符串;非法 limit 回落到 MAX_TEXT。
|
|
94
|
+
*/
|
|
95
|
+
export function clipText(value, limit = MAX_TEXT) {
|
|
96
|
+
const text = typeof value === 'string' ? value : String(value ?? '');
|
|
97
|
+
const max = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : MAX_TEXT;
|
|
98
|
+
if (text.length <= max) return text;
|
|
99
|
+
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 容错解析一个命令文件。
|
|
104
|
+
* 契约之外的字段忽略;name 缺失时用文件名兜底;名字非法或没有 board 返回 null。
|
|
105
|
+
*
|
|
106
|
+
* @param {unknown} raw 命令文件 JSON
|
|
107
|
+
* @param {string} [fallbackName] 文件名(去掉 .json)
|
|
108
|
+
* @returns {{version:number,name:string,board:string,createdAt:number}|null}
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeCommandRecord(raw, fallbackName = '') {
|
|
111
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
112
|
+
const name = asString(raw.name) || asString(fallbackName);
|
|
113
|
+
const board = asString(raw.board);
|
|
114
|
+
if (!isCommandName(name) || !board) return null;
|
|
115
|
+
const version = Number.isFinite(Number(raw.version)) ? Number(raw.version) : 1;
|
|
116
|
+
const createdAt = Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : 0;
|
|
117
|
+
return { version, name, board, createdAt };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 容错归一化一张画布:节点按 id/label/task/criteria/model/upstream 读,
|
|
122
|
+
* 上游优先用节点自带的 upstream,没有就按 edges 反推(引擎真实数据就是 edges)。
|
|
123
|
+
*
|
|
124
|
+
* @param {unknown} raw 画布文件 JSON
|
|
125
|
+
* @param {string} [fallbackId] 文件对应的画布 id
|
|
126
|
+
* @returns {{id:string,name:string,nodes:Array<object>,edges:Array<object>}}
|
|
127
|
+
*/
|
|
128
|
+
export function normalizeBoard(raw, fallbackId = '') {
|
|
129
|
+
const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
130
|
+
const id = asString(source.id) || asString(fallbackId);
|
|
131
|
+
const name = asString(source.name) || id || '(未命名画布)';
|
|
132
|
+
|
|
133
|
+
const edges = (Array.isArray(source.edges) ? source.edges : [])
|
|
134
|
+
.map((edge) => {
|
|
135
|
+
if (!edge || typeof edge !== 'object') return null;
|
|
136
|
+
const from = asString(edge.from);
|
|
137
|
+
const to = asString(edge.to);
|
|
138
|
+
return from && to ? { from, to } : null;
|
|
139
|
+
})
|
|
140
|
+
.filter(Boolean);
|
|
141
|
+
|
|
142
|
+
const incoming = new Map();
|
|
143
|
+
for (const edge of edges) {
|
|
144
|
+
const list = incoming.get(edge.to) ?? [];
|
|
145
|
+
list.push(edge.from);
|
|
146
|
+
incoming.set(edge.to, list);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const nodes = (Array.isArray(source.nodes) ? source.nodes : []).map((node, index) => {
|
|
150
|
+
const value = node && typeof node === 'object' ? node : {};
|
|
151
|
+
const nodeId = asString(value.id) || `n${index + 1}`;
|
|
152
|
+
const declared = Array.isArray(value.upstream) ? value.upstream.map(asString).filter(Boolean) : [];
|
|
153
|
+
return {
|
|
154
|
+
id: nodeId,
|
|
155
|
+
label: asString(value.label) || nodeId,
|
|
156
|
+
// 引擎的权威标记:调度卡 type === 'orchestrator'(旧画布没有这个字段)
|
|
157
|
+
type: asString(value.type),
|
|
158
|
+
// 引擎里任务正文就叫 task;instruction/prompt 只是给别的导出格式留的容错
|
|
159
|
+
task: asString(value.task) || asString(value.instruction) || asString(value.prompt),
|
|
160
|
+
// criteria 只认 criteria:verify 是 self/jev 模式的判定值,不是验收标准
|
|
161
|
+
criteria: asString(value.criteria),
|
|
162
|
+
model: asString(value.model),
|
|
163
|
+
provider: asString(value.provider),
|
|
164
|
+
reasoningEffort: asString(value.reasoningEffort),
|
|
165
|
+
skills: Array.isArray(value.skills) ? value.skills.map(asString).filter(Boolean) : [],
|
|
166
|
+
tools: Array.isArray(value.tools) ? value.tools.map(asString).filter(Boolean) : [],
|
|
167
|
+
// MCP 节点:确定性执行单个工具,没有 task 正文
|
|
168
|
+
tool: asString(value.tool),
|
|
169
|
+
args: value.args && typeof value.args === 'object' ? value.args : undefined,
|
|
170
|
+
upstream: declared.length ? declared : (incoming.get(nodeId) ?? []),
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return { id, name, nodes, edges };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 单行节点摘要:id/label/任务/上游/模型。 */
|
|
178
|
+
export function describeNode(node) {
|
|
179
|
+
const task = String(node?.task ?? '').replace(/\s+/g, ' ').trim();
|
|
180
|
+
const upstream = Array.isArray(node?.upstream) && node.upstream.length ? node.upstream.join(',') : '无';
|
|
181
|
+
const bits = [`- ${node?.id ?? '?'}「${node?.label ?? '?'}」`];
|
|
182
|
+
if (task) bits.push(`任务:${clipText(task, 80)}`);
|
|
183
|
+
bits.push(`上游:${upstream}`);
|
|
184
|
+
if (node?.model) bits.push(`模型:${node.model}`);
|
|
185
|
+
return bits.join(' · ');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 画布摘要:第一行画布名与节点数,之后每个节点一行。 */
|
|
189
|
+
export function buildBoardSummary(board) {
|
|
190
|
+
const safe = normalizeBoard(board);
|
|
191
|
+
const lines = [`画布「${safe.name}」· ${safe.nodes.length} 个节点`];
|
|
192
|
+
if (!safe.nodes.length) lines.push('(画布还没有节点)');
|
|
193
|
+
for (const node of safe.nodes) lines.push(describeNode(node));
|
|
194
|
+
return lines.join('\n');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 无输入时返回给 UI 的文本:画布 + 节点列表 + 用法。
|
|
199
|
+
* 只读本地文件,不 steer、不烧 token。
|
|
200
|
+
*
|
|
201
|
+
* @param {string} commandName 命令名(不带 /)
|
|
202
|
+
* @param {object} board 画布
|
|
203
|
+
* @param {number} [limit] 文本上限
|
|
204
|
+
*/
|
|
205
|
+
export function buildCanvasText(commandName, board, limit = MAX_TEXT) {
|
|
206
|
+
const usage = [
|
|
207
|
+
'用法:',
|
|
208
|
+
` /${commandName} <要做的事> 进入工作流,把画布与你的要求一起交给模型`,
|
|
209
|
+
` /${commandName} 只看画布(不唤醒模型)`,
|
|
210
|
+
` /${commandName} off 退出「${normalizeBoard(board).name}」工作流`,
|
|
211
|
+
];
|
|
212
|
+
return clipText([buildBoardSummary(board), '', ...usage].join('\n'), limit);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// 热注册
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
function warn(message) {
|
|
220
|
+
console.warn('[agentflow] 命令:' + message);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function errorText(err) {
|
|
224
|
+
return err && err.message ? err.message : String(err);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** agent 身份:本体 id 就是 session id;缺失时归到同一桶,别让 Map 炸。 */
|
|
228
|
+
function agentKey(agent) {
|
|
229
|
+
return agent && agent.id !== undefined && agent.id !== null ? String(agent.id) : 'unknown';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function inert() {
|
|
233
|
+
return {
|
|
234
|
+
dispose() {},
|
|
235
|
+
reload: async () => {},
|
|
236
|
+
ready: Promise.resolve(),
|
|
237
|
+
registered: new Map(),
|
|
238
|
+
modes: new Map(),
|
|
239
|
+
getRun: () => null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 一次「命令目录 → 已注册命令」的会话。
|
|
245
|
+
* 服务可用后 attach;dispose 时停 watcher/定时器并注销全部命令。
|
|
246
|
+
*/
|
|
247
|
+
class CommandSession {
|
|
248
|
+
constructor(options = {}) {
|
|
249
|
+
this.dataDir = options.dataDir;
|
|
250
|
+
this.engineDir = options.dir || '';
|
|
251
|
+
this.commandsDir = path.join(options.dataDir, 'commands');
|
|
252
|
+
/** 进行中的 run 落盘目录:DSH 重启后还能续跑 */
|
|
253
|
+
this.runsDir = path.join(options.dataDir, 'runs');
|
|
254
|
+
this.pollMs = Number.isFinite(options.pollMs) && options.pollMs > 0 ? options.pollMs : DEFAULT_POLL_MS;
|
|
255
|
+
this.debounceMs =
|
|
256
|
+
Number.isFinite(options.debounceMs) && options.debounceMs >= 0 ? options.debounceMs : DEFAULT_DEBOUNCE_MS;
|
|
257
|
+
|
|
258
|
+
this.commands = null;
|
|
259
|
+
/** name -> {dispose, signature, description, boardId} */
|
|
260
|
+
this.registered = new Map();
|
|
261
|
+
/** agentId -> run(或历史遗留的 boardId 字符串,只当"记过画布")*/
|
|
262
|
+
this.modes = new Map();
|
|
263
|
+
/** agentId -> 已发起过的 run 次数:Agent Teams 的 roster 名字不复用,第 N 次要带 -rN 后缀 */
|
|
264
|
+
this.runCounts = new Map();
|
|
265
|
+
/** 可选的 Agent Teams 服务:/off 时顺手中断已派出去的 teammate */
|
|
266
|
+
this.agentTeams = null;
|
|
267
|
+
/** 四个 agent/tools 钩子的 disposer(和 commands 同一 inject 作用域)*/
|
|
268
|
+
this.hookDisposers = [];
|
|
269
|
+
|
|
270
|
+
this.started = false;
|
|
271
|
+
this.disposed = false;
|
|
272
|
+
this.ready = null;
|
|
273
|
+
this.chain = Promise.resolve();
|
|
274
|
+
this.watcher = null;
|
|
275
|
+
this.pollTimer = null;
|
|
276
|
+
this.debounceTimer = null;
|
|
277
|
+
/** 同一类坏文件只 warn 一次,避免 10s 轮询刷屏 */
|
|
278
|
+
this.warned = new Set();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 中止 run 时把已经派出去的 teammate 也打断,避免它们继续烧 token。
|
|
283
|
+
* 服务不可用/目标已不在时只 warn,不影响退出。
|
|
284
|
+
* @returns {number} 成功中断的数量
|
|
285
|
+
*/
|
|
286
|
+
interruptSpawned(agent, run) {
|
|
287
|
+
const teams = this.agentTeams;
|
|
288
|
+
if (!teams || typeof teams.interrupt !== 'function' || !run) return 0;
|
|
289
|
+
let stopped = 0;
|
|
290
|
+
for (const nodeId of run.spawned) {
|
|
291
|
+
const name = spawnNameFor(run, nodeId);
|
|
292
|
+
try {
|
|
293
|
+
teams.interrupt(agent, name);
|
|
294
|
+
stopped += 1;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
warn(`中断 teammate ${name} 失败(已忽略):` + errorText(err));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return stopped;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** 落盘 / 清理进行中的 run(fire-and-forget,失败只 warn)。 */
|
|
303
|
+
persistRun(agentKey, run) {
|
|
304
|
+
const key = String(agentKey || '');
|
|
305
|
+
if (!key) return;
|
|
306
|
+
const file = path.join(this.runsDir, `${key.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)}.json`);
|
|
307
|
+
const data = run && !run.finishedAt && !run.stuck ? serializeRun(run) : null;
|
|
308
|
+
const task = data
|
|
309
|
+
? fsp
|
|
310
|
+
.mkdir(this.runsDir, { recursive: true })
|
|
311
|
+
.then(() => writeJsonAtomic(file, data))
|
|
312
|
+
.catch((err) => warn('保存工作流状态失败(已忽略):' + errorText(err)))
|
|
313
|
+
: fsp.rm(file, { force: true }).catch(() => {});
|
|
314
|
+
void task;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 启动时恢复上次没跑完的 run。
|
|
319
|
+
*
|
|
320
|
+
* 恢复的 run 会换新 token 并清空 spawned/requested:重启后旧 teammate 已经没了,
|
|
321
|
+
* roster 却还记得旧名字,不清就会既派不出去也等不到回报。
|
|
322
|
+
*/
|
|
323
|
+
async loadPersistedRuns() {
|
|
324
|
+
let files;
|
|
325
|
+
try {
|
|
326
|
+
files = await fsp.readdir(this.runsDir);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if (err && err.code === 'ENOENT') return;
|
|
329
|
+
warn('读工作流状态目录失败(已忽略):' + errorText(err));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
let restored = 0;
|
|
333
|
+
for (const file of files) {
|
|
334
|
+
if (!file.endsWith('.json')) continue;
|
|
335
|
+
const full = path.join(this.runsDir, file);
|
|
336
|
+
try {
|
|
337
|
+
const raw = JSON.parse(await fsp.readFile(full, 'utf8'));
|
|
338
|
+
const run = deserializeRun(raw);
|
|
339
|
+
const agentKey = file.slice(0, -'.json'.length);
|
|
340
|
+
if (!run || run.finishedAt || run.stuck) {
|
|
341
|
+
await fsp.rm(full, { force: true });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
// 已经在本进程里活跃的 run(例如刚建的)不覆盖
|
|
345
|
+
if (this.activeRun({ id: agentKey })) continue;
|
|
346
|
+
run.ownerId = run.ownerId || agentKey;
|
|
347
|
+
this.modes.set(agentKey, run);
|
|
348
|
+
restored += 1;
|
|
349
|
+
} catch (err) {
|
|
350
|
+
warn(`工作流状态损坏,已跳过 ${file}:` + errorText(err));
|
|
351
|
+
await fsp.rm(full, { force: true }).catch(() => {});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (restored > 0) {
|
|
355
|
+
warn(`已恢复 ${restored} 个未完成的工作流(重启后旧 teammate 已不在,会重新派发)`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 绑定 commands 服务并开始扫描;服务被卸载时 detach。 */
|
|
360
|
+
attach(child) {
|
|
361
|
+
if (this.disposed || this.started) return;
|
|
362
|
+
const runtime = child && child.commands;
|
|
363
|
+
if (!runtime || typeof runtime.register !== 'function') {
|
|
364
|
+
warn('宿主没有可用的 commands 服务,命令不启用');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.commands = runtime;
|
|
368
|
+
this.started = true;
|
|
369
|
+
|
|
370
|
+
// commands 服务换了/卸载了:注销并停掉本地 watcher,等下一次 attach
|
|
371
|
+
if (typeof child.effect === 'function') {
|
|
372
|
+
try {
|
|
373
|
+
child.effect(() => () => this.detach(), 'agentflow: commands-runtime');
|
|
374
|
+
} catch (err) {
|
|
375
|
+
warn('监听 commands 服务生命周期失败(已忽略):' + errorText(err));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Agent Teams 是可选的:装上才能 /off 时中断 teammate,没装就只是不中断
|
|
380
|
+
try {
|
|
381
|
+
if (typeof child.inject === 'function') {
|
|
382
|
+
child.inject(['agentTeams'], (teamCtx) => {
|
|
383
|
+
this.agentTeams = teamCtx && teamCtx.agentTeams ? teamCtx.agentTeams : null;
|
|
384
|
+
if (teamCtx && typeof teamCtx.effect === 'function') {
|
|
385
|
+
teamCtx.effect(() => () => {
|
|
386
|
+
this.agentTeams = null;
|
|
387
|
+
}, 'agentflow: agentTeams');
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
} catch (err) {
|
|
392
|
+
warn('挂载 Agent Teams 失败(已忽略,/off 不会中断 teammate):' + errorText(err));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.ready = this.reload();
|
|
396
|
+
this.installHooks(child);
|
|
397
|
+
void this.loadPersistedRuns();
|
|
398
|
+
this.startWatch();
|
|
399
|
+
this.startPoll();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 停掉 watcher/定时器、摘掉钩子并注销全部命令;保留 modes(服务重载回来还能记住用户在哪张画布)。 */
|
|
403
|
+
detach() {
|
|
404
|
+
this.stopWatch();
|
|
405
|
+
this.stopPoll();
|
|
406
|
+
for (const dispose of this.hookDisposers.splice(0)) {
|
|
407
|
+
try {
|
|
408
|
+
dispose?.();
|
|
409
|
+
} catch (err) {
|
|
410
|
+
warn('摘除工作流钩子失败(已忽略):' + errorText(err));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
for (const name of [...this.registered.keys()]) this.unregister(name);
|
|
414
|
+
this.commands = null;
|
|
415
|
+
this.started = false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
dispose() {
|
|
419
|
+
if (this.disposed) return;
|
|
420
|
+
this.disposed = true;
|
|
421
|
+
this.detach();
|
|
422
|
+
this.modes.clear();
|
|
423
|
+
this.runCounts.clear();
|
|
424
|
+
this.warned.clear();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
startWatch() {
|
|
428
|
+
// 命令目录可能还不存在(引擎侧从没生成过命令):自己建一个空目录,
|
|
429
|
+
// 这样 fs.watch 有稳定的监听目标,引擎之后写文件就能立刻看到。
|
|
430
|
+
try {
|
|
431
|
+
fs.mkdirSync(this.commandsDir, { recursive: true });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
warn('创建命令目录失败(不致命):' + errorText(err));
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
this.watcher = fs.watch(this.commandsDir, { persistent: false }, () => this.scheduleReload());
|
|
437
|
+
if (typeof this.watcher.on === 'function') {
|
|
438
|
+
this.watcher.on('error', (err) => warn('命令目录监听中断,靠轮询兜底:' + errorText(err)));
|
|
439
|
+
}
|
|
440
|
+
} catch (err) {
|
|
441
|
+
warn('命令目录监听失败,靠 10s 轮询兜底:' + errorText(err));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
stopWatch() {
|
|
446
|
+
if (this.debounceTimer) {
|
|
447
|
+
clearTimeout(this.debounceTimer);
|
|
448
|
+
this.debounceTimer = null;
|
|
449
|
+
}
|
|
450
|
+
if (this.watcher) {
|
|
451
|
+
try {
|
|
452
|
+
this.watcher.close();
|
|
453
|
+
} catch {
|
|
454
|
+
/* 关不掉也没有补救动作 */
|
|
455
|
+
}
|
|
456
|
+
this.watcher = null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
startPoll() {
|
|
461
|
+
this.pollTimer = setInterval(() => void this.reload(), this.pollMs);
|
|
462
|
+
// 不拖住进程退出(测试/一次性宿主尤其需要)
|
|
463
|
+
if (typeof this.pollTimer.unref === 'function') this.pollTimer.unref();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
stopPoll() {
|
|
467
|
+
if (this.pollTimer) {
|
|
468
|
+
clearInterval(this.pollTimer);
|
|
469
|
+
this.pollTimer = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 200ms 防抖:一次保存触发的多个 fs 事件合并成一次全量 diff。 */
|
|
474
|
+
scheduleReload() {
|
|
475
|
+
if (this.disposed) return;
|
|
476
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
477
|
+
this.debounceTimer = setTimeout(() => {
|
|
478
|
+
this.debounceTimer = null;
|
|
479
|
+
void this.reload();
|
|
480
|
+
}, this.debounceMs);
|
|
481
|
+
if (typeof this.debounceTimer.unref === 'function') this.debounceTimer.unref();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** 串行化 reload:watch 事件、轮询、手动调用互不重入。 */
|
|
485
|
+
reload() {
|
|
486
|
+
if (this.disposed || !this.commands) return Promise.resolve();
|
|
487
|
+
this.chain = this.chain.catch(() => {}).then(() => this.reloadOnce());
|
|
488
|
+
return this.chain;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async reloadOnce() {
|
|
492
|
+
if (this.disposed || !this.commands) return;
|
|
493
|
+
let desired;
|
|
494
|
+
try {
|
|
495
|
+
desired = await this.scan();
|
|
496
|
+
} catch (err) {
|
|
497
|
+
warn('扫描命令目录失败(保持现状):' + errorText(err));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// null = 画布索引暂时读不出来:不做任何注销,等下一次扫描
|
|
501
|
+
if (desired === null) return;
|
|
502
|
+
if (this.disposed || !this.commands) return;
|
|
503
|
+
|
|
504
|
+
// 先注销「消失」和「变了」的,再注册新的:重复注册前必须先 dispose 旧的
|
|
505
|
+
for (const [name, previous] of [...this.registered]) {
|
|
506
|
+
const next = desired.get(name);
|
|
507
|
+
if (next && next.signature === previous.signature) continue;
|
|
508
|
+
this.unregister(name);
|
|
509
|
+
}
|
|
510
|
+
for (const [name, entry] of desired) {
|
|
511
|
+
if (this.registered.has(name) || this.disposed || !this.commands) continue;
|
|
512
|
+
try {
|
|
513
|
+
const dispose = this.commands.register({
|
|
514
|
+
name,
|
|
515
|
+
description: entry.description,
|
|
516
|
+
input: { hint: INPUT_HINT },
|
|
517
|
+
handler: this.makeHandler(entry),
|
|
518
|
+
});
|
|
519
|
+
this.registered.set(name, {
|
|
520
|
+
dispose,
|
|
521
|
+
signature: entry.signature,
|
|
522
|
+
description: entry.description,
|
|
523
|
+
boardId: entry.boardId,
|
|
524
|
+
});
|
|
525
|
+
} catch (err) {
|
|
526
|
+
warn(`注册 /${name} 失败(已跳过):${errorText(err)}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
unregister(name) {
|
|
532
|
+
const previous = this.registered.get(name);
|
|
533
|
+
this.registered.delete(name);
|
|
534
|
+
if (!previous) return;
|
|
535
|
+
try {
|
|
536
|
+
previous.dispose?.();
|
|
537
|
+
} catch (err) {
|
|
538
|
+
warn(`注销 /${name} 失败(已忽略):${errorText(err)}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 全量扫描:命令文件 + boards.json + 画布文件 → desired Map。
|
|
544
|
+
* @returns {Promise<Map<string, object>|null>} null 表示索引暂时不可读,本轮不动
|
|
545
|
+
*/
|
|
546
|
+
async scan() {
|
|
547
|
+
const index = await this.readBoardsIndex();
|
|
548
|
+
if (index === null) return null;
|
|
549
|
+
|
|
550
|
+
let files;
|
|
551
|
+
try {
|
|
552
|
+
files = await fsp.readdir(this.commandsDir);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (err && err.code === 'ENOENT') return new Map();
|
|
555
|
+
warn('读命令目录失败(保持现状):' + errorText(err));
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const desired = new Map();
|
|
560
|
+
for (const file of files.sort()) {
|
|
561
|
+
if (!file.endsWith('.json') || file.startsWith('.')) continue;
|
|
562
|
+
const full = path.join(this.commandsDir, file);
|
|
563
|
+
const fallback = file.slice(0, -'.json'.length);
|
|
564
|
+
|
|
565
|
+
let raw;
|
|
566
|
+
try {
|
|
567
|
+
raw = JSON.parse(await fsp.readFile(full, 'utf8'));
|
|
568
|
+
} catch (err) {
|
|
569
|
+
this.warnOnce(`parse:${file}`, `命令文件损坏,已跳过 ${file}:${errorText(err)}`);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const record = normalizeCommandRecord(raw, fallback);
|
|
573
|
+
if (!record) {
|
|
574
|
+
this.warnOnce(`record:${file}`, `命令文件格式不对,已跳过 ${file}`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const meta = index.get(record.board);
|
|
579
|
+
// boards.json 里没有这张画布:命令失效,静默跳过(画布被删是正常操作)
|
|
580
|
+
if (!meta) continue;
|
|
581
|
+
|
|
582
|
+
const boardFile = path.join(this.dataDir, 'boards', `${record.board}.json`);
|
|
583
|
+
let boardRaw;
|
|
584
|
+
try {
|
|
585
|
+
boardRaw = JSON.parse(await fsp.readFile(boardFile, 'utf8'));
|
|
586
|
+
} catch (err) {
|
|
587
|
+
this.warnOnce(
|
|
588
|
+
`board:${record.board}`,
|
|
589
|
+
`画布 ${record.board} 读不到,命令 ${record.name} 已跳过:${errorText(err)}`,
|
|
590
|
+
);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const board = normalizeBoard(boardRaw, record.board);
|
|
595
|
+
const boardName = board.name || meta.name || record.board;
|
|
596
|
+
board.name = boardName;
|
|
597
|
+
desired.set(record.name, {
|
|
598
|
+
name: record.name,
|
|
599
|
+
boardId: record.board,
|
|
600
|
+
boardName,
|
|
601
|
+
board,
|
|
602
|
+
description: `进入「${boardName}」工作流`,
|
|
603
|
+
signature: `${record.board}|${boardName}|${await signatureOf(full, boardFile)}`,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
return desired;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* 读 boards.json → Map<id, {id,name}>。
|
|
611
|
+
* ENOENT = 还没有画布(空 Map);解析失败 = null(本轮保持现状,别把命令全注销)。
|
|
612
|
+
*/
|
|
613
|
+
async readBoardsIndex() {
|
|
614
|
+
const file = path.join(this.dataDir, 'boards.json');
|
|
615
|
+
let raw;
|
|
616
|
+
try {
|
|
617
|
+
raw = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
618
|
+
} catch (err) {
|
|
619
|
+
if (err && err.code === 'ENOENT') return new Map();
|
|
620
|
+
this.warnOnce('boards-json', '画布索引读不到,命令先保持现状:' + errorText(err));
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
const out = new Map();
|
|
624
|
+
const boards = raw && typeof raw === 'object' ? raw.boards : null;
|
|
625
|
+
if (Array.isArray(boards)) {
|
|
626
|
+
for (const item of boards) {
|
|
627
|
+
if (item && item.id !== undefined) {
|
|
628
|
+
out.set(String(item.id), { id: String(item.id), name: asString(item.name) });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
} else if (boards && typeof boards === 'object') {
|
|
632
|
+
for (const id of Object.keys(boards)) {
|
|
633
|
+
const item = boards[id] && typeof boards[id] === 'object' ? boards[id] : {};
|
|
634
|
+
out.set(String(id), { id: String(id), name: asString(item.name) });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
warnOnce(key, message) {
|
|
641
|
+
if (this.warned.has(key)) return;
|
|
642
|
+
this.warned.add(key);
|
|
643
|
+
warn(message);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** 当前 agent 的进行中 run;历史遗留的字符串值一律当没有(只记过画布)。 */
|
|
647
|
+
activeRun(agent) {
|
|
648
|
+
const value = this.modes.get(agentKey(agent));
|
|
649
|
+
return value && typeof value === 'object' && isActive(value) ? value : null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** 工具用:拿这个 agent 记着的 run(可能已结束/卡住),没有或旧格式返回 null。 */
|
|
653
|
+
getRun(agentId) {
|
|
654
|
+
const value = this.modes.get(String(agentId ?? ''));
|
|
655
|
+
return value && typeof value === 'object' ? value : null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 唯一注入动作:异常只 warn,绝不让 hook/命令抛到宿主。 */
|
|
659
|
+
steer(agent, text) {
|
|
660
|
+
if (!text) return false;
|
|
661
|
+
if (!agent || typeof agent.steer !== 'function') return false;
|
|
662
|
+
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }));
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** 命令 handler:off 退出 / 空输入看画布 / 其它按画布 DAG 开一个 run。 */
|
|
667
|
+
makeHandler(entry) {
|
|
668
|
+
return (invocation) => {
|
|
669
|
+
try {
|
|
670
|
+
const rawInput = typeof invocation?.rawInput === 'string' ? invocation.rawInput : '';
|
|
671
|
+
const text = rawInput.trim();
|
|
672
|
+
const agent = invocation?.agent;
|
|
673
|
+
const key = agentKey(agent);
|
|
674
|
+
|
|
675
|
+
if (/^off$/i.test(text)) {
|
|
676
|
+
const stopping = this.activeRun(agent);
|
|
677
|
+
this.modes.delete(key);
|
|
678
|
+
this.persistRun(key, null);
|
|
679
|
+
const stopped = stopping ? this.interruptSpawned(agent, stopping) : 0;
|
|
680
|
+
return {
|
|
681
|
+
kind: 'success',
|
|
682
|
+
text: `已退出「${entry.boardName}」工作流${stopped > 0 ? `,已中断 ${stopped} 个 teammate` : ''}`,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (!text) {
|
|
687
|
+
return { kind: 'success', text: buildCanvasText(entry.name, entry.board) };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 同一个 agent 同时只允许一个 run:静默覆盖会让上一轮的 teammate 和状态全部失联
|
|
691
|
+
if (this.activeRun(agent)) {
|
|
692
|
+
return {
|
|
693
|
+
kind: 'error',
|
|
694
|
+
text: `已有进行中的画布工作流,先 /${entry.name} off 或等它结束`,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
if (!agent || typeof agent.steer !== 'function') {
|
|
698
|
+
return { kind: 'error', text: '当前宿主不支持注入消息,画布工作流无法开始' };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Agent Teams 的 roster 名字不复用(跨 DSH 重启亦然),所以 teammate 名由
|
|
702
|
+
// createRun 生成的随机后缀保证唯一;runIndex 只作诊断,不参与命名。
|
|
703
|
+
const runIndex = (this.runCounts.get(key) ?? 0) + 1;
|
|
704
|
+
const run = createRun({
|
|
705
|
+
board: entry.board,
|
|
706
|
+
boardId: entry.boardId,
|
|
707
|
+
boardName: entry.boardName,
|
|
708
|
+
seed: text,
|
|
709
|
+
ownerId: key,
|
|
710
|
+
runIndex,
|
|
711
|
+
});
|
|
712
|
+
if (!run.rootId) {
|
|
713
|
+
return {
|
|
714
|
+
kind: 'error',
|
|
715
|
+
text: `画布「${entry.boardName}」没有入口节点(没有调度卡,也没有唯一的无上游节点),这次工作流没法开始`,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
if (!isActive(run)) {
|
|
719
|
+
return {
|
|
720
|
+
kind: 'error',
|
|
721
|
+
text: `画布「${entry.boardName}」没有可执行的节点(${run.stuck || '所有节点任务为空'}),这次工作流没法开始`,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
this.runCounts.set(key, runIndex);
|
|
725
|
+
this.modes.set(key, run);
|
|
726
|
+
this.persistRun(key, run);
|
|
727
|
+
this.steer(agent, startInstruction(run));
|
|
728
|
+
return { kind: 'success', text: `已进入画布「${entry.boardName}」,根节点开始` };
|
|
729
|
+
} catch (err) {
|
|
730
|
+
warn(`/${entry.name} 执行失败:${errorText(err)}`);
|
|
731
|
+
return { kind: 'error', text: `命令执行失败:${errorText(err)}` };
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---- 工作流钩子:pre-step 提醒 / turn-stopping 推进 / pre-execute 收口派发 / post-execute 记账 ----
|
|
737
|
+
|
|
738
|
+
/** 在 commands 的同一个 inject child 上挂四个钩子;服务卸载时随 detach 摘掉。 */
|
|
739
|
+
installHooks(child) {
|
|
740
|
+
if (!child || typeof child.on !== 'function') return;
|
|
741
|
+
const register = (event, listener) => {
|
|
742
|
+
try {
|
|
743
|
+
const dispose = child.on(event, listener);
|
|
744
|
+
if (typeof dispose === 'function') this.hookDisposers.push(dispose);
|
|
745
|
+
} catch (err) {
|
|
746
|
+
warn(`挂 ${event} 钩子失败(已忽略):${errorText(err)}`);
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
register('agent/pre-step', async (payload, next) => {
|
|
751
|
+
// waterfall 纪律:先让下游决定,绝不改结果类型;只有 enter 分支才追加提示
|
|
752
|
+
const decision = await next();
|
|
753
|
+
if (!decision || decision.kind === 'reject') return decision;
|
|
754
|
+
try {
|
|
755
|
+
const run = this.activeRun(payload?.agent);
|
|
756
|
+
if (!run) return decision;
|
|
757
|
+
// 状态没变就不再注入:同样的提示重复塞进历史只是白烧 token
|
|
758
|
+
const signature = hintSignature(run);
|
|
759
|
+
if (!signature || run.lastHintSignature === signature) return decision;
|
|
760
|
+
run.lastHintSignature = signature;
|
|
761
|
+
const hint = stepHint(run);
|
|
762
|
+
if (!hint) return decision;
|
|
763
|
+
const note = createUserMessage({ content: [{ type: 'text', text: hint }], source: { kind: 'user' } });
|
|
764
|
+
return {
|
|
765
|
+
...decision,
|
|
766
|
+
messages: [...(Array.isArray(decision.messages) ? decision.messages : []), note],
|
|
767
|
+
};
|
|
768
|
+
} catch (err) {
|
|
769
|
+
warn('pre-step 注入画布提示失败(已忽略):' + errorText(err));
|
|
770
|
+
return decision;
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
register('agent/turn-stopping', (payload) => {
|
|
775
|
+
try {
|
|
776
|
+
const agent = payload?.agent;
|
|
777
|
+
const run = this.activeRun(agent);
|
|
778
|
+
if (!run) return;
|
|
779
|
+
|
|
780
|
+
// a. 有该派发的:先按未标记状态生成指令,steer 成功后才记为「已请求」
|
|
781
|
+
const layer = toDispatch(run);
|
|
782
|
+
if (layer.length > 0) {
|
|
783
|
+
const instruction = dispatchInstruction(run);
|
|
784
|
+
if (this.steer(agent, instruction)) {
|
|
785
|
+
markRequested(run, layer.map((node) => node.id));
|
|
786
|
+
this.persistRun(agentKey(agent), run);
|
|
787
|
+
}
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// b. 请求过派发、但还没看到成功的 spawn:超时后催一次(不要每步刷)
|
|
792
|
+
const awaiting = awaitingSpawn(run);
|
|
793
|
+
if (awaiting.length > 0) {
|
|
794
|
+
// 同一个 turn 先立刻催一次(不然模型直接结束时,没有下一个 turn,
|
|
795
|
+
// 120s 超时永远等不到);之后才走超时重试。
|
|
796
|
+
const stale = Date.now() - run.lastAdvanceAt > SPAWN_RETRY_MS;
|
|
797
|
+
const fresh = run.spawnNudgeTurn !== payload?.turn;
|
|
798
|
+
if (!stale && !fresh) return;
|
|
799
|
+
const names = awaiting.map((nodeId) => spawnNameFor(run, nodeId));
|
|
800
|
+
const reminder =
|
|
801
|
+
`【AgentFlow】这些节点还没建 teammate:${names.join('、')}(名字由节点 id 生成)。` +
|
|
802
|
+
'请先 spawn_teammate 把它们建出来,再等回报;不要自己代做这些节点。';
|
|
803
|
+
if (this.steer(agent, reminder)) {
|
|
804
|
+
run.lastAdvanceAt = Date.now();
|
|
805
|
+
run.spawnNudgeTurn = payload?.turn;
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// c. 根节点还没声明完成:同一个 turn 最多催一次,避免每步刷屏
|
|
811
|
+
if (run.status.get(run.rootId) === FLOW_STATES.STATE_PENDING) {
|
|
812
|
+
if (run.rootNudgeTurn === payload?.turn) return;
|
|
813
|
+
const nudged = this.steer(
|
|
814
|
+
agent,
|
|
815
|
+
`【AgentFlow】根节点 ${run.rootId} 还没完成:做完请调 agentflow_node_done(node_id="${run.rootId}", output="实际产出"),不调不算完成。`,
|
|
816
|
+
);
|
|
817
|
+
if (nudged) run.rootNudgeTurn = payload?.turn;
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// d. 已全部完成 / 正在等 teammate 回报:不 steer,让它自然结束
|
|
822
|
+
} catch (err) {
|
|
823
|
+
warn('turn-stopping 推进画布失败(已忽略):' + errorText(err));
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
register('tools/pre-execute', (exec, next) => {
|
|
828
|
+
// 只拦 spawn_teammate;其它工具/没有 run 的 agent 一律放行
|
|
829
|
+
let veto = null;
|
|
830
|
+
try {
|
|
831
|
+
if (exec && exec.name === 'spawn_teammate') {
|
|
832
|
+
const run = this.activeRun(exec.agent);
|
|
833
|
+
if (run) {
|
|
834
|
+
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
|
|
835
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
836
|
+
// 允许的名字只能来自 spawnNameFor:派发指令、pre-execute、超时提醒共用同一套名字
|
|
837
|
+
const ids = [...new Set([...run.requested, ...run.spawned])];
|
|
838
|
+
const names = ids.map((nodeId) => spawnNameFor(run, nodeId));
|
|
839
|
+
if (!name || !names.includes(name)) {
|
|
840
|
+
veto = {
|
|
841
|
+
kind: 'deny',
|
|
842
|
+
reason: `当前层只允许派发这些名字:${names.join('、') || '无'}(名字由节点 id 生成,可用节点 id 反查;不要直接用节点 id 当 teammate 名)`,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
} catch (err) {
|
|
848
|
+
warn('pre-execute 校验派发失败(已忽略,放行):' + errorText(err));
|
|
849
|
+
}
|
|
850
|
+
return veto ?? next();
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
register('tools/post-execute', async (exec, result, next) => {
|
|
854
|
+
// 第二个参数才是工具真实结果;next() 返回的是决策对象(只有 kind)
|
|
855
|
+
try {
|
|
856
|
+
if (exec && exec.name === 'spawn_teammate') {
|
|
857
|
+
const run = this.activeRun(exec.agent);
|
|
858
|
+
if (run) {
|
|
859
|
+
const args = exec.arguments && typeof exec.arguments === 'object' ? exec.arguments : {};
|
|
860
|
+
const name = typeof args.name === 'string' ? args.name.trim() : '';
|
|
861
|
+
const nodeId = name ? nodeForSpawnName(run, name) : '';
|
|
862
|
+
if (nodeId && result && !result.isError) {
|
|
863
|
+
// 命不中本 run 的节点名就忽略:这是 dispatched 的唯一真实来源
|
|
864
|
+
markSpawned(run, nodeId);
|
|
865
|
+
this.persistRun(agentKey(exec.agent), run);
|
|
866
|
+
} else if (nodeId && result && result.isError && isNameTaken(result)) {
|
|
867
|
+
// 撞名(概率极低但存在):换一个新名字,让 turn-stopping 重派一次
|
|
868
|
+
const fresh = regenerateSpawnName(run, nodeId);
|
|
869
|
+
this.persistRun(agentKey(exec.agent), run);
|
|
870
|
+
if (fresh) {
|
|
871
|
+
warn(`teammate 名被占用,已为节点 ${nodeId} 换名:${fresh}`);
|
|
872
|
+
this.steer(
|
|
873
|
+
exec.agent,
|
|
874
|
+
`【AgentFlow】节点 ${nodeId} 的 teammate 名被占用了,已换成 "${fresh}"。请用这个新名字重新 spawn_teammate。`,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
} catch (err) {
|
|
881
|
+
warn('post-execute 记录 spawn 失败(已忽略):' + errorText(err));
|
|
882
|
+
}
|
|
883
|
+
return next();
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* roster 撞名的判定:Agent Teams 用 TEAM_MEMBER_NAME_TAKEN,但错误可能被包成
|
|
890
|
+
* 普通文本,所以同时认错误码与原文关键词。
|
|
891
|
+
*/
|
|
892
|
+
function isNameTaken(result) {
|
|
893
|
+
const texts = [];
|
|
894
|
+
if (result && Array.isArray(result.content)) {
|
|
895
|
+
for (const block of result.content) if (block && typeof block.text === 'string') texts.push(block.text);
|
|
896
|
+
}
|
|
897
|
+
if (result && result.error && typeof result.error.message === 'string') texts.push(result.error.message);
|
|
898
|
+
if (result && typeof result.error === 'string') texts.push(result.error);
|
|
899
|
+
const joined = texts.join('\n');
|
|
900
|
+
return /TEAM_MEMBER_NAME_TAKEN|already used in this Team|已经用过|名字.*占用/i.test(joined);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* agentflow_node_done:完成当前画布节点必须调用,不调用视为未完成。
|
|
905
|
+
* 全局注册;handler 里按 run.ownerId 校验 —— 只有发起这次 run 的 agent 能标记。
|
|
906
|
+
*
|
|
907
|
+
* @param {(agentId: string) => object|null} getRun 按 agent id 取 run
|
|
908
|
+
*/
|
|
909
|
+
export function createNodeDoneTool(getRun, onChange) {
|
|
910
|
+
return defineTool({
|
|
911
|
+
name: 'agentflow_node_done',
|
|
912
|
+
description:
|
|
913
|
+
'完成当前画布节点必须调用,不调用视为未完成。交回节点 id 与该节点的实际产出(>=10 字);只有发起这次画布工作流的 agent 能调用。每完成一个节点调用一次,按返回的提示继续下一层。',
|
|
914
|
+
parameters: {
|
|
915
|
+
node_id: {
|
|
916
|
+
type: 'string',
|
|
917
|
+
required: true,
|
|
918
|
+
description: '要标记完成的画布节点 id(当前层「当前可做」里的一个)',
|
|
919
|
+
},
|
|
920
|
+
output: {
|
|
921
|
+
type: 'string',
|
|
922
|
+
required: true,
|
|
923
|
+
description: '该节点的实际产出正文(>=10 字):结论、清单或关键内容,不要只写「完成」',
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
output: {
|
|
927
|
+
schema: { type: 'object', additionalProperties: true },
|
|
928
|
+
render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '') }],
|
|
929
|
+
},
|
|
930
|
+
async execute(args, exec) {
|
|
931
|
+
try {
|
|
932
|
+
const agent = exec?.agent;
|
|
933
|
+
const agentId = agentKey(agent);
|
|
934
|
+
const run = typeof getRun === 'function' ? getRun(agentId) : null;
|
|
935
|
+
const notify = typeof onChange === 'function' ? onChange : null;
|
|
936
|
+
if (!run || !isActive(run)) {
|
|
937
|
+
return { text: '当前没有工作流。先用 /agentflow-xxx <要做的事> 进入一张画布。' };
|
|
938
|
+
}
|
|
939
|
+
if (run.ownerId && run.ownerId !== agentId) {
|
|
940
|
+
return { text: `${statusLine(run)}\n不行:只有发起这次画布工作流的 agent(${run.ownerId})能标记节点完成。` };
|
|
941
|
+
}
|
|
942
|
+
const nodeId = typeof args?.node_id === 'string' ? args.node_id.trim() : '';
|
|
943
|
+
const output = typeof args?.output === 'string' ? args.output : '';
|
|
944
|
+
if (!nodeId) return { text: `${statusLine(run)}\n不行:缺 node_id,传当前要完成的节点 id。` };
|
|
945
|
+
const result = markDone(run, nodeId, output);
|
|
946
|
+
// 每次状态变化都落盘:DSH 重启后还能从断点续跑(结束/卡住就清文件)
|
|
947
|
+
if (notify) {
|
|
948
|
+
try {
|
|
949
|
+
notify(agentId, run);
|
|
950
|
+
} catch {
|
|
951
|
+
/* 落盘失败不影响本次标记 */
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (!result.ok) return { text: `${statusLine(run)}\n不行:${result.reason}` };
|
|
955
|
+
const status = statusLine(run);
|
|
956
|
+
if (result.state === 'done') return { text: `${status}\n${summaryInstruction(run)}` };
|
|
957
|
+
if (result.state === 'stuck') return { text: `${status}\n卡住:${run.stuck}` };
|
|
958
|
+
return { text: `${status}\n继续:${stepHint(run)}` };
|
|
959
|
+
} catch (err) {
|
|
960
|
+
return { text: `agentflow_node_done 出错(未标记任何节点):${errorText(err)}` };
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
presentCall: (args) => ({
|
|
964
|
+
card: 'generic',
|
|
965
|
+
title: `agentflow_node_done · ${args?.node_id ?? '?'}`,
|
|
966
|
+
kind: 'other',
|
|
967
|
+
rawInput: { node_id: args?.node_id },
|
|
968
|
+
}),
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** 文件签名:mtime + 大小。画布改了/命令重写了都要重注册,保证描述与摘要不过期。 */
|
|
973
|
+
async function signatureOf(...files) {
|
|
974
|
+
const parts = [];
|
|
975
|
+
for (const file of files) {
|
|
976
|
+
try {
|
|
977
|
+
const stat = await fsp.stat(file);
|
|
978
|
+
parts.push(`${stat.mtimeMs}:${stat.size}`);
|
|
979
|
+
} catch {
|
|
980
|
+
parts.push('missing');
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return parts.join('|');
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* 启动命令热注册。
|
|
988
|
+
*
|
|
989
|
+
* @param {object} ctx 插件上下文(需要 ctx.inject;极简宿主可直接给 ctx.commands)
|
|
990
|
+
* @param {object} options
|
|
991
|
+
* @param {string} options.dir 引擎 runtime 目录(保留给诊断信息,不 import 引擎)
|
|
992
|
+
* @param {string} options.dataDir 数据根目录(命令目录内部拼成 <dataDir>/commands)
|
|
993
|
+
* @param {number} [options.pollMs] 轮询间隔,默认 10000
|
|
994
|
+
* @param {number} [options.debounceMs] watch 防抖,默认 200
|
|
995
|
+
* @returns {{dispose: () => void, reload: () => Promise<void>, ready: Promise<void>|null, registered: Map, modes: Map, getRun: (agentId: string) => object|null}}
|
|
996
|
+
*/
|
|
997
|
+
export function startCommands(ctx, options = {}) {
|
|
998
|
+
const config = options && typeof options === 'object' ? options : {};
|
|
999
|
+
const dataDir = typeof config.dataDir === 'string' && config.dataDir ? path.resolve(config.dataDir) : '';
|
|
1000
|
+
if (!dataDir) {
|
|
1001
|
+
warn('缺少 dataDir,命令不启用');
|
|
1002
|
+
return inert();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const session = new CommandSession({ ...config, dataDir });
|
|
1006
|
+
const apiOf = (dispose) => ({
|
|
1007
|
+
dispose,
|
|
1008
|
+
reload: () => session.reload(),
|
|
1009
|
+
get ready() {
|
|
1010
|
+
return session.ready;
|
|
1011
|
+
},
|
|
1012
|
+
get registered() {
|
|
1013
|
+
return session.registered;
|
|
1014
|
+
},
|
|
1015
|
+
get modes() {
|
|
1016
|
+
return session.modes;
|
|
1017
|
+
},
|
|
1018
|
+
getRun: (agentId) => session.getRun(agentId),
|
|
1019
|
+
/** 节点完成后把最新状态落盘(index.js 的 node_done 工具回调) */
|
|
1020
|
+
persistRun: (agentId, run) => session.persistRun(agentId, run),
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
// 极简宿主(测试桩/嵌入)可能直接带 commands、没有 cordis 的 inject
|
|
1024
|
+
if (!ctx || typeof ctx.inject !== 'function') {
|
|
1025
|
+
if (ctx && ctx.commands && typeof ctx.commands.register === 'function') {
|
|
1026
|
+
session.attach(ctx);
|
|
1027
|
+
return apiOf(() => session.dispose());
|
|
1028
|
+
}
|
|
1029
|
+
warn('宿主没有 ctx.inject,命令不启用');
|
|
1030
|
+
return inert();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
let fiber = null;
|
|
1034
|
+
try {
|
|
1035
|
+
fiber = ctx.inject(['commands'], (child) => {
|
|
1036
|
+
session.attach(child);
|
|
1037
|
+
});
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
warn('挂载 commands 服务失败,命令不启用:' + errorText(err));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
return apiOf(() => {
|
|
1043
|
+
// 先停本地 watcher/定时器,再让 cordis 回收注入的 child fiber
|
|
1044
|
+
try {
|
|
1045
|
+
session.dispose();
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
warn('卸载命令失败(已忽略):' + errorText(err));
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
1050
|
+
const result = fiber && typeof fiber.dispose === 'function' ? fiber.dispose() : null;
|
|
1051
|
+
if (result && typeof result.then === 'function') result.catch(() => {});
|
|
1052
|
+
} catch {
|
|
1053
|
+
/* 卸载失败没有补救动作 */
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export default startCommands;
|