dsh-tree-task-flow 0.3.12 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * 意外中断的自动拉起。
3
+ *
4
+ * 模型跑到一半停住,有两种完全不同的停法,**只有后一种值得插件插手**:
5
+ *
6
+ * ① **正常收尾**:`turn/end` 的 `reason.kind` 是 `completed` / `max-tokens`。
7
+ * 模型自己把话说完、把工具停下来了。计划没做完是模型偷懒,那是
8
+ * `auto-continue.js` 的活,不在这里掺和。
9
+ *
10
+ * ② **意外中断**:模型根本没机会说完。DSH 层面只有两种形态(2026-10-08 扫本机
11
+ * 187 个会话日志得出的结论,全量 2000+ 次 `turn/end` 都落在这两类里):
12
+ *
13
+ * - **模型 API 调用失败**:`turn/end` 的 `reason.kind === 'error'`,里面是
14
+ * 一个 `LlmFailure`(`{ message, code }`)。见过的码有 `TRANSPORT`
15
+ * (连接被断)、`RATE_LIMIT`(429)、`QUOTA`(402 余额不足)、
16
+ * `MALFORMED_RESPONSE`(模型吐出的工具调用参数不是合法 JSON)。
17
+ * DSH 自带的 `dsh-llm-retry` 会**先**快速重试 5 次(500ms→10s 退避),
18
+ * 这里接的是它耗光之后那一段长间隔。
19
+ *
20
+ * - **工具执行到一半进程没了**:会话重新加载时,DSH 的崩溃修复
21
+ * (`dsh-session` 的 `repair.js`,`openTurnClosers`)发现"有工具调用没落盘
22
+ * 结果",补一条合成错误结果——`error.code` 是 `TOOL_OUTCOME_UNKNOWN`
23
+ * (调用已记录、结果没落盘)或 `TOOL_NOT_STARTED`(调用还没记录)——
24
+ * 同时补一条 `turn/end` `reason.kind === 'interrupted'`。
25
+ * **这不是"工具报错"**:它意味着那次操作到底做没做,没人知道。
26
+ *
27
+ * ## 为什么不看 `isError`
28
+ *
29
+ * 全量日志里带 `isError` 的工具结果有 800 多条,绝大多数是
30
+ * `FS_STALE_VERSION`(283)、`FS_EDIT_NOT_FOUND`(133)、`WEB_BLOCKED_URL`(63)、
31
+ * `SEARCH_FAILED`(13) 这一类——模型看得懂,自己会改道,`turn` 照样走完。
32
+ * 拿它当判据只会到处乱拉。真正和"停住"绑定的只有上面那种**没有真实结果**的
33
+ * 合成错误。
34
+ *
35
+ * ## 拉起的节奏
36
+ *
37
+ * | 中断 | 节奏 |
38
+ * |---|---|
39
+ * | 网络类(`TRANSPORT` / `TIMEOUT` / `SERVER` / `RATE_LIMIT` / `EMPTY_RESPONSE`) | 中断后第 1、5、10、30 分钟各一次 |
40
+ * | 工具类(合成错误 `TOOL_OUTCOME_UNKNOWN` / `TOOL_NOT_STARTED`) | 立刻一次;再失败就把那次工具调用连拉起消息一起收走,再拉一次,然后停手 |
41
+ * | 输出类(`MALFORMED_RESPONSE`) | 立刻一次;再失败就直接再拉一次(没有东西可收),然后停手 |
42
+ *
43
+ * ## 为什么 `MALFORMED_RESPONSE` 要单独一类
44
+ *
45
+ * 它的全文是「模型给出的工具调用参数不是合法 JSON」,**整条响应被 DSH 直接作废**:
46
+ * 一个工具都没执行,连 `tool/call` 都没落盘,也就没有"结果未知"可言。把它和合成
47
+ * 错误混在一起,会同时错两处——提示词让人去勘察一个不存在的副作用,收走逻辑又
48
+ * 找不到对象却照样宣称"连同拉起消息一起收走了"(2026-10-12 在
49
+ * session-ff946de5 上逐分支推演时发现)。
50
+ *
51
+ * `QUOTA`(余额不足)**不拉**:等再久也不会自己好,白烧消息。
52
+ * `aborted`(人按了停止)永远不拉。
53
+ *
54
+ * ## 一条链只跑一遍
55
+ *
56
+ * 时刻表用完就把它标成 `exhausted` 停在磁盘上,不再开新的链——否则"拉起→失败→
57
+ * 从头再来"会无限循环下去。**真人一发消息就把它清掉**:人回来了,环境大概也变了,
58
+ * 值得重来一遍。
59
+ *
60
+ * @module dsh-tree-task-flow/interrupt
61
+ */
62
+
63
+ import { activePath, renderContinue } from "./plan.js";
64
+ import { hasSummary, isPluginSource, pluginSource, summaryMessage } from "./region.js";
65
+
66
+ /**
67
+ * 传输类的模型 API 失败码——这些等一会儿大概率会好。
68
+ *
69
+ * 与 `@deepseek-ai/dsh-llm` 的 `DEFAULT_RETRYABLE_CODES` 保持一致
70
+ * (那边还有 `EMPTY_RESPONSE`),**但不含 `QUOTA`**:余额不足不会自己好。
71
+ */
72
+ export const NETWORK_CODES = Object.freeze([
73
+ "TRANSPORT",
74
+ "TIMEOUT",
75
+ "SERVER",
76
+ "RATE_LIMIT",
77
+ "EMPTY_RESPONSE",
78
+ ]);
79
+
80
+ /**
81
+ * "结果未知"的合成错误码——会话重载时由 DSH 的崩溃修复补上。
82
+ *
83
+ * 这类结果**不是工具真的报错**,而是"那次调用到底做没做,没人知道"。
84
+ */
85
+ export const SYNTHETIC_TOOL_CODES = Object.freeze(["TOOL_OUTCOME_UNKNOWN", "TOOL_NOT_STARTED"]);
86
+
87
+ /**
88
+ * 模型给出的工具调用参数不是合法 JSON。
89
+ *
90
+ * 由 DSH 的 provider 适配器在**解析服务端返回的响应流**时抛出:收到 `message_stop` 后对
91
+ * 每个 tool-call 块的实参做 `JSON.parse`,失败即 `MALFORMED_RESPONSE`。此刻那一条响应
92
+ * 已被整条作废,没有任何工具被执行。
93
+ */
94
+ const MALFORMED_CODE = "MALFORMED_RESPONSE";
95
+
96
+ /** 需要"一有结果就立刻拉"的两类:都要等 agent 空下来才能真正推消息。 */
97
+ const IMMEDIATE_CLASSES = Object.freeze(["tool", "malformed"]);
98
+
99
+ /** 默认时刻表:中断后第 1、5、10、30 分钟各一次。 */
100
+ export const DEFAULT_DELAYS_MS = Object.freeze([60_000, 300_000, 600_000, 1_800_000]);
101
+
102
+ /** 拉起消息在界面上的那一行摘要,也是"这条是我注入的"的识别标记。 */
103
+ export const REVIVE_SUMMARY = "中断拉起";
104
+
105
+ /** 收走失败尝试时落下的那条消息的摘要。 */
106
+ export const HIDE_SUMMARY = "中断重试 · 上一次尝试已作废";
107
+
108
+ /** 同一毫秒内连发两条也不撞 id。 */
109
+ let seq = 0;
110
+ function nextId(prefix) {
111
+ seq += 1;
112
+ return `${prefix}-${Date.now().toString(36)}-${seq.toString(36)}`;
113
+ }
114
+
115
+ /**
116
+ * 拉起消息的开头一句——按中断类别说实话。
117
+ *
118
+ * 三类的中断事实完全不同,用同一句话只会把模型带偏:
119
+ *
120
+ * - `tool`:确实发起过工具调用,结果没落盘,"做没做"真的没人知道,所以要先勘察影响;
121
+ * - `malformed`:那整条响应被判废,**一个工具都没执行**,没有影响可勘察,原样重做即可;
122
+ * - `network`:请求根本没送达,同上,重来一遍。
123
+ *
124
+ * @param kind - `'network'` / `'tool'` / `'malformed'`。
125
+ * @param code - 中断码。
126
+ * @returns 一句话(不含后续的续行提示)。
127
+ */
128
+ export function reviveLead(kind, code) {
129
+ if (kind === "tool") {
130
+ return `tree_task消息:上一轮的工具调用没有留下结果(${code}),它到底做没做没人知道——先确认那一步的实际影响,再决定要不要重做,不要盲目重试。`;
131
+ }
132
+ if (kind === "malformed") {
133
+ return `tree_task消息:上一轮模型给出的工具调用参数不是合法 JSON(${code}),DSH 把那一整条响应作废了——没有任何工具被执行,也没有任何副作用要勘察,把那一步原样重做一遍就行。写实参时把引号和括号写全(长文本尤其当心),必要时一次只调用一个工具。`;
134
+ }
135
+ return `tree_task消息:上一轮因为 ${code} 中断了,会话没跑完。`;
136
+ }
137
+
138
+ /**
139
+ * 判定这次 `turn/end` 算不算意外中断。
140
+ *
141
+ * @param reason - `turn/end` 的 `reason`。
142
+ * @param syntheticCode - 这一轮里出现过的合成错误码,没有则 null。
143
+ * @returns `{ class: 'network' | 'tool' | 'malformed', code }`,不属于这套机制时返回 null。
144
+ */
145
+ export function classifyInterrupt(reason, syntheticCode = null) {
146
+ const kind = reason?.kind;
147
+ if (kind === "error") {
148
+ const code = reason?.error?.code;
149
+ if (typeof code !== "string" || code === "") return null;
150
+ if (NETWORK_CODES.includes(code)) return { class: "network", code };
151
+ if (code === MALFORMED_CODE) return { class: "malformed", code };
152
+ return null; // QUOTA 等:不归这套管
153
+ }
154
+ if (kind === "interrupted") {
155
+ if (typeof syntheticCode !== "string" || syntheticCode === "") return null;
156
+ return { class: "tool", code: syntheticCode };
157
+ }
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * 注册意外中断拉起。
163
+ *
164
+ * @param ctx - 挂载本行的 Cordis 上下文。
165
+ * @param options - `{ store, cfg, pause }`。
166
+ * @returns 控制接口:`{ stop(sessionId), revive(sessionId) }`。
167
+ */
168
+ export function registerInterruptRecovery(ctx, { store, cfg, pause = null }) {
169
+ /** 每个会话一个待触发的定时器(网络类)。 */
170
+ const timers = new Map();
171
+ /** 这一轮里见到过的合成错误码:sessionId → code。 */
172
+ const synthetic = new Map();
173
+ /** 工具类 / 输出类中断在等 agent 空下来:sessionId → `{ code, klass }`。 */
174
+ const waiting = new Map();
175
+
176
+ /** 取实时 agent;拿不到可选服务时 cordis 会抛错,所以反射式取。 */
177
+ function liveAgent(sessionId) {
178
+ return ctx.get?.("agents")?.get?.(sessionId) ?? null;
179
+ }
180
+
181
+ function clearTimer(sessionId) {
182
+ const handle = timers.get(sessionId);
183
+ if (handle !== undefined) {
184
+ clearTimeout(handle);
185
+ timers.delete(sessionId);
186
+ }
187
+ }
188
+
189
+ function delays() {
190
+ const configured = cfg?.interruptDelaysMs;
191
+ return Array.isArray(configured) && configured.length > 0 ? configured : DEFAULT_DELAYS_MS;
192
+ }
193
+
194
+ /** 计划树还在进行中吗——没拆完、有检查点都算"还在进行"。 */
195
+ function pending(plan) {
196
+ if (plan === null) return false;
197
+ const active = activePath(plan);
198
+ return active !== null && active.step !== null;
199
+ }
200
+
201
+ /** 现在能不能拉:暂停 / 停止 / 没计划 / 没加载 / 不空闲,一条不满足就不拉。 */
202
+ function blockedBy(sessionId) {
203
+ if (pause?.isPaused?.(sessionId) === true) return "paused";
204
+ if (store.readHeld(sessionId)?.stopped === true) return "stopped";
205
+ const plan = store.readPlan(sessionId);
206
+ if (plan === null) return "no-plan";
207
+ if (!pending(plan)) return "finished";
208
+ const agent = liveAgent(sessionId);
209
+ if (agent === null) return "unloaded";
210
+ if (agent.status !== "idle") return "busy";
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * 推一条拉起消息。
216
+ *
217
+ * @param sessionId - 要拉起的会话。
218
+ * @param kind - `'network'` / `'tool'` / `'malformed'`。
219
+ * @param code - 触发这次拉起的中断码。
220
+ * @returns `{ ok, reason?, text? }`。
221
+ */
222
+ function revive(sessionId, kind, code) {
223
+ const blocked = blockedBy(sessionId);
224
+ if (blocked !== null) return { ok: false, reason: blocked };
225
+
226
+ const plan = store.readPlan(sessionId);
227
+ const text = `${reviveLead(kind, code)}${renderContinue(plan)}`;
228
+
229
+ const agent = liveAgent(sessionId);
230
+ try {
231
+ agent.followup({
232
+ id: nextId(`tt-revive-${sessionId}`),
233
+ role: "user",
234
+ content: [{ type: "text", text }],
235
+ source: pluginSource(REVIVE_SUMMARY),
236
+ });
237
+ } catch (error) {
238
+ ctx.logger?.warn?.(
239
+ `dsh-tree-task-flow: 中断拉起失败:${String(error?.message ?? error)}`,
240
+ );
241
+ return { ok: false, reason: "error" };
242
+ }
243
+ ctx.logger?.info?.(
244
+ `dsh-tree-task-flow: 意外中断拉起(${kind}/${code}):${sessionId}`,
245
+ );
246
+ return { ok: true };
247
+ }
248
+
249
+ /**
250
+ * 排下一次网络类拉起。
251
+ *
252
+ * 磁盘上的 `pending` 是**绝对时刻**;已经过去的一律丢掉(进程没起来时补拉没有意义),
253
+ * 一个不剩就把这条链标成用完。
254
+ */
255
+ function scheduleNext(sessionId) {
256
+ clearTimer(sessionId);
257
+ const state = store.readRevive(sessionId);
258
+ if (state === null || state.class !== "network") return;
259
+
260
+ const now = Date.now();
261
+ const future = (state.pending ?? []).filter((at) => typeof at === "number" && at > now);
262
+ if (future.length === 0) {
263
+ store.writeRevive(sessionId, { ...state, pending: [], exhausted: true });
264
+ return;
265
+ }
266
+
267
+ const next = future[0];
268
+ const handle = setTimeout(() => {
269
+ timers.delete(sessionId);
270
+ const fresh = store.readRevive(sessionId);
271
+ if (fresh === null) return;
272
+ // 到点就消费掉这个时刻,拉起成功与否都不还给它。
273
+ store.writeRevive(sessionId, {
274
+ ...fresh,
275
+ pending: (fresh.pending ?? []).filter((at) => at !== next),
276
+ });
277
+ revive(sessionId, "network", fresh.code);
278
+ scheduleNext(sessionId);
279
+ }, next - now);
280
+ handle.unref?.();
281
+ timers.set(sessionId, handle);
282
+ }
283
+
284
+ /** 链结束:把定时器与磁盘状态一起清掉。 */
285
+ function endChain(sessionId) {
286
+ clearTimer(sessionId);
287
+ waiting.delete(sessionId);
288
+ store.clearRevive(sessionId);
289
+ }
290
+
291
+ /**
292
+ * 收走"上一次没成的那一下"。
293
+ *
294
+ * 收走三样连在一起的东西:**发起那次工具调用的 assistant 消息**、它的
295
+ * **结果未知的合成错误结果**、以及**我们刚推的那条拉起消息**。留着它们,
296
+ * 模型下一轮多半还会照着再来一遍;收走之后它才可能换条路走。
297
+ *
298
+ * ★ **只对 `tool` 类调用**。`malformed` 类(`MALFORMED_RESPONSE`)什么都没执行、
299
+ * 没有合成结果可定位,走到这里只会以「只收走拉起消息」收场,却仍然落下一条
300
+ * "连同拉起消息一起收走了"的通告——话说过头了,所以那类压根不调它。
301
+ *
302
+ * @param session - 当前会话。
303
+ * @param code - 触发这次收走的中断码。
304
+ * @returns 是否真的收走了。
305
+ */
306
+ function hideFailedAttempt(session, code) {
307
+ const nodes = session.surface?.nodes ?? [];
308
+ if (nodes.length === 0 || typeof session.eventAt !== "function") return false;
309
+
310
+ // ① 最后一条我们注入的拉起消息。
311
+ let reviveIdx = -1;
312
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
313
+ const event = session.eventAt(nodes[index]);
314
+ if (
315
+ event?.type === "user/message" &&
316
+ isPluginSource(event.data?.source) &&
317
+ // 带不带「树形任务流 · 」前缀都认:升级前落下的那几条没有前缀。
318
+ hasSummary(event.data?.source, REVIVE_SUMMARY)
319
+ ) {
320
+ reviveIdx = index;
321
+ break;
322
+ }
323
+ }
324
+ if (reviveIdx === -1) return false;
325
+
326
+ // ② 它前面最近的那条"结果未知"合成结果。
327
+ let resultIdx = -1;
328
+ for (let index = reviveIdx - 1; index >= 0; index -= 1) {
329
+ const event = session.eventAt(nodes[index]);
330
+ if (event?.type === "user/message" && isPluginSource(event.data?.source)) break;
331
+ if (event?.type === "tool/result" && SYNTHETIC_TOOL_CODES.includes(event.data?.error?.code)) {
332
+ resultIdx = index;
333
+ break;
334
+ }
335
+ }
336
+
337
+ // ③ 再往前,发起那次调用的 assistant 消息。
338
+ let startIdx = resultIdx === -1 ? reviveIdx : resultIdx;
339
+ if (resultIdx !== -1) {
340
+ for (let index = resultIdx - 1; index >= 0 && resultIdx - index <= 5; index -= 1) {
341
+ const event = session.eventAt(nodes[index]);
342
+ if (event?.type === "assistant/message") {
343
+ startIdx = index;
344
+ break;
345
+ }
346
+ }
347
+ }
348
+
349
+ const from = nodes[startIdx];
350
+ const to = nodes[reviveIdx];
351
+ const shadowed = nodes.slice(startIdx, reviveIdx + 1);
352
+ const count = shadowed.length;
353
+ const text =
354
+ `tree_task消息:上一次尝试(${code})已经作废,它那一段过程连同拉起消息一起收走了。` +
355
+ `重新开始这一步,换一条路走。`;
356
+
357
+ try {
358
+ session.append("user/message", summaryMessage(nextId("tt-hide"), text, HIDE_SUMMARY), {
359
+ surfaceOp: { op: "replace", startSeq: from, endSeq: to },
360
+ sourceEventSeqs: shadowed,
361
+ });
362
+ } catch (error) {
363
+ // 收不走不算致命:下一次中断还会再试。绝不因此中断会话。
364
+ ctx.logger?.warn?.(
365
+ `dsh-tree-task-flow: 收走失败尝试失败,保留原区间:${String(error?.message ?? error)}`,
366
+ );
367
+ return false;
368
+ }
369
+ ctx.logger?.info?.(
370
+ `dsh-tree-task-flow: 已收走 ${count} 条失败尝试(${code}),再拉一次:${session.id}`,
371
+ );
372
+ return true;
373
+ }
374
+
375
+ /**
376
+ * 工具类 / 输出类:立刻拉起一次,第二次失败再拉一次,第三次停手。
377
+ *
378
+ * 两次之间的差别只在于"有没有东西可收":`tool` 那类确实发起过工具调用,把那次调用
379
+ * 连同拉起消息一起收走,模型下一轮才可能换条路走;`malformed` 那类什么都没执行,
380
+ * 没有东西可收,收了反而只是一句空话。
381
+ *
382
+ * @param session - 当前会话。
383
+ * @param sessionId - 会话 id。
384
+ * @param turn - 中断所在的轮次。
385
+ * @param klass - `'tool'` / `'malformed'`。
386
+ * @param code - 中断码。
387
+ */
388
+ function onImmediateInterrupt(session, sessionId, turn, klass, code) {
389
+ const chain = store.readRevive(sessionId);
390
+ // 这条链已经用完两次、停手了:不再重复告警,等真人发消息把它清掉。
391
+ if (chain?.class === klass && chain.exhausted === true) return;
392
+ const attempts = chain?.class === klass ? chain.attempts ?? 0 : 0;
393
+
394
+ if (attempts === 0) {
395
+ store.writeRevive(sessionId, {
396
+ turn,
397
+ class: klass,
398
+ code,
399
+ pending: [],
400
+ attempts: 1,
401
+ hidden: false,
402
+ });
403
+ // turn/end 到达时 agent 往往还没翻回 idle,这里只登记,等 idle 再推。
404
+ waiting.set(sessionId, { code, klass });
405
+ return;
406
+ }
407
+
408
+ if (attempts === 1) {
409
+ const hidden = klass === "tool" ? hideFailedAttempt(session, code) : false;
410
+ store.writeRevive(sessionId, {
411
+ ...chain,
412
+ turn,
413
+ code,
414
+ attempts: 2,
415
+ hidden,
416
+ });
417
+ waiting.set(sessionId, { code, klass });
418
+ return;
419
+ }
420
+
421
+ // 两次都用过了还是停住:停手,等人。
422
+ store.writeRevive(sessionId, { ...chain, turn, code, exhausted: true });
423
+ waiting.delete(sessionId);
424
+ ctx.logger?.warn?.(
425
+ `dsh-tree-task-flow: ${klass} 类中断重复出现(${code}),已停手,等真人介入:${sessionId}`,
426
+ );
427
+ }
428
+
429
+ /** 网络类:开一条链或接着用剩下的时刻。 */
430
+ function onNetworkInterrupt(sessionId, turn, code) {
431
+ const chain = store.readRevive(sessionId);
432
+ if (chain?.exhausted === true) return;
433
+ const sameChain = chain?.class === "network" && Array.isArray(chain.pending) && chain.pending.length > 0;
434
+ const now = Date.now();
435
+ const pending = sameChain ? chain.pending : delays().map((delay) => now + delay);
436
+ store.writeRevive(sessionId, {
437
+ turn,
438
+ class: "network",
439
+ code,
440
+ pending,
441
+ attempts: 0,
442
+ hidden: false,
443
+ });
444
+ scheduleNext(sessionId);
445
+ }
446
+
447
+ // ---------------------------------------------------------------- 会话事件
448
+
449
+ ctx.on("session/event", (session, event) => {
450
+ const sessionId = session?.id;
451
+ if (sessionId === undefined || event === undefined) return;
452
+
453
+ if (event.type === "turn/start") {
454
+ synthetic.delete(sessionId);
455
+ return;
456
+ }
457
+
458
+ if (event.type === "tool/result") {
459
+ const code = event.data?.error?.code;
460
+ if (SYNTHETIC_TOOL_CODES.includes(code)) synthetic.set(sessionId, code);
461
+ return;
462
+ }
463
+
464
+ if (event.type !== "turn/end") return;
465
+
466
+ const reason = event.data?.reason;
467
+ const sawSynthetic = synthetic.get(sessionId) ?? null;
468
+ synthetic.delete(sessionId);
469
+
470
+ // 正常收尾 / 被按住:这条链到此为止。
471
+ if (reason?.kind === "completed" || reason?.kind === "max-tokens" ||
472
+ reason?.kind === "aborted" || reason?.kind === "blocked" || reason?.kind === "forked") {
473
+ endChain(sessionId);
474
+ return;
475
+ }
476
+
477
+ const hit = classifyInterrupt(reason, sawSynthetic);
478
+ if (hit === null) return;
479
+
480
+ // 拉起的唯一前提:这棵树上还有没做完的活。
481
+ const plan = store.readPlan(sessionId);
482
+ if (plan === null || !pending(plan)) {
483
+ endChain(sessionId);
484
+ return;
485
+ }
486
+
487
+ const turn = event.data?.turn;
488
+ if (IMMEDIATE_CLASSES.includes(hit.class)) {
489
+ onImmediateInterrupt(session, sessionId, turn, hit.class, hit.code);
490
+ return;
491
+ }
492
+ onNetworkInterrupt(sessionId, turn, hit.code);
493
+ });
494
+
495
+ // ---------------------------------------------------------------- agent 状态
496
+
497
+ ctx.on("agent/status", (payload) => {
498
+ const sessionId = payload?.agent?.session?.id;
499
+ if (sessionId === undefined || payload?.status !== "idle") return;
500
+
501
+ // 工具类 / 输出类中断一直在等这个时刻:agent 终于空下来了。
502
+ const queued = waiting.get(sessionId);
503
+ if (queued !== undefined) {
504
+ waiting.delete(sessionId);
505
+ revive(sessionId, queued.klass, queued.code);
506
+ }
507
+
508
+ // 跨重启:磁盘上还留着没跑完的时刻表,重新排上。
509
+ scheduleNext(sessionId);
510
+ });
511
+
512
+ // 真人一发消息就清链:人回来了,环境大概也变了,允许重新开一条。
513
+ ctx.on("agent/pre-step", async (payload, next) => {
514
+ const decision = await next();
515
+ const sessionId = payload?.agent?.session?.id;
516
+ if (sessionId === undefined) return decision;
517
+ const fromHuman = (payload?.messages ?? []).some(
518
+ (message) => message?.source?.kind === "user",
519
+ );
520
+ if (fromHuman) endChain(sessionId);
521
+ return decision;
522
+ });
523
+
524
+ // 人按了停止:这条链立刻作废,一条都不许再推。
525
+ ctx.on("agent/turn-stopping", (payload) => {
526
+ const sessionId = payload?.agent?.session?.id;
527
+ if (sessionId === undefined) return;
528
+ if (payload?.signal?.aborted === true) endChain(sessionId);
529
+ });
530
+
531
+ return {
532
+ /** 停掉某个会话的拉起(人按停止时用)。 */
533
+ stop(sessionId) {
534
+ endChain(sessionId);
535
+ },
536
+ /** 立刻尝试拉起一次(给命令用)。 */
537
+ revive(sessionId, kind = "network", code = "MANUAL") {
538
+ return revive(sessionId, kind, code);
539
+ },
540
+ };
541
+ }