dsh-tree-task-flow 0.2.0 → 0.3.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 +65 -24
- package/client.js +426 -463
- package/cordis.patch.yml +1 -1
- package/lib/auto-continue.js +3 -5
- package/lib/commands.js +20 -20
- package/lib/http.js +12 -5
- package/lib/index.js +14 -9
- package/lib/pause.js +138 -0
- package/lib/plan.js +5 -6
- package/lib/prompt.js +28 -30
- package/lib/tools.js +1 -62
- package/package.json +1 -1
package/cordis.patch.yml
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# enabled 总开关 默认 false(装上 ≠ 启用)
|
|
14
14
|
# autoContinue 是否允许自动续行 默认 false(开着会让人停不下来)
|
|
15
15
|
# maxAutoRounds 连续自动续行轮次上限 默认 5
|
|
16
|
-
# promptOrder
|
|
16
|
+
# promptOrder 工具说明段顺序 默认 1800
|
|
17
17
|
# rootDir 插件状态目录 默认 $DSH_HOME/dsh-task-tree
|
|
18
18
|
- insert:
|
|
19
19
|
- id: task-tree
|
package/lib/auto-continue.js
CHANGED
|
@@ -9,10 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 三条安全阀缺一不可(方案 §7.1):
|
|
11
11
|
*
|
|
12
|
-
* 1. **有活跃的内置 goal
|
|
13
|
-
*
|
|
14
|
-
* 那会让系统提示词走归一化分支;节点 0 一旦被判定需要重写,
|
|
15
|
-
* 整个上下文从第一个 token 起失去缓存。
|
|
12
|
+
* 1. **有活跃的内置 goal 时完全不插手**——内置 goal 由 `dsh-goal-round-driver`
|
|
13
|
+
* 自己推进轮次,插件再续一次就成了两套机制同时推同一个会话。
|
|
16
14
|
* 2. **队列里还有人在等的时候绝不抢**:`inbox.nextTurn` / `nextStep` 非空就退出。
|
|
17
15
|
* 3. **连续续行轮次有上限**,达到就停,避免把 token 烧在一个卡住的任务上。
|
|
18
16
|
*
|
|
@@ -64,7 +62,7 @@ export function registerAutoContinue(ctx, { store, cfg }) {
|
|
|
64
62
|
);
|
|
65
63
|
if (fromHuman) {
|
|
66
64
|
rounds.set(sessionId, 0);
|
|
67
|
-
//
|
|
65
|
+
// 人回来了,解除中断标记,允许新一轮的续行。
|
|
68
66
|
stopped.delete(sessionId);
|
|
69
67
|
}
|
|
70
68
|
return decision;
|
package/lib/commands.js
CHANGED
|
@@ -2,12 +2,9 @@
|
|
|
2
2
|
* `/task-tree` 斜杠命令:给人用的入口。
|
|
3
3
|
*
|
|
4
4
|
* 命令**不经过模型**——`handler` 直接执行、直接返回文本。所以它是查看和管理
|
|
5
|
-
* 任务树最直接的方式,也是"
|
|
5
|
+
* 任务树最直接的方式,也是"暂停自动执行"的那个开关:
|
|
6
6
|
* 任务自动执行必须是**受控**的,人得有一句话就能接管的手段。
|
|
7
7
|
*
|
|
8
|
-
* 命令同时是**检查点的旁路**:检查点闸门只拦模型(`tools/pre-execute`),
|
|
9
|
-
* 拦不住人。人在检查点里照样能提交结果、丢弃节点、或者把自动续行停掉。
|
|
10
|
-
*
|
|
11
8
|
* @module dsh-tree-task-flow/commands
|
|
12
9
|
*/
|
|
13
10
|
|
|
@@ -18,8 +15,8 @@ const USAGE = [
|
|
|
18
15
|
"/task-tree 查看本会话的任务树与检查点状态",
|
|
19
16
|
"/task-tree done <id> <结果> 提交某个节点的结果并完成它",
|
|
20
17
|
"/task-tree drop <id> 丢弃某个节点",
|
|
21
|
-
"/task-tree
|
|
22
|
-
"/task-tree resume
|
|
18
|
+
"/task-tree pause 暂停:当前工具跑完后停住,不再往下推进",
|
|
19
|
+
"/task-tree resume 继续:从停住的地方接着跑,不插消息",
|
|
23
20
|
"/task-tree reset 清掉本会话的计划树",
|
|
24
21
|
].join("\n");
|
|
25
22
|
|
|
@@ -29,7 +26,7 @@ const DISABLED_NOTE =
|
|
|
29
26
|
"也不会折叠任何上下文。要启用请在 profile 的 cordis.patch.yml 里把 enabled 设为 true。";
|
|
30
27
|
|
|
31
28
|
/**
|
|
32
|
-
* 把检查点那句话拼出来。措辞与 `plan.js`
|
|
29
|
+
* 把检查点那句话拼出来。措辞与 `plan.js` 的检查点提示一致。
|
|
33
30
|
*
|
|
34
31
|
* @param checkpoint - `checkpointOf(plan)` 的结果。
|
|
35
32
|
* @returns 两行文本。
|
|
@@ -38,8 +35,8 @@ function checkpointLines(checkpoint) {
|
|
|
38
35
|
const label = levelLabel(checkpoint.level);
|
|
39
36
|
return [
|
|
40
37
|
`检查点:${label}「${checkpoint.node.title}」(${checkpoint.node.id}) 的子节点已全部结束。`,
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
`此刻轮到你的判断:用 tree_task_plan 继续给 ${checkpoint.node.id} 拆子节点,` +
|
|
39
|
+
`或者用 tree_task_done(id="${checkpoint.node.id}", result="…") 提交这个${label}的结果并完成它。`,
|
|
43
40
|
];
|
|
44
41
|
}
|
|
45
42
|
|
|
@@ -47,10 +44,10 @@ function checkpointLines(checkpoint) {
|
|
|
47
44
|
* 注册命令。
|
|
48
45
|
*
|
|
49
46
|
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
50
|
-
* @param options - `{ store, cfg,
|
|
51
|
-
*
|
|
47
|
+
* @param options - `{ store, cfg, pause, enabled }`;`pause` 是 `registerPause`
|
|
48
|
+
* 返回的控制接口,未启用时是 null。
|
|
52
49
|
*/
|
|
53
|
-
export function registerCommands(ctx, { store, cfg,
|
|
50
|
+
export function registerCommands(ctx, { store, cfg, pause = null, enabled = false }) {
|
|
54
51
|
// 和 tools 一样,命令服务不是每个部署都有;用 get 探测而不是写进 inject。
|
|
55
52
|
const commands = ctx.get?.("commands");
|
|
56
53
|
if (commands === undefined || commands === null) {
|
|
@@ -60,8 +57,8 @@ export function registerCommands(ctx, { store, cfg, autoContinue, enabled = fals
|
|
|
60
57
|
|
|
61
58
|
commands.register({
|
|
62
59
|
name: "task-tree",
|
|
63
|
-
description: "查看与管理任务树:status / done <id> <结果> / drop <id> /
|
|
64
|
-
input: { hint: "status | done <id> <结果> | drop <id> |
|
|
60
|
+
description: "查看与管理任务树:status / done <id> <结果> / drop <id> / pause / resume / reset",
|
|
61
|
+
input: { hint: "status | done <id> <结果> | drop <id> | pause | resume | reset" },
|
|
65
62
|
handler: (invocation) => {
|
|
66
63
|
const sessionId = invocation?.agent?.session?.id;
|
|
67
64
|
if (sessionId === undefined) {
|
|
@@ -84,22 +81,25 @@ export function registerCommands(ctx, { store, cfg, autoContinue, enabled = fals
|
|
|
84
81
|
};
|
|
85
82
|
}
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
// `stop` 是旧写法,含义与 `pause` 完全一样,留着免得习惯改不过来。
|
|
85
|
+
if (verb === "pause" || verb === "stop") {
|
|
86
|
+
pause?.pause?.(sessionId);
|
|
89
87
|
return {
|
|
90
88
|
kind: "success",
|
|
91
|
-
text:
|
|
89
|
+
text:
|
|
90
|
+
"已暂停本会话:正在执行的那个工具会正常跑完,之后不再往下推进。" +
|
|
91
|
+
"任务树的进度没有变,点继续就从停住的地方接着跑。",
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
if (verb === "resume") {
|
|
96
|
-
|
|
96
|
+
pause?.resume?.(sessionId);
|
|
97
97
|
return {
|
|
98
98
|
kind: "success",
|
|
99
99
|
text:
|
|
100
100
|
cfg.autoContinue === true
|
|
101
|
-
? "
|
|
102
|
-
: "
|
|
101
|
+
? "已继续:停在岔口上的那一步已经放行,自动续行也重新允许了。"
|
|
102
|
+
: "已继续:停在岔口上的那一步已经放行。配置里 autoContinue 是 false,所以模型自己停下后不会再被自动拉起。",
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
package/lib/http.js
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* 全部挂在 `/dsh-tree-task-flow/` 前缀下;只读写插件**自己的**状态目录,
|
|
6
6
|
* 不碰会话日志(那是后端内部格式)。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* 条目要显示三样东西,都在这里算好给它:
|
|
9
9
|
*
|
|
10
10
|
* - **每层的结果**:节点完成时提交的文本。条目不自己去挖计划树的深层结构,
|
|
11
11
|
* 接口直接把三层各自的结果摆出来。
|
|
12
12
|
* - **检查点状态**:一个节点自己的子节点全部结束、而它自己还开着的那一刻。
|
|
13
13
|
* 此刻只有两条路——继续拆子节点,或者提交结果并完成它。
|
|
14
|
+
* - **暂停状态**:条目上的暂停/继续按钮写的就是这个。暂停挂的是
|
|
15
|
+
* `agent/pre-step`(见 `pause.js`),不插消息、不改计划,所以它不属于计划树,
|
|
16
|
+
* 单独回传。
|
|
14
17
|
*
|
|
15
18
|
* 条目上的按钮都写计划树,所以写完要把**新的**活动路径、结果与检查点一并回传,
|
|
16
19
|
* 免得条目还要再发一次请求才知道自己按出了什么效果。
|
|
@@ -98,9 +101,9 @@ function afterWrite(plan) {
|
|
|
98
101
|
* 注册全部面板接口。
|
|
99
102
|
*
|
|
100
103
|
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
101
|
-
* @param options - `{ store, autoContinue }
|
|
104
|
+
* @param options - `{ store, autoContinue, pause }`;未启用时后两者是 null。
|
|
102
105
|
*/
|
|
103
|
-
export function registerHttp(ctx, { store, autoContinue }) {
|
|
106
|
+
export function registerHttp(ctx, { store, autoContinue, pause = null }) {
|
|
104
107
|
ctx.inject(["webServer"], (host) => {
|
|
105
108
|
host.effect(() => {
|
|
106
109
|
// 一棵树的全部信息:结构 + 每层结果 + 检查点 + 当前路径 + 三层折叠游标。
|
|
@@ -126,6 +129,7 @@ export function registerHttp(ctx, { store, autoContinue }) {
|
|
|
126
129
|
step: cursors.step ?? null,
|
|
127
130
|
},
|
|
128
131
|
stopped: autoContinue?.isStopped?.(sessionId) === true,
|
|
132
|
+
paused: pause?.isPaused?.(sessionId) === true,
|
|
129
133
|
});
|
|
130
134
|
},
|
|
131
135
|
},
|
|
@@ -143,10 +147,13 @@ export function registerHttp(ctx, { store, autoContinue }) {
|
|
|
143
147
|
const sessionId = String(body.sessionId ?? "");
|
|
144
148
|
const action = String(body.action ?? "");
|
|
145
149
|
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
// 暂停 / 继续。暂停会同时压住自动续行兜底,两个状态一起回传,
|
|
151
|
+
// 免得条目还要再问一次。
|
|
152
|
+
if (action === "pause" || action === "resume") {
|
|
153
|
+
pause?.[action]?.(sessionId);
|
|
148
154
|
return sendJson(response, 200, {
|
|
149
155
|
ok: true,
|
|
156
|
+
paused: pause?.isPaused?.(sessionId) === true,
|
|
150
157
|
stopped: autoContinue?.isStopped?.(sessionId) === true,
|
|
151
158
|
});
|
|
152
159
|
}
|
package/lib/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* ```
|
|
7
7
|
* 一个节点的上下文 =
|
|
8
|
-
* ① 节点 0:会话级稳定前缀(身份 / 工具 /
|
|
8
|
+
* ① 节点 0:会话级稳定前缀(身份 / 工具 / 工具说明) ← 不随计划变化
|
|
9
9
|
* + ② 这一层已有的汇总消息 ← 同级逐条累积,不被卷走
|
|
10
10
|
* + ③ 当前节点的执行过程 ← 完成时整段折叠成一条汇总
|
|
11
11
|
* ```
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { registerAutoContinue } from "./auto-continue.js";
|
|
21
21
|
import { registerCommands } from "./commands.js";
|
|
22
22
|
import { registerHttp } from "./http.js";
|
|
23
|
+
import { registerPause } from "./pause.js";
|
|
23
24
|
import { DEFAULT_ORDER, registerPromptSection } from "./prompt.js";
|
|
24
25
|
import { registerRegion } from "./region.js";
|
|
25
26
|
import { createStore, defaultRoot } from "./store.js";
|
|
@@ -57,7 +58,7 @@ export const DEFAULT_CONFIG = {
|
|
|
57
58
|
autoContinue: false,
|
|
58
59
|
/** 连续自动续行的轮次上限,达到就停。 */
|
|
59
60
|
maxAutoRounds: 5,
|
|
60
|
-
/**
|
|
61
|
+
/** 工具说明段落的排序。 */
|
|
61
62
|
promptOrder: DEFAULT_ORDER,
|
|
62
63
|
/** 状态目录;null 表示 $DSH_HOME/dsh-task-tree。 */
|
|
63
64
|
rootDir: null,
|
|
@@ -77,21 +78,21 @@ export function apply(ctx, config) {
|
|
|
77
78
|
// 总开关默认**关**。未启用时一个字节都不碰会话:不注册工具、不加提示词段落、
|
|
78
79
|
// 不折叠上下文、不续行——只留一条命令,让人能确认"装了但没开"以及怎么开。
|
|
79
80
|
if (cfg.enabled !== true) {
|
|
80
|
-
registerCommands(ctx, { store, cfg,
|
|
81
|
+
registerCommands(ctx, { store, cfg, pause: null, enabled: false });
|
|
81
82
|
// 条目接口照挂:客户端条目与 enabled 无关,照样会来读。读不到计划它就返回
|
|
82
83
|
// null,一个像素都不占;而既有的计划文件仍然能看到。
|
|
83
84
|
// 只读插件自己的状态目录,不碰任何会话。
|
|
84
|
-
registerHttp(ctx, { store, autoContinue: null });
|
|
85
|
+
registerHttp(ctx, { store, autoContinue: null, pause: null });
|
|
85
86
|
ctx.logger?.warn?.(
|
|
86
87
|
"dsh-tree-task-flow: 已装载但**未启用**(enabled=false)。要启用请在 profile 的 cordis.patch.yml 里设为 true。",
|
|
87
88
|
);
|
|
88
89
|
return;
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
//
|
|
92
|
+
// 工具说明段。必须包在 effect 里,否则热更新后会重复注册而抛错。
|
|
92
93
|
ctx.effect(registerPromptSection(ctx, cfg.promptOrder), "dsh-tree-task-flow.section");
|
|
93
94
|
|
|
94
|
-
//
|
|
95
|
+
// 六个工具。tools 已在 inject 里声明,这里必定就绪。
|
|
95
96
|
registerTools(ctx, { store, cfg });
|
|
96
97
|
|
|
97
98
|
// 节点完成时把它的执行过程折叠成一条携带结果的汇总消息。
|
|
@@ -101,11 +102,15 @@ export function apply(ctx, config) {
|
|
|
101
102
|
// 让人随时能一句话停下来。
|
|
102
103
|
const autoContinue = registerAutoContinue(ctx, { store, cfg });
|
|
103
104
|
|
|
104
|
-
// /
|
|
105
|
-
|
|
105
|
+
// 暂停闸门:挂在 `agent/pre-step` 上,人点暂停就停在"上一个工具跑完、
|
|
106
|
+
// 下一次模型请求还没发出"那个岔口。它同时压住上面的自动续行兜底。
|
|
107
|
+
const pause = registerPause(ctx, { autoContinue });
|
|
108
|
+
|
|
109
|
+
// /task-tree 命令:查看与管理任务树,也是"暂停 / 继续"的开关。
|
|
110
|
+
registerCommands(ctx, { store, cfg, pause, enabled: true });
|
|
106
111
|
|
|
107
112
|
// 任务树条目要用的 HTTP 接口。
|
|
108
|
-
registerHttp(ctx, { store, autoContinue });
|
|
113
|
+
registerHttp(ctx, { store, autoContinue, pause });
|
|
109
114
|
|
|
110
115
|
ctx.logger?.info?.(`dsh-tree-task-flow: 已启用(状态目录 ${root})`);
|
|
111
116
|
}
|
package/lib/pause.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 暂停 / 继续。
|
|
3
|
+
*
|
|
4
|
+
* 人按下暂停时想要的是"**现在就停,别再往下跑**",而且**不要留下痕迹**:
|
|
5
|
+
* 不要插一条消息进对话,也不要凭空多出一轮模型请求。
|
|
6
|
+
*
|
|
7
|
+
* 能同时满足这两点的位置只有一个:**`agent/pre-step`**。
|
|
8
|
+
*
|
|
9
|
+
* 一个 step 走完、工具结果都已落盘之后,驱动循环会回到起点,向 `agent/pre-step`
|
|
10
|
+
* 询问"下一步进不进、带哪些消息进去"(`agent-loop` 的 `turn()` 里那次
|
|
11
|
+
* `await this.preStep(target, …)`)。此刻的状态恰好是:
|
|
12
|
+
*
|
|
13
|
+
* - 正在执行的工具**已经正常跑完**,结果也在会话里了——不打断它;
|
|
14
|
+
* - 模型**还没**发起下一次请求——拦在这里就真的什么都不会发生;
|
|
15
|
+
* - 消息已被 claim 但尚未提交,轮次也还开着——放行后模型从原处接着请求,
|
|
16
|
+
* 上下文里**零新增消息**。
|
|
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
|
+
* ★ **闸门只拦自动推进,不拦真人发言。** 暂停期间人自己发了消息,说明人要它跑,
|
|
27
|
+
* 这时既不放闸也不该继续端着:直接放行,并顺手解除暂停。
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-tree-task-flow/pause
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 注册暂停闸门。
|
|
34
|
+
*
|
|
35
|
+
* @param ctx - 挂载本行的 Cordis 上下文。
|
|
36
|
+
* @param options - `{ autoContinue }`:自动续行兜底的控制接口,可为 null。
|
|
37
|
+
* 暂停要同时压住它,否则模型刚停下、兜底就把会话又 followup 拉起来了。
|
|
38
|
+
*/
|
|
39
|
+
export function registerPause(ctx, { autoContinue = null } = {}) {
|
|
40
|
+
/** 处于暂停态的会话。 */
|
|
41
|
+
const paused = new Set();
|
|
42
|
+
/**
|
|
43
|
+
* 正挂在 pre-step 上的会话 → 唤醒它们的那些函数。
|
|
44
|
+
*
|
|
45
|
+
* 存成集合而不是单个函数:驱动循环虽然串行,一个会话同一时刻只会有一个
|
|
46
|
+
* pre-step 在等,但"继续"必须是幂等且彻底的——万一有第二个挂上来,
|
|
47
|
+
* 后一个把前一个的唤醒函数盖掉,先挂住的那次就永远醒不过来了。
|
|
48
|
+
*/
|
|
49
|
+
const waiters = new Map();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 挂起当前会话,直到有人点继续、或这一轮被取消。
|
|
53
|
+
*
|
|
54
|
+
* @param sessionId - 要挂起的会话。
|
|
55
|
+
* @param signal - 本轮的取消信号。人在界面上按停止时它会 abort,
|
|
56
|
+
* 必须跟着醒来——否则轮次会被永远挂住,连停都停不下来。
|
|
57
|
+
* @returns 可以放行时兑现的 promise(不传递任何值)。
|
|
58
|
+
*/
|
|
59
|
+
function waitForResume(sessionId, signal) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
let settled = false;
|
|
62
|
+
const release = () => {
|
|
63
|
+
if (settled) return;
|
|
64
|
+
settled = true;
|
|
65
|
+
const pending = waiters.get(sessionId);
|
|
66
|
+
if (pending !== undefined) {
|
|
67
|
+
pending.delete(release);
|
|
68
|
+
if (pending.size === 0) waiters.delete(sessionId);
|
|
69
|
+
}
|
|
70
|
+
signal?.removeEventListener?.("abort", release);
|
|
71
|
+
resolve();
|
|
72
|
+
};
|
|
73
|
+
const pending = waiters.get(sessionId) ?? new Set();
|
|
74
|
+
pending.add(release);
|
|
75
|
+
waiters.set(sessionId, pending);
|
|
76
|
+
if (signal?.aborted === true) {
|
|
77
|
+
release();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
signal?.addEventListener?.("abort", release, { once: true });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// waterfall:必须 await next() 并把它返回,否则会截断监听器链。
|
|
85
|
+
// auto-continue.js 也注册了同一个事件(重置续行配额),两条互不干扰。
|
|
86
|
+
ctx.on("agent/pre-step", async (payload, next) => {
|
|
87
|
+
const sessionId = payload?.agent?.session?.id;
|
|
88
|
+
if (sessionId === undefined || !paused.has(sessionId)) return next();
|
|
89
|
+
|
|
90
|
+
// 真人发言 = 人要它继续跑。放行,并且不再端着暂停态。
|
|
91
|
+
const fromHuman = (payload?.messages ?? []).some(
|
|
92
|
+
(message) => message?.source?.kind === "user",
|
|
93
|
+
);
|
|
94
|
+
if (fromHuman) {
|
|
95
|
+
paused.delete(sessionId);
|
|
96
|
+
return next();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await waitForResume(sessionId, payload?.signal);
|
|
100
|
+
return next();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
/**
|
|
105
|
+
* 暂停某个会话:之后的每一次自动推进都停在 pre-step。
|
|
106
|
+
* 正在执行的工具不受影响,它跑完才会走到这里。
|
|
107
|
+
*
|
|
108
|
+
* @param sessionId - 要暂停的会话。
|
|
109
|
+
*/
|
|
110
|
+
pause(sessionId) {
|
|
111
|
+
if (sessionId === undefined) return;
|
|
112
|
+
paused.add(sessionId);
|
|
113
|
+
autoContinue?.stop?.(sessionId);
|
|
114
|
+
},
|
|
115
|
+
/**
|
|
116
|
+
* 继续某个会话:放行挂在 pre-step 上的那一步。
|
|
117
|
+
* 不推送任何消息——模型只是从它原本要请求的地方接着请求。
|
|
118
|
+
*
|
|
119
|
+
* @param sessionId - 要放行的会话。
|
|
120
|
+
*/
|
|
121
|
+
resume(sessionId) {
|
|
122
|
+
if (sessionId === undefined) return;
|
|
123
|
+
paused.delete(sessionId);
|
|
124
|
+
const pending = waiters.get(sessionId);
|
|
125
|
+
if (pending !== undefined) for (const release of [...pending]) release();
|
|
126
|
+
autoContinue?.resume?.(sessionId);
|
|
127
|
+
},
|
|
128
|
+
/**
|
|
129
|
+
* 这个会话当前是不是暂停着。
|
|
130
|
+
*
|
|
131
|
+
* @param sessionId - 要查询的会话。
|
|
132
|
+
* @returns 暂停中为 true。
|
|
133
|
+
*/
|
|
134
|
+
isPaused(sessionId) {
|
|
135
|
+
return paused.has(sessionId);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/lib/plan.js
CHANGED
|
@@ -323,7 +323,7 @@ export function updateNode(plan, id, patch, now = Date.now()) {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
/**
|
|
326
|
-
*
|
|
326
|
+
* 层级的显示名。树渲染、检查点提示、命令回执都用它,保证几处称呼一致。
|
|
327
327
|
*
|
|
328
328
|
* @param level - `goal` / `task` / `step`。
|
|
329
329
|
* @returns 中文称呼。
|
|
@@ -334,12 +334,12 @@ export function levelLabel(level) {
|
|
|
334
334
|
return "子任务";
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
/**
|
|
337
|
+
/** 检查点那句话:把判断权交还给模型,不假装替它规定了出路。 */
|
|
338
338
|
function checkpointNote(checkpoint) {
|
|
339
339
|
const label = levelLabel(checkpoint.level);
|
|
340
340
|
return (
|
|
341
341
|
`${label}「${checkpoint.node.title}」的子节点已全部结束,转入检查点。` +
|
|
342
|
-
|
|
342
|
+
`此刻轮到你的判断:用 tree_task_plan 继续给 ${checkpoint.node.id} 拆子节点,` +
|
|
343
343
|
`或者用 tree_task_done(id="${checkpoint.node.id}", result="…") 提交这个${label}的结果并完成它。`
|
|
344
344
|
);
|
|
345
345
|
}
|
|
@@ -402,9 +402,8 @@ export function renderContinue(plan) {
|
|
|
402
402
|
const label = levelLabel(checkpoint.level);
|
|
403
403
|
return [
|
|
404
404
|
`检查点:${label}「${checkpoint.node.title}」(${checkpoint.node.id}) 的子节点已全部结束。`,
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
`不要再调用别的工具。`,
|
|
405
|
+
`此刻轮到你的判断:用 tree_task_plan 继续给 ${checkpoint.node.id} 拆子节点,` +
|
|
406
|
+
`或者用 tree_task_done(id="${checkpoint.node.id}", result="…") 提交这个${label}的结果并完成它。`,
|
|
408
407
|
].join("\n");
|
|
409
408
|
}
|
|
410
409
|
|
package/lib/prompt.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 系统提示词段落:**只有一段**,讲的是"什么时候用这组工具、完成时守什么规矩"。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 工具清单**不在这里**——每个工具的用法由它自己的 `description` 承担。同一句话
|
|
5
|
+
* 写在两处,只会让它在每一次请求里占两份位置,而模型真要调用时读的始终是
|
|
6
|
+
* `description`。计划当前的状态同样不在这里,由 `tree_task_status` 现取。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* (默认 in-history 路由);万一撞上归一化,还会重写节点 0,
|
|
10
|
-
* 让整个上下文从第一个 token 起失去缓存。所以这里一个字都不动。
|
|
8
|
+
* 写法与内置工具段落一致:`text` 是个函数,工具不在当前作用域里就输出空串。
|
|
11
9
|
*
|
|
12
10
|
* @module dsh-tree-task-flow/prompt
|
|
13
11
|
*/
|
|
@@ -15,34 +13,28 @@
|
|
|
15
13
|
/** 段落名。注册表要求全局唯一。 */
|
|
16
14
|
export const SECTION_NAME = "dsh-tree-task-flow";
|
|
17
15
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
16
|
+
/**
|
|
17
|
+
* 默认排序。
|
|
18
|
+
*
|
|
19
|
+
* 落在 TOOL_PTY(1700)与 TOOL_WEB_SEARCH(2000)之间的空档:与内置工具指引同区,
|
|
20
|
+
* 又不与任何已占用的号相撞。内置贡献方靠 `getSectionOrder` 取中央分配的位置,
|
|
21
|
+
* 外部插件没有这个待遇,只能自己挑一个不冲突的有限值。
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_ORDER = 1800;
|
|
20
24
|
|
|
21
25
|
/** 段落正文。 */
|
|
22
26
|
export const SECTION_TEXT = `## 树形任务流(tree_task_* 工具组)
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
一件事需要多步才能做完时,用这组工具把它组织成**目标 → 任务 → 子任务**三级树,
|
|
29
|
+
而不是把计划写在回复正文里,也不要再用 todo_write 另记一份。层级固定三级,
|
|
26
30
|
子任务是最低一级,不能再往下拆。
|
|
27
31
|
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
三条规则:
|
|
36
|
-
|
|
37
|
-
1. **完成必须写 result。** 一个节点完成时,插件会把它从开始到现在的执行过程
|
|
38
|
-
从上下文里折叠掉,换成一条只携带这段结果的汇总消息。所以 result 要写清它
|
|
39
|
-
究竟产出了什么,写成能独立看懂的样子——过程会被丢掉,别写在里面。
|
|
40
|
-
2. **子任务全部结束,不会让父节点自动完成**,而是转入**检查点**。那一刻只剩两条路:
|
|
41
|
-
用 tree_task_plan 继续给这个节点拆子节点,或者用 tree_task_done 提交它的结果。
|
|
42
|
-
检查点期间其它工具一律被拒绝——"这一层到底做完没有"要由你判断,不能被计数规则替你定死。
|
|
43
|
-
3. 折叠只发生在完成的那一刻:已完成节点的执行过程不再可见,只留下它提交的结果,
|
|
44
|
-
而同级节点各自的结果都会保留下来。需要看全貌——哪些做完了、各自产出了什么——
|
|
45
|
-
调用 tree_task_status,不要凭印象猜。`;
|
|
32
|
+
- **完成必须提交能独立看懂的 result。** 这个节点从开始到现在的执行过程会被整段
|
|
33
|
+
折叠掉,只留下这条结果;同级已完成的汇总不会被后来的折叠卷走。要看全貌就调
|
|
34
|
+
tree_task_status,不要凭印象猜。
|
|
35
|
+
- **子任务全部结束后,父节点不会自动完成。** 那一刻轮到你的判断:继续用
|
|
36
|
+
tree_task_plan 拆子节点,还是用 tree_task_done 提交结果收尾。插件不会替你决定
|
|
37
|
+
这一层做完没有。`;
|
|
46
38
|
|
|
47
39
|
/**
|
|
48
40
|
* 注册段落。调用方必须包在 `ctx.effect()` 里,否则热更新后会重复注册而抛错。
|
|
@@ -52,5 +44,11 @@ export const SECTION_TEXT = `## 树形任务流(tree_task_* 工具组)
|
|
|
52
44
|
* @returns 注册返回的 disposer(由 ctx.effect 持有)。
|
|
53
45
|
*/
|
|
54
46
|
export function registerPromptSection(ctx, order) {
|
|
55
|
-
return () =>
|
|
47
|
+
return () =>
|
|
48
|
+
ctx.systemPrompt.section({
|
|
49
|
+
name: SECTION_NAME,
|
|
50
|
+
order,
|
|
51
|
+
text: ({ scope }) =>
|
|
52
|
+
ctx.tools.get("tree_task_status", scope) === undefined ? "" : SECTION_TEXT,
|
|
53
|
+
});
|
|
56
54
|
}
|
package/lib/tools.js
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 六个 `tree_task_*` 工具。
|
|
3
3
|
*
|
|
4
|
-
* 工具本身**只做状态读写**——不注入消息、不动 surface
|
|
4
|
+
* 工具本身**只做状态读写**——不注入消息、不动 surface,也不拦任何别的工具。
|
|
5
5
|
* 「完成时把这个节点的执行过程从上下文里折起来、换成一条携带它结果的汇总」
|
|
6
6
|
* 那件事完全由 `agent/pre-step` 监听器负责(它比对游标与 `activePath` 就知道
|
|
7
7
|
* 哪一层刚结束)。
|
|
8
8
|
*
|
|
9
|
-
* 这边唯一的 hook 是**检查点闸门**:它也不碰会话、不碰 surface,只是在
|
|
10
|
-
* `tools/pre-execute` 上拦一道——检查点期间除了拆子节点和提交结果,别的工具一律
|
|
11
|
-
* 拒掉。判断本身在纯函数 `checkpointGate` 里,方便单测。
|
|
12
|
-
*
|
|
13
9
|
* 工具能决定的只有一件事:**这个节点算不算做完了**。而它做完了就必须交出
|
|
14
10
|
* `result`——插件正是拿这段文字去顶替它整段执行过程。没有结果的完成会让那段
|
|
15
11
|
* 上下文白白消失,所以 `result` 是必填的。
|
|
@@ -23,11 +19,9 @@
|
|
|
23
19
|
|
|
24
20
|
import {
|
|
25
21
|
addChildren,
|
|
26
|
-
checkpointOf,
|
|
27
22
|
complete,
|
|
28
23
|
createPlan,
|
|
29
24
|
dropNode,
|
|
30
|
-
levelLabel,
|
|
31
25
|
renderContinue,
|
|
32
26
|
renderTree,
|
|
33
27
|
updateNode,
|
|
@@ -55,43 +49,6 @@ function childLabel(level) {
|
|
|
55
49
|
return level === "goal" ? "任务" : "子任务";
|
|
56
50
|
}
|
|
57
51
|
|
|
58
|
-
/**
|
|
59
|
-
* 检查点期间放行的工具,就这两个。
|
|
60
|
-
*
|
|
61
|
-
* 检查点是"这一层只剩一个判断"的时刻:继续往下拆,或者交出结果收尾。
|
|
62
|
-
* 那一刻再多放行任何一个工具,模型都可以绕开这个判断继续埋头干活,
|
|
63
|
-
* 检查点也就成了摆设。所以这里是白名单,不是黑名单。
|
|
64
|
-
*/
|
|
65
|
-
export const CHECKPOINT_ALLOWED = Object.freeze(["tree_task_plan", "tree_task_done"]);
|
|
66
|
-
|
|
67
|
-
/** 在检查点里被挡下时,给模型看的话:局面是什么、只剩哪两条路。 */
|
|
68
|
-
function checkpointDenyReason(toolName, checkpoint) {
|
|
69
|
-
const label = levelLabel(checkpoint.level);
|
|
70
|
-
const id = checkpoint.node.id;
|
|
71
|
-
return [
|
|
72
|
-
`现在是检查点:${label}「${checkpoint.node.title}」(${id}) 的子节点已全部结束。`,
|
|
73
|
-
`此刻只放行两个工具:tree_task_plan 继续给 ${id} 拆子节点,` +
|
|
74
|
-
`或者 tree_task_done(id="${id}", result="…") 提交这个${label}的结果并完成它。`,
|
|
75
|
-
`工具 "${toolName}" 不在放行范围内,已被拒绝。`,
|
|
76
|
-
].join("\n");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* 检查点闸门的判定。
|
|
81
|
-
*
|
|
82
|
-
* **纯函数**——不读磁盘、不碰会话,只看出入参,所以可以直接单测。
|
|
83
|
-
* 注册处只负责把当前计划读出来、把 `checkpointOf` 的结果递进来。
|
|
84
|
-
*
|
|
85
|
-
* @param toolName - 模型这次要调的工具名。
|
|
86
|
-
* @param checkpoint - `checkpointOf(plan)` 的结果;`null` 表示不在检查点。
|
|
87
|
-
* @returns `{ kind: "allow" }`,或 `{ kind: "deny", reason }`。
|
|
88
|
-
*/
|
|
89
|
-
export function checkpointGate(toolName, checkpoint) {
|
|
90
|
-
if (checkpoint === null || checkpoint === undefined) return { kind: "allow" };
|
|
91
|
-
if (CHECKPOINT_ALLOWED.includes(toolName)) return { kind: "allow" };
|
|
92
|
-
return { kind: "deny", reason: checkpointDenyReason(toolName, checkpoint) };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
52
|
/**
|
|
96
53
|
* 注册全部工具。
|
|
97
54
|
*
|
|
@@ -101,24 +58,6 @@ export function checkpointGate(toolName, checkpoint) {
|
|
|
101
58
|
export function registerTools(ctx, { store, cfg }) {
|
|
102
59
|
const sessionOf = (exec) => exec?.agent?.session?.id ?? null;
|
|
103
60
|
|
|
104
|
-
/**
|
|
105
|
-
* 检查点的硬闸门。
|
|
106
|
-
*
|
|
107
|
-
* 先跑完下游,别人已经拒了就尊重别人的决定——那多半是审批或 hook;
|
|
108
|
-
* 只有一路放行到这里,才轮到用检查点收敛。
|
|
109
|
-
*
|
|
110
|
-
* 计划按会话取,所以闸门只对"正处于检查点的那个会话"生效,
|
|
111
|
-
* 别的会话照常使用全部工具。
|
|
112
|
-
*/
|
|
113
|
-
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
114
|
-
const decision = await next();
|
|
115
|
-
if (decision?.kind !== "allow") return decision;
|
|
116
|
-
|
|
117
|
-
const checkpoint = checkpointOf(store.readPlan(sessionOf(exec)));
|
|
118
|
-
const verdict = checkpointGate(exec?.name, checkpoint);
|
|
119
|
-
return verdict.kind === "allow" ? decision : verdict;
|
|
120
|
-
});
|
|
121
|
-
|
|
122
61
|
// ---------------------------------------------------------------- 计划树
|
|
123
62
|
|
|
124
63
|
ctx.tools.register({
|