dsh-tree-task-flow 0.3.12 → 0.3.14
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 +153 -30
- package/client.js +212 -10
- package/config.example.yml +17 -3
- package/cordis.patch.yml +11 -5
- package/lib/auto-continue.js +32 -1
- package/lib/commands.js +46 -5
- package/lib/compaction.js +2 -2
- package/lib/http.js +52 -6
- package/lib/index.js +45 -6
- package/lib/interrupt.js +559 -0
- package/lib/pause.js +40 -15
- package/lib/plan.js +144 -30
- package/lib/region.js +65 -15
- package/lib/settings.js +53 -0
- package/lib/store.js +89 -0
- package/lib/tools.js +102 -17
- package/package.json +1 -1
package/lib/interrupt.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
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
|
+
* 开关关着时整个模块都不动。
|
|
203
|
+
*
|
|
204
|
+
* 它是**面板上随时可改**的,所以每次事件现读 `cfg`,不能开机读一次就定死;
|
|
205
|
+
* 注册与否也不该取决于它(见 `lib/index.js` 里那句"无条件注册")。
|
|
206
|
+
*
|
|
207
|
+
* 判据写成"**显式 false 才算关**":这一项的默认值是开,缺字段的意思是"没配,
|
|
208
|
+
* 按默认走",不该被当成关掉——直接构造这个模块的调用方(自检、以后别的入口)
|
|
209
|
+
* 往往只给一个 `{}`。
|
|
210
|
+
*/
|
|
211
|
+
function off() {
|
|
212
|
+
return cfg?.interruptRecovery === false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 现在能不能拉:暂停 / 停止 / 没计划 / 没加载 / 不空闲,一条不满足就不拉。 */
|
|
216
|
+
function blockedBy(sessionId) {
|
|
217
|
+
if (pause?.isPaused?.(sessionId) === true) return "paused";
|
|
218
|
+
if (store.readHeld(sessionId)?.stopped === true) return "stopped";
|
|
219
|
+
const plan = store.readPlan(sessionId);
|
|
220
|
+
if (plan === null) return "no-plan";
|
|
221
|
+
if (!pending(plan)) return "finished";
|
|
222
|
+
const agent = liveAgent(sessionId);
|
|
223
|
+
if (agent === null) return "unloaded";
|
|
224
|
+
if (agent.status !== "idle") return "busy";
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 推一条拉起消息。
|
|
230
|
+
*
|
|
231
|
+
* @param sessionId - 要拉起的会话。
|
|
232
|
+
* @param kind - `'network'` / `'tool'` / `'malformed'`。
|
|
233
|
+
* @param code - 触发这次拉起的中断码。
|
|
234
|
+
* @returns `{ ok, reason?, text? }`。
|
|
235
|
+
*/
|
|
236
|
+
function revive(sessionId, kind, code) {
|
|
237
|
+
if (off()) return { ok: false, reason: "disabled" };
|
|
238
|
+
const blocked = blockedBy(sessionId);
|
|
239
|
+
if (blocked !== null) return { ok: false, reason: blocked };
|
|
240
|
+
|
|
241
|
+
const plan = store.readPlan(sessionId);
|
|
242
|
+
const text = `${reviveLead(kind, code)}${renderContinue(plan)}`;
|
|
243
|
+
|
|
244
|
+
const agent = liveAgent(sessionId);
|
|
245
|
+
try {
|
|
246
|
+
agent.followup({
|
|
247
|
+
id: nextId(`tt-revive-${sessionId}`),
|
|
248
|
+
role: "user",
|
|
249
|
+
content: [{ type: "text", text }],
|
|
250
|
+
source: pluginSource(REVIVE_SUMMARY),
|
|
251
|
+
});
|
|
252
|
+
} catch (error) {
|
|
253
|
+
ctx.logger?.warn?.(
|
|
254
|
+
`dsh-tree-task-flow: 中断拉起失败:${String(error?.message ?? error)}`,
|
|
255
|
+
);
|
|
256
|
+
return { ok: false, reason: "error" };
|
|
257
|
+
}
|
|
258
|
+
ctx.logger?.info?.(
|
|
259
|
+
`dsh-tree-task-flow: 意外中断拉起(${kind}/${code}):${sessionId}`,
|
|
260
|
+
);
|
|
261
|
+
return { ok: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 排下一次网络类拉起。
|
|
266
|
+
*
|
|
267
|
+
* 磁盘上的 `pending` 是**绝对时刻**;已经过去的一律丢掉(进程没起来时补拉没有意义),
|
|
268
|
+
* 一个不剩就把这条链标成用完。
|
|
269
|
+
*/
|
|
270
|
+
function scheduleNext(sessionId) {
|
|
271
|
+
if (off()) return;
|
|
272
|
+
clearTimer(sessionId);
|
|
273
|
+
const state = store.readRevive(sessionId);
|
|
274
|
+
if (state === null || state.class !== "network") return;
|
|
275
|
+
|
|
276
|
+
const now = Date.now();
|
|
277
|
+
const future = (state.pending ?? []).filter((at) => typeof at === "number" && at > now);
|
|
278
|
+
if (future.length === 0) {
|
|
279
|
+
store.writeRevive(sessionId, { ...state, pending: [], exhausted: true });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const next = future[0];
|
|
284
|
+
const handle = setTimeout(() => {
|
|
285
|
+
timers.delete(sessionId);
|
|
286
|
+
const fresh = store.readRevive(sessionId);
|
|
287
|
+
if (fresh === null) return;
|
|
288
|
+
// 到点就消费掉这个时刻,拉起成功与否都不还给它。
|
|
289
|
+
store.writeRevive(sessionId, {
|
|
290
|
+
...fresh,
|
|
291
|
+
pending: (fresh.pending ?? []).filter((at) => at !== next),
|
|
292
|
+
});
|
|
293
|
+
revive(sessionId, "network", fresh.code);
|
|
294
|
+
scheduleNext(sessionId);
|
|
295
|
+
}, next - now);
|
|
296
|
+
handle.unref?.();
|
|
297
|
+
timers.set(sessionId, handle);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 链结束:把定时器与磁盘状态一起清掉。 */
|
|
301
|
+
function endChain(sessionId) {
|
|
302
|
+
clearTimer(sessionId);
|
|
303
|
+
waiting.delete(sessionId);
|
|
304
|
+
store.clearRevive(sessionId);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 收走"上一次没成的那一下"。
|
|
309
|
+
*
|
|
310
|
+
* 收走三样连在一起的东西:**发起那次工具调用的 assistant 消息**、它的
|
|
311
|
+
* **结果未知的合成错误结果**、以及**我们刚推的那条拉起消息**。留着它们,
|
|
312
|
+
* 模型下一轮多半还会照着再来一遍;收走之后它才可能换条路走。
|
|
313
|
+
*
|
|
314
|
+
* ★ **只对 `tool` 类调用**。`malformed` 类(`MALFORMED_RESPONSE`)什么都没执行、
|
|
315
|
+
* 没有合成结果可定位,走到这里只会以「只收走拉起消息」收场,却仍然落下一条
|
|
316
|
+
* "连同拉起消息一起收走了"的通告——话说过头了,所以那类压根不调它。
|
|
317
|
+
*
|
|
318
|
+
* @param session - 当前会话。
|
|
319
|
+
* @param code - 触发这次收走的中断码。
|
|
320
|
+
* @returns 是否真的收走了。
|
|
321
|
+
*/
|
|
322
|
+
function hideFailedAttempt(session, code) {
|
|
323
|
+
const nodes = session.surface?.nodes ?? [];
|
|
324
|
+
if (nodes.length === 0 || typeof session.eventAt !== "function") return false;
|
|
325
|
+
|
|
326
|
+
// ① 最后一条我们注入的拉起消息。
|
|
327
|
+
let reviveIdx = -1;
|
|
328
|
+
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
329
|
+
const event = session.eventAt(nodes[index]);
|
|
330
|
+
if (
|
|
331
|
+
event?.type === "user/message" &&
|
|
332
|
+
isPluginSource(event.data?.source) &&
|
|
333
|
+
// 带不带「树形任务流 · 」前缀都认:升级前落下的那几条没有前缀。
|
|
334
|
+
hasSummary(event.data?.source, REVIVE_SUMMARY)
|
|
335
|
+
) {
|
|
336
|
+
reviveIdx = index;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (reviveIdx === -1) return false;
|
|
341
|
+
|
|
342
|
+
// ② 它前面最近的那条"结果未知"合成结果。
|
|
343
|
+
let resultIdx = -1;
|
|
344
|
+
for (let index = reviveIdx - 1; index >= 0; index -= 1) {
|
|
345
|
+
const event = session.eventAt(nodes[index]);
|
|
346
|
+
if (event?.type === "user/message" && isPluginSource(event.data?.source)) break;
|
|
347
|
+
if (event?.type === "tool/result" && SYNTHETIC_TOOL_CODES.includes(event.data?.error?.code)) {
|
|
348
|
+
resultIdx = index;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ③ 再往前,发起那次调用的 assistant 消息。
|
|
354
|
+
let startIdx = resultIdx === -1 ? reviveIdx : resultIdx;
|
|
355
|
+
if (resultIdx !== -1) {
|
|
356
|
+
for (let index = resultIdx - 1; index >= 0 && resultIdx - index <= 5; index -= 1) {
|
|
357
|
+
const event = session.eventAt(nodes[index]);
|
|
358
|
+
if (event?.type === "assistant/message") {
|
|
359
|
+
startIdx = index;
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const from = nodes[startIdx];
|
|
366
|
+
const to = nodes[reviveIdx];
|
|
367
|
+
const shadowed = nodes.slice(startIdx, reviveIdx + 1);
|
|
368
|
+
const count = shadowed.length;
|
|
369
|
+
const text =
|
|
370
|
+
`tree_task消息:上一次尝试(${code})已经作废,它那一段过程连同拉起消息一起收走了。` +
|
|
371
|
+
`重新开始这一步,换一条路走。`;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
session.append("user/message", summaryMessage(nextId("tt-hide"), text, HIDE_SUMMARY), {
|
|
375
|
+
surfaceOp: { op: "replace", startSeq: from, endSeq: to },
|
|
376
|
+
sourceEventSeqs: shadowed,
|
|
377
|
+
});
|
|
378
|
+
} catch (error) {
|
|
379
|
+
// 收不走不算致命:下一次中断还会再试。绝不因此中断会话。
|
|
380
|
+
ctx.logger?.warn?.(
|
|
381
|
+
`dsh-tree-task-flow: 收走失败尝试失败,保留原区间:${String(error?.message ?? error)}`,
|
|
382
|
+
);
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
ctx.logger?.info?.(
|
|
386
|
+
`dsh-tree-task-flow: 已收走 ${count} 条失败尝试(${code}),再拉一次:${session.id}`,
|
|
387
|
+
);
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 工具类 / 输出类:立刻拉起一次,第二次失败再拉一次,第三次停手。
|
|
393
|
+
*
|
|
394
|
+
* 两次之间的差别只在于"有没有东西可收":`tool` 那类确实发起过工具调用,把那次调用
|
|
395
|
+
* 连同拉起消息一起收走,模型下一轮才可能换条路走;`malformed` 那类什么都没执行,
|
|
396
|
+
* 没有东西可收,收了反而只是一句空话。
|
|
397
|
+
*
|
|
398
|
+
* @param session - 当前会话。
|
|
399
|
+
* @param sessionId - 会话 id。
|
|
400
|
+
* @param turn - 中断所在的轮次。
|
|
401
|
+
* @param klass - `'tool'` / `'malformed'`。
|
|
402
|
+
* @param code - 中断码。
|
|
403
|
+
*/
|
|
404
|
+
function onImmediateInterrupt(session, sessionId, turn, klass, code) {
|
|
405
|
+
const chain = store.readRevive(sessionId);
|
|
406
|
+
// 这条链已经用完两次、停手了:不再重复告警,等真人发消息把它清掉。
|
|
407
|
+
if (chain?.class === klass && chain.exhausted === true) return;
|
|
408
|
+
const attempts = chain?.class === klass ? chain.attempts ?? 0 : 0;
|
|
409
|
+
|
|
410
|
+
if (attempts === 0) {
|
|
411
|
+
store.writeRevive(sessionId, {
|
|
412
|
+
turn,
|
|
413
|
+
class: klass,
|
|
414
|
+
code,
|
|
415
|
+
pending: [],
|
|
416
|
+
attempts: 1,
|
|
417
|
+
hidden: false,
|
|
418
|
+
});
|
|
419
|
+
// turn/end 到达时 agent 往往还没翻回 idle,这里只登记,等 idle 再推。
|
|
420
|
+
waiting.set(sessionId, { code, klass });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (attempts === 1) {
|
|
425
|
+
const hidden = klass === "tool" ? hideFailedAttempt(session, code) : false;
|
|
426
|
+
store.writeRevive(sessionId, {
|
|
427
|
+
...chain,
|
|
428
|
+
turn,
|
|
429
|
+
code,
|
|
430
|
+
attempts: 2,
|
|
431
|
+
hidden,
|
|
432
|
+
});
|
|
433
|
+
waiting.set(sessionId, { code, klass });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 两次都用过了还是停住:停手,等人。
|
|
438
|
+
store.writeRevive(sessionId, { ...chain, turn, code, exhausted: true });
|
|
439
|
+
waiting.delete(sessionId);
|
|
440
|
+
ctx.logger?.warn?.(
|
|
441
|
+
`dsh-tree-task-flow: ${klass} 类中断重复出现(${code}),已停手,等真人介入:${sessionId}`,
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 网络类:开一条链或接着用剩下的时刻。 */
|
|
446
|
+
function onNetworkInterrupt(sessionId, turn, code) {
|
|
447
|
+
const chain = store.readRevive(sessionId);
|
|
448
|
+
if (chain?.exhausted === true) return;
|
|
449
|
+
const sameChain = chain?.class === "network" && Array.isArray(chain.pending) && chain.pending.length > 0;
|
|
450
|
+
const now = Date.now();
|
|
451
|
+
const pending = sameChain ? chain.pending : delays().map((delay) => now + delay);
|
|
452
|
+
store.writeRevive(sessionId, {
|
|
453
|
+
turn,
|
|
454
|
+
class: "network",
|
|
455
|
+
code,
|
|
456
|
+
pending,
|
|
457
|
+
attempts: 0,
|
|
458
|
+
hidden: false,
|
|
459
|
+
});
|
|
460
|
+
scheduleNext(sessionId);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------- 会话事件
|
|
464
|
+
|
|
465
|
+
ctx.on("session/event", (session, event) => {
|
|
466
|
+
if (off()) return;
|
|
467
|
+
const sessionId = session?.id;
|
|
468
|
+
if (sessionId === undefined || event === undefined) return;
|
|
469
|
+
|
|
470
|
+
if (event.type === "turn/start") {
|
|
471
|
+
synthetic.delete(sessionId);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (event.type === "tool/result") {
|
|
476
|
+
const code = event.data?.error?.code;
|
|
477
|
+
if (SYNTHETIC_TOOL_CODES.includes(code)) synthetic.set(sessionId, code);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (event.type !== "turn/end") return;
|
|
482
|
+
|
|
483
|
+
const reason = event.data?.reason;
|
|
484
|
+
const sawSynthetic = synthetic.get(sessionId) ?? null;
|
|
485
|
+
synthetic.delete(sessionId);
|
|
486
|
+
|
|
487
|
+
// 正常收尾 / 被按住:这条链到此为止。
|
|
488
|
+
if (reason?.kind === "completed" || reason?.kind === "max-tokens" ||
|
|
489
|
+
reason?.kind === "aborted" || reason?.kind === "blocked" || reason?.kind === "forked") {
|
|
490
|
+
endChain(sessionId);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const hit = classifyInterrupt(reason, sawSynthetic);
|
|
495
|
+
if (hit === null) return;
|
|
496
|
+
|
|
497
|
+
// 拉起的唯一前提:这棵树上还有没做完的活。
|
|
498
|
+
const plan = store.readPlan(sessionId);
|
|
499
|
+
if (plan === null || !pending(plan)) {
|
|
500
|
+
endChain(sessionId);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const turn = event.data?.turn;
|
|
505
|
+
if (IMMEDIATE_CLASSES.includes(hit.class)) {
|
|
506
|
+
onImmediateInterrupt(session, sessionId, turn, hit.class, hit.code);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
onNetworkInterrupt(sessionId, turn, hit.code);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------- agent 状态
|
|
513
|
+
|
|
514
|
+
ctx.on("agent/status", (payload) => {
|
|
515
|
+
if (off()) return;
|
|
516
|
+
const sessionId = payload?.agent?.session?.id;
|
|
517
|
+
if (sessionId === undefined || payload?.status !== "idle") return;
|
|
518
|
+
|
|
519
|
+
// 工具类 / 输出类中断一直在等这个时刻:agent 终于空下来了。
|
|
520
|
+
const queued = waiting.get(sessionId);
|
|
521
|
+
if (queued !== undefined) {
|
|
522
|
+
waiting.delete(sessionId);
|
|
523
|
+
revive(sessionId, queued.klass, queued.code);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// 跨重启:磁盘上还留着没跑完的时刻表,重新排上。
|
|
527
|
+
scheduleNext(sessionId);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// 真人一发消息就清链:人回来了,环境大概也变了,允许重新开一条。
|
|
531
|
+
ctx.on("agent/pre-step", async (payload, next) => {
|
|
532
|
+
const decision = await next();
|
|
533
|
+
const sessionId = payload?.agent?.session?.id;
|
|
534
|
+
if (sessionId === undefined) return decision;
|
|
535
|
+
const fromHuman = (payload?.messages ?? []).some(
|
|
536
|
+
(message) => message?.source?.kind === "user",
|
|
537
|
+
);
|
|
538
|
+
if (fromHuman) endChain(sessionId);
|
|
539
|
+
return decision;
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// 人按了停止:这条链立刻作废,一条都不许再推。
|
|
543
|
+
ctx.on("agent/turn-stopping", (payload) => {
|
|
544
|
+
const sessionId = payload?.agent?.session?.id;
|
|
545
|
+
if (sessionId === undefined) return;
|
|
546
|
+
if (payload?.signal?.aborted === true) endChain(sessionId);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
/** 停掉某个会话的拉起(人按停止时用)。 */
|
|
551
|
+
stop(sessionId) {
|
|
552
|
+
endChain(sessionId);
|
|
553
|
+
},
|
|
554
|
+
/** 立刻尝试拉起一次(给命令用)。 */
|
|
555
|
+
revive(sessionId, kind = "network", code = "MANUAL") {
|
|
556
|
+
return revive(sessionId, kind, code);
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
}
|
package/lib/pause.js
CHANGED
|
@@ -35,6 +35,20 @@
|
|
|
35
35
|
* 唤醒要求该会话已被 DSH 加载成实时 agent(Web 界面打开这个会话就会触发按需
|
|
36
36
|
* resume)。拿不到 agent 时不做任何事,只回一句"先打开它,或在其中发一条消息"。
|
|
37
37
|
*
|
|
38
|
+
* ★ **点「继续」是无条件的。** "接着跑"是人的意思表示,不是"此刻有没有闸门挂着"
|
|
39
|
+
* 的一次查询。所以 `resume()` 的第一件事永远是**解除「被按住」**,然后才谈要不要
|
|
40
|
+
* 主动唤醒。
|
|
41
|
+
*
|
|
42
|
+
* 旧实现把这层意思押在了"此刻 `waiters` 里正好挂着一个"上:没有 waiter 就退到
|
|
43
|
+
* `wakeIdle()`,而它要求 agent 恰好 `idle`——会话正忙(工具还在跑、这一轮还没走到
|
|
44
|
+
* 岔口)时直接拒绝,并且**一个标记都不清**。于是那一次点击被静默吞掉:闸门在下一个
|
|
45
|
+
* 岔口照旧挂上,会话停住,人却以为自己已经点过了。实机就卡在这儿:
|
|
46
|
+
* session-c516e2cc 23:05:59 点了暂停,那个工具 23:07:03 才跑完,中间点的那次
|
|
47
|
+
* 「继续」白点了——等到闸门真挂上时,已经没人在等那一下点击。
|
|
48
|
+
*
|
|
49
|
+
* 唤醒失败也不再反过来把状态留下:留着只会让"我点过了它却还按着我"再来一次。
|
|
50
|
+
* 推不起来的原因照样如实回报(没加载 / 唤醒抛错 / 计划已收尾),人能据它决定下一步。
|
|
51
|
+
*
|
|
38
52
|
* @module dsh-tree-task-flow/pause
|
|
39
53
|
*/
|
|
40
54
|
|
|
@@ -71,6 +85,18 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
|
|
|
71
85
|
store?.patchHeld?.(sessionId, { paused: false });
|
|
72
86
|
}
|
|
73
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 真解除"被按住":暂停与停止两半一起清,内存、磁盘、自动续行压住的那一份都放开。
|
|
90
|
+
* 「继续」走的就是这一条,而且**无条件**(见模块头的 ★)。
|
|
91
|
+
*
|
|
92
|
+
* @param sessionId - 要放开的会话。
|
|
93
|
+
*/
|
|
94
|
+
function clearHeld(sessionId) {
|
|
95
|
+
paused.delete(sessionId);
|
|
96
|
+
store?.patchHeld?.(sessionId, { paused: false, stopped: false });
|
|
97
|
+
autoContinue?.resume?.(sessionId);
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/**
|
|
75
101
|
* 取这个会话的实时 agent。
|
|
76
102
|
*
|
|
@@ -138,7 +164,13 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
|
|
|
138
164
|
};
|
|
139
165
|
}
|
|
140
166
|
if (agent.status !== "idle") {
|
|
141
|
-
|
|
167
|
+
// 正忙不是失败:它自己会在下一个岔口接着跑,本来就不需要谁推它一把。
|
|
168
|
+
// 「被按住」已经由 `resume()` 无条件解除,所以这里什么都不用做。
|
|
169
|
+
return {
|
|
170
|
+
woke: false,
|
|
171
|
+
reason: "busy",
|
|
172
|
+
text: "这个会话正在跑,不用唤醒:被按住的状态已经解除,它会在下一个岔口接着往下跑。",
|
|
173
|
+
};
|
|
142
174
|
}
|
|
143
175
|
const plan = store?.readPlan?.(sessionId) ?? null;
|
|
144
176
|
const active = activePath(plan);
|
|
@@ -215,28 +247,21 @@ export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
|
|
|
215
247
|
return { released: false, woke: false, reason: "no-session", text: "拿不到当前会话。" };
|
|
216
248
|
}
|
|
217
249
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
store?.patchHeld?.(sessionId, { paused: false, stopped: false });
|
|
222
|
-
autoContinue?.resume?.(sessionId);
|
|
223
|
-
};
|
|
250
|
+
// ★ 先无条件解除"被按住",再谈唤醒。理由见模块头那段 ★:点「继续」是一个
|
|
251
|
+
// 意思表示,不该有条件;有条件就会制造"点过了却还按着"的静默卡死。
|
|
252
|
+
clearHeld(sessionId);
|
|
224
253
|
|
|
225
|
-
//
|
|
254
|
+
// 本进程里真挂着闸门:放行那一步——常规路径,**零新增消息**。
|
|
226
255
|
const pending = waiters.get(sessionId);
|
|
227
256
|
if (pending !== undefined && pending.size > 0) {
|
|
228
|
-
clearHeld();
|
|
229
257
|
for (const release of [...pending]) release();
|
|
230
258
|
return { released: true, woke: false, reason: "released", text: "停在岔口上的那一步已经放行。" };
|
|
231
259
|
}
|
|
232
260
|
|
|
233
|
-
// 没有挂着的 waiter
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// (会话没加载 / 正在跑 / 唤醒抛错)必须把状态留着:否则用户既没有「继续」
|
|
237
|
-
// 可再点一次,也看不到为什么没动,只剩一个停在原地的会话。
|
|
261
|
+
// 没有挂着的 waiter:本进程里没有东西可放行(进程重启过,或者这次暂停赶在
|
|
262
|
+
// 闸门挂上之前——那时候点的那一下,旧实现会被吞掉)。退一步,尽最大努力把
|
|
263
|
+
// idle 的会话推起来;推不起来也不改变"已经解除被按住"这个事实。
|
|
238
264
|
const outcome = wakeIdle(sessionId);
|
|
239
|
-
if (outcome.woke === true || outcome.reason === "finished") clearHeld();
|
|
240
265
|
return { released: false, ...outcome };
|
|
241
266
|
},
|
|
242
267
|
/**
|