dsh-tree-task-flow 0.3.5 → 0.3.7
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 +23 -34
- package/client.js +16 -1
- package/lib/auto-continue.js +14 -4
- package/lib/commands.js +8 -6
- package/lib/http.js +4 -1
- package/lib/index.js +1 -1
- package/lib/pause.js +141 -22
- package/lib/region.js +54 -24
- package/lib/store.js +78 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,24 +27,29 @@
|
|
|
27
27
|
规则只有一条,**只看表层,与计划树无关**:
|
|
28
28
|
|
|
29
29
|
```
|
|
30
|
-
|
|
30
|
+
从「上一次 tree_task_done 之后的第一个 create / plan」之后
|
|
31
|
+
→ 到本次 tree_task_done 之前,这一段收走
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
起点是上一次 `tree_task_done` 之后的**第一个** `tree_task_create` / `tree_task_plan`;
|
|
35
|
+
会话里还没有过 `tree_task_done` 时,就是表层第一个 `create` / `plan`。
|
|
36
|
+
**中间的 `create` / `plan` 不再是切点**——它们连同过程一起收走。
|
|
37
|
+
起点那次调用自己的调用**与返回**留在上下文里,本次 `tree_task_done` 的调用与返回
|
|
38
|
+
同样留着(模型靠它们看"这一步做什么、下一步干什么")。
|
|
35
39
|
折叠只收它们**之间**的过程,换成一条计数通告:
|
|
36
40
|
|
|
37
41
|
```
|
|
38
|
-
[
|
|
42
|
+
[tree_task_create 调用] [返回:计划已建立 + 整棵树]
|
|
39
43
|
[tree_task消息:隐藏了 12 条过程上下文。读取:… 写入:…]
|
|
40
44
|
[tree_task_done 调用] [返回:接下来需要进行步骤:「二」(s-4)。]
|
|
41
45
|
```
|
|
42
46
|
|
|
43
47
|
两条硬约束:
|
|
44
48
|
|
|
45
|
-
-
|
|
49
|
+
- 折叠区间里**夹着你的消息就不折**——往前折丢了需求,往后折丢了回应,
|
|
46
50
|
这一段整段跳过,一个节点都不动;
|
|
47
|
-
-
|
|
51
|
+
- 区间**为空**(起点那次调用与 `done` 紧挨着,中间没有过程)也什么都不注入,
|
|
52
|
+
不追加、不留空消息。
|
|
48
53
|
|
|
49
54
|
每一次 `tree_task_done` 的调用与它的返回都**留在上下文里**,一轮轮累积;
|
|
50
55
|
模型下一轮还看得到"我刚做完了什么、接下来干什么"。
|
|
@@ -83,7 +88,8 @@ tree_task消息:接下来需要进行步骤:「步骤二」(s-4)。
|
|
|
83
88
|
| 整棵树都完了 | `tree_task消息:请汇报最终结果。` |
|
|
84
89
|
|
|
85
90
|
两样都**不重复提交内容**(`result` 就在这次调用的参数里)。折叠时,**这次
|
|
86
|
-
`tree_task_done`
|
|
91
|
+
`tree_task_done` 的调用连同它的返回原样留在上下文里**,被收走的只是它**前面**那段过程
|
|
92
|
+
——那一段里若夹着别的 `create` / `plan` 调用,它们也一并收走。
|
|
87
93
|
|
|
88
94
|
**子任务全部结束,不会让父节点自动完成。** 那一刻树进入**检查点**,
|
|
89
95
|
也就是上表第二种返回。
|
|
@@ -226,6 +232,12 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
226
232
|
继续(界面按钮或 `/task-tree resume`)就是从这个岔口放行:模型从它原本要请求的地方
|
|
227
233
|
接着请求,同样**零新增消息**。任务树的进度停在哪儿,就从哪儿接着跑。
|
|
228
234
|
|
|
235
|
+
**重启 `dsh web` 之后暂停依然有效。** "被按住"这件事写在状态目录的 `held/` 下,
|
|
236
|
+
重启后条目仍显示「已暂停」,点「继续」即可接着跑。区别只有一处:进程重启过之后,
|
|
237
|
+
没有挂在岔口上的那一口气可放,插件只能给会话排一条「恢复续行」的消息把它从 `idle`
|
|
238
|
+
推起来——**只有跨过重启的这一次恢复会留下这条消息**。若那个会话还没被打开(DSH 还没把它
|
|
239
|
+
加载成实时 agent),点继续会提示你先打开它,并且**保留暂停状态**,不会白丢。
|
|
240
|
+
|
|
229
241
|
闸门开在 agent 的 `agent/pre-step` 上——一个 step 走完、工具结果都已落盘、模型还没
|
|
230
242
|
发起下一次请求的那一刻。这是唯一同时满足"不打断工具"和"不留痕迹"的位置:再晚一点
|
|
231
243
|
的 `agent/turn-stopping` 一旦放行,就得靠 `steer()` 推一条消息才能续上,那就留下痕迹了。
|
|
@@ -276,8 +288,10 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
276
288
|
空 `result` 会被拒绝。所以要写清"产出了什么",写成能独立看懂的样子——
|
|
277
289
|
过程会丢,别把过程写进去。
|
|
278
290
|
|
|
279
|
-
2. **折叠只看表层。**
|
|
280
|
-
|
|
291
|
+
2. **折叠只看表层。** 起点是上一次 `done` 之后的第一个 `create` / `plan`,
|
|
292
|
+
中间的 `create` / `plan` 不再是切点,会连同过程一起收走;区间里没有过程时
|
|
293
|
+
什么都不注入,夹着你的消息时整段不折——那一段原样留着(往前折丢需求、
|
|
294
|
+
往后折丢回应),代价是上下文不收缩。
|
|
281
295
|
|
|
282
296
|
3. **模型看到的工具说明不随计划变化,也不复述工具清单。** 那一段只讲什么时候用
|
|
283
297
|
这组工具、完成时守什么规矩;每个工具的用法由它自己的 `description` 承担。
|
|
@@ -305,26 +319,6 @@ dsh --profile web --dump-config # 输出里应出现 task-tree 这一
|
|
|
305
319
|
| 并行推进多个目标 | 结构上支持一个会话有多个目标(目标层就是兄弟列表),但同一时刻只沿一条活动路径走 |
|
|
306
320
|
| 把计划内容注入提示词 | 计划状态由 `tree_task_status` 现取,提示词里只放不随计划变化的说明 |
|
|
307
321
|
|
|
308
|
-
## 自检
|
|
309
|
-
|
|
310
|
-
六个自检脚本都不 import 任何 `@deepseek-ai/*` 运行时包——插件以 `link:` 方式安装时,
|
|
311
|
-
从插件目录往上找不到宿主作用域的包,一旦 import 就根本跑不起来。
|
|
312
|
-
`verify-client.mjs` 因此自带一套最小的 `window` / `document` / React 替身,
|
|
313
|
-
真去物化 `client.js`、真调一次 `apply`、真渲染一遍条目。
|
|
314
|
-
|
|
315
|
-
```powershell
|
|
316
|
-
node verify-entry.mjs # 入口装配:默认(安装即启用)/ 显式关闭两条路径各注册了什么
|
|
317
|
-
node verify-tools.mjs # 工具层:schema、result 必填、三级约束
|
|
318
|
-
node verify-region.mjs # 折叠行为:游标推进、降级追加、检查点提示
|
|
319
|
-
node verify-plan.mjs # 计划树纯逻辑:状态流转、自动完成、丢弃级联
|
|
320
|
-
node verify-pause.mjs # 暂停闸门:挂起、放行、取消信号、真人发言、会话隔离
|
|
321
|
-
node verify-client.mjs # 客户端条目:挂载点、样式注入、文案键集、四种渲染态
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
`verify-region.mjs` 用假 session 忠实复刻了 DSH surface 的三条硬规则
|
|
325
|
-
(替换保持位置、`sourceEventSeqs` 必须覆盖全部遮蔽节点、节点 0 重写约束),
|
|
326
|
-
否则测出来的东西不算数。
|
|
327
|
-
|
|
328
322
|
## 卸载
|
|
329
323
|
|
|
330
324
|
```powershell
|
|
@@ -333,11 +327,6 @@ dsh plugin --profile web remove dsh-tree-task-flow
|
|
|
333
327
|
|
|
334
328
|
状态目录不会自动清理,需要的话手动删 `$DSH_HOME/dsh-task-tree`。
|
|
335
329
|
|
|
336
|
-
## 设计文档
|
|
337
|
-
|
|
338
|
-
- `docs/02-DSH插件开发方案.md` —— 完整设计:上下文组成、游标、折叠、检查点
|
|
339
|
-
- `docs/01-AIDesktop联动设计整理.md` —— 本插件最初的设计来源
|
|
340
|
-
|
|
341
330
|
## 许可
|
|
342
331
|
|
|
343
332
|
MIT
|
package/client.js
CHANGED
|
@@ -666,11 +666,24 @@ window.__ModuleLoader__.load({
|
|
|
666
666
|
return () => clearInterval(timer);
|
|
667
667
|
}, [sessionId, reload]);
|
|
668
668
|
|
|
669
|
+
// 这次操作的结果说明(已放行 / 已唤醒 / 为什么没动):恢复失败时用户最需要
|
|
670
|
+
// 的就是这句话,不能只留在接口的返回里。
|
|
671
|
+
const [note, setNote] = useState(null);
|
|
672
|
+
useEffect(() => {
|
|
673
|
+
if (!note) return undefined;
|
|
674
|
+
const timer = setTimeout(() => setNote(null), 8000);
|
|
675
|
+
return () => clearTimeout(timer);
|
|
676
|
+
}, [note]);
|
|
677
|
+
|
|
669
678
|
const act = useCallback(
|
|
670
679
|
async (body) => {
|
|
671
680
|
setBusy(true);
|
|
681
|
+
setNote(null);
|
|
672
682
|
try {
|
|
673
|
-
await post(Object.assign({ sessionId }, body));
|
|
683
|
+
const result = await post(Object.assign({ sessionId }, body));
|
|
684
|
+
if (result && typeof result.message === "string" && result.message !== "") {
|
|
685
|
+
setNote(result.message);
|
|
686
|
+
}
|
|
674
687
|
await reload();
|
|
675
688
|
} finally {
|
|
676
689
|
setBusy(false);
|
|
@@ -747,6 +760,8 @@ window.__ModuleLoader__.load({
|
|
|
747
760
|
}),
|
|
748
761
|
),
|
|
749
762
|
),
|
|
763
|
+
// 这次操作的结果与原因(已放行 / 已唤醒 / 为什么没动)。
|
|
764
|
+
note ? h("div", { className: "dsh-ttf-note" }, note) : null,
|
|
750
765
|
open
|
|
751
766
|
? h(
|
|
752
767
|
"div",
|
package/lib/auto-continue.js
CHANGED
|
@@ -37,10 +37,19 @@ export function registerAutoContinue(ctx, { store, cfg }) {
|
|
|
37
37
|
*/
|
|
38
38
|
const stopped = new Set();
|
|
39
39
|
|
|
40
|
+
/** 这个会话是不是被"停止"按住:内存说是,或磁盘说是。 */
|
|
41
|
+
function heldNow(sessionId) {
|
|
42
|
+
return stopped.has(sessionId) || store?.readHeld?.(sessionId)?.stopped === true;
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
ctx.on("agent/turn-stopping", (payload) => {
|
|
41
46
|
const sessionId = payload?.agent?.session?.id;
|
|
42
47
|
if (sessionId === undefined) return;
|
|
43
|
-
if (payload?.signal?.aborted === true)
|
|
48
|
+
if (payload?.signal?.aborted === true) {
|
|
49
|
+
stopped.add(sessionId);
|
|
50
|
+
// 落盘:重启后条目上仍看得到「继续」,也仍然压得住自动续行。
|
|
51
|
+
store?.patchHeld?.(sessionId, { stopped: true });
|
|
52
|
+
}
|
|
44
53
|
});
|
|
45
54
|
|
|
46
55
|
// 只有**真人**发的消息才重置配额。
|
|
@@ -62,6 +71,7 @@ export function registerAutoContinue(ctx, { store, cfg }) {
|
|
|
62
71
|
rounds.set(sessionId, 0);
|
|
63
72
|
// 人回来了,解除中断标记,允许新一轮的续行。
|
|
64
73
|
stopped.delete(sessionId);
|
|
74
|
+
store?.patchHeld?.(sessionId, { stopped: false });
|
|
65
75
|
}
|
|
66
76
|
return decision;
|
|
67
77
|
});
|
|
@@ -75,7 +85,7 @@ export function registerAutoContinue(ctx, { store, cfg }) {
|
|
|
75
85
|
if (agent === undefined || sessionId === undefined) return;
|
|
76
86
|
|
|
77
87
|
// ★ 安全阀 0:这一轮是被用户**中止**的 → 绝不能把它拉起来。
|
|
78
|
-
if (
|
|
88
|
+
if (heldNow(sessionId)) return;
|
|
79
89
|
|
|
80
90
|
// 安全阀 1:已经有排队输入,别抢在用户前面。
|
|
81
91
|
const inbox = agent.inbox;
|
|
@@ -116,9 +126,9 @@ export function registerAutoContinue(ctx, { store, cfg }) {
|
|
|
116
126
|
resume(sessionId) {
|
|
117
127
|
if (sessionId !== undefined) stopped.delete(sessionId);
|
|
118
128
|
},
|
|
119
|
-
/**
|
|
129
|
+
/** 这个会话当前是不是处于被停止状态(跨重启)。 */
|
|
120
130
|
isStopped(sessionId) {
|
|
121
|
-
return
|
|
131
|
+
return heldNow(sessionId);
|
|
122
132
|
},
|
|
123
133
|
};
|
|
124
134
|
}
|
package/lib/commands.js
CHANGED
|
@@ -90,18 +90,20 @@ export function registerCommands(ctx, { store, cfg, pause = null, enabled = fals
|
|
|
90
90
|
kind: "success",
|
|
91
91
|
text:
|
|
92
92
|
"已暂停本会话:正在执行的那个工具会正常跑完,之后不再往下推进。" +
|
|
93
|
-
"任务树的进度没有变,点继续就从停住的地方接着跑。"
|
|
93
|
+
"任务树的进度没有变,点继续就从停住的地方接着跑。" +
|
|
94
|
+
"这个暂停会写进状态目录,重启 dsh web 之后依然有效。",
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
if (verb === "resume") {
|
|
98
|
-
pause?.resume?.(sessionId);
|
|
99
|
+
const outcome = pause?.resume?.(sessionId) ?? null;
|
|
100
|
+
const tail =
|
|
101
|
+
cfg.autoContinue === true
|
|
102
|
+
? "自动续行也重新允许了。"
|
|
103
|
+
: "配置里 autoContinue 是 false,所以模型自己停下后不会再被自动拉起。";
|
|
99
104
|
return {
|
|
100
105
|
kind: "success",
|
|
101
|
-
text:
|
|
102
|
-
cfg.autoContinue === true
|
|
103
|
-
? "已继续:停在岔口上的那一步已经放行,自动续行也重新允许了。"
|
|
104
|
-
: "已继续:停在岔口上的那一步已经放行。配置里 autoContinue 是 false,所以模型自己停下后不会再被自动拉起。",
|
|
106
|
+
text: `已继续:${outcome?.text ?? "停在岔口上的那一步已经放行。"}${tail}`,
|
|
105
107
|
};
|
|
106
108
|
}
|
|
107
109
|
|
package/lib/http.js
CHANGED
|
@@ -150,11 +150,14 @@ export function registerHttp(ctx, { store, autoContinue, pause = null }) {
|
|
|
150
150
|
// 暂停 / 继续。暂停会同时压住自动续行兜底,两个状态一起回传,
|
|
151
151
|
// 免得条目还要再问一次。
|
|
152
152
|
if (action === "pause" || action === "resume") {
|
|
153
|
-
pause?.[action]?.(sessionId);
|
|
153
|
+
const outcome = pause?.[action]?.(sessionId) ?? null;
|
|
154
154
|
return sendJson(response, 200, {
|
|
155
155
|
ok: true,
|
|
156
156
|
paused: pause?.isPaused?.(sessionId) === true,
|
|
157
157
|
stopped: autoContinue?.isStopped?.(sessionId) === true,
|
|
158
|
+
// 跨重启的那次「继续」可能什么都没能唤醒,把话说给界面听。
|
|
159
|
+
message: typeof outcome?.text === "string" ? outcome.text : null,
|
|
160
|
+
woke: outcome?.woke === true,
|
|
158
161
|
});
|
|
159
162
|
}
|
|
160
163
|
|
package/lib/index.js
CHANGED
|
@@ -106,7 +106,7 @@ export function apply(ctx, config) {
|
|
|
106
106
|
|
|
107
107
|
// 暂停闸门:挂在 `agent/pre-step` 上,人点暂停就停在"上一个工具跑完、
|
|
108
108
|
// 下一次模型请求还没发出"那个岔口。它同时压住上面的自动续行兜底。
|
|
109
|
-
const pause = registerPause(ctx, { autoContinue });
|
|
109
|
+
const pause = registerPause(ctx, { store, autoContinue });
|
|
110
110
|
|
|
111
111
|
// /task-tree 命令:查看与管理任务树,也是"暂停 / 继续"的开关。
|
|
112
112
|
registerCommands(ctx, { store, cfg, pause, enabled: true });
|
package/lib/pause.js
CHANGED
|
@@ -15,29 +15,44 @@
|
|
|
15
15
|
* - 消息已被 claim 但尚未提交,轮次也还开着——放行后模型从原处接着请求,
|
|
16
16
|
* 上下文里**零新增消息**。
|
|
17
17
|
*
|
|
18
|
-
* 所以本模块做的事只有一件:在这个岔口上挂住,等人点继续。
|
|
19
|
-
*
|
|
20
|
-
* 相比之下另外两个候选都不合适:
|
|
21
|
-
* - `agent/turn-stopping` 虽然也是被 await 的,但轮次紧接着就会关闭
|
|
22
|
-
* (`agent-loop` 里 `if (turnEnds && this.inbox.nextStep.length === 0) break;`),
|
|
23
|
-
* 想让它继续跑就必须 `steer()` 一条消息进去——那就留下了痕迹。
|
|
24
|
-
* - 工具结果里的 `concludesTurn: true` 只是提前收尾,同样得靠新输入才能再启动。
|
|
25
|
-
*
|
|
26
18
|
* ★ **闸门只拦自动推进,不拦真人发言。** 暂停期间人自己发了消息,说明人要它跑,
|
|
27
19
|
* 这时既不放闸也不该继续端着:直接放行,并顺手解除暂停。
|
|
28
20
|
*
|
|
21
|
+
* ## 重启之后(本模块与"零痕迹"的唯一分歧点)
|
|
22
|
+
*
|
|
23
|
+
* 闸门挂住的是进程里的一个 Promise。**重启会把"挂住"这件事本身抹掉**:
|
|
24
|
+
* 没有 waiter 可放行,也没有标记可清,会话就停在 `idle` 上再也起不来。
|
|
25
|
+
*
|
|
26
|
+
* 所以"被按住"这件事**落盘**(`held/<sessionId>.json`,见 store):
|
|
27
|
+
*
|
|
28
|
+
* - 重启后 `isPaused` 依旧为真 → 条目上仍有「继续」按钮,人找得到入口;
|
|
29
|
+
* - 闸门也按落盘的 `paused` 判定 → 重启后若还有别的力量(比如自动续行)把会话
|
|
30
|
+
* 推起来,它照样会在下一个岔口被按住;
|
|
31
|
+
* - 点继续时本进程里没有 waiter,就退一步用 `agent.followup()` 把会话从 `idle`
|
|
32
|
+
* 唤醒。**这一次恢复会往会话里排一条续行消息**——常规暂停/继续仍然零新增消息,
|
|
33
|
+
* 只有跨过重启的那一次不同,因为那是唯一能真正把它跑起来的手段。
|
|
34
|
+
*
|
|
35
|
+
* 唤醒要求该会话已被 DSH 加载成实时 agent(Web 界面打开这个会话就会触发按需
|
|
36
|
+
* resume)。拿不到 agent 时不做任何事,只回一句"先打开它,或在其中发一条消息"。
|
|
37
|
+
*
|
|
29
38
|
* @module dsh-tree-task-flow/pause
|
|
30
39
|
*/
|
|
31
40
|
|
|
41
|
+
import { activePath, renderContinue } from "./plan.js";
|
|
42
|
+
|
|
43
|
+
/** 本插件放下的消息都带这个身份。 */
|
|
44
|
+
const PLUGIN = "dsh-tree-task-flow";
|
|
45
|
+
|
|
32
46
|
/**
|
|
33
47
|
* 注册暂停闸门。
|
|
34
48
|
*
|
|
35
49
|
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
36
|
-
* @param options - `{ autoContinue }
|
|
37
|
-
*
|
|
50
|
+
* @param options - `{ store, autoContinue }`:`store` 是状态读写端口;
|
|
51
|
+
* `autoContinue` 是自动续行兜底的控制接口,可为 null——暂停要同时压住它,
|
|
52
|
+
* 否则模型刚停下、兜底就把会话又 followup 拉起来了。
|
|
38
53
|
*/
|
|
39
|
-
export function registerPause(ctx, { autoContinue = null } = {}) {
|
|
40
|
-
/**
|
|
54
|
+
export function registerPause(ctx, { store = null, autoContinue = null } = {}) {
|
|
55
|
+
/** 本进程内处于暂停态的会话。 */
|
|
41
56
|
const paused = new Set();
|
|
42
57
|
/**
|
|
43
58
|
* 正挂在 pre-step 上的会话 → 唤醒它们的那些函数。
|
|
@@ -48,6 +63,27 @@ export function registerPause(ctx, { autoContinue = null } = {}) {
|
|
|
48
63
|
*/
|
|
49
64
|
const waiters = new Map();
|
|
50
65
|
|
|
66
|
+
/** 这个会话是不是被按住:内存说是,或磁盘说是。 */
|
|
67
|
+
function heldNow(sessionId) {
|
|
68
|
+
return paused.has(sessionId) || store?.readHeld?.(sessionId)?.paused === true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 只清"暂停"这一半;"停止"那一半归 auto-continue 管。 */
|
|
72
|
+
function clearPaused(sessionId) {
|
|
73
|
+
store?.patchHeld?.(sessionId, { paused: false });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 取这个会话的实时 agent。
|
|
78
|
+
*
|
|
79
|
+
* 用 `ctx.get()` 反射式取,不写进 `inject`:拿不到可选服务时 cordis 会直接抛错,
|
|
80
|
+
* 那会让整个插件加载失败。取不到就退回"提示用户自己发消息"。
|
|
81
|
+
*/
|
|
82
|
+
function liveAgent(sessionId) {
|
|
83
|
+
const agents = ctx.get?.("agents");
|
|
84
|
+
return agents?.get?.(sessionId) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
51
87
|
/**
|
|
52
88
|
* 挂起当前会话,直到有人点继续、或这一轮被取消。
|
|
53
89
|
*
|
|
@@ -81,11 +117,62 @@ export function registerPause(ctx, { autoContinue = null } = {}) {
|
|
|
81
117
|
});
|
|
82
118
|
}
|
|
83
119
|
|
|
120
|
+
/**
|
|
121
|
+
* 重启之后的那次"继续":本进程里没有 waiter 可放行,只能把 idle 的会话推起来。
|
|
122
|
+
*
|
|
123
|
+
* 只有"会话已被加载 + 停在 idle + 计划里还有**未结束的节点**"三条同时成立才推——
|
|
124
|
+
* 正在跑的会话不需要推,整棵树已经收尾的会话不该被推。
|
|
125
|
+
*
|
|
126
|
+
* 注意别把"当前任务还没拆子任务"和"检查点"当成收尾:前者要模型去 `tree_task_plan`,
|
|
127
|
+
* 后者要模型拍板这一层到底完成没有,两者都还是活的。只有 `activePath()` 返回 null
|
|
128
|
+
* (整棵树都结束了)才真的没得跑。
|
|
129
|
+
*
|
|
130
|
+
* @param sessionId - 要唤醒的会话。
|
|
131
|
+
* @returns `{ woke, reason, text }`:`text` 是给命令与界面看的一句话。
|
|
132
|
+
*/
|
|
133
|
+
function wakeIdle(sessionId) {
|
|
134
|
+
const agent = liveAgent(sessionId);
|
|
135
|
+
if (agent === null) {
|
|
136
|
+
return {
|
|
137
|
+
woke: false,
|
|
138
|
+
reason: "unloaded",
|
|
139
|
+
text: "这个会话还没被加载:先在界面上打开它,或在其中发一条消息。",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (agent.status !== "idle") {
|
|
143
|
+
return { woke: false, reason: "busy", text: "这个会话正在跑,不需要唤醒。" };
|
|
144
|
+
}
|
|
145
|
+
const plan = store?.readPlan?.(sessionId) ?? null;
|
|
146
|
+
const active = activePath(plan);
|
|
147
|
+
if (active === null) {
|
|
148
|
+
return { woke: false, reason: "finished", text: "计划树上的节点都已结束,没有什么可以接着跑的。" };
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
agent.followup({
|
|
152
|
+
id: `tt-resume-${sessionId}-${Date.now().toString(36)}`,
|
|
153
|
+
role: "user",
|
|
154
|
+
content: [{ type: "text", text: renderContinue(plan) }],
|
|
155
|
+
source: { kind: "plugin", plugin: PLUGIN, form: "notice", summary: "恢复续行" },
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
return {
|
|
159
|
+
woke: false,
|
|
160
|
+
reason: "error",
|
|
161
|
+
text: `唤醒失败:${String(error?.message ?? error)}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
woke: true,
|
|
166
|
+
reason: "woke",
|
|
167
|
+
text: "已把会话从停住的地方推起来(跨重启的这次恢复会往会话里排一条续行消息)。",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
84
171
|
// waterfall:必须 await next() 并把它返回,否则会截断监听器链。
|
|
85
172
|
// auto-continue.js 也注册了同一个事件(重置续行配额),两条互不干扰。
|
|
86
173
|
ctx.on("agent/pre-step", async (payload, next) => {
|
|
87
174
|
const sessionId = payload?.agent?.session?.id;
|
|
88
|
-
if (sessionId === undefined || !
|
|
175
|
+
if (sessionId === undefined || !heldNow(sessionId)) return next();
|
|
89
176
|
|
|
90
177
|
// 真人发言 = 人要它继续跑。放行,并且不再端着暂停态。
|
|
91
178
|
const fromHuman = (payload?.messages ?? []).some(
|
|
@@ -93,6 +180,7 @@ export function registerPause(ctx, { autoContinue = null } = {}) {
|
|
|
93
180
|
);
|
|
94
181
|
if (fromHuman) {
|
|
95
182
|
paused.delete(sessionId);
|
|
183
|
+
clearPaused(sessionId);
|
|
96
184
|
return next();
|
|
97
185
|
}
|
|
98
186
|
|
|
@@ -110,29 +198,60 @@ export function registerPause(ctx, { autoContinue = null } = {}) {
|
|
|
110
198
|
pause(sessionId) {
|
|
111
199
|
if (sessionId === undefined) return;
|
|
112
200
|
paused.add(sessionId);
|
|
201
|
+
store?.patchHeld?.(sessionId, { paused: true });
|
|
113
202
|
autoContinue?.stop?.(sessionId);
|
|
114
203
|
},
|
|
115
204
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
205
|
+
* 继续某个会话。
|
|
206
|
+
*
|
|
207
|
+
* 分两种情况:
|
|
208
|
+
* - 本进程里真挂着(常规暂停)→ 放行那一步,**零新增消息**;
|
|
209
|
+
* - 没有挂着的 waiter(进程重启过)→ 退一步唤醒 idle 的会话,
|
|
210
|
+
* 这一次会往会话里排一条续行消息。
|
|
118
211
|
*
|
|
119
212
|
* @param sessionId - 要放行的会话。
|
|
213
|
+
* @returns `{ released, woke, reason, text }`。
|
|
120
214
|
*/
|
|
121
215
|
resume(sessionId) {
|
|
122
|
-
if (sessionId === undefined)
|
|
123
|
-
|
|
216
|
+
if (sessionId === undefined) {
|
|
217
|
+
return { released: false, woke: false, reason: "no-session", text: "拿不到当前会话。" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 真解除"被按住":暂停与停止两半一起清,连自动续行的压住也放开。 */
|
|
221
|
+
const clearHeld = () => {
|
|
222
|
+
paused.delete(sessionId);
|
|
223
|
+
store?.patchHeld?.(sessionId, { paused: false, stopped: false });
|
|
224
|
+
autoContinue?.resume?.(sessionId);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// 本进程里真挂着:放行那一步——这是常规路径,**零新增消息**。
|
|
124
228
|
const pending = waiters.get(sessionId);
|
|
125
|
-
if (pending !== undefined
|
|
126
|
-
|
|
229
|
+
if (pending !== undefined && pending.size > 0) {
|
|
230
|
+
clearHeld();
|
|
231
|
+
for (const release of [...pending]) release();
|
|
232
|
+
return { released: true, woke: false, reason: "released", text: "停在岔口上的那一步已经放行。" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 没有挂着的 waiter(进程重启过):退一步唤醒 idle 的会话。
|
|
236
|
+
//
|
|
237
|
+
// **只有真把它推起来、或计划确实已经收尾,才解除"被按住"。** 没成功时
|
|
238
|
+
// (会话没加载 / 正在跑 / 唤醒抛错)必须把状态留着:否则用户既没有「继续」
|
|
239
|
+
// 可再点一次,也看不到为什么没动,只剩一个停在原地的会话。
|
|
240
|
+
const outcome = wakeIdle(sessionId);
|
|
241
|
+
if (outcome.woke === true || outcome.reason === "finished") clearHeld();
|
|
242
|
+
return { released: false, ...outcome };
|
|
127
243
|
},
|
|
128
244
|
/**
|
|
129
|
-
*
|
|
245
|
+
* 这个会话当前是不是被按住(暂停)。
|
|
246
|
+
*
|
|
247
|
+
* 读的是内存加磁盘的并集:重启后内存里的那份没了,磁盘上的还在,
|
|
248
|
+
* 否则界面上连「继续」按钮都不会画出来。
|
|
130
249
|
*
|
|
131
250
|
* @param sessionId - 要查询的会话。
|
|
132
|
-
* @returns
|
|
251
|
+
* @returns 被按住为 true。
|
|
133
252
|
*/
|
|
134
253
|
isPaused(sessionId) {
|
|
135
|
-
return
|
|
254
|
+
return heldNow(sessionId);
|
|
136
255
|
},
|
|
137
256
|
};
|
|
138
257
|
}
|
package/lib/region.js
CHANGED
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 规则只有一条,**只看表层,与计划树无关**:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* 从「上一次 `tree_task_done` 之后的第一个 `create` / `plan`」之后,
|
|
7
|
+
* 到本次 `tree_task_done` 之前,中间那一段过程收走。
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* 更早没有 `tree_task_done` 时,起点取表层第一个 `create` / `plan`;区间里一次
|
|
10
|
+
* `create` / `plan` 都没有时,回退到上一次 `tree_task_done` 之后(更早没有 `done`
|
|
11
|
+
* 则是表层开头)。起点那次调用、以及本次 `done` 的调用与返回,都留在表层上——
|
|
12
|
+
* 那是模型看"计划是什么、下一步干什么"的地方;被跨过的其它 `create` / `plan`
|
|
13
|
+
* 连同过程一起收走。
|
|
14
|
+
*
|
|
15
|
+
* 判定表见 `docs/06-折叠范围规则.md`。
|
|
11
16
|
*
|
|
12
17
|
* 两条硬约束:
|
|
13
18
|
*
|
|
@@ -18,11 +23,13 @@
|
|
|
18
23
|
*
|
|
19
24
|
* [节点 0] 系统提示词
|
|
20
25
|
* [用户消息]
|
|
21
|
-
* [tree_task_create 调用]
|
|
22
|
-
* [tree_task_plan 调用] [返回:已添加 2 个子任务 + 整棵树]
|
|
26
|
+
* [tree_task_create 调用] [返回:计划已建立 + 整棵树]
|
|
23
27
|
* [tree_task消息:隐藏了 12 条过程上下文。读取:… 写入:…]
|
|
24
|
-
* [tree_task_done 调用]
|
|
25
|
-
* [当前这一段执行过程]
|
|
28
|
+
* [tree_task_done 调用] [返回:接下来需要进行步骤:「二」(s-4)。]
|
|
29
|
+
* [当前这一段执行过程] ← 下一次折叠的对象
|
|
30
|
+
*
|
|
31
|
+
* 这一段过程里原本还有一次 `tree_task_plan` 的调用与返回——它夹在 create 与 done 之间,
|
|
32
|
+
* 不是起点,于是随这一条通告一起收走了。
|
|
26
33
|
*
|
|
27
34
|
* 时机是 `agent/pre-step`:上一轮已结束、下一个请求还没构造。工具执行中途不能折叠
|
|
28
35
|
* ——那一刻 surface 还在增长,替换会把自己卷进去。
|
|
@@ -182,15 +189,22 @@ function summaryLine(count, files) {
|
|
|
182
189
|
return touched === 0 ? base : `${base} · 涉及 ${touched} 个文件`;
|
|
183
190
|
}
|
|
184
191
|
|
|
192
|
+
/** 这条 assistant 消息里有没有 `tree_task_create` / `tree_task_plan`。 */
|
|
193
|
+
function isPlanCall(event) {
|
|
194
|
+
const names = toolNamesOf(event);
|
|
195
|
+
return names.includes("tree_task_create") || names.includes("tree_task_plan");
|
|
196
|
+
}
|
|
197
|
+
|
|
185
198
|
/**
|
|
186
199
|
* 找这一次要折叠的区间。
|
|
187
200
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
201
|
+
* 从末尾往前找最近的一次边界调用,**它必须是 `tree_task_done`**(它就是这次完成的
|
|
202
|
+
* 那一下);再往前找上一次 `tree_task_done`,起点就是**它之后最早的那次
|
|
203
|
+
* `create` / `plan`**——更早没有 `done` 时,起点是表层第一个 `create` / `plan`。
|
|
204
|
+
* 中间的 `create` / `plan` 不是切点:它们连同过程一起被收走。
|
|
191
205
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
206
|
+
* 区间里一次 `create` / `plan` 都没有时,起点退回上一次 `done` 之后(更早没有 `done`
|
|
207
|
+
* 则是表层第一个节点之后)——那时区间多半会撞上真人消息,于是整段不折,这是安全的那一侧。
|
|
194
208
|
*
|
|
195
209
|
* @param session - 当前会话。
|
|
196
210
|
* @returns `{ from, to, shadowed }`;这一轮不该折时返回 null。
|
|
@@ -199,26 +213,42 @@ export function foldRange(session) {
|
|
|
199
213
|
const nodes = session.surface?.nodes ?? [];
|
|
200
214
|
if (typeof session.eventAt !== "function" || nodes.length === 0) return null;
|
|
201
215
|
|
|
216
|
+
// 终点:最近的一次边界调用,必须是一次 done。
|
|
202
217
|
let endIdx = -1;
|
|
203
|
-
let startIdx = -1;
|
|
204
218
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
|
205
219
|
if (!isBoundaryCall(session.eventAt(nodes[index]))) continue;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
endIdx = index;
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
startIdx = index;
|
|
220
|
+
// 末尾最近的那次边界调用不是 done:说明还没走到"完成"这一步,这一轮不折。
|
|
221
|
+
if (!isDoneCall(session.eventAt(nodes[index]))) return null;
|
|
222
|
+
endIdx = index;
|
|
213
223
|
break;
|
|
214
224
|
}
|
|
215
225
|
if (endIdx === -1) return null;
|
|
216
226
|
|
|
217
|
-
//
|
|
218
|
-
|
|
227
|
+
// 上一次 done 的位置;更早没有 done 时是 -1,起点就从头算。
|
|
228
|
+
let prevDoneIdx = -1;
|
|
229
|
+
for (let index = endIdx - 1; index >= 0; index -= 1) {
|
|
230
|
+
if (isDoneCall(session.eventAt(nodes[index]))) {
|
|
231
|
+
prevDoneIdx = index;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 起点:上一次 done 之后最早的那次 create / plan。
|
|
237
|
+
let startIdx = -1;
|
|
238
|
+
for (let index = prevDoneIdx + 1; index < endIdx; index += 1) {
|
|
239
|
+
if (isPlanCall(session.eventAt(nodes[index]))) {
|
|
240
|
+
startIdx = index;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// 区间里没有 create / plan:退回上一次 done 之后(更早没有 done 就是表层开头)。
|
|
245
|
+
if (startIdx === -1) startIdx = prevDoneIdx;
|
|
246
|
+
|
|
247
|
+
// 区间从起点调用的 `tool/result` 之后开始;找不到起点(-1)就退回表层第一个节点之后
|
|
248
|
+
// (节点 0 是系统提示词,受 DSH 的重写保护,不能进替换区间)。
|
|
219
249
|
//
|
|
220
250
|
// 还要跳过紧邻的两样东西:那次调用自己的 `tool/result`,以及**上一次折叠落下的通告**
|
|
221
|
-
//
|
|
251
|
+
// ——后者虽然夹在区间里,却已经是收过的结果,再收一次就会一轮轮空折下去。
|
|
222
252
|
let from = startIdx === -1 ? 1 : startIdx + 1;
|
|
223
253
|
while (from < endIdx) {
|
|
224
254
|
const event = session.eventAt(nodes[from]);
|
package/lib/store.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 插件自有状态的读写。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 三类状态,都**以 `sessionId` 为键**:
|
|
5
5
|
*
|
|
6
6
|
* - `plans/<sessionId>.json` 计划树
|
|
7
|
-
* - `surfaces/<sessionId>.json`
|
|
7
|
+
* - `surfaces/<sessionId>.json` 三层的折叠游标(只给界面看,不参与折叠)
|
|
8
|
+
* - `held/<sessionId>.json` 「被按住」:暂停 / 停止,**要跨重启保留**
|
|
8
9
|
*
|
|
9
10
|
* 计划属于建立它的那个会话。放到会话无关的位置,任何一个会话的计划都会被
|
|
10
11
|
* 所有会话的 `agent/pre-step` 读到——那等于把不相干的节点指派塞进别人的上下文,
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
* @module dsh-tree-task-flow/store
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
26
27
|
import { homedir } from "node:os";
|
|
27
28
|
import { dirname, join } from "node:path";
|
|
28
29
|
|
|
@@ -81,6 +82,35 @@ function normalizePlan(plan) {
|
|
|
81
82
|
export function createStore(root) {
|
|
82
83
|
const file = (...parts) => join(root, ...parts);
|
|
83
84
|
|
|
85
|
+
/** 「被按住」状态的文件路径。 */
|
|
86
|
+
const heldFile = (sessionId) => file("held", `${sessionId}.json`);
|
|
87
|
+
|
|
88
|
+
/** 读「被按住」状态;读不到或文件坏了,一律按"没被按住"处理。 */
|
|
89
|
+
function readHeldState(sessionId) {
|
|
90
|
+
if (!sessionId) return { paused: false, stopped: false, at: null };
|
|
91
|
+
const value = readJson(heldFile(sessionId), null);
|
|
92
|
+
if (value === null || typeof value !== "object") {
|
|
93
|
+
return { paused: false, stopped: false, at: null };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
paused: value.paused === true,
|
|
97
|
+
stopped: value.stopped === true,
|
|
98
|
+
at: typeof value.at === "number" ? value.at : null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 写「被按住」状态;两份都不是 true 就把文件删掉,不留空壳。 */
|
|
103
|
+
function writeHeldState(sessionId, value) {
|
|
104
|
+
if (!sessionId) return;
|
|
105
|
+
const paused = value?.paused === true;
|
|
106
|
+
const stopped = value?.stopped === true;
|
|
107
|
+
if (!paused && !stopped) {
|
|
108
|
+
rmSync(heldFile(sessionId), { force: true });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
writeJson(heldFile(sessionId), { sessionId, paused, stopped, at: Date.now() });
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
return {
|
|
85
115
|
/** 状态根目录。 */
|
|
86
116
|
root,
|
|
@@ -136,5 +166,50 @@ export function createStore(root) {
|
|
|
136
166
|
writeSurfaces(sessionId, value) {
|
|
137
167
|
writeJson(file("surfaces", `${sessionId}.json`), value);
|
|
138
168
|
},
|
|
169
|
+
|
|
170
|
+
// ---- 「被按住」:暂停 / 停止。**要跨重启保留**,所以落盘 ----
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 读「被按住」状态。
|
|
174
|
+
*
|
|
175
|
+
* 暂停闸门挂住的是进程里的一个 Promise,重启就把"挂住"这件事本身抹掉了,
|
|
176
|
+
* 于是既没有 waiter 可放行、也没有标记可清。这份状态因此必须落盘——
|
|
177
|
+
* 否则重启后条目看不到「继续」按钮,人连恢复的入口都没有。
|
|
178
|
+
*
|
|
179
|
+
* @param sessionId - 会话 id。
|
|
180
|
+
* @returns `{ paused, stopped, at }`;`at` 是最后一次写入的时间,没有则为 null。
|
|
181
|
+
*/
|
|
182
|
+
readHeld(sessionId) {
|
|
183
|
+
return readHeldState(sessionId);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 写「被按住」状态。
|
|
188
|
+
*
|
|
189
|
+
* @param sessionId - 会话 id。
|
|
190
|
+
* @param value - `{ paused?, stopped? }`。
|
|
191
|
+
*/
|
|
192
|
+
writeHeld(sessionId, value) {
|
|
193
|
+
writeHeldState(sessionId, value);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 只改某几个字段,其余保持磁盘上的原样。
|
|
198
|
+
*
|
|
199
|
+
* 暂停与停止分属两个模块,各自只知道自己那一半:谁都用整份覆盖写,
|
|
200
|
+
* 就会把对方刚落下的那一半抹掉。所以两份状态一律走这里读改写。
|
|
201
|
+
*
|
|
202
|
+
* @param sessionId - 会话 id。
|
|
203
|
+
* @param patch - `{ paused?, stopped? }`;没给的字段不动。
|
|
204
|
+
* @returns 写入后的完整状态。
|
|
205
|
+
*/
|
|
206
|
+
patchHeld(sessionId, patch) {
|
|
207
|
+
const current = readHeldState(sessionId);
|
|
208
|
+
writeHeldState(sessionId, {
|
|
209
|
+
paused: patch?.paused ?? current.paused,
|
|
210
|
+
stopped: patch?.stopped ?? current.stopped,
|
|
211
|
+
});
|
|
212
|
+
return readHeldState(sessionId);
|
|
213
|
+
},
|
|
139
214
|
};
|
|
140
215
|
}
|